[
  {
    "path": ".devcontainer/apache-vhost.conf",
    "content": "ServerName 127.0.0.1\n<VirtualHost *:8080>\n    # The ServerName directive sets the request scheme, hostname and port that\n    # the server uses to identify itself. This is used when creating\n    # redirection URLs. In the context of virtual hosts, the ServerName\n    # specifies what hostname must appear in the request's Host: header to\n    # match this virtual host. For the default virtual host (this file) this\n    # value is not decisive as it is used as a last resort host regardless.\n    # However, you must set it for any further virtual host explicitly.\n    #ServerName www.example.com\n\n    ServerAdmin webmaster@localhost\n    DocumentRoot /var/www/html/public\n\n    # Available loglevels: trace8, ..., trace1, debug, info, notice, warn,\n    # error, crit, alert, emerg.\n    # It is also possible to configure the loglevel for particular\n    # modules, e.g.\n    #LogLevel info ssl:warn\n\n    ErrorLog ${APACHE_LOG_DIR}/error.log\n    CustomLog ${APACHE_LOG_DIR}/access.log combined\n\n    # For most configuration files from conf-available/, which are\n    # enabled or disabled at a global level, it is possible to\n    # include a line for only one particular virtual host. For example the\n    # following line enables the CGI configuration for this host only\n    # after it has been globally disabled with \"a2disconf\".\n    #Include conf-available/serve-cgi-bin.conf\n\n    <Directory /var/www/html/public>\n        AllowOverride None\n        Require all granted\n        FallbackResource /index.php\n        DirectoryIndex index.php\n\n        # Disabling MultiViews prevents unwanted negotiation, e.g. \"/index\" should not resolve\n        # to the front controller \"/index.php\" but be rewritten to \"/index.php/index\".\n        <IfModule mod_negotiation.c>\n            Options -MultiViews\n        </IfModule>\n\n        <IfModule mod_rewrite.c>\n            RewriteEngine On\n\n            # This RewriteRule is used to dynamically discover the RewriteBase path.\n            # See https://httpd.apache.org/docs/current/mod/mod_rewrite.html#rewriterule\n            # Here we will compare the stripped per-dir path *relative to the filesystem\n            # path where the .htaccess file is read from* with the URI of the request.\n            #\n            # If a match is found, the prefix path is stored into an ENV var that is later\n            # used to properly prefix the URI of the front controller index.php.\n            # This is what makes it possible to host a Symfony application under a subpath,\n            # such as example.com/subpath\n\n            # The convoluted rewrite condition means:\n            #   1. Match all current URI in the RewriteRule and backreference it using $0\n            #   2. Strip the request uri the per-dir path and use ir as REQUEST_URI.\n            #      This is documented in https://bit.ly/3zDm3SI (\"What is matched?\")\n            #   3. Evaluate the RewriteCond, assuming your DocumentRoot is /var/www/html,\n            #      this .htaccess is in the /var/www/html/public dir and your request URI\n            #      is /public/hello/world:\n            #      * strip per-dir prefix: /var/www/html/public/hello/world -> hello/world\n            #      * applying pattern '.*' to uri 'hello/world'\n            #      * RewriteCond: input='/public/hello/world::hello/world' pattern='^(/.+)/(.*)::\\\\2$' => matched\n            #   4. Execute the RewriteRule:\n            #      * The %1 in the RewriteRule flag E=BASE:%1 refers to the first group captured in the RewriteCond ^(/.+)/(.*)\n            #      * setting env variable 'BASE' to '/public'\n            RewriteCond %{REQUEST_URI}::$0 ^(/.+)/(.*)::\\2$\n            RewriteRule .* - [E=BASE:%1]\n\n            # Sets the HTTP_AUTHORIZATION header removed by Apache\n            RewriteCond %{HTTP:Authorization} .+\n            RewriteRule ^ - [E=HTTP_AUTHORIZATION:%0]\n\n            # Removes the /index.php/ part from a URL, if present\n            RewriteCond %{ENV:REDIRECT_STATUS} ^$\n            RewriteRule ^index\\.php(?:/(.*)|$) %{ENV:BASE}/$1 [R=301,L]\n\n            # If the requested filename exists, simply serve it.\n            # Otherwise rewrite all other queries to the front controller.\n            RewriteCond %{REQUEST_FILENAME} !-f\n            RewriteRule ^ %{ENV:BASE}/index.php [L]\n        </IfModule>\n    </Directory>\n</VirtualHost>\n\n# vim: syntax=apache ts=4 sw=4 sts=4 sr noet\n"
  },
  {
    "path": ".devcontainer/devcontainer.json",
    "content": "// For format details, see https://aka.ms/devcontainer.json. For config options, see the\n// README at: https://github.com/devcontainers/templates/tree/main/src/php\n{\n    \"name\": \"PHP\",\n    // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile\n    \"image\": \"mcr.microsoft.com/devcontainers/php:3.0.3-8.4-trixie\",\n\n    // Features to add to the dev container. More info: https://containers.dev/features.\n    \"features\": {\n        \"ghcr.io/yassinedoghri/devcontainers/php-extensions-installer:1\": {\n            \"extensions\": \"amqp apcu bcmath exif gd intl opcache pcntl pdo_pgsql pgsql redis\"\n        },\n        \"ghcr.io/devcontainers/features/github-cli:1\": {},\n        \"ghcr.io/devcontainers/features/node:1\": {\n            \"version\": \"latest\"\n        },\n        \"ghcr.io/itsmechlark/features/postgresql:1\": {\n            \"version\": \"18\"\n        },\n        \"ghcr.io/itsmechlark/features/rabbitmq-server:1\": {},\n        \"ghcr.io/itsmechlark/features/redis-server:1\": {}\n    },\n\n    // Configure tool-specific properties.\n    \"customizations\": {\n        // Configure properties specific to VS Code.\n        \"vscode\": {\n            \"extensions\": [\n                \"christian-kohler.npm-intellisense\",\n                \"christian-kohler.path-intellisense\",\n                \"editorconfig.editorconfig\",\n                \"ikappas.composer\",\n                \"junstyle.php-cs-fixer\",\n                \"marcoroth.stimulus-lsp\",\n                \"mblode.twig-language\",\n                \"mikestead.dotenv\",\n                \"ms-azuretools.vscode-docker\",\n                \"neilbrayfield.php-docblocker\",\n                \"recca0120.vscode-phpunit\",\n                \"redhat.vscode-yaml\",\n                \"sanderronde.phpstan-vscode\"\n            ],\n            \"settings\": {\n                \"javascript.suggest.paths\": false,\n                \"typescript.suggest.paths\": false,\n                \"pgsql.connections\": [\n                    {\n                        \"server\": \"127.0.0.1\",\n                        \"database\": \"postgres\",\n                        \"user\": \"postgres\",\n                        \"password\": \"\"\n                    }\n                ]\n            }\n        }\n    },\n\n    // Use 'forwardPorts' to make a list of ports inside the container available locally.\n    \"forwardPorts\": [\n        8080\n    ],\n\n    // Use 'postCreateCommand' to run commands after the container is created.\n    \"postCreateCommand\": {\n        \"webdir\": \"sudo chmod a+x \\\"$(pwd)\\\" && sudo rm -rf /var/www/html && sudo ln -s \\\"$(pwd)\\\" /var/www/html\",\n        \"deps\": \"if [ -f composer.json ]; then composer install; fi\",\n        \"config\": \"cp .devcontainer/.env.devcontainer .env\",\n        \"apache\": \"sudo sed -i 's/Listen 80$//' /etc/apache2/ports.conf && sudo cp .devcontainer/apache-vhost.conf /etc/apache2/sites-enabled/000-default.conf && sudo a2enmod rewrite\",\n        \"phpconf\": \"sudo cp .devcontainer/php_config.ini /usr/local/etc/php/conf.d/custom.ini\",\n        \"symfony\": \"wget https://get.symfony.com/cli/installer -O - | bash && sudo mv ~/.symfony5/bin/symfony /usr/local/bin/symfony\"\n    }\n\n    // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.\n    // \"remoteUser\": \"root\"\n\n    // Uncomment if you are using Podman\n    //runArgs: [\n    //    \"--userns=keep-id\",\n    //    \"--security-opt=label=disable\"\n    //],\n    //\"updateRemoteUserUID\": true\n}\n"
  },
  {
    "path": ".devcontainer/php_config.ini",
    "content": "memory_limit = 1G\nmax_execution_time = 60\n"
  },
  {
    "path": ".dockerignore",
    "content": "**/*.log\n**/*.md\n**/*.php~\n**/*.dist.php\n**/*.dist\n**/*.cache\n**/._*\n**/.dockerignore\n**/.DS_Store\n**/.git/\n**/.gitattributes\n**/.gitignore\n**/.gitmodules\n**/compose.*.yaml\n**/compose.yaml\n**/Dockerfile\n**/Thumbs.db\n.github/\nstorage/\ndocs/\npublic/bundles/\ntests/\ntools/\nvar/\nvendor/\n.vs/\n.editorconfig\n.env.*.local\n.env.local\n.env.local.php\n.env.test\n.env\nDockerfile\n"
  },
  {
    "path": ".editorconfig",
    "content": "root = true\n\n[*]\ncharset = utf-8\nend_of_line = lf\nindent_size = 4\nindent_style = space\ninsert_final_newline = true\ntrim_trailing_whitespace = true\n\n[*.php]\nindent_style = space\nindent_size = 4\n\n[*.twig]\nindent_style = space\nindent_size = 4\n\n[*.js]\nindent_style = space\nindent_size = 4\n\n[*.{css,scss}]\nindent_style = space\nindent_size = 2\n\n[*.json]\nindent_style = space\nindent_size = 4\n\n[*.yaml]\nindent_style = space\nindent_size = 4\nquote_type = single\n\n[*.md]\ntrim_trailing_whitespace = false\n\n[{compose.yaml,compose.*.yaml}]\nindent_size = 2\n\n[translations/*.yaml]\nindent_style = space\nindent_size = 2\nquote_type = single\n\n[.github/workflows/*.yaml]\nindent_style = space\nindent_size = 2\nquote_type = single\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.md",
    "content": "---\nname: Bug report\nabout: Create a report to help us improve\ntitle: ''\nlabels: ['bug']\nassignees: ''\n\n---\n\n**Describe the bug**\nA clear and concise description of what the bug is.\n\n**On which Mbin instance did you find the bug?**\n[domain.tld]\n\n**Which Mbin version was running on the instance?**\n[e.g. 1.7.4]\n\n**To Reproduce**\nSteps to reproduce the behavior:\n1. Go to '...'\n2. Click on '....'\n3. Scroll down to '....'\n4. See error\n\n**Expected behavior**\nA clear and concise description of what you expected to happen.\n\n**Screenshots**\nIf applicable, add screenshots to help explain your problem.\n\n**Desktop (please complete the following information):**\n - OS: [e.g. iOS]\n - Browser: [e.g. chrome, safari]\n - Browser Version: [e.g. 123]\n\n**Smartphone (please complete the following information):**\n - Device: [e.g. iPhone6]\n - OS: [e.g. iOS8.1]\n - Browser: [e.g. stock browser, safari]\n - Browser Version: [e.g. 123]\n\n**Additional context**\nAdd any other context about the problem here.\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/feature_request.md",
    "content": "---\nname: Feature request\nabout: Suggest an idea for this project\ntitle: ''\nlabels: ['enhancement']\nassignees: ''\n\n---\n\n<!--- If you are considering creating a new feature request, also consider to become a contributor and create pull requests! \nWe are all volunteers, we need developers who implement all the features. --->\n\n**Is your feature request related to a problem? Please describe.**\nA clear and concise description of what the problem is. Ex. I'm always frustrated when [...]\n\n**Describe the solution you'd like**\nA clear and concise description of what you want to happen.\n\n**Describe alternatives you've considered**\nA clear and concise description of any alternative solutions or features you've considered.\n\n**Additional context**\nAdd any other context or screenshots about the feature request here.\n"
  },
  {
    "path": ".github/PULL_REQUEST_TEMPLATE/pull_request_template.md",
    "content": "<!-- This is a comment. You can remove it and other comments while filling out the template -->\n\n# Summary\n\n<!-- \nPlease provide a **short** summary in a few sentences of what the PR does.\nTry to keep it to as few full sentences as possible.\n-->\n\n## Checklist\n\n<!--\nPoints that don't don't apply can simply be checked off.\nFeel free to add **N/A** for clarity.\n\nExample:\n\n  - [x] Added tests (for code changes) **N/A**\n-->\n\n - [ ] Marked as draft PR while still working on PR\n - [ ] Marked as \"ready for review\" once not in progress\n - [ ] Added tests (for code changes)\n - [ ] Provided screenshots (for visual changes)\n\n\n# Additional information\n\n<!--\nIn this section you can describe more in depth:\n\n - **why** you made it\n - **how** it achieves its goals\n\n====================Screenshots=====================\nIf your PR is visual **provide screenshots**!\nIt makes it much easier for reviewers to evaluate your work\n\nCopy this table out of the comment:\n\n|     Before   |    After    |\n| ------------ | ----------- |\n| image_before | image_after |\n-->\n\n# Related issues\n\n<!-- \nIf your PR resolves and existing issue, please link to it in this section.\n\nExample:\n\n  Resolves #1234\n\nShould it not be related to any issue just with \"N/A\" or \"Not applicable\"\n-->\n"
  },
  {
    "path": ".github/dependabot.yml",
    "content": "# Inspired by: https://github.com/dependabot/dependabot-core/blob/main/.github/dependabot.yml\n# Please see the documentation for all configuration options:\n# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file\n\nversion: 2\nupdates:\n  - package-ecosystem: \"npm\"\n    directory: \"/\"\n    schedule:\n      interval: \"weekly\"\n      day: \"saturday\"\n      time: \"14:00\"\n    groups:\n      npm:\n        applies-to: security-updates\n        update-types:\n          - \"minor\"\n          - \"patch\"\n  - package-ecosystem: \"composer\"\n    directory: \"/\"\n    schedule:\n      interval: \"weekly\"\n      day: \"saturday\"\n      time: \"14:00\"\n    groups:\n      php:\n        applies-to: security-updates\n        update-types:\n          - \"minor\"\n          - \"patch\"\n  - package-ecosystem: \"devcontainers\"\n    directory: \"/\"\n    schedule:\n      interval: \"weekly\"\n      day: \"saturday\"\n      time: \"14:00\"\n    groups:\n      devcontainers:\n        applies-to: security-updates\n        update-types:\n          - \"minor\"\n          - \"patch\"\n"
  },
  {
    "path": ".github/workflows/action.yaml",
    "content": "name: Mbin Workflow\non:\n  pull_request:\n    branches:\n      - main\n      - develop\n      - dev/new_features\n  push:\n    branches:\n      - main\n      - dev/new_features\n    tags:\n      - 'v*'\n\nconcurrency:\n  group: ${{ github.workflow }}-${{ github.ref }}\n  cancel-in-progress: ${{ startsWith(github.ref, 'refs/pull/') }}\n\njobs:\n  build:\n    runs-on: ubuntu-latest\n    container:\n      image: ghcr.io/mbinorg/mbin-pipeline-image:latest\n    steps:\n      - uses: actions/checkout@v4\n\n      - name: Get NPM cache directory path\n        id: npm-cache-dir-path\n        run: echo \"dir=$(npm get cache)\" >> $GITHUB_OUTPUT\n\n      - name: Add GITHUB_WORKSPACE as a safe directory\n        run: git config --global --add safe.directory $GITHUB_WORKSPACE\n\n      - name: Get Composer Cache Directory\n        id: composer-cache\n        run: echo \"dir=$(composer config cache-files-dir)\" >> $GITHUB_OUTPUT\n\n      - uses: actions/cache@v4\n        id: npm-cache\n        with:\n          path: ${{ steps.npm-cache-dir-path.outputs.dir }}\n          key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}\n\n      - uses: actions/cache@v4\n        with:\n          path: ${{ steps.composer-cache.outputs.dir }}\n          key: ${{ runner.os }}-composer-no-dev-${{ hashFiles('**/composer.lock') }}\n\n      - run: cp .env.example .env\n      - name: Composer install\n        run: >\n          ./ci/skipOnExcluded.sh\n          composer install --no-dev --no-progress\n\n      - name: Test API dump\n        run: >\n          ./ci/skipOnExcluded.sh\n          php bin/console nelmio:apidoc:dump\n\n      - name: NPM install\n        run: >\n          ./ci/skipOnExcluded.sh\n          npm ci --include=dev\n        env:\n          NODE_ENV: production\n\n      - name: Build frontend (production)\n        run: >\n          ./ci/skipOnExcluded.sh\n          npm run build\n\n  automated-tests:\n    runs-on: ubuntu-latest\n    container:\n      image: ghcr.io/mbinorg/mbin-pipeline-image:latest\n    steps:\n      - uses: actions/checkout@v4\n\n      - name: Add GITHUB_WORKSPACE as a safe directory\n        run: git config --global --add safe.directory $GITHUB_WORKSPACE\n\n      - name: Get Composer Cache Directory\n        id: composer-cache\n        run: echo \"dir=$(composer config cache-files-dir)\" >> $GITHUB_OUTPUT\n\n      - name: Get NPM cache directory path\n        id: npm-cache-dir-path\n        run: echo \"dir=$(npm get cache)\" >> $GITHUB_OUTPUT\n\n      - uses: actions/cache@v4\n        with:\n          path: ${{ steps.composer-cache.outputs.dir }}\n          key: ${{ runner.os }}-composer-${{ hashFiles('*/composer.lock') }}\n          restore-keys: ${{ runner.os }}-composer-\n\n      - uses: actions/cache@v4\n        id: npm-cache\n        with:\n          path: ${{ steps.npm-cache-dir-path.outputs.dir }}\n          key: ${{ runner.os }}-npm-${{ hashFiles('*/package-lock.json') }}\n          restore-keys: ${{ runner.os }}-npm-\n\n      - name: Composer install\n        run: >\n          ./ci/skipOnExcluded.sh\n          composer install --no-scripts --no-progress\n\n      - run: cp .env.example .env\n\n      - name: NPM install\n        run: >\n          ./ci/skipOnExcluded.sh\n          npm ci --include=dev\n        env:\n          NODE_ENV: production\n\n      - name: Build frontend (production)\n        run: >\n          ./ci/skipOnExcluded.sh\n          npm run build\n\n      - name: Run unit tests\n        env:\n          COMPOSER_CACHE_DIR: ${{ steps.composer-cache.outputs.dir }}\n          SYMFONY_DEPRECATIONS_HELPER: disabled\n          DATABASE_HOST: postgres\n          DATABASE_PORT: 5432\n          REDIS_HOST: valkey\n          REDIS_PORT: 6379\n          CREATE_SNAPSHOTS: false\n        run: >\n          ./ci/skipOnExcluded.sh\n          php vendor/bin/paratest tests/Unit\n\n      - name: Run non thread safe integration tests\n        env:\n          COMPOSER_CACHE_DIR: ${{ steps.composer-cache.outputs.dir }}\n          SYMFONY_DEPRECATIONS_HELPER: disabled\n          DATABASE_HOST: postgres\n          DATABASE_PORT: 5432\n          REDIS_HOST: valkey\n          REDIS_PORT: 6379\n        run: >\n          ./ci/skipOnExcluded.sh\n          php vendor/bin/phpunit tests/Functional\n          --group NonThreadSafe\n\n      - name: Run thread safe integration tests\n        env:\n          COMPOSER_CACHE_DIR: ${{ steps.composer-cache.outputs.dir }}\n          SYMFONY_DEPRECATIONS_HELPER: disabled\n          DATABASE_HOST: postgres\n          DATABASE_PORT: 5432\n          REDIS_HOST: valkey\n          REDIS_PORT: 6379\n        run: >\n          ./ci/skipOnExcluded.sh\n          php vendor/bin/paratest tests/Functional\n          --exclude-group NonThreadSafe\n    services:\n      postgres:\n        # Docker Hub image\n        image: postgres:16\n        # Provide the password for postgres\n        env:\n          POSTGRES_DB: mbin_test\n          POSTGRES_USER: mbin\n          POSTGRES_PASSWORD: ChangeThisPostgresPass\n        # Set health checks to wait until postgres has started\n        options: >-\n          --health-cmd pg_isready\n          --health-interval 10s\n          --health-timeout 5s\n          --health-retries 5\n      valkey:\n        # Docker Hub image\n        image: valkey/valkey\n        # Set health checks to wait until redis has started\n        options: >-\n          --health-cmd \"redis-cli ping\"\n          --health-interval 10s\n          --health-timeout 5s\n          --health-retries 5\n\n  audit-check:\n    runs-on: ubuntu-latest\n    container:\n      image: ghcr.io/mbinorg/mbin-pipeline-image:latest\n    continue-on-error: true\n    steps:\n      - uses: actions/checkout@v4\n\n      - name: Add GITHUB_WORKSPACE as a safe directory\n        run: git config --global --add safe.directory $GITHUB_WORKSPACE\n\n      - name: Get Composer Cache Directory\n        id: composer-cache\n        run: echo \"dir=$(composer config cache-files-dir)\" >> $GITHUB_OUTPUT\n\n      - name: Cache vendor directory\n        uses: actions/cache@v4\n        with:\n          path: ${{ steps.composer-cache.outputs.dir }}\n          key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}\n          restore-keys: ${{ runner.os }}-composer-\n\n      - run: cp .env.example .env\n      - name: Composer install\n        run: composer install --no-scripts --no-progress\n\n      - name: Run Npm audit\n        run: npm audit --omit=dev\n\n      - name: Run Composer audit\n        run: composer audit --no-dev --abandoned=ignore\n\n  fixer-dry-run:\n    runs-on: ubuntu-latest\n    container:\n      image: ghcr.io/mbinorg/mbin-pipeline-image:latest\n    steps:\n      - uses: actions/checkout@v4\n\n      - name: Add GITHUB_WORKSPACE as a safe directory\n        run: git config --global --add safe.directory $GITHUB_WORKSPACE\n\n      - name: Get Composer Cache Directory\n        id: composer-cache\n        run: echo \"dir=$(composer config cache-files-dir)\" >> $GITHUB_OUTPUT\n\n      - uses: actions/cache@v4\n        with:\n          path: ${{ steps.composer-cache.outputs.dir }}\n          key: ${{ runner.os }}-composer-tools-${{ hashFiles('**/composer.lock') }}\n          restore-keys: ${{ runner.os }}-composer-tools-\n\n      - name: Composer tools install\n        run: composer -d tools install --no-scripts --no-progress\n\n      - name: PHP CS Fixer dry-run with diff\n        run: >\n          tools/vendor/bin/php-cs-fixer fix\n          --dry-run --diff --show-progress=none\n\n      - name: PHP CS Fixer to PR Annotations\n        run: >\n          tools/vendor/bin/php-cs-fixer fix\n          --dry-run --format=checkstyle --show-progress=none | cs2pr\n\n  twig-lint:\n    runs-on: ubuntu-latest\n    container:\n      image: ghcr.io/mbinorg/mbin-pipeline-image:latest\n    steps:\n      - uses: actions/checkout@v4\n\n      - name: Add GITHUB_WORKSPACE as a safe directory\n        run: git config --global --add safe.directory $GITHUB_WORKSPACE\n\n      - name: Get Composer Cache Directory\n        id: composer-cache\n        run: echo \"dir=$(composer config cache-files-dir)\" >> $GITHUB_OUTPUT\n\n      - uses: actions/cache@v4\n        with:\n          path: ${{ steps.composer-cache.outputs.dir }}\n          key: ${{ runner.os }}-composer-tools-${{ hashFiles('**/composer.lock') }}\n          restore-keys: ${{ runner.os }}-composer-tools-\n\n      - run: cp .env.example .env\n\n      - name: Composer tools install\n        run: composer install --no-scripts --no-progress\n\n      - name: Twig linter\n        run: php bin/console lint:twig templates/\n\n  frontend-lint:\n    runs-on: ubuntu-latest\n    container:\n      image: ghcr.io/mbinorg/mbin-pipeline-image:latest\n    steps:\n      - uses: actions/checkout@v4\n\n      - name: Add GITHUB_WORKSPACE as a safe directory\n        run: git config --global --add safe.directory $GITHUB_WORKSPACE\n\n      - name: Get Composer Cache Directory\n        id: composer-cache\n        run: echo \"dir=$(composer config cache-files-dir)\" >> $GITHUB_OUTPUT\n\n      - name: Get NPM cache directory path\n        id: npm-cache-dir-path\n        run: echo \"dir=$(npm get cache)\" >> $GITHUB_OUTPUT\n\n      - uses: actions/cache@v4\n        with:\n          path: ${{ steps.composer-cache.outputs.dir }}\n          key: ${{ runner.os }}-composer-${{ hashFiles('*/composer.lock') }}\n          restore-keys: ${{ runner.os }}-composer-\n\n      - uses: actions/cache@v4\n        id: npm-cache\n        with:\n          path: ${{ steps.npm-cache-dir-path.outputs.dir }}\n          key: ${{ runner.os }}-npm-${{ hashFiles('*/package-lock.json') }}\n          restore-keys: ${{ runner.os }}-npm-\n\n      - name: Composer install\n        run: composer install --no-scripts --no-progress\n\n      - run: cp .env.example .env\n\n      - name: NPM install\n        run: npm ci\n\n      - name: eslint\n        run: npm run lint\n\n  build-and-publish-docker-image:\n    runs-on: ubuntu-latest\n    # Let's only run this on branches and tagged releases only\n    # Because the Docker build takes quite some time.\n    if: github.event_name != 'pull_request'\n    permissions:\n      contents: write\n      packages: write\n    steps:\n      - uses: actions/checkout@v4\n\n      - name: Login to ghcr\n        if: github.event_name != 'pull_request'\n        uses: docker/login-action@v3\n        with:\n          registry: ghcr.io\n          username: ${{ github.repository_owner }}\n          password: ${{ secrets.GITHUB_TOKEN }}\n\n      - name: Docker meta data\n        id: meta\n        uses: docker/metadata-action@v5\n        with:\n          images: ghcr.io/mbinorg/mbin\n\n      - name: Build and push Docker image\n        uses: docker/build-push-action@v5\n        with:\n          context: .\n          file: ./docker/Dockerfile\n          push: ${{ github.event_name != 'pull_request' }}\n          tags: ${{ steps.meta.outputs.tags }}\n          labels: ${{ steps.meta.outputs.labels }}\n\n      # We will also use this job to dispatch an event to the mbin-docs repository to trigger documentation build and publish\n      - name: Trigger mbin-docs workflow dispatch\n        if: github.event_name != 'pull_request'\n        uses: peter-evans/repository-dispatch@v4\n        with:\n          token: ${{ secrets.MBIN_ACCESS_TOKEN }}\n          repository: MbinOrg/mbin-docs\n          event-type: update-docs\n"
  },
  {
    "path": ".github/workflows/build-and-publish-pipeline-image.yaml",
    "content": "name: Build and publish Mbin GitHub pipeline image\n\n# Trigger either manually or when ci/Dockerfile changes (on the main branch)\non:\n  push:\n    branches: ['main']\n    paths:\n      - 'ci/Dockerfile'\n  workflow_dispatch:\n\njobs:\n  build-and-publish-docker-image:\n    runs-on: ubuntu-latest\n    permissions:\n      contents: write\n      packages: write\n\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v4\n        with:\n          ref: main\n\n      - name: Login to ghcr\n        uses: docker/login-action@v3\n        with:\n          registry: ghcr.io\n          username: ${{ github.repository_owner }}\n          password: ${{ secrets.GITHUB_TOKEN }}\n\n      - name: Build image\n        working-directory: ./ci\n        run: |\n          docker build -t ghcr.io/mbinorg/mbin-pipeline-image:latest .\n\n      - name: Publish\n        run: |\n          docker push ghcr.io/mbinorg/mbin-pipeline-image:latest\n"
  },
  {
    "path": ".github/workflows/build-pipeline-image.yaml",
    "content": "name: Build Mbin GitHub pipeline image\n\n# Only trigger on Pull requests when ci/Dockerfile is changed (do not push the image)\non:\n  pull_request:\n    branches:\n      - main\n    paths:\n      - 'ci/Dockerfile'\n\njobs:\n  build-docker-image:\n    runs-on: ubuntu-latest\n\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v4\n\n      - name: Build test image\n        working-directory: ./ci\n        run: |\n          docker build .\n"
  },
  {
    "path": ".github/workflows/contrib.yaml",
    "content": "name: Contributor Workflow\non:\n  push:\n    branches:\n      - main\n\njobs:\n  contrib-readme:\n    runs-on: ubuntu-latest\n    name: Update contrib in README\n    permissions:\n          contents: write\n          pull-requests: write\n    steps:\n      - name: Contribute List\n        uses: akhilmhdh/contributors-readme-action@v2.3.11\n        env:\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n"
  },
  {
    "path": ".github/workflows/psalm.yml",
    "content": "name: Psalm Security Scan\n\non:\n  push:\n    branches: [\"main\"]\n  pull_request:\n    # The branches below must be a subset of the branches above\n    branches: [\"main\"]\n  schedule:\n    - cron: \"25 9 * * 0\"\n\npermissions:\n  contents: read\n\nconcurrency:\n  group: ${{ github.workflow }}-${{ github.ref }}\n  cancel-in-progress: ${{ startsWith(github.ref, 'refs/pull/') }}\n\njobs:\n  php-security-scan:\n    runs-on: ubuntu-latest\n    permissions:\n      contents: read\n      security-events: write # for github/codeql-action/upload-sarif to upload SARIF results\n      actions: read # only required for a private repository by github/codeql-action/upload-sarif to get the Action run status\n\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v4\n\n      - name: Psalm Security Scan by Mbin\n        uses: docker://ghcr.io/mbinorg/psalm-security-scan\n\n      - name: Import Security Analysis results into GitHub Security Code Scanning\n        uses: github/codeql-action/upload-sarif@v2\n        with:\n          sarif_file: results.sarif\n"
  },
  {
    "path": ".github/workflows/stale.yml",
    "content": "name: \"Close stale issues and PRs\"\non:\n  schedule:\n    - cron: \"46 1 * * *\"\n\njobs:\n  stale:\n    permissions:\n      issues: write\n      pull-requests: write\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/stale@v9\n        with:\n          stale-issue-message: \"This issue is stale because it has been open a year with no activity.\"\n          stale-pr-message: \"This PR is stale because it has been open 40 days with no activity.\"\n          close-issue-message: \"This issue was closed because it has been stalled for 6 days with no activity.\"\n          exempt-issue-labels: \"high priority\"\n          days-before-issue-stale: 365\n          days-before-pr-stale: 40\n          days-before-issue-close: -1\n          days-before-pr-close: -1\n"
  },
  {
    "path": ".gitignore",
    "content": "# IDEA/PhpStorm\n*.iml\n.idea/\n.DS_Store\nsupervisord.log\nsupervisord.pid\nreports/\n.php-cs-fixer.cache\ntools/vendor/\n\n# VSCode\n.vscode/\n.vs/\n*.session.sql\n\n# Keys\n*.pem\n\n# Mbin specific\n.env\n/public/media/*\n/public/media\nyarn.lock\n/metal/\n/tests/assets/copy\n\n# autogenerated files\n/public/.rnd\n/config/reference.php\n\n# Docker specific\n/storage/\n/compose.override.yaml\n\n###> symfony/framework-bundle ###\n/.env.local\n/.env.local.php\n/.env.*.local\n/config/secrets/prod/prod.decrypt.private.php\n/public/bundles/\n/public/cache/\n/var/\n/vendor/\n/cache/\n###< symfony/framework-bundle ###\n\n###> symfony/phpunit-bridge ###\n.phpunit\n.phpunit.result.cache\n/phpunit.xml\n.phpunit.cache/\n###< symfony/phpunit-bridge ###\n\n###> symfony/webpack-encore-bundle ###\n/node_modules/\n/public/build/\nnpm-debug.log\nyarn-error.log\n###< symfony/webpack-encore-bundle ###\n\n###> liip/imagine-bundle ###\n/public/media/cache/\n###< liip/imagine-bundle ###\n\n###> league/oauth2-server-bundle ###\n/config/jwt/*.pem\n###< league/oauth2-server-bundle ###\n\n###> phpunit/phpunit ###\n/phpunit.xml\n.phpunit.result.cache\nclover.xml\n/coverage\n/.phpunit.cache/\n###< phpunit/phpunit ###\n\n###> phpstan/phpstan ###\nphpstan.neon\n###< phpstan/phpstan ###\n"
  },
  {
    "path": ".php-cs-fixer.dist.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\n$finder = (new PhpCsFixer\\Finder())\n    ->in(__DIR__)\n    ->exclude([\n        'var',\n        'node_modules',\n        'vendor',\n        'docker',\n    ])\n;\n\nreturn (new PhpCsFixer\\Config())\n    ->setParallelConfig(PhpCsFixer\\Runner\\Parallel\\ParallelConfigFactory::detect())\n    ->setRules([\n        '@Symfony' => true,\n\n        # defined as \"risky\" as they could break code. Since our codebase is passing that's fine\n        'declare_strict_types' => true,\n        'strict_comparison' => true,\n        'native_function_invocation' => true,\n        'phpdoc_to_comment' => [\n            'ignored_tags' => ['var']\n        ]\n    ])\n    ->setRiskyAllowed(true)\n    ->setFinder($finder)\n    ;\n"
  },
  {
    "path": "C4.md",
    "content": "# Collective Code Construction Contract (C4) - Mbin\n\n- Status: final\n- Editor: Melroy van den Berg (melroy at melroy dot org)\n\nThe Collective Code Construction Contract (C4) is an evolution of the github.com Fork + Pull Model, aimed at providing an optimal collaboration model for free software projects. This is _our_ Mbin revision of the upstream C4 specification, built on the lessons learned from the experience of many other projects and the original C4 specification itself.\n\n## License\n\nCopyright (c) 2009-2016 Pieter Hintjens. Copyright (c) 2016-2018 The ZeroMQ developers.\nCopyright (c) 2023-2024 Melroy van den Berg & Mbin developers.\n\nThis Specification is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 3 of the License, or (at your option) any later version.\n\nThis Specification is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.\n\nYou should have received a copy of the GNU General Public License along with this program; if not, see [http://www.gnu.org/licenses](http://www.gnu.org/licenses).\n\n## Abstract\n\nC4 provides a standard process for contributing, evaluating and discussing improvements on software projects. It defines specific technical requirements for projects like a style guide, unit tests, `git` and similar platforms. It also establishes different personas for projects, with clear and distinct duties. C4 specifies a process for documenting and discussing issues including seeking consensus and clear descriptions, use of “pull requests” and systematic reviews.\n\n## Language\n\nThe key words “MUST”, “MUST NOT”, “REQUIRED”, “SHALL”, “SHALL NOT”, “SHOULD”, “SHOULD NOT”, “RECOMMENDED”, “MAY”, and “OPTIONAL” in this document are to be interpreted as described in [RFC 2119](http://tools.ietf.org/html/rfc2119).\n\n## 1. Goals\n\nC4 is meant to provide a reusable optimal collaboration model for open source software projects. It has these specific goals:\n\n1.  To maximize the scale and diversity of the community around a project, by reducing the friction for new Contributors and creating a scaled participation model with strong positive feedbacks;\n2.  To relieve dependencies on key individuals by separating different skill sets so that there is a larger pool of competence in any required domain;\n3.  To allow the project to develop faster and more accurately, by increasing the diversity of the decision making process;\n4.  To support the natural life cycle of project versions from experimental through to stable, by allowing safe experimentation, rapid failure, and isolation of stable code;\n5.  To reduce the internal complexity of project repositories, thus making it easier for Contributors to participate and reducing the scope for error;\n6.  To enforce collective ownership of the project, which increases economic incentive to Contributors and reduces the risk of hijack by hostile entities.\n\n## 2. Design\n\n### 2.1. Preliminaries\n\n1.  The project SHALL use the git distributed revision control system.\n2.  The project SHALL be hosted on github.com or equivalent, herein called the “Platform”.\n3.  The project SHALL use the Platform issue tracker.\n4.  The project SHOULD have clearly documented guidelines for code style.\n5.  A code change is refer to as a “patch” or “PR” on the Platform.\n6.  A “Contributor” is a person who wishes to provide a patch/PR, being a set of commits that solve some clearly identified problem.\n7.  A “Maintainer” is a person who merges patches/PRs to the project. Maintainers can also be developers / contributors at the same time.\n8.  Maintainers are owners of the project. There is no single “founder” or “creator” of the project.\n9.  Contributors SHALL NOT have commit access to the repository unless they are also Maintainers.\n10. Maintainers SHALL have commit access to the repository.\n11. Administrators SHALL have administration rights on the Platform.\n12. Everyone, without distinction or discrimination, SHALL have an equal right to become a Contributor under the terms of this contract.\n\n### 2.2. Licensing and Ownership\n\n1. The project SHALL use the share-alike license: [AGPL](https://github.com/MbinOrg/mbin/blob/main/LICENSE).\n2. All contributions (patches/PRs) to the project source code SHALL use the same license as the project.\n3. All patches / PRs are owned by their authors. There SHALL NOT be any copyright assignment process.\n\n### 2.3. Patch / PR Requirements\n\n1.  A patch / PR SHOULD be a minimal and accurate answer to exactly one identified and agreed problem.\n2.  A patch / PR MUST adhere to the code style guidelines of the project if these are defined.\n3.  A patch / PR MUST adhere to the “Evolution of Public Contracts” guidelines defined below.\n4.  A patch / PR SHALL NOT include non-trivial code from other projects unless the Contributor is the original author of that code.\n5.  A patch / PR SHALL NOT include libraries that are incompliant with the project license.\n6.  A patch / PR MUST compile cleanly and pass project self-tests (for example unit tests or linting) before a Maintainer can merge it. Also known as the “All-green policy”.\n7.  A commit message MUST consist of a single short (less than 100 characters) line stating the problem and/or solution that is being solved.\n8.  A commit message MAY be prefixed with a addenum “FIX:”, “FEAT:”, “DOCS:”, “TEST:”, “REFACTOR:” or \"IMPROVEMENT:\" to indicate the type of commit. Also known as “semantic commit messages”.\n9.  A commit type MAY be part of the PR title as well however using Labels on the Platform PR is usually preferred way of classifying the type of the Patch / PR.\n10. A “Correct Patch / PR” is one that satisfies the above requirements.\n\n### 2.4. Development Process\n\n1. Change on the project SHALL be governed by the pattern of accurately identifying problems and applying minimal, accurate solutions to these problems.\n2. To request changes, a user SHOULD log an issue on the project Platform issue tracker.\n3. The user or Contributor SHOULD write the issue by describing the problem they face or observe.\n4. The user or Contributor SHOULD seek consensus on the accuracy of their observation, and the value of solving the problem.\n5. Users SHALL NOT log feature requests, ideas, suggestions, or any solutions to problems that are not explicitly documented and provable.\n6. Thus, the release history of the project SHALL be a list of meaningful issues logged and solved.\n7. To work on an issue, a Contributor SHOULD fork the project repository and then work on their forked repository. Unless the Contributor is also a Maintainer then a fork is NOT required, creating a new git branch SHOULD be sufficient.\n8. To submit a patch, a Contributor SHALL create a Platform pull request back to the project.\n9. Maintainers or Contributors SHOULD NOT directly push changes to the default branch (main), instead they SHOULD use the Platform Pull requests functionality. (See also branch protection rules of the Platform)\n10. Contributors or Maintainers SHALL mark their PRs as “Draft” on the Platform, whenever the patch/PR is not yet ready for review / not finished.\n11. If the Platform implements pull requests as issues, a Contributor MAY directly send a pull request without logging a separate issue.\n12. To discuss a patch (PR), people SHOULD comment on the Platform pull request, on the commit, or on [Matrix Space (chat)](https://matrix.to/#/#mbin:melroy.org). We have various Matrix Rooms (also a dedicated [Matrix room for Pull Requests/Reviews](https://matrix.to/#/#mbin-pr:melroy.org)).\n13. Contributors MAY want to discuss very large / complex changes (PRs) in the [Matrix Space](https://matrix.to/#/#mbin:melroy.org) first, since the effort might be all for nothing if the patch is rejected by the Maintainers in advance.\n14. To request changes, accept or reject a patch / PR, a Maintainer SHALL use the Platform interface.\n15. Maintainers SHOULD NOT merge patches (PRs), even their own, unless there is at least one (1) other Maintainer approval.\n    Or in exceptional cases, such as non-responsiveness from other Maintainers for an extended period (more than 3-4 days), and the patch / PR has a high criticality level and cannot be waited on for more than 4 days before being merged.\n16. Maintainers SHALL merge their own patches (PRs). Maintainers SHALL NOT merge patches from other Maintainers without their consent.\n17. Maintainers SHOULD merge patches (PRs) from other Contributors, since Contributors do NOT have the rights to merge Pull Requests.\n18. Maintainers SHALL NOT make value judgments on correct patches (PRs).\n19. Maintainers SHALL merge correct patches (PRs) from other Contributors rapidly.\n20. Maintainers MAY merge incorrect patches (PRs) from other Contributors with the goals of (a) ending fruitless discussions, (b) capturing toxic patches (PRs) in the historical record, (c) engaging with the Contributor on improving their patch (PR) quality.\n21. The user who created an issue SHOULD close the issue after checking the patch (PR) is successful. Using “Closing keywords” in the description with a reference to the issue on the Platform will close the issue automatically. For example: “Fixes #251”.\n22. Any Contributor who has value judgments on a patch / PR SHOULD express these via their own patches (PRs). Ideally after the correct patch / PR has been merged, avoiding file conflicts.\n23. Maintainers SHALL use the “Squash and merge” option on the Platform pull request interface to merge a patch (PR).\n24. Stale Platform Action is used to automatically mark an issue or a PR as “stale” and close the issue over time. PRs will NOT be closed automatically.\n\n### 2.5. Branches and Releases\n\n1. The project SHALL have one branch (“main”) that always holds the latest in-progress version and SHOULD always build.\n2. The project MAY use topic / feature branches for new functionality.\n3. To make a stable release a Maintainer SHALL tag the repository. Stable releases SHALL always be released from the repository main.\n4. A Maintainer SHOULD create a release from the Platform Release page. The release description SHOULD contain our template table (“DB migrations”, “Cache clearning”, etc.) as well as releases notes (changes made in the release) in all cases.\n\n### 2.6. Evolution of Public Contracts\n\n1.  All Public Contracts (APIs or protocols and their behaviour and side effects) SHALL be documented.\n2.  All Public Contracts SHOULD have space for extensibility and experimentation.\n3.  A patch (PR) that modifies a stable Public Contract SHOULD not break existing applications unless there is overriding consensus on the value of doing this.\n4.  A patch (PR) that introduces new features SHOULD do so using new names (a new contract).\n5.  New contracts SHOULD be marked as “draft” until they are stable and used by real users.\n6.  Old contracts SHOULD be deprecated in a systematic fashion by marking them as “deprecated” and replacing them with new contracts as needed.\n7.  When sufficient time has passed, old deprecated contracts SHOULD be removed.\n8.  Old names SHALL NOT be reused by new contracts.\n9.  A new contract marked as “draft” MUST NOT be changed to “stable” until all the following conditions are met:\n    1. Documentation has been written and is as comprehensive as that of comparable contracts.\n    2. Self-tests exercising the functionality are passing.\n    3. No changes in the contract have happened for at least one public release.\n    4. No changes in the contract have happened for at least 6 months.\n    5. No veto from the Contributor(s) of the new contract and its implementation on the change of status.\n10. A new contract marked as “draft” SHOULD be changed to “stable” when the above conditions are met.\n11. The “draft” to “stable” transition status for new contracts SHOULD be tracked using the Platform issue tracker.\n\n### 2.7. Project Administration\n\n1. The project's existing Maintainers SHALL act as Administrators to manage the set of project Maintainers.\n2. The Administrators SHALL ensure their own succession over time by promoting the most effective Maintainers.\n3. A new Contributor who makes correct patches (PRs), who clearly understands the project goals. After a discussion with existing Maintainers whether we SHOULD be invite a new Contributor, the new Contributor SHOULD be invited to become a Maintainer. But only after the new Contributor has demonstrated the above for a period of time (multiple correct PRs and more than 2-3 months).\n4. Administrators MAY remove Maintainers that are long inactive (~1-2 years). Mainly due to security reasons. The Maintainer can always return back, if the person wants to become Maintainer again.\n5. Administrators SHOULD remove Maintainers who repeatedly fail to apply this process accurately.\n6. Administrators SHOULD block or ban “bad actors” who cause stress and pain to others in the project. This should be done after public discussion, with a chance for all parties to speak. A bad actor is someone who repeatedly ignores the rules and culture of the project, who is needlessly argumentative or hostile, or who is offensive, and who is unable to self-correct their behavior when asked to do so by others.\n   If the majority of the currently active Maintainers agrees (or neutral) on the removal of the “bad actor” (after giving the “bad actor” time to self-improve), it can then be the final agreement on the decision to proceed with removal.\n\n## Further Reading\n\n- [Original C4 rev. 3](https://rfc.zeromq.org/spec/44/) - C4 by Pieter Hintjens\n\n- [Argyris’ Models 1 and 2](http://en.wikipedia.org/wiki/Chris_Argyris) - the goals of C4 are consistent with Argyris’ Model 2.\n\n- [Toyota Kata](http://en.wikipedia.org/wiki/Toyota_Kata) - covering the Improvement Kata (fixing problems one at a time) and the Coaching Kata (helping others to learn the Improvement Kata).\n"
  },
  {
    "path": "CONTRIBUTING.md",
    "content": "# Contributing to Mbin\n\nFor all the details about contributing [go to the following contributing page](docs/03-contributing).\n"
  },
  {
    "path": "LICENSE",
    "content": "                    GNU AFFERO GENERAL PUBLIC LICENSE\n                       Version 3, 19 November 2007\n\n Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>\n Everyone is permitted to copy and distribute verbatim copies\n of this license document, but changing it is not allowed.\n\n                            Preamble\n\n  The GNU Affero General Public License is a free, copyleft license for\nsoftware and other kinds of works, specifically designed to ensure\ncooperation with the community in the case of network server software.\n\n  The licenses for most software and other practical works are designed\nto take away your freedom to share and change the works.  By contrast,\nour General Public Licenses are intended to guarantee your freedom to\nshare and change all versions of a program--to make sure it remains free\nsoftware for all its users.\n\n  When we speak of free software, we are referring to freedom, not\nprice.  Our General Public Licenses are designed to make sure that you\nhave the freedom to distribute copies of free software (and charge for\nthem if you wish), that you receive source code or can get it if you\nwant it, that you can change the software or use pieces of it in new\nfree programs, and that you know you can do these things.\n\n  Developers that use our General Public Licenses protect your rights\nwith two steps: (1) assert copyright on the software, and (2) offer\nyou this License which gives you legal permission to copy, distribute\nand/or modify the software.\n\n  A secondary benefit of defending all users' freedom is that\nimprovements made in alternate versions of the program, if they\nreceive widespread use, become available for other developers to\nincorporate.  Many developers of free software are heartened and\nencouraged by the resulting cooperation.  However, in the case of\nsoftware used on network servers, this result may fail to come about.\nThe GNU General Public License permits making a modified version and\nletting the public access it on a server without ever releasing its\nsource code to the public.\n\n  The GNU Affero General Public License is designed specifically to\nensure that, in such cases, the modified source code becomes available\nto the community.  It requires the operator of a network server to\nprovide the source code of the modified version running there to the\nusers of that server.  Therefore, public use of a modified version, on\na publicly accessible server, gives the public access to the source\ncode of the modified version.\n\n  An older license, called the Affero General Public License and\npublished by Affero, was designed to accomplish similar goals.  This is\na different license, not a version of the Affero GPL, but Affero has\nreleased a new version of the Affero GPL which permits relicensing under\nthis license.\n\n  The precise terms and conditions for copying, distribution and\nmodification follow.\n\n                       TERMS AND CONDITIONS\n\n  0. Definitions.\n\n  \"This License\" refers to version 3 of the GNU Affero General Public License.\n\n  \"Copyright\" also means copyright-like laws that apply to other kinds of\nworks, such as semiconductor masks.\n\n  \"The Program\" refers to any copyrightable work licensed under this\nLicense.  Each licensee is addressed as \"you\".  \"Licensees\" and\n\"recipients\" may be individuals or organizations.\n\n  To \"modify\" a work means to copy from or adapt all or part of the work\nin a fashion requiring copyright permission, other than the making of an\nexact copy.  The resulting work is called a \"modified version\" of the\nearlier work or a work \"based on\" the earlier work.\n\n  A \"covered work\" means either the unmodified Program or a work based\non the Program.\n\n  To \"propagate\" a work means to do anything with it that, without\npermission, would make you directly or secondarily liable for\ninfringement under applicable copyright law, except executing it on a\ncomputer or modifying a private copy.  Propagation includes copying,\ndistribution (with or without modification), making available to the\npublic, and in some countries other activities as well.\n\n  To \"convey\" a work means any kind of propagation that enables other\nparties to make or receive copies.  Mere interaction with a user through\na computer network, with no transfer of a copy, is not conveying.\n\n  An interactive user interface displays \"Appropriate Legal Notices\"\nto the extent that it includes a convenient and prominently visible\nfeature that (1) displays an appropriate copyright notice, and (2)\ntells the user that there is no warranty for the work (except to the\nextent that warranties are provided), that licensees may convey the\nwork under this License, and how to view a copy of this License.  If\nthe interface presents a list of user commands or options, such as a\nmenu, a prominent item in the list meets this criterion.\n\n  1. Source Code.\n\n  The \"source code\" for a work means the preferred form of the work\nfor making modifications to it.  \"Object code\" means any non-source\nform of a work.\n\n  A \"Standard Interface\" means an interface that either is an official\nstandard defined by a recognized standards body, or, in the case of\ninterfaces specified for a particular programming language, one that\nis widely used among developers working in that language.\n\n  The \"System Libraries\" of an executable work include anything, other\nthan the work as a whole, that (a) is included in the normal form of\npackaging a Major Component, but which is not part of that Major\nComponent, and (b) serves only to enable use of the work with that\nMajor Component, or to implement a Standard Interface for which an\nimplementation is available to the public in source code form.  A\n\"Major Component\", in this context, means a major essential component\n(kernel, window system, and so on) of the specific operating system\n(if any) on which the executable work runs, or a compiler used to\nproduce the work, or an object code interpreter used to run it.\n\n  The \"Corresponding Source\" for a work in object code form means all\nthe source code needed to generate, install, and (for an executable\nwork) run the object code and to modify the work, including scripts to\ncontrol those activities.  However, it does not include the work's\nSystem Libraries, or general-purpose tools or generally available free\nprograms which are used unmodified in performing those activities but\nwhich are not part of the work.  For example, Corresponding Source\nincludes interface definition files associated with source files for\nthe work, and the source code for shared libraries and dynamically\nlinked subprograms that the work is specifically designed to require,\nsuch as by intimate data communication or control flow between those\nsubprograms and other parts of the work.\n\n  The Corresponding Source need not include anything that users\ncan regenerate automatically from other parts of the Corresponding\nSource.\n\n  The Corresponding Source for a work in source code form is that\nsame work.\n\n  2. Basic Permissions.\n\n  All rights granted under this License are granted for the term of\ncopyright on the Program, and are irrevocable provided the stated\nconditions are met.  This License explicitly affirms your unlimited\npermission to run the unmodified Program.  The output from running a\ncovered work is covered by this License only if the output, given its\ncontent, constitutes a covered work.  This License acknowledges your\nrights of fair use or other equivalent, as provided by copyright law.\n\n  You may make, run and propagate covered works that you do not\nconvey, without conditions so long as your license otherwise remains\nin force.  You may convey covered works to others for the sole purpose\nof having them make modifications exclusively for you, or provide you\nwith facilities for running those works, provided that you comply with\nthe terms of this License in conveying all material for which you do\nnot control copyright.  Those thus making or running the covered works\nfor you must do so exclusively on your behalf, under your direction\nand control, on terms that prohibit them from making any copies of\nyour copyrighted material outside their relationship with you.\n\n  Conveying under any other circumstances is permitted solely under\nthe conditions stated below.  Sublicensing is not allowed; section 10\nmakes it unnecessary.\n\n  3. Protecting Users' Legal Rights From Anti-Circumvention Law.\n\n  No covered work shall be deemed part of an effective technological\nmeasure under any applicable law fulfilling obligations under article\n11 of the WIPO copyright treaty adopted on 20 December 1996, or\nsimilar laws prohibiting or restricting circumvention of such\nmeasures.\n\n  When you convey a covered work, you waive any legal power to forbid\ncircumvention of technological measures to the extent such circumvention\nis effected by exercising rights under this License with respect to\nthe covered work, and you disclaim any intention to limit operation or\nmodification of the work as a means of enforcing, against the work's\nusers, your or third parties' legal rights to forbid circumvention of\ntechnological measures.\n\n  4. Conveying Verbatim Copies.\n\n  You may convey verbatim copies of the Program's source code as you\nreceive it, in any medium, provided that you conspicuously and\nappropriately publish on each copy an appropriate copyright notice;\nkeep intact all notices stating that this License and any\nnon-permissive terms added in accord with section 7 apply to the code;\nkeep intact all notices of the absence of any warranty; and give all\nrecipients a copy of this License along with the Program.\n\n  You may charge any price or no price for each copy that you convey,\nand you may offer support or warranty protection for a fee.\n\n  5. Conveying Modified Source Versions.\n\n  You may convey a work based on the Program, or the modifications to\nproduce it from the Program, in the form of source code under the\nterms of section 4, provided that you also meet all of these conditions:\n\n    a) The work must carry prominent notices stating that you modified\n    it, and giving a relevant date.\n\n    b) The work must carry prominent notices stating that it is\n    released under this License and any conditions added under section\n    7.  This requirement modifies the requirement in section 4 to\n    \"keep intact all notices\".\n\n    c) You must license the entire work, as a whole, under this\n    License to anyone who comes into possession of a copy.  This\n    License will therefore apply, along with any applicable section 7\n    additional terms, to the whole of the work, and all its parts,\n    regardless of how they are packaged.  This License gives no\n    permission to license the work in any other way, but it does not\n    invalidate such permission if you have separately received it.\n\n    d) If the work has interactive user interfaces, each must display\n    Appropriate Legal Notices; however, if the Program has interactive\n    interfaces that do not display Appropriate Legal Notices, your\n    work need not make them do so.\n\n  A compilation of a covered work with other separate and independent\nworks, which are not by their nature extensions of the covered work,\nand which are not combined with it such as to form a larger program,\nin or on a volume of a storage or distribution medium, is called an\n\"aggregate\" if the compilation and its resulting copyright are not\nused to limit the access or legal rights of the compilation's users\nbeyond what the individual works permit.  Inclusion of a covered work\nin an aggregate does not cause this License to apply to the other\nparts of the aggregate.\n\n  6. Conveying Non-Source Forms.\n\n  You may convey a covered work in object code form under the terms\nof sections 4 and 5, provided that you also convey the\nmachine-readable Corresponding Source under the terms of this License,\nin one of these ways:\n\n    a) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by the\n    Corresponding Source fixed on a durable physical medium\n    customarily used for software interchange.\n\n    b) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by a\n    written offer, valid for at least three years and valid for as\n    long as you offer spare parts or customer support for that product\n    model, to give anyone who possesses the object code either (1) a\n    copy of the Corresponding Source for all the software in the\n    product that is covered by this License, on a durable physical\n    medium customarily used for software interchange, for a price no\n    more than your reasonable cost of physically performing this\n    conveying of source, or (2) access to copy the\n    Corresponding Source from a network server at no charge.\n\n    c) Convey individual copies of the object code with a copy of the\n    written offer to provide the Corresponding Source.  This\n    alternative is allowed only occasionally and noncommercially, and\n    only if you received the object code with such an offer, in accord\n    with subsection 6b.\n\n    d) Convey the object code by offering access from a designated\n    place (gratis or for a charge), and offer equivalent access to the\n    Corresponding Source in the same way through the same place at no\n    further charge.  You need not require recipients to copy the\n    Corresponding Source along with the object code.  If the place to\n    copy the object code is a network server, the Corresponding Source\n    may be on a different server (operated by you or a third party)\n    that supports equivalent copying facilities, provided you maintain\n    clear directions next to the object code saying where to find the\n    Corresponding Source.  Regardless of what server hosts the\n    Corresponding Source, you remain obligated to ensure that it is\n    available for as long as needed to satisfy these requirements.\n\n    e) Convey the object code using peer-to-peer transmission, provided\n    you inform other peers where the object code and Corresponding\n    Source of the work are being offered to the general public at no\n    charge under subsection 6d.\n\n  A separable portion of the object code, whose source code is excluded\nfrom the Corresponding Source as a System Library, need not be\nincluded in conveying the object code work.\n\n  A \"User Product\" is either (1) a \"consumer product\", which means any\ntangible personal property which is normally used for personal, family,\nor household purposes, or (2) anything designed or sold for incorporation\ninto a dwelling.  In determining whether a product is a consumer product,\ndoubtful cases shall be resolved in favor of coverage.  For a particular\nproduct received by a particular user, \"normally used\" refers to a\ntypical or common use of that class of product, regardless of the status\nof the particular user or of the way in which the particular user\nactually uses, or expects or is expected to use, the product.  A product\nis a consumer product regardless of whether the product has substantial\ncommercial, industrial or non-consumer uses, unless such uses represent\nthe only significant mode of use of the product.\n\n  \"Installation Information\" for a User Product means any methods,\nprocedures, authorization keys, or other information required to install\nand execute modified versions of a covered work in that User Product from\na modified version of its Corresponding Source.  The information must\nsuffice to ensure that the continued functioning of the modified object\ncode is in no case prevented or interfered with solely because\nmodification has been made.\n\n  If you convey an object code work under this section in, or with, or\nspecifically for use in, a User Product, and the conveying occurs as\npart of a transaction in which the right of possession and use of the\nUser Product is transferred to the recipient in perpetuity or for a\nfixed term (regardless of how the transaction is characterized), the\nCorresponding Source conveyed under this section must be accompanied\nby the Installation Information.  But this requirement does not apply\nif neither you nor any third party retains the ability to install\nmodified object code on the User Product (for example, the work has\nbeen installed in ROM).\n\n  The requirement to provide Installation Information does not include a\nrequirement to continue to provide support service, warranty, or updates\nfor a work that has been modified or installed by the recipient, or for\nthe User Product in which it has been modified or installed.  Access to a\nnetwork may be denied when the modification itself materially and\nadversely affects the operation of the network or violates the rules and\nprotocols for communication across the network.\n\n  Corresponding Source conveyed, and Installation Information provided,\nin accord with this section must be in a format that is publicly\ndocumented (and with an implementation available to the public in\nsource code form), and must require no special password or key for\nunpacking, reading or copying.\n\n  7. Additional Terms.\n\n  \"Additional permissions\" are terms that supplement the terms of this\nLicense by making exceptions from one or more of its conditions.\nAdditional permissions that are applicable to the entire Program shall\nbe treated as though they were included in this License, to the extent\nthat they are valid under applicable law.  If additional permissions\napply only to part of the Program, that part may be used separately\nunder those permissions, but the entire Program remains governed by\nthis License without regard to the additional permissions.\n\n  When you convey a copy of a covered work, you may at your option\nremove any additional permissions from that copy, or from any part of\nit.  (Additional permissions may be written to require their own\nremoval in certain cases when you modify the work.)  You may place\nadditional permissions on material, added by you to a covered work,\nfor which you have or can give appropriate copyright permission.\n\n  Notwithstanding any other provision of this License, for material you\nadd to a covered work, you may (if authorized by the copyright holders of\nthat material) supplement the terms of this License with terms:\n\n    a) Disclaiming warranty or limiting liability differently from the\n    terms of sections 15 and 16 of this License; or\n\n    b) Requiring preservation of specified reasonable legal notices or\n    author attributions in that material or in the Appropriate Legal\n    Notices displayed by works containing it; or\n\n    c) Prohibiting misrepresentation of the origin of that material, or\n    requiring that modified versions of such material be marked in\n    reasonable ways as different from the original version; or\n\n    d) Limiting the use for publicity purposes of names of licensors or\n    authors of the material; or\n\n    e) Declining to grant rights under trademark law for use of some\n    trade names, trademarks, or service marks; or\n\n    f) Requiring indemnification of licensors and authors of that\n    material by anyone who conveys the material (or modified versions of\n    it) with contractual assumptions of liability to the recipient, for\n    any liability that these contractual assumptions directly impose on\n    those licensors and authors.\n\n  All other non-permissive additional terms are considered \"further\nrestrictions\" within the meaning of section 10.  If the Program as you\nreceived it, or any part of it, contains a notice stating that it is\ngoverned by this License along with a term that is a further\nrestriction, you may remove that term.  If a license document contains\na further restriction but permits relicensing or conveying under this\nLicense, you may add to a covered work material governed by the terms\nof that license document, provided that the further restriction does\nnot survive such relicensing or conveying.\n\n  If you add terms to a covered work in accord with this section, you\nmust place, in the relevant source files, a statement of the\nadditional terms that apply to those files, or a notice indicating\nwhere to find the applicable terms.\n\n  Additional terms, permissive or non-permissive, may be stated in the\nform of a separately written license, or stated as exceptions;\nthe above requirements apply either way.\n\n  8. Termination.\n\n  You may not propagate or modify a covered work except as expressly\nprovided under this License.  Any attempt otherwise to propagate or\nmodify it is void, and will automatically terminate your rights under\nthis License (including any patent licenses granted under the third\nparagraph of section 11).\n\n  However, if you cease all violation of this License, then your\nlicense from a particular copyright holder is reinstated (a)\nprovisionally, unless and until the copyright holder explicitly and\nfinally terminates your license, and (b) permanently, if the copyright\nholder fails to notify you of the violation by some reasonable means\nprior to 60 days after the cessation.\n\n  Moreover, your license from a particular copyright holder is\nreinstated permanently if the copyright holder notifies you of the\nviolation by some reasonable means, this is the first time you have\nreceived notice of violation of this License (for any work) from that\ncopyright holder, and you cure the violation prior to 30 days after\nyour receipt of the notice.\n\n  Termination of your rights under this section does not terminate the\nlicenses of parties who have received copies or rights from you under\nthis License.  If your rights have been terminated and not permanently\nreinstated, you do not qualify to receive new licenses for the same\nmaterial under section 10.\n\n  9. Acceptance Not Required for Having Copies.\n\n  You are not required to accept this License in order to receive or\nrun a copy of the Program.  Ancillary propagation of a covered work\noccurring solely as a consequence of using peer-to-peer transmission\nto receive a copy likewise does not require acceptance.  However,\nnothing other than this License grants you permission to propagate or\nmodify any covered work.  These actions infringe copyright if you do\nnot accept this License.  Therefore, by modifying or propagating a\ncovered work, you indicate your acceptance of this License to do so.\n\n  10. Automatic Licensing of Downstream Recipients.\n\n  Each time you convey a covered work, the recipient automatically\nreceives a license from the original licensors, to run, modify and\npropagate that work, subject to this License.  You are not responsible\nfor enforcing compliance by third parties with this License.\n\n  An \"entity transaction\" is a transaction transferring control of an\norganization, or substantially all assets of one, or subdividing an\norganization, or merging organizations.  If propagation of a covered\nwork results from an entity transaction, each party to that\ntransaction who receives a copy of the work also receives whatever\nlicenses to the work the party's predecessor in interest had or could\ngive under the previous paragraph, plus a right to possession of the\nCorresponding Source of the work from the predecessor in interest, if\nthe predecessor has it or can get it with reasonable efforts.\n\n  You may not impose any further restrictions on the exercise of the\nrights granted or affirmed under this License.  For example, you may\nnot impose a license fee, royalty, or other charge for exercise of\nrights granted under this License, and you may not initiate litigation\n(including a cross-claim or counterclaim in a lawsuit) alleging that\nany patent claim is infringed by making, using, selling, offering for\nsale, or importing the Program or any portion of it.\n\n  11. Patents.\n\n  A \"contributor\" is a copyright holder who authorizes use under this\nLicense of the Program or a work on which the Program is based.  The\nwork thus licensed is called the contributor's \"contributor version\".\n\n  A contributor's \"essential patent claims\" are all patent claims\nowned or controlled by the contributor, whether already acquired or\nhereafter acquired, that would be infringed by some manner, permitted\nby this License, of making, using, or selling its contributor version,\nbut do not include claims that would be infringed only as a\nconsequence of further modification of the contributor version.  For\npurposes of this definition, \"control\" includes the right to grant\npatent sublicenses in a manner consistent with the requirements of\nthis License.\n\n  Each contributor grants you a non-exclusive, worldwide, royalty-free\npatent license under the contributor's essential patent claims, to\nmake, use, sell, offer for sale, import and otherwise run, modify and\npropagate the contents of its contributor version.\n\n  In the following three paragraphs, a \"patent license\" is any express\nagreement or commitment, however denominated, not to enforce a patent\n(such as an express permission to practice a patent or covenant not to\nsue for patent infringement).  To \"grant\" such a patent license to a\nparty means to make such an agreement or commitment not to enforce a\npatent against the party.\n\n  If you convey a covered work, knowingly relying on a patent license,\nand the Corresponding Source of the work is not available for anyone\nto copy, free of charge and under the terms of this License, through a\npublicly available network server or other readily accessible means,\nthen you must either (1) cause the Corresponding Source to be so\navailable, or (2) arrange to deprive yourself of the benefit of the\npatent license for this particular work, or (3) arrange, in a manner\nconsistent with the requirements of this License, to extend the patent\nlicense to downstream recipients.  \"Knowingly relying\" means you have\nactual knowledge that, but for the patent license, your conveying the\ncovered work in a country, or your recipient's use of the covered work\nin a country, would infringe one or more identifiable patents in that\ncountry that you have reason to believe are valid.\n\n  If, pursuant to or in connection with a single transaction or\narrangement, you convey, or propagate by procuring conveyance of, a\ncovered work, and grant a patent license to some of the parties\nreceiving the covered work authorizing them to use, propagate, modify\nor convey a specific copy of the covered work, then the patent license\nyou grant is automatically extended to all recipients of the covered\nwork and works based on it.\n\n  A patent license is \"discriminatory\" if it does not include within\nthe scope of its coverage, prohibits the exercise of, or is\nconditioned on the non-exercise of one or more of the rights that are\nspecifically granted under this License.  You may not convey a covered\nwork if you are a party to an arrangement with a third party that is\nin the business of distributing software, under which you make payment\nto the third party based on the extent of your activity of conveying\nthe work, and under which the third party grants, to any of the\nparties who would receive the covered work from you, a discriminatory\npatent license (a) in connection with copies of the covered work\nconveyed by you (or copies made from those copies), or (b) primarily\nfor and in connection with specific products or compilations that\ncontain the covered work, unless you entered into that arrangement,\nor that patent license was granted, prior to 28 March 2007.\n\n  Nothing in this License shall be construed as excluding or limiting\nany implied license or other defenses to infringement that may\notherwise be available to you under applicable patent law.\n\n  12. No Surrender of Others' Freedom.\n\n  If conditions are imposed on you (whether by court order, agreement or\notherwise) that contradict the conditions of this License, they do not\nexcuse you from the conditions of this License.  If you cannot convey a\ncovered work so as to satisfy simultaneously your obligations under this\nLicense and any other pertinent obligations, then as a consequence you may\nnot convey it at all.  For example, if you agree to terms that obligate you\nto collect a royalty for further conveying from those to whom you convey\nthe Program, the only way you could satisfy both those terms and this\nLicense would be to refrain entirely from conveying the Program.\n\n  13. Remote Network Interaction; Use with the GNU General Public License.\n\n  Notwithstanding any other provision of this License, if you modify the\nProgram, your modified version must prominently offer all users\ninteracting with it remotely through a computer network (if your version\nsupports such interaction) an opportunity to receive the Corresponding\nSource of your version by providing access to the Corresponding Source\nfrom a network server at no charge, through some standard or customary\nmeans of facilitating copying of software.  This Corresponding Source\nshall include the Corresponding Source for any work covered by version 3\nof the GNU General Public License that is incorporated pursuant to the\nfollowing paragraph.\n\n  Notwithstanding any other provision of this License, you have\npermission to link or combine any covered work with a work licensed\nunder version 3 of the GNU General Public License into a single\ncombined work, and to convey the resulting work.  The terms of this\nLicense will continue to apply to the part which is the covered work,\nbut the work with which it is combined will remain governed by version\n3 of the GNU General Public License.\n\n  14. Revised Versions of this License.\n\n  The Free Software Foundation may publish revised and/or new versions of\nthe GNU Affero General Public License from time to time.  Such new versions\nwill be similar in spirit to the present version, but may differ in detail to\naddress new problems or concerns.\n\n  Each version is given a distinguishing version number.  If the\nProgram specifies that a certain numbered version of the GNU Affero General\nPublic License \"or any later version\" applies to it, you have the\noption of following the terms and conditions either of that numbered\nversion or of any later version published by the Free Software\nFoundation.  If the Program does not specify a version number of the\nGNU Affero General Public License, you may choose any version ever published\nby the Free Software Foundation.\n\n  If the Program specifies that a proxy can decide which future\nversions of the GNU Affero General Public License can be used, that proxy's\npublic statement of acceptance of a version permanently authorizes you\nto choose that version for the Program.\n\n  Later license versions may give you additional or different\npermissions.  However, no additional obligations are imposed on any\nauthor or copyright holder as a result of your choosing to follow a\nlater version.\n\n  15. Disclaimer of Warranty.\n\n  THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY\nAPPLICABLE LAW.  EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT\nHOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM \"AS IS\" WITHOUT WARRANTY\nOF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,\nTHE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR\nPURPOSE.  THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM\nIS WITH YOU.  SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF\nALL NECESSARY SERVICING, REPAIR OR CORRECTION.\n\n  16. Limitation of Liability.\n\n  IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING\nWILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS\nTHE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY\nGENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE\nUSE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF\nDATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD\nPARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),\nEVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF\nSUCH DAMAGES.\n\n  17. Interpretation of Sections 15 and 16.\n\n  If the disclaimer of warranty and limitation of liability provided\nabove cannot be given local legal effect according to their terms,\nreviewing courts shall apply local law that most closely approximates\nan absolute waiver of all civil liability in connection with the\nProgram, unless a warranty or assumption of liability accompanies a\ncopy of the Program in return for a fee.\n\n                     END OF TERMS AND CONDITIONS\n\n            How to Apply These Terms to Your New Programs\n\n  If you develop a new program, and you want it to be of the greatest\npossible use to the public, the best way to achieve this is to make it\nfree software which everyone can redistribute and change under these terms.\n\n  To do so, attach the following notices to the program.  It is safest\nto attach them to the start of each source file to most effectively\nstate the exclusion of warranty; and each file should have at least\nthe \"copyright\" line and a pointer to where the full notice is found.\n\n    <one line to give the program's name and a brief idea of what it does.>\n    Copyright (C) <year>  <name of author>\n\n    This program is free software: you can redistribute it and/or modify\n    it under the terms of the GNU Affero General Public License as published\n    by the Free Software Foundation, either version 3 of the License, or\n    (at your option) any later version.\n\n    This program is distributed in the hope that it will be useful,\n    but WITHOUT ANY WARRANTY; without even the implied warranty of\n    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n    GNU Affero General Public License for more details.\n\n    You should have received a copy of the GNU Affero General Public License\n    along with this program.  If not, see <https://www.gnu.org/licenses/>.\n\nAlso add information on how to contact you by electronic and paper mail.\n\n  If your software can interact with users remotely through a computer\nnetwork, you should also make sure that it provides a way for users to\nget its source.  For example, if your program is a web application, its\ninterface could display a \"Source\" link that leads users to an archive\nof the code.  There are many ways you could offer source, and different\nsolutions will be better for different programs; see section 13 for the\nspecific requirements.\n\n  You should also get your employer (if you work as a programmer) or school,\nif any, to sign a \"copyright disclaimer\" for the program, if necessary.\nFor more information on this, and how to apply and follow the GNU AGPL, see\n<https://www.gnu.org/licenses/>.\n"
  },
  {
    "path": "LICENSES/Zlib.txt",
    "content": "This software is provided 'as-is', without any express or implied\nwarranty.  In no event will the authors be held liable for any damages\narising from the use of this software.\n\nPermission is granted to anyone to use this software for any purpose,\nincluding commercial applications, and to alter it and redistribute it\nfreely, subject to the following restrictions:\n\n1. The origin of this software must not be misrepresented; you must not\n   claim that you wrote the original software. If you use this software\n   in a product, an acknowledgment in the product documentation would be\n   appreciated but is not required.\n2. Altered source versions must be plainly marked as such, and must not be\n   misrepresented as being the original software.\n3. This notice may not be removed or altered from any source distribution."
  },
  {
    "path": "README.md",
    "content": "<p align=\"center\">\n    <img src=\"docs/images/mbin.png\" alt=\"Mbin logo\" width=\"400\">\n</p>\n<p align=\"center\">\n  <a href=\"https://github.com/MbinOrg/mbin/actions/workflows/action.yaml?query=branch%3Amain\"><img src=\"https://github.com/MbinOrg/mbin/actions/workflows/action.yaml/badge.svg?branch=main\" alt=\"GitHub Actions Workflow\"></a>\n  <a href=\"https://github.com/MbinOrg/mbin/actions/workflows/psalm.yml?query=branch%3Amain\"><img src=\"https://github.com/MbinOrg/mbin/actions/workflows/psalm.yml/badge.svg?branch=main\" alt=\"Psalm Security Scan\"></a>\n  <a href=\"https://hosted.weblate.org/engage/mbin/\"><img src=\"https://hosted.weblate.org/widgets/mbin/-/svg-badge.svg\" alt=\"Translation status\"></a>\n  <a href=\"https://matrix.to/#/#mbin:melroy.org\"><img src=\"https://img.shields.io/badge/chat-on%20matrix-brightgreen\" alt=\"Matrix chat\"></a>\n  <a href=\"https://github.com/MbinOrg/mbin/blob/main/LICENSE\"><img src=\"https://img.shields.io/badge/AGPL%203.0-license-blue\" alt=\"License\"></a>\n</p>\n\n## Introduction\n\nMbin is a decentralized content aggregator, voting, discussion, and microblogging platform running on the fediverse. It can\ncommunicate with various ActivityPub services, including but not limited to: Mastodon, Lemmy, Pixelfed, Pleroma, and PeerTube.\n\nMbin is a fork and continuation of [/kbin](https://codeberg.org/Kbin/kbin-core), but community-focused. Feel free to chat on [Matrix](https://matrix.to/#/#mbin:melroy.org). Pull requests are always welcome.\n\n> [!Important]\n> Mbin is focused on what the community wants. Pull requests can be merged by any repo maintainer with merge rights in GitHub. Discussions take place on [Matrix](https://matrix.to/#/#mbin:melroy.org) then _consensus_ has to be reached by the community.\n\nUnique Features of Mbin for server owners & users alike:\n\n- Tons of **[GUI improvements](https://github.com/MbinOrg/mbin/pulls?q=is%3Apr+is%3Amerged+label%3Afrontend)**\n- A lot of **[enhancements](https://github.com/MbinOrg/mbin/pulls?q=is%3Apr+is%3Amerged+label%3Aenhancement)**\n- Various **[bug fixes](https://github.com/MbinOrg/mbin/pulls?q=is%3Apr+is%3Amerged+label%3Abug)**\n- Support of **all** ActivityPub Actor Types (including also \"Service\" account support; thus support for robot accounts)\n- **Up-to-date** PHP packages and **security/vulnerability** issues fixed\n- Support for `application/json` Accept request header on all ActivityPub end-points\n- Introducing a hosted documentation: [docs.joinmbin.org](https://docs.joinmbin.org)\n\nSee also: [all merged PRs](https://github.com/MbinOrg/mbin/pulls?q=is%3Apr+is%3Amerged) or [our releases](https://github.com/MbinOrg/mbin/releases).\n\nFor developers:\n\n- Improved [bare metal/VM guide](https://docs.joinmbin.org/admin/installation/bare_metal) and [Docker guide](https://docs.joinmbin.org/admin/installation/docker/)\n- [Improved Docker setup](https://github.com/MbinOrg/mbin/pulls?q=is%3Apr+is%3Amerged+label%3Adocker)\n- _Developer_ server explained (see [Development Server documentation here](https://docs.joinmbin.org/contributing/development_server) )\n- GitHub Security advisories, vulnerability reporting, [Dependabot](https://github.com/features/security) and [Advanced code scanning](https://docs.github.com/en/code-security/code-scanning/introduction-to-code-scanning/about-code-scanning) enabled. And we run `composer audit`.\n- Improved **code documentation**\n- **Tight integration** with [Mbin Weblate project](https://hosted.weblate.org/engage/mbin/) for translations (Two way sync)\n- Last but not least, a **community-focus project embracing the [Collective Code Construction Contract](./C4.md)** (C4). No single maintainer.\n\n## Instances\n\n- [List of instances](https://joinmbin.org/servers)\n- [Alternative list of instances at fedidb.org](https://fedidb.org/software/mbin)\n- [Alternative list of instances at fediverse.observer](https://mbin.fediverse.observer/list)\n\n## Star History\n\n[![Star History Chart](https://api.star-history.com/svg?repos=MbinOrg/mbin&type=Date)](https://star-history.com/#MbinOrg/mbin&Date)\n\n## Contributing\n\n- [Official repository on GitHub](https://github.com/MbinOrg/mbin)\n- [Matrix Space for discussions](https://matrix.to/#/#mbin:melroy.org)\n- [Translations](https://hosted.weblate.org/engage/mbin/)\n- [Contribution guidelines](docs/03-contributing) - please read first, including before opening an issue!\n\n## Magazines\n\nUnofficial magazines:\n\n- [@mbinmeta@gehirneimer.de](https://gehirneimer.de/m/mbinmeta)\n- [@updates@kbin.melroy.org](https://kbin.melroy.org/m/updates)\n- [@AskMbin@fedia.io](https://fedia.io/m/AskMbin)\n\n## Contributors\n\n<!-- readme: contributors -start -->\n<table>\n\t<tbody>\n\t\t<tr>\n            <td align=\"center\">\n                <a href=\"https://github.com/ernestwisniewski\">\n                    <img src=\"https://avatars.githubusercontent.com/u/10058784?v=4\" width=\"100;\" alt=\"ernestwisniewski\"/>\n                    <br />\n                    <sub><b>Ernest</b></sub>\n                </a>\n            </td>\n            <td align=\"center\">\n                <a href=\"https://github.com/melroy89\">\n                    <img src=\"https://avatars.githubusercontent.com/u/628926?v=4\" width=\"100;\" alt=\"melroy89\"/>\n                    <br />\n                    <sub><b>Melroy van den Berg</b></sub>\n                </a>\n            </td>\n            <td align=\"center\">\n                <a href=\"https://github.com/BentiGorlich\">\n                    <img src=\"https://avatars.githubusercontent.com/u/25664458?v=4\" width=\"100;\" alt=\"BentiGorlich\"/>\n                    <br />\n                    <sub><b>BentiGorlich</b></sub>\n                </a>\n            </td>\n            <td align=\"center\">\n                <a href=\"https://github.com/weblate\">\n                    <img src=\"https://avatars.githubusercontent.com/u/1607653?v=4\" width=\"100;\" alt=\"weblate\"/>\n                    <br />\n                    <sub><b>Weblate (bot)</b></sub>\n                </a>\n            </td>\n            <td align=\"center\">\n                <a href=\"https://github.com/e-five256\">\n                    <img src=\"https://avatars.githubusercontent.com/u/146029455?v=4\" width=\"100;\" alt=\"e-five256\"/>\n                    <br />\n                    <sub><b>e-five</b></sub>\n                </a>\n            </td>\n            <td align=\"center\">\n                <a href=\"https://github.com/asdfzdfj\">\n                    <img src=\"https://avatars.githubusercontent.com/u/20770492?v=4\" width=\"100;\" alt=\"asdfzdfj\"/>\n                    <br />\n                    <sub><b>asdfzdfj</b></sub>\n                </a>\n            </td>\n\t\t</tr>\n\t\t<tr>\n            <td align=\"center\">\n                <a href=\"https://github.com/SzymonKaminski\">\n                    <img src=\"https://avatars.githubusercontent.com/u/8536735?v=4\" width=\"100;\" alt=\"SzymonKaminski\"/>\n                    <br />\n                    <sub><b>SzymonKaminski</b></sub>\n                </a>\n            </td>\n            <td align=\"center\">\n                <a href=\"https://github.com/cooperaj\">\n                    <img src=\"https://avatars.githubusercontent.com/u/400210?v=4\" width=\"100;\" alt=\"cooperaj\"/>\n                    <br />\n                    <sub><b>Adam Cooper</b></sub>\n                </a>\n            </td>\n            <td align=\"center\">\n                <a href=\"https://github.com/simonrcodrington\">\n                    <img src=\"https://avatars.githubusercontent.com/u/12083338?v=4\" width=\"100;\" alt=\"simonrcodrington\"/>\n                    <br />\n                    <sub><b>Simon Codrington</b></sub>\n                </a>\n            </td>\n            <td align=\"center\">\n                <a href=\"https://github.com/blued-gear\">\n                    <img src=\"https://avatars.githubusercontent.com/u/164888202?v=4\" width=\"100;\" alt=\"blued-gear\"/>\n                    <br />\n                    <sub><b>blued_gear</b></sub>\n                </a>\n            </td>\n            <td align=\"center\">\n                <a href=\"https://github.com/kkoyung\">\n                    <img src=\"https://avatars.githubusercontent.com/u/11942650?v=4\" width=\"100;\" alt=\"kkoyung\"/>\n                    <br />\n                    <sub><b>Kingsley Yung</b></sub>\n                </a>\n            </td>\n            <td align=\"center\">\n                <a href=\"https://github.com/TheVillageGuy\">\n                    <img src=\"https://avatars.githubusercontent.com/u/47496248?v=4\" width=\"100;\" alt=\"TheVillageGuy\"/>\n                    <br />\n                    <sub><b>TheVillageGuy</b></sub>\n                </a>\n            </td>\n\t\t</tr>\n\t\t<tr>\n            <td align=\"center\">\n                <a href=\"https://github.com/danielpervan\">\n                    <img src=\"https://avatars.githubusercontent.com/u/5121830?v=4\" width=\"100;\" alt=\"danielpervan\"/>\n                    <br />\n                    <sub><b>Daniel Pervan</b></sub>\n                </a>\n            </td>\n            <td align=\"center\">\n                <a href=\"https://github.com/garrettw\">\n                    <img src=\"https://avatars.githubusercontent.com/u/84885?v=4\" width=\"100;\" alt=\"garrettw\"/>\n                    <br />\n                    <sub><b>Garrett W.</b></sub>\n                </a>\n            </td>\n            <td align=\"center\">\n                <a href=\"https://github.com/jwr1\">\n                    <img src=\"https://avatars.githubusercontent.com/u/47087725?v=4\" width=\"100;\" alt=\"jwr1\"/>\n                    <br />\n                    <sub><b>John Wesley</b></sub>\n                </a>\n            </td>\n            <td align=\"center\">\n                <a href=\"https://github.com/Ahrotahn\">\n                    <img src=\"https://avatars.githubusercontent.com/u/40727284?v=4\" width=\"100;\" alt=\"Ahrotahn\"/>\n                    <br />\n                    <sub><b>Ahrotahn</b></sub>\n                </a>\n            </td>\n            <td align=\"center\">\n                <a href=\"https://github.com/GauthierPLM\">\n                    <img src=\"https://avatars.githubusercontent.com/u/2579741?v=4\" width=\"100;\" alt=\"GauthierPLM\"/>\n                    <br />\n                    <sub><b>Gauthier POGAM--LE MONTAGNER</b></sub>\n                </a>\n            </td>\n            <td align=\"center\">\n                <a href=\"https://github.com/CocoPoops\">\n                    <img src=\"https://avatars.githubusercontent.com/u/7891055?v=4\" width=\"100;\" alt=\"CocoPoops\"/>\n                    <br />\n                    <sub><b>CocoPoops</b></sub>\n                </a>\n            </td>\n\t\t</tr>\n\t\t<tr>\n            <td align=\"center\">\n                <a href=\"https://github.com/thepaperpilot\">\n                    <img src=\"https://avatars.githubusercontent.com/u/3683148?v=4\" width=\"100;\" alt=\"thepaperpilot\"/>\n                    <br />\n                    <sub><b>Anthony Lawn</b></sub>\n                </a>\n            </td>\n            <td align=\"center\">\n                <a href=\"https://github.com/chall8908\">\n                    <img src=\"https://avatars.githubusercontent.com/u/315948?v=4\" width=\"100;\" alt=\"chall8908\"/>\n                    <br />\n                    <sub><b>Chris Hall</b></sub>\n                </a>\n            </td>\n            <td align=\"center\">\n                <a href=\"https://github.com/andrewmoise\">\n                    <img src=\"https://avatars.githubusercontent.com/u/8404538?v=4\" width=\"100;\" alt=\"andrewmoise\"/>\n                    <br />\n                    <sub><b>andrewmoise</b></sub>\n                </a>\n            </td>\n            <td align=\"center\">\n                <a href=\"https://github.com/piotr-sikora-v\">\n                    <img src=\"https://avatars.githubusercontent.com/u/1295000?v=4\" width=\"100;\" alt=\"piotr-sikora-v\"/>\n                    <br />\n                    <sub><b>Piotr Sikora</b></sub>\n                </a>\n            </td>\n            <td align=\"center\">\n                <a href=\"https://github.com/ryanmonsen\">\n                    <img src=\"https://avatars.githubusercontent.com/u/55466117?v=4\" width=\"100;\" alt=\"ryanmonsen\"/>\n                    <br />\n                    <sub><b>ryanmonsen</b></sub>\n                </a>\n            </td>\n            <td align=\"center\">\n                <a href=\"https://github.com/drupol\">\n                    <img src=\"https://avatars.githubusercontent.com/u/252042?v=4\" width=\"100;\" alt=\"drupol\"/>\n                    <br />\n                    <sub><b>Pol Dellaiera</b></sub>\n                </a>\n            </td>\n\t\t</tr>\n\t\t<tr>\n            <td align=\"center\">\n                <a href=\"https://github.com/MakaryGo\">\n                    <img src=\"https://avatars.githubusercontent.com/u/24472656?v=4\" width=\"100;\" alt=\"MakaryGo\"/>\n                    <br />\n                    <sub><b>Makary</b></sub>\n                </a>\n            </td>\n            <td align=\"center\">\n                <a href=\"https://github.com/cavebob\">\n                    <img src=\"https://avatars.githubusercontent.com/u/75441692?v=4\" width=\"100;\" alt=\"cavebob\"/>\n                    <br />\n                    <sub><b>cavebob</b></sub>\n                </a>\n            </td>\n            <td align=\"center\">\n                <a href=\"https://github.com/vpzomtrrfrt\">\n                    <img src=\"https://avatars.githubusercontent.com/u/3528358?v=4\" width=\"100;\" alt=\"vpzomtrrfrt\"/>\n                    <br />\n                    <sub><b>vpzomtrrfrt</b></sub>\n                </a>\n            </td>\n            <td align=\"center\">\n                <a href=\"https://github.com/lilfade\">\n                    <img src=\"https://avatars.githubusercontent.com/u/4168401?v=4\" width=\"100;\" alt=\"lilfade\"/>\n                    <br />\n                    <sub><b>Bryson</b></sub>\n                </a>\n            </td>\n            <td align=\"center\">\n                <a href=\"https://github.com/comradekingu\">\n                    <img src=\"https://avatars.githubusercontent.com/u/13802408?v=4\" width=\"100;\" alt=\"comradekingu\"/>\n                    <br />\n                    <sub><b>Allan Nordhøy</b></sub>\n                </a>\n            </td>\n            <td align=\"center\">\n                <a href=\"https://github.com/CSDUMMI\">\n                    <img src=\"https://avatars.githubusercontent.com/u/31551856?v=4\" width=\"100;\" alt=\"CSDUMMI\"/>\n                    <br />\n                    <sub><b>CSDUMMI</b></sub>\n                </a>\n            </td>\n\t\t</tr>\n\t\t<tr>\n            <td align=\"center\">\n                <a href=\"https://github.com/e-michalak\">\n                    <img src=\"https://avatars.githubusercontent.com/u/236532325?v=4\" width=\"100;\" alt=\"e-michalak\"/>\n                    <br />\n                    <sub><b>e-michalak</b></sub>\n                </a>\n            </td>\n            <td align=\"center\">\n                <a href=\"https://github.com/MHLoppy\">\n                    <img src=\"https://avatars.githubusercontent.com/u/12670674?v=4\" width=\"100;\" alt=\"MHLoppy\"/>\n                    <br />\n                    <sub><b>Mark Heath</b></sub>\n                </a>\n            </td>\n            <td align=\"center\">\n                <a href=\"https://github.com/robertolopezlopez\">\n                    <img src=\"https://avatars.githubusercontent.com/u/1519467?v=4\" width=\"100;\" alt=\"robertolopezlopez\"/>\n                    <br />\n                    <sub><b>Roberto López López</b></sub>\n                </a>\n            </td>\n            <td align=\"center\">\n                <a href=\"https://github.com/grahhnt\">\n                    <img src=\"https://avatars.githubusercontent.com/u/46821216?v=4\" width=\"100;\" alt=\"grahhnt\"/>\n                    <br />\n                    <sub><b>grahhnt</b></sub>\n                </a>\n            </td>\n            <td align=\"center\">\n                <a href=\"https://github.com/olorin99\">\n                    <img src=\"https://avatars.githubusercontent.com/u/36951539?v=4\" width=\"100;\" alt=\"olorin99\"/>\n                    <br />\n                    <sub><b>olorin99</b></sub>\n                </a>\n            </td>\n            <td align=\"center\">\n                <a href=\"https://github.com/privacyguard\">\n                    <img src=\"https://avatars.githubusercontent.com/u/92675882?v=4\" width=\"100;\" alt=\"privacyguard\"/>\n                    <br />\n                    <sub><b>privacyguard</b></sub>\n                </a>\n            </td>\n\t\t</tr>\n\t<tbody>\n</table>\n<!-- readme: contributors -end -->\n\n## Getting Started\n\n### Documentation\n\nSee [docs.joinmbin.org](https://docs.joinmbin.org)\n\n### Requirements\n\n[See also Symfony requirements](https://symfony.com/doc/current/setup.html#technical-requirements)\n\n- PHP version: 8.2 or higher\n- GD or Imagemagick PHP extension\n- NGINX / Apache / Caddy\n- PostgreSQL\n- RabbitMQ\n- Valkey / KeyDB / Redis\n- Mercure (optional)\n\n## Languages\n\nFollowing languages are currently supported/translated:\n\n- Bulgarian\n- Catalan\n- Chinese\n- Danish\n- Dutch\n- English\n- Esperanto\n- Filipino\n- French\n- Galician\n- German\n- Greek\n- Italian\n- Japanese\n- Polish\n- Portuguese\n- Portuguese (Brazil)\n- Russian\n- Spanish\n- Turkish\n- Ukrainian\n\n## Credits\n\n- [grumpyDev](https://karab.in/u/grumpyDev): icons, kbin-theme\n- [Emma](https://codeberg.org/LItiGiousemMA/Postmill): Postmill\n- [Ernest](https://github.com/ernestwisniewski): Kbin\n\n## License\n\n[AGPL-3.0 license](LICENSE)\n"
  },
  {
    "path": "UPGRADE.md",
    "content": "# Upgrade\n\n## Bare Metal / VM Upgrade\n\nIf you perform a mbin upgrade (eg. `git pull`), be aware to _always_ execute the following Bash script:\n\n```bash\n./bin/post-upgrade\n```\n\nAnd when needed also execute: `sudo redis-cli FLUSHDB` to get rid of Redis cache issues. And reload the PHP FPM service if you have OPCache enabled.\n\n## Docker Upgrade\n\n> [!Note]\n> When you're using the [Docker v2 guide](docker/v2/), then the database migration is executed during the Docker container start-up.\n\n```bash\n$ docker compose exec php bin/console cache:clear\n$ docker compose exec redis redis-cli\n> auth REDIS_PASSWORD\n> FLUSHDB\n```\n"
  },
  {
    "path": "assets/app.js",
    "content": "import './stimulus_bootstrap.js';\nimport './styles/app.scss';\nimport './utils/popover.js';\nimport '@github/markdown-toolbar-element';\nimport { Application } from '@hotwired/stimulus';\n\nif ('serviceWorker' in navigator) {\n    window.addEventListener('load', function() {\n        navigator.serviceWorker.register('/sw.js');\n    });\n}\n\n// start the Stimulus application\nApplication.start();\n"
  },
  {
    "path": "assets/controllers/autogrow_controller.js",
    "content": "import TextareaAutoGrow from 'stimulus-textarea-autogrow';\n\n/* stimulusFetch: 'lazy' */\nexport default class extends TextareaAutoGrow {\n\n    connect() {\n        super.connect();\n    }\n}\n"
  },
  {
    "path": "assets/controllers/clipboard_controller.js",
    "content": "import { Controller } from '@hotwired/stimulus';\n\n/* stimulusFetch: 'lazy' */\nexport default class extends Controller {\n    copy(event) {\n        event.preventDefault();\n\n        const url = event.target.href;\n        navigator.clipboard.writeText(url);\n    }\n}\n"
  },
  {
    "path": "assets/controllers/collapsable_controller.js",
    "content": "import { Controller } from '@hotwired/stimulus';\nimport debounce from '../utils/debounce';\n\n// use some buffer-space so that the expand-button won't be included if just a couple of lines would be hidden\nconst MAX_COLLAPSED_HEIGHT_REM = 25;\nconst MAX_FULL_HEIGHT_REM = 28;\n\n/* stimulusFetch: 'lazy' */\nexport default class extends Controller {\n\n    static targets = ['content', 'button'];\n\n    maxCollapsedHeightPx = 0;\n    maxFullHeightPx = 0;\n\n    isActive = false;\n    isExpanded = true;\n    button = null;\n    buttonIcon = null;\n\n    connect() {\n        const remConvert = parseFloat(getComputedStyle(document.documentElement).fontSize);\n        this.maxCollapsedHeightPx = MAX_COLLAPSED_HEIGHT_REM * remConvert;\n        this.maxFullHeightPx = MAX_FULL_HEIGHT_REM * remConvert;\n\n        this.setup();\n\n        const observerDebounced = debounce(200, () => {\n            this.setup();\n        });\n        const observer = new ResizeObserver(observerDebounced);\n        observer.observe(this.contentTarget);\n    }\n\n    setup() {\n        const activate = this.checkSize();\n        if (activate === this.isActive) {\n            return;\n        }\n\n        if (activate) {\n            this.setupButton();\n            this.setExpanded(false, true);\n        } else {\n            this.contentTarget.style.maxHeight = null;\n            this.button.remove();\n        }\n\n        this.isActive = activate;\n    }\n\n    checkSize() {\n        const elem = this.contentTarget;\n        return elem.scrollHeight - 30 > this.maxFullHeightPx || elem.scrollWidth > elem.clientWidth;\n    }\n\n    setupButton() {\n        this.buttonIcon = document.createElement('i');\n        this.buttonIcon.classList.add('fa-solid', 'fa-angles-down');\n\n        this.button = document.createElement('div');\n        this.button.classList.add('more');\n        this.button.appendChild(this.buttonIcon);\n\n        this.button.addEventListener('click', () => {\n            this.setExpanded(!this.isExpanded, false);\n        });\n\n        this.buttonTarget.appendChild(this.button);\n    }\n\n    setExpanded(expanded, skipEffects) {\n        if (expanded) {\n            this.contentTarget.style.maxHeight = null;\n            this.buttonIcon.classList.remove('fa-angles-down');\n            this.buttonIcon.classList.add('fa-angles-up');\n        } else {\n            this.contentTarget.style.maxHeight = `${MAX_COLLAPSED_HEIGHT_REM}rem`;\n            this.buttonIcon.classList.remove('fa-angles-up');\n            this.buttonIcon.classList.add('fa-angles-down');\n\n            if (!skipEffects) {\n                this.contentTarget.scrollIntoView();\n            }\n        }\n\n        this.isExpanded = expanded;\n    }\n}\n"
  },
  {
    "path": "assets/controllers/comment_collapse_controller.js",
    "content": "import { Controller } from '@hotwired/stimulus';\nimport { getLevel } from '../utils/mbin';\n\nconst COMMENT_ELEMENT_TAG = 'BLOCKQUOTE';\nconst COLLAPSIBLE_CLASS = 'collapsible';\nconst COLLAPSED_CLASS = 'collapsed';\nconst HIDDEN_CLASS = 'hidden';\n\n/* stimulusFetch: 'lazy' */\nexport default class extends Controller {\n    static values = {\n        depth: Number,\n        hiddenBy: Number,\n    };\n    static targets = ['counter'];\n\n    connect() {\n        // derive depth value if it doesn't exist\n        // or when attached depth is 1 but css depth says otherwise (trying to handle dynamic list)\n        const cssLevel = getLevel(this.element);\n        if (!this.hasDepthValue\n            || (1 === this.depthValue && cssLevel > this.depthValue)) {\n            this.depthValue = cssLevel;\n        }\n\n        this.element.classList.add(COLLAPSIBLE_CLASS);\n        this.element.collapse = this;\n    }\n\n    // main function, use this in action\n    toggleCollapse(event) {\n        event.preventDefault();\n\n        for (\n            var nextSibling = this.element.nextElementSibling, collapsed = 0;\n            nextSibling && COMMENT_ELEMENT_TAG === nextSibling.tagName;\n            nextSibling = nextSibling.nextElementSibling\n        ) {\n            const siblingDepth = nextSibling.dataset.commentCollapseDepthValue;\n            if (!siblingDepth || siblingDepth <= this.depthValue) {\n                break;\n            }\n\n            this.toggleHideSibling(nextSibling, this.depthValue);\n            collapsed += 1;\n        }\n\n        this.toggleCollapseSelf();\n\n        if (0 < collapsed) {\n            this.updateCounter(collapsed);\n        }\n    }\n\n    // signals sibling comment element to hide itself\n    toggleHideSibling(element, collapserDepth) {\n        if (!element.collapse.hasHiddenByValue) {\n            element.collapse.hiddenByValue = collapserDepth;\n        } else if (collapserDepth === element.collapse.hiddenByValue) {\n            element.collapse.hiddenByValue = undefined;\n        }\n    }\n\n    // put itself into collapsed state\n    toggleCollapseSelf() {\n        this.element.classList.toggle(COLLAPSED_CLASS);\n    }\n\n    updateCounter(count) {\n        if (!this.hasCounterTarget) {\n            return;\n        }\n\n        if (this.element.classList.contains(COLLAPSED_CLASS)) {\n            this.counterTarget.innerText = `(${count})`;\n        } else {\n            this.counterTarget.innerText = '';\n        }\n    }\n\n    // using value changed callback to enforce proper state appearance\n\n    // existence of hidden-by value means this comment is in hidden state\n    // (basically display: none)\n    hiddenByValueChanged() {\n        if (this.hasHiddenByValue) {\n            this.element.classList.add(HIDDEN_CLASS);\n        } else {\n            this.element.classList.remove(HIDDEN_CLASS);\n        }\n    }\n}\n"
  },
  {
    "path": "assets/controllers/confirmation_controller.js",
    "content": "import { Controller } from '@hotwired/stimulus';\n\n/* stimulusFetch: 'lazy' */\nexport default class extends Controller {\n    ask(event) {\n        if (!window.confirm(event.params.message)) {\n            event.preventDefault();\n            event.stopImmediatePropagation();\n        }\n    }\n}\n"
  },
  {
    "path": "assets/controllers/entry_link_create_controller.js",
    "content": "import { ApplicationController, useThrottle } from 'stimulus-use';\nimport { fetch, ok } from '../utils/http';\nimport router from '../utils/routing';\n\n/* stimulusFetch: 'lazy' */\nexport default class extends ApplicationController {\n    static throttles = ['fetchLink'];\n    static targets = ['title', 'description', 'url', 'loader'];\n    static values = {\n        loading: Boolean,\n    };\n\n    timeoutId = null;\n\n    connect() {\n        useThrottle(this, {\n            wait: 1000,\n        });\n\n        const params = new URLSearchParams(window.location.search);\n        const url = params.get('url');\n        if (url) {\n            this.urlTarget.value = url;\n            this.urlTarget.dispatchEvent(new Event('input'));\n        }\n    }\n\n    fetchLink(event) {\n        if (!event.target.value) {\n            return;\n        }\n\n        if (this.timeoutId) {\n            window.clearTimeout(this.timeoutId);\n            this.timeoutId = null;\n        }\n\n        this.timeoutId = window.setTimeout(() => {\n            this.loadingValue = true;\n            this.fetchTitleAndDescription(event)\n                .then(() => {\n                    this.loadingValue = false;\n                    this.timeoutId = null;\n                })\n                .catch(() => {\n                    this.loadingValue = false;\n                    this.timeoutId = null;\n                });\n        }, 1000);\n    }\n\n    loadingValueChanged(val) {\n        this.titleTarget.disabled = val;\n        this.descriptionTarget.disabled = val;\n\n        if (val) {\n            this.loaderTarget.classList.remove('hide');\n        } else {\n            this.loaderTarget.classList.add('hide');\n        }\n    }\n\n    async fetchTitleAndDescription(event) {\n        if (this.titleTarget.value && false === confirm('Are you sure you want to fetch the title and description? This will overwrite the current values.')) {\n            return;\n        }\n\n        const url = router().generate('ajax_fetch_title');\n        let response = await fetch(url, {\n            method: 'POST',\n            body: JSON.stringify({\n                'url': event.target.value,\n            }),\n        });\n\n        response = await ok(response);\n        response = await response.json();\n\n        this.titleTarget.value = response.title;\n        this.descriptionTarget.value = response.description;\n\n        // required for input length indicator\n        this.titleTarget.dispatchEvent(new Event('input'));\n        this.descriptionTarget.dispatchEvent(new Event('input'));\n    }\n}\n"
  },
  {
    "path": "assets/controllers/form_collection_controller.js",
    "content": "import { Controller } from '@hotwired/stimulus';\n\n/* stimulusFetch: 'lazy' */\nexport default class extends Controller {\n    static targets = ['collectionContainer'];\n\n    static values = {\n        index    : Number,\n        prototype: String,\n    };\n\n    addCollectionElement() {\n        const item = document.createElement('div');\n        item.innerHTML = this.prototypeValue.replace(/__name__/g, this.indexValue);\n        this.collectionContainerTarget.appendChild(item);\n        this.indexValue++;\n    }\n}\n"
  },
  {
    "path": "assets/controllers/html_refresh_controller.js",
    "content": "import { fetch, ok } from '../utils/http';\nimport { Controller } from '@hotwired/stimulus';\n\n/* stimulusFetch: 'lazy' */\nexport default class extends Controller {\n    /**\n     * Calls the address attached to the nearest link node. Replaces the outer html of the nearest `cssclass` parameter\n     * with the response from the link\n     */\n    async linkCallback(event) {\n        event.preventDefault();\n        const { cssclass: cssClass, refreshlink: refreshLink, refreshselector: refreshSelector } = event.params;\n\n        const a = event.target.closest('a');\n        const subjectController = this.application.getControllerForElementAndIdentifier(this.element, 'subject');\n\n        try {\n            if (subjectController) {\n                subjectController.loadingValue = true;\n            }\n\n            let response = await fetch(a.href);\n\n            response = await ok(response);\n            response = await response.json();\n\n            event.target.closest(`.${cssClass}`).outerHTML = response.html;\n\n            const refreshElement = this.element.querySelector(refreshSelector);\n\n            if (!!refreshLink && '' !== refreshLink && !!refreshElement) {\n                let response = await fetch(refreshLink);\n\n                response = await ok(response);\n                response = await response.json();\n                refreshElement.outerHTML = response.html;\n            }\n        } catch (e) {\n            console.error(e);\n        } finally {\n            if (subjectController) {\n                subjectController.loadingValue = false;\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "assets/controllers/image_upload_controller.js",
    "content": "import { Controller } from '@hotwired/stimulus';\n\n/* stimulusFetch: 'lazy' */\nexport default class extends Controller {\n    connect() {\n        const container = this.element;\n        const input = container.querySelector('.image-input');\n        const preview = container.querySelector('.image-preview');\n        const clearButton = container.querySelector('.image-preview-clear');\n\n        input.addEventListener('change', function(e) {\n            const file = e.target.files[0];\n            const reader = new FileReader();\n\n            reader.onload = function(e) {\n                preview.src = e.target.result;\n                preview.style.display = 'block';\n                clearButton.setAttribute('style', 'display: inline-block !important');\n            };\n\n            reader.readAsDataURL(file);\n        });\n    }\n\n    clearPreview() {\n        const container = this.element;\n        const input = container.querySelector('.image-input');\n        const preview = container.querySelector('.image-preview');\n        const clearButton = container.querySelector('.image-preview-clear');\n\n        input.value = '';\n        preview.src = '#';\n        preview.style.display = 'none';\n        clearButton.style.display = 'none';\n    }\n}\n"
  },
  {
    "path": "assets/controllers/infinite_scroll_controller.js",
    "content": "import { fetch, ok } from '../utils/http';\nimport { Controller } from '@hotwired/stimulus';\nimport { useIntersection } from 'stimulus-use';\n\n/* stimulusFetch: 'lazy' */\nexport default class extends Controller {\n    static targets = ['loader', 'pagination'];\n    static values = {\n        loading: Boolean,\n    };\n\n    connect() {\n        window.infiniteScrollUrls = [];\n        useIntersection(this);\n    }\n\n    async appear() {\n        if (true === this.loadingValue) {\n            return;\n        }\n\n        try {\n            this.loadingValue = true;\n\n            const cursorPaginationElement = this.paginationTarget.getElementsByClassName('cursor-pagination');\n            let paginationElem = null;\n            if (cursorPaginationElement.length) {\n                const button = cursorPaginationElement[0].getElementsByClassName('next');\n                if (!button.length) {\n                    throw new Error('No more pages');\n                }\n                paginationElem = button[0];\n            } else {\n                paginationElem = this.paginationTarget.getElementsByClassName('pagination__item--current-page')[0].nextElementSibling;\n                if (paginationElem.classList.contains('pagination__item--disabled')) {\n                    throw new Error('No more pages');\n                }\n            }\n\n            if (window.infiniteScrollUrls.includes(paginationElem.href)) {\n                return;\n            }\n\n            window.infiniteScrollUrls.push(paginationElem.href);\n\n            this.handleEntries(paginationElem.href);\n        } catch {\n            this.loadingValue = false;\n            this.showPagination();\n        }\n    }\n\n    async handleEntries(url) {\n        let response = await fetch(url, { method: 'GET' });\n\n        response = await ok(response);\n\n        try {\n            response = await response.json();\n        } catch {\n            this.showPagination();\n            throw new Error('Invalid JSON response');\n        }\n\n        const div = document.createElement('div');\n        div.innerHTML = response.html;\n\n        const elements = div.querySelectorAll('[data-controller=\"subject-list\"] > *');\n        for (let i = 0; i < elements.length; i++) {\n            const element = elements[i];\n            if ((element.id && null === document.getElementById(element.id)) || element.classList.contains('user-box-inline') || element.classList.contains('magazine') || element.classList.contains('post-container')) {\n                this.element.before(element);\n\n                if (elements[i + 1] && elements[i + 1].classList.contains('post-comments')) {\n                    this.element.before(elements[i + 1]);\n                }\n            }\n        }\n\n        const scroll = div.querySelector('[data-controller=\"infinite-scroll\"]');\n        if (scroll) {\n            this.element.after(div.querySelector('[data-controller=\"infinite-scroll\"]'));\n        }\n\n        this.element.remove();\n\n        this.application\n            .getControllerForElementAndIdentifier(document.getElementById('main'), 'lightbox')\n            .connect();\n        this.application\n            .getControllerForElementAndIdentifier(document.getElementById('main'), 'timeago')\n            .connect();\n    }\n\n    loadingValueChanged(val) {\n        this.loaderTarget.style.display = true === val ? 'block' : 'none';\n    }\n\n    showPagination() {\n        this.loadingValue = false;\n        this.paginationTarget.classList.remove('visually-hidden');\n    }\n}\n"
  },
  {
    "path": "assets/controllers/input_length_controller.js",
    "content": "import { Controller } from '@hotwired/stimulus';\n\n/* stimulusFetch: 'lazy' */\nexport default class extends Controller {\n    static values = {\n        max: Number,\n    };\n\n    /** DOM element that will hold the current/max text */\n    lengthIndicator;\n\n    connect() {\n        if (!this.hasMaxValue) {\n            return;\n        }\n\n        //create a html element to display the current/max text\n        const indicator = document.createElement('div');\n\n        indicator.classList.add('length-indicator');\n\n        this.element.insertAdjacentElement('afterend', indicator);\n\n        this.lengthIndicator = indicator;\n\n        this.updateDisplay();\n    }\n\n    updateDisplay() {\n        if (!this.lengthIndicator) {\n            return;\n        }\n\n        //trim to max length if needed\n        if (this.element.value.length >= this.maxValue) {\n            this.element.value = this.element.value.substring(0, this.maxValue);\n        }\n\n        //display to user\n        this.lengthIndicator.innerHTML = `${this.element.value.length}/${this.maxValue}`;\n    }\n}\n"
  },
  {
    "path": "assets/controllers/lightbox_controller.js",
    "content": "import { Controller } from '@hotwired/stimulus';\nimport GLightbox from 'glightbox';\n\n/* stimulusFetch: 'lazy' */\nexport default class extends Controller {\n    connect() {\n        const params = {\n            selector: '.thumb',\n            openEffect: 'none',\n            closeEffect: 'none',\n            touchNavigation: true,\n        };\n        GLightbox(params);\n    }\n}\n"
  },
  {
    "path": "assets/controllers/markdown_toolbar_controller.js",
    "content": "// SPDX-FileCopyrightText: 2023-2024 /kbin & Mbin contributors\n//\n// SPDX-License-Identifier: AGPL-3.0-only\n\nimport 'emoji-picker-element';\nimport { autoUpdate, computePosition, flip, limitShift, shift } from '@floating-ui/dom';\nimport { Controller } from '@hotwired/stimulus';\n\n/* stimulusFetch: 'lazy' */\nexport default class extends Controller {\n    addSpoiler(event) {\n        event.preventDefault();\n\n        const input = document.getElementById(this.element.getAttribute('for'));\n        let spoilerBody = 'spoiler body';\n        let contentAfterCursor;\n\n        const start = input.selectionStart;\n        const end = input.selectionEnd;\n\n        const contentBeforeCursor = input.value.substring(0, start);\n        if (start === end) {\n            contentAfterCursor = input.value.substring(start);\n        } else {\n            contentAfterCursor = input.value.substring(end);\n            spoilerBody = input.value.substring(start, end);\n        }\n\n        const spoiler = `\n::: spoiler spoiler-title\n${spoilerBody}\n:::`;\n\n        input.value = contentBeforeCursor + spoiler + contentAfterCursor;\n        input.dispatchEvent(new Event('input'));\n\n        const spoilerTitlePosition = contentBeforeCursor.length + '::: spoiler '.length + 1;\n        input.setSelectionRange(spoilerTitlePosition, spoilerTitlePosition);\n        input.focus();\n    }\n\n    toggleEmojiPicker(event) {\n        event.preventDefault();\n\n        const button = event.currentTarget;\n        const input = document.getElementById(this.element.getAttribute('for'));\n        const tooltip = document.querySelector('#tooltip');\n        const emojiPicker = document.querySelector('#emoji-picker');\n\n        // Remove any existing event listener\n        if (this.emojiClickHandler) {\n            emojiPicker.removeEventListener('emoji-click', this.emojiClickHandler);\n        }\n\n        if (!this.cleanupTooltip) {\n            this.cleanupTooltip = autoUpdate(button, tooltip, () => {\n                computePosition(button, tooltip, {\n                    placement: 'bottom',\n                    middleware: [flip(), shift({ limiter: limitShift() })],\n                }).then(({ x, y }) => {\n                    Object.assign(tooltip.style, {\n                        left: `${x}px`,\n                        top: `${y}px`,\n                    });\n                });\n            });\n        }\n\n        tooltip.classList.toggle('shown');\n\n        if (tooltip.classList.contains('shown')) {\n            this.emojiClickHandler = (event) => {\n                const emoji = event.detail.emoji.unicode;\n                const start = input.selectionStart;\n                const end = input.selectionEnd;\n\n                input.value = input.value.slice(0, start) + emoji + input.value.slice(end);\n                const emojiPosition = start + emoji.length;\n                input.setSelectionRange(emojiPosition, emojiPosition);\n                input.focus();\n\n                tooltip.classList.remove('shown');\n                this.cleanupTooltip();\n                emojiPicker.removeEventListener('emoji-click', this.emojiClickHandler);\n                this.emojiClickHandler = null;\n            };\n\n            emojiPicker.addEventListener('emoji-click', this.emojiClickHandler);\n        }\n    }\n}\n"
  },
  {
    "path": "assets/controllers/mbin_controller.js",
    "content": "import { ApplicationController, useDebounce } from 'stimulus-use';\n\n/* stimulusFetch: 'lazy' */\nexport default class extends ApplicationController {\n    static values = {\n        loading: Boolean,\n    };\n\n    static debounces = ['mention'];\n\n    connect() {\n        useDebounce(this, { wait: 800 });\n        this.handleDropdowns();\n        this.handleOptionsBarScroll();\n    }\n\n    handleDropdowns() {\n        this.element.querySelectorAll('.dropdown > a').forEach((dropdown) => {\n            dropdown.addEventListener('click', (event) => {\n                event.preventDefault();\n            });\n        });\n    }\n\n    handleOptionsBarScroll() {\n        const container = document.getElementById('options');\n        if (container) {\n            const containerWidth = container.clientWidth;\n            const area = container.querySelector('.options__main');\n\n            if (null === area) {\n                return;\n            }\n\n            const areaWidth = area.scrollWidth;\n\n            if (areaWidth > containerWidth && !area.nextElementSibling) {\n                container.insertAdjacentHTML('beforeend', '<menu class=\"scroll\"><li class=\"scroll-left\"><i class=\"fa-solid fa-circle-left\"></i></li><li class=\"scroll-right\"><i class=\"fa-solid fa-circle-right\"></i></li></menu>');\n\n                const scrollLeft = container.querySelector('.scroll-left');\n                const scrollRight = container.querySelector('.scroll-right');\n                const scrollArea = container.querySelector('.options__main');\n\n                scrollRight.addEventListener('click', () => {\n                    scrollArea.scrollLeft += 100;\n                });\n                scrollLeft.addEventListener('click', () => {\n                    scrollArea.scrollLeft -= 100;\n                });\n            }\n        }\n    }\n\n    /**\n     * Handles interaction with the mobile nav button, opening the sidebar\n     */\n    handleNavToggleClick() {\n        const sidebar = document.getElementById('sidebar');\n        sidebar.classList.toggle('open');\n    }\n\n    changeLang(event) {\n        window.location.href = '/settings/theme/mbin_lang/' + event.target.value;\n    }\n}\n"
  },
  {
    "path": "assets/controllers/mentions_controller.js",
    "content": "import { fetch, ok } from '../utils/http';\nimport { Controller } from '@hotwired/stimulus';\nimport router from '../utils/routing';\n\n/* stimulusFetch: 'lazy' */\nexport default class extends Controller {\n\n    /**\n     * Instance of setTimeout to be used for the display of the popup. This is cleared if the user\n     * exits the target before the delay is reached\n     */\n    userPopupTimeout;\n\n    /**\n     * Delay to wait until the popup is displayed\n     */\n    userPopupTimeoutDelay = 1200;\n\n    /**\n     * Called on mouseover\n     * @param {*} event\n     * @returns\n     */\n    async userPopup(event) {\n\n        if (false === event.target.matches(':hover')) {\n            return;\n        }\n\n        //create a setTimeout callback to be executed when the user has hovered over the target for a set amount of time\n        this.userPopupTimeout = setTimeout(this.triggerUserPopup, this.userPopupTimeoutDelay, event);\n    }\n\n    /**\n     * Called on mouseout, cancel the UI popup as the user has moved off the element\n     */\n    async userPopupOut() {\n        clearTimeout(this.userPopupTimeout);\n    }\n\n    /**\n     * Called when the user popup should open\n     */\n    async triggerUserPopup(event) {\n\n        try {\n            let param = event.params.username;\n\n            if ('@' === param.charAt(0)) {\n                param = param.substring(1);\n            }\n            const username = param.includes('@') ? `@${param}` : param;\n            const url = router().generate('ajax_fetch_user_popup', { username: username });\n\n            this.loadingValue = true;\n\n            let response = await fetch(url);\n\n            response = await ok(response);\n            response = await response.json();\n\n            document.querySelector('.popover').innerHTML = response.html;\n\n            popover.trigger = event.target;\n            popover.selectedTrigger = event.target;\n            popover.element.dispatchEvent(new Event('openPopover'));\n        } catch {\n        } finally {\n            this.loadingValue = false;\n        }\n    }\n\n    async navigateUser(event) {\n        event.preventDefault();\n\n        window.location = '/u/' + event.params.username;\n    }\n\n    async navigateMagazine(event) {\n        event.preventDefault();\n\n        window.location = '/m/' + event.params.username;\n    }\n}\n"
  },
  {
    "path": "assets/controllers/notifications_controller.js",
    "content": "import { ThrowResponseIfNotOk, fetch } from '../utils/http';\nimport { Controller } from '@hotwired/stimulus';\nimport Subscribe from '../utils/event-source';\n\n/* stimulusFetch: 'lazy' */\nexport default class extends Controller {\n    static values = {\n        endpoint: String,\n        user: String,\n        magazine: String,\n        entryId: String,\n        postId: String,\n    };\n\n    connect() {\n        if (this.endpointValue) {\n            this.connectEs(this.endpointValue, this.getTopics());\n\n            window.addEventListener('pagehide', this.closeEs);\n        }\n        if (this.userValue) {\n            this.fetchAndSetNewNotificationAndMessageCount();\n        }\n    }\n\n    disconnect() {\n        this.closeEs();\n    }\n\n    connectEs(endpoint, topics) {\n        this.closeEs();\n\n        const cb = (e) => {\n            const data = JSON.parse(e.data);\n\n            this.dispatch(data.op, { detail: data });\n\n            this.dispatch('Notification', { detail: data });\n\n            // if (data.op.includes('Create')) {\n            //     self.dispatch('CreatedNotification', {detail: data});\n            // }\n\n            // if (data.op === 'EntryCreatedNotification' || data.op === 'PostCreatedNotification') {\n            //     self.dispatch('MainSubjectCreatedNotification', {detail: data});\n            // }\n            //\n        };\n\n        const eventSource = Subscribe(endpoint, topics, cb);\n        if (eventSource) {\n            window.es = eventSource;\n            // firefox bug: https://bugzilla.mozilla.org/show_bug.cgi?id=1803431\n            if (navigator.userAgent.toLowerCase().includes('firefox')) {\n                const resubscribe = () => {\n                    window.es.close();\n                    setTimeout(() => {\n                        const eventSource = Subscribe(endpoint, topics, cb);\n                        if (eventSource) {\n                            window.es = eventSource;\n                            window.es.onerror = resubscribe;\n                        }\n                    }, 10000);\n                };\n                window.es.onerror = resubscribe;\n            }\n        }\n    }\n\n    closeEs() {\n        if (window.es instanceof EventSource) {\n            window.es.close();\n        }\n    }\n\n    getTopics() {\n        let pub = true;\n        const topics = [\n            'count',\n        ];\n\n        if (this.userValue) {\n            topics.push(`/api/users/${this.userValue}`);\n            pub = true;\n        }\n\n        if (this.magazineValue) {\n            topics.push(`/api/magazines/${this.magazineValue}`);\n            pub = false;\n        }\n\n        if (this.entryIdValue) {\n            topics.push(`/api/entries/${this.entryIdValue}`);\n            pub = false;\n        }\n\n        if (this.postIdValue) {\n            topics.push(`/api/posts/${this.postIdValue}`);\n            pub = false;\n        }\n\n        if (pub) {\n            topics.push('pub');\n        }\n\n        return topics;\n    }\n\n    fetchAndSetNewNotificationAndMessageCount() {\n        fetch('/ajax/fetch_user_notifications_count')\n            .then(ThrowResponseIfNotOk)\n            .then((data) => {\n                if ('number' === typeof data.notifications) {\n                    this.setNotificationCount(data.notifications);\n                }\n                if ('number' === typeof data.messages) {\n                    this.setMessageCount(data.messages);\n                }\n                window.setTimeout(() => this.fetchAndSetNewNotificationAndMessageCount(), 30 * 1000);\n            });\n    }\n\n    /**\n     * @param {number} count\n     */\n    setNotificationCount(count) {\n        const notificationHeader = self.window.document.getElementById('header-notification-count');\n        notificationHeader.style.display = count ? '' : 'none';\n        this.setCountInSubBadgeElement(notificationHeader, count);\n        const notificationDropdown = self.window.document.getElementById('dropdown-notifications-count');\n        this.setCountInSubBadgeElement(notificationDropdown, count);\n    }\n\n    /**\n     * @param {number} count\n     */\n    setMessageCount(count) {\n        const messagesHeader = self.window.document.getElementById('header-messages-count');\n        messagesHeader.style.display = count ? '' : 'none';\n        this.setCountInSubBadgeElement(messagesHeader, count);\n        const messageDropdown = self.window.document.getElementById('dropdown-messages-count');\n        this.setCountInSubBadgeElement(messageDropdown, count);\n    }\n\n    /**\n     * @param {Element} element\n     * @param {number} count\n     */\n    setCountInSubBadgeElement(element, count) {\n        const badgeElements = element.getElementsByClassName('badge');\n        for (let i = 0; i < badgeElements.length; i++) {\n            const el = badgeElements.item(i);\n            el.textContent = count.toString(10);\n            el.style.display = count ? '' : 'none';\n        }\n    }\n}\n"
  },
  {
    "path": "assets/controllers/options_controller.js",
    "content": "import { Controller } from '@hotwired/stimulus';\n\n/* stimulusFetch: 'lazy' */\nexport default class extends Controller {\n    static targets = ['settings', 'actions'];\n    static values = {\n        activeTab: String,\n    };\n\n    connect() {\n        const activeTabFragment = window.location.hash;\n\n        if (!activeTabFragment) {\n            return;\n        }\n\n        if ('#settings' !== activeTabFragment) {\n            return;\n        }\n\n        this.actionsTarget.querySelector(`a[href=\"${activeTabFragment}\"]`).classList.add('active');\n        this.activeTabValue = activeTabFragment.substring(1);\n    }\n\n    toggleTab(e) {\n        const selectedTab = e.params.tab;\n\n        this.actionsTarget.querySelectorAll('.active').forEach((el) => el.classList.remove('active'));\n\n        if (selectedTab === this.activeTabValue) {\n            this.activeTabValue = 'none';\n        } else {\n            this.activeTabValue = selectedTab;\n\n            e.currentTarget.classList.add('active');\n        }\n    }\n\n    activeTabValueChanged(selectedTab) {\n        if ('none' === selectedTab) {\n            this.settingsTarget.style.display = 'none';\n\n            return;\n        }\n\n        this[`${selectedTab}Target`].style.display = 'block';\n\n        // If you were to need to hide another tab:\n\n        //const otherTab = selectedTab === 'settings' ? 'federation' : 'settings';\n        //\n        //this[`${otherTab}Target`].style.display = 'none';\n    }\n\n    closeMobileSidebar() {\n        document.getElementById('sidebar').classList.remove('open');\n    }\n\n    appearanceReloadRequired(event) {\n        event.target.classList.add('spin');\n        window.location.reload();\n    }\n}\n"
  },
  {
    "path": "assets/controllers/password_preview_controller.js",
    "content": "import { Controller } from '@hotwired/stimulus';\n\n/* stimulusFetch: 'lazy' */\nexport default class extends Controller {\n\n    previewButton;\n\n    previewIcon;\n\n    input;\n\n    connect() {\n        this.input = this.element.querySelector('[type=\"password\"]');\n        //create the preview button\n        this.setupPasswordPreviewButton();\n    }\n\n    /**\n     * Create the preview button and bind its event listener\n     */\n    setupPasswordPreviewButton() {\n        const previewButton = document.createElement('div');\n        previewButton.classList.add('password-preview-button', 'btn', 'btn__secondary');\n        this.previewButton = previewButton;\n\n        const previewIcon = document.createElement('i');\n        previewIcon.classList.add('fas', 'fa-eye-slash');\n        this.previewIcon = previewIcon;\n\n        previewButton.append(previewIcon);\n        this.element.append(previewButton);\n\n        //setup event listener\n        previewButton.addEventListener('click', () => {\n            this.onPreviewButtonClick();\n        });\n    }\n\n    /**\n     * On press, switch out the input 'type' to show or hide the password\n     */\n    onPreviewButtonClick() {\n        const inputType = this.input.getAttribute('type');\n        if ('password' === inputType) {\n            this.input.setAttribute('type', 'text');\n            this.previewIcon.classList.remove('fa-eye-slash');\n            this.previewIcon.classList.add('fa-eye');\n\n        } else {\n            this.input.setAttribute('type', 'password');\n            this.previewIcon.classList.remove('fa-eye');\n            this.previewIcon.classList.add('fa-eye-slash');\n        }\n    }\n}\n"
  },
  {
    "path": "assets/controllers/post_controller.js",
    "content": "import { fetch, ok } from '../utils/http';\nimport { Controller } from '@hotwired/stimulus';\nimport getIntIdFromElement from '../utils/mbin';\nimport router from '../utils/routing';\n\n/* stimulusFetch: 'lazy' */\nexport default class extends Controller {\n    static targets = ['main', 'loader', 'expand', 'collapse', 'comments'];\n    static values = {\n        loading: Boolean,\n    };\n\n    async expandComments(event) {\n        event.preventDefault();\n\n        if (true === this.loadingValue) {\n            return;\n        }\n\n        try {\n            this.loadingValue = true;\n\n            const url = router().generate('ajax_fetch_post_comments', { 'id': getIntIdFromElement(this.mainTarget) });\n\n            let response = await fetch(url, { method: 'GET' });\n\n            response = await ok(response);\n            response = await response.json();\n\n            this.collapseComments();\n\n            this.commentsTarget.innerHTML = response.html;\n\n            if (this.commentsTarget.children.length && this.commentsTarget.children[0].classList.contains('comments')) {\n                const container = this.commentsTarget.children[0];\n                const parentDiv = container.parentNode;\n\n                while (container.firstChild) {\n                    parentDiv.insertBefore(container.firstChild, container);\n                }\n\n                parentDiv.removeChild(container);\n            }\n\n            this.expandTarget.style.display = 'none';\n            this.collapseTarget.style.display = 'block';\n            this.commentsTarget.style.display = 'block';\n\n            this.application\n                .getControllerForElementAndIdentifier(document.getElementById('main'), 'lightbox')\n                .connect();\n            this.application\n                .getControllerForElementAndIdentifier(document.getElementById('main'), 'timeago')\n                .connect();\n        } catch (e) {\n            console.error(e);\n        } finally {\n            this.loadingValue = false;\n        }\n    }\n\n    collapseComments(event) {\n        event?.preventDefault();\n\n        while (this.commentsTarget.firstChild) {\n            this.commentsTarget.removeChild(this.commentsTarget.firstChild);\n        }\n\n        this.expandTarget.style.display = 'block';\n        this.collapseTarget.style.display = 'none';\n        this.commentsTarget.style.display = 'none';\n    }\n\n    async expandVoters(event) {\n        event?.preventDefault();\n\n        try {\n            this.loadingValue = true;\n\n            let response = await fetch(event.target.href, { method: 'GET' });\n\n            response = await ok(response);\n            response = await response.json();\n\n            event.target.parentNode.innerHTML = response.html;\n        } catch (e) {\n            console.error(e);\n        } finally {\n            this.loadingValue = false;\n        }\n    }\n\n    loadingValueChanged(val) {\n        const subjectController = this.application.getControllerForElementAndIdentifier(this.mainTarget, 'subject');\n        if (null !== subjectController) {\n            subjectController.loadingValue = val;\n        }\n    }\n}\n"
  },
  {
    "path": "assets/controllers/preview_controller.js",
    "content": "import { fetch, ok } from '../utils/http';\nimport { Controller } from '@hotwired/stimulus';\nimport router from '../utils/routing';\nimport { useThrottle } from 'stimulus-use';\n\n/* stimulusFetch: 'lazy' */\nexport default class extends Controller {\n    static values = {\n        loading: Boolean,\n    };\n\n    static targets = ['container'];\n    static throttles = ['show'];\n\n    /** memoization of fetched embed response */\n    fetchedResponse = {};\n\n    connect() {\n        useThrottle(this, { wait: 1000 });\n\n        // workaround: give itself a container if it couldn't find one\n        // I am not happy with this\n        if (!this.hasContainerTarget && this.element.matches('span.preview')) {\n            const container = this.createContainerTarget('preview-target');\n            this.element.insertAdjacentElement('beforeend', container);\n            console.warn('unable to find container target, creating one for itself at', this.element.lastChild);\n        }\n    }\n\n    createContainerTarget(extraClasses) {\n        const classes = [].concat(extraClasses ?? []);\n\n        const div = document.createElement('div');\n        div.classList.add(...classes, 'hidden');\n        div.dataset.previewTarget = 'container';\n\n        return div;\n    }\n\n    async retry(event) {\n        event.preventDefault();\n\n        this.containerTarget.replaceChildren();\n        this.containerTarget.classList.add('hidden');\n\n        await this.show(event);\n    }\n\n    async fetchEmbed(url) {\n        if (this.fetchedResponse[url]) {\n            return this.fetchedResponse[url];\n        }\n\n        let response = await fetch(router().generate('ajax_fetch_embed', { url }), { method: 'GET' });\n\n        response = await ok(response);\n        response = await response.json();\n\n        this.fetchedResponse[url] = response;\n\n        return response;\n    }\n\n    async show(event) {\n        event.preventDefault();\n\n        if (this.containerTarget.hasChildNodes()) {\n            this.containerTarget.replaceChildren();\n            this.containerTarget.classList.add('hidden');\n            return;\n        }\n\n        try {\n            this.loadingValue = true;\n\n            const response = await this.fetchEmbed(event.params.url);\n\n            this.containerTarget.innerHTML = response.html;\n            this.containerTarget.classList.remove('hidden');\n            if (event.params.ratio) {\n                this.containerTarget\n                    .querySelector('.preview')\n                    .classList.add('ratio');\n            }\n            this.loadScripts(response.html);\n        } catch (e) {\n            console.error('preview failed: ', e);\n            const failedHtml =\n                `<div class=\"preview\">\n                    <a class=\"retry-failed\" href=\"#\"\n                        data-action=\"preview#retry\"\n                        data-preview-url-param=\"${event.params.url}\"\n                        data-preview-ratio-param=\"${event.params.ratio}\">\n                            Failed to load. Click here to retry.\n                    </a>\n                </div>`;\n            this.containerTarget.innerHTML = failedHtml;\n            this.containerTarget.classList.remove('hidden');\n        } finally {\n            this.loadingValue = false;\n        }\n    }\n\n    loadScripts(response) {\n        const tmp = document.createElement('div');\n        tmp.innerHTML = response;\n        const el = tmp.getElementsByTagName('script');\n\n        if (el.length) {\n            const script = document.createElement('script');\n            script.setAttribute('src', el[0].getAttribute('src'));\n            script.setAttribute('async', 'false');\n\n            // let exists = [...document.head.querySelectorAll('script')]\n            //     .filter(value => value.getAttribute('src') >= script.getAttribute('src'));\n            //\n            // if (exists.length) {\n            //     return;\n            // }\n\n            const head = document.head;\n            head.insertBefore(script, head.firstElementChild);\n        }\n    }\n\n    loadingValueChanged(val) {\n        const subject = this.element.closest('.subject');\n        if (null !== subject) {\n            const subjectController = this.application.getControllerForElementAndIdentifier(subject, 'subject');\n            subjectController.loadingValue = val;\n        }\n    }\n}\n"
  },
  {
    "path": "assets/controllers/push_controller.js",
    "content": "import { ThrowResponseIfNotOk, fetch } from '../utils/http';\nimport { Controller } from '@hotwired/stimulus';\n\nexport default class extends Controller {\n\n    applicationServerPublicKey;\n\n    connect() {\n        this.applicationServerPublicKey = this.element.dataset.applicationServerPublicKey;\n        window.navigator.serviceWorker.getRegistration()\n            .then((registration) => {\n                return registration?.pushManager.getSubscription();\n            })\n            .then((pushSubscription) => {\n                this.updateButtonVisibility(pushSubscription);\n            })\n            .catch((error) => {\n                console.error('There was an error in the service worker registration method', error);\n                this.element.style.display = 'none';\n            });\n\n        if (!('serviceWorker' in navigator)) {\n            // Service Worker isn't supported on this browser, disable or hide UI.\n            this.element.style.display = 'none';\n        }\n\n        if (!('PushManager' in window)) {\n            // Push isn't supported on this browser, disable or hide UI.\n            this.element.style.display = 'none';\n        }\n    }\n\n    updateButtonVisibility(pushSubscription) {\n        const registerBtn = document.getElementById('push-subscription-register-btn');\n        const unregisterBtn = document.getElementById('push-subscription-unregister-btn');\n        const testBtn = document.getElementById('push-subscription-test-btn');\n        if (pushSubscription) {\n            registerBtn.style.display = 'none';\n            testBtn.style.display = '';\n            unregisterBtn.style.display = '';\n        } else {\n            registerBtn.style.display = '';\n            testBtn.style.display = 'none';\n            unregisterBtn.style.display = 'none';\n        }\n    }\n\n    async retry() {\n\n    }\n\n    async show() {\n\n    }\n\n    askPermission() {\n        return new Promise(function (resolve, reject) {\n            const permissionResult = Notification.requestPermission(function (result) {\n                resolve(result);\n            });\n\n            if (permissionResult) {\n                permissionResult.then(resolve, reject);\n            }\n        })\n            .then(function (permissionResult) {\n                if ('granted' !== permissionResult) {\n                    throw new Error('We weren\\'t granted permission.');\n                }\n            });\n    }\n\n    registerPush() {\n        this.askPermission()\n            .then(() => window.navigator.serviceWorker.getRegistration())\n            .then((registration) => {\n                const subscribeOptions = {\n                    userVisibleOnly: true,\n                    applicationServerKey: this.applicationServerPublicKey,\n                };\n\n                return registration.pushManager.subscribe(subscribeOptions);\n            })\n            .then((pushSubscription) => {\n                this.updateButtonVisibility(pushSubscription);\n                const jsonSub = pushSubscription.toJSON();\n                const payload = {\n                    endpoint: pushSubscription.endpoint,\n                    deviceKey: this.getDeviceKey(),\n                    contentPublicKey: jsonSub.keys['p256dh'],\n                    serverKey: jsonSub.keys['auth'],\n                };\n                return fetch('/ajax/register_push', {\n                    method: 'post',\n                    body: JSON.stringify(payload),\n                    headers: { 'Content-Type': 'application/json' },\n                });\n            })\n            .then((response) => {\n                if (!response.ok) {\n                    throw response;\n                }\n                return response.json();\n            })\n            .catch((error) => {\n                console.error(error);\n                this.unregisterPush();\n            });\n    }\n\n    unregisterPush() {\n        window.navigator.serviceWorker.getRegistration()\n            .then((registration) => registration?.pushManager.getSubscription())\n            .then((pushSubscription) => pushSubscription.unsubscribe())\n            .then((successful) => {\n                if (successful) {\n                    this.updateButtonVisibility(null);\n                    const payload = {\n                        deviceKey: this.getDeviceKey(),\n                    };\n                    fetch('/ajax/unregister_push', {\n                        method: 'post',\n                        body: JSON.stringify(payload),\n                        headers: { 'Content-Type': 'application/json' },\n                    })\n                        .then(ThrowResponseIfNotOk)\n                        .then(() => {\n                        })\n                        .catch((error) => console.error(error));\n                }\n            })\n            .catch((error) => {\n                console.error('There was an error in the service worker registration method, for unsubscribing', error);\n            });\n    }\n\n    testPush() {\n        fetch('/ajax/test_push', { method: 'post', body: JSON.stringify({ deviceKey: this.getDeviceKey() }), headers: { 'Content-Type': 'application/json' } })\n            .then((response) => {\n                if (!response.ok) {\n                    throw response;\n                }\n                return response.json();\n            })\n            .then(() => { })\n            .catch((error) => console.error(error));\n    }\n\n    storageKeyPushSubscriptionDevice = 'push_subscription_device_key';\n\n    getDeviceKey() {\n        if (localStorage.getItem(this.storageKeyPushSubscriptionDevice)) {\n            return localStorage.getItem(this.storageKeyPushSubscriptionDevice);\n        }\n        const subscriptionKey = crypto.randomUUID();\n        localStorage.setItem(this.storageKeyPushSubscriptionDevice, subscriptionKey);\n        return subscriptionKey;\n    }\n}\n"
  },
  {
    "path": "assets/controllers/rich_textarea_controller.js",
    "content": "import { Controller } from '@hotwired/stimulus';\nimport { fetch } from '../utils/http';\n\n/* stimulusFetch: 'lazy' */\nexport default class extends Controller {\n    connect() {\n        this.element.addEventListener('keydown', this.handleInput.bind(this));\n        this.element.addEventListener('blur', this.delayedClearAutocomplete.bind(this));\n    }\n\n    // map: allowed enclosure key -> max repeats\n    enclosureKeys = {\n        '`': 1, '\"': 1, \"'\": 1,\n        '*': 2, '_': 2, '~': 2,\n    };\n\n    emojiAutocompleteActive = false;\n    mentionAutocompleteActive = false;\n\n    abortController;\n    requestActive = false;\n\n    selectedSuggestionIndex = 0;\n\n    handleInput (event) {\n        const hasSelection = this.element.selectionStart !== this.element.selectionEnd;\n        const key = event.key;\n\n        if (event.ctrlKey && 'Enter' === key) {\n            // ctrl + enter to submit form\n\n            this.element.form.submit();\n            event.preventDefault();\n        } else if (event.ctrlKey && 'b' === key) {\n            // ctrl + b to toggle bold\n\n            this.toggleFormattingEnclosure('**');\n            event.preventDefault();\n        } else if (event.ctrlKey && 'i' === key) {\n            // ctrl + i to toggle italic\n\n            this.toggleFormattingEnclosure('_');\n            event.preventDefault();\n        } else if (hasSelection && key in this.enclosureKeys) {\n            // toggle/cycle wrapping on selection texts\n\n            this.toggleFormattingEnclosure(key, this.enclosureKeys[key] ?? 1);\n            event.preventDefault();\n        } else if (!this.emojiAutocompleteActive && !this.mentionAutocompleteActive && ':' === key) {\n            this.emojiAutocompleteActive = true;\n        } else if (this.emojiAutocompleteActive && ('Escape' === key || ' ' === key)) {\n            this.clearAutocomplete();\n        } else if (!this.emojiAutocompleteActive && !this.mentionAutocompleteActive && '@' === key) {\n            this.mentionAutocompleteActive = true;\n        } else if (this.mentionAutocompleteActive && ('Escape' === key || ' ' === key)) {\n            this.clearAutocomplete();\n        } else if (this.mentionAutocompleteActive || this.emojiAutocompleteActive) {\n            if ('ArrowDown' === key || 'ArrowUp' === key) {\n                if ('ArrowDown' === key) {\n                    this.selectedSuggestionIndex = Math.min(this.getSuggestionElements().length-1, this.selectedSuggestionIndex + 1);\n                } else if ('ArrowUp' === key) {\n                    this.selectedSuggestionIndex = Math.max(0, this.selectedSuggestionIndex - 1);\n                }\n                this.markSelectedSuggestion();\n                event.preventDefault();\n            } else if ('Enter' === key) {\n                this.replaceAutocompleteSearchString(this.getSelectedSuggestionReplacement());\n                event.preventDefault();\n            } else {\n                this.fetchAutocompleteResults(this.getAutocompleteSearchString(key));\n            }\n        }\n    }\n\n    toggleFormattingEnclosure(encl, maxLength = 1) {\n        const start = this.element.selectionStart, end = this.element.selectionEnd;\n        const before = this.element.value.substring(0, start),\n            inner = this.element.value.substring(start, end),\n            after = this.element.value.substring(end);\n\n        // TODO: find a way to do undo-aware text manipulations that isn't deprecated like execCommand?\n        // it seems like specs never actually replaced it with anything unless i'm missing it\n\n        // remove enclosure when it's at the max\n        const finalEnclosure = encl.repeat(maxLength);\n        if (before.endsWith(finalEnclosure) && after.startsWith(finalEnclosure)) {\n            const outerStart = start - finalEnclosure.length,\n                outerEnd = end + finalEnclosure.length;\n\n            this.element.selectionStart = outerStart;\n            this.element.selectionEnd = outerEnd;\n\n            // no need for delete command as insertText should deletes selection by itself\n            // ref: https://developer.mozilla.org/en-US/docs/Web/API/Document/execCommand#inserttext\n            document.execCommand('insertText', false, inner);\n\n            this.element.selectionStart = start - finalEnclosure.length;\n            this.element.selectionEnd = end - finalEnclosure.length;\n        } else {\n            // add a new enclosure\n\n            document.execCommand('insertText', false, encl + inner + encl);\n\n            this.element.selectionStart = start + encl.length;\n            this.element.selectionEnd = end + encl.length;\n        }\n    }\n\n    delayedClearAutocomplete() {\n        window.setTimeout(() => this.clearAutocomplete(), 100);\n    }\n\n    clearAutocomplete() {\n        this.selectedSuggestionIndex = 0;\n        this.emojiAutocompleteActive = false;\n        this.mentionAutocompleteActive = false;\n        this.abortController.abort();\n        this.requestActive = false;\n        document.getElementById('user-suggestions')?.remove();\n        document.getElementById('emoji-suggestions')?.remove();\n    }\n\n    getAutocompleteSearchString(key) {\n        const [wordStart, wordEnd] = this.getAutocompleteSearchStringStartAndEnd();\n        let val = this.element.value.substring(wordStart, wordEnd+1);\n\n        if (1 === key.length) {\n            val += key;\n        }\n\n        return val;\n    }\n\n    getAutocompleteSearchStringStartAndEnd() {\n        const value = this.element.value;\n        const selection = this.element.selectionStart-1;\n        let cursor = selection;\n        const breakCharacters = ' \\n\\t*#?!';\n        while (0 < cursor) {\n            cursor--;\n            if (breakCharacters.includes(value[cursor])) {\n                cursor++;\n                break;\n            }\n        }\n        const wordStart = cursor;\n        cursor = selection;\n\n        while (cursor < value.length) {\n            cursor++;\n            if (breakCharacters.includes(value[cursor])) {\n                cursor--;\n                break;\n            }\n        }\n        const wordEnd = cursor;\n\n        return [wordStart, wordEnd];\n    }\n\n    fetchAutocompleteResults(searchText) {\n        if (this.requestActive) {\n            this.abortController.abort();\n        }\n\n        if (this.mentionAutocompleteActive) {\n            this.abortController = new AbortController();\n            this.requestActive = true;\n            fetch(`/ajax/fetch_users_suggestions/${searchText}`, { signal: this.abortController.signal })\n                .then((response) => response.json())\n                .then((data) => {\n                    this.fillSuggestions(data.html);\n                    this.requestActive = false;\n                })\n                .catch(() => {});\n        } else if (this.emojiAutocompleteActive) {\n            this.abortController = new AbortController();\n            this.requestActive = true;\n            fetch(`/ajax/fetch_emoji_suggestions?query=${searchText}`, { signal: this.abortController.signal })\n                .then((response) => response.json())\n                .then((data) => {\n                    this.fillSuggestions(data.html);\n                    this.requestActive = false;\n                })\n                .catch(() => {});\n        }\n    }\n\n    replaceAutocompleteSearchString(replaceText) {\n        const [wordStart, wordEnd] = this.getAutocompleteSearchStringStartAndEnd();\n        this.element.selectionStart = wordStart;\n        this.element.selectionEnd = wordEnd+1;\n        document.execCommand('insertText', false, replaceText);\n        this.clearAutocomplete();\n        const resultCursor = wordStart + replaceText.length;\n        this.element.selectionStart = resultCursor;\n        this.element.selectionEnd = resultCursor;\n    }\n\n    fillSuggestions (html) {\n        const id = this.mentionAutocompleteActive ? 'user-suggestions' : 'emoji-suggestions';\n        let element = document.getElementById(id);\n        if (element) {\n            element.outerHTML = html;\n        } else {\n            element = this.element.insertAdjacentElement('afterend', document.createElement('div'));\n            element.outerHTML = html;\n        }\n        for (const suggestion of this.getSuggestionElements()) {\n            suggestion.onclick = (event) => {\n                const value = event.target.getAttribute('data-replace') ?? event.target.outerText;\n                this.element.focus();\n                this.replaceAutocompleteSearchString(value);\n            };\n        }\n        this.markSelectedSuggestion();\n    }\n\n    markSelectedSuggestion() {\n        let i = 0;\n        for (const suggestion of this.getSuggestionElements()) {\n            if (i === this.selectedSuggestionIndex) {\n                suggestion.classList.add('selected');\n            } else {\n                suggestion.classList.remove('selected');\n            }\n            i++;\n        }\n    }\n\n    getSelectedSuggestionReplacement() {\n        let i = 0;\n        for (const suggestion of this.getSuggestionElements()) {\n            if (i === this.selectedSuggestionIndex) {\n                suggestion.classList.add('selected');\n                return suggestion.getAttribute('data-replace') ?? suggestion.outerText;\n            }\n            i++;\n        }\n        return null;\n    }\n\n    getSuggestionElements() {\n        const suggestions = document.getElementById(this.mentionAutocompleteActive ? 'user-suggestions' : 'emoji-suggestions');\n        return suggestions.querySelectorAll('.suggestion');\n    }\n}\n"
  },
  {
    "path": "assets/controllers/scroll_top_controller.js",
    "content": "import { Controller } from '@hotwired/stimulus';\n\nexport default class extends Controller {\n    connect() {\n        const self = this;\n        window.onscroll = function () {\n            self.scroll();\n        };\n    }\n\n    scroll() {\n        if (\n            20 < document.body.scrollTop ||\n            20 < document.documentElement.scrollTop\n        ) {\n            this.element.style.display = 'block';\n        } else {\n            this.element.style.display = 'none';\n        }\n    }\n\n    increaseCounter() {\n        const counter = this.element.querySelector('small');\n        counter.innerHTML = parseInt(counter.innerHTML) + 1;\n        counter.classList.remove('hidden');\n    }\n\n    scrollTop() {\n        document.body.scrollTop = 0;\n        document.documentElement.scrollTop = 0;\n    }\n}\n"
  },
  {
    "path": "assets/controllers/selection_controller.js",
    "content": "import { Controller } from '@hotwired/stimulus';\n\n/* stimulusFetch: 'lazy' */\nexport default class extends Controller {\n    changeLocation(event) {\n        window.location = event.currentTarget.value;\n    }\n}\n"
  },
  {
    "path": "assets/controllers/settings_row_enum_controller.js",
    "content": "import { Controller } from '@hotwired/stimulus';\n\nexport default class extends Controller {\n    /**\n     * Calls the action at the given path when the value changes\n     * @param actionPath {string} - The path to the action to be called\n     * @param reloadRequired {boolean} - Whether the page needs to be reloaded after the action is called\n     */\n    change({ params: { actionPath, reloadRequired } }) {\n        return fetch(actionPath).then(() => {\n            if (reloadRequired) {\n                document.querySelector('.settings-list').classList.add('reload-required');\n            }\n        });\n    }\n}\n"
  },
  {
    "path": "assets/controllers/settings_row_switch_controller.js",
    "content": "import { Controller } from '@hotwired/stimulus';\n\nexport default class extends Controller {\n    /**\n     * Calls the action at the given path when the toggle is checked or unchecked\n     * @param target {HTMLInputElement} - The checkbox element of the toggle that was clicked\n     * @param truePath {string} - The path to the action to be called when the toggle is checked\n     * @param falsePath {string} - The path to the action to be called when the toggle is unchecked\n     * @param reloadRequired {boolean} - Whether the page needs to be reloaded after the action is called\n     */\n    toggle({ target, params: { truePath, falsePath, reloadRequired } }) {\n        const path = target.checked ? truePath : falsePath;\n        return fetch(path).then(() => {\n            if (reloadRequired) {\n                document.querySelector('.settings-list').classList.add('reload-required');\n            }\n        });\n    }\n}\n"
  },
  {
    "path": "assets/controllers/subject_controller.js",
    "content": "import { fetch, ok } from '../utils/http';\nimport getIntIdFromElement, { getDepth, getLevel, getTypeFromNotification } from '../utils/mbin';\nimport { Controller } from '@hotwired/stimulus';\nimport router from '../utils/routing';\nimport { useIntersection } from 'stimulus-use';\n\n/* stimulusFetch: 'lazy' */\nexport default class extends Controller {\n    static previewInit = false;\n    static targets = ['loader', 'more', 'container', 'commentsCounter', 'favCounter', 'upvoteCounter', 'downvoteCounter'];\n    static values = {\n        loading: Boolean,\n        isOnCombined: Boolean,\n    };\n    static sendBtnLabel = null;\n\n    connect() {\n        this.wireMoreFocusClassAdjustment();\n\n        if (this.element.classList.contains('show-preview')) {\n            useIntersection(this);\n        }\n\n        this.wireTouchEvent();\n    }\n\n    async getForm(event) {\n        event.preventDefault();\n\n        if ('' !== this.containerTarget.innerHTML.trim()) {\n            if (false === confirm('Do you really want to leave?')) {\n                return;\n            }\n        }\n\n        try {\n            this.loadingValue = true;\n\n            let response = await fetch(event.target.href, { method: 'GET' });\n\n            response = await ok(response);\n            response = await response.json();\n\n            this.containerTarget.style.display = 'block';\n            this.containerTarget.innerHTML = response.form;\n\n            const textarea = this.containerTarget.querySelector('textarea');\n            if (textarea) {\n                if ('' !== textarea.value) {\n                    let firstLineEnd = textarea.value.indexOf('\\n');\n                    if (-1 === firstLineEnd) {\n                        firstLineEnd = textarea.value.length;\n                        textarea.value = textarea.value.slice(0, firstLineEnd) + ' ' + textarea.value.slice(firstLineEnd);\n                        textarea.selectionStart = firstLineEnd + 1;\n                        textarea.selectionEnd = firstLineEnd + 1;\n                    } else {\n                        textarea.value = textarea.value.slice(0, firstLineEnd) + ' ' + textarea.value.slice(firstLineEnd);\n                        textarea.selectionStart = firstLineEnd + 1;\n                        textarea.selectionEnd = firstLineEnd + 1;\n                    }\n                }\n\n                textarea.focus();\n            }\n        } catch {\n            window.location.href = event.target.href;\n        } finally {\n            this.loadingValue = false;\n            popover.togglePopover(false);\n        }\n    }\n\n    async sendForm(event) {\n        event.preventDefault();\n\n        const form = event.target.closest('form');\n        const url = form.action;\n\n        try {\n            this.loadingValue = true;\n            self.sendBtnLabel = event.target.innerHTML;\n            event.target.disabled = true;\n            event.target.innerHTML = 'Sending...';\n\n            let response = await fetch(url, {\n                method: 'POST',\n                body: new FormData(form),\n            });\n\n            response = await ok(response);\n            response = await response.json();\n\n            if (response.form) {\n                this.containerTarget.style.display = 'block';\n                this.containerTarget.innerHTML = response.form;\n            } else if (form.classList.contains('replace')) {\n                const div = document.createElement('div');\n                div.innerHTML = response.html;\n                div.firstElementChild.className = this.element.className;\n\n                this.element.innerHTML = div.firstElementChild.innerHTML;\n            } else {\n                const div = document.createElement('div');\n                div.innerHTML = response.html;\n\n                const level = getLevel(this.element);\n                const depth = getDepth(this.element);\n\n                div.firstElementChild.classList.remove('comment-level--1');\n                div.firstElementChild.classList.add('comment-level--' + (10 <= level ? 10 : level + 1));\n                div.firstElementChild.dataset.commentCollapseDepthValue = depth + 1;\n\n                if (this.element.nextElementSibling && this.element.nextElementSibling.classList.contains('comments')) {\n                    this.element.nextElementSibling.appendChild(div.firstElementChild);\n                    this.element.classList.add('mb-0');\n                } else {\n                    this.element.parentNode.insertBefore(div.firstElementChild, this.element.nextSibling);\n                }\n\n                this.containerTarget.style.display = 'none';\n                this.containerTarget.innerHTML = '';\n            }\n        } catch (e) {\n            console.error(e);\n            // this.containerTarget.innerHTML = '';\n        } finally {\n            this.application\n                .getControllerForElementAndIdentifier(document.getElementById('main'), 'lightbox')\n                .connect();\n            this.application\n                .getControllerForElementAndIdentifier(document.getElementById('main'), 'timeago')\n                .connect();\n            this.loadingValue = false;\n            event.target.disabled = false;\n            event.target.innerHTML = self.sendBtnLabel;\n        }\n\n    }\n\n    async favourite(event) {\n        event.preventDefault();\n\n        const form = event.target.closest('form');\n\n        try {\n            this.loadingValue = true;\n\n            let response = await fetch(form.action, {\n                method: 'POST',\n                body: new FormData(form),\n            });\n\n            response = await ok(response);\n            response = await response.json();\n\n            form.innerHTML = response.html;\n        } catch {\n            form.submit();\n        } finally {\n            this.loadingValue = false;\n        }\n    }\n\n    async vote(event) {\n        event.preventDefault();\n\n        const form = event.target.closest('form');\n\n        try {\n            this.loadingValue = true;\n\n            let response = await fetch(form.action, {\n                method: 'POST',\n                body: new FormData(form),\n            });\n\n            response = await ok(response);\n            response = await response.json();\n\n            event.target.closest('.vote').outerHTML = response.html;\n        } catch {\n            form.submit();\n        } finally {\n            this.loadingValue = false;\n        }\n    }\n\n    loadingValueChanged(val) {\n        const submitButton = this.containerTarget.querySelector('form button[type=\"submit\"]');\n\n        if (true === val) {\n            if (submitButton) {\n                submitButton.disabled = true;\n            }\n            this.loaderTarget.style.display = 'block';\n        } else {\n            if (submitButton) {\n                submitButton.disabled = false;\n            }\n            this.loaderTarget.style.display = 'none';\n        }\n    }\n\n    async showModPanel(event) {\n        event.preventDefault();\n\n        let container = this.element.querySelector('.moderate-inline');\n        if (null !== container) {\n            // moderate panel was already added to this post, toggle\n            // hidden on it to show/hide it and exit\n            container.classList.toggle('hidden');\n            return;\n        }\n\n        container = document.createElement('div');\n        container.classList.add('moderate-inline');\n        this.element.insertAdjacentHTML('beforeend', container.outerHTML);\n\n        try {\n            this.loadingValue = true;\n\n            let response = await fetch(event.target.href);\n\n            response = await ok(response);\n            response = await response.json();\n\n            this.element.querySelector('.moderate-inline').insertAdjacentHTML('afterbegin', response.html);\n        } catch {\n            window.location.href = event.target.href;\n        } finally {\n            this.loadingValue = false;\n        }\n    }\n\n    notification(data) {\n        if (data.detail.parentSubject && this.element.id === data.detail.parentSubject.htmlId) {\n            if (data.detail.op.endsWith('CommentDeletedNotification') || data.detail.op.endsWith('CommentCreatedNotification')) {\n                this.updateCommentCounter(data);\n            }\n        }\n\n        if (this.element.id !== data.detail.htmlId) {\n            return;\n        }\n\n        if (data.detail.op.endsWith('EditedNotification')) {\n            this.refresh(data);\n            return;\n        }\n\n        if (data.detail.op.endsWith('DeletedNotification')) {\n            this.element.remove();\n            return;\n        }\n\n        if (data.detail.op.endsWith('Vote')) {\n            this.updateVotes(data);\n            return;\n        }\n\n        if (data.detail.op.endsWith('Favourite')) {\n            this.updateFavourites(data);\n            return;\n        }\n    }\n\n    async refresh(data) {\n        try {\n            this.loadingValue = true;\n\n            const url = router().generate(`ajax_fetch_${getTypeFromNotification(data)}`, { id: getIntIdFromElement(this.element) });\n\n            let response = await fetch(url);\n\n            response = await ok(response);\n            response = await response.json();\n\n            const div = document.createElement('div');\n            div.innerHTML = response.html;\n\n            div.firstElementChild.className = this.element.className;\n            this.element.outerHTML = div.firstElementChild.outerHTML;\n        } catch {\n        } finally {\n            this.loadingValue = false;\n        }\n    }\n\n    updateVotes(data) {\n        this.upvoteCounterTarget.innerText = `(${data.detail.up})`;\n\n        if (0 < data.detail.up) {\n            this.upvoteCounterTarget.classList.remove('hidden');\n        } else {\n            this.upvoteCounterTarget.classList.add('hidden');\n        }\n\n        if (this.hasDownvoteCounterTarget) {\n            this.downvoteCounterTarget.innerText = data.detail.down;\n        }\n    }\n\n    updateFavourites(data) {\n        if (this.hasFavCounterTarget) {\n            this.favCounterTarget.innerText = data.detail.count;\n        }\n    }\n\n    updateCommentCounter(data) {\n        if (data.detail.op.endsWith('CommentCreatedNotification') && this.hasCommentsCounterTarget) {\n            this.commentsCounterTarget.innerText = parseInt(this.commentsCounterTarget.innerText) + 1;\n        }\n\n        if (data.detail.op.endsWith('CommentDeletedNotification') && this.hasCommentsCounterTarget) {\n            this.commentsCounterTarget.innerText = parseInt(this.commentsCounterTarget.innerText) - 1;\n        }\n    }\n\n    async removeImage(event) {\n        event.preventDefault();\n\n        try {\n            this.loadingValue = true;\n\n            let response = await fetch(event.target.parentNode.formAction, { method: 'POST' });\n\n            response = await ok(response);\n            await response.json();\n\n            event.target.parentNode.previousElementSibling.remove();\n            event.target.parentNode.nextElementSibling.classList.remove('hidden');\n            event.target.parentNode.remove();\n        } catch {\n        } finally {\n            this.loadingValue = false;\n        }\n    }\n\n    appear() {\n        if (this.previewInit) {\n            return;\n        }\n\n        const prev = this.element.querySelectorAll('.show-preview');\n\n        prev.forEach((el) => {\n            el.click();\n        });\n\n        this.previewInit = true;\n    }\n\n    wireMoreFocusClassAdjustment() {\n        const self = this;\n        if (this.hasMoreTarget) {\n            // Add z-5 (higher z-index with !important) to the element when more button is focused (eg. clicked)\n            // Remove z-5 from other elements in the same parent\n            this.moreTarget.addEventListener('focusin', () => {\n                self.element.parentNode\n                    .querySelectorAll('.z-5')\n                    .forEach((el) => {\n                        el.classList.remove('z-5');\n                    });\n                this.element.classList.add('z-5');\n            });\n\n            // During a mouse hover, remove z-5 from other elements in the same parent\n            // and clear :focus-within from any focused element inside the same parent\n            this.moreTarget.addEventListener('mouseenter', () => {\n                // Remove z-5 from other elements in the same parent\n                const parent = self.element.parentNode;\n                parent\n                    .querySelectorAll('.z-5')\n                    .forEach((el) => {\n                        el.classList.remove('z-5');\n                    });\n\n                // Clear keyboard/mouse focus from any element inside the same\n                // parent so that :focus-within is removed from the old\n                // element without assigning focus to the hovered one.\n                const active = document.activeElement;\n                if (active && parent.contains(active) && !self.moreTarget.contains(active)) {\n                    try {\n                        active.blur();\n                    } catch {\n                        // ignore environments where blur may throw\n                    }\n                }\n            });\n        }\n    }\n\n    wireTouchEvent() {\n        if (this.isOnCombinedValue) {\n            this.wireTouchEventCombined();\n        } else {\n            this.wireTouchEventRegular();\n        }\n    }\n\n    wireTouchEventRegular() {\n        // if in a list and the click is made via touch, open the post\n        if (!this.element.classList.contains('isSingle')) {\n            this.element.querySelector('.content')?.addEventListener('click', (e) => {\n                if (this.filterClickEvent(e)) {\n                    return;\n                }\n\n                if ('touch' === e.pointerType) {\n                    const link = this.element.querySelector('header a:not(.user-inline)');\n                    if (link) {\n                        const href = link.getAttribute('href');\n                        if (href) {\n                            document.location.href = href;\n                        }\n                    }\n                }\n            });\n        }\n    }\n\n    wireTouchEventCombined() {\n        // if on Combined view, open the post via click on card\n        this.element.addEventListener('click', (e) => {\n            if (this.filterClickEvent(e)) {\n                return;\n            }\n\n            const link = this.element.querySelector('footer span[data-subject-target=\"commentsCounter\"]')?.parentElement;\n            if (link) {\n                let href = link.getAttribute('href');\n                href = href.substring(0, href.length - '#comments'.length);\n                if (href) {\n                    document.location.href = href;\n                }\n            } else {\n                const link = this.element.querySelector('footer span[data-subject-x=\"subjectLink\"]')?.parentElement;\n                if (link) {\n                    const href = link.getAttribute('href');\n                    if (href) {\n                        document.location.href = href;\n                    }\n                }\n            }\n        });\n    }\n\n    filterClickEvent(e) {\n        if (e.defaultPrevented) {\n            return true;\n        }\n\n        const filteredElementTypes = [\n            'a',\n            'button',\n            'select',\n            'option',\n            'input',\n            'textarea',\n            'details',\n            'summary',\n        ];\n        for (const type of filteredElementTypes) {\n            if (e.target.nodeName?.toLowerCase() === type || e.target.tagName?.toLowerCase() === type) {\n                return true;\n            }\n        }\n\n        // ignore click on images\n        const figures = this.element.querySelectorAll('figure');\n        if (\n            figures.entries().some(([, elem]) => elem.contains(e.target))\n        ) {\n            return true;\n        }\n\n        return false;\n    }\n}\n"
  },
  {
    "path": "assets/controllers/subject_list_controller.js",
    "content": "import { fetch, ok } from '../utils/http';\nimport { getDepth, getLevel, getTypeFromNotification } from '../utils/mbin';\nimport { Controller } from '@hotwired/stimulus';\nimport router from '../utils/routing';\n\n/* stimulusFetch: 'lazy' */\nexport default class extends Controller {\n    addComment(data) {\n        if (!document.getElementById(data.detail.parentSubject.htmlId)) {\n            return;\n        }\n\n        this.addMainSubject(data);\n    }\n\n    async addMainSubject(data) {\n        try {\n            const url = router().generate(`ajax_fetch_${getTypeFromNotification(data)}`, { id: data.detail.id });\n\n            let response = await fetch(url);\n\n            response = await ok(response);\n            response = await response.json();\n\n            if (!data.detail.parent) {\n                if (!document.getElementById(data.detail.htmlId)) {\n                    this.element.insertAdjacentHTML('afterbegin', response.html);\n                }\n\n                return;\n            }\n\n            const parent = document.getElementById(data.detail.parent.htmlId);\n            if (parent) {\n                const div = document.createElement('div');\n                div.innerHTML = response.html;\n\n                const level = getLevel(parent);\n                const depth = getDepth(parent);\n\n                div.firstElementChild.classList.remove('comment-level--1');\n                div.firstElementChild.classList.add('comment-level--' + (10 <= level ? 10 : level + 1));\n                div.firstElementChild.dataset.commentCollapseDepthValue = depth + 1;\n\n                let current = parent;\n                while (current) {\n                    if (!current.nextElementSibling) {\n                        break;\n                    }\n                    if ('undefined' === current.nextElementSibling.dataset.subjectParentValue) {\n                        break;\n                    }\n                    if (current.nextElementSibling.dataset.subjectParentValue !== div.firstElementChild.dataset.subjectParentValue\n                        && getLevel(current.nextElementSibling) <= level) {\n                        break;\n                    }\n\n                    current = current.nextElementSibling;\n                }\n\n                if (!document.getElementById(div.firstElementChild.id)) {\n                    current.insertAdjacentElement('afterend', div.firstElementChild);\n                }\n            }\n        } catch {\n        } finally {\n            this.application\n                .getControllerForElementAndIdentifier(document.getElementById('main'), 'timeago')\n                .connect();\n        }\n    }\n\n    async addCommentOverview(data) {\n        try {\n            const parent = document.getElementById(data.detail.parentSubject.htmlId);\n            if (!parent) {\n                return;\n            }\n\n            const url = router().generate(`ajax_fetch_${getTypeFromNotification(data)}`, { id: data.detail.id });\n\n            let response = await fetch(url);\n\n            response = await ok(response);\n            response = await response.json();\n\n            const div = document.createElement('div');\n            div.innerHTML = response.html;\n\n            div.firstElementChild.classList.add('comment-level--2');\n\n            if (!parent.nextElementSibling || !parent.nextElementSibling.classList.contains('comments')) {\n                const comments = document.createElement('div');\n                comments.classList.add('comments', 'post-comments', 'comments-tree');\n                parent.insertAdjacentElement('afterend', comments);\n            }\n\n            parent.classList.add('mb-0');\n            if (parent.nextElementSibling.querySelector('#' + data.detail.htmlId)) {\n                return;\n            }\n\n            parent.nextElementSibling.appendChild(div.firstElementChild);\n        } catch {\n        } finally {\n            this.application\n                .getControllerForElementAndIdentifier(document.getElementById('main'), 'timeago')\n                .connect();\n        }\n    }\n\n    increaseCounter() {\n        this.application\n            .getControllerForElementAndIdentifier(document.getElementById('scroll-top'), 'scroll-top')\n            .increaseCounter();\n    }\n}\n"
  },
  {
    "path": "assets/controllers/subs_controller.js",
    "content": "import { fetch, ok } from '../utils/http';\nimport { Controller } from '@hotwired/stimulus';\n\n/* stimulusFetch: 'lazy' */\nexport default class extends Controller {\n    static values = {\n        loading: Boolean,\n    };\n\n    async send(event) {\n        event.preventDefault();\n\n        const form = event.target.closest('form');\n\n        try {\n            this.loadingValue = true;\n\n            let response = await fetch(form.action, {\n                method: 'POST',\n                body: new FormData(form),\n            });\n\n            response = await ok(response);\n            response = await response.json();\n\n            this.element.outerHTML = response.html;\n        } catch {\n            form.submit();\n        } finally {\n            this.loadingValue = false;\n        }\n    }\n}\n"
  },
  {
    "path": "assets/controllers/subs_panel_controller.js",
    "content": "import { Controller } from '@hotwired/stimulus';\nimport router from '../utils/routing';\n\nconst KBIN_SUBSCRIPTIONS_IN_SEPARATE_SIDEBAR = 'kbin_subscriptions_in_separate_sidebar';\nconst KBIN_SUBSCRIPTIONS_SIDEBARS_SAME_SIDE = 'kbin_subscriptions_sidebars_same_side';\n\n/* stimulusFetch: 'lazy' */\nexport default class extends Controller {\n    static values = {\n        sidebarPosition: String,\n    };\n\n    generateSettingsRoute(key, value) {\n        return router().generate('theme_settings', { key, value });\n    }\n\n    async reattach() {\n        await window.fetch(\n            this.generateSettingsRoute(KBIN_SUBSCRIPTIONS_IN_SEPARATE_SIDEBAR, 'false'),\n        );\n        window.location.reload();\n    }\n\n    async popLeft() {\n        await window.fetch(\n            this.generateSettingsRoute(KBIN_SUBSCRIPTIONS_IN_SEPARATE_SIDEBAR, 'true'),\n        );\n        await window.fetch(\n            this.generateSettingsRoute(\n                KBIN_SUBSCRIPTIONS_SIDEBARS_SAME_SIDE,\n                ('left' === this.sidebarPositionValue ? 'true' : 'false'),\n            ),\n        );\n        window.location.reload();\n    }\n\n    async popRight() {\n        await window.fetch(\n            this.generateSettingsRoute(KBIN_SUBSCRIPTIONS_IN_SEPARATE_SIDEBAR, 'true'),\n        );\n        await window.fetch(\n            this.generateSettingsRoute(\n                KBIN_SUBSCRIPTIONS_SIDEBARS_SAME_SIDE,\n                ('left' !== this.sidebarPositionValue ? 'true' : 'false'),\n            ),\n        );\n        window.location.reload();\n    }\n}\n"
  },
  {
    "path": "assets/controllers/thumb_controller.js",
    "content": "import { Controller } from '@hotwired/stimulus';\n\n/* stimulusFetch: 'lazy' */\nexport default class extends Controller {\n    /**\n     * Called on mouseover\n     * @param {*} event\n     * @returns\n     */\n    async adultImageHover(event) {\n        if (false === event.target.matches(':hover')) {\n            return;\n        }\n\n        event.target.style.filter = 'none';\n    }\n\n    /**\n     * Called on mouseout\n     * @param {*} event\n     */\n    async adultImageHoverOut(event) {\n        event.target.style.filter = 'blur(8px)';\n    }\n}\n"
  },
  {
    "path": "assets/controllers/timeago_controller.js",
    "content": "import { Controller } from '@hotwired/stimulus';\n/* eslint-disable camelcase -- zh_TW is a specific identifier */\n// eslint-disable-next-line -- grouping timeago imports here is more readable than properly sorting\nimport * as timeago from 'timeago.js';\nimport bg from 'timeago.js/lib/lang/bg';\nimport da from 'timeago.js/lib/lang/da';\nimport de from 'timeago.js/lib/lang/de';\nimport el from 'timeago.js/lib/lang/el';\nimport en from 'timeago.js/lib/lang/en_US';\nimport es from 'timeago.js/lib/lang/es';\nimport fr from 'timeago.js/lib/lang/fr';\nimport gl from 'timeago.js/lib/lang/gl';\nimport it from 'timeago.js/lib/lang/it';\nimport ja from 'timeago.js/lib/lang/ja';\nimport nl from 'timeago.js/lib/lang/nl';\nimport pl from 'timeago.js/lib/lang/pl';\nimport pt_BR from 'timeago.js/lib/lang/pt_BR';\nimport ru from 'timeago.js/lib/lang/ru';\nimport tr from 'timeago.js/lib/lang/tr';\nimport uk from 'timeago.js/lib/lang/uk';\nimport zh_TW from 'timeago.js/lib/lang/zh_TW';\n\n/* stimulusFetch: 'lazy' */\nexport default class extends Controller {\n    connect() {\n        const elems = document.querySelectorAll('.timeago');\n\n        if (!elems.length) {\n            return;\n        }\n\n        const lang = document.documentElement.lang;\n        const languages = { bg, da, de, el, en, es, fr, gl, it, ja, nl, pl, pt_BR, ru, tr, uk, zh_TW };\n\n        if (languages[lang]) {\n            timeago.register(lang, languages[lang]);\n            timeago.render(elems, lang);\n        } else {\n            timeago.render(elems);\n        }\n    }\n}\n"
  },
  {
    "path": "assets/controllers.json",
    "content": "{\n    \"controllers\": {\n        \"@symfony/ux-autocomplete\": {\n            \"autocomplete\": {\n                \"enabled\": true,\n                \"fetch\": \"eager\",\n                \"autoimport\": {\n                    \"tom-select/dist/css/tom-select.default.css\": true,\n                    \"tom-select/dist/css/tom-select.bootstrap4.css\": false,\n                    \"tom-select/dist/css/tom-select.bootstrap5.css\": false\n                }\n            }\n        },\n        \"@symfony/ux-chartjs\": {\n            \"chart\": {\n                \"enabled\": true,\n                \"fetch\": \"eager\"\n            }\n        }\n    },\n    \"entrypoints\": []\n}\n"
  },
  {
    "path": "assets/email.js",
    "content": "import './styles/emails.scss';\n"
  },
  {
    "path": "assets/stimulus_bootstrap.js",
    "content": "// register any custom, 3rd party controllers here\n// app.register('some_controller_name', SomeImportedController);\nimport { startStimulusApp } from '@symfony/stimulus-bridge';\n\n// Registers Stimulus controllers from controllers.json and in the controllers/ directory\nexport const app = startStimulusApp(require.context(\n    '@symfony/stimulus-bridge/lazy-controller-loader!./controllers',\n    true,\n    /\\.[jt]sx?$/,\n));\n"
  },
  {
    "path": "assets/styles/_shared.scss",
    "content": "// a file for shared CSS styling between multiple components or views\n\n.user-badge {\n  border: var(--kbin-section-border);\n  padding: 0.25rem 0.5rem;\n  margin-left: .25rem;\n  border-radius: var(--kbin-rounded-edges-radius);\n}\n"
  },
  {
    "path": "assets/styles/_variables.scss",
    "content": "$grid-breakpoints: (\n        xs: 0,\n        sm: 690px,\n        md: 768px,\n        lg: 992px,\n        xl: 1200px,\n        xxl: 1400px\n) !default;\n\n$aspect-ratios: (\n        \"1x1\": 100%,\n        \"4x3\": calc(3 / 4 * 100%),\n        \"16x9\": calc(9 / 16 * 100%),\n        \"21x9\": calc(9 / 21 * 100%)\n) !default;\n\n:root {\n  // ---------------------------------------------------------------------------\n  // Variables that are common to all themes\n  // ---------------------------------------------------------------------------\n\n  --kbin-shadow: rgba(0, 0, 0, 0.25) 0px 54px 55px, rgba(0, 0, 0, 0.12) 0px -12px 30px, rgba(0, 0, 0, 0.12) 0px 4px 6px, rgba(0, 0, 0, 0.17) 0px 12px 13px, rgba(0, 0, 0, 0.09) 0px -3px 5px;\n\n  --kbin-rounded-edges-radius: .5rem;\n\n  // buttons\n  --kbin-button-danger-bg:  #842029;\n  --kbin-button-danger-hover-bg: #921d27;\n  --kbin-button-danger-text-color: #fff;\n  --kbin-button-danger-text-hover-color: #fff;\n  --kbin-button-danger-border: 1px dashed #842029;\n\n  // topbar\n  --kbin-topbar-link-color: #fff;\n\n  // alerts\n  --kbin-alert-success-bg: var(--kbin-success-color);\n  --kbin-alert-success-border: 1px solid var(--kbin-success-color);\n  --kbin-alert-success-text-color: #fff;\n  --kbin-alert-success-link-color: #fff;\n\n  // fontawesome\n  --kbin-font-awesome-font-family: \"Font Awesome 6 Free\";\n\n  // fonts\n  --kbin-body-font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"Noto Sans\", Roboto, \"Helvetica Neue\", Arial, sans-serif, \"Apple Color Emoji\", \"Segoe UI Emoji\", \"Segoe UI Symbol\";\n\n  // ---------------------------------------------------------------------------\n  // Default theme variables\n  // ---------------------------------------------------------------------------\n\n  --kbin-body-font-size: 1rem;\n  --kbin-body-font-weight: 400;\n  --kbin-body-line-height: 1.5;\n  --kbin-body-text-align: left;\n  --kbin-body-bg: #fff;\n\n  --kbin-bg: #ecf0f1;\n  --kbin-bg-nth: #fafafa;\n\n  --kbin-text-color: #212529;\n  --kbin-link-color: #37769e;\n  --kbin-link-hover-color: #275878;\n  --kbin-outline: #ff8c00 solid 4px;\n\n  --kbin-primary-color: #61366b;\n  --kbin-text-muted-color: #95a6a6;\n\n  --kbin-success-color: #0f5132;\n  --kbin-danger-color: #842029;\n\n  --kbin-own-color: #0f5132;\n  --kbin-author-color: #842029;\n\n  // section\n  --kbin-section-bg: #fff;\n  --kbin-section-text-color: var(--kbin-text-color);\n  --kbin-section-title-link-color: var(--kbin-link-color);\n  --kbin-section-link-color: var(--kbin-link-color);\n  --kbin-section-link-hover-color: var(--kbin-link-hover-color);\n  --kbin-section-border: 1px solid #e5eaec;\n  --kbin-author-border: 1px dashed var(--kbin-author-color);\n  --kbin-own-border: 1px dashed var(--kbin-own-color);\n\n  // meta\n  --kbin-meta-bg: none;\n  --kbin-meta-text-color: #606060;\n  --kbin-meta-link-color: #606060;\n  --kbin-meta-link-hover-color: var(--kbin-link-hover-color);\n  --kbin-meta-border: 1px dashed #e5eaec;\n  --kbin-avatar-border: 3px solid #ecf0f1;\n  --kbin-avatar-border-active: 3px solid #d3d5d6;\n  --kbin-blockquote-color: #0f5132;\n\n  // options\n  --kbin-options-bg: #fff;\n  --kbin-options-text-color: #95a5a6;\n  --kbin-options-link-color: #95a5a6;\n  --kbin-options-link-hover-color: #32465b;\n  --kbin-options-border: 1px solid #e5eaec;\n  --kbin-options-link-hover-border: 3px solid #32465b;\n\n  // forms\n  --kbin-input-bg: #fff;\n  --kbin-input-text-color: var(--kbin-text-color);\n  --kbin-input-border-color: #e5eaec;\n  --kbin-input-border: 1px solid var(--kbin-input-border-color);\n  --kbin-input-placeholder-text-color: #929497;\n\n  // buttons\n  --kbin-button-primary-bg: #4e3a8c;\n  --kbin-button-primary-hover-bg: #3f2e77;\n  --kbin-button-primary-text-color: #fff;\n  --kbin-button-primary-text-hover-color: #fff;\n  --kbin-button-primary-border: 1px solid #3f2e77;\n\n  --kbin-button-secondary-bg: #fff;\n  --kbin-button-secondary-hover-bg: #f5f5f5;\n  --kbin-button-secondary-text-color: var(--kbin-meta-text-color);\n  --kbin-button-secondary-text-hover-color: var(--kbin-text-color);\n  --kbin-button-secondary-border: 1px dashed #e5eaec;\n\n  // header\n  --kbin-header-bg: #110045;\n  --kbin-header-text-color: #fff;\n  --kbin-header-link-color: #fff;\n  --kbin-header-link-hover-color: #e8e8e8;\n  --kbin-header-link-active-bg: #0a0026;\n  --kbin-header-border: 1px solid #e5eaec;\n  --kbin-header-hover-border: 3px solid #fff;\n\n  // topbar\n  --kbin-topbar-bg: #0a0026;\n  --kbin-topbar-active-bg: #150a37;\n  --kbin-topbar-active-link-color: #fff;\n  --kbin-topbar-hover-bg: #150a37;\n  --kbin-topbar-border: 1px solid  #150a37;\n\n  // sidebar\n  --kbin-sidebar-header-text-color: #909ea2;\n  --kbin-sidebar-header-border: 1px solid #e5eaec;\n  --kbin-sidebar-settings-row-bg: #E5EAEC;\n  --kbin-sidebar-settings-switch-on-color: #fff ;\n  --kbin-sidebar-settings-switch-on-bg: var(--kbin-button-primary-bg);\n  --kbin-sidebar-settings-switch-off-color: #fff ;\n  --kbin-sidebar-settings-switch-off-bg: #b5c4c9;\n  --kbin-sidebar-settings-switch-hover-bg: #9992BC;\n\n  // vote\n  --kbin-vote-bg: #f3f3f3;\n  --kbin-vote-text-color: #b6b6b6;\n  --kbin-vote-text-hover-color: #000;\n  --kbin-upvoted-color: #0f5132;\n  --kbin-downvoted-color: #842029;\n\n  // boost\n  --kbin-boosted-color: var(--kbin-upvoted-color);\n\n  // alerts\n  --kbin-alert-info-bg: #fff3cd;\n  --kbin-alert-info-border: 1px solid #ffe69c;\n  --kbin-alert-info-text-color: #997404;\n  --kbin-alert-info-link-color: #997404;\n\n  --kbin-alert-danger-bg: #f8d7da;\n  --kbin-alert-danger-border: 1px solid #f5c2c7;\n  --kbin-alert-danger-text-color: var(--kbin-danger-color);\n  --kbin-alert-danger-link-color: var(--kbin-danger-color);\n\n  // entry\n  --kbin-entry-link-visited-color: #7e8f99;\n\n  // details\n  --mbin-details-border: var(--kbin-section-border);\n  --mbin-details-separator-border: var(--kbin-meta-border);\n\n  --mbin-details-detail-color: var(--kbin-link-hover-color);\n  --mbin-details-spoiler-color: var(--kbin-danger-color);\n\n  --mbin-details-detail-label: \"Details\";\n  --mbin-details-spoiler-label: \"Spoiler\";\n}\n"
  },
  {
    "path": "assets/styles/app.scss",
    "content": "@use '@fortawesome/fontawesome-free/scss/fontawesome';\n@use '@fortawesome/fontawesome-free/scss/solid';\n@use '@fortawesome/fontawesome-free/scss/regular';\n@use '@fortawesome/fontawesome-free/scss/brands';\n@use 'simple-icons-font/font/simple-icons';\n@use 'variables';\n@use 'shared';\n@use 'layout/breakpoints';\n@use 'layout/typo';\n@use 'layout/layout';\n@use 'layout/section';\n@use 'layout/options';\n@use 'layout/meta';\n@use 'layout/tools';\n@use 'layout/alerts';\n@use 'layout/forms';\n@use 'layout/images';\n@use 'layout/icons';\n@use 'components/announcement';\n@use 'components/topbar';\n@use 'components/header';\n@use 'components/sidebar';\n@use 'components/magazine';\n@use 'components/domain';\n@use 'components/user';\n@use 'components/main';\n@use 'components/vote';\n@use 'components/entry';\n@use 'components/comment';\n@use 'components/figure_image';\n@use 'components/figure_lightbox';\n@use 'components/post';\n@use 'components/search';\n@use 'components/subject';\n@use 'components/suggestions';\n@use 'components/login';\n@use 'components/modlog';\n@use 'components/monitoring';\n@use 'components/notification_switch';\n@use 'components/notifications';\n@use 'components/messages';\n@use 'components/dropdown';\n@use 'components/pagination';\n@use 'components/media';\n@use 'components/preview';\n@use 'components/popover';\n@use 'components/stats';\n@use 'components/infinite_scroll';\n@use 'components/sidebar-subscriptions';\n@use 'components/settings_row';\n@use 'components/inline_md';\n@use 'components/emoji_picker';\n@use 'components/filter_list';\n@use 'pages/post_single';\n@use 'pages/post_front';\n@use 'pages/page_bookmarks';\n@use 'pages/page_modlog';\n@use 'pages/page_profile';\n@use 'pages/page_filter_lists';\n@use 'themes/kbin';\n@use 'themes/default';\n@use 'themes/solarized';\n@use 'themes/tokyo-night';\n@use 'components/tag';\n\n@import 'glightbox/dist/css/glightbox.min.css';\n"
  },
  {
    "path": "assets/styles/components/_announcement.scss",
    "content": ".announcement {\n  padding: 0.75rem;\n  position: relative;\n\n  p {\n    margin: 0;\n    text-align: center;\n  }\n\n  a{\n    font-weight: bold;\n  }\n\n  &__info {\n    background: var(--kbin-alert-info-bg);\n    border: var(--kbin-alert-info-border);\n    color: var(--kbin-alert-info-text-color);\n\n    a {\n      color: var(--kbin-alert-info-link-color);    \n    }\n  }\n\n}\n"
  },
  {
    "path": "assets/styles/components/_comment.scss",
    "content": "@use \"sass:list\";\n@use \"sass:string\";\n@use '../layout/breakpoints' as b;\n@use '../mixins/animations' as ani;\n@use '../mixins/mbin';\n\n$levels: ('#ac5353', '#71ac53', '#ffa500', '#538eac', '#6253ac', '#ac53ac', '#ac5353', '#2b7070ff', '#b9ab52', '#808080ff');\n$comment-margin-xl: 1rem;\n$comment-margin-lg: .5rem;\n$comment-margin-sm: .3rem;\n\n.comment-add {\n  .row {\n    margin-bottom: 0;\n  }\n\n  @include b.media-breakpoint-down(sm) {\n    .params {\n      display: block;\n\n      div {\n        margin-bottom: 1rem;\n      }\n\n      > div:last-of-type {\n        margin-bottom: 0;\n      }\n    }\n  }\n}\n\n.comment {\n  display: grid;\n  font-size: .9rem;\n  grid-gap: .5rem;\n  grid-template-areas: \"avatar header aside\"\n                         \"avatar body body\"\n                         \"avatar footer footer\"\n                         \"moderate moderate moderate\";\n  grid-template-columns: min-content auto min-content;\n  margin: .5rem 0;\n  padding: 0.5rem 0.75rem;\n  position: relative;\n  z-index: 2;\n\n  @include b.media-breakpoint-down(sm) {\n    grid-template-areas: \"avatar header aside\"\n                         \"body body body\"\n                         \"footer footer footer\"\n                         \"moderate moderate moderate\";\n  }\n\n  &:hover,\n  &:focus-visible {\n    z-index: 3;\n  }\n\n  header {\n    color: var(--kbin-meta-text-color);\n    font-size: .8rem;\n    grid-area: header;\n    opacity: .75;\n\n    a {\n      color: var(--kbin-meta-text-color);\n      font-weight: bold;\n\n      time {\n        font-weight: normal;\n      }\n    }\n  }\n\n  .content {\n    p:last-child {\n      margin-bottom: 0;\n    }\n  }\n\n  .aside {\n    grid-area: aside;\n    display: flex;\n    gap: .5rem;\n  }\n\n  .comment-collapse {\n    cursor: pointer;\n    display: none;\n    white-space: nowrap;\n\n    > a {\n      padding: 0 .25rem;\n    }\n  }\n\n  .expand-label {\n    display: none;\n  }\n\n  div {\n    grid-area: body;\n\n    p {\n      margin-top: 0;\n      margin-bottom: 0.5rem;\n    }\n  }\n\n  > figure {\n    grid-area: avatar;\n    display: none;\n    margin: 0;\n\n    img {\n      display: block;\n      width: 30px;\n      height: 30px;\n    }\n\n    @include b.media-breakpoint-up(sm) {\n      img {\n        border: var(--kbin-avatar-border);\n        width: 40px;\n        height: 40px;\n      }\n    }\n  }\n\n  .vote {\n    display: flex;\n    gap: .5rem;\n    justify-content: flex-end;\n\n    button {\n      height: 1.2rem;\n      width: 4rem;\n    }\n  }\n\n  footer {\n    color: var(--kbin-meta-text-color);\n    font-size: .75rem;\n    font-weight: 300;\n    grid-area: footer;\n\n    menu {\n      column-gap: 1rem;\n      display: flex;\n      grid-area: meta;\n      list-style: none;\n      opacity: .75;\n      position: relative;\n      z-index: 4;\n\n      & > a.active,\n      & > li button.active {\n        text-decoration: underline;\n      }\n\n      button,\n      a {\n        font-size: .8rem;\n        @include mbin.btn-link;\n      }\n\n      > li {\n        width: max-content;\n      }\n\n      li:first-child a {\n        padding-left: 0;\n      }\n    }\n\n    menu, .boosts {\n      opacity: .75;\n    }\n\n    a {\n      @include mbin.btn-link;\n    }\n\n    figure {\n      display: block;\n      margin: .5rem 0;\n    }\n  }\n\n  .loader {\n    height: 20px;\n    position: absolute;\n    width: 20px;\n  }\n\n  &.collapsible {\n    .comment-collapse {\n      display: revert;\n    }\n  }\n\n  &.collapsed {\n    border-style: dashed;\n    grid-template-areas: \"avatar header aside\";\n    grid-template-columns: min-content auto min-content;\n\n    > :not(header, figure, .aside) {\n      display: none;\n    }\n\n    .aside > :not(.comment-collapse) {\n      display: none;\n    }\n\n    header a {\n      color: var(--kbin-text-muted-color);\n    }\n\n    > figure img {\n      filter: grayscale(.25) opacity(.75);\n    }\n\n    .comment-collapse {\n      opacity: .75;\n    }\n\n    .collapse-label {\n      display: none;\n    }\n\n    .expand-label {\n      display: revert;\n    }\n  }\n\n  &.hidden {\n    display: none;\n  }\n\n  &:hover,\n  &:focus-within {\n    header, footer menu, footer .boosts {\n      @include ani.fade-in(.5s, .75);\n    }\n  }\n}\n\n.post-comments {\n  blockquote {\n    margin: 0;\n  }\n}\n\n.comments-view-style--tree,\n.comments-view-style--classic {\n  .comment-level--1:not(:first-child) {\n    margin-top: 0.5rem;\n  }\n}\n\n.comments-tree {\n  position: relative;\n\n  blockquote {\n    margin-top: 0;\n  }\n\n  .comment {\n    @for $i from 2 to 11 {\n      &-line--#{$i} {\n        border-left: 1px dashed string.unquote(list.nth($levels, $i));\n        bottom: 0;\n        height: 100%;\n        left: $comment-margin-lg * ($i - 1);\n        opacity: .4;\n\n        position: absolute;\n        z-index: 1;\n\n        @include b.media-breakpoint-up(xl) {\n          left: $comment-margin-xl * ($i - 1);\n        }\n\n        @include b.media-breakpoint-down(sm) {\n          left: $comment-margin-sm * ($i - 1);\n        }\n      }\n    }\n\n    @for $i from 2 to 11 {\n      &-level--#{$i} {\n        border-left: 1px solid string.unquote(list.nth($levels, $i));\n        margin-left: $comment-margin-lg * ($i - 1) !important;\n\n        @include b.media-breakpoint-up(xl) {\n          margin-left: $comment-margin-xl * ($i - 1) !important;\n        }\n\n        @include b.media-breakpoint-down(sm) {\n          margin-left: $comment-margin-sm * ($i - 1) !important;\n        }\n      }\n    }\n  }\n}\n\n.show-comment-avatar {\n  .comment>figure {\n    display: block;\n  }\n}\n\naside.comments {\n  position: relative;\n}\n\n.entry-comment {\n  margin-bottom: 0;\n}\n\n#comment-add {\n  margin: .5rem 0;\n  padding: 0.75rem;\n}\n"
  },
  {
    "path": "assets/styles/components/_domain.scss",
    "content": ".domain {\n  header {\n    text-align: center;\n  \n    h4 {\n      font-size: 1rem;\n      margin-bottom: 1rem;\n      margin-top: .5rem;\n    }\n  }\n\n  &__name {\n    margin-top: 0;\n  }\n\n  &__subscribe {\n    display: flex;\n    flex-direction: row;\n    font-size: .9rem;\n    justify-content: center;\n    margin-bottom: 2.5rem;\n\n    div {\n      align-items: center;\n      background: var(--kbin-button-secondary-bg);\n      border: var(--kbin-button-secondary-border);\n      color: var(--kbin-button-secondary-text-color);\n      display: flex;\n      flex-direction: row;\n      left: 1px;\n      padding: .3rem .5rem;\n      position: relative;\n\n      .rounded-edges & {\n        border-radius: .5rem;\n      }\n\n      i {\n        padding-right: .5rem;\n      }\n    }\n\n    button {\n      height: 100%;\n      padding-bottom: .5rem;\n      padding-top: .5rem;\n    }\n\n    form:last-of-type {\n      position: relative;\n      right: 1px;\n    }\n  }\n}\n\ntd {\n  .domain__subscribe {\n    margin: 0;\n  }\n}\n"
  },
  {
    "path": "assets/styles/components/_dropdown.scss",
    "content": "// Learn about how this was made:\n// @link https://moderncss.dev/css-only-accessible-dropdown-navigation-menu/\n$transition: 180ms all 120ms ease-out;\n.dropdown {\n  position: relative;\n\n  &__menu {\n    background-color: var(--kbin-section-bg);\n    border: var(--kbin-section-border);\n    box-shadow: var(--kbin-shadow);\n    left: 50%;\n    margin-bottom: 0;\n    margin-top: 0;\n    min-width: 15rem;\n    opacity: 0;\n    position: absolute;\n    transform: rotateX(-90deg) translateX(-50%);\n    transform-origin: top center;\n    visibility: hidden;\n    z-index: 100;\n    top: 100%;\n    padding: 0em;\n    overflow: clip;\n\n    li {\n      list-style: none;\n      padding: 0;\n    }\n\n    a {\n      color: var(--kbin-meta-link-color) !important;\n      font-weight: normal !important;\n      border: 0 !important;\n      display: block !important;\n      padding: .5rem 1rem !important;\n      text-decoration: none;\n      width: 100%;\n      text-align: left;\n      border-radius: 0 !important;\n\n      &:hover {\n        color: var(--kbin-meta-link-hover-color) !important;\n        background: var(--kbin-bg) !important;\n      }\n\n      &.active {\n        font-weight: bold !important;\n      }\n    }\n\n    button {\n      color: var(--kbin-button-secondary-text-color);\n      background: var(--kbin-button-secondary-bg);\n      font-weight: normal;\n      display: block;\n      padding: .5rem 1rem;\n      width: 100%;\n\n      &:hover {\n        color: var(--kbin-button-secondary-text-hover-color);\n        background: var(--kbin-button-secondary-hover-bg);\n      }\n\n      &.active {\n        font-weight: bold;\n      }\n    }\n  }\n\n  .dropdown__menu > li button {\n    padding: .5rem 1rem;\n    text-align: left;\n    border-radius: 0 !important;\n\n    &:hover {\n      color: var(--kbin-meta-link-hover-color);\n      background: var(--kbin-bg);\n    }\n  }\n\n  &:hover,\n  &:focus-within {\n    .dropdown__menu {\n      transform: rotateX(0) translateX(-50%);\n      visibility: visible;\n      transition: visibility 0s, opacity .2s;\n      opacity: 1;\n    }\n  }\n\n  &:hover {\n    z-index: 101;\n  }\n\n  &:focus-within > .btn__secondary {\n    color: var(--kbin-button-secondary-text-hover-color) !important;\n    background: var(--kbin-button-secondary-hover-bg);\n  }\n\n  .dropdown__separator {\n    border: var(--kbin-section-border);\n    height: 0px;\n    margin: 2px 5px;\n  }\n}\n"
  },
  {
    "path": "assets/styles/components/_emoji_picker.scss",
    "content": "emoji-picker {\n  --background: var(--kbin-bg);\n  --input-font-color: var(--kbin-text-color);\n  --button-active-background: var(--kbin-button-primary-bg);\n  --button-hover-background: var(--kbin-button-primary-hover-bg);\n  --border-color: var(--kbin-input-border-color);\n}\n\n.rounded-edges emoji-picker {\n  --border-radius: var(--kbin-rounded-edges-radius);\n}\n"
  },
  {
    "path": "assets/styles/components/_entry.scss",
    "content": "@use '../layout/breakpoints' as b;\n@use '../mixins/animations' as ani;\n@use '../mixins/mbin';\n\n:root {\n  --kbin-entry-element-spacing: 10px;\n}\n\n.entry {\n  display: grid;\n  grid-template-areas: \"vote image title\"\n                       \"vote image shortDesc\"\n                       \"vote image meta\"\n                       \"vote image footer\"\n                       \"moderate moderate moderate\"\n                       \"preview preview preview\"\n                       \"body body body\";\n  grid-template-columns: min-content min-content 1fr;\n  grid-template-rows: 1fr min-content;\n  padding: 0;\n  position: relative;\n  z-index: 2;\n\n  &.no-image {\n    grid-template-areas: \"vote title\"\n                       \"vote shortDesc\"\n                       \"vote meta\"\n                       \"vote footer\"\n                       \"moderate moderate\"\n                       \"preview preview\"\n                       \"body body\";\n    grid-template-columns: min-content 1fr;\n  }\n\n  header,\n  .vote,\n  figure,\n  .no-image-placeholder,\n  .short-desc,\n  footer,\n  &__meta {\n    margin-left: var(--kbin-entry-element-spacing);\n  }\n\n  @include b.media-breakpoint-down(sm) {\n    grid-template-areas: \"image image\"\n                         \"vote title\"\n                         \"shortDesc shortDesc\"\n                         \"meta meta\"\n                         \"footer footer\"\n                         \"moderate moderate\"\n                         \"preview preview\"\n                         \"body body\";\n    grid-template-columns: min-content 1fr;\n\n    header,\n    .vote,\n    .short-desc,\n    footer,\n    &__meta {\n      margin-left: var(--kbin-entry-element-spacing);\n    }\n\n    &.no-image {\n      grid-template-areas: \"vote title\"\n                           \"shortDesc shortDesc\"\n                           \"meta meta\"\n                           \"footer footer\"\n                           \"moderate moderate\"\n                           \"preview preview\"\n                           \"body body\";\n      grid-template-columns: min-content 1fr;\n    }\n\n    .view-compact & {\n      grid-template-areas: \"title title vote\"\n                       \"meta meta image\"\n                       \"footer footer image\"\n                       \"moderate moderate moderate\"\n                       \"preview preview preview\"\n                       \"body body body\";\n      grid-template-columns: 1fr min-content min-content;\n\n      .vote {\n        justify-content: right;\n        margin-right: var(--kbin-entry-element-spacing);\n        margin-left: 0;\n      }\n\n      .short-desc {\n        display: none;\n      }\n    }\n\n    .view-compact &.no-meta {\n      grid-template-areas: \"title vote\"\n                       \"shortDesc shortDesc\"\n                       \"meta meta\"\n                       \"footer footer\"\n                       \"moderate moderate\"\n                       \"preview preview\"\n                       \"body body\";\n      grid-template-columns: 1fr min-content;\n    }\n  }\n\n  @include b.media-breakpoint-up(sm) {\n    .view-compact & {\n      grid-template-areas: \"vote title image\"\n                       \"vote meta image\"\n                       \"vote footer image\"\n                       \"moderate moderate moderate\"\n                       \"preview preview preview\"\n                       \"body body body\";\n      grid-template-columns: min-content 1fr min-content;\n\n      .short-desc {\n        display: none;\n      }\n    }\n  }\n\n  &:hover,\n  &:focus-visible {\n    z-index: 3;\n  }\n\n  .vote {\n    grid-area: vote;\n    margin-top: var(--kbin-entry-element-spacing);\n    margin-bottom: var(--kbin-entry-element-spacing);\n  }\n\n  figure,\n  .no-image-placeholder {\n    position: relative;\n    grid-area: image;\n    margin: var(--kbin-entry-element-spacing) 0 var(--kbin-entry-element-spacing) var(--kbin-entry-element-spacing);\n    width: 170px;\n    height: calc(170px / 1.5); // 3:2 ratio\n    justify-self: right;\n    overflow: hidden;\n\n    img {\n      position: absolute;\n      top: 0;\n      height: 100%;\n      width: 100%;\n      object-fit: contain;\n      -o-object-fit: contain;\n    }\n\n    .image-filler {\n      background: var(--kbin-vote-bg);\n      position: absolute;\n      width: 100%;\n      height: 100%;\n\n      img {\n        object-fit: cover;\n        filter: brightness(85%);\n      }\n    }\n\n    .rounded-edges &,\n    .rounded-edges & .image-filler {\n      border-radius: var(--kbin-rounded-edges-radius);\n    }\n\n    .view-compact & {\n      width: 170px;\n      height: 100%;\n      margin: 0 0 0 var(--kbin-entry-element-spacing);\n    }\n\n    .rounded-edges .view-compact & {\n      border-top-left-radius: 0 !important;\n      border-bottom-left-radius: 0 !important;\n    }\n\n    .figure-badge {\n      bottom: .25rem;\n      right: .25rem;\n      gap: .25rem;\n    }\n\n    .sensitive-button-label {\n      line-height: 1rem;\n    }\n\n    @include b.media-breakpoint-down(lg) {\n      width: 140px;\n      height: calc(140px / 1.5); // 3:2 ratio\n    }\n\n    @include b.media-breakpoint-down(sm) {\n      margin: 0;\n      height: 110px;\n      width: 100%;\n\n      .view-compact & {\n        margin: 0 10px 10px 10px;\n        height: calc(100% - 10px);\n        width: calc(100% - 10px);\n\n        .sensitive-button-hide {\n          display: none;\n        }\n\n        .figure-badge {\n          display: none;\n        }\n      }\n\n      .rounded-edges & {\n        border-bottom-left-radius: 0 !important;\n        border-bottom-right-radius: 0 !important;\n      }\n\n      .rounded-edges .view-compact & {\n        border-radius: var(--kbin-rounded-edges-radius) !important;\n      }\n    }\n  }\n\n  .no-image-placeholder {\n    background: var(--kbin-vote-bg);\n    font-size: 2.5rem;\n\n    a {\n      display: flex;\n      height: 100%;\n      align-items: center;\n      justify-content: center;\n    }\n\n    i {\n      color: var(--kbin-vote-text-color);\n      opacity: .5;\n    }\n\n    .view-compact & {\n      display: none;\n    }\n\n    @include b.media-breakpoint-down(sm) {\n      display: none;\n    }\n  }\n\n  &.no-image {\n    figure {\n      display: none;\n    }\n  }\n\n  header {\n    align-items: flex-start;\n    display: flex;\n    flex-wrap: wrap;\n    grid-area: title;\n    margin: var(--kbin-entry-element-spacing);\n    overflow-wrap: anywhere;\n\n    h2, h1 {\n      font-size: 1.0rem;\n      font-weight: 600;\n      line-height: 1.2;\n      margin: 0;\n\n      a:visited {\n        color: var(--kbin-entry-link-visited-color);\n      }\n\n      a:hover {\n        color: var(--kbin-link-hover-color);\n      }\n    }\n\n    h1 {\n      font-size: 1.3rem;\n    }\n  }\n\n  .short-desc {\n    grid-area: shortDesc;\n\n    p {\n      font-size: .85rem;\n      margin: 0 var(--kbin-entry-element-spacing) 1rem 0;\n    }\n  }\n\n  &__preview {\n    grid-area: preview;\n    margin: 0.5rem;\n  }\n\n  &__body {\n    grid-area: body;\n    margin-top: 1.5rem;\n  }\n\n  &__meta {\n    grid-area: meta;\n    align-self: flex-end;\n    justify-content: flex-start;\n    align-items: center;\n    column-gap: 0.25rem;\n\n    .edited {\n      font-style: italic;\n    }\n  }\n\n  footer {\n    grid-area: footer;\n    align-self: flex-end;\n    margin-bottom: var(--kbin-entry-element-spacing);\n\n    menu {\n      column-gap: 1rem;\n      display: grid;\n      grid-auto-columns: max-content;\n      grid-auto-flow: column;\n      list-style: none;\n      opacity: .75;\n\n      & > li {\n        line-height: 1rem;\n      }\n\n      & > a.active,\n      & > li button.active {\n        text-decoration: underline;\n      }\n\n      button, input[type='submit'], a:not(.notification-setting) {\n        @include mbin.btn-link;\n      }\n    }\n\n    .view-compact & {\n      margin-bottom: 0.3rem;\n    }\n  }\n\n  &__domain {\n    color: var(--kbin-meta-text-color);\n    font-size: .7rem;\n    white-space: nowrap;\n\n    a {\n      color: var(--kbin-meta-text-color);\n    }\n\n    i {\n      font-size: .6rem;\n    }\n  }\n\n  .loader {\n    height: 20px;\n    position: absolute;\n    width: 20px;\n  }\n\n  &:hover,\n  &:focus-within {\n    footer menu,\n    .entry__meta {\n      @include ani.fade-in(.5s, .75);\n    }\n  }\n\n  &--single {\n    border-top: 0;\n    margin-top: 0;\n\n    .entry__body {\n      margin: 0 var(--kbin-entry-element-spacing) var(--kbin-entry-element-spacing) var(--kbin-entry-element-spacing);\n      padding: 3px;\n\n      .content *:last-child {\n        margin-bottom: 0;\n      }\n\n      .more {\n        width: inherit;\n        margin: var(--kbin-entry-element-spacing);\n        margin-top: 1rem;\n      }\n\n      h1, h2, h3, h4, h5, h6 {\n        margin: 1rem auto;\n      }\n\n      h1 {\n        font-size: 1.25rem;\n      }\n\n      h2 {\n        font-size: 1.20rem;\n      }\n\n      h3 {\n        font-size: 1.15rem;\n      }\n\n      h4 {\n        font-size: 1.10rem;\n      }\n\n      h5 {\n        font-size: 1.05rem;\n      }\n\n      h6 {\n        font-size: 1rem;\n      }\n    }\n\n    .no-image-placeholder {\n      display: none;\n    }\n\n    .rounded-edges .view-compact & figure {\n      border-bottom-right-radius: 0;\n    }\n\n    @include b.media-breakpoint-down(sm) {\n      .rounded-edges .view-compact & figure {\n        border-bottom-right-radius: var(--kbin-rounded-edges-radius);\n      }\n    }\n  }\n\n  small {\n    font-size: .75rem;\n  }\n\n  .badge {\n    display: inline-block;\n    position: relative;\n    top: -2px;\n    padding: .25rem;\n  }\n}\n\n.entries-cross-2, .entries-cross-3 {\n  display: grid;\n  margin: 0;\n  padding: 0;\n\n  @include b.media-breakpoint-down(lg) {\n    display: block;\n  }\n}\n\n.entries-cross-2 {\n  grid-template-columns: repeat(2, 1fr);\n}\n\n.entries-cross-3 {\n  grid-template-columns: repeat(3, 1fr);\n}\n\n.entry-cross {\n  grid-template-areas: \"vote meta\"\n                       \"preview preview\"\n                       \"moderate moderate\" !important;\n  grid-template-columns: min-content 1fr !important;\n  margin-top: -.5rem;\n\n  header {\n    p {\n      font-size: .9rem;\n    }\n  }\n\n  .vote span {\n    font-size: .7rem;\n  }\n\n  .vote button {\n    height: 1.5rem\n  }\n\n  .vote {\n    margin-right: 0 !important;\n  }\n\n  @include b.media-breakpoint-down(sm) {\n    .vote {\n      margin-left: var(--kbin-entry-element-spacing) !important;\n    }\n  }\n\n  footer {\n    margin-bottom: 0;\n  }\n\n  .meta {\n    align-self: center;\n\n    @include b.media-breakpoint-down(lg) {\n      & div {\n        white-space: nowrap;\n        overflow: hidden;\n        text-overflow: ellipsis;\n        max-width: 270px;\n      }\n    }\n\n    @include b.media-breakpoint-up(lg) {\n      .entries-cross-2 & div, .entries-cross-3 & div {\n        white-space: nowrap;\n        overflow: hidden;\n        text-overflow: ellipsis;\n      }\n\n      .entries-cross-2 & div {\n        max-width: 370px;\n      }\n\n      .entries-cross-3 & div {\n        max-width: 270px;\n      }\n    }\n  }\n\n  footer {\n    align-self: center;\n    margin-left: 0;\n    margin-bottom: 0 !important;\n  }\n\n  .entry__preview {\n    opacity: 1.0;\n  }\n}\n\n.page-entry-create {\n  .container {\n    margin: 0 auto;\n    max-width: 30rem;\n\n    .params {\n      margin-bottom: 2.5rem !important;\n    }\n  }\n}\n\n.page-entry-single {\n  .entry-comments {\n    margin-bottom: 0.5rem;\n  }\n}\n"
  },
  {
    "path": "assets/styles/components/_figure_image.scss",
    "content": "// main wrapper\n.figure-container {\n  position: relative;\n  width: fit-content;\n  height: fit-content;\n}\n\n// main image thumbnail\n.figure-thumb {\n  .thumb {\n    display: block;\n  }\n\n  img {\n    display: block;\n    width: 100%;\n    height: 100%;\n    max-width: 600px;\n    max-height: 500px;\n  }\n}\n\n// blurhash image obscuring main image\n.figure-blur {\n  position: absolute;\n  top: 0;\n  left: 0;\n  width: 100%;\n  height: 100%;\n\n  img {\n    width: 100%;\n    height: 100%;\n    overflow: hidden;\n    object-fit: cover;\n  }\n}\n\n// checkbox to store sensitive state\ninput.sensitive-state {\n  position: absolute;\n  top: 0;\n  left: 0;\n  width: 0;\n  height: 0;\n  opacity: 0;\n  z-index: -1;\n}\n\n.figure-badge {\n  position: absolute;\n  pointer-events: none;\n  bottom: .5rem;\n  right: .5rem;\n\n  display: flex;\n  gap: .5rem;\n\n  &-label {\n    padding: .2rem .4rem;\n\n    background: var(--kbin-button-secondary-bg);\n    opacity: .5;\n\n    font-weight: 500;\n    font-size: .75rem;\n    line-height: 1rem;\n    text-align: center;\n\n    i {\n      font-size: 1rem;\n    }\n\n    .rounded-edges & {\n      border-radius: var(--kbin-rounded-edges-radius);\n    }\n  }\n}\n\n// button to toggle sensitive\n.sensitive-button {\n  position: absolute;\n\n  &-label {\n    background: var(--kbin-button-secondary-bg);\n    padding: .5rem;\n\n    font-weight: normal;\n    font-size: .8rem;\n    text-align: center;\n\n    opacity: .8;\n\n    i {\n      font-size: 1rem;\n    }\n\n    .rounded-edges & {\n      border-radius: var(--kbin-rounded-edges-radius);\n    }\n\n    &:hover,\n    &:active {\n      opacity: 1;\n    }\n  }\n\n  &-show {\n    top: 0;\n    left: 0;\n    width: 100%;\n    height: 100%;\n\n    .sensitive-button-label {\n      position: absolute;\n      top: 50%;\n      left: 50%;\n      transform: translate(-50%,-50%);\n    }\n  }\n\n  &-hide {\n    top: .5rem;\n    right: .5rem;\n\n    .sensitive-button-label {\n      opacity: .5;\n      line-height: 1rem;\n\n      i {\n        font-size: .9rem;\n      }\n\n      &:hover,\n      &:active {\n        opacity: .7;\n      }\n    }\n  }\n}\n\n// the magic part: toggle visibility depending on sensitive state\n.sensitive-state {\n  ~ .sensitive-checked--hide {\n    display: initial;\n  }\n\n  ~ .sensitive-checked--show {\n    display: none;\n  }\n}\n\n.sensitive-state:checked {\n  ~ .sensitive-checked--hide {\n    display: none;\n  }\n\n  ~ .sensitive-checked--show {\n    display: revert;\n  }\n}\n"
  },
  {
    "path": "assets/styles/components/_figure_lightbox.scss",
    "content": ".glightbox-container {\n  .goverlay {\n    background: rgba(0, 0, 0, 0.7);\n  }\n\n  .gslide-description {\n    font-family: var(--kbin-body-font-family);\n    background: var(--kbin-body-bg);\n  }\n\n  .gdesc-inner {\n    padding: 1rem;\n  }\n\n  .gslide-title,\n  .gslide-desc {\n    font-family: var(--kbin-body-font-family);\n    font-size: 0.9rem;\n    color: var(--kbin-text-color);\n    max-height: 12rem;\n    overflow-wrap: break-word;\n    overflow-x: hidden;\n    overflow-y: scroll;\n  }\n\n  .gslide-image img {\n    max-height: 95vh;\n  }\n}\n\n.glightbox-mobile .glightbox-container {\n  .goverlay {\n    background: rgba(0, 0, 0, 0.7);\n  }\n\n  .gslide-description {\n    font-family: var(--kbin-body-font-family);\n    background: color-mix(in srgb, var(--kbin-body-bg) 85%, transparent);\n  }\n\n  .gslide-title,\n  .gslide-desc {\n    font-family: var(--kbin-body-font-family);\n    font-size: 0.9rem;\n    color: var(--kbin-text-color);\n    overflow: unset;\n    max-height: unset;\n  }\n\n  .gslide-image img {\n    max-width: 95vw;\n  }\n}\n\n.gdesc-open {\n  .gslide-media {\n    filter: brightness(.7);\n    opacity: 1;\n\n    -webkit-transition: filter .5s ease;\n    transition: filter .5s ease;\n  }\n\n  &.glightbox-mobile {\n    .gslide-description {\n      background: var(--kbin-body-bg);\n    }\n  }\n}\n\n.gdesc-closed .gslide-media {\n  filter: brightness(1);\n  opacity: 1;\n\n  -webkit-transition: filter .5s ease;\n  transition: filter .5s ease;\n}\n"
  },
  {
    "path": "assets/styles/components/_filter_list.scss",
    "content": ".filter-list {\n  h3 {\n    margin-top: 0;\n    margin-bottom: 1rem;\n  }\n\n  .flex {\n    gap: 1rem;\n  }\n}\n"
  },
  {
    "path": "assets/styles/components/_header.scss",
    "content": "@use '../layout/breakpoints' as b;\n\n#header {\n  align-items: end;\n  background: var(--kbin-header-bg);\n  color: var(--kbin-header-text-color);\n  font-size: .85rem;\n  position: relative;\n  z-index: 10;\n  height: 3.25rem;\n  line-height: normal;\n\n  #logo {\n    height: 1.75rem;\n  }\n\n  .dropdown__menu {\n    display: none;\n  }\n\n  .dropdown:focus-within,\n  .dropdown:hover {\n    .dropdown__menu {\n      display: block;\n\n      @include b.media-breakpoint-down(sm) {\n        left: auto;\n        top: 100%;\n        transform: none;\n        right: 0;\n        min-width: 10rem;\n      }\n    }\n\n  }\n\n  menu {\n    .dropdown__menu {\n      left: -3.75rem;\n      opacity: 1;\n    }\n\n    .dropdown:last-of-type .dropdown__menu {\n      left: auto;\n    }\n  }\n\n  .mbin-container {\n    display: grid;\n    grid-template-areas: 'sr-nav brand magazine nav menu';\n    grid-template-columns: min-content max-content max-content auto max-content;\n    position: relative;\n    height: 100%;\n\n    @include b.media-breakpoint-down(lg) {\n      max-width: 100vw;\n    }\n\n    & > menu {\n      @include b.media-breakpoint-down(lg) {\n        margin-right: .5rem;\n      }\n    }\n  }\n\n  .fixed-navbar & {\n    position: sticky;\n    top: 0;\n  }\n\n  .topbar & {\n    padding-top: 1.25rem;\n    height: 4.5rem;\n  }\n\n  .login,\n  .counter a {\n    font-weight: normal;\n  }\n\n  .user-name {\n    height: 1.25rem;\n  }\n\n  .login {\n\n    @include b.media-breakpoint-down(sm) {\n\n      white-space: nowrap;\n\n      .user-name {\n        text-overflow: ellipsis;\n        overflow: hidden;\n        max-width: 12vw;\n        padding: 0 .25rem\n      }\n\n    }\n\n  }\n\n  .login.has-avatar {\n    display: flex;\n    align-items: center;\n    gap: .3rem;\n\n    .user-avatar {\n      border-radius: 50%;\n      height: 1.5625rem;\n      width: 1.5625rem;\n    }\n\n    @include b.media-breakpoint-down(sm) {\n\n      .user-name {\n        display: none;\n      }\n    }\n\n  }\n\n  .counter a {\n    min-width: unset;\n  }\n\n  .badge {\n    display: inline-flex;\n    justify-content: center;\n    align-items: center;\n    height: 1.5625rem;\n    min-width: 1.5625rem;\n  }\n\n  a {\n    color: var(--kbin-header-link-color);\n\n    &:hover {\n      color: var(--kbin-header-link-hover-color);\n    }\n  }\n\n  nav {\n    display: flex;\n    grid-area: nav;\n  }\n\n  menu {\n    grid-area: menu;\n    display: flex;\n    align-items: center;\n    list-style: none;\n\n    .sidebar-link {\n      display: none;\n    }\n\n    .icon i {\n      font-size: .85rem;\n    }\n\n    li {\n      display: flex;\n      align-items: center;\n      height: 100%;\n    }\n\n    li a {\n      border-bottom: 3px solid transparent;\n      padding: 3px 1rem 0;\n      display: flex;\n      align-items: center;\n      justify-content: center;\n      min-width: 3rem;\n      height: 100%;\n\n      @include b.media-breakpoint-down(sm) {\n        padding: 3px 0 0;\n        min-width: 2.5rem;\n      }\n    }\n\n    li a:hover {\n      border-bottom: var(--kbin-header-hover-border);\n\n    }\n\n    li .active {\n      border-bottom: var(--kbin-header-hover-border);\n    }\n\n    .magazine {\n      align-self: center;\n      margin-left: 1rem;\n      padding-top: .2rem;\n\n      span {\n        color: var(--kbin-header-text-color);\n        font-weight: 100;\n        opacity: .75;\n      }\n    }\n  }\n\n  .sr-nav {\n    grid-area: sr-nav;\n    z-index: 100;\n\n    a {\n      background-color: white;\n      border: 0;\n      clip: rect(0, 0, 0, 0);\n      font-size: 1.3rem;\n      font-weight: bold;\n      height: 1px;\n      left: 0;\n      overflow: hidden;\n      padding: .5rem 1rem;\n      position: absolute;\n      top: 0;\n      white-space: nowrap;\n      width: 1px;\n\n      &:focus {\n        clip: auto;\n        color: black;\n        height: auto;\n        outline: solid 4px darkorange;\n        overflow: visible;\n        position: absolute;\n        white-space: normal;\n        width: auto;\n      }\n    }\n  }\n\n  .brand {\n    display: flex;\n    font-weight: 400;\n    text-decoration: none;\n    height: 100%;\n\n    #nav-toggle {\n      display: none;\n      font-size: .9rem;\n      cursor: pointer;\n    }\n\n    a {\n      display: flex;\n      align-items: center;\n      justify-content: center;\n      padding: 0 1rem;\n      height: 100%;\n\n      span {\n        font-size: clamp(.6875rem, 3.5vw, 1.5rem);\n      }\n    }\n\n    @include b.media-breakpoint-down(sm) {\n      #nav-toggle {\n        min-width: 2.5rem;\n      }\n    }\n\n    @include b.media-breakpoint-down(lg) {\n\n      a {\n        gap: .5rem;\n        padding: 0;\n\n        span {\n          line-height: normal;\n        }\n      }\n\n      #logo {\n        height: 1.5rem;\n      }\n\n      #nav-toggle {\n        display: flex;\n        align-items: center;\n        justify-content: center;\n        min-width: 3rem;\n        height: 100%;\n      }\n    }\n  }\n\n  .head-title {\n    align-items: center;\n    display: flex;\n    height: 100%;\n\n    span {\n      opacity: 0.5;\n    }\n\n    a {\n      padding-left: 0;\n\n      &:hover {\n        border-bottom-color: transparent;\n      }\n    }\n\n    @include b.media-breakpoint-down(lg) {\n      color: var(--kbin-meta-text-color);\n\n      span {\n        padding-left: .5rem;\n        width: max-content;\n      }\n\n      a {\n        padding-left: 0;\n        font-weight: bold;\n      }\n    }\n  }\n\n  .head-nav {\n\n    @include b.media-breakpoint-down(lg) {\n      overflow: hidden;\n\n      &__menu {\n        display: none;\n      }\n    }\n\n    @include b.media-breakpoint-up(lg) {\n\n      li a.active {\n        background: var(--kbin-header-link-active-bg);\n      }\n\n      &__mobile-menu {\n        display: none;\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "assets/styles/components/_infinite_scroll.scss",
    "content": ".infinite-scroll {\n  text-align: center;\n\n  .loader {\n    margin: 2rem 0;\n  }\n}\n"
  },
  {
    "path": "assets/styles/components/_inline_md.scss",
    "content": ".entry-inline,\n.entry-comment-inline,\n.post-inline,\n.post-comment-inline {\n  display: inline-block;\n  font-weight: bold;\n  border: var(--kbin-section-border);\n  padding: .25em;\n  background: var(--kbin-bg);\n\n  a .fa-photo-film {\n    margin-right: .25em;\n  }\n}\n\n.rounded-edges {\n  .entry-inline,\n  .entry-comment-inline,\n  .post-inline,\n  .post-comment-inline {\n    border-radius: var(--kbin-rounded-edges-radius);\n  }\n}\n"
  },
  {
    "path": "assets/styles/components/_login.scss",
    "content": "@use '../layout/breakpoints' as b;\n\n.page-login,\n.page-register,\n.page-reset-password,\n.page-reset-password-email-sent,\n.page-resend-activation-email {\n  #content .container {\n    margin: 0 auto;\n    max-width: 30rem;\n\n    p {\n      margin-bottom: .5rem;\n    }\n\n    a {\n      font-weight: bold;\n    }\n\n    .separator {\n      display: none;\n      height: 0;\n    }\n    .separator:has(+ div p), .separator.separator-show {\n      display: block;\n      border: var(--kbin-section-border);\n      height: 0;\n      margin: 20px 30px;\n    }\n\n    .actions {\n      margin-top: 1rem;\n      display: block;\n    }\n\n    .social {\n      grid-template-columns: repeat(1, 1fr);\n      display: grid;\n      gap: 0.5rem;\n      justify-content: center;\n\n      @include b.media-breakpoint-up(lg) {\n        &:has(a + a) {\n          grid-template-columns: repeat(2, 1fr);\n        }\n      }\n\n      a {\n        text-align: center;\n      }\n    }\n  }\n}\n\n.page-2fa {\n  #content .container {\n    margin: 0 auto;\n    max-width: 30rem;\n\n    .actions {\n      display: flex;\n      gap: 1rem;\n      justify-content: flex-end;\n      align-items: center;\n    }\n  }\n}\n\n.page-reset-password-email-sent {\n  p:last-of-type {\n    margin-top: 2rem;\n    text-align: right;\n  }\n}\n"
  },
  {
    "path": "assets/styles/components/_magazine.scss",
    "content": "@use '../layout/breakpoints' as b;\n\n.magazine {\n  .panel {\n    margin-bottom: 1rem;\n    text-align: center;\n\n    a, button {\n      display: block;\n      width: 100%;\n    }\n  }\n\n  header {\n    text-align: center;\n\n    h4, h4 a {\n      font-size: 1.2rem;\n      margin-bottom: 0;\n      margin-top: .5rem;\n    }\n  }\n\n  figure {\n    text-align: center;\n  }\n\n  &__name {\n    margin-top: 0;\n\n    i {\n      font-size: 0.7rem;\n    }\n  }\n\n  &__description,\n  &__rules {\n    ul {\n      padding-left: 1.5rem;\n\n      li {\n        margin-bottom: .5rem;\n      }\n    }\n\n    ol {\n      padding-left: 1.5rem;\n\n      li {\n        margin-left: 2px;\n        margin-bottom: 0.5rem;\n      }\n    }\n  }\n\n  &__description {\n    margin-top: 2.5rem;\n  }\n\n  &__subscribe {\n    display: flex;\n    flex-direction: row;\n    justify-content: center;\n    flex-wrap: wrap;\n\n    div {\n      align-items: center;\n      background: var(--kbin-button-secondary-bg);\n      border: var(--kbin-button-secondary-border);\n      color: var(--kbin-button-secondary-text-color);\n      display: flex;\n      flex-direction: row;\n      font-size: .9rem;\n      left: 1px;\n      padding: .3rem .5rem;\n      position: relative;\n\n      .rounded-edges & {\n        border-radius: .5rem;\n      }\n    }\n\n    .action{\n      span{\n        margin-left: 0.5rem;\n      }\n    }\n\n    button {\n      height: 100%;\n      padding-bottom: .5rem;\n      padding-top: .5rem;\n    }\n\n    form:last-of-type {\n      position: relative;\n      right: 1px;\n    }\n  }\n}\n\n.magazine-inline {\n  img {\n    border-radius: 50%;\n    vertical-align: middle;\n    margin-right: 0.25rem;\n\n    @include b.media-breakpoint-down(sm){\n      width: 25px;\n      height: 25px;\n    }\n  }\n}\n\n.magazines-cards {\n  display: grid;\n  gap: 1rem;\n  grid-template-columns: repeat(2, 1fr);\n\n  @include b.media-breakpoint-down(sm) {\n    grid-template-columns: repeat(1, 1fr);\n  }\n\n  .magazine {\n    align-content: start;\n    align-items: center;\n    display: grid;\n    grid-template-rows: auto;\n  }\n  h3 {\n    border-bottom: var(--kbin-sidebar-header-border);\n    color: var(--kbin-sidebar-header-text-color);\n    font-size: .8rem;\n    margin: 0 0 1rem;\n    text-transform: uppercase;\n  }\n  .content {\n    font-size: 0.85rem;\n  }\n}\n\n.magazines-columns,\n.domains-columns {\n  font-size: .9rem;\n\n  ul {\n    display: grid;\n    grid-gap: 1rem;\n    grid-template-columns: repeat(3, 1fr);\n    margin: 0;\n    padding: 0;\n\n    @include b.media-breakpoint-down(lg) {\n      grid-template-columns: repeat(2, 1fr);\n    }\n\n    @include b.media-breakpoint-down(sm) {\n      grid-template-columns: repeat(1, 1fr);\n    }\n  }\n\n  ul figure {\n    margin: 0 .5rem 0 0;\n  }\n\n  ul li {\n    align-items: center;\n    display: flex;\n    list-style: none;\n    position: relative;\n  }\n\n  ul li a {\n    display: block\n  }\n\n  ul li small {\n    color: var(--kbin-meta-text-color);\n    font-size: .85rem;\n  }\n\n  .stretched-link {\n    small {\n      &.badge.danger {\n        color: var(--kbin-danger-color);\n      }\n    }\n  }\n}\n\ntd {\n  .magazine__subscribe {\n    margin: 0;\n  }\n}\n\n.page-magazine-panel {\n  .container {\n    margin: 0 auto;\n    max-width: 30rem;\n  }\n\n  .report {\n    div {\n      margin-bottom: .5rem;\n    }\n  }\n\n  .actions {\n    margin-bottom: 0 !important;\n\n    @include b.media-breakpoint-down(sm) {\n      display: flex;\n      flex-wrap: wrap;\n    }\n  }\n\n  .users-columns,\n  .columns {\n    .actions {\n      margin-left: 1rem;\n\n      .btn {\n        padding: .5rem;\n      }\n    }\n  }\n}\n\n.related-magazines {\n  h3 {\n    margin: 0 0 .5rem !important;\n  }\n\n  ul.meta li:first-child {\n    border-top: 0 !important;\n    margin-top: 0 !important;\n  }\n}\n\n.magazines.table-responsive{\n  display: none;\n\n  @include b.media-breakpoint-up(md){\n    display: block;\n  }\n\n  td:first-of-type {\n    max-width: 220px;\n  }\n}\n\n.magazine-list-mobile{\n  display: none;\n\n  .magazines__sortby{\n    display: flex;\n    column-gap: 0.5rem;\n    row-gap: 0.5rem;\n    align-items: center;\n    margin-bottom: 1rem;\n\n    span{\n      display: flex;\n    }\n  }\n\n  .magazine{\n    position: relative;\n    border-bottom: var(--kbin-section-border);\n    padding: 1rem;\n    font-size: 0.9rem;\n\n    &:nth-of-type(even){\n      background-color: var(--kbin-bg-nth);\n    }\n\n    &__top{\n      display: flex;\n      justify-content: flex-start;\n      align-items: center;\n      column-gap: 1rem;\n      row-gap: 0.5rem;\n      flex-wrap: wrap;\n    }\n\n    &__inline{\n      width: 100%;\n      position: relative;\n    }\n\n    &__sub{\n\n    }\n\n    &__information{\n      justify-content: space-evenly;\n      display: flex;\n      column-gap: 1rem;\n      width: 100%;\n\n      > span{\n        border-left:  var(--kbin-section-border);\n        padding-left: 1rem;\n        margin-right: auto;\n\n        &:first-child{\n          padding-left: 0px;\n          border:0px;\n        }\n      }\n\n    }\n\n    &__info{\n      display: flex;\n      flex-direction: column;\n      justify-content: center;\n      align-items: center;\n\n      .value{\n        font-weight: bold;\n      }\n    }\n\n    span{\n      position: relative;\n    }\n\n    &__subscribe{\n      margin-bottom: 0px;\n    }\n  }\n\n  @include b.media-breakpoint-up(md){\n    border: solid 1px red;\n  }\n\n  @include b.media-breakpoint-down(md){\n    display: block;\n  }\n\n\n}\n\n.new-magazine-icon {\n  color: green;\n}\n"
  },
  {
    "path": "assets/styles/components/_main.scss",
    "content": "#main {\n  padding-bottom: 1rem;\n}\n"
  },
  {
    "path": "assets/styles/components/_media.scss",
    "content": "@use '../layout/breakpoints' as b;\n\n.media {\n  text-align: left;\n  align-items: center;\n  display: flex;\n  gap: 1rem;\n\n  .actions {\n    display: flex;\n  }\n\n  > div {\n    display: flex;\n    flex-flow: row wrap;\n    gap: 1rem;\n    flex: 1;\n    max-width: 25rem;\n\n    &:first-child {\n      height: 12.5rem;\n      width: 12.5rem;\n\n      @include b.media-breakpoint-down(md) {\n        height: 10rem;\n      }\n    }\n\n    > div {\n      width: 100%\n\n\n    }\n    @include b.media-breakpoint-down(md) {\n      flex: 100%;\n    }\n\n    .image-input {\n      max-width: 100%;\n    }\n  }\n\n  @include b.media-breakpoint-down(md) {\n    flex-flow: row wrap;\n    justify-content: center;\n  }\n\n  .image-preview-container {\n    display: flex;\n    justify-content: center;\n    align-items: center;\n    position: relative;\n    background: rgba(0, 0, 0, 0.75);\n\n    img {\n      display: none;\n      max-width: 100%;\n      max-height: 100%;\n    }\n\n    .image-preview-clear {\n      color: var(--kbin-button-secondary-text-color);\n      background: var(--kbin-button-secondary-bg);\n      border: var(--kbin-button-secondary-border);\n      position: absolute;\n      top: 0.25rem;\n      right: 0.25rem;\n      width: 1.75rem;\n      height: 1.75rem;\n      padding: 0;\n      text-align: center;\n      font-weight: bold;\n      display: none;\n      cursor: pointer;\n\n      &:hover {\n        background: var(--kbin-button-secondary-hover-bg);\n        color: var(--kbin-button-secondary-text-hover-color);\n      }\n    }\n  }\n\n  .image-form {\n    width: 100%;\n  }\n}\n\n.comment-add,\n.post-add,\n.comment-edit,\n.post-edit,\n.page-entry-create {\n  .dropdown {\n    @include b.media-breakpoint-down(lg) {\n      position: static;\n    }\n  }\n\n  .dropdown__menu {\n    padding: 1.5rem;\n    left: 50%;\n    top: auto;\n    bottom: calc(100% + 1rem);\n    z-index: 5;\n\n    @include b.media-breakpoint-down(md) {\n      padding: 1rem;\n      width: 100%;\n      max-width: 20rem;\n    }\n  }\n\n  .media {\n    color: var(--kbin-meta-text-color);\n    font-size: .9rem;\n    list-style: none;\n  }\n}\n\n.page-post-front,\n.page-post-create {\n  .post-add {\n    .dropdown__menu {\n      margin-top: .5rem;\n      bottom: auto;\n    }\n  }\n}\n"
  },
  {
    "path": "assets/styles/components/_messages.scss",
    "content": ".page-messages {\n  #main .thread {\n    display: flex;\n    gap: .5rem;\n    justify-content: space-between;\n  }\n\n  .message-view {\n    max-height: calc(100vh - 11em);\n    overflow: auto;\n    position: relative;\n  }\n\n  .thread-participants {\n    position: absolute;\n  }\n\n  .section--top {\n    padding: .25em;\n  }\n\n  .message {\n    max-width: 75%;\n    width: fit-content;\n    min-width: 20%;\n    padding: .25em .5em;\n\n    p {\n      margin-bottom: .25em;\n    }\n  }\n\n  .message-self {\n    margin-left: auto;\n  }\n\n  .message-other {\n\n  }\n\n  .col {\n    flex: 1 1 auto;\n    margin-bottom: 0;\n  }\n\n  .col-auto {\n    flex: 0 0 auto;\n    margin-bottom: 0;\n  }\n\n  .message-form {\n    margin: 0;\n    display: flex;\n    position: relative;\n    bottom: 0;\n\n    form div {\n      margin-bottom: 0;\n    }\n\n    .message-input {\n      height: 3em;\n      padding: .75em;\n    }\n  }\n\n  .message-view-container {\n    position:absolute;\n    width:calc(100% - 1em);\n    height: calc(100vh - 4em);\n  }\n}\n"
  },
  {
    "path": "assets/styles/components/_modlog.scss",
    "content": ".page-modlog {\n  #main .log {\n    display: flex;\n    gap: 1rem;\n    justify-content: space-between;\n  }\n\n  .log span {\n    min-width: fit-content;\n  }\n}\n"
  },
  {
    "path": "assets/styles/components/_monitoring.scss",
    "content": ".page-admin-monitoring {\n  h1, h2, h3, h4, h5, h6 {\n    margin-top: 0;\n  }\n  .monitoring-twig-render {\n    .children {\n      margin-left: 2rem;\n    }\n  }\n\n  .more {\n    background: var(--kbin-bg);\n    cursor: pointer;\n    text-align: center;\n    width: 100%;\n    margin-top: 1rem;\n\n    // bigger button for touch devices\n    @media (pointer:none), (pointer:coarse) {\n      margin-top: 2rem;\n      padding: 0.5rem;\n    }\n\n    i {\n      padding: .35rem;\n      pointer-events: none;\n    }\n\n    .rounded-edges & {\n      border-radius: var(--kbin-rounded-edges-radius);\n    }\n  }\n\n  input[type=text],\n  input[type=datetime-local],\n  select {\n    padding: 0.65rem;\n    width: 100%;\n  }\n\n  form div {\n    margin-bottom: .25rem;\n  }\n\n  table tr {\n    vertical-align: top;\n    td.query * {\n      margin: 0;\n      overflow: hidden;\n    }\n  }\n\n  .row {\n    display: flex;\n    flex-direction: row;\n\n    .col {\n      flex: 1 1;\n      margin-bottom: 0;\n      padding: 0 .25rem;\n    }\n\n    .col-auto {\n      flex: 0 0 auto;\n      margin-bottom: 0;\n    }\n\n    .btn-col {\n      text-align: right;\n      margin: auto;\n    }\n  }\n}\n"
  },
  {
    "path": "assets/styles/components/_notification_switch.scss",
    "content": ".notification-switch-container .notification-switch {\n  align-items: center;\n  justify-content: center;\n}\n\n.entry-info,\n.user-main {\n  .notification-switch > * {\n    opacity: .75;\n  }\n}\n\nfooter .notification-switch {\n  padding: 0.25em 0;\n  margin-top: 0;\n  line-height: 1.25em;\n}\n\n.notification-switch {\n  display: flex;\n  flex-direction: row;\n  margin-top: .25em;\n  line-height: 1.5;\n\n  >* {\n    cursor: pointer;\n    padding: .25em .375em;\n    border: var(--kbin-button-secondary-border);\n    background: var(--kbin-button-secondary-bg);\n    color: var(--kbin-button-secondary-text-color);\n\n    &:hover:not(.active) {\n      background: var(--kbin-button-secondary-hover-bg);\n      color: var(--kbin-button-secondary-text-hover-color);\n    }\n\n    &.active {\n      cursor: unset;\n      background: var(--kbin-button-primary-bg);\n      color: var(--kbin-button-primary-text-color);\n\n      &:hover {\n        background: var(--kbin-button-primary-hover-bg);\n        color: var(--kbin-button-primary-text-hover-color);\n      }\n    }\n\n    &:last-child {\n      border-radius: 0 1em 1em 0;\n      padding-right: .75em;\n    }\n\n    &:first-child {\n      border-radius: 1em 0 0 1em;\n      padding-left: .75em;\n    }\n  }\n}\n"
  },
  {
    "path": "assets/styles/components/_notifications.scss",
    "content": ".page-notifications {\n  #main .notification {\n    display: flex;\n    gap: .5rem;\n    justify-content: space-between;\n  }\n}\n"
  },
  {
    "path": "assets/styles/components/_pagination.scss",
    "content": "@use '../layout/breakpoints' as b;\n\n.pagination {\n  color: var(--kbin-meta-text-color);\n  display: flex;\n  gap: 1rem;\n  justify-content: center;\n  margin: .5rem 0;\n  position: relative;\n  z-index: 2;\n  padding: .5rem;\n  flex-flow: row wrap;\n\n  a, span {\n    padding: .6rem 1rem;\n  }\n\n  a {\n    font-weight: bold;\n  }\n\n  &__item--current-page {\n    background-color: var(--kbin-bg);\n  }\n\n  @include b.media-breakpoint-down(sm) {\n    justify-content: space-between;\n    gap: 0rem;\n\n    a, span {\n      padding: 1rem .5rem;\n    }\n  }\n}\n"
  },
  {
    "path": "assets/styles/components/_popover.scss",
    "content": ":root {\n  --popover-width: 250px;\n  --popover-control-gap: 4px; // ⚠️ use px units - vertical gap between the popover and its control\n  --popover-viewport-gap: 20px; // ⚠️ use px units - vertical gap between the popover and the viewport - visible if popover height > viewport height\n  --popover-transition-duration: 0.2s;\n}\n\n.popover {\n  box-shadow: var(--kbin-shadow);\n  margin-bottom: var(--popover-control-gap); // top/left position set in JS\n  margin-top: var(--popover-control-gap);\n  opacity: 0;\n  overflow: auto;\n  -webkit-overflow-scrolling: touch;\n  position: fixed;\n  transition: visibility 0s var(--popover-transition-duration), opacity var(--popover-transition-duration);\n\n  visibility: hidden;\n  //width: var(--popover-width);\n  z-index: var(--z-index-popover, 25);\n\n  a {\n    color: var(--kbin-meta-link-color);\n    line-height: normal;\n    display: inline-block;\n\n    &:hover {\n      color: var(--kbin-meta-link-color-hover);\n    }\n  }\n}\n\n.popover--is-visible {\n  opacity: 1;\n  outline: none;\n  transition: visibility 0s, opacity var(--popover-transition-duration);\n  visibility: visible;\n}\n\n.popover-control--active {\n  outline: none;\n  // class added to the trigger when popover is visible\n}\n\n.user-popover {\n  min-width: 26rem;\n\n  header {\n    display: flex;\n    gap: 1rem;\n\n    h3 {\n      font-size: 1.2rem;\n      margin: 0;\n    }\n\n    p {\n      font-size: .9rem;\n      margin: 0;\n    }\n\n    ul {\n      font-size: .9rem;\n      list-style: none;\n      padding: 0;\n\n      li {\n        div {\n          i {\n            padding-right: .5rem;\n          }\n        }\n      }\n    }\n\n    .user__actions {\n      justify-content: left;\n      margin-bottom: 1rem;\n    }\n  }\n\n  footer {\n    menu {\n      display: flex;\n      font-size: .9rem;\n      font-weight: 800;\n      gap: 1rem;\n      justify-content: space-around;\n      list-style: none;\n      position: relative;\n\n      li {\n        text-align: center;\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "assets/styles/components/_post.scss",
    "content": "@use '../layout/breakpoints' as b;\n@use '../mixins/animations' as ani;\n@use '../mixins/mbin';\n\n.post-add {\n  .ts-control {\n    min-width: 18rem;\n  }\n\n  .row {\n    flex-wrap: wrap-reverse;\n    margin-bottom: 0;\n\n    @include b.media-breakpoint-down(sm) {\n      display: block;\n\n      > div {\n        margin-bottom: 1rem;\n      }\n    }\n  }\n\n  div {\n    margin-bottom: 0;\n  }\n}\n\n.post-container {\n  margin: 0 0 1rem;\n}\n\nblockquote.post {\n  display: grid;\n  font-size: .9rem;\n  grid-gap: .5rem;\n  grid-template-areas:   \"vote header header\"\n                         \"vote body body\"\n                         \"vote meta meta\"\n                         \"vote footer footer\"\n                         \"moderate moderate moderate\";\n  grid-template-columns: min-content auto min-content;\n  margin: 0 0 .5rem;\n  padding: var(--kbin-entry-element-spacing);\n  position: relative;\n  z-index: 2;\n\n  @include b.media-breakpoint-down(sm) {\n    grid-template-areas: \"vote header header\"\n                         \"body body body\"\n                         \"meta meta meta\"\n                         \"footer footer footer\"\n                         \"moderate moderate moderate\";\n  }\n\n  &:hover,\n  &:focus-visible {\n    z-index: 3;\n  }\n\n  header {\n    grid-area: header;\n    color: var(--kbin-meta-text-color);\n    font-size: .8rem;\n    margin-bottom: 0;\n    opacity: .75;\n\n    a:not(.notification-setting) {\n      color: var(--kbin-meta-link-color);\n      font-weight: bold;\n\n      time {\n        font-weight: normal;\n      }\n    }\n  }\n\n  .content {\n    p:last-child {\n      margin-bottom: 0;\n    }\n  }\n\n  aside:not(.notification-switch) {\n    grid-area: vote;\n  }\n\n  div {\n    grid-area: body;\n\n    p {\n      margin-top: 0\n    }\n  }\n\n  > figure {\n    grid-area: avatar;\n    margin: 0;\n    display: none;\n\n    img {\n      border: var(--kbin-avatar-border);\n    }\n  }\n\n  .vote {\n    display: flex;\n    gap: .5rem;\n    justify-content: flex-end;\n  }\n\n  footer {\n    color: var(--kbin-meta-text-color);\n    font-weight: 300;\n    grid-area: footer;\n\n    .boosts {\n      font-size: .75rem;\n      opacity: .75;\n    }\n\n    menu {\n      column-gap: 1rem;\n      display: grid;\n      grid-area: meta;\n      grid-auto-columns: max-content;\n      grid-auto-flow: column;\n      list-style: none;\n      font-size: .8rem;\n      opacity: .75;\n      position: relative;\n      z-index: 4;\n\n      & > li {\n        line-height: 1rem;\n      }\n\n      & > a:not(.notification-setting).active,\n      & > li button.active {\n        text-decoration: underline;\n      }\n\n      button,\n      a:not(.notification-setting) {\n        font-size: .8rem;\n        @include mbin.btn-link;\n      }\n\n      li:first-child a {\n        padding-left: 0;\n      }\n    }\n\n    a:not(.notification-setting) {\n      @include mbin.btn-link;\n    }\n\n    figure {\n      display: block;\n      margin: .5rem 0;\n    }\n\n    button {\n      position: relative;\n    }\n  }\n\n  .loader {\n    height: 20px;\n    position: absolute;\n    width: 20px;\n  }\n\n  &:hover,\n  &:focus-within {\n    header, footer menu, footer .boosts {\n      @include ani.fade-in(.5s, .75);\n    }\n  }\n\n  &--single {\n    border-top: 0;\n    margin-top: 0;\n    padding-bottom: 2rem;\n    padding-top: 2rem;\n\n    .entry__body {\n      padding: 0 2rem;\n    }\n  }\n}\n\narticle.post {\n  display: grid;\n  grid-template-areas: \"vote image header\"\n                       \"vote image shortDesc\"\n                       \"vote image meta\"\n                       \"vote image footer\"\n                       \"moderate moderate moderate\"\n                       \"preview preview preview\";\n  grid-template-columns: min-content min-content 1fr;\n  grid-template-rows: 1fr min-content;\n  padding: 0;\n  position: relative;\n  z-index: 2;\n\n  &.no-image {\n    grid-template-areas: \"vote shortDesc\"\n                         \"header meta\"\n                         \"header footer\"\n                         \"moderate moderate\"\n                         \"preview preview\";\n    grid-template-columns: min-content 1fr;\n  }\n\n  header,\n  .vote,\n  figure,\n  .no-image-placeholder,\n  .short-desc,\n  footer,\n  &__meta {\n    margin-left: var(--kbin-entry-element-spacing);\n  }\n\n  @include b.media-breakpoint-down(sm) {\n    grid-template-areas: \"image image\"\n                         \"vote header\"\n                         \"vote shortDesc\"\n                         \"meta meta\"\n                         \"footer footer\"\n                         \"moderate moderate\"\n                         \"preview preview\";\n    grid-template-columns: min-content 1fr;\n\n    header,\n    .vote,\n    .short-desc,\n    footer,\n    &__meta {\n      margin-left: var(--kbin-entry-element-spacing);\n    }\n\n    &.no-image {\n      grid-template-areas: \"vote header\"\n                           \"vote shortDesc\"\n                           \"meta meta\"\n                           \"footer footer\"\n                           \"moderate moderate\"\n                           \"preview preview\";\n      grid-template-columns: min-content 1fr;\n    }\n\n    .view-compact & {\n      grid-template-areas: \"shortDesc shortDesc shortDesc vote\"\n                           \"header meta meta image\"\n                           \"footer footer footer image\"\n                           \"moderate moderate moderate moderate\"\n                           \"preview preview preview preview\";\n      grid-template-columns: max-content 1fr min-content min-content;\n\n      .vote {\n        justify-content: right;\n        margin-right: var(--kbin-entry-element-spacing);\n        margin-left: 0;\n      }\n\n      header {\n        margin-top: 0.2rem;\n        margin-bottom: 0;\n        flex-flow: row-reverse;\n      }\n\n      .short-desc {\n        margin-top: 0.2rem;\n        margin-bottom: 0.2rem;\n\n        p {\n          max-height: 3lh;\n          margin-bottom: 0;\n        }\n      }\n    }\n\n    .view-compact &.no-meta {\n      grid-template-areas: \"shortDesc vote\"\n                           \"header header\"\n                           \"meta meta\"\n                           \"footer footer\"\n                           \"moderate moderate\"\n                           \"preview preview\";\n      grid-template-columns: 1fr min-content;\n    }\n  }\n\n  @include b.media-breakpoint-up(sm) {\n    .view-compact & {\n      grid-template-areas: \"vote header image\"\n                           \"vote shortDesc image\"\n                           \"vote meta image\"\n                           \"vote footer image\"\n                           \"moderate moderate moderate\"\n                           \"preview preview preview\";\n      grid-template-columns: min-content 1fr min-content;\n\n      header {\n        margin-top: 0.2rem;\n        margin-bottom: 0;\n      }\n\n      .short-desc {\n        margin-top: 0.2rem;\n        margin-bottom: 0.2rem;\n\n        p {\n          max-height: 3lh;\n          margin-bottom: 0;\n        }\n      }\n    }\n  }\n\n  &:hover,\n  &:focus-visible {\n    z-index: 3;\n  }\n\n  .vote {\n    grid-area: vote;\n    margin-top: var(--kbin-entry-element-spacing);\n    margin-bottom: var(--kbin-entry-element-spacing);\n  }\n\n  figure,\n  .no-image-placeholder {\n    position: relative;\n    grid-area: image;\n    margin: var(--kbin-entry-element-spacing) 0 var(--kbin-entry-element-spacing) var(--kbin-entry-element-spacing);\n    width: 170px;\n    height: calc(170px / 1.5); // 3:2 ratio\n    justify-self: right;\n    overflow: hidden;\n\n    img {\n      position: absolute;\n      top: 0;\n      height: 100%;\n      width: 100%;\n      object-fit: contain;\n      -o-object-fit: contain;\n    }\n\n    .image-filler {\n      background: var(--kbin-vote-bg);\n      position: absolute;\n      width: 100%;\n      height: 100%;\n\n      img {\n        object-fit: cover;\n        filter: brightness(85%);\n      }\n    }\n\n    .rounded-edges &,\n    .rounded-edges & .image-filler {\n      border-radius: var(--kbin-rounded-edges-radius);\n    }\n\n    .view-compact & {\n      width: 170px;\n      height: 100%;\n      margin: 0 0 0 var(--kbin-entry-element-spacing);\n    }\n\n    .rounded-edges .view-compact & {\n      border-top-left-radius: 0 !important;\n      border-bottom-left-radius: 0 !important;\n    }\n\n    .figure-badge {\n      bottom: .25rem;\n      right: .25rem;\n      gap: .25rem;\n    }\n\n    .sensitive-button-label {\n      line-height: 1rem;\n    }\n\n    @include b.media-breakpoint-down(lg) {\n      width: 140px;\n      height: calc(140px / 1.5); // 3:2 ratio\n    }\n\n    @include b.media-breakpoint-down(sm) {\n      margin: 0;\n      height: 110px;\n      width: 100%;\n\n      .view-compact & {\n        margin: 0 10px 10px 10px;\n        height: calc(100% - 10px);\n        width: calc(100% - 10px);\n\n        .sensitive-button-hide {\n          display: none;\n        }\n\n        .figure-badge {\n          display: none;\n        }\n      }\n\n      .rounded-edges & {\n        border-bottom-left-radius: 0 !important;\n        border-bottom-right-radius: 0 !important;\n      }\n\n      .rounded-edges .view-compact & {\n        border-radius: var(--kbin-rounded-edges-radius) !important;\n      }\n    }\n  }\n\n  .no-image-placeholder {\n    background: var(--kbin-vote-bg);\n    font-size: 2.5rem;\n\n    a {\n      display: flex;\n      height: 100%;\n      align-items: center;\n      justify-content: center;\n    }\n\n    i {\n      color: var(--kbin-vote-text-color);\n      opacity: .5;\n    }\n\n    .view-compact & {\n      display: none;\n    }\n\n    @include b.media-breakpoint-down(sm) {\n      display: none;\n    }\n  }\n\n  &.no-image {\n    figure {\n      display: none;\n    }\n\n    .short-desc {\n      @include b.media-breakpoint-up(sm) {\n        max-height: 1lh;\n      }\n    }\n  }\n\n  header {\n    grid-area: header;\n    align-items: flex-start;\n    display: flex;\n    flex-wrap: wrap;\n    margin: var(--kbin-entry-element-spacing);\n    overflow-wrap: anywhere;\n\n    h2, h1 {\n      font-size: 1.0rem;\n      font-weight: 600;\n      line-height: 1.2;\n      margin: 0;\n\n      a:visited {\n        color: var(--kbin-entry-link-visited-color);\n      }\n\n      a:hover {\n        color: var(--kbin-link-hover-color);\n      }\n    }\n\n    h1 {\n      font-size: 1.3rem;\n    }\n  }\n\n  .short-desc {\n    grid-area: shortDesc;\n\n    p {\n      font-size: .85rem;\n      margin: 0 var(--kbin-entry-element-spacing) 1rem 0;\n    }\n  }\n\n  &__preview {\n    grid-area: preview;\n    margin: 0.5rem;\n  }\n\n  &__meta {\n    grid-area: meta;\n    align-self: flex-end;\n    justify-content: flex-start;\n    align-items: center;\n    column-gap: 0.25rem;\n\n    .edited {\n      font-style: italic;\n    }\n  }\n\n  footer {\n    grid-area: footer;\n    align-self: flex-end;\n    margin-bottom: var(--kbin-entry-element-spacing);\n\n    menu {\n      column-gap: 1rem;\n      display: grid;\n      grid-auto-columns: max-content;\n      grid-auto-flow: column;\n      list-style: none;\n      opacity: .75;\n\n      & > li {\n        line-height: 1rem;\n      }\n\n      & > a.active,\n      & > li button.active {\n        text-decoration: underline;\n      }\n\n      button, input[type='submit'], a:not(.notification-setting) {\n        @include mbin.btn-link;\n      }\n    }\n\n    .view-compact & {\n      margin-bottom: 0.3rem;\n    }\n  }\n\n  .loader {\n    height: 20px;\n    position: absolute;\n    width: 20px;\n  }\n\n  &:hover,\n  &:focus-within {\n    footer menu,\n    .entry__meta {\n      @include ani.fade-in(.5s, .75);\n    }\n  }\n\n  small {\n    font-size: .75rem;\n  }\n\n  .badge {\n    display: inline-block;\n    position: relative;\n    top: -2px;\n    padding: .25rem;\n  }\n}\n\n.show-comment-avatar {\n  .comment>figure {\n    display: block;\n  }\n}\n\n.show-post-avatar {\n  .post>figure {\n    display: block;\n  }\n}\n.post-comments-preview {\n  margin-top: -.5rem;\n  margin-bottom: .5rem;\n}\n"
  },
  {
    "path": "assets/styles/components/_preview.scss",
    "content": "@use '../variables' as v;\n\n.preview {\n  text-align:center;\n  //display: inline-flex;\n\n  button {\n    margin: 0 .25rem;\n    padding: 0;\n  }\n\n  img {\n    max-width: 100%;\n  }\n\n  .show-preview {\n    background: none;\n    border: 0;\n    color: var(--kbin-meta-text-color);\n    display: inline-flex;\n    margin-top: .2rem;\n    padding-left: 0;\n  }\n\n  video, iframe {\n    max-width: 100%;\n  }\n}\n\n.preview-target {\n  margin: 0.5rem 0;\n\n  &:not(.hidden) {\n    display: block;\n  }\n}\n\n// Credit: Nicolas Gallagher and SUIT CSS.\n.ratio {\n  --aspect-ratio: 16 / 9;\n  aspect-ratio: 16 / 9;\n  position: relative;\n  width: 100%;\n\nimg {\nmax-width: 100%;\n}\n\n&::before {\ncontent: \"\";\ndisplay: block;\npadding-top: 56.25%;\n}\n\n> * {\nheight: 100%;\nleft: 0;\nposition: absolute;\ntop: 0;\nwidth: 100%;\n}\n}\n\n@each $key, $ratio in v.$aspect-ratios {\n.ratio-#{$key} {\naspect-ratio: #{$ratio};\n}\n}\n"
  },
  {
    "path": "assets/styles/components/_search.scss",
    "content": ".search-container {\n  background: var(--kbin-input-bg);\n  border: var(--kbin-input-border);\n  border-radius: var(--kbin-rounded-edges-radius) !important;\n\n  input.form-control {\n    border-radius: 0 !important;\n    border: none;\n    background: transparent;\n    margin: 0 .5em;\n    padding: .5rem .25rem;\n  }\n\n  button {\n    border-radius: 0 var(--kbin-rounded-edges-radius) var(--kbin-rounded-edges-radius) 0 !important;\n    border: 0;\n    &:not(.small) {\n      padding: 1rem 0.5rem;\n    }\n\n    &:not(:hover) {\n      background: var(--kbin-input-bg);\n      color: var(--kbin-input-text-color) !important;\n    }\n  }\n}\n"
  },
  {
    "path": "assets/styles/components/_settings_row.scss",
    "content": ".settings-row {\n  display: grid;\n  grid-template-areas: \"label value\";\n  grid-template-columns: auto;\n  align-items: center;\n  width: 100%;\n  background: var(--kbin-sidebar-settings-row-bg);\n\n  .rounded-edges & {\n    &:first-child {\n      border-top-left-radius: .375rem;\n      border-top-right-radius: .375rem;\n      overflow: clip;\n    }\n\n    &:last-child {\n      border-bottom-left-radius: .375rem;\n      border-bottom-right-radius: .375rem;\n      overflow: clip;\n    }\n  }\n\n  &[data-controller=\"settings-row-enum\"] {\n    grid-template-areas: \"label value\";\n  }\n\n  .label {\n    grid-area: label;\n    line-height: normal;\n    align-items: center;\n    display: flex;\n    margin-left: .375rem;\n  }\n\n  .value-container {\n    display: flex;\n    justify-content: end;\n    width: 100%;\n    padding: 4px 6px;\n    line-height: normal;\n    flex-grow: 1;\n    grid-area: value;\n\n    .link-muted.active {\n      color: var(--kbin-primary);\n      font-weight: 800 !important;\n    }\n\n    /** Enum Settings row **/\n    .enum {\n      display: flex;\n      align-items: center;\n      text-align: center;\n      background-color: var(--kbin-sidebar-settings-switch-off-bg);\n      overflow: clip;\n      font-size: .8em;\n      box-shadow: 0 .0625rem hsla(0,0%,100%,.08);\n\n      .rounded-edges & {\n        border-radius: var(--kbin-rounded-edges-radius);\n      }\n\n      input {\n        display: none;\n      }\n\n      .value {\n        cursor: pointer;\n\n        span {\n          min-width: 3rem;\n          height: 100%;\n          display: block;\n          padding: .25rem .25rem;\n          font-weight: 400;\n          color: var(--kbin-button-secondary-text-color);\n          transition: color .25s, background-color .25s, font-weight .15s;\n        }\n\n        &:hover {\n          input:checked + span {\n            background: var(--kbin-sidebar-settings-switch-hover-bg);\n            color: var(--kbin-button-primary-hover-text-color);\n          }\n\n          span {\n            background: var(--kbin-sidebar-settings-switch-hover-bg);\n            color: var(--kbin-button-secondary-text-hover-color);\n          }\n        }\n      }\n\n      input:checked + span {\n        background: var(--kbin-sidebar-settings-switch-on-bg);\n        color: var(--kbin-sidebar-settings-switch-on-color);\n        font-weight: 800;\n      }\n    }\n\n    /** Button Settings row **/\n    button {\n      background: var(--kbin-button-primary-bg);\n      color: var(--kbin-button-primary-text-color);\n      border: var(--kbin-button-primary-border);\n      cursor: pointer;\n      font-size: 0.8em;\n\n      .rounded-edges & {\n        border-radius: var(--kbin-rounded-edges-radius);\n      }\n\n      &:hover {\n        background: var(--kbin-button-primary-hover-bg);\n        color: var(--kbin-button-primary-hover-text-color);\n      }\n    }\n\n    /** Switch Settings row **/\n    .switch {\n\n      .rounded-edges & {\n        border-radius: .75rem;\n      }\n\n      input {\n        display: none;\n      }\n    }\n\n    .slider {\n      cursor: pointer;\n      background-color: var(--kbin-sidebar-settings-switch-off-bg);\n      transition: .25s;\n      display: block;\n      height: 1.25rem;\n      width: 2rem;\n      border: .125rem solid var(--kbin-sidebar-settings-switch-off-bg);\n      box-shadow: 0px .0625rem hsla(0, 0%, 100%, .08);\n\n      .rounded-edges & {\n        border-radius: .75rem;\n      }\n\n      &:hover {\n        background-color: var(--kbin-sidebar-settings-switch-hover-bg);\n        border-color: var(--kbin-sidebar-settings-switch-hover-bg);\n\n        &::before {\n          background-color: var(--kbin-sidebar-settings-switch-on-color);\n        }\n      }\n\n      &:before {\n        position: absolute;\n        content: \"\";\n        height: 1rem;\n        width: 1rem;\n        background-color: var(--kbin-sidebar-settings-switch-off-color);\n        transition: .25s;\n\n        .rounded-edges & {\n          border-radius: 50%;\n        }\n      }\n    }\n\n    input:checked + .slider {\n      background-color: var(--kbin-sidebar-settings-switch-on-bg);\n      border: .125rem solid var(--kbin-sidebar-settings-switch-on-bg);\n      box-shadow: 0px -1px hsla(0, 0%, 0%, .1);\n    }\n\n    input:checked + .slider:before {\n      transform: translateX(.75rem);\n      background: var(--kbin-sidebar-settings-switch-on-color);\n      box-shadow: inset 0 -.0625rem hsl(0, 0%, 0%, .1);\n    }\n  }\n}\n"
  },
  {
    "path": "assets/styles/components/_sidebar-subscriptions.scss",
    "content": "@use '../layout/breakpoints' as b;\n\n.rounded-edges .sidebar-subscriptions .active {\n  border-radius: 0.5rem;\n}\n\n.mbin-container.width--fixed {\n  .sidebar-subscriptions:not(.inline) .subscription-list .subscription a {\n    max-width: calc(max(305px, 1360px / 6) - 3rem);\n  }\n\n  .sidebar-subscriptions.inline .subscription-list .subscription a {\n    max-width: calc(max(305px, 1360px / 4) - 3rem);\n  }\n}\n\n.mbin-container.width--auto {\n  .sidebar-subscriptions:not(.inline) .subscription-list .subscription a {\n    max-width: calc(max(305px, 85vw / 6) - 3rem);\n  }\n\n  .sidebar-subscriptions.inline .subscription-list .subscription a {\n    max-width: calc(max(305px, 85vw / 4) - 3rem);\n  }\n}\n\n.mbin-container.width--max {\n  .sidebar-subscriptions:not(.inline) .subscription-list .subscription a {\n    max-width: calc(max(305px, 100vw / 6) - 3rem);\n  }\n\n  .sidebar-subscriptions.inline .subscription-list .subscription a {\n    max-width: calc(max(305px, 100vw / 4) - 3rem);\n  }\n}\n\n#sidebar .sidebar-subscriptions,\n.sidebar-subscriptions {\n  height: fit-content;\n\n  &:not(.inline) {\n    padding: 0 0.5rem;\n  }\n\n  &.inline {\n    padding-bottom: unset;\n  }\n\n  .sidebar-subscriptions-icons {\n    float: right;\n\n    @include b.media-breakpoint-down(lg) {\n      display: none;\n    }\n  }\n\n  .inline .subscription-list {\n    max-height: 20em;\n    overflow: auto;\n    &.lg {\n      max-height: 40em;\n    }\n  }\n\n  .inline .magazine-subscription-avatar-placeholder {\n    display: inline-block;\n  }\n\n  :not(.inline) .magazine-subscription-avatar-placeholder {\n    display: none;\n  }\n\n  .section {\n    padding: 0.5rem 0.5rem 0;\n\n    @include b.media-breakpoint-down(lg) {\n      padding: 0.5rem;\n    }\n  }\n\n  h3 {\n    margin: 0 0 .5rem;\n\n    @include b.media-breakpoint-down(lg) {\n      margin: 0;\n    }\n  }\n  .subscription-list {\n\n    &.meta :first-child {\n      border-top: unset;\n    }\n\n    @include b.media-breakpoint-down(lg) {\n      display: flex;\n      flex-direction: row;\n      overflow-y: auto;\n      max-width: calc(-2rem + 100vw);\n    }\n\n    .subscription {\n      padding: 0.5rem;\n\n      @include b.media-breakpoint-down(lg) {\n        min-width: max-content;\n      }\n\n      &:not(:last-child) {\n        @include b.media-breakpoint-up(lg) {\n          border-bottom: var(--kbin-meta-border);\n        }\n\n        @include b.media-breakpoint-down(lg) {\n          border-right: var(--kbin-meta-border);\n        }\n      }\n\n      &:last-child {\n        border-bottom: unset;\n      }\n\n      .magazine-subscription-avatar {\n        margin: 0;\n        height: 1.5rem;\n        max-height: 1.5rem;\n        width: 1.5rem;\n        max-width: 1.5rem;\n        object-fit: scale-down;\n        border-radius: 50%;\n        vertical-align: middle;\n        &.onlyMobile {\n          @include b.media-breakpoint-up(lg) {\n            display: none;\n          }\n        }\n      }\n\n      .magazine-subscription-avatar-placeholder{\n        width: 1.5rem;\n        @include b.media-breakpoint-down(lg) {\n          display: none;\n        }\n        &.onlyMobile {\n          @include b.media-breakpoint-up(lg) {\n            display: none;\n          }\n        }\n      }\n\n      &.active {\n        background-color: var(--kbin-bg);\n      }\n\n      a {\n        display: inline-block;\n        overflow: hidden;\n        text-overflow: ellipsis;\n        white-space: nowrap;\n\n        .magazine-name {\n          font-weight: bold;\n          display: inline;\n\n          &.has-image {\n            margin-left: .25rem;\n            &.onlyMobile {\n              @include b.media-breakpoint-up(lg) {\n                margin-left: 0;\n              }\n            }\n            @include b.media-breakpoint-down(lg) {\n              display: none;\n            }\n          }\n        }\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "assets/styles/components/_sidebar.scss",
    "content": "@use '../layout/breakpoints' as b;\n@use '../mixins/mbin';\n\n#sidebar,\n.sidebar-subscriptions {\n  font-size: .85rem;\n  opacity: 1;\n  padding-bottom: 1rem;\n\n  h3, h5 {\n    border-bottom: var(--kbin-sidebar-header-border);\n    color: var(--kbin-sidebar-header-text-color);\n    font-size: .8rem;\n    margin: 0 0 1rem;\n    text-transform: uppercase;\n  }\n\n  figure, blockquote {\n    margin: 0;\n  }\n\n  .options {\n    grid-template-columns: 1fr;\n\n    menu {\n      display: flex;\n\n      li {\n        flex-grow: 1;\n        flex-shrink: 0;\n        text-align: center;\n      }\n    }\n  }\n\n  .section {\n    padding: .5rem;\n  }\n\n  .sidebar-options {\n    .top-options.section {\n      padding: 0px;\n      overflow: hidden;\n\n      menu {\n        display: flex;\n        overflow: hidden;\n\n        ul {\n          list-style-type: none;\n          display: flex;\n          flex-direction: row;\n          column-gap: 0.5rem;\n          padding: 0px;\n          margin: 0px;\n          width: 100%;\n        }\n\n        li {\n          display: flex;\n          justify-content: center;\n\n          a {\n            flex-grow: 1;\n          }\n        }\n\n        li.close-button,\n        li.home-button {\n          display: none;\n        }\n      }\n\n      @include b.media-breakpoint-down(lg) {\n        menu {\n          justify-content: flex-start;\n\n          li.close-button,\n          li.home-button {\n            display: flex;\n          }\n\n          li.close-button {\n            margin-left: auto;\n          }\n        }\n      }\n    }\n  }\n\n  .posts,\n  .entries {\n    color: var(--kbin-meta-text-color);\n\n    .container {\n      @include b.media-breakpoint-down(lg) {\n        max-width: 100%;\n      }\n\n      @include b.media-breakpoint-only(md) {\n        display: grid;\n        gap: 2rem;\n        grid-template-columns: repeat(2, 1fr);\n      }\n    }\n\n    figure {\n      border-bottom: var(--kbin-meta-border);\n      margin-bottom: 1rem;\n      padding-bottom: 1rem;\n\n      &:last-child {\n        margin-bottom: 0;\n      }\n\n      .row img {\n        height: 100px;\n        margin-bottom: .5rem;\n        -o-object-fit: cover;\n        object-fit: cover;\n        width: 100%;\n      }\n\n      blockquote {\n        border: 0;\n        padding: 0;\n\n        a {\n          @include mbin.btn-link;\n        }\n\n        p:last-of-type {\n          margin: 0em;\n        }\n\n        div {\n          margin-top: 0.5em;\n        }\n      }\n\n      .more {\n        opacity: 0;\n      }\n\n      figcaption {\n        color: var(--kbin-meta-text-color);\n        text-align: right;\n      }\n    }\n  }\n\n  .entries blockquote {\n    font-weight: bold;\n  }\n\n  .meta, .info {\n    color: var(--kbin-meta-text-color);\n    list-style: none;\n    margin: 0;\n    padding: 0;\n\n    li {\n      align-items: center;\n      border-bottom: var(--kbin-meta-border);\n      display: flex;\n      flex-direction: row;\n      justify-content: space-between;\n      min-height: 3rem;\n      padding: .5rem;\n\n      position: relative;\n\n      &:first-child {\n        border-top: var(--kbin-meta-border);\n      }\n\n      a {\n        font-weight: bold;\n        padding: 0;\n      }\n\n      div {\n        i {\n          padding-right: .5rem;\n        }\n      }\n    }\n  }\n\n  .user-list {\n    ul {\n      margin: 0;\n      padding: 0;\n    }\n\n    li.moderator-item {\n      height: calc(30px + 1rem);\n    }\n\n    ul li {\n      align-items: center;\n      border-top: var(--kbin-meta-border);\n      display: flex;\n      list-style: none;\n      position: relative;\n\n      &:first-child {\n        border-top: 0;\n        padding-top: 0;\n      }\n\n      &:last-child {\n        border-bottom: var(--kbin-meta-border);\n      }\n\n      a {\n        padding: 0 .5rem;\n      }\n\n      img {\n        margin: .5rem 0;\n        border-right: 0.25rem;\n      }\n    }\n\n    footer {\n      opacity: .85;\n      padding: .5rem 0;\n      position: relative;\n      text-align: center;\n\n      a {\n        color: var(--kbin-meta-text-color);\n      }\n    }\n  }\n\n  .entry-info,\n  .user-info {\n    .row {\n      text-align: center;\n\n      h4 {\n        font-size: 1rem;\n        margin-bottom: 0;\n        margin-top: .5rem;\n      }\n    }\n\n    a:not(.notification-setting) {\n      color: var(--kbin-meta-link-color);\n    }\n\n    &_name {\n      margin-top: 0;\n    }\n\n    figure {\n      text-align: center;\n    }\n  }\n\n  .entry-info ul.info {\n    margin-top: 2.5rem;\n  }\n\n  .settings {\n    display: flex;\n    gap: 1rem;\n    justify-content: center;\n    margin-bottom: 1.5rem;\n    align-items: flex-end;\n    flex-flow: row wrap;\n\n    & + .settings {\n      margin-bottom: .5rem;\n    }\n\n    &:last-of-type {\n      margin-bottom: 0;\n    }\n\n    .theme {\n      filter: drop-shadow(1px 2px 3px hsl(0, 0%, 0%, .25));\n      height: 2rem;\n      width: 2rem;\n\n      &.light {\n        background: url(\"/assets/images/light.svg\") no-repeat;\n        background-size: 2rem;\n        background-position: center;\n\n        .theme--light & {\n          outline: 2px solid var(--kbin-sidebar-settings-switch-on-bg);\n        }\n\n        .theme--default & {\n          @media (prefers-color-scheme: light) {\n            outline: 2px solid var(--kbin-sidebar-settings-switch-on-bg);\n          }\n        }\n      }\n\n      &.dark {\n        background: url(\"/assets/images/dark.svg\") no-repeat;\n        background-size: 2rem;\n        background-position: center;\n\n        .theme--dark & {\n          outline: 2px solid var(--kbin-sidebar-settings-switch-on-bg);\n        }\n\n        .theme--default & {\n          @media (prefers-color-scheme: dark) {\n            outline: 2px solid var(--kbin-sidebar-settings-switch-on-bg);\n          }\n        }\n      }\n\n      &.kbin {\n        background: url(\"/assets/images/kbin.svg\") no-repeat;\n        background-size: 2rem;\n        background-position: center;\n\n        .theme--kbin & {\n          outline: 2px solid var(--kbin-sidebar-settings-switch-on-bg);\n        }\n      }\n\n      &.solarized-light {\n        background: url(\"/assets/images/solarized.svg\") no-repeat;\n        background-size: 2rem;\n        background-position: center;\n\n        .theme--solarized-light & {\n          outline: 2px solid var(--kbin-sidebar-settings-switch-on-bg);\n        }\n\n        .theme--solarized & {\n          @media (prefers-color-scheme: light) {\n            outline: 2px solid var(--kbin-sidebar-settings-switch-on-bg);\n          }\n        }\n      }\n\n      &.solarized-dark {\n        background: url(\"/assets/images/solarized-dark.svg\") no-repeat;\n        background-size: 2rem;\n        background-position: center;\n\n        .theme--solarized-dark & {\n          outline: 2px solid var(--kbin-sidebar-settings-switch-on-bg);\n        }\n\n        .theme--solarized & {\n          @media (prefers-color-scheme: dark) {\n            outline: 2px solid var(--kbin-sidebar-settings-switch-on-bg);\n          }\n        }\n      }\n\n      &.tokyo-night {\n        background: url(\"/assets/images/tokyo-night.svg\") no-repeat;\n        background-size: 2rem;\n        background-position: center;\n\n        .theme--tokyo-night & {\n          outline: 2px solid var(--kbin-sidebar-settings-switch-on-bg);\n        }\n      }\n    }\n\n    .font-size {\n      align-items: center;\n      border: 3px solid transparent;\n      box-sizing: content-box;\n      display: flex;\n      height: 30px;\n      justify-content: center;\n      padding: 3px;\n      width: 30px;\n\n      &.active {\n        border: var(--kbin-avatar-border);\n      }\n    }\n  }\n\n  .settings-list {\n    display: flex;\n    flex-flow: row wrap;\n    gap: 1px;\n    align-items: center;\n    color: var(--kbin-meta-text-color);\n\n    &.reload-required .reload-required-section {\n      display: block;\n    }\n\n    .reload-required-section {\n      z-index: 1;\n      background: var(--kbin-section-bg);\n      position: sticky;\n      top: 0;\n      display: none;\n      text-align: center;\n      width: 100%;\n      animation: showReloadRequired .25s ease-in-out forwards;\n      @keyframes showReloadRequired {\n        0% {\n          opacity: 0;\n          transform: translateY(-.5em);\n        }\n        75% {\n          opacity: 1;\n          transform: translateY(.25em);\n        }\n        100% {\n          transform: translateY(0);\n        }\n      }\n\n      .rounded-edges & {\n        border-radius: var(--kbin-rounded-edges-radius);\n      }\n\n      .btn {\n        width: 100%;\n        display: flex;\n        gap: .5rem;\n        justify-content: center;\n        align-items: center;\n        width: 100%;\n\n        &:hover {\n          color: var(--kbin-button-secondary-text-hover-color);\n        }\n      }\n\n      &:hover {\n        background: var(--kbin-button-secondary-hover-bg);\n        color: var(--kbin-button-secondary-text-hover-color);\n      }\n\n      /** Faster spin animation than fa-spin */\n      button.spin i {\n        animation: spin .5s linear infinite;\n        @keyframes spin {\n          0% {\n            transform: rotate(0deg);\n          }\n          100% {\n            transform: rotate(360deg);\n          }\n        }\n      }\n    }\n\n    strong {\n      margin-top: .5rem;\n      display: block;\n      flex: 100%;\n      font-weight: 700;\n      font-variant: all-small-caps;\n      margin-left: .3125rem;\n      color: var(--kbin-meta-text-color);\n      opacity: .5;\n    }\n\n    .settings-section {\n      display: flex;\n      flex: auto;\n      flex-flow: column wrap;\n      gap: 1px;\n      align-items: center;\n\n      @include b.media-breakpoint-down(lg) {\n        & .width-setting {\n          display: none;\n        }\n      }\n    }\n\n    .row {\n      display: flex;\n      justify-content: space-between;\n      align-items: center;\n      flex: 100%;\n      min-height: 1.5rem;\n\n      div {\n        display: flex;\n        align-items: center;\n        font-size: 0;\n        color: var(--kbin-meta-text-color);\n        border: var(--kbin-button-secondary-border);\n        background: var(--kbin-button-secondary-bg);\n        height: 1.5rem;\n        overflow: clip;\n\n        a {\n          font-weight: 400;\n          height: 100%;\n          font-size: .85rem;\n          padding: 0 .375rem;\n          white-space: nowrap;\n\n          &.active {\n            font-weight: 700;\n            background: var(--kbin-button-secondary-hover-bg);\n            color: var(--kbin-button-secondary-text-hover-color);\n          }\n\n          &.link-muted:not(.active):hover {\n            color: var(--kbin-link-hover-color);\n          }\n        }\n      }\n\n      span {\n        line-height: normal;\n      }\n    }\n  }\n\n  #settings, #sidebarcontent {\n    display: none;\n  }\n\n  #settings:target, #sidebarcontent:target {\n    display: block;\n  }\n\n  .active-users {\n    > div {\n      display: grid;\n      gap: .2rem;\n      grid-template-columns: repeat(4, 1fr);\n      text-align: center;\n    }\n\n    img {\n      border: var(--kbin-avatar-border);\n    }\n  }\n\n  .intro {\n    .container {\n      background: url('/assets/images/intro-bg.png') no-repeat center 20%;\n      background-size: cover;\n      color: #ffffff !important;\n      font-size: .85rem;\n      margin: -.5rem;\n      padding: 1rem;\n\n      .rounded-edges & {\n        border-radius: .5rem .5rem 0 0;\n      }\n\n      h3 {\n        border: 0;\n        color: white;\n        font-size: 1rem;\n        font-weight: 600;\n        margin: 1rem 0 1rem 0;\n        text-transform: none;\n      }\n    }\n\n    .btn:first-of-type {\n      margin-bottom: 1rem;\n      margin-top: 2rem;\n    }\n\n    .btn {\n      display: block;\n      text-align: center;\n      width: 100% !important;\n\n      .rounded-edges & {\n        border-radius: .5rem;\n      }\n    }\n  }\n\n  .about {\n    ul {\n      padding: 0;\n      opacity: 0.8;\n\n      &:last-child {\n        margin-bottom: 0;\n      }\n\n      li {\n        padding: 0.1rem;\n        list-style: none;\n        display: inline;\n      }\n\n      &.about-mbin {\n          text-align: center;\n      }\n    }\n\n    .about-options {\n      li {\n        display: flex;\n        justify-content: center;\n      }\n\n      select {\n        padding: 0.4rem;\n        background: var(--kbin-section-bg);\n        color: var(--kbin-button-secondary-text-color);\n      }\n    }\n\n    .about-seperator {\n      border: var(--kbin-section-border);\n      height: 0;\n      margin: 2px 5px;\n    }\n  }\n\n  .kbin-promo {\n    display: flex;\n    gap: 1rem;\n    padding: 1rem;\n    position: relative;\n\n    h4 {\n      font-size: 1.0rem;\n      font-weight: 600;\n      margin: 0;\n    }\n\n    p {\n      font-size: .8rem;\n      margin: 0;\n    }\n\n    a {\n      color: var(--kbin-text-color);\n    }\n  }\n\n  .mobile-close {\n    //display: none;\n  }\n\n  .mobile-nav {\n    display: none;\n  }\n\n  @include b.media-breakpoint-down(lg) {\n    .mobile-nav {\n      display: block;\n      position: relative;\n\n      li {\n        font-size: 1.1rem;\n        list-style: none;\n        position: relative;\n        padding: .85rem 0;\n      }\n\n      a {\n        color: var(--kbin-meta-link-color);\n\n        &::after {\n          bottom: 0;\n          content: '';\n          left: 0;\n          position: absolute;\n          right: 0;\n          top: 0;\n          z-index: 1;\n        }\n      }\n\n      a.active {\n        font-weight: bold;\n      }\n\n      .head-title {\n        border-bottom: 1px solid var(--kbin-options-text-color);\n        font-weight: bold;\n        padding-bottom: .5rem;\n      }\n\n      .head-title span {\n        display: none;\n      }\n    }\n  }\n}\n\n// Use white background for light themes\n.theme--light #sidebar .intro .container, \n.theme--solarized-light #sidebar .intro .container {\n  background: url('/assets/images/intro-bg-white.png') no-repeat center 50%;\n  color: rgb(39, 39, 39) !important;\n  font-weight: 400;\n  h3 {\n    color: #202020;\n  }\n}\n\n#sidebar {\n  @include b.media-breakpoint-down(lg) {\n    &.open {\n      background: var(--kbin-bg);\n      height: 100%;\n      left: 0;\n      overflow: auto;\n      padding-bottom: 100px !important;\n      position: fixed;\n      top: 3.25rem;\n      width: 100%;\n      z-index: 98;\n\n      .topbar & {\n        top: 4.5rem;\n      }\n\n      .mobile-close {\n        display: flex;\n        font-size: 1.5rem;\n        justify-content: space-between;\n        padding: 0.5em;\n\n        button {\n          height: auto;\n        }\n      }\n    }\n    &:not(.open) {\n      display: none;\n    }\n  }\n}\n"
  },
  {
    "path": "assets/styles/components/_stats.scss",
    "content": "@use '../layout/breakpoints' as b;\n\n.stats-count {\n  display: grid;\n  grid-template-columns: repeat(3, 1fr);\n  row-gap: 2rem;\n\n  @include b.media-breakpoint-only(xs) {\n    grid-template-columns: repeat(1, 1fr);\n  }\n\n  div {\n    display: table;\n    text-align: center;\n\n    h3 {\n      font-size: .9rem;\n      font-weight: bold;\n      margin: 0;\n    }\n\n    p {\n      font-size: 1.8rem;\n      font-weight: bold;\n    }\n  }\n}\n"
  },
  {
    "path": "assets/styles/components/_subject.scss",
    "content": ".subjects {\n  .post,\n  .entry {\n    margin-top: .5rem;\n\n    &:first-child {\n      margin-top: 0\n    }\n  }\n\n  .comment {\n    margin-bottom: 0;\n    margin-top: 0.5em;\n  }\n\n  .post-comment {\n    margin-left: 1rem;\n  }\n}\n\n.subject {\n  .more {\n    background: var(--kbin-bg);\n    cursor: pointer;\n    text-align: center;\n    width: 100%;\n    margin-top: 1rem;\n\n    // bigger button for touch devices\n    @media (pointer:none), (pointer:coarse) {\n      margin-top: 2rem;\n      padding: 0.5rem;\n    }\n\n    i {\n      padding: .35rem;\n      pointer-events: none;\n    }\n\n    .rounded-edges & {\n      border-radius: var(--kbin-rounded-edges-radius);\n    }\n  }\n\n  .show-preview {\n    cursor: pointer;\n  }\n\n  &:nth-of-type(odd) {\n  }\n\n  .js-container {\n    display: none;\n    font-size: 1rem;\n    margin-top: 1rem;\n  }\n\n  &.author {\n    border-left: var(--kbin-author-border);\n  }\n\n  &.own {\n    border-left: var(--kbin-own-border);\n  }\n}\n\ndiv.moderate-inline {\n  grid-area: moderate !important;\n  // this is to appear below the more menu\n  z-index: -1;\n}\n\n.moderate-panel {\n  position: relative;\n  z-index: 2;\n\n  menu {\n    align-items: center;\n    column-gap: 1rem;\n    display: flex;\n    flex-wrap: wrap;\n    grid-auto-columns: max-content;\n    grid-auto-flow: column;\n    justify-content: space-around;\n    list-style: none;\n\n    select {\n      padding: .25rem;\n    }\n\n    input {\n      width: 10rem;\n    }\n\n    .actions form {\n      display: flex;\n    }\n\n    input,\n    select,\n    input[type=checkbox] {\n      margin-right: .25rem;\n    }\n\n    & > a.active,\n    & > li button.active, {\n      text-decoration: underline;\n    }\n\n  }\n}\n\n.overview .comments-tree {\n  margin-top: -.5rem;\n}\n"
  },
  {
    "path": "assets/styles/components/_suggestions.scss",
    "content": ".suggestions {\n  position: absolute;\n  z-index: 10;\n  border: var(--kbin-input-border);\n  background: var(--kbin-input-bg);\n  padding: 0 .5rem;\n\n  .suggestion {\n    color: var(--kbin-input-text-color);\n    cursor: pointer;\n    margin: 0;\n    padding: .5rem 0;\n\n    &:hover,\n    &.selected {\n      background: var(--kbin-input-hover-background);\n      color: var(--kbin-meta-link-hover-color);\n    }\n\n    &:not(:last-child):not(:only-child) {\n      border-bottom: var(--kbin-input-border);\n    }\n  }\n}\n\n.rounded-edges {\n  .suggestions {\n    border-radius: var(--kbin-rounded-edges-radius) !important;\n  }\n}\n"
  },
  {
    "path": "assets/styles/components/_tag.scss",
    "content": ".section.tag {\n  header {\n    text-align: center;\n\n    h4 {\n      font-size: 1.2rem;\n      margin-bottom: 0;\n      margin-top: .5rem;\n    }\n  }\n\n  .tag__actions {\n    display: flex;\n    flex-direction: row;\n    flex-wrap: wrap;\n    justify-content: center;\n    margin-top: 1rem;\n    margin-bottom: 2.5rem;\n    gap: .25rem;\n  }\n}"
  },
  {
    "path": "assets/styles/components/_topbar.scss",
    "content": "#topbar {\n  background: var(--kbin-topbar-bg);\n  border-bottom: var(--kbin-topbar-border);\n  display: none;\n  grid-template-areas: 'left middle right';\n  grid-template-columns: min-content auto max-content;\n  position: absolute;\n  top: 0;\n  width: 100%;\n  z-index: 20;\n  height: 1.25rem;\n\n  .topbar & {\n    display: grid;\n    position: fixed;\n  }\n\n  .fixed-navbar & {\n    position: fixed;\n  }\n\n  menu:nth-child(1) {\n    grid-area: left;\n  }\n\n  menu:nth-child(2) {\n    grid-area: middle;\n  }\n\n  a {\n    color: var(--kbin-topbar-link-color);\n    white-space: nowrap;\n  }\n\n  menu:nth-child(3) {\n    background: var(--kbin-topbar-bg);\n    position: absolute;\n    right: 0;\n    z-index: 10;\n  }\n\n  menu {\n    display: flex;\n    font-size: .75rem;\n    list-style: none;\n\n    li {\n      padding: 0 .5rem;\n      position: relative;\n\n      &:hover {\n        background: var(--kbin-topbar-hover-bg);\n      }\n    }\n\n    li.active {\n      background: var(--kbin-topbar-active-bg);\n\n      a {\n        color: var(--kbin-topbar-active-link-color);\n        font-weight: bold;\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "assets/styles/components/_user.scss",
    "content": "@use '../layout/breakpoints' as b;\n\n.user {\n  &__actions {\n    display: flex;\n    flex-direction: row;\n    justify-content: center;\n    opacity: .75;\n\n    div {\n      align-items: center;\n      background: var(--kbin-button-secondary-bg);\n      border: var(--kbin-button-secondary-border);\n      color: var(--kbin-button-secondary-text-color);\n      display: flex;\n      flex-direction: row;\n      font-size: .9rem;\n      left: 1px;\n      padding: .3rem .5rem;\n      position: relative;\n\n      .rounded-edges & {\n        border-radius: .5rem;\n      }\n\n      i {\n        padding-right: .5rem;\n      }\n    }\n\n    button {\n      height: 100%;\n      padding-bottom: .5rem;\n      padding-top: .5rem;\n    }\n\n    form:last-of-type {\n      position: relative;\n      right: 1px;\n    }\n  }\n\n  &__name {\n    i {\n      font-size: 0.7rem;\n    }\n  }\n}\n\n.user-inline {\n  img {\n    border-radius: 50%;\n    vertical-align: middle;\n    margin-right: 0.25rem;\n\n    @include b.media-breakpoint-down(sm) {\n      width: 25px;\n      height: 25px;\n    }\n  }\n}\n\n.page-user-overview {\n  .section--top {\n    padding: 0;\n    overflow: clip;\n  }\n}\n\n.user-box,\n.user-box-inline {\n  figure {\n    margin: 0;\n    padding: 0;\n  }\n\n  img {\n    -o-object-fit: cover;\n    object-fit: cover;\n  }\n\n  h1 {\n    font-size: 1.2rem;\n  }\n\n  .user-main {\n    margin-left: 1.5rem;\n    padding-top: 2rem;\n    text-align: center;\n    width: max-content;\n\n    @include b.media-breakpoint-down(lg) {\n      justify-content: space-around;\n      margin-left: 0;\n    }\n\n    h1 {\n      margin-bottom: 0;\n    }\n\n    h1 > code {\n      background: var(--kbin-button-secondary-bg);\n      border: var(--kbin-button-secondary-border);\n      color: var(--kbin-button-secondary-text-color);\n      font-size: .8rem;\n      padding: .3rem .5rem;\n      left: 2px;\n      top: -2px;\n      position: relative;\n    }\n\n    small {\n      display: block;\n      margin-bottom: 1rem;\n    }\n\n    img {\n      border: var(--kbin-avatar-border);\n    }\n  }\n\n  .about {\n    margin-bottom: 2.5rem;\n    padding: 0 1.5rem;\n\n    @include b.media-breakpoint-down(sm) {\n      padding: 0 .5rem;\n    }\n  }\n\n  .with-cover.with-avatar {\n    figure {\n      position: relative;\n      top: -60px;\n      height: 40px;\n    }\n  }\n}\n\n.user-box {\n  .with-cover.with-avatar {\n    .user-main {\n      margin-top: 0;\n      padding-top: 0;\n\n      .user__actions {\n        margin: 0;\n      }\n    }\n\n    .about {\n      margin-bottom: 1.5rem;\n      margin-top: 1em;\n    }\n  }\n}\n\n.users-cards {\n  display: grid;\n  gap: 1rem;\n  grid-template-columns: repeat(2, 1fr);\n\n  @include b.media-breakpoint-down(sm) {\n    grid-template-columns: repeat(1, 1fr);\n  }\n\n  .magazine {\n    align-content: center;\n    align-items: center;\n    display: grid;\n    grid-template-rows: auto;\n  }\n}\n\n.users-columns,\n.columns {\n  font-size: .9rem;\n\n  ul {\n    display: grid;\n    grid-gap: 1rem;\n    grid-template-columns: repeat(3, 1fr);\n    margin: 0;\n    padding: 0;\n\n    @include b.media-breakpoint-down(lg) {\n      grid-template-columns: repeat(2, 1fr);\n    }\n\n    @include b.media-breakpoint-down(sm) {\n      grid-template-columns: repeat(1, 1fr);\n    }\n  }\n\n  ul figure {\n    margin: 0 .5rem 0 0;\n  }\n\n  ul li {\n    align-items: center;\n    display: flex;\n    flex-wrap: wrap;\n    list-style: none;\n    position: relative;\n\n    form {\n      margin-bottom: 0 !important;\n    }\n  }\n\n\n  ul li a {\n    display: block\n  }\n\n  ul li small {\n    color: var(--kbin-meta-text-color);\n    font-size: .85rem;\n  }\n}\n\n.page-user-overview {\n  #sidebar {\n    .user-info {\n      ul li:first-of-type {\n        border-top: 0;\n      }\n\n    }\n  }\n}\n\n.page-people {\n  .users-cards.section {\n    padding: 0;\n  }\n\n  .users-cards {\n    .section {\n      padding: 0;\n      overflow: clip;\n    }\n  }\n\n  .user-box {\n    .cover {\n      height: 150px;\n    }\n\n    .user-main {\n      justify-content: center;\n      padding-top: 12.6rem;\n      position: relative;\n\n      .row {\n        position: relative;\n\n        .stretched-link {\n          color: var(--kbin-text-color) !important;\n          font-weight: bold !important;\n        }\n      }\n    }\n\n    .about {\n      font-size: .9rem;\n    }\n  }\n\n  .with-cover {\n    .user-main {\n      padding-top: 2.87rem\n    }\n  }\n\n  .with-avatar {\n    .user-main {\n      padding-top: 6rem;\n    }\n  }\n\n  .with-cover.with-avatar {\n    padding-top: 0;\n    position: inherit;\n  }\n}\n\n.page-settings {\n  .container {\n    margin: 0 auto;\n    max-width: 32.7rem;\n\n    h2:first-of-type {\n      margin-top: 0;\n    }\n  }\n}\n\ntd .user__actions {\n  margin: 0;\n}\n\n.page-settings-password {\n  a.btn {\n    display: inline-block;\n  }\n}\n\n.page-settings-2fa {\n  .twofa-qrcode {\n    display: block;\n    text-align: center;\n    margin-bottom: 2rem;\n\n    img {\n      width: 250px;\n      height: 250px;\n    }\n  }\n\n  .twofa-backup-codes,\n  .twofa-secret{\n    display: flex;\n    gap: 0.2rem 1rem;\n    flex-wrap: wrap;\n    justify-content: start;\n\n    font-family: monospace;\n    background: var(--kbin-input-bg);\n    border: var(--kbin-section-border);\n    padding: 0.5rem;\n\n    li {\n      list-style: none;\n      padding: 0.1rem 0.34rem;\n    }\n  }\n\n  .actions {\n    gap: 1rem;\n    justify-content: flex-end;\n    align-items: center;\n    margin-bottom: 1rem;\n  }\n}\n\n.new-user-icon {\n  color: green;\n}\n\n.user-box-inline {\n  padding: 0;\n  overflow: clip;\n\n  .user-box-info {\n    display: flex;\n  }\n\n  .with-cover.with-avatar {\n    .user-main {\n      margin-top: 0;\n      padding-top: 0;\n\n      .user__actions {\n        margin: 0;\n      }\n    }\n  }\n\n  .about {\n    padding-top: 2em;\n    margin-bottom: 1.5rem;\n  }\n}\n"
  },
  {
    "path": "assets/styles/components/_vote.scss",
    "content": ".vote {\n  display: grid;\n  gap: .5rem;\n  grid-template-rows: min-content min-content;\n\n  .active.vote__up button {\n    color: var(--kbin-upvoted-color);\n  }\n\n  .active.vote__down button {\n    color: var(--kbin-downvoted-color);\n  }\n\n  button {\n    background-color: var(--kbin-vote-bg);\n    border: 0;\n    color: var(--kbin-vote-text-color);\n    cursor: pointer;\n    font-weight: bold;\n    height: 1.9rem;\n    margin: 0;\n    padding: 0;\n    width: 4rem;\n\n    &:hover,\n    &:focus-visible {\n      background-color: var(--kbin-vote-bg-hover-bg);\n      color: var(--kbin-vote-text-hover-color);\n    }\n\n    span {\n      font-size: .85rem;\n      font-weight: normal;\n    }\n  }\n}\n\n"
  },
  {
    "path": "assets/styles/emails.scss",
    "content": "\nbody{\n    background-color: #fff;\n    color: #212529;\n    font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"Noto Sans\", Roboto, \"Helvetica Neue\", Arial, sans-serif, \"Apple Color Emoji\", \"Segoe UI Emoji\", \"Segoe UI Symbol\";\n    font-size: 1em;\n    font-weight: 400;\n    line-height: 1.5;\n    background-color: #fff;\n    margin: 0px;\n}\n\n.container{\n    padding: 0.5em;\n}\n\nh1,h2,h3,h4,h5,h6{\n    margin: 0.5em auto;\n    line-height: 100%;\n}\n\nh1{\n\n}\nh2{\n\n}\nh3{\n\n}\n\na {\n    color: #37769e;\n    text-decoration: none;\n\n    &:hover {\n      color: #275878;\n    }\n}\n\n\n\n.btn {\n    height: 100%;\n    padding: 0.7em;\n    font-size: 0.85em;\n    cursor: pointer;\n\n    &__danger{\n      background: #842029;\n      border: 1px dashed #842029;\n      color: #fff;\n      font-weight: bold;\n\n      &:hover,\n      &:focus-visible {\n        background: #921d27;\n        color: #fff;\n      }\n\n      a, a:hover {\n        color: #fff;\n      }\n\n    }\n\n    &__primary {\n      background: #4e3a8c;\n      border: 1px solid #3f2e77;\n      color: #fff;\n      font-weight: bold;\n\n      &:hover,\n      &:focus-visible {\n        background: #3f2e77;\n        color: #fff;\n      }\n\n      a, a:hover {\n        color: #fff;\n      }\n    }\n\n    &__secondry {\n      background: #fff;\n      border: 1px dashed #e5eaec;\n      color: #606060;\n\n      &:hover,\n      &:focus-visible\n      {\n        background: #f5f5f5;\n        color: #212529;\n      }\n    }\n  }\n\n.footer{\n    background-color: #1e1f22;\n    color: #fff;\n\n    a{\n        color: #fff;\n\n        &:hover,\n        &:active{\n            color: #e8e8e8;\n        }\n    }\n}\n\n.logo{\n    max-width: 100px;\n}\n\n.header{\n    background-color:#1e1f22;\n    color: #fff;\n}\n"
  },
  {
    "path": "assets/styles/layout/_alerts.scss",
    "content": ".alert {\n  margin: .5rem 0;\n  padding: 1rem;\n  position: relative;\n  z-index: 2;\n\n  p {\n    margin: 0;\n  }\n\n  a {\n    font-weight: bold;\n  }\n\n  i {\n    font-size: 0.8rem;\n    vertical-align: middle;\n  }\n\n  &__info {\n    background: var(--kbin-alert-info-bg);\n    border: var(--kbin-alert-info-border);\n    color: var(--kbin-alert-info-text-color);\n\n    a {\n      color: var(--kbin-alert-info-link-color);\n    }\n  }\n\n  &__danger {\n    background: var(--kbin-alert-danger-bg);\n    border: var(--kbin-alert-danger-border);\n    color: var(--kbin-alert-danger-text-color);\n\n    a {\n      color: var(--kbin-alert-danger-link-color);\n    }\n  }\n\n  &__success {\n\n    background: var(--kbin-alert-success-bg);\n    border: var(--kbin-alert-success-border);\n    color: var(--kbin-alert-success-text-color);\n\n    a {\n      color: var(--kbin-alert-success-link-color);\n    }\n\n  }\n\n}\n"
  },
  {
    "path": "assets/styles/layout/_breakpoints.scss",
    "content": "@use \"sass:list\";\n@use \"sass:map\";\n// https://github.com/twbs/bootstrap/blob/main/scss/mixins/_breakpoints.scss\n//\n// Breakpoint viewport sizes and media queries.\n//\n// Breakpoints are defined as a map of (name: minimum width), order from small to large:\n//\n//    (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px, xxl: 1400px)\n//\n// The map defined in the `$grid-breakpoints` variable is used as the `$breakpoints` argument by default.\n@use '../variables' as v;\n\n// Name of the next breakpoint, or null for the last breakpoint.\n//\n//    >> breakpoint-next(sm)\n//    md\n//    >> breakpoint-next(sm, (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px, xxl: 1400px))\n//    md\n//    >> breakpoint-next(sm, $breakpoint-names: (xs sm md lg xl xxl))\n//    md\n@function breakpoint-next($name, $breakpoints: v.$grid-breakpoints, $breakpoint-names: map.keys($breakpoints)) {\n    $n: list.index($breakpoint-names, $name);\n    @if not $n {\n        @error \"breakpoint `#{$name}` not found in `#{$breakpoints}`\";\n    }\n    @return if($n < list.length($breakpoint-names), list.nth($breakpoint-names, $n + 1), null);\n}\n\n// Minimum breakpoint width. Null for the smallest (first) breakpoint.\n//\n//    >> breakpoint-min(sm, (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px, xxl: 1400px))\n//    576px\n@function breakpoint-min($name, $breakpoints: v.$grid-breakpoints) {\n    $min: map.get($breakpoints, $name);\n    @return if($min != 0, $min, null);\n}\n\n// Maximum breakpoint width.\n// The maximum value is reduced by 0.02px to work around the limitations of\n// `min-` and `max-` prefixes and viewports with fractional widths.\n// See https://www.w3.org/TR/mediaqueries-4/#mq-min-max\n// Uses 0.02px rather than 0.01px to work around a current rounding bug in Safari.\n// See https://bugs.webkit.org/show_bug.cgi?id=178261\n//\n//    >> breakpoint-max(md, (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px, xxl: 1400px))\n//    767.98px\n@function breakpoint-max($name, $breakpoints: v.$grid-breakpoints) {\n    $max: map.get($breakpoints, $name);\n    @return if($max and $max > 0, $max - .02, null);\n}\n\n// Returns a blank string if smallest breakpoint, otherwise returns the name with a dash in front.\n// Useful for making responsive utilities.\n//\n//    >> breakpoint-infix(xs, (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px, xxl: 1400px))\n//    \"\"  (Returns a blank string)\n//    >> breakpoint-infix(sm, (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px, xxl: 1400px))\n//    \"-sm\"\n@function breakpoint-infix($name, $breakpoints: v.$grid-breakpoints) {\n    @return if(breakpoint-min($name, $breakpoints) == null, \"\", \"-#{$name}\");\n}\n\n// Media of at least the minimum breakpoint width. No query for the smallest breakpoint.\n// Makes the @content apply to the given breakpoint and wider.\n@mixin media-breakpoint-up($name, $breakpoints: v.$grid-breakpoints) {\n    $min: breakpoint-min($name, $breakpoints);\n    @if $min {\n        @media (min-width: $min) {\n            @content;\n        }\n    } @else {\n        @content;\n    }\n}\n\n// Media of at most the maximum breakpoint width. No query for the largest breakpoint.\n// Makes the @content apply to the given breakpoint and narrower.\n@mixin media-breakpoint-down($name, $breakpoints: v.$grid-breakpoints) {\n    $max: breakpoint-max($name, $breakpoints);\n    @if $max {\n        @media (max-width: $max) {\n            @content;\n        }\n    } @else {\n        @content;\n    }\n}\n\n// Media that spans multiple breakpoint widths.\n// Makes the @content apply between the min and max breakpoints\n@mixin media-breakpoint-between($lower, $upper, $breakpoints: v.$grid-breakpoints) {\n    $min: breakpoint-min($lower, $breakpoints);\n    $max: breakpoint-max($upper, $breakpoints);\n\n    @if $min != null and $max != null {\n        @media (min-width: $min) and (max-width: $max) {\n            @content;\n        }\n    } @else if $max == null {\n        @include media-breakpoint-up($lower, $breakpoints) {\n            @content;\n        }\n    } @else if $min == null {\n        @include media-breakpoint-down($upper, $breakpoints) {\n            @content;\n        }\n    }\n}\n\n// Media between the breakpoint's minimum and maximum widths.\n// No minimum for the smallest breakpoint, and no maximum for the largest one.\n// Makes the @content apply only to the given breakpoint, not viewports any wider or narrower.\n@mixin media-breakpoint-only($name, $breakpoints: v.$grid-breakpoints) {\n    $min:  breakpoint-min($name, $breakpoints);\n    $next: breakpoint-next($name, $breakpoints);\n    $max:  breakpoint-max($next, $breakpoints);\n\n    @if $min != null and $max != null {\n        @media (min-width: $min) and (max-width: $max) {\n            @content;\n        }\n    } @else if $max == null {\n        @include media-breakpoint-up($name, $breakpoints) {\n            @content;\n        }\n    } @else if $min == null {\n        @include media-breakpoint-down($next, $breakpoints) {\n            @content;\n        }\n    }\n}\n\n// Last ditch function; just \"don't show on small screens\" class\n.hide-on-mobile {\n    @include media-breakpoint-down(lg) {\n      display: none;\n    }\n  }\n"
  },
  {
    "path": "assets/styles/layout/_forms.scss",
    "content": "@use 'breakpoints' as b;\n@use '../mixins/mbin';\n@use '@fortawesome/fontawesome-free/scss/fontawesome' as fa;\n// needed for the checkmark do render correctly even though it is not directly used\n@use '@fortawesome/fontawesome-free/scss/solid' as faS;\n\n.btn {\n  font-size: .85rem;\n  height: 100%;\n  padding: .7rem;\n  cursor: pointer;\n\n  span {\n    margin-left: .5rem;\n  }\n\n  &__danger {\n    background: var(--kbin-button-danger-bg);\n    border: var(--kbin-button-danger-border);\n    color: var(--kbin-button-danger-text-color) !important;\n    font-weight: bold;\n\n    &:hover,\n    &:focus-visible {\n      background: var(--kbin-button-danger-hover-bg);\n      color: var(--kbin-button-danger-text-hover-color) !important;\n    }\n\n    a, a:hover {\n      color: var(--kbin-button-danger-text-hover-color) !important;\n    }\n\n  }\n\n  &__primary {\n    background: var(--kbin-button-primary-bg);\n    border: var(--kbin-button-primary-border);\n    color: var(--kbin-button-primary-text-color) !important;\n    font-weight: bold;\n\n    &:hover,\n    &:focus-visible {\n      background: var(--kbin-button-primary-hover-bg);\n      color: var(--kbin-button-primary-text-hover-color) !important;\n    }\n\n    a, a:hover {\n      color: var(--kbin-button-primary-text-hover-color) !important;\n    }\n  }\n\n  &__secondary {\n    background: var(--kbin-button-secondary-bg);\n    border: var(--kbin-button-secondary-border);\n    color: var(--kbin-button-secondary-text-color) !important;\n\n    &:hover,\n    &:focus-visible {\n      background: var(--kbin-button-secondary-hover-bg);\n      color: var(--kbin-button-secondary-text-hover-color) !important;\n    }\n  }\n\n  &__secondry {\n    background: var(--kbin-button-secondary-bg);\n    border: var(--kbin-button-secondary-border);\n    color: var(--kbin-button-secondary-text-color) !important;\n\n    &:hover,\n    &:focus-visible {\n      background: var(--kbin-button-secondary-hover-bg);\n      color: var(--kbin-button-secondary-text-hover-color) !important;\n    }\n  }\n\n  &__emoji {\n    cursor: pointer;\n    float: right;\n    position: absolute;\n    top: 5px;\n    right: 5px;\n    height: fit-content;\n    background-color: transparent;\n    border: 0;\n\n    &:hover {\n      background-color: var(--kbin-button-primary-bg);\n    }\n\n    &.active {\n      background-color: var(--kbin-button-primary-hover-bg);\n    }\n  }\n}\n\nselect {\n  -webkit-appearance: menulist-button;\n}\n\ninput,\ntextarea {\n  background: var(--kbin-input-bg);\n  border: var(--kbin-input-border);\n  color: var(--kbin-input-text-color);\n  font-family: var(--kbin-body-font-family);\n  font-size: .9rem;\n\n  &::placeholder {\n    color: var(--kbin-input-placeholder-text-color) !important;\n  }\n}\n\ninput,\ntextarea,\nselect,\nbutton {\n\n  &[disabled] {\n    opacity: 0.5;\n    cursor: not-allowed;\n  }\n}\n\ntextarea {\n  box-sizing: border-box;\n  height: 5rem;\n  padding: 1rem .5rem;\n  resize: vertical;\n  width: 100%;\n}\n\ninput[type=radio] {\n  border-radius: 50%;\n}\n\ninput[type=checkbox],\ninput[type=radio] {\n  -webkit-appearance: none;\n  appearance: none;\n  font-size: 0.9rem;\n  display: grid;\n  margin: 0px;\n  width: 1.5rem;\n  height: 1.5rem;\n  justify-content: center;\n  align-items: center;\n  align-content: center;\n  cursor: pointer;\n\n  @include fa.fa-icon-solid(fa.$fa-var-check);\n\n  &::before {\n    transform: scale(0);\n    transition: 100ms transform ease-in;\n  }\n\n  &:checked::before {\n    transform: scale(1);\n  }\n\n  &[disabled] {\n    opacity: 0.5;\n    cursor: not-allowed;\n  }\n\n}\n\nlabel {\n  display: block;\n}\n\ninput[type=text],\ninput[type=email],\ninput[type=password],\ninput[type=select-one] {\n  display: block;\n  padding: 1rem .5rem;\n  width: 100%;\n  text-indent: .1rem !important;\n}\n\n.password-preview {\n  display: grid;\n  grid-template-areas:\n      \"label label\"\n      \"password preview\";\n  justify-items: start;\n  align-items: end;\n  grid-template-columns: 2fr 0fr;\n\n  label {\n    grid-area: label;\n  }\n\n  input[type=password],\n  input[type=text] {\n    grid-area: password;\n    width: 100%;\n\n    .rounded-edges & {\n      border-top-right-radius: 0px !important;\n      border-bottom-right-radius: 0px !important;\n    }\n  }\n\n  .password-preview-button {\n    display: flex;\n    justify-content: center;\n    align-items: center;\n    grid-area: preview;\n    width: 40px;\n    cursor: pointer;\n    height: 100%;\n    margin: 0;\n\n    .rounded-edges & {\n      border-top-left-radius: 0px !important;\n      border-bottom-left-radius: 0px !important;\n    }\n  }\n}\n\n\nform {\n  div {\n    margin-bottom: 1rem;\n\n    ul {\n      color: var(--kbin-danger-color);\n      font-weight: bold;\n      list-style: none;\n      margin: 1rem 0;\n      padding: 0;\n    }\n\n    ul li {\n      padding: 0;\n    }\n  }\n\n  .help-text {\n    font-size: 0.8rem;\n\n    &.checkbox {\n      margin-top: -.75rem;\n      margin-left: 2rem;\n      margin-bottom: 0;\n    }\n  }\n\n  .length-indicator {\n    font-size: 0.8rem;\n  }\n}\n\n.checkbox {\n  display: flex;\n  flex-direction: row-reverse;\n  justify-content: flex-end;\n\n  input[type=checkbox] {\n    margin-right: .5rem;\n  }\n}\n\n.ts-wrapper {\n  div {\n    margin-bottom: 0;\n  }\n\n  &.single .ts-control {\n    background: var(--kbin-meta-bg);\n    box-shadow: none;\n  }\n\n  .clear-button {\n    color: var(--kbin-meta-text-color);\n    font-size: 1.5rem;\n  }\n\n  .ts-control {\n    background: var(--kbin-input-bg) !important;\n    border: var(--kbin-input-border) !important;\n    border-radius: 0;\n    box-shadow: none !important;\n    color: var(--kbin-input-text-color);\n    display: flex;\n    flex-flow: row wrap;\n    gap: .5rem;\n    padding: 1rem .5rem;\n    width: 100%;\n    line-height: normal;\n\n    input {\n      color: var(--kbin-input-text-color);\n      width: auto;\n      min-width: 8rem;\n      border-radius: 0 !important;\n    }\n\n    & > * {\n      font-size: .85rem;\n    }\n  }\n\n  &.multi {\n    .ts-control {\n      > [data-value].item,\n      > [data-value].active {\n        background-image: none;\n        background: var(--kbin-button-primary-bg);\n        color: var(--kbin-button-primary-text-color);\n        border: var(--kbin-button-primary-border);\n        border-radius: 0;\n        text-shadow: none;\n        box-shadow: none;\n        padding: 0 .5rem;\n        height: 2rem;\n        max-width: fit-content;\n        margin: 0;\n        overflow: clip;\n      }\n    }\n\n    &.plugin-remove_button:not(.rtl) .item > .remove {\n      display: inline-flex;\n      align-items: center;\n      justify-content: center;\n      font-size: 1rem;\n      border-left: var(--kbin-button-primary-border);\n      margin-left: .5rem;\n      padding: 0 .5rem 0 .4375rem;\n      height: 100%;\n\n      &:hover {\n        background-color: var(--kbin-button-primary-hover-bg);\n        color: var(--kbin-button-primary-text-hover-color);\n      }\n    }\n  }\n\n\n  &.single.input-active .ts-control input {\n    color: var(--kbin-meta-text-color)\n    //display: none !important;\n  }\n\n  &.single .ts-control, .ts-dropdown.single {\n    border: var(--kbin-input-border);\n  }\n\n  .ts-dropdown {\n    font-size: .85rem;\n    line-height: normal;\n    margin: -1px 0 0;\n    border: var(--kbin-input-border);\n    border-top: 0;\n    background: var(--kbin-input-bg);\n    color: var(--kbin-meta-text-color);\n    box-shadow: var(--kbin-shadow);\n    overflow: clip;\n\n    .active {\n      color: var(--kbin-meta-text-color);\n    }\n\n    &.single .active {\n      background: var(--kbin-options-bg);\n    }\n\n    &.multi .active {\n      background-color: var(--kbin-input-bg);\n    }\n  }\n\n  &.multi.has-items .ts-control {\n    padding: .5rem;\n  }\n}\n\n.actions ul {\n  margin: 0;\n\n  > img {\n    height: max-content;\n    order: -2;\n    flex: calc(100% - 2.5rem);\n    width: 100%;\n    max-height: 15rem;\n    margin-bottom: .75rem;\n    object-fit: contain;\n    background-color: hsla(0, 0%, 0%, 0.75);\n\n    .rounded-edges & {\n      border-radius: var(--kbin-rounded-edges-radius);\n    }\n\n    + .btn-link {\n      order: -1;\n      width: 2rem;\n      height: 2rem;\n      margin-left: -2.25rem;\n      color: var(--kbin-button-secondary-text-color);\n      background: var(--kbin-button-secondary-bg);\n      border: var(--kbin-button-secondary-border);\n\n      &:hover {\n        color: var(--kbin-button-secondary-text-hover-color);\n        background: var(--kbin-button-secondary-hover-bg);\n      }\n    }\n  }\n}\n\n.actions,\n.actions ul,\n.params {\n  display: flex;\n  gap: .25rem;\n  justify-content: flex-end;\n  align-items: center;\n\n  .btn-link i {\n    position: relative;\n  }\n\n  div {\n    margin-bottom: 0;\n  }\n\n  div button {\n    height: 100%;\n    white-space: nowrap;\n  }\n\n  .ts-control {\n    padding: .5rem;\n  }\n\n  .ts-wrapper {\n    &.single .ts-control, .ts-dropdown.single {\n      background: var(--kbin-input-bg) !important;\n      border: var(--kbin-meta-border) !important;\n      box-shadow: none;\n    }\n  }\n}\n\n.row.actions ul {\n  flex-flow: row wrap;\n  width: 100%;\n}\n\nselect {\n  background: var(--kbin-button-secondary-bg);\n  border: var(--kbin-button-secondary-border);\n  color: var(--kbin-button-secondary-text-color) !important;\n  padding: 0.65rem;\n  border-radius: 0;\n  cursor: pointer;\n  font-size: .85rem;\n}\n\n.select-flex {\n  select {\n    width: 100%;\n  }\n}\n\n.button-flex-hf {\n  text-align: center;\n  button {\n    width: 50%;\n\n    @include b.media-breakpoint-down(lg) {\n      width: 100%;\n    }\n  }\n}\n\n.actions {\n  @include b.media-breakpoint-down(sm) {\n    text-align: right;\n\n    > * {\n      margin-bottom: .5rem !important;\n    }\n  }\n}\n\n.params {\n  color: var(--kbin-meta-text-color);\n  font-size: .813rem;\n  gap: 1rem;\n  margin-bottom: .5rem !important;\n  overflow: visible;\n\n  > div {\n    align-items: center;\n    display: flex;\n    flex-direction: row-reverse;\n  }\n\n  &__left {\n    margin-bottom: 1rem;\n  }\n}\n\n.radios {\n  > div {\n    display: flex;\n    flex-wrap: wrap;\n\n    label {\n      margin-right: 1rem;\n    }\n  }\n}\n\n.actions select {\n  max-width: 6.4rem;\n}\n\nmarkdown-toolbar {\n\n  > * {\n    @include mbin.cursor-pointer;\n    @include mbin.simple-transition;\n\n    &:focus,\n    &:hover {\n      color: var(--kbin-section-link-hover-color);\n    }\n  }\n\n}\n\n.tooltip {\n  width: max-content;\n  position: absolute;\n  top: 0;\n  left: 0;\n  z-index: 9999;\n}\n\n.tooltip:not(.shown) {\n  display: none;\n}\n\ndiv.input-box {\n  background: var(--kbin-input-bg);\n  border: var(--kbin-input-border);\n  color: var(--kbin-input-text-color);\n  font-family: var(--kbin-body-font-family);\n  font-size: .9rem;\n\n  display: block;\n  padding: 1rem 0.5rem;\n  width: 100%;\n  line-height: normal;\n\n  &.disabled {\n    opacity: 0.5;\n    cursor: not-allowed;\n  }\n\n  .rounded-edges & {\n    border-radius: var(--kbin-rounded-edges-radius) !important;\n  }\n}\n\n.form-control {\n  display: block;\n  width: 100%;\n\n}\n"
  },
  {
    "path": "assets/styles/layout/_icons.scss",
    "content": "i.active {\n  color: var(--kbin-color-icon-active, orange);\n}\n"
  },
  {
    "path": "assets/styles/layout/_images.scss",
    "content": ".image-inline {\n    display: inline-block;\n    overflow: hidden;\n\n    // inline icons/avatars that are nsfw\n    // likely are used with .stretched-link,\n    // so this is to get the mouse events above the link\n    &.image-adult {\n      position: relative;\n      z-index: 2;\n    }\n}\n\n.image-adult {\n  filter: blur(8px);\n}\n"
  },
  {
    "path": "assets/styles/layout/_layout.scss",
    "content": "@use 'breakpoints' as b;\n\nbody {\n  background: var(--kbin-bg);\n  position: relative;\n}\n\n#logo path {\n  fill: red\n}\n\n.mbin-container {\n  margin: 0 auto;\n  max-width: 1360px;\n\n  &.width--max {\n    max-width: 100%;\n  }\n\n  @include b.media-breakpoint-up(lg) {\n    &.width--auto {\n      max-width: 85%;\n    }\n  }\n}\n\n#middle {\n  background: var(--kbin-bg);\n  z-index: 5;\n  position: relative;\n\n  .mbin-container {\n    display: grid;\n    grid-template-areas: 'main sidebar';\n    grid-template-columns: 3fr 1fr;\n\n    @include b.media-breakpoint-up(lg) {\n      .subs-show & {\n        grid-template-areas: 'subs main sidebar';\n        grid-template-columns: minmax(305px, 1fr) 4fr minmax(305px, 1fr);\n      }\n\n      .sidebar-left & {\n        grid-template-areas: 'sidebar main';\n        grid-template-columns: 1fr 3fr;\n      }\n\n      .sidebar-left.subs-show & {\n        grid-template-areas: 'sidebar main subs';\n        grid-template-columns: minmax(305px, 1fr) 4fr minmax(305px, 1fr);\n      }\n\n\n      .sidebars-same-side.subs-show & {\n        grid-template-areas: 'main sidebar subs ';\n        grid-template-columns: 4fr minmax(305px, 1fr) minmax(305px, 1fr);\n      }\n\n      .sidebars-same-side.sidebar-left.subs-show & {\n        grid-template-areas: 'subs sidebar main';\n        grid-template-columns: minmax(305px, 1fr) minmax(305px, 1fr) 4fr;\n      }\n    }\n\n    @include b.media-breakpoint-down(lg) {\n      grid-template-areas: 'subs subs'\n                          'main main'\n                          'sidebar sidebar';\n      grid-template-columns: 1fr;\n      margin: 0 auto;\n    }\n  }\n\n  //a:focus-visible,\n  //input:focus-visible,\n  //button:focus-visible,\n  //textarea:focus-visible {\n  //  outline-color: darkorange;\n  //}\n\n  #main {\n    grid-area: main;\n    padding: 0 .5rem;\n    position: relative;\n\n    @include b.media-breakpoint-down(md) {\n      overflow-x: clip;\n    }\n  }\n\n  #sidebar {\n    grid-area: sidebar;\n    padding: 0 .5rem;\n  }\n\n}\n\nhtml {\n  box-sizing: border-box;\n}\n\n*, *:before, *:after {\n  box-sizing: inherit;\n}\n\nmenu {\n  margin: 0;\n  padding: 0;\n}\n\n.content {\n  // margin: -3px !important;\n  overflow: hidden !important;\n  // padding: 3px !important;\n\n  blockquote {\n    border-left: 2px solid var(--kbin-blockquote-color);\n    margin: 0 0 1rem 1rem !important;\n    padding-left: 1rem;\n  }\n}\n\nmain {\n  .content {\n    a {\n      color: var(--kbin-section-link-color) !important;\n    }\n  }\n}\n\n.row,\n.content {\n  position: relative;\n  word-break: break-word;\n}\n\nhr {\n  border: 1px solid var(--kbin-bg);\n}\n\n.float-end {\n  text-align: right;\n}\n\ntable {\n  border-collapse: collapse;\n  font-family: sans-serif;\n  font-size: .9em;\n  -webkit-overflow-scrolling: touch;\n  overflow-x: auto;\n  width: 100%;\n  border: var(--kbin-section-border);\n}\n\ntable thead tr {\n  font-weight: bold;\n  text-align: left;\n}\n\ntable th{\n  background-color: var(--kbin-bg);\n}\n\ntable th a{\n  overflow-wrap: normal !important;\n}\n\ntable th,\ntable td {\n  padding: 0.5rem 0.25rem;\n  position: relative;\n  border: var(--kbin-section-border);\n\n  button.btn {\n    padding: .25rem;\n  }\n}\n\ntable tbody tr {\n  border-bottom: var(--kbin-section-border);\n}\n\ntable tbody tr:nth-of-type(even) {\n  background-color: var(--kbin-bg-nth);\n}\n\n.icon {\n  font-size: 0;\n\n  i {\n    font-size: initial;\n  }\n}\n\nfigure {\n  margin: 0;\n}\n\n.options--top,\n.section--top {\n  margin-top: 0.5rem !important;\n}\n\n.rounded-edges {\n  .section,\n  .options,\n  .alert,\n  .btn,\n  figure img,\n  input:not([type='radio']),\n  textarea,\n  select,\n  button,\n  details,\n  .preview img,\n  .preview iframe,\n  .dropdown__menu,\n  #sidebar .theme,\n  #sidebar .font-size,\n  #sidebar .row div,\n  #sidebar .user-list img,\n  .no-image-placeholder,\n  .pagination__item,\n  .no-avatar,\n  code,\n  .ts-control > [data-value].item,\n  .image-preview-container {\n    &:not(.ignore-edges) {\n      border-radius: var(--kbin-rounded-edges-radius) !important;\n    }\n  }\n\n  .ts-wrapper {\n    .ts-control {\n      border-radius: .5rem;\n    }\n\n    &.dropdown-active .ts-control {\n      border-radius: .5rem .5rem 0 0;\n    }\n  }\n\n  .ts-dropdown {\n    border-radius: 0 0 .5rem .5rem\n  }\n\n  .options {\n    button {\n      border-radius: 0;\n    }\n\n    menu {\n      border-radius: 0 0 0 .5rem;\n    }\n  }\n\n  .options--top,\n  .section--top {\n    border-radius: 0 0 .5rem .5rem !important;\n  }\n\n  .magazine__subscribe,\n  .user__actions,\n  .domain__subscribe {\n    gap: 0.25rem\n  }\n}\n\n.dot {\n  background: var(--kbin-primary-color);\n  border-radius: 50%;\n  display: inline-block;\n  height: 15px;\n  width: 15px;\n}\n\n.opacity-50 {\n  opacity: .5;\n}\n\n.ms-1 {\n  margin-left: .5rem;\n}\n\n.me-1 {\n  margin-right: .5rem;\n}\n\n.text-right {\n  text-align: right !important;\n}\n\n.z-5 {\n  z-index: 5 !important;\n}\n\n.visually-hidden {\n  visibility: hidden;\n}\n\n.loader {\n  animation: rotation 1s linear infinite;\n  border: 5px solid var(--kbin-meta-text-color);\n  border-bottom-color: transparent;\n  border-radius: 50%;\n  box-sizing: border-box;\n  display: inline-block;\n  height: 28px;\n  text-align: center;\n  width: 28px;\n  line-height: 1;\n  margin: auto;\n\n  span {\n    visibility: hidden;\n  }\n\n  &.hide{\n    display: none;\n  }\n\n  &.small{\n    width: 14px;\n    height: 14px;\n    border-width: 3px;\n  }\n}\n\n.danger, .danger i {\n  color: var(--kbin-danger-color);\n}\n\n.danger-bg {\n  background: var(--kbin-danger-color);\n}\n\n.success,\n.success i {\n  color: var(--kbin-success-color);\n}\n\n.secondary-bg {\n  background: var(--kbin-section-bg);\n  color: var(--kbin-meta-text-color);\n}\n\n.kbin-bg {\n  background: var(--kbin-bg);\n}\n\n@keyframes rotation {\n  0% {\n    transform: rotate(0deg);\n  }\n  100% {\n    transform: rotate(360deg);\n  }\n}\n\n.hidden {\n  display: none;\n}\n\n.select div {\n  height: 100%;\n\n  select {\n    height: 100%;\n  }\n}\n\n.flex {\n  display: flex;\n  gap: .25rem;\n\n  &.flex-reverse {\n    flex-direction: row-reverse;\n  }\n}\n\n.flex-item {\n  flex-grow: 1;\n\n  &.flex-item-auto {\n    flex-grow: 0;\n  }\n}\n\n@include b.media-breakpoint-down(lg) {\n  .flex.mobile {\n    display: block;\n  }\n}\n\n.flex-wrap {\n  flex-wrap: wrap;\n}\n\npre, code {\n  white-space: pre-wrap;\n  word-wrap: break-word;\n}\n\npre > code {\n  display: inline-block;\n  color: var(--kbin-text-color);\n  background: var(--kbin-bg);\n  padding: 1rem;\n  font-size: .85rem;\n  max-height: 16rem;\n  overflow: auto;\n}\n\np > code {\n  color: var(--kbin-text-color);\n  background: var(--kbin-bg);\n  padding: 0.2rem .4rem;\n  font-size: .85rem;\n}\n\ndetails {\n  border: var(--mbin-details-border);\n  border-left: 2px solid var(--mbin-details-detail-color);\n  padding: .5rem;\n  margin: .5rem 0;\n\n  summary {\n    padding-left: .5rem;\n    cursor: pointer;\n\n    > * {\n      display: inline;\n    }\n\n    &:empty::after {\n      content: var(--mbin-details-detail-label);\n    }\n  }\n\n  > .content {\n    margin-top: .5rem;\n    padding-top: .5rem;\n    padding-left: .5rem;\n  }\n\n  &.spoiler {\n    border-left: 2px solid var(--mbin-details-spoiler-color);\n\n    summary:empty::after {\n      content: var(--mbin-details-spoiler-label);\n    }\n  }\n\n  &[open] > .content {\n    border-top: var(--mbin-details-separator-border);\n  }\n\n  @include b.media-breakpoint-down(sm) {\n    summary,\n    > .content {\n      padding-left: .25rem;\n    }\n  }\n\n  #sidebar & {\n    summary,\n    > .content {\n      padding-left: .25rem;\n    }\n  }\n}\n\n.markdown {\n  display: flex;\n  flex-wrap: wrap;\n  gap: 1rem;\n  padding: .5rem;\n}\n\n#scroll-top {\n  background-color: var(--kbin-section-bg);\n  border-radius: 5px;\n  bottom: 20px;\n  cursor: pointer;\n  //display: none;\n  font-size: 18px;\n  outline: none;\n  padding: 15px 20px;\n  position: fixed;\n  right: 30px;\n  z-index: 99;\n}\n\n.js-container {\n  margin-bottom: 0;\n}\n\n.bold {\n  font-weight: bold;\n}\n\n.no-avatar {\n  display: block;\n  width: 30px;\n  height: 30px;\n  border: var(--kbin-avatar-border);\n\n  @include b.media-breakpoint-up(sm) {\n    width: 40px;\n    height: 40px;\n  }\n}\n\n:target {\n  scroll-margin-top: 8rem;\n}\n\n.boost-link {\n  &.active{\n    color: var(--kbin-boosted-color);\n  }\n}\n\n.magazine-banner {\n  margin-top: .5rem;\n  text-align: center;\n\n  img.cover {\n    width: 100%;\n    max-height: 300px;\n    object-fit: cover;\n  }\n}\n\n.rounded-edges {\n  .magazine-banner {\n    border-radius: var(--kbin-rounded-edges-radius);\n    img.cover {\n      border-radius: var(--kbin-rounded-edges-radius);\n    }\n  }\n}\n"
  },
  {
    "path": "assets/styles/layout/_meta.scss",
    "content": ".meta {\n  color: var(--kbin-meta-text-color);\n  font-size: .8rem;\n  opacity: .75;\n\n  a {\n    color: var(--kbin-meta-link-color);\n    font-weight: bold;\n    padding: .5rem 0;\n\n    img {\n      margin-left: 0.25rem;\n    }\n\n    &:hover {\n      color: var(--kbin-meta-link-hover-color);\n    }\n\n    &:first-of-type {\n      margin-left: 0rem;\n    }\n  }\n}\n\n.meta-link {\n  color: var(--kbin-meta-link-color);\n}\n"
  },
  {
    "path": "assets/styles/layout/_normalize.scss",
    "content": "/*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */\n\n/* Document\n   ========================================================================== */\n\n/**\n * 1. Correct the line height in all browsers.\n * 2. Prevent adjustments of font size after orientation changes in iOS.\n */\n\nhtml {\n    line-height: 1.15; /* 1 */\n    -webkit-text-size-adjust: 100%; /* 2 */\n}\n\n/* Sections\n   ========================================================================== */\n\n/**\n * Remove the margin in all browsers.\n */\n\nbody {\n    margin: 0;\n}\n\n/**\n * Render the `main` element consistently in IE.\n */\n\nmain {\n    display: block;\n}\n\n/**\n * Correct the font size and margin on `h1` elements within `section` and\n * `article` contexts in Chrome, Firefox, and Safari.\n */\n\nh1 {\n    font-size: 2em;\n    margin: 0.67em 0;\n}\n\n/* Grouping content\n   ========================================================================== */\n\n/**\n * 1. Add the correct box sizing in Firefox.\n * 2. Show the overflow in Edge and IE.\n */\n\nhr {\n    box-sizing: content-box; /* 1 */\n    height: 0; /* 1 */\n    overflow: visible; /* 2 */\n}\n\n/**\n * 1. Correct the inheritance and scaling of font size in all browsers.\n * 2. Correct the odd `em` font sizing in all browsers.\n */\n\npre {\n    font-family: monospace, monospace; /* 1 */\n    font-size: 1em; /* 2 */\n}\n\n/* Text-level semantics\n   ========================================================================== */\n\n/**\n * Remove the gray background on active links in IE 10.\n */\n\na {\n    background-color: transparent;\n}\n\n/**\n * 1. Remove the bottom border in Chrome 57-\n * 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari.\n */\n\nabbr[title] {\n    border-bottom: none; /* 1 */\n    text-decoration: underline; /* 2 */\n    text-decoration: underline dotted; /* 2 */\n}\n\n/**\n * Add the correct font weight in Chrome, Edge, and Safari.\n */\n\nb,\nstrong {\n    font-weight: bolder;\n}\n\n/**\n * 1. Correct the inheritance and scaling of font size in all browsers.\n * 2. Correct the odd `em` font sizing in all browsers.\n */\n\ncode,\nkbd,\nsamp {\n    font-family: monospace, monospace; /* 1 */\n    font-size: 1em; /* 2 */\n}\n\n/**\n * Add the correct font size in all browsers.\n */\n\nsmall {\n    font-size: 80%;\n}\n\n/**\n * Prevent `sub` and `sup` elements from affecting the line height in\n * all browsers.\n */\n\nsub,\nsup {\n    font-size: 75%;\n    line-height: 0;\n    position: relative;\n    vertical-align: baseline;\n}\n\nsub {\n    bottom: -0.25em;\n}\n\nsup {\n    top: -0.5em;\n}\n\n/* Embedded content\n   ========================================================================== */\n\n/**\n * Remove the border on images inside links in IE 10.\n */\n\nimg {\n    border-style: none;\n}\n\n/* Forms\n   ========================================================================== */\n\n/**\n * 1. Change the font styles in all browsers.\n * 2. Remove the margin in Firefox and Safari.\n */\n\nbutton,\ninput,\noptgroup,\nselect,\ntextarea {\n    font-family: inherit; /* 1 */\n    font-size: 100%; /* 1 */\n    line-height: 1.15; /* 1 */\n    margin: 0; /* 2 */\n}\n\n/**\n * Show the overflow in IE.\n * 1. Show the overflow in Edge.\n */\n\nbutton,\ninput { /* 1 */\n    overflow: visible;\n}\n\n/**\n * Remove the inheritance of text transform in Edge, Firefox, and IE.\n * 1. Remove the inheritance of text transform in Firefox.\n */\n\nbutton,\nselect { /* 1 */\n    text-transform: none;\n}\n\n/**\n * Correct the inability to style clickable types in iOS and Safari.\n */\n\nbutton,\n[type=\"button\"],\n[type=\"reset\"],\n[type=\"submit\"] {\n    -webkit-appearance: button;\n}\n\n/**\n * Remove the inner border and padding in Firefox.\n */\n\nbutton::-moz-focus-inner,\n[type=\"button\"]::-moz-focus-inner,\n[type=\"reset\"]::-moz-focus-inner,\n[type=\"submit\"]::-moz-focus-inner {\n    border-style: none;\n    padding: 0;\n}\n\n/**\n * Restore the focus styles unset by the previous rule.\n */\n\nbutton:-moz-focusring,\n[type=\"button\"]:-moz-focusring,\n[type=\"reset\"]:-moz-focusring,\n[type=\"submit\"]:-moz-focusring {\n    outline: 1px dotted ButtonText;\n}\n\n/**\n * Correct the padding in Firefox.\n */\n\nfieldset {\n    padding: 0.35em 0.75em 0.625em;\n}\n\n/**\n * 1. Correct the text wrapping in Edge and IE.\n * 2. Correct the color inheritance from `fieldset` elements in IE.\n * 3. Remove the padding so developers are not caught out when they zero out\n *    `fieldset` elements in all browsers.\n */\n\nlegend {\n    box-sizing: border-box; /* 1 */\n    color: inherit; /* 2 */\n    display: table; /* 1 */\n    max-width: 100%; /* 1 */\n    padding: 0; /* 3 */\n    white-space: normal; /* 1 */\n}\n\n/**\n * Add the correct vertical alignment in Chrome, Firefox, and Opera.\n */\n\nprogress {\n    vertical-align: baseline;\n}\n\n/**\n * Remove the default vertical scrollbar in IE 10+.\n */\n\ntextarea {\n    overflow: auto;\n}\n\n/**\n * 1. Add the correct box sizing in IE 10.\n * 2. Remove the padding in IE 10.\n */\n\n[type=\"checkbox\"],\n[type=\"radio\"] {\n    box-sizing: border-box; /* 1 */\n    padding: 0; /* 2 */\n}\n\n/**\n * Correct the cursor style of increment and decrement buttons in Chrome.\n */\n\n[type=\"number\"]::-webkit-inner-spin-button,\n[type=\"number\"]::-webkit-outer-spin-button {\n    height: auto;\n}\n\n/**\n * 1. Correct the odd appearance in Chrome and Safari.\n * 2. Correct the outline style in Safari.\n */\n\n[type=\"search\"] {\n    -webkit-appearance: textfield; /* 1 */\n    outline-offset: -2px; /* 2 */\n}\n\n/**\n * Remove the inner padding in Chrome and Safari on macOS.\n */\n\n[type=\"search\"]::-webkit-search-decoration {\n    -webkit-appearance: none;\n}\n\n/**\n * 1. Correct the inability to style clickable types in iOS and Safari.\n * 2. Change font properties to `inherit` in Safari.\n */\n\n::-webkit-file-upload-button {\n    -webkit-appearance: button; /* 1 */\n    font: inherit; /* 2 */\n}\n\n/* Interactive\n   ========================================================================== */\n\n/*\n * Add the correct display in Edge, IE 10+, and Firefox.\n */\n\ndetails {\n    display: block;\n}\n\n/*\n * Add the correct display in all browsers.\n */\n\nsummary {\n    display: list-item;\n}\n\n/* Misc\n   ========================================================================== */\n\n/**\n * Add the correct display in IE 10+.\n */\n\ntemplate {\n    display: none;\n}\n\n/**\n * Add the correct display in IE 10.\n */\n\n[hidden] {\n    display: none;\n}\n"
  },
  {
    "path": "assets/styles/layout/_options.scss",
    "content": "@use 'breakpoints' as b;\n\n.options {\n  background: var(--kbin-options-bg);\n  border: var(--kbin-options-border);\n  color: var(--kbin-options-text-color);\n  display: grid;\n  font-size: .85rem;\n  grid-template-areas: \"start middle beforeEnd end\";\n  grid-template-columns: max-content auto max-content max-content;\n  height: 2.5rem;\n  margin-bottom: .5rem;\n  z-index: 5;\n\n  .dropdown__menu {\n    opacity: 1;\n  }\n\n  .dropdown:hover,\n  .dropdown:focus-within{\n\n    .dropdown__menu {\n      @include b.media-breakpoint-down(lg){\n        left: auto;\n        top: 100%;\n        transform: none;\n        right: 0;\n        min-width: 10rem;\n      }\n    }\n  }\n\n  .options__filter .dropdown__menu,\n  .options__filter .dropdown:hover .dropdown__menu,\n  .options__filter .dropdown:focus-within .dropdown__menu {\n      /* Positioning for dropdown menus inside .options__main */\n      left: 0;\n      right: auto; /* Reset the right property */\n      top: 100%; /* Position it below the trigger element */\n      transform: none;\n      min-width: 10rem;\n  }\n\n  .scroll {\n    position: static;\n    align-self: center;\n    border-left: var(--kbin-options-border);\n    border-radius: 0;\n    height: 100%;\n    padding: 0px;\n\n    .scroll-left,\n    .scroll-right{\n      padding: 0.5rem;\n      cursor: pointer;\n      color: var(--kbin-button-secondary-text-color);\n\n      &:hover,\n      &:active{\n        color: var(--kbin-button-secondary-text-hover-color);\n      }\n    }\n  }\n\n  &__view{\n\n    li:not(:last-of-type){\n      button{\n        border-bottom-left-radius: 0px!important;\n        border-bottom-right-radius: 0px!important;\n      }\n    }\n    li:last-of-type{\n      button{\n        border-bottom-left-radius: 0px!important;\n      }\n    }\n\n  }\n\n  &__filter {\n    li:first-of-type {\n      button {\n          border-bottom-right-radius: 0px!important;\n      }\n    }\n\n    li:not(:first-of-type) {\n      button {\n        border-bottom-right-radius: 0px!important;\n        border-bottom-left-radius: 0px!important;\n      }\n    }\n\n    button {\n      font-size: 0;\n\n      i {\n        font-size: .85rem;\n      }\n\n      span {\n        font-size: .85rem;\n        margin-left: 0.5rem;\n      }\n    }\n  }\n\n  &--top {\n    border-top: 0;\n  }\n\n  h1, h2, h3 {\n    font-size: .85rem;\n    font-weight: bold;\n    margin: 0;\n    border-bottom: 3px solid transparent;\n  }\n\n  & > * {\n    align-items: center;\n    align-self: self-end;\n    display: grid;\n    grid-auto-columns: max-content;\n    grid-auto-flow: column;\n    justify-content: end;\n    list-style: none;\n    margin: 0;\n    padding: 0;\n\n    .options__nolink {\n      background: none;\n      border: 0;\n      border-bottom: 3px solid transparent;\n      display: block;\n      padding: .5rem 1rem;\n      text-decoration: none;\n    }\n\n    a, button {\n      background: none;\n      border: 0;\n      border-bottom: 3px solid transparent;\n      color: var(--kbin-options-link-color);\n      display: block;\n      padding: .5rem 1rem;\n      text-decoration: none;\n\n      &.active,\n      &:focus-visible,\n      &:hover {\n        border-bottom: var(--kbin-options-link-hover-border);\n        color: var(--kbin-options-link-hover-color);\n      }\n    }\n  }\n\n  &__main {\n    justify-content: start;\n    overflow: hidden;\n    -ms-overflow-style: none;\n    overflow-x: auto;\n    scrollbar-width: none;\n  }\n\n  &__main::-webkit-scrollbar {\n    display: none;\n  }\n\n  &__title {\n    align-self: center;\n    margin: 0 .5rem;\n    text-transform: uppercase;\n  }\n\n  &__filter {\n    justify-content: start;\n  }\n\n  &__view button {\n    font-size: 0;\n\n    i {\n      font-size: .85rem;\n    }\n\n    span {\n      font-size: .85rem;\n      margin-left: 0.5rem;\n\n      @include b.media-breakpoint-down(lg) {\n          display: none;\n      }\n    }\n  }\n}\n\n.pills {\n  margin-bottom: .5rem;\n  padding: 1rem 0;\n\n  menu,\n  div {\n    display: flex;\n    flex-wrap: wrap;\n    gap: .5rem;\n    list-style: none;\n\n    a {\n      color: var(--kbin-meta-link-color);\n      font-weight: bold;\n      padding: 1rem;\n    }\n\n    a:hover,\n    .active {\n      color: var(--kbin-meta-link-hover-color);\n    }\n  }\n}\n"
  },
  {
    "path": "assets/styles/layout/_section.scss",
    "content": ".section {\n  background-color: var(--kbin-section-bg);\n  border: var(--kbin-section-border);\n  color: var(--kbin-section-text-color);\n  margin-bottom: .5rem;\n  padding: 2rem 1rem;\n\n  &.section-sm {\n    padding: .5em;\n  }\n\n  a:not(.notification-setting) {\n    color: var(--kbin-section-title-link-color);\n    overflow-wrap: anywhere;\n\n    &:hover {\n      color: var(--kbin-section-link-hover-color);\n    }\n  }\n\n  menu {\n    font-size: .8rem;\n\n    > li:first-child a {\n      padding-left: 0;\n    }\n\n    > li {\n      padding: .5rem 0;\n      position: relative;\n    }\n  }\n\n  &--small {\n    padding: 1rem 1rem;\n  }\n\n  &--top {\n    border-top: 0 !important;\n  }\n\n  &--muted {\n    font-size: 1.3rem !important;\n    font-weight: bold;\n    text-align: center;\n\n    p {\n      color: var(--kbin-text-muted-color);\n      margin: 0;\n    }\n\n    small {\n      color: var(--kbin-text-muted-color);\n      display: block;\n      font-size: .8rem;\n      font-weight: normal;\n    }\n  }\n\n  &--no-bg {\n    background: none;\n  }\n\n  &--no-border {\n    border: 0;\n  }\n\n  &__danger {\n    border: var(--kbin-alert-danger-border);\n    background-color: var(--kbin-alert-danger-bg);\n    color: var(--kbin-alert-danger-text-color);\n  }\n}\n\n.cursor-pagination .section {\n  text-align: center;\n  padding: .25rem;\n\n  .container {\n    display: flex;\n    flex-direction: row;\n\n    > .col {\n      display: block;\n      flex-grow: 1;\n\n      &.col-auto {\n        flex-grow: 0;\n      }\n    }\n  }\n\n  a {\n    display: inline-block;\n    width: 150px;\n\n    &.small {\n      width: 50px\n    }\n  }\n}\n"
  },
  {
    "path": "assets/styles/layout/_tools.scss",
    "content": ".stretched-link {\n  &::after {\n    bottom: 0;\n    content: '';\n    left: 0;\n    position: absolute;\n    right: 0;\n    top: 0;\n    z-index: 1;\n  }\n}\n\na.link-muted,\n.text-muted {\n  color: var(--kbin-meta-text-color);\n  font-weight: normal;\n}\n\na.link-muted:hover {\n  color: var(--kbin-link-hover-color);\n}\n\n.mb-0 {\n  margin-bottom: 0 !important;\n}\n\n.mb-1 {\n  margin-bottom: .5rem !important;\n}\n\n.mb-2 {\n  margin-bottom: 1rem !important;\n}\n\n.mt-0 {\n  margin-bottom: 0 !important;\n}\n\n.mt-1 {\n  margin-top: .5rem !important;\n}\n\n.mt-2 {\n  margin-top: 1rem !important;\n}\n\n.mt-3 {\n  margin-top: 1.5rem !important;\n}\n\n.mt-4 {\n  margin-top: 2rem !important;\n}\n\n.table-responsive {\n  -webkit-overflow-scrolling: touch;\n  overflow-x: auto;\n}\n\n.user-content-table-responsive {\n  display: block;\n  overflow-x: auto;\n  white-space: pre-wrap;\n}\n\n.badge {\n  border-radius: 20px;\n  padding: .5rem;\n}\n\n.badge-lang {\n  border-radius: 20px;\n  padding: .3rem;\n}"
  },
  {
    "path": "assets/styles/layout/_typo.scss",
    "content": "@use '../mixins/mbin';\n\nbody {\n  background-color: var(--kbin-body-bg);\n  color: var(--kbin-text-color);\n  font-family: var(--kbin-body-font-family);\n  font-size: var(--kbin-body-font-size);\n  font-weight: var(--kbin-body-font-weight);\n  line-height: var(--kbin-body-line-height);\n  margin: 0;\n  -webkit-tap-highlight-color: transparent;\n  text-align: var(--kbin-body-text-align);\n  -webkit-text-size-adjust: 100%;\n}\n\na {\n  color: var(--kbin-link-color);\n  text-decoration: none;\n\n  &:hover {\n    color: var(--kbin-link-hover-color);\n  }\n}\n\np {\n  margin-bottom: 1rem;\n  margin-top: 0;\n}\n\nh1 {\n  font-size: 2.5rem;\n}\n\nh2 {\n  font-size: 2rem;\n}\n\nh3 {\n  font-size: 1.75rem;\n}\n\nh4 {\n  font-size: 1.5rem;\n}\n\nh5 {\n  font-size: 1.25rem;\n}\n\nh6 {\n  font-size: 1rem;\n}\n\n.content:not(.formatted) {\n  h1, h2, h3, h4, h5 {\n    font-size: 1rem;\n    font-weight: bold;\n  }\n}\n\n.btn-link {\n  @include mbin.btn-link;\n}\n"
  },
  {
    "path": "assets/styles/mixins/animations.scss",
    "content": "@use '../layout/breakpoints' as b;\n\n@mixin fade-in($waitTime, $from) {\n  animation: fadein #{$waitTime} linear 1 normal forwards;\n  -webkit-animation: fadein #{$waitTime} linear 1 normal forwards;\n\n  @include b.media-breakpoint-down(sm) {\n    animation: fadein 0s linear 1 normal forwards;\n    -webkit-animation: fadein 0s linear 1 normal forwards;\n  }\n\n\n  @keyframes fadein {\n    from {\n      opacity: $from;\n    }\n    to {\n      opacity: 1;\n    }\n  }\n\n  @-webkit-keyframes fadein {\n    from {\n      opacity: $from;\n    }\n    to {\n      opacity: 1;\n    }\n  }\n}\n"
  },
  {
    "path": "assets/styles/mixins/mbin.scss",
    "content": "@mixin btn-link {\n  background: none;\n  border: 0;\n  display: inline;\n  font-weight: normal;\n  margin: 0;\n  padding: 0;\n  color: var(--kbin-meta-link-color);\n  font-size: 1em;\n\n  &:hover,\n  &:focus-within {\n    background: none;\n    cursor: pointer;\n  }\n\n  &:hover {\n    color: var(--kbin-meta-link-hover-color);\n  }\n}\n\n@mixin cursor-pointer{\n  cursor: pointer;\n}\n\n@mixin simple-transition{\n  transition: all 150ms ease-in;\n}\n"
  },
  {
    "path": "assets/styles/mixins/theme-dark.scss",
    "content": "// loaded under .theme--dark\n// or .theme--default with prefers-color-scheme: dark\n@mixin theme {\n  --kbin-body-font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"Noto Sans\", Roboto, \"Helvetica Neue\", Arial, sans-serif, \"Apple Color Emoji\", \"Segoe UI Emoji\", \"Segoe UI Symbol\";\n  --kbin-body-font-size: 1rem;\n  --kbin-body-font-weight: 400;\n  --kbin-body-line-height: 1.5;\n  --kbin-body-text-align: left;\n  --kbin-body-bg: #1c1c1c;\n\n  --kbin-bg: #1c1c1c;\n  --kbin-bg-nth: #232323;\n\n  --kbin-text-color: #cacece;\n  --kbin-link-color: #cdd5de;\n  --kbin-link-hover-color: #fafafa;\n  --kbin-outline: #ff8c00 solid 4px;\n\n  --kbin-primary-color: #3c3c3c;\n  --kbin-text-muted-color: #95a6a6;\n\n  --kbin-success-color: #24b270;\n  --kbin-danger-color: #dc2f3f;\n\n  --kbin-own-color: #24b270;\n  --kbin-author-color: #dc2f3f;\n\n  // section\n  --kbin-section-bg: #2c2c2c;\n  --kbin-section-text-color: var(--kbin-text-color);\n  --kbin-section-title-link-color: var(--kbin-link-color);\n  --kbin-section-link-color: #85a1c0;\n  --kbin-section-link-hover-color: var(--kbin-link-hover-color);\n  --kbin-section-border: 1px solid #373737;\n  --kbin-author-border: 1px dashed var(--kbin-author-color);\n  --kbin-own-border: 1px dashed var(--kbin-own-color);\n\n  // meta\n  --kbin-meta-bg: none;\n  --kbin-meta-text-color: #cecece;\n  --kbin-meta-link-color: #d9dde5;\n  --kbin-meta-link-hover-color: var(--kbin-link-hover-color);\n  --kbin-meta-border: 1px dashed #4d5052;\n  --kbin-avatar-border: 3px solid #373737;\n  --kbin-avatar-border-active: 3px solid #3c3c3c;\n  --kbin-blockquote-color: #24b270;\n\n  // options\n  --kbin-options-bg: #2c2c2c;\n  --kbin-options-text-color: #95a5a6;\n  --kbin-options-link-color: #cdd5de;\n  --kbin-options-link-hover-color: #e1e5ec;\n  --kbin-options-border: 1px solid #373737;\n  --kbin-options-link-hover-border: 3px solid #e1e5ec;\n\n  // forms\n  --kbin-input-bg: #1c1c1c;\n  --kbin-input-text-color: var(--kbin-text-color);\n  --kbin-input-border-color: #373737;\n  --kbin-input-border: 1px solid var(--kbin-input-border-color);\n  --kbin-input-placeholder-text-color: #878787;\n\n  // buttons\n  --kbin-button-primary-bg: var(--kbin-primary-color);\n  --kbin-button-primary-hover-bg: #333333;\n  --kbin-button-primary-text-color: #fff;\n  --kbin-button-primary-text-hover-color: #fff;\n  --kbin-button-primary-border: 1px solid #4a4a4a;\n\n  --kbin-button-secondary-bg: #1c1c1c;\n  --kbin-button-secondary-hover-bg: #282828;\n  --kbin-button-secondary-text-color: var(--kbin-meta-text-color);\n  --kbin-button-secondary-text-hover-color: var(--kbin-text-color);\n  --kbin-button-secondary-border: 1px dashed #373737;\n\n  // header\n  --kbin-header-bg: var(--kbin-primary-color);\n  --kbin-header-text-color: #fff;\n  --kbin-header-link-color: #fff;\n  --kbin-header-link-hover-color: #e8e8e8;\n  --kbin-header-link-active-bg: var(--kbin-options-bg);\n  --kbin-header-border: 1px solid #e5eaec;\n  --kbin-header-hover-border: 3px solid #fff;\n\n  // topbar\n  --kbin-topbar-bg: var(--kbin-section-bg);\n  --kbin-topbar-active-bg: #fff;\n  --kbin-topbar-active-link-color: #000;\n  --kbin-topbar-hover-bg: #282828;\n  --kbin-topbar-border: 1px solid #4d5052;\n\n  //sidebar\n  --kbin-sidebar-header-text-color: #909ea2;\n  --kbin-sidebar-header-border: 1px solid #4a4a4a;\n  --kbin-sidebar-settings-row-bg: #3c3c3c;\n  --kbin-sidebar-settings-switch-on-color: #FFFFFF;\n  --kbin-sidebar-settings-switch-on-bg: #B3B3B3;\n  --kbin-sidebar-settings-switch-off-color: #989898;\n  --kbin-sidebar-settings-switch-off-bg: #1C1C1C;\n  --kbin-sidebar-settings-switch-hover-bg: #666666;\n\n  //vote\n  --kbin-vote-bg: #1c1c1c;\n  --kbin-vote-bg-hover-bg: #161616;\n  --kbin-vote-text-color: #b6b6b6;\n  --kbin-vote-text-hover-color: #fafafa;\n  --kbin-upvoted-color: #24b270;\n  --kbin-downvoted-color: #dc2f3f;\n\n  //boost\n  --kbin-boosted-color: var(--kbin-upvoted-color);\n\n  // alerts\n  --kbin-alert-info-bg: rgba(153,116,4,0.15);\n  --kbin-alert-info-border: 1px solid rgba(153,116,4, 0.04);\n  --kbin-alert-info-text-color: #997404;\n  --kbin-alert-info-link-color: #997404;\n\n  --kbin-alert-danger-bg: rgba(171, 28, 40, 0.9);\n  --kbin-alert-danger-border: 1px solid rgba(171, 28, 40, 1);\n  --kbin-alert-danger-text-color: var(--kbin-danger-color);\n  --kbin-alert-danger-link-color: var(--kbin-danger-color);\n\n  //entry\n  --kbin-entry-link-visited-color: #8e939b;\n\n  // details\n  --mbin-details-border: var(--kbin-section-border);\n  --mbin-details-separator-border: var(--kbin-meta-border);\n\n  --mbin-details-detail-color: var(--kbin-link-hover-color);\n  --mbin-details-spoiler-color: var(--kbin-danger-color);\n}\n"
  },
  {
    "path": "assets/styles/mixins/theme-light.scss",
    "content": "// loaded under .theme--light\n// or .theme--default with prefers-color-scheme: light\n@mixin theme {\n  --kbin-body-font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"Noto Sans\", Roboto, \"Helvetica Neue\", Arial, sans-serif, \"Apple Color Emoji\", \"Segoe UI Emoji\", \"Segoe UI Symbol\";\n  --kbin-body-font-size: 1rem;\n  --kbin-body-font-weight: 400;\n  --kbin-body-line-height: 1.5;\n  --kbin-body-text-align: left;\n  --kbin-body-bg: #fff;\n\n  --kbin-bg: #ecf0f1;\n  --kbin-bg-nth: #fafafa;\n\n\n  --kbin-text-color: #1c1e20;\n  --kbin-link-color: #245e83;\n  --kbin-link-hover-color: #153b57;\n  --kbin-outline: #ff8c00 solid 4px;\n\n  --kbin-primary-color: #61366b;\n  --kbin-text-muted-color: #95a6a6;\n\n  --kbin-success-color: #0f5132;\n  --kbin-danger-color: #842029;\n\n  --kbin-own-color: #0f5132;\n  --kbin-author-color: #842029;\n\n  // section\n  --kbin-section-bg: #fff;\n  --kbin-section-text-color: var(--kbin-text-color);\n  --kbin-section-title-link-color: var(--kbin-link-color);\n  --kbin-section-link-color: var(--kbin-link-color);\n  --kbin-section-link-hover-color: var(--kbin-link-hover-color);\n  --kbin-section-border: 1px solid #e5eaec;\n  --kbin-author-border: 1px dashed var(--kbin-author-color);\n  --kbin-own-border: 1px dashed var(--kbin-own-color);\n\n  // meta\n  --kbin-meta-bg: none;\n  --kbin-meta-text-color: #222;\n  --kbin-meta-link-color: #222;\n  --kbin-meta-link-hover-color: var(--kbin-link-hover-color);\n  --kbin-meta-border: 1px dashed #e5eaec;\n  --kbin-avatar-border: 3px solid #ecf0f1;\n  --kbin-avatar-border-active: 3px solid #d3d5d6;\n  --kbin-blockquote-color: #0f5132;\n\n  // options\n  --kbin-options-bg: #fff;\n  --kbin-options-text-color: #6f7575;\n  --kbin-options-link-color: #6f7575;\n  --kbin-options-link-hover-color: #32465b;\n  --kbin-options-border: 1px solid #e5eaec;\n  --kbin-options-link-hover-border: 3px solid #32465b;\n\n  // forms\n  --kbin-input-bg: #f3f3f3;\n  --kbin-input-text-color: var(--kbin-text-color);\n  --kbin-input-border-color: #c9cecf;\n  --kbin-input-border: 1px solid var(--kbin-input-border-color);\n  --kbin-input-placeholder-text-color: #929497;\n\n  // buttons\n  --kbin-button-primary-bg: #7951a7;\n  --kbin-button-primary-hover-bg: #66438f;\n  --kbin-button-primary-text-color: #fff;\n  --kbin-button-primary-text-hover-color: #fff;\n  --kbin-button-primary-border: 1px solid #66438f;\n\n  --kbin-button-secondary-bg: #fff;\n  --kbin-button-secondary-hover-bg: #f5f5f5;\n  --kbin-button-secondary-text-color: var(--kbin-meta-text-color);\n  --kbin-button-secondary-text-hover-color: var(--kbin-text-color);\n  --kbin-button-secondary-border: 1px dashed #e5eaec;\n\n  // header\n  --kbin-header-bg: #2c074b;\n  --kbin-header-text-color: #fff;\n  --kbin-header-link-color: #fff;\n  --kbin-header-link-hover-color: #e8e8e8;\n  --kbin-header-link-active-bg: #0a0026;\n  --kbin-header-border: 1px solid #e5eaec;\n  --kbin-header-hover-border: 3px solid #fff;\n\n  // topbar\n  --kbin-topbar-bg: #0a0026;\n  --kbin-topbar-active-bg: #150a37;\n  --kbin-topbar-active-link-color: #fff;\n  --kbin-topbar-hover-bg: #150a37;\n  --kbin-topbar-border: 1px solid  #150a37;\n\n  //sidebar\n  --kbin-sidebar-header-text-color: #595d5e;\n  --kbin-sidebar-header-border: 1px solid #e5eaec;\n  --kbin-sidebar-settings-row-bg: #E5EAEC;\n  --kbin-sidebar-settings-switch-on-color: #fff ;\n  --kbin-sidebar-settings-switch-on-bg: var(--kbin-button-primary-bg);\n  --kbin-sidebar-settings-switch-off-color: #fff ;\n  --kbin-sidebar-settings-switch-off-bg: #b5c4c9;\n  --kbin-sidebar-settings-switch-hover-bg: #9992BC;\n\n  //vote\n  --kbin-vote-bg: #e7e7e7;\n  --kbin-vote-bg-hover-bg: #dfdfdf;\n  --kbin-vote-text-color: #222222;\n  --kbin-vote-text-hover-color: #0d0014;\n  --kbin-upvoted-color: #0f663d;\n  --kbin-downvoted-color: #961822;\n\n  //boost\n  --kbin-boosted-color: var(--kbin-upvoted-color);\n\n  // alerts\n  --kbin-alert-info-bg: #fff3cd;\n  --kbin-alert-info-border: 1px solid #ffe69c;\n  --kbin-alert-info-text-color: #997404;\n  --kbin-alert-info-link-color: #997404;\n\n  --kbin-alert-danger-bg: #f8d7da;\n  --kbin-alert-danger-border: 1px solid #f5c2c7;\n  --kbin-alert-danger-text-color: var(--kbin-danger-color);\n  --kbin-alert-danger-link-color: var(--kbin-danger-color);\n\n  //entry\n  --kbin-entry-link-visited-color: #60707a;\n\n  // details\n  --mbin-details-border: var(--kbin-section-border);\n  --mbin-details-separator-border: var(--kbin-meta-border);\n\n  --mbin-details-detail-color: var(--kbin-link-hover-color);\n  --mbin-details-spoiler-color: var(--kbin-danger-color);\n}\n"
  },
  {
    "path": "assets/styles/mixins/theme-solarized-dark.scss",
    "content": "$base03: #002b36;\n$base02: #073642;\n$base01: #586e75;\n$base00: #657b83;\n$base0: #839496;\n$base1: #93a1a1;\n$base2: #eee8d5;\n$base3: #fdf6e3;\n$yellow: #b58900;\n$orange: #cb4b16;\n$red: #dc322f;\n$magenta: #d33682;\n$violet: #6c71c4;\n$blue: #268bd2;\n$cyan: #2aa198;\n$green: #859900;\n\n// loaded under .theme--solarized-dark\n// or .theme--solarized with prefers-color-scheme: dark\n@mixin theme {\n  --kbin-body-font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"Noto Sans\", Roboto, \"Helvetica Neue\", Arial, sans-serif, \"Apple Color Emoji\", \"Segoe UI Emoji\", \"Segoe UI Symbol\";\n  --kbin-body-font-size: 1rem;\n  --kbin-body-font-weight: 400;\n  --kbin-body-line-height: 1.5;\n  --kbin-body-text-align: left;\n  --kbin-body-bg: #{$base02};\n\n  --kbin-bg: #{$base02};\n  --kbin-bg-nth: #{$base03};\n\n  --kbin-text-color: #809293;\n  --kbin-link-color: #b2c9cc;\n  --kbin-link-hover-color: #dcf5f8;\n  --kbin-outline: #ff8c00 solid 4px;\n\n  --kbin-primary-color: #{$base02};\n  --kbin-text-muted-color: #{$base01};\n\n  --kbin-success-color: #{$green};\n  --kbin-danger-color: #{$red};\n\n  --kbin-own-color: #{$green};\n  --kbin-author-color: #{$red};\n\n  // section\n  --kbin-section-bg: #{$base03};\n  --kbin-section-text-color: var(--kbin-text-color);\n  --kbin-section-title-link-color: var(--kbin-link-color);\n  --kbin-section-link-color: #{$yellow};\n  --kbin-section-link-hover-color: var(--kbin-link-hover-color);\n  --kbin-section-border-color: #214852;\n  --kbin-section-border: 1px solid var(--kbin-section-border-color);\n  --kbin-author-border: 1px dashed var(--kbin-author-color);\n  --kbin-own-border: 1px dashed var(--kbin-own-color);\n\n  // meta\n  --kbin-meta-bg: none;\n  --kbin-meta-text-color: #{$base1};\n  --kbin-meta-link-color: #{$base01};\n  --kbin-meta-link-hover-color: #a9bbbb;\n  --kbin-meta-border: 1px dashed #001a21;\n  --kbin-avatar-border: 3px solid #373737;\n  --kbin-avatar-border-active: 3px solid #555555;\n  --kbin-blockquote-color: #{$green};\n\n  // options\n  --kbin-options-bg: #{$base03};\n  --kbin-options-text-color: #95a5a6;\n  --kbin-options-link-color: #{$base00};\n  --kbin-options-link-hover-color: #e1e5ec;\n  --kbin-options-border: 1px solid #214852;\n  --kbin-options-link-hover-border: 3px solid #{$magenta};\n\n  // forms\n  --kbin-input-bg: #00242c;\n  --kbin-input-text-color: var(--kbin-text-color);\n  --kbin-input-border-color: var(--kbin-section-border-color);\n  --kbin-input-border: 1px solid var(--kbin-input-border-color);\n  --kbin-input-placeholder-text-color: #5d6a6b;\n\n  // buttons\n  --kbin-button-primary-bg: var(--kbin-primary-color);\n  --kbin-button-primary-hover-bg: #0b5467;\n  --kbin-button-primary-text-color: #fff;\n  --kbin-button-primary-text-hover-color: #fff;\n  --kbin-button-primary-border: 1px solid #04242c;\n\n  --kbin-button-secondary-bg: #00242c;\n  --kbin-button-secondary-hover-bg: #01313b;\n  --kbin-button-secondary-text-color: var(--kbin-meta-text-color);\n  --kbin-button-secondary-text-hover-color: var(--kbin-text-color);\n  --kbin-button-secondary-border: 1px dashed #001a21;\n\n  // header\n  --kbin-header-bg: var(--kbin-primary-color);\n  --kbin-header-text-color: #{$base0};\n  --kbin-header-link-color: #{$base0};\n  --kbin-header-link-hover-color: #e8e8e8;\n  --kbin-header-link-active-bg: var(--kbin-options-bg);\n  --kbin-header-border: 1px solid #e5eaec;\n  --kbin-header-hover-border: 3px solid #{$base01};\n\n  // topbar\n  --kbin-topbar-bg: #{$base03};\n  --kbin-topbar-active-bg: var(--kbin-primary-color);\n  --kbin-topbar-link-color: var(--kbin-meta-text-color);\n  --kbin-topbar-active-link-color: #{$base2};\n  --kbin-topbar-hover-bg: var(--kbin-primary-color);\n  --kbin-topbar-border: 1px solid #{$base02};\n\n  //sidebar\n  --kbin-sidebar-header-text-color: #909ea2;\n  --kbin-sidebar-header-border: var(--kbin-section-border);\n  --kbin-sidebar-settings-row-bg: var(--kbin-body-bg);\n  --kbin-sidebar-settings-switch-on-color: #40a4bf;\n  --kbin-sidebar-settings-switch-on-bg: var(--kbin-button-primary-hover-bg);\n  --kbin-sidebar-settings-switch-off-color: var(--kbin-body-bg);\n  --kbin-sidebar-settings-switch-off-bg: var(--kbin-button-secondary-bg);\n  --kbin-sidebar-settings-switch-hover-bg: #084554;\n\n  //vote\n  --kbin-vote-bg: #00242c;\n  --kbin-vote-bg-hover-bg: #001f25;\n  --kbin-vote-text-color: #b6b6b6;\n  --kbin-vote-text-hover-color: #fafafa;\n  --kbin-upvoted-color: #{$green};\n  --kbin-downvoted-color: #{$red};\n\n   //boost\n   --kbin-boosted-color: var(--kbin-upvoted-color);\n\n  // alerts\n  --kbin-alert-info-bg: rgba(153,116,4,0.15);\n  --kbin-alert-info-border: 1px solid rgba(153,116,4,0.4);\n  --kbin-alert-info-text-color: #997404;\n  --kbin-alert-info-link-color: #997404;\n\n  --kbin-alert-danger-bg: rgba(171, 28, 40, 0.9);\n  --kbin-alert-danger-border: 1px solid rgba(171, 28, 40, 1);\n  --kbin-alert-danger-text-color: var(--kbin-danger-color);\n  --kbin-alert-danger-link-color: var(--kbin-danger-color);\n\n  //entry\n  --kbin-entry-link-visited-color: #586e75;\n\n  // details\n  --mbin-details-border: var(--kbin-section-border);\n  --mbin-details-separator-border: var(--kbin-meta-border);\n\n  --mbin-details-detail-color: #{$blue};\n  --mbin-details-spoiler-color: var(--kbin-danger-color);\n\n  .options--top,\n  .section--top {\n    border-top: var(--kbin-options-border) !important;\n  }\n\n  &.rounded-edges {\n    .section--top,\n    .options--top {\n      border-radius: .5rem !important;\n    }\n\n    .head-nav__menu li a {\n      border-radius: 0 0 .5rem .5rem;\n    }\n  }\n\n  .entry__domain,\n  .entry__domain a {\n    color: $yellow !important;\n  }\n}\n"
  },
  {
    "path": "assets/styles/mixins/theme-solarized-light.scss",
    "content": "$base03: #002b36;\n$base02: #073642;\n$base01: #405358;\n$base00: #5b6f77;\n$base0: #839496;\n$base1: #606e6e;\n$base2: #eee8d5;\n$base3: #fdf6e3;\n$yellow: #b58900;\n$orange: #cb4b16;\n$red: #dc322f;\n$magenta: #d33682;\n$violet: #c0ae73;\n$blue: #268bd2;\n$cyan: #2aa198;\n$green: #859900;\n\n// loaded under .theme--solarized-light\n// or .theme--solarized with prefers-color-scheme: light\n@mixin theme {\n  --kbin-body-font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"Noto Sans\", Roboto, \"Helvetica Neue\", Arial, sans-serif, \"Apple Color Emoji\", \"Segoe UI Emoji\", \"Segoe UI Symbol\";\n  --kbin-body-font-size: 1rem;\n  --kbin-body-font-weight: 400;\n  --kbin-body-line-height: 1.5;\n  --kbin-body-text-align: left;\n  --kbin-body-bg: #eee8d5;\n\n  --kbin-bg: #eee8d5;\n  --kbin-bg-nth: #{$base3};\n\n  --kbin-text-color: #5b6f75;\n  --kbin-link-color: #4d6369;\n  --kbin-link-hover-color: #3a4c52;\n  --kbin-outline: #ff8c00 solid 4px;\n\n  --kbin-primary-color: #{$base2};\n  --kbin-text-muted-color: #bebfbf;\n\n  --kbin-success-color: #{$green};\n  --kbin-danger-color: #{$red};\n\n  --kbin-own-color: #{$green};\n  --kbin-author-color: #{$red};\n\n  // section\n  --kbin-section-bg: #{$base3};\n  --kbin-section-text-color: var(--kbin-text-color);\n  --kbin-section-title-link-color: var(--kbin-link-color);\n  --kbin-section-link-color: #{$blue};\n  --kbin-section-link-hover-color: var(--kbin-link-hover-color);\n  --kbin-section-border-color: #c8cac0;\n  --kbin-section-border: 1px solid var(--kbin-section-border-color);\n  --kbin-author-border: 1px dashed var(--kbin-author-color);\n  --kbin-own-border: 1px dashed var(--kbin-own-color);\n\n  // meta\n  --kbin-meta-bg: none;\n  --kbin-meta-text-color: #{$base1};\n  --kbin-meta-link-color: #{$base01};\n  --kbin-meta-link-hover-color: #{$base1};\n  --kbin-meta-border: 1px dashed #e7dfc6;\n  --kbin-avatar-border: 3px solid #d8cda9;\n  --kbin-avatar-border-active: 3px solid #c2b691;\n  --kbin-blockquote-color: #{$green};\n\n  // options\n  --kbin-options-bg: #{$base3};\n  --kbin-options-text-color: #95a5a6;\n  --kbin-options-link-color: #{$base01};\n  --kbin-options-link-hover-color: #41545b;\n  --kbin-options-border: 1px solid #c8cac0;\n  --kbin-options-link-hover-border: 3px solid #{$violet};\n\n  // forms\n  --kbin-input-bg: #f6edd4;\n  --kbin-input-text-color: var(--kbin-text-color);\n  --kbin-input-border-color: var(--kbin-section-border-color);\n  --kbin-input-border: 1px solid var(--kbin-input-border-color);\n  --kbin-input-placeholder-text-color: #4f6166;\n\n  // buttons\n  --kbin-button-primary-bg: var(--kbin-primary-color);\n  --kbin-button-primary-hover-bg: #f5eedb;\n  --kbin-button-primary-text-color: #{$base0};\n  --kbin-button-primary-text-hover-color: #{$base0};\n  --kbin-button-primary-border: 1px solid #d8cda9;\n\n  --kbin-button-secondary-bg: #fdf6e3;\n  --kbin-button-secondary-hover-bg: #fff6e1;\n  --kbin-button-secondary-text-color: var(--kbin-meta-text-color);\n  --kbin-button-secondary-text-hover-color: var(--kbin-text-color);\n  --kbin-button-secondary-border: 1px dashed #d8cda9;\n\n  // header\n  --kbin-header-bg: var(--kbin-primary-color);\n  --kbin-header-text-color: #{$base01};\n  --kbin-header-link-color: #{$base01};\n  --kbin-header-link-hover-color: #41545b;\n  --kbin-header-link-active-bg: var(--kbin-options-bg);\n  --kbin-header-border: 1px solid #d8cda9;\n  --kbin-header-hover-border: 3px solid #{$base01};\n\n  // topbar\n  --kbin-topbar-bg: #{$base3};\n  --kbin-topbar-active-bg: var(--kbin-primary-color);\n  --kbin-topbar-link-color: var(--kbin-meta-text-color);\n  --kbin-topbar-active-link-color: #{$base01};\n  --kbin-topbar-hover-bg: var(--kbin-primary-color);\n  --kbin-topbar-border: 1px solid #{$base2};\n\n  //sidebar\n  --kbin-sidebar-header-text-color: #677479;\n  --kbin-sidebar-header-border: var(--kbin-section-border);\n  --kbin-sidebar-settings-row-bg: var(--kbin-body-bg);\n  --kbin-sidebar-settings-switch-on-color: var(--kbin-section-bg);\n  --kbin-sidebar-settings-switch-on-bg: var(--kbin-button-primary-text-color);\n  --kbin-sidebar-settings-switch-off-color: #d7d6c7;\n  --kbin-sidebar-settings-switch-off-bg: var(--kbin-button-secondary-bg);\n  --kbin-sidebar-settings-switch-hover-bg: #b8bdb5;\n\n  //vote\n  --kbin-vote-bg: #f5eacd;\n  --kbin-vote-bg-hover-bg: #e7ddc3;\n  --kbin-vote-text-color: #{$base01};\n  --kbin-vote-text-hover-color: #{$base1};\n  --kbin-upvoted-color: #{$green};\n  --kbin-downvoted-color: #{$red};\n\n  //boost\n  --kbin-boosted-color: var(--kbin-upvoted-color);\n\n  // alerts\n  --kbin-alert-info-bg: #fff3cd;\n  --kbin-alert-info-border: 1px solid #ffe69c;\n  --kbin-alert-info-text-color: #997404;\n  --kbin-alert-info-link-color: #997404;\n\n  --kbin-alert-danger-bg: #f8d7da;\n  --kbin-alert-danger-border: 1px solid #f5c2c7;\n  --kbin-alert-danger-text-color: var(--kbin-danger-color);\n  --kbin-alert-danger-link-color: var(--kbin-danger-color);\n\n  //entry\n  --kbin-entry-link-visited-color: #586e75;\n\n  // details\n  --mbin-details-border: var(--kbin-section-border);\n  --mbin-details-separator-border: var(--kbin-meta-border);\n\n  --mbin-details-detail-color: #{$blue};\n  --mbin-details-spoiler-color: var(--kbin-danger-color);\n\n  .options--top,\n  .section--top {\n    border-top: var(--kbin-options-border) !important;\n  }\n\n  &.rounded-edges {\n    .section--top,\n    .options--top {\n      border-radius: .5rem !important;\n    }\n\n    .head-nav__menu li a {\n      border-radius: 0 0 .5rem .5rem;\n    }\n  }\n\n  .entry__domain,\n  .entry__domain a {\n    color: $yellow !important;\n  }\n}\n"
  },
  {
    "path": "assets/styles/pages/page_bookmarks.scss",
    "content": ".page-bookmarks {\n  .entry, .entry-comment, .post, .post-comment, .comment {\n    margin-top: 0!important;\n    margin-bottom: .5em!important;\n  }\n}\n"
  },
  {
    "path": "assets/styles/pages/page_filter_lists.scss",
    "content": ".page-settings.page-settings-filter-lists {\n  .existing-words > div > label {\n    display:none\n  }\n\n  .existing-words > div > div,\n  .words-container > div > div {\n    display: flex;\n    gap: .5rem;\n\n    > * {\n      flex-grow: 1;\n      margin: auto;\n\n      &.checkbox {\n        flex-grow: 0;\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "assets/styles/pages/page_modlog.scss",
    "content": ".page-modlog {\n  .ts-wrapper .ts-control input {\n    min-width: unset;\n  }\n\n  .ts-wrapper {\n    margin-bottom: 0;\n  }\n}\n"
  },
  {
    "path": "assets/styles/pages/page_profile.scss",
    "content": ".page-user:not(.page-user-replies) {\n  .subjects {\n    .entry-comment,\n    .post-comment,\n    .comment {\n      margin-left: 1.5rem;\n    }\n  }\n}\n\n.page-user {\n  .entry-comment,\n  .post-comment {\n    margin-bottom: .5rem;\n  }\n}\n"
  },
  {
    "path": "assets/styles/pages/post_front.scss",
    "content": ".page-post-front .section--top .dropdown__menu {\n  z-index: 10;\n}\n"
  },
  {
    "path": "assets/styles/pages/post_single.scss",
    "content": ".page-post-single {\n  .options__view {\n    grid-area: end;\n  }\n\n  .post-comments {\n    margin-bottom: .5rem;\n  }\n}\n"
  },
  {
    "path": "assets/styles/themes/_default.scss",
    "content": "@use '../mixins/theme-dark' as dark;\n@use '../mixins/theme-light' as light;\n\n.theme--default {\n  @media (prefers-color-scheme: dark) {\n    @include dark.theme;\n  }\n\n  @media (prefers-color-scheme: light) {\n    @include light.theme;\n  }\n}\n\n.theme--dark {\n  @include dark.theme;\n}\n\n.theme--light {\n  @include light.theme;\n}\n"
  },
  {
    "path": "assets/styles/themes/_kbin.scss",
    "content": ".theme--kbin {\n  --kbin-body-font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"Noto Sans\", Roboto, \"Helvetica Neue\", Arial, sans-serif, \"Apple Color Emoji\", \"Segoe UI Emoji\", \"Segoe UI Symbol\";\n  --kbin-body-font-size: 1rem;\n  --kbin-body-font-weight: 400;\n  --kbin-body-line-height: 1.5;\n  --kbin-body-text-align: left;\n  --kbin-body-bg: #2B2D31;\n\n  --kbin-bg: #2B2D31;\n  --kbin-bg-nth: #34363b;\n\n  --kbin-text-color: #DBDEE2;\n  --kbin-link-color: #F2F3F6;\n  --kbin-link-hover-color: #8e939b;\n  --kbin-outline: #ff8c00 solid 4px;\n\n  --kbin-primary-color: #3c3c3c;\n  --kbin-text-muted-color: #95a6a6;\n\n  --kbin-success-color: #24b270;\n  --kbin-danger-color: #dc2f3f;\n\n  --kbin-own-color: #24b270;\n  --kbin-author-color: #dc2f3f;\n\n  // section\n  --kbin-section-bg: #313338;\n  --kbin-section-text-color: var(--kbin-text-color);\n  --kbin-section-title-link-color: var(--kbin-link-color);\n  --kbin-section-link-color: #97a7ce;\n  --kbin-section-link-hover-color: #fff;\n  --kbin-section-border: 1px solid #24262A;\n  --kbin-author-border: 1px dashed var(--kbin-author-color);\n  --kbin-own-border: 1px dashed var(--kbin-own-color);\n\n  // meta\n  --kbin-meta-bg: none;\n  --kbin-meta-text-color: #DBDEE2;\n  --kbin-meta-link-color: var(--kbin-link-color);\n  --kbin-meta-link-hover-color: var(--kbin-link-hover-color);\n  --kbin-meta-border: 1px dashed #24262A;\n  --kbin-avatar-border: 3px solid #373737;\n  --kbin-avatar-border-active: 3px solid #282727;\n  --kbin-blockquote-color: #24b270;\n\n  // options\n  --kbin-options-bg: #313338;\n  --kbin-options-text-color: #95a5a6;\n  --kbin-options-link-color: var(--kbin-link-color);\n  --kbin-options-link-hover-color: var(--kbin-link-hover-color);\n  --kbin-options-border: 1px solid #24262A;\n  --kbin-options-link-hover-border: 3px solid #e1e5ec;\n\n  // forms\n  --kbin-input-bg: #2C2D31;\n  --kbin-input-text-color: var(--kbin-text-color);\n  --kbin-input-border-color: #24262A;\n  --kbin-input-border: 1px solid var(--kbin-input-border-color);\n  --kbin-input-placeholder-text-color: #9ea0a3;\n\n  // buttons\n  --kbin-button-primary-bg: #6166EF;\n  --kbin-button-primary-hover-bg: #5257d5;\n  --kbin-button-primary-text-color: #fff;\n  --kbin-button-primary-text-hover-color: #fff;\n  --kbin-button-primary-border: 1px solid transparent;\n\n  --kbin-button-secondary-bg: #2f3033;\n  --kbin-button-secondary-hover-bg: #26272a;\n  --kbin-button-secondary-text-color: var(--kbin-meta-text-color);\n  --kbin-button-secondary-text-hover-color: var(--kbin-text-color);\n  --kbin-button-secondary-border: 1px dashed #24262A;\n\n  // header\n  --kbin-header-bg: #1E1F22;\n  --kbin-header-text-color: #fff;\n  --kbin-header-link-color: #fff;\n  --kbin-header-link-hover-color: #e8e8e8;\n  --kbin-header-link-active-bg: var(--kbin-options-bg);\n  --kbin-header-border: 1px solid #1E1F22;\n  --kbin-header-hover-border: 3px solid #fff;\n\n  // topbar\n  --kbin-topbar-bg: var(--kbin-section-bg);\n  --kbin-topbar-active-bg: #fff;\n  --kbin-topbar-active-link-color: #000;\n  --kbin-topbar-hover-bg: #282828;\n  --kbin-topbar-border: 1px solid #4d5052;\n\n  //sidebar\n  --kbin-sidebar-header-text-color: #909ea2;\n  --kbin-sidebar-header-border: 1px solid #4a4a4a;\n  --kbin-sidebar-settings-row-bg: #404349;\n  --kbin-sidebar-settings-switch-on-color: #cfd5e2;\n  --kbin-sidebar-settings-switch-on-bg: var(--kbin-button-primary-bg);\n  --kbin-sidebar-settings-switch-off-color: #404349;\n  --kbin-sidebar-settings-switch-off-bg: var(--kbin-button-secondary-bg);\n  --kbin-sidebar-settings-switch-hover-bg: #7d8bd4;\n\n  //vote\n  --kbin-vote-bg: #2B2D31;\n  --kbin-vote-bg-hover-bg: #222427;\n  --kbin-vote-text-color: #B6BAC2;\n  --kbin-vote-text-hover-color: #fafafa;\n  --kbin-upvoted-color: #24b270;\n  --kbin-downvoted-color: #dc2f3f;\n\n  //boost\n  --kbin-boosted-color: var(--kbin-upvoted-color);\n\n  // alerts\n  --kbin-alert-info-bg: rgba(153,116,4,0.15);\n  --kbin-alert-info-border: 1px solid rgba(153,116,4,0.4);\n  --kbin-alert-info-text-color: #997404;\n  --kbin-alert-info-link-color: #997404;\n\n  --kbin-alert-danger-bg: rgba(171, 28, 40, 0.9);\n  --kbin-alert-danger-border: 1px solid rgba(171, 28, 40, 1);\n  --kbin-alert-danger-text-color: var(--kbin-danger-color);\n  --kbin-alert-danger-link-color: var(--kbin-danger-color);\n\n  //entry\n  --kbin-entry-link-visited-color: #8e939b;\n\n  // details\n  --mbin-details-border: var(--kbin-section-border);\n  --mbin-details-separator-border: var(--kbin-meta-border);\n\n  --mbin-details-detail-color: var(--kbin-link-hover-color);\n  --mbin-details-spoiler-color: var(--kbin-danger-color);\n\n  .section--top,\n  .options--top {\n    border-top: var(--kbin-options-border) !important;\n  }\n\n  &.rounded-edges {\n    .options--top,\n    .section--top {\n      border-radius: .5rem !important;\n    }\n  }\n}\n"
  },
  {
    "path": "assets/styles/themes/_solarized.scss",
    "content": "@use '../mixins/theme-solarized-dark' as sol-dark;\n@use '../mixins/theme-solarized-light' as sol-light;\n\n.theme--solarized {\n  @media (prefers-color-scheme: dark) {\n    @include sol-dark.theme;\n  }\n\n  @media (prefers-color-scheme: light) {\n    @include sol-light.theme;\n  }\n}\n\n.theme--solarized-dark {\n  @include sol-dark.theme;\n}\n\n.theme--solarized-light {\n  @include sol-light.theme;\n}\n"
  },
  {
    "path": "assets/styles/themes/_tokyo-night.scss",
    "content": ".theme--tokyo-night {\n  --kbin-body-font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"Noto Sans\", Roboto, \"Helvetica Neue\", Arial, sans-serif, \"Apple Color Emoji\", \"Segoe UI Emoji\", \"Segoe UI Symbol\";\n  --kbin-body-font-size: 1rem;\n  --kbin-body-font-weight: 400;\n  --kbin-body-line-height: 1.5;\n  --kbin-body-text-align: left;\n  --kbin-body-bg: #1f202e;\n\n  --kbin-bg: #1f202e;\n  --kbin-bg-nth: #262636;\n\n  --kbin-text-color: #e4e4e7;\n  --kbin-link-color: #d6d6ff;\n  --kbin-link-hover-color: #9999ff;\n  --kbin-outline: #9999ff solid 4px;\n\n  --kbin-primary-color: #363649;\n  --kbin-text-muted-color: #9191a1;\n\n  --kbin-success-color: #a1e87d;\n  --kbin-danger-color: #ff6675;\n\n  --kbin-own-color: var(--kbin-success-color);\n  --kbin-author-color: var(--kbin-danger-color);\n\n  // section\n  --kbin-section-bg: #2a2a3c;\n  --kbin-section-text-color: var(--kbin-text-color);\n  --kbin-section-title-link-color: var(--kbin-link-color);\n  --kbin-section-link-color: #9c9fc9;\n  --kbin-section-link-hover-color: var(--kbin-link-hover-color);\n  --kbin-section-border: 1px solid #414358;\n  --kbin-author-border: 1px dashed var(--kbin-author-color);\n  --kbin-own-border: 1px dashed var(--kbin-own-color);\n\n  // meta\n  --kbin-meta-bg: none;\n  --kbin-meta-text-color: #d5d6dd;\n  --kbin-meta-link-color: #d6ddff;\n  --kbin-meta-link-hover-color: var(--kbin-link-hover-color);\n  --kbin-meta-border: 1px dashed #4f5064;\n  --kbin-avatar-border: 3px solid #414358;\n  --kbin-avatar-border-active: 3px solid #54576e;\n  --kbin-blockquote-color: var(--kbin-success-color);\n\n  // options\n  --kbin-options-bg: #2a2a3c;\n  --kbin-options-text-color: #a5a5c0;\n  --kbin-options-link-color: #a9b1d6;\n  --kbin-options-link-hover-color: #e1e5ec;\n  --kbin-options-border: 1px solid #414358;\n  --kbin-options-link-hover-border: 3px solid #e1e5ec;\n\n  // forms\n  --kbin-input-bg: #1b1c27;\n  --kbin-input-text-color: #d6d6ff;\n  --kbin-input-border-color: #414358;\n  --kbin-input-border: 1px solid var(--kbin-input-border-color);\n  --kbin-input-placeholder-text-color: #9292b1;\n\n  // buttons\n  --kbin-button-primary-bg: #9eadfa;\n  --kbin-button-primary-hover-bg: #6e79f7;\n  --kbin-button-primary-text-color: #1c1c22;\n  --kbin-button-primary-text-hover-color: #0b0b0e;\n  --kbin-button-primary-border: 1px solid transparent;\n\n  --kbin-button-secondary-bg: #16161d;\n  --kbin-button-secondary-hover-bg: #9eadfa;\n  --kbin-button-secondary-text-color: var(--kbin-meta-text-color);\n  --kbin-button-secondary-text-hover-color: #1c1c22;\n  --kbin-button-secondary-border: 1px solid #414358;\n\n  // header\n  --kbin-header-bg: #16161d;\n  --kbin-header-text-color: #e8e8ee;\n  --kbin-header-link-color: #e8e8ee;\n  --kbin-header-link-hover-color: #e8e8e8;\n  --kbin-header-link-active-bg: var(--kbin-options-bg);\n  --kbin-header-border: 1px solid #e5eaec;\n  --kbin-header-hover-border: 3px solid #e8e8ee;\n\n  // topbar\n  --kbin-topbar-bg: #292b3d;\n  --kbin-topbar-active-bg: #b3b3ff;\n  --kbin-topbar-active-link-color: #000;\n  --kbin-topbar-hover-bg: #535679;\n  --kbin-topbar-border: 1px solid #575775;\n\n  // sidebar\n  --kbin-sidebar-header-text-color: #9191aa;\n  --kbin-sidebar-header-border: 1px solid #575975;\n  --kbin-sidebar-settings-row-bg: #37384e;\n  --kbin-sidebar-settings-switch-on-color: #1c1c22 ;\n  --kbin-sidebar-settings-switch-on-bg: var(--kbin-button-primary-bg);\n  --kbin-sidebar-settings-switch-off-color: var(--kbin-body-bg);\n  --kbin-sidebar-settings-switch-off-bg: #16161d;\n  --kbin-sidebar-settings-switch-hover-bg: #7575a3;\n\n  // vote\n  --kbin-vote-bg: #1c1d26;\n  --kbin-vote-bg-hover-bg: #13141b;\n  --kbin-vote-text-color: #d6d6ff;\n  --kbin-vote-text-hover-color: #9999ff;\n  --kbin-upvoted-color: var(--kbin-success-color);\n  --kbin-downvoted-color: var(--kbin-danger-color);\n\n   //boost\n   --kbin-boosted-color: var(--kbin-upvoted-color);\n\n  // alerts\n  --kbin-alert-info-bg: #fff3cc;\n  --kbin-alert-info-border: 1px solid #fff3cc;\n  --kbin-alert-info-text-color: #261f0d;\n  --kbin-alert-info-link-color: #664b00;\n\n  --kbin-alert-danger-bg: #16161d;\n  --kbin-alert-danger-border: 1px solid var(--kbin-alert-danger-text-color);\n  --kbin-alert-danger-text-color: var(--kbin-danger-color);\n  --kbin-alert-danger-link-color: var(--kbin-danger-color);\n\n  // entry\n  --kbin-entry-link-visited-color: #a4a4c1;\n\n  // details\n  --mbin-details-border: var(--kbin-section-border);\n  --mbin-details-separator-border: var(--kbin-meta-border);\n\n  --mbin-details-detail-color: var(--kbin-link-hover-color);\n  --mbin-details-spoiler-color: var(--kbin-danger-color);\n\n  .options--top,\n  .section--top {\n    border-top: var(--kbin-options-border) !important;\n  }\n\n  &.rounded-edges {\n    .options--top,\n    .section--top {\n      border-radius: 0.5rem !important;\n    }\n  }\n}\n"
  },
  {
    "path": "assets/utils/debounce.js",
    "content": "export default function debounce(delay, handler) {\n    let timer = 0;\n    return function() {\n        clearTimeout(timer);\n        timer = setTimeout(handler, delay);\n    };\n}\n"
  },
  {
    "path": "assets/utils/event-source.js",
    "content": "export default function subscribe(endpoint, topics, cb) {\n    if (!endpoint) {\n        return null;\n    }\n\n    const url = new URL(endpoint);\n\n    topics.forEach((topic) => {\n        url.searchParams.append('topic', topic);\n    });\n\n    const eventSource = new EventSource(url);\n    eventSource.onmessage = (e) => cb(e);\n\n    return eventSource;\n}\n"
  },
  {
    "path": "assets/utils/http.js",
    "content": "/**\n * @param {RequestInfo} url\n * @param {RequestInit} options\n * @returns {Promise<Response>}\n */\nexport async function fetch(url = '', options = {}) {\n    if ('object' === typeof url && null !== url) {\n        options = url;\n        url = options.url;\n    }\n\n    options = { ...options };\n    options.credentials = options.credentials || 'same-origin';\n    options.redirect = options.redirect || 'error';\n    options.headers = {\n        ...options.headers,\n        'X-Requested-With': 'XMLHttpRequest',\n    };\n\n    return window.fetch(url, options);\n}\n\nexport async function ok(response) {\n    if (!response.ok) {\n        const e = new Error(response.statusText);\n        e.response = response;\n\n        throw e;\n    }\n\n    return response;\n}\n\n/**\n * Throws the response if not ok, otherwise, call .json()\n * @param {Response} response\n * @return {Promise<any>}\n */\nexport function ThrowResponseIfNotOk(response) {\n    if (!response.ok) {\n        throw response;\n    }\n    return response.json();\n}\n"
  },
  {
    "path": "assets/utils/mbin.js",
    "content": "export default function getIntIdFromElement(element) {\n    return element.id.substring(element.id.lastIndexOf('-') + 1);\n}\n\nexport function getIdPrefixFromNotification(data) {\n    switch (data.type) {\n        case 'Entry':\n            return 'entry-';\n        case 'EntryComment':\n            return 'entry-comment-';\n        case 'Post':\n            return 'post-';\n        case 'PostComment':\n            return 'post-comment-';\n    }\n}\n\nexport function getTypeFromNotification(data) {\n    switch (data.detail.op) {\n        case 'EntryEditedNotification':\n        case 'EntryCreatedNotification':\n            return 'entry';\n        case 'EntryCommentEditedNotification':\n        case 'EntryCommentCreatedNotification':\n            return 'entry_comment';\n        case 'PostEditedNotification':\n        case 'PostCreatedNotification':\n            return 'post';\n        case 'PostCommentEditedNotification':\n        case 'PostCommentCreatedNotification':\n            return 'post_comment';\n    }\n}\n\nexport function getLevel(element) {\n    const level = parseInt(element.className.replace('comment-level--1', '').split('--')[1]);\n    return isNaN(level) ? 1 : level;\n}\n\nexport function getDepth(element) {\n    const depth = parseInt(element.dataset.commentCollapseDepthValue);\n    return isNaN(depth) ? 1 : depth;\n}\n"
  },
  {
    "path": "assets/utils/popover.js",
    "content": "/* eslint-disable no-undef */\nUtil = function() {};\n\nUtil.hasClass = function(el, className) {\n    return el.classList.contains(className);\n};\n\nUtil.addClass = function(el, className) {\n    var classList = className.split(' ');\n    el.classList.add(classList[0]);\n    if (1 < classList.length) {\n        Util.addClass(el, classList.slice(1).join(' '));\n    }\n};\n\nUtil.removeClass = function(el, className) {\n    var classList = className.split(' ');\n    el.classList.remove(classList[0]);\n    if (1 < classList.length) {\n        Util.removeClass(el, classList.slice(1).join(' '));\n    }\n};\n\nUtil.toggleClass = function(el, className, bool) {\n    if (bool) {\n        Util.addClass(el, className);\n    } else {\n        Util.removeClass(el, className);\n    }\n};\n\nUtil.setAttributes = function(el, attrs) {\n    for (var key in attrs) {\n        el.setAttribute(key, attrs[key]);\n    }\n};\n\nUtil.moveFocus = function (element) {\n    if (!element) {\n        element = document.getElementsByTagName('body')[0];\n    }\n    element.focus();\n    if (document.activeElement !== element) {\n        element.setAttribute('tabindex', '-1');\n        element.focus();\n    }\n};\n\n// Usage: codyhouse.co/license\n(function() {\n    var Popover = function(element) {\n        this.element = element;\n        this.elementId = this.element.getAttribute('id');\n        this.trigger = document.querySelectorAll('[aria-controls=\"'+this.elementId+'\"]');\n        this.selectedTrigger = false;\n        this.popoverVisibleClass = 'popover--is-visible';\n        this.selectedTriggerClass = 'popover-control--active';\n        this.popoverIsOpen = false;\n        // focusable elements\n        this.firstFocusable = false;\n        this.lastFocusable = false;\n        // position target - position tooltip relative to a specified element\n        this.positionTarget = getPositionTarget(this);\n        // gap between element and viewport - if there's max-height\n        this.viewportGap = parseInt(getComputedStyle(this.element).getPropertyValue('--popover-viewport-gap')) || 20;\n        initPopover(this);\n        initPopoverEvents(this);\n    };\n\n    // public methods\n    Popover.prototype.togglePopover = function(bool, moveFocus) {\n        togglePopover(this, bool, moveFocus);\n    };\n\n    Popover.prototype.checkPopoverClick = function(target) {\n        checkPopoverClick(this, target);\n    };\n\n    Popover.prototype.checkPopoverFocus = function() {\n        checkPopoverFocus(this);\n    };\n\n    // private methods\n    function getPositionTarget(popover) {\n        // position tooltip relative to a specified element - if provided\n        var positionTargetSelector = popover.element.getAttribute('data-position-target');\n        if (!positionTargetSelector) {\n            return false;\n        }\n        var positionTarget = document.querySelector(positionTargetSelector);\n        return positionTarget;\n    }\n\n    function initPopover(popover) {\n        // reset popover position\n        initPopoverPosition(popover);\n        // init aria-labels\n        for (var i = 0; i < popover.trigger.length; i++) {\n            Util.setAttributes(popover.trigger[i], { 'aria-expanded': 'false', 'aria-haspopup': 'true' });\n        }\n    }\n\n    function initPopoverEvents(popover) {\n        for (var i = 0; i < popover.trigger.length; i++) {\n            (function(i) {\n                popover.trigger[i].addEventListener('click', function(event) {\n                    event.preventDefault();\n                    // if the popover had been previously opened by another trigger element -> close it first and reopen in the right position\n                    if (Util.hasClass(popover.element, popover.popoverVisibleClass) && popover.s !== popover.trigger[i]) {\n                        togglePopover(popover, false, false); // close menu\n                    }\n                    // toggle popover\n                    popover.selectedTrigger = popover.trigger[i];\n                    togglePopover(popover, !Util.hasClass(popover.element, popover.popoverVisibleClass), true);\n                });\n            })(i);\n        }\n\n        // trap focus\n        popover.element.addEventListener('keydown', function(event) {\n            if (event.keyCode && 9 === event.keyCode || event.key && 'Tab' === event.key) {\n                //trap focus inside popover\n                trapFocus(popover, event);\n            }\n        });\n\n        // custom events -> open/close popover\n        popover.element.addEventListener('openPopover', function() {\n            togglePopover(popover, true);\n        });\n\n        popover.element.addEventListener('closePopover', function(event) {\n            togglePopover(popover, false, event.detail);\n        });\n    }\n\n    function togglePopover(popover, bool, moveFocus) {\n        // toggle popover visibility\n        Util.toggleClass(popover.element, popover.popoverVisibleClass, bool);\n        popover.popoverIsOpen = bool;\n        if (bool) {\n            popover.selectedTrigger.setAttribute('aria-expanded', 'true');\n            getFocusableElements(popover);\n            // move focus\n            focusPopover(popover);\n            popover.element.addEventListener('transitionend', function() {\n                focusPopover(popover);\n            }, { once: true });\n            // position the popover element\n            positionPopover(popover);\n            // add class to popover trigger\n            Util.addClass(popover.selectedTrigger, popover.selectedTriggerClass);\n        } else if (popover.selectedTrigger) {\n            popover.selectedTrigger.setAttribute('aria-expanded', 'false');\n            if (moveFocus) {\n                Util.moveFocus(popover.selectedTrigger);\n            }\n            // remove class from menu trigger\n            Util.removeClass(popover.selectedTrigger, popover.selectedTriggerClass);\n            popover.selectedTrigger = false;\n        }\n    }\n\n    function focusPopover(popover) {\n        if (popover.firstFocusable) {\n            popover.firstFocusable.focus();\n        } else {\n            Util.moveFocus(popover.element);\n        }\n    }\n\n    function positionPopover(popover) {\n        // reset popover position\n        resetPopoverStyle(popover);\n        var selectedTriggerPosition = (popover.positionTarget) ? popover.positionTarget.getBoundingClientRect() : popover.selectedTrigger.getBoundingClientRect();\n\n        var menuOnTop = (window.innerHeight - selectedTriggerPosition.bottom) < selectedTriggerPosition.top;\n\n        var left = selectedTriggerPosition.left,\n            right = (window.innerWidth - selectedTriggerPosition.right),\n            isRight = (window.innerWidth < selectedTriggerPosition.left + popover.element.offsetWidth);\n\n        var horizontal = isRight ? 'right: '+right+'px;' : 'left: '+left+'px;',\n            vertical = menuOnTop\n                ? 'bottom: '+(window.innerHeight - selectedTriggerPosition.top)+'px;'\n                : 'top: '+selectedTriggerPosition.bottom+'px;';\n        // check right position is correct -> otherwise set left to 0\n        if (isRight && (right + popover.element.offsetWidth) > window.innerWidth) {\n            horizontal = 'left: '+ parseInt((window.innerWidth - popover.element.offsetWidth)/2)+'px;';\n        }\n        // check if popover needs a max-height (user will scroll inside the popover)\n        var maxHeight = menuOnTop ? selectedTriggerPosition.top - popover.viewportGap : window.innerHeight - selectedTriggerPosition.bottom - popover.viewportGap;\n\n        var initialStyle = popover.element.getAttribute('style');\n        if (!initialStyle) {\n            initialStyle = '';\n        }\n        popover.element.setAttribute('style', initialStyle + horizontal + vertical +'max-height:'+Math.floor(maxHeight)+'px;');\n    }\n\n    function resetPopoverStyle(popover) {\n        // remove popover inline style before applying new style\n        popover.element.style.maxHeight = '';\n        popover.element.style.top = '';\n        popover.element.style.bottom = '';\n        popover.element.style.left = '';\n        popover.element.style.right = '';\n    }\n\n    function initPopoverPosition(popover) {\n        // make sure the popover does not create any scrollbar\n        popover.element.style.top = '0px';\n        popover.element.style.left = '0px';\n    }\n\n    function checkPopoverClick(popover, target) {\n        // close popover when clicking outside it\n        if (!popover.popoverIsOpen) {\n            return;\n        }\n        if (!popover.element.contains(target) && !target.closest('[aria-controls=\"'+popover.elementId+'\"]')) {\n            togglePopover(popover, false);\n        }\n    }\n\n    function checkPopoverFocus(popover) {\n        // on Esc key -> close popover if open and move focus (if focus was inside popover)\n        if (!popover.popoverIsOpen) {\n            return;\n        }\n        var popoverParent = document.activeElement.closest('.js-popover');\n        togglePopover(popover, false, popoverParent);\n    }\n\n    function getFocusableElements(popover) {\n        //get all focusable elements inside the popover\n        var allFocusable = popover.element.querySelectorAll(focusableElString);\n        getFirstVisible(popover, allFocusable);\n        getLastVisible(popover, allFocusable);\n    }\n\n    function getFirstVisible(popover, elements) {\n        //get first visible focusable element inside the popover\n        for (var i = 0; i < elements.length; i++) {\n            if (isVisible(elements[i])) {\n                popover.firstFocusable = elements[i];\n                break;\n            }\n        }\n    }\n\n    function getLastVisible(popover, elements) {\n        //get last visible focusable element inside the popover\n        for (var i = elements.length - 1; 0 <= i; i--) {\n            if (isVisible(elements[i])) {\n                popover.lastFocusable = elements[i];\n                break;\n            }\n        }\n    }\n\n    function trapFocus(popover, event) {\n        if (popover.firstFocusable === document.activeElement && event.shiftKey) {\n            //on Shift+Tab -> focus last focusable element when focus moves out of popover\n            event.preventDefault();\n            popover.lastFocusable.focus();\n        }\n        if (popover.lastFocusable === document.activeElement && !event.shiftKey) {\n            //on Tab -> focus first focusable element when focus moves out of popover\n            event.preventDefault();\n            popover.firstFocusable.focus();\n        }\n    }\n\n    function isVisible(element) {\n        // check if element is visible\n        return element.offsetWidth || element.offsetHeight || element.getClientRects().length;\n    }\n\n    window.Popover = Popover;\n\n    //initialize the Popover objects\n    var popovers = document.getElementsByClassName('js-popover');\n    // generic focusable elements string selector\n    var focusableElString = '[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), button:not([disabled]), iframe, object, embed, [tabindex]:not([tabindex=\"-1\"]), [contenteditable], audio[controls], video[controls], summary';\n\n    if (0 < popovers.length) {\n        var popoversArray = [];\n        var scrollingContainers = [];\n        for (var i = 0; i < popovers.length; i++) {\n            (function(i) {\n                popoversArray.push(new Popover(popovers[i]));\n                var scrollableElement = popovers[i].getAttribute('data-scrollable-element');\n                if (scrollableElement && !scrollingContainers.includes(scrollableElement)) {\n                    scrollingContainers.push(scrollableElement);\n                }\n            })(i);\n        }\n\n        // listen for key events\n        window.addEventListener('keyup', function(event) {\n            if (event.keyCode && 27 === event.keyCode || event.key && 'escape' === event.key.toLowerCase()) {\n                // close popover on 'Esc'\n                popoversArray.forEach(function(element) {\n                    element.checkPopoverFocus();\n                });\n            }\n        });\n        // close popover when clicking outside it\n        window.addEventListener('click', function(event) {\n            popoversArray.forEach(function(element) {\n                element.checkPopoverClick(event.target);\n            });\n        });\n        // on resize -> close all popover elements\n        window.addEventListener('resize', function() {\n            popoversArray.forEach(function(element) {\n                element.togglePopover(false, false);\n            });\n        });\n        // on scroll -> close all popover elements\n        window.addEventListener('scroll', function() {\n            popoversArray.forEach(function(element) {\n                if (element.popoverIsOpen) {\n                    element.togglePopover(false, false);\n                }\n            });\n        });\n        // take additional scrollable containers into account\n        for (var j = 0; j < scrollingContainers.length; j++) {\n            var scrollingContainer = document.querySelector(scrollingContainers[j]);\n            if (scrollingContainer) {\n                scrollingContainer.addEventListener('scroll', function() {\n                    popoversArray.forEach(function(element) {\n                        if (element.popoverIsOpen) {\n                            element.togglePopover(false, false);\n                        }\n                    });\n                });\n            }\n        }\n    }\n\n    window.popover = popoversArray[0];\n}());\n"
  },
  {
    "path": "assets/utils/routing.js",
    "content": "import Routing from '../../vendor/friendsofsymfony/jsrouting-bundle/Resources/public/js/router.min.js';\n\nconst routes = require('../../public/js/fos_js_routes.json');\n\nexport default function router() {\n    Routing.setRoutingData(routes);\n\n    return Routing;\n}\n"
  },
  {
    "path": "bin/console",
    "content": "#!/usr/bin/env php\n<?php\n\nuse App\\Kernel;\nuse Symfony\\Bundle\\FrameworkBundle\\Console\\Application;\n\nif (!is_dir(dirname(__DIR__).'/vendor')) {\n    throw new LogicException('Dependencies are missing. Try running \"composer install\".');\n}\n\nif (!is_file(dirname(__DIR__).'/vendor/autoload_runtime.php')) {\n    throw new LogicException('Symfony Runtime is missing. Try running \"composer require symfony/runtime\".');\n}\n\nrequire_once dirname(__DIR__).'/vendor/autoload_runtime.php';\n\nreturn function (array $context) {\n    $kernel = new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);\n\n    return new Application($kernel);\n};\n"
  },
  {
    "path": "bin/phpunit",
    "content": "#!/usr/bin/env php\n<?php\n\nrequire dirname(__DIR__).'/vendor/phpunit/phpunit/phpunit';\n"
  },
  {
    "path": "bin/post-upgrade",
    "content": "#!/usr/bin/env bash\nset -e\nBIN_DIR=$( cd -- \"$( dirname -- \"${BASH_SOURCE[0]}\" )\" &> /dev/null && pwd )\n\nprintf 'Do you want to proceed with the upgrade? (y/N)? '\nread -r answer\nif [ \"$answer\" != \"${answer#[Yy]}\" ]; then\n    # Retrieve prod or dev from .env.local.php file\n    ENV=$(php -r \"\\$env = require '$BIN_DIR/../.env.local.php'; echo \\$env['APP_ENV'];\")\n    if [[ \"$ENV\" == \"dev\" ]]; then\n        # Development\n        echo -e \"INFO: Environment detected: Development\\n\"\n\n        cd \"$BIN_DIR/..\"\n        echo \"INFO: Install/update PHP packages\"\n        composer install\n        echo \"INFO: Dump env file\"\n        composer dump-env dev\n        echo \"INFO: Clear application cache\"\n        APP_ENV=dev APP_DEBUG=1 php \"${BIN_DIR}\"/console cache:clear -n\n        echo \"INFO: Perform database migration\"\n        APP_ENV=dev php \"${BIN_DIR}\"/console doctrine:migrations:migrate -n\n        echo \"INFO: Clear composer cache\"\n        composer clear-cache\n        echo \"INFO: Install/update JS packages\"\n        NODE_ENV=development npm ci\n        echo \"INFO: Build frontend (development)\"\n        NODE_ENV=development npm run dev\n    else\n        # Production\n        echo -e \"INFO: Environment detected: Production\\n\"\n\n        cd \"$BIN_DIR/..\"\n        echo \"INFO: Install/update PHP packages\"\n        composer install --no-dev\n        echo \"INFO: Dump env file\"\n        composer dump-env prod\n        echo \"INFO: Clear application cache\"\n        APP_ENV=prod APP_DEBUG=0 php \"${BIN_DIR}\"/console cache:clear -n\n        echo \"INFO: Perform database migration\"\n        APP_ENV=prod php \"${BIN_DIR}\"/console doctrine:migrations:migrate -n\n        echo \"INFO: Clear composer cache\"\n        composer clear-cache\n        echo \"INFO: Install/update JS packages\"\n        # Note: npm install also require dev dependencies for the build step\n        npm ci --include=dev\n        echo \"INFO: Build frontend (production)\"\n        NODE_ENV=production npm run build\n    fi\n\n    echo -e \"INFO: Upgrade successfully completed!\\n\"\n    echo \"INFO: You might want to clear your Redis/Valkey cache (redis-cli FLUSHDB).\n    If you have OPCache enabled also reload your PHP FPM service, to clear the PHP cache.\"\nfi\n"
  },
  {
    "path": "ci/Dockerfile",
    "content": "# Using latest Debian Stable\nFROM debian:13-slim\n\nCOPY --from=composer/composer:latest-bin /composer /usr/bin/composer\n\nARG DEBIAN_FRONTEND=noninteractive\n\nRUN apt update\nRUN apt upgrade -y\nRUN apt install -y lsb-release ca-certificates curl wget unzip gnupg apt-transport-https acl nodejs npm\n\n# Install PHP\nRUN apt install -y php8.4 \\\n    php8.4-common \\\n    php8.4-fpm \\\n    php8.4-cli \\\n    php8.4-amqp \\\n    php8.4-bcmath \\\n    php8.4-pgsql \\\n    php8.4-gd \\\n    php8.4-curl \\\n    php8.4-xml \\\n    php8.4-redis \\\n    php8.4-mbstring \\\n    php8.4-zip \\\n    php8.4-bz2 \\\n    php8.4-intl\n\n# Unlimited memory\nRUN echo \"memory_limit = -1\" >>/etc/php/8.4/cli/conf.d/docker-php-memlimit.ini\nRUN echo \"memory_limit = -1\" >>/etc/php/8.4/fpm/conf.d/docker-php-memlimit.ini\n\n# Add cs2pr binary using wget\nRUN wget https://raw.githubusercontent.com/staabm/annotate-pull-request-from-checkstyle/refs/heads/master/cs2pr -O /usr/local/bin/cs2pr\nRUN chmod +x /usr/local/bin/cs2pr\n"
  },
  {
    "path": "ci/ignoredPaths.txt",
    "content": "# Dev container is not relevant to the workflow\n.devcontainer/**\n# Issue and pull request templates are not relevant to the workflow\n.github/ISSUE_TEMPLATE/**\n.github/PULL_REQUEST_TEMPLATE/**\n# A separate workflow is used to build and publish the pipeline image\nci/**\n# Documentation changes are not relevant to the workflow and are taking care of by mbin-docs repository\ndocs/**\n# Other files are not relevant to the workflow\nLICENSES/**\n*.md\npublic/robots.txt\n"
  },
  {
    "path": "ci/skipOnExcluded.sh",
    "content": "#!/usr/bin/env bash\n\nset -eu\n\n# Necessary in the GitHub Action environment\ngit config --global --add safe.directory \"$(realpath \"$GITHUB_WORKSPACE\")\"\n\nignoredPatterns=\"$(cat \"$GITHUB_WORKSPACE\"/ci/ignoredPaths.txt)\"\nif [[ \"${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}\" == 'main' ]]; then\n    git fetch origin main --depth 2\n    changedFiles=\"$(git diff --name-only main^ HEAD)\"\nelse\n    git fetch origin main:main --depth 1\n    changedFiles=\"$(git diff --name-only main)\"\nfi\n\ndoSkip=1\nwhile read -r path; do\n    while read -r pattern; do\n        [[ \"$pattern\" == '' ]] && continue\n        [[ \"$pattern\" == \\#* ]] && continue\n\n        # shellcheck disable=SC2053\n        if [[ \"$path\" == $pattern ]]; then\n            continue 2\n        fi\n    done <<< \"$ignoredPatterns\"\n\n    doSkip=0\n    break\ndone <<< \"$changedFiles\"\n\nif [[ \"$doSkip\" == 1 ]]; then\n    echo \"Skipping actions because diff only affects ignored paths\"\n    exit 0\nelse\n    echo \"Running actions\"\n    echo\n    exec \"$@\"\nfi\n"
  },
  {
    "path": "compose.dev.yaml",
    "content": "# Development environment override\nservices:\n    php:\n        pull_policy: build\n        build:\n            dockerfile: docker/Dockerfile\n            context: .\n            target: dev\n        volumes:\n            - ./:/app\n            - ./docker/Caddyfile:/etc/caddy/Caddyfile:ro\n            - ./docker/conf.d/20-app.dev.ini:/usr/local/etc/php/app.conf.d/20-app.dev.ini:ro\n            # If you develop on Mac or Windows you can remove the vendor/ directory\n            #  from the bind-mount for better performance by enabling the next line:\n            #- /app/vendor\n        environment:\n            # See https://xdebug.org/docs/all_settings#mode\n            XDEBUG_MODE: '${XDEBUG_MODE:-off}'\n            FRANKENPHP_WORKER_CONFIG: watch\n            MERCURE_EXTRA_DIRECTIVES: demo\n        extra_hosts:\n            # Ensure that host.docker.internal is correctly defined on Linux\n            - host.docker.internal:host-gateway\n        tty: true\n\n    node:\n        image: node:24-trixie-slim\n        user: ${MBIN_USER}\n        volumes:\n            - ./:/app\n        working_dir: /app\n        command: ['sh', '-c', 'npm install && npm run watch']\n\n    messenger:\n        pull_policy: build\n        build:\n            dockerfile: docker/Dockerfile\n            context: .\n            target: dev\n        volumes:\n            - ./:/app\n            - ./docker/conf.d/20-app.dev.ini:/usr/local/etc/php/app.conf.d/20-app.dev.ini:ro\n            # If you develop on Mac or Windows you can remove the vendor/ directory\n            #  from the bind-mount for better performance by enabling the next line:\n            #- /app/vendor\n        environment:\n            # See https://xdebug.org/docs/all_settings#mode\n            XDEBUG_MODE: '${XDEBUG_MODE:-off}'\n        extra_hosts:\n            # Ensure that host.docker.internal is correctly defined on Linux\n            - host.docker.internal:host-gateway\n        tty: true\n        deploy:\n            mode: replicated\n            # Change to 1 to enable federation\n            replicas: 0\n\n    amqproxy:\n        deploy:\n            mode: replicated\n            # Change to 1 to enable federation\n            replicas: 0\n\n    rabbitmq:\n        deploy:\n            mode: replicated\n            # Change to 1 to enable federation\n            replicas: 0\n"
  },
  {
    "path": "compose.yaml",
    "content": "services:\n    php:\n        image: ghcr.io/mbinorg/mbin:latest\n        build:\n            dockerfile: docker/Dockerfile\n            context: .\n            target: prod\n        restart: unless-stopped\n        env_file: .env\n        environment:\n            MERCURE_PUBLISHER_JWT_KEY: ${MERCURE_JWT_SECRET}\n            MERCURE_SUBSCRIBER_JWT_KEY: ${MERCURE_JWT_SECRET}\n        volumes:\n            - ./storage/caddy_config:/config\n            - ./storage/caddy_data:/data\n            - ./storage/media:/app/public/media\n            - ./storage/oauth:/app/config/oauth2\n            - ./storage/php_logs:/app/var/log\n        ports:\n            - 80:80\n            - 443:443\n            - 443:443/udp\n        depends_on:\n            - amqproxy\n            - postgres\n            - valkey\n\n    messenger:\n        image: ghcr.io/mbinorg/mbin:latest\n        build:\n            dockerfile: docker/Dockerfile\n            context: .\n            target: prod\n        restart: unless-stopped\n        command: bin/console messenger:consume scheduler_default old async outbox deliver inbox resolve receive failed --time-limit=3600\n        healthcheck:\n            test: ['CMD-SHELL', \"ps aux | grep 'messenger[:]consume' || exit 1\"]\n        env_file: .env\n        volumes:\n            - ./storage/media:/app/public/media\n            - ./storage/messenger_logs:/app/var/log\n        depends_on:\n            - amqproxy\n            - postgres\n            - valkey\n        deploy:\n            mode: replicated\n            replicas: 6\n\n    amqproxy:\n        image: cloudamqp/amqproxy\n        restart: unless-stopped\n        user: ${MBIN_USER}\n        command: amqp://rabbitmq:5672\n        depends_on:\n            rabbitmq:\n                condition: service_healthy\n\n    rabbitmq:\n        image: rabbitmq:3-management-alpine\n        restart: unless-stopped\n        user: ${MBIN_USER}\n        healthcheck:\n            test: ['CMD', 'rabbitmq-diagnostics', 'check_port_connectivity']\n        env_file: .env\n        volumes:\n            - ./storage/rabbitmq_data:/var/lib/rabbitmq\n            - ./storage/rabbitmq_logs:/var/log/rabbitmq\n        ports:\n            - 15672:15672\n        # Hostname specified to ensure persistent data: https://stackoverflow.com/questions/41330514\n        hostname: rabbitmq\n\n    postgres:\n        image: postgres:${POSTGRES_VERSION}-trixie\n        restart: unless-stopped\n        user: ${MBIN_USER}\n        shm_size: '2gb'\n        healthcheck:\n            test: ['CMD', 'pg_isready']\n        env_file: .env\n        volumes:\n            - ./storage/postgres:/var/lib/postgresql/data\n\n    valkey:\n        image: valkey/valkey:trixie\n        restart: unless-stopped\n        user: ${MBIN_USER}\n        command: valkey-server /valkey.conf --requirepass ${VALKEY_PASSWORD}\n        healthcheck:\n            test: ['CMD', 'valkey-cli', 'ping']\n        volumes:\n            - ./docker/valkey.conf:/valkey.conf\n"
  },
  {
    "path": "composer.json",
    "content": "{\n    \"name\": \"mbinorg/mbin\",\n    \"description\": \"Mbin is a decentralized content aggregator and microblogging platform running on the Fediverse network\",\n    \"type\": \"project\",\n    \"license\": \"AGPL-3.0-or-later\",\n    \"minimum-stability\": \"stable\",\n    \"prefer-stable\": true,\n    \"require\": {\n        \"php\": \">=8.3\",\n        \"ext-amqp\": \"*\",\n        \"ext-bcmath\": \"*\",\n        \"ext-ctype\": \"*\",\n        \"ext-curl\": \"*\",\n        \"ext-fileinfo\": \"*\",\n        \"ext-gd\": \"*\",\n        \"ext-iconv\": \"*\",\n        \"ext-intl\": \"*\",\n        \"ext-openssl\": \"*\",\n        \"ext-redis\": \"*\",\n        \"aws/aws-sdk-php\": \"^3.317.0\",\n        \"babdev/pagerfanta-bundle\": \"^v4.4.0\",\n        \"debril/rss-atom-bundle\": \"^5.2.0\",\n        \"doctrine/doctrine-bundle\": \"^2.18.2\",\n        \"doctrine/doctrine-migrations-bundle\": \"^3.7.0\",\n        \"doctrine/instantiator\": \"2.0.0\",\n        \"doctrine/orm\": \"^3.6.2\",\n        \"embed/embed\": \"^4.4.12\",\n        \"endroid/qr-code\": \"^6.0.3\",\n        \"firebase/php-jwt\": \"7.0.2 as 6.11.1\",\n        \"friendsofsymfony/jsrouting-bundle\": \"^3.5.0\",\n        \"furqansiddiqui/bip39-mnemonic-php\": \"^0.1.7\",\n        \"gumlet/php-image-resize\": \"^2.0.4\",\n        \"imagine/imagine\": \"^1.5\",\n        \"knplabs/knp-time-bundle\": \"^2.4.0\",\n        \"knpuniversity/oauth2-client-bundle\": \"^2.18.1\",\n        \"kornrunner/blurhash\": \"^1.2.2\",\n        \"league/commonmark\": \"^2.5.1\",\n        \"league/flysystem-aws-s3-v3\": \"^3.28.0\",\n        \"league/html-to-markdown\": \"^5.1.1\",\n        \"league/oauth2-facebook\": \"^2.2.0\",\n        \"league/oauth2-github\": \"^3.1.0\",\n        \"league/oauth2-google\": \"^4.0.1\",\n        \"league/oauth2-server-bundle\": \"^1.0.0\",\n        \"liip/imagine-bundle\": \"^2.13.1\",\n        \"meteo-concept/hcaptcha-bundle\": \"^4.1.0\",\n        \"minishlink/web-push\": \"^10.0.3\",\n        \"neitanod/forceutf8\": \"^2.0.4\",\n        \"nelmio/api-doc-bundle\": \"^5.7.0\",\n        \"nelmio/cors-bundle\": \"^2.5.0\",\n        \"nyholm/psr7\": \"^1.8.1\",\n        \"omines/antispam-bundle\": \"^0.1.8\",\n        \"oneup/flysystem-bundle\": \"^4.12.2\",\n        \"pagerfanta/core\": \"^4.7.0\",\n        \"pagerfanta/doctrine-collections-adapter\": \"^4.6.0\",\n        \"pagerfanta/doctrine-dbal-adapter\": \"^4.6.0\",\n        \"pagerfanta/doctrine-orm-adapter\": \"^4.6.0\",\n        \"pagerfanta/twig\": \"^4.6.0\",\n        \"phpdocumentor/reflection-docblock\": \"^5.4.1\",\n        \"phpseclib/phpseclib\": \"^3.0.42\",\n        \"phpstan/phpdoc-parser\": \"^2.0.0\",\n        \"predis/predis\": \"^3.0.1\",\n        \"privacyportal/oauth2-privacyportal\": \"^0.1.1\",\n        \"runtime/frankenphp-symfony\": \"^0.2.0\",\n        \"scheb/2fa-backup-code\": \"^7.5.0\",\n        \"scheb/2fa-bundle\": \"^7.5.0\",\n        \"scheb/2fa-totp\": \"^7.5.0\",\n        \"scienta/doctrine-json-functions\": \"^6.1.0\",\n        \"stevenmaguire/oauth2-keycloak\": \"^5.1.0\",\n        \"symfony/amqp-messenger\": \"*\",\n        \"symfony/asset\": \"*\",\n        \"symfony/cache\": \"*\",\n        \"symfony/console\": \"*\",\n        \"symfony/css-selector\": \"*\",\n        \"symfony/doctrine-messenger\": \"*\",\n        \"symfony/dotenv\": \"*\",\n        \"symfony/emoji\": \"*\",\n        \"symfony/expression-language\": \"*\",\n        \"symfony/flex\": \"^2.4.5\",\n        \"symfony/form\": \"*\",\n        \"symfony/framework-bundle\": \"*\",\n        \"symfony/http-client\": \"*\",\n        \"symfony/lock\": \"*\",\n        \"symfony/mailer\": \"*\",\n        \"symfony/mailgun-mailer\": \"*\",\n        \"symfony/mercure-bundle\": \"0.3.*\",\n        \"symfony/messenger\": \"*\",\n        \"symfony/mime\": \"*\",\n        \"symfony/monolog-bundle\": \"^4.0.1\",\n        \"symfony/property-access\": \"*\",\n        \"symfony/property-info\": \"*\",\n        \"symfony/rate-limiter\": \"*\",\n        \"symfony/redis-messenger\": \"*\",\n        \"symfony/runtime\": \"*\",\n        \"symfony/scheduler\": \"*\",\n        \"symfony/security-bundle\": \"*\",\n        \"symfony/security-csrf\": \"*\",\n        \"symfony/serializer\": \"*\",\n        \"symfony/string\": \"*\",\n        \"symfony/translation\": \"*\",\n        \"symfony/twig-bundle\": \"*\",\n        \"symfony/type-info\": \"*\",\n        \"symfony/uid\": \"*\",\n        \"symfony/ux-autocomplete\": \"^2.18.0\",\n        \"symfony/ux-chartjs\": \"^2.18.0\",\n        \"symfony/ux-twig-component\": \"^2.18.1\",\n        \"symfony/validator\": \"*\",\n        \"symfony/webpack-encore-bundle\": \"^2.1.1\",\n        \"symfony/workflow\": \"*\",\n        \"symfony/yaml\": \"*\",\n        \"symfonycasts/reset-password-bundle\": \"^1.22.0\",\n        \"symfonycasts/verify-email-bundle\": \"^1.17.0\",\n        \"thenetworg/oauth2-azure\": \"^2.2.2\",\n        \"twig/cssinliner-extra\": \"^3.10.0\",\n        \"twig/extra-bundle\": \"^3.10.0\",\n        \"twig/html-extra\": \"^3.10.0\",\n        \"twig/intl-extra\": \"^3.10.0\",\n        \"twig/twig\": \"^3.15.0\",\n        \"webmozart/assert\": \"^1.11.0\",\n        \"wohali/oauth2-discord-new\": \"^1.2.1\"\n    },\n    \"require-dev\": {\n        \"brianium/paratest\": \"^7.10.1\",\n        \"dama/doctrine-test-bundle\": \"^8.2.0\",\n        \"doctrine/doctrine-fixtures-bundle\": \"^4.3.1\",\n        \"fakerphp/faker\": \"^1.23.1\",\n        \"justinrainbow/json-schema\": \"^6.0.0\",\n        \"phpstan/phpstan\": \"^2.0.2\",\n        \"phpunit/phpunit\": \"12.1.*\",\n        \"spatie/phpunit-snapshot-assertions\": \"^5.2\",\n        \"symfony/browser-kit\": \"*\",\n        \"symfony/debug-bundle\": \"*\",\n        \"symfony/maker-bundle\": \"1.67.0\",\n        \"symfony/phpunit-bridge\": \"*\",\n        \"symfony/stopwatch\": \"*\",\n        \"symfony/web-profiler-bundle\": \"*\"\n    },\n    \"replace\": {\n        \"symfony/polyfill-ctype\": \"*\",\n        \"symfony/polyfill-iconv\": \"*\",\n        \"symfony/polyfill-php72\": \"*\"\n    },\n    \"conflict\": {\n        \"symfony/symfony\": \"*\"\n    },\n    \"autoload\": {\n        \"psr-4\": {\n            \"App\\\\\": \"src/\"\n        }\n    },\n    \"autoload-dev\": {\n        \"psr-4\": {\n            \"App\\\\Tests\\\\\": \"tests/\"\n        }\n    },\n    \"config\": {\n        \"allow-plugins\": {\n            \"dealerdirect/phpcodesniffer-composer-installer\": true,\n            \"symfony/flex\": true,\n            \"symfony/runtime\": true,\n            \"ergebnis/composer-normalize\": true\n        },\n        \"optimize-autoloader\": true,\n        \"classmap-authoritative\": true,\n        \"preferred-install\": {\n            \"*\": \"dist\"\n        },\n        \"sort-packages\": true\n    },\n    \"extra\": {\n        \"symfony\": {\n            \"allow-contrib\": false,\n            \"require\": \"7.4.*\"\n        }\n    },\n    \"scripts\": {\n        \"auto-scripts\": {\n            \"cache:clear\": \"symfony-cmd\",\n            \"assets:install %PUBLIC_DIR%\": \"symfony-cmd\"\n        },\n        \"post-install-cmd\": [\n            \"@auto-scripts\"\n        ],\n        \"post-update-cmd\": [\n            \"@auto-scripts\"\n        ],\n        \"codestyle:fix\": [\n            \"tools/vendor/bin/php-cs-fixer fix\"\n        ]\n    }\n}\n"
  },
  {
    "path": "config/bundles.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nreturn [\n    Symfony\\Bundle\\FrameworkBundle\\FrameworkBundle::class => ['all' => true],\n    Symfony\\Bundle\\SecurityBundle\\SecurityBundle::class => ['all' => true],\n    Symfony\\Bundle\\MakerBundle\\MakerBundle::class => ['dev' => true],\n    Doctrine\\Bundle\\DoctrineBundle\\DoctrineBundle::class => ['all' => true],\n    Doctrine\\Bundle\\MigrationsBundle\\DoctrineMigrationsBundle::class => ['all' => true],\n    Doctrine\\Bundle\\FixturesBundle\\DoctrineFixturesBundle::class => ['dev' => true, 'test' => true],\n    Symfony\\Bundle\\TwigBundle\\TwigBundle::class => ['all' => true],\n    Twig\\Extra\\TwigExtraBundle\\TwigExtraBundle::class => ['all' => true],\n    Symfony\\WebpackEncoreBundle\\WebpackEncoreBundle::class => ['all' => true],\n    Symfony\\Bundle\\WebProfilerBundle\\WebProfilerBundle::class => ['dev' => true, 'test' => true],\n    Symfony\\Bundle\\MonologBundle\\MonologBundle::class => ['all' => true],\n    DAMA\\DoctrineTestBundle\\DAMADoctrineTestBundle::class => ['test' => true],\n    Knp\\Bundle\\TimeBundle\\KnpTimeBundle::class => ['all' => true],\n    BabDev\\PagerfantaBundle\\BabDevPagerfantaBundle::class => ['all' => true],\n    Liip\\ImagineBundle\\LiipImagineBundle::class => ['all' => true],\n    Oneup\\FlysystemBundle\\OneupFlysystemBundle::class => ['all' => true],\n    FOS\\JsRoutingBundle\\FOSJsRoutingBundle::class => ['all' => true],\n    SymfonyCasts\\Bundle\\VerifyEmail\\SymfonyCastsVerifyEmailBundle::class => ['all' => true],\n    Symfony\\Bundle\\MercureBundle\\MercureBundle::class => ['all' => true],\n    Nelmio\\CorsBundle\\NelmioCorsBundle::class => ['all' => true],\n    Debril\\RssAtomBundle\\DebrilRssAtomBundle::class => ['all' => true],\n    KnpU\\OAuth2ClientBundle\\KnpUOAuth2ClientBundle::class => ['all' => true],\n    SymfonyCasts\\Bundle\\ResetPassword\\SymfonyCastsResetPasswordBundle::class => ['all' => true],\n    Symfony\\UX\\Chartjs\\ChartjsBundle::class => ['all' => true],\n    Symfony\\UX\\TwigComponent\\TwigComponentBundle::class => ['all' => true],\n    Symfony\\UX\\Autocomplete\\AutocompleteBundle::class => ['all' => true],\n    MeteoConcept\\HCaptchaBundle\\MeteoConceptHCaptchaBundle::class => ['all' => true],\n    Symfony\\UX\\StimulusBundle\\StimulusBundle::class => ['all' => true],\n    Symfony\\Bundle\\DebugBundle\\DebugBundle::class => ['dev' => true],\n    Nelmio\\ApiDocBundle\\NelmioApiDocBundle::class => ['all' => true],\n    League\\Bundle\\OAuth2ServerBundle\\LeagueOAuth2ServerBundle::class => ['all' => true],\n    Scheb\\TwoFactorBundle\\SchebTwoFactorBundle::class => ['all' => true],\n    Omines\\AntiSpamBundle\\AntiSpamBundle::class => ['all' => true],\n];\n"
  },
  {
    "path": "config/mbin_routes/activity_pub.yaml",
    "content": "ap_webfinger:\n    controller: App\\Controller\\ActivityPub\\WebFingerController\n    path: '/.well-known/webfinger'\n    methods: [GET]\n\nap_hostmeta:\n    controller: App\\Controller\\ActivityPub\\HostMetaController\n    path: '/.well-known/host-meta'\n    methods: [GET]\n\nap_node_info:\n    controller: App\\Controller\\ActivityPub\\NodeInfoController::nodeInfo\n    path: '/.well-known/nodeinfo'\n    methods: [GET]\n\nap_node_info_v2:\n    controller: App\\Controller\\ActivityPub\\NodeInfoController::nodeInfoV2\n    path: '/nodeinfo/{version}.{_format}'\n    methods: [GET]\n    requirements:\n        version: '2.0|2.1'\n    format: json\n\nap_instance:\n    controller: App\\Controller\\ActivityPub\\InstanceController\n    path: '/i/actor'\n    methods: [GET]\n\nap_instance_front:\n    controller: App\\Controller\\ActivityPub\\InstanceController\n    path: '/'\n    methods: [GET]\n    condition: '%kbin_ap_route_condition%'\n\nap_instance_inbox:\n    controller: App\\Controller\\ActivityPub\\SharedInboxController\n    path: /i/inbox\n    methods: [POST]\n\nap_instance_outbox:\n    controller: App\\Controller\\ActivityPub\\InstanceOutboxController\n    path: /i/outbox\n    methods: [POST]\n\nap_shared_inbox:\n    controller: App\\Controller\\ActivityPub\\SharedInboxController\n    path: /f/inbox\n    methods: [POST]\n\nap_object:\n    controller: App\\Controller\\ActivityPub\\ObjectController\n    path: /f/object/{id}\n    methods: [GET]\n    condition: '%kbin_ap_route_condition%'\n\nap_user:\n    controller: App\\Controller\\ActivityPub\\User\\UserController\n    path: /u/{username}\n    methods: [GET]\n    condition: '%kbin_ap_route_condition%'\n\nap_user_inbox:\n    controller: App\\Controller\\ActivityPub\\User\\UserInboxController\n    path: /u/{username}/inbox\n    methods: [POST]\n\nap_user_outbox:\n    controller: App\\Controller\\ActivityPub\\User\\UserOutboxController\n    path: /u/{username}/outbox\n    methods: [GET]\n    condition: '%kbin_ap_route_condition%'\n\nap_user_followers:\n    controller: App\\Controller\\ActivityPub\\User\\UserFollowersController::followers\n    path: /u/{username}/followers\n    methods: [GET]\n    condition: '%kbin_ap_route_condition%'\n\nap_user_following:\n    controller: App\\Controller\\ActivityPub\\User\\UserFollowersController::following\n    path: /u/{username}/following\n    methods: [GET]\n    condition: '%kbin_ap_route_condition%'\n\nap_magazine:\n    controller: App\\Controller\\ActivityPub\\Magazine\\MagazineController\n    path: /m/{name}\n    methods: [GET]\n    condition: '%kbin_ap_route_condition%'\n\nap_magazine_inbox:\n    controller: App\\Controller\\ActivityPub\\Magazine\\MagazineInboxController\n    path: /m/{name}/inbox\n    methods: [POST]\n\nap_magazine_outbox:\n    controller: App\\Controller\\ActivityPub\\Magazine\\MagazineOutboxController\n    path: /m/{name}/outbox\n    methods: [GET]\n    condition: '%kbin_ap_route_condition%'\n\nap_magazine_followers:\n    controller: App\\Controller\\ActivityPub\\Magazine\\MagazineFollowersController\n    path: /m/{name}/followers\n    methods: [GET]\n    condition: '%kbin_ap_route_condition%'\n\nap_magazine_moderators:\n    controller: App\\Controller\\ActivityPub\\Magazine\\MagazineModeratorsController\n    path: /m/{name}/moderators\n    methods: [GET]\n    condition: '%kbin_ap_route_condition%'\n\nap_magazine_pinned:\n    controller: App\\Controller\\ActivityPub\\Magazine\\MagazinePinnedController\n    path: /m/{name}/pinned\n    methods: [GET]\n    condition: '%kbin_ap_route_condition%'\n\nap_entry:\n    controller: App\\Controller\\ActivityPub\\EntryController\n    defaults: { slug: -, sortBy: hot }\n    path: /m/{magazine_name}/t/{entry_id}/{slug}/{sortBy}\n    methods: [GET]\n    condition: '%kbin_ap_route_condition%'\n\nap_entry_comment:\n    controller: App\\Controller\\ActivityPub\\EntryCommentController\n    defaults: { slug: - }\n    path: /m/{magazine_name}/t/{entry_id}/{slug}/comment/{comment_id}\n    methods: [GET]\n    condition: '%kbin_ap_route_condition%'\n\nap_post:\n    controller: App\\Controller\\ActivityPub\\PostController\n    defaults: { slug: - }\n    path: /m/{magazine_name}/p/{post_id}/{slug}\n    methods: [GET]\n    condition: '%kbin_ap_route_condition%'\n\nap_post_comment:\n    controller: App\\Controller\\ActivityPub\\PostCommentController\n    defaults: { slug: - }\n    path: /m/{magazine_name}/p/{post_id}/{slug}/reply/{comment_id}\n    methods: [GET]\n    condition: '%kbin_ap_route_condition%'\n\nap_report:\n    controller: App\\Controller\\ActivityPub\\ReportController\n    path: /reports/{report_id}\n    methods: [GET]\n    condition: '%kbin_ap_route_condition%'\n\nap_message:\n    controller: App\\Controller\\ActivityPub\\MessageController\n    path: /message/{uuid}\n    methods: [GET]\n    condition: '%kbin_ap_route_condition%'\n\nap_contexts:\n    controller: App\\Controller\\ActivityPub\\ContextsController\n    path: /contexts.{_format}\n    methods: [GET]\n    format: jsonld\n"
  },
  {
    "path": "config/mbin_routes/admin.yaml",
    "content": "admin_users_active:\n    controller: App\\Controller\\Admin\\AdminUsersController::active\n    defaults: { withFederated: false }\n    path: /admin/users/active/{withFederated}\n    methods: [GET]\n\nadmin_users_inactive:\n    controller: App\\Controller\\Admin\\AdminUsersController::inactive\n    path: /admin/users/inactive\n    methods: [GET]\n\nadmin_users_suspended:\n    controller: App\\Controller\\Admin\\AdminUsersController::suspended\n    defaults: { withFederated: false }\n    path: /admin/users/suspended/{withFederated}\n    methods: [GET]\n\nadmin_users_banned:\n    controller: App\\Controller\\Admin\\AdminUsersController::banned\n    defaults: { withFederated: false }\n    path: /admin/users/banned/{withFederated}\n    methods: [GET]\n\nadmin_reports:\n    controller: App\\Controller\\Admin\\AdminReportController\n    path: /admin/reports/{status}\n    defaults: { status: !php/const \\App\\Entity\\Report::STATUS_ANY }\n    methods: [GET]\n\nadmin_settings:\n    controller: App\\Controller\\Admin\\AdminSettingsController\n    path: /admin/settings\n    methods: [GET, POST]\n\nadmin_federation:\n    controller: App\\Controller\\Admin\\AdminFederationController\n    path: /admin/federation\n    methods: [GET, POST]\n\nadmin_federation_ban_instance:\n    controller: App\\Controller\\Admin\\AdminFederationController::banInstance\n    path: /admin/federation/ban\n    methods: [GET, POST]\n\nadmin_federation_unban_instance:\n    controller: App\\Controller\\Admin\\AdminFederationController::unbanInstance\n    path: /admin/federation/unban\n    methods: [GET]\n\nadmin_federation_allow_instance:\n    controller: App\\Controller\\Admin\\AdminFederationController::allowInstance\n    path: /admin/federation/allow\n    methods: [GET]\n\nadmin_federation_deny_instance:\n    controller: App\\Controller\\Admin\\AdminFederationController::denyInstance\n    path: /admin/federation/deny\n    methods: [GET, POST]\n\nadmin_pages:\n    controller: App\\Controller\\Admin\\AdminPagesController\n    path: /admin/pages/{page}\n    methods: [GET, POST]\n\nadmin_deletion_users:\n    controller: App\\Controller\\Admin\\AdminDeletionController::users\n    path: /admin/deletion/users\n    methods: [GET]\n\nadmin_deletion_magazines:\n    controller: App\\Controller\\Admin\\AdminDeletionController::magazines\n    path: /admin/deletion/magazines\n    methods: [GET]\n\nadmin_moderators:\n    controller: App\\Controller\\Admin\\AdminModeratorController::moderators\n    path: /admin/moderators\n    methods: [GET, POST]\n\nadmin_moderator_purge:\n    controller: App\\Controller\\Admin\\AdminModeratorController::removeModerator\n    path: /admin/moderators/purge/{username}\n    methods: [POST]\n\nadmin_magazine_ownership_requests:\n    controller: App\\Controller\\Admin\\AdminMagazineOwnershipRequestController::requests\n    path: /admin/magazine_ownership\n    methods: [GET]\n\nadmin_magazine_ownership_requests_accept:\n    controller: App\\Controller\\Admin\\AdminMagazineOwnershipRequestController::accept\n    path: /admin/magazine_ownership/{name}/{username}/accept\n    methods: [POST]\n\nadmin_magazine_ownership_requests_reject:\n    controller: App\\Controller\\Admin\\AdminMagazineOwnershipRequestController::reject\n    path: /admin/magazine_ownership/{name}/{username}/reject\n    methods: [POST]\n\nadmin_signup_requests:\n    controller: App\\Controller\\Admin\\AdminSignupRequestsController::requests\n    path: /admin/signup_requests\n    methods: [ GET ]\n\nadmin_signup_requests_approve:\n    controller: App\\Controller\\Admin\\AdminSignupRequestsController::approve\n    path: /admin/signup_requests/{id}/approve\n    methods: [ POST ]\n\nadmin_signup_requests_reject:\n    controller: App\\Controller\\Admin\\AdminSignupRequestsController::reject\n    path: /admin/signup_requests/{id}/reject\n    methods: [ POST ]\n\nadmin_cc:\n    controller: App\\Controller\\Admin\\AdminClearCacheController\n    path: /admin/cc\n    methods: [GET]\n\nadmin_monitoring:\n    controller: App\\Controller\\Admin\\AdminMonitoringController::overview\n    path: /admin/monitoring\n    methods: [GET]\n\nadmin_monitoring_single_context:\n    controller: App\\Controller\\Admin\\AdminMonitoringController::single\n    defaults: { page: overview }\n    path: /admin/monitoring/{id}/{page}\n    methods: [GET]\n\nadmin_dashboard:\n    controller: App\\Controller\\Admin\\AdminDashboardController\n    path: /admin/{statsPeriod}/{withFederated}\n    defaults: { statsType: content, statsPeriod: -1, withFederated: false }\n    methods: [GET]\n"
  },
  {
    "path": "config/mbin_routes/admin_api.yaml",
    "content": "api_admin_entry_purge:\n  controller: App\\Controller\\Api\\Entry\\Admin\\EntriesPurgeApi\n  path: /api/admin/entry/{entry_id}/purge\n  methods: [ DELETE ]\n  format: json\n\napi_admin_entry_change_magazine:\n  controller: App\\Controller\\Api\\Entry\\Admin\\EntriesChangeMagazineApi\n  path: /api/admin/entry/{entry_id}/change-magazine/{target_id}\n  methods: [ PUT ]\n  format: json\n\napi_admin_comment_purge:\n  controller: App\\Controller\\Api\\Entry\\Comments\\Admin\\EntryCommentsPurgeApi\n  path: /api/admin/comment/{comment_id}/purge\n  methods: [ DELETE ]\n  format: json\n\napi_admin_post_purge:\n  controller: App\\Controller\\Api\\Post\\Admin\\PostsPurgeApi\n  path: /api/admin/post/{post_id}/purge\n  methods: [ DELETE ]\n  format: json\n\napi_admin_post_comment_purge:\n  controller: App\\Controller\\Api\\Post\\Comments\\Admin\\PostCommentsPurgeApi\n  path: /api/admin/post-comment/{comment_id}/purge\n  methods: [ DELETE ]\n  format: json\n\napi_admin_user_retrieve_banned:\n  controller: App\\Controller\\Api\\User\\Admin\\UserRetrieveBannedApi::collection\n  path: /api/admin/users/banned\n  methods: [ GET ]\n  format: json\n\napi_admin_user_ban:\n  controller: App\\Controller\\Api\\User\\Admin\\UserBanApi::ban\n  path: /api/admin/users/{user_id}/ban\n  methods: [ POST ]\n  format: json\n\napi_admin_user_unban:\n  controller: App\\Controller\\Api\\User\\Admin\\UserBanApi::unban\n  path: /api/admin/users/{user_id}/unban\n  methods: [ POST ]\n  format: json\n\napi_admin_user_delete_account:\n  controller: App\\Controller\\Api\\User\\Admin\\UserDeleteApi\n  path: /api/admin/users/{user_id}/delete_account\n  methods: [ DELETE ]\n  format: json\n\napi_admin_user_purge:\n  controller: App\\Controller\\Api\\User\\Admin\\UserPurgeApi\n  path: /api/admin/users/{user_id}/purge_account\n  methods: [ DELETE ]\n  format: json\n\napi_admin_user_verify:\n  controller: App\\Controller\\Api\\User\\Admin\\UserVerifyApi\n  path: /api/admin/users/{user_id}/verify\n  methods: [ PUT ]\n  format: json\n\napi_admin_retrieve_settings:\n  controller: App\\Controller\\Api\\Instance\\Admin\\InstanceRetrieveSettingsApi\n  path: /api/instance/settings\n  methods: [ GET ]\n  format: json\n\napi_admin_update_settings:\n  controller: App\\Controller\\Api\\Instance\\Admin\\InstanceUpdateSettingsApi\n  path: /api/instance/settings\n  methods: [ PUT ]\n  format: json\n\napi_admin_ban_instance:\n    controller: App\\Controller\\Api\\Instance\\Admin\\InstanceUpdateFederationApi::banInstance\n    path: /api/instance/ban/{domain}\n    methods: [ PUT ]\n    format: json\n\napi_admin_unban_instance:\n    controller: App\\Controller\\Api\\Instance\\Admin\\InstanceUpdateFederationApi::unbanInstance\n    path: /api/instance/unban/{domain}\n    methods: [ PUT ]\n    format: json\n\napi_admin_allow_instance:\n    controller: App\\Controller\\Api\\Instance\\Admin\\InstanceUpdateFederationApi::allowInstance\n    path: /api/instance/allow/{domain}\n    methods: [ PUT ]\n    format: json\n\napi_admin_deny_instance:\n    controller: App\\Controller\\Api\\Instance\\Admin\\InstanceUpdateFederationApi::denyInstance\n    path: /api/instance/deny/{domain}\n    methods: [ PUT ]\n    format: json\n\napi_admin_update_pages:\n  controller: App\\Controller\\Api\\Instance\\Admin\\InstanceUpdatePagesApi\n  path: /api/instance/{page}\n  methods: [ PUT ]\n  format: json\n\napi_admin_retrieve_client_stats:\n  controller: App\\Controller\\Api\\OAuth2\\Admin\\RetrieveClientStatsApi\n  path: /api/clients/stats\n  methods: [ GET ]\n  format: json\n\napi_admin_retrieve_client:\n  controller: App\\Controller\\Api\\OAuth2\\Admin\\RetrieveClientApi\n  path: /api/clients/{client_identifier}\n  methods: [ GET ]\n  format: json\n\napi_admin_retrieve_client_collection:\n  controller: App\\Controller\\Api\\OAuth2\\Admin\\RetrieveClientApi::collection\n  path: /api/clients\n  methods: [ GET ]\n  format: json\n\napi_admin_update_defederated_instances:\n  controller: App\\Controller\\Api\\Instance\\Admin\\InstanceUpdateFederationApi\n  path: /api/defederated\n  methods: [ PUT ]\n  format: json\n\napi_admin_purge_magazine:\n  controller: App\\Controller\\Api\\Magazine\\Admin\\MagazinePurgeApi\n  path: /api/admin/magazine/{magazine_id}/purge\n  methods: [ DELETE ]\n  format: json\n\napi_admin_view_user_applications:\n    controller: App\\Controller\\Api\\User\\Admin\\UserApplicationApi::retrieve\n    path: /api/admin/users/applications\n    methods: [ GET ]\n    format: json\n\napi_admin_view_user_application_approve:\n    controller: App\\Controller\\Api\\User\\Admin\\UserApplicationApi::approve\n    path: /api/admin/users/applications/{user_id}/approve\n    methods: [ GET ]\n    format: json\n\napi_admin_view_user_application_reject:\n    controller: App\\Controller\\Api\\User\\Admin\\UserApplicationApi::reject\n    path: /api/admin/users/applications/{user_id}/reject\n    methods: [ GET ]\n    format: json\n"
  },
  {
    "path": "config/mbin_routes/ajax.yaml",
    "content": "ajax_fetch_title:\n  controller: App\\Controller\\AjaxController::fetchTitle\n  defaults: { _format: json }\n  path: /ajax/fetch_title\n  methods: [POST]\n\najax_fetch_duplicates:\n  controller: App\\Controller\\AjaxController::fetchDuplicates\n  defaults: { _format: json }\n  path: /ajax/fetch_duplicates\n  methods: [POST]\n\najax_fetch_embed:\n  controller: App\\Controller\\AjaxController::fetchEmbed\n  defaults: { _format: json }\n  path: /ajax/fetch_embed\n  methods: [GET]\n\najax_fetch_post_comments:\n  controller: App\\Controller\\AjaxController::fetchPostComments\n  defaults: { _format: json }\n  path: /ajax/fetch_post_comments/{id}\n  methods: [GET]\n  requirements:\n    id: \\d+\n\najax_fetch_entry:\n  controller: App\\Controller\\AjaxController::fetchEntry\n  defaults: { _format: json }\n  path: /ajax/fetch_entry/{id}\n  methods: [GET]\n  requirements:\n    id: \\d+\n\najax_fetch_entry_comment:\n  controller: App\\Controller\\AjaxController::fetchEntryComment\n  defaults: { _format: json }\n  path: /ajax/fetch_entry_comment/{id}\n  methods: [GET]\n  requirements:\n    id: \\d+\n\najax_fetch_post:\n  controller: App\\Controller\\AjaxController::fetchPost\n  defaults: { _format: json }\n  path: /ajax/fetch_post/{id}\n  methods: [GET]\n  requirements:\n    id: \\d+\n\najax_fetch_post_comment:\n  controller: App\\Controller\\AjaxController::fetchPostComment\n  defaults: { _format: json }\n  path: /ajax/fetch_post_comment/{id}\n  methods: [GET]\n  requirements:\n    id: \\d+\n\najax_fetch_online:\n  controller: App\\Controller\\AjaxController::fetchOnline\n  defaults: { _format: json }\n  path: /ajax/fetch_online/{topic}\n  methods: [ GET ]\n\najax_fetch_user_popup:\n  controller: App\\Controller\\AjaxController::fetchUserPopup\n  defaults: { _format: json }\n  path: /ajax/fetch_user_popup/{username}\n  methods: [ GET ]\n\najax_fetch_user_notifications_count:\n  controller: App\\Controller\\AjaxController::fetchNotificationsCount\n  defaults: { _format: json }\n  path: /ajax/fetch_user_notifications_count\n  methods: [ GET ]\n\najax_register_notification_push:\n  controller: App\\Controller\\AjaxController::registerPushNotifications\n  path: /ajax/register_push\n  methods: [ POST ]\n\najax_unregister_notification_push:\n    controller: App\\Controller\\AjaxController::unregisterPushNotifications\n    path: /ajax/unregister_push\n    methods: [ POST ]\n\najax_test_notification_push:\n    controller: App\\Controller\\AjaxController::testPushNotification\n    path: /ajax/test_push\n    methods: [ POST ]\n\najax_fetch_users_suggestions:\n  controller: App\\Controller\\AjaxController::fetchUsersSuggestions\n  defaults: { _format: json }\n  path: /ajax/fetch_users_suggestions/{username}\n  methods: [ GET ]\n\najax_fetch_emoji_suggestions:\n    controller: App\\Controller\\AjaxController::fetchEmojiSuggestions\n    defaults: { _format: json }\n    path: /ajax/fetch_emoji_suggestions\n    methods: [ GET ]\n"
  },
  {
    "path": "config/mbin_routes/api.yaml",
    "content": "app.swagger_ui:\n  path: /api/docs\n  methods: GET\n  defaults: { _controller: nelmio_api_doc.controller.swagger_ui }\n"
  },
  {
    "path": "config/mbin_routes/bookmark.yaml",
    "content": "bookmark_front:\n    controller: App\\Controller\\BookmarkListController::front\n    defaults: { sortBy: hot, time: '∞', federation: all }\n    path: /bookmark-lists/show/{list}/{sortBy}/{time}/{federation}\n    methods: [GET]\n    requirements: &front_requirement\n        sortBy: \"%default_sort_options%\"\n        time: \"%default_time_options%\"\n        federation: \"%default_federation_options%\"\n\nbookmark_lists:\n    controller: App\\Controller\\BookmarkListController::list\n    path: /bookmark-lists\n    methods: [GET, POST]\n\nbookmark_lists_menu_refresh_status:\n    controller: App\\Controller\\BookmarkListController::subjectBookmarkMenuListRefresh\n    path: /blr/{subject_id}/{subject_type}\n    requirements:\n        subject_type: \"%default_subject_type_options%\"\n    methods: [ GET ]\n\nbookmark_lists_make_default:\n    controller: App\\Controller\\BookmarkListController::makeDefault\n    path: /bookmark-lists/makeDefault\n    methods: [GET]\n\nbookmark_lists_edit_list:\n    controller: App\\Controller\\BookmarkListController::editList\n    path: /bookmark-lists/editList/{list}\n    methods: [GET, POST]\n\nbookmark_lists_delete_list:\n    controller: App\\Controller\\BookmarkListController::deleteList\n    path: /bookmark-lists/deleteList/{list}\n    methods: [GET]\n\nsubject_bookmark_standard:\n    controller: App\\Controller\\BookmarkController::subjectBookmarkStandard\n    path: /bos/{subject_id}/{subject_type}\n    requirements:\n        subject_type: \"%default_subject_type_options%\"\n    methods: [ GET ]\n\nsubject_bookmark_refresh_status:\n    controller: App\\Controller\\BookmarkController::subjectBookmarkRefresh\n    path: /bor/{subject_id}/{subject_type}\n    requirements:\n        subject_type: \"%default_subject_type_options%\"\n    methods: [ GET ]\n\nsubject_bookmark_to_list:\n    controller: App\\Controller\\BookmarkController::subjectBookmarkToList\n    path: /bol/{subject_id}/{subject_type}/{list}\n    requirements:\n        subject_type: \"%default_subject_type_options%\"\n    methods: [ GET ]\n\nsubject_remove_bookmarks:\n    controller: App\\Controller\\BookmarkController::subjectRemoveBookmarks\n    path: /rbo/{subject_id}/{subject_type}\n    requirements:\n        subject_type: \"%default_subject_type_options%\"\n    methods: [ GET ]\n\nsubject_remove_bookmark_from_list:\n    controller: App\\Controller\\BookmarkController::subjectRemoveBookmarkFromList\n    path: /rbol/{subject_id}/{subject_type}/{list}\n    requirements:\n        subject_type: \"%default_subject_type_options%\"\n    methods: [ GET ]\n"
  },
  {
    "path": "config/mbin_routes/bookmark_api.yaml",
    "content": "api_bookmark_front:\n    controller: App\\Controller\\Api\\Bookmark\\BookmarkListApiController::front\n    path: /api/bookmark-lists/show\n    methods: [GET]\n    format: json\n\napi_bookmark_lists:\n    controller: App\\Controller\\Api\\Bookmark\\BookmarkListApiController::list\n    path: /api/bookmark-lists\n    methods: [GET]\n    format: json\n\napi_bookmark_lists_make_default:\n    controller: App\\Controller\\Api\\Bookmark\\BookmarkListApiController::makeDefault\n    path: /api/bookmark-lists/{list_name}/makeDefault\n    methods: [PUT]\n    format: json\n\napi_bookmark_lists_edit_list:\n    controller: App\\Controller\\Api\\Bookmark\\BookmarkListApiController::editList\n    path: /api/bookmark-lists/{list_name}\n    methods: [PUT]\n    format: json\n\napi_bookmark_lists_add_list:\n    controller: App\\Controller\\Api\\Bookmark\\BookmarkListApiController::createList\n    path: /api/bookmark-lists/{list_name}\n    methods: [POST]\n    format: json\n\napi_bookmark_lists_delete_list:\n    controller: App\\Controller\\Api\\Bookmark\\BookmarkListApiController::deleteList\n    path: /api/bookmark-lists/{list_name}\n    methods: [DELETE]\n    format: json\n\napi_subject_bookmark_standard:\n    controller: App\\Controller\\Api\\Bookmark\\BookmarkApiController::subjectBookmarkStandard\n    path: /api/bos/{subject_id}/{subject_type}\n    requirements:\n        subject_type: \"%default_subject_type_options%\"\n    methods: [ PUT ]\n    format: json\n\napi_subject_bookmark_to_list:\n    controller: App\\Controller\\Api\\Bookmark\\BookmarkApiController::subjectBookmarkToList\n    path: /api/bol/{subject_id}/{subject_type}/{list_name}\n    requirements:\n        subject_type: \"%default_subject_type_options%\"\n    methods: [ PUT ]\n    format: json\n\napi_subject_remove_bookmarks:\n    controller: App\\Controller\\Api\\Bookmark\\BookmarkApiController::subjectRemoveBookmarks\n    path: /api/rbo/{subject_id}/{subject_type}\n    requirements:\n        subject_type: \"%default_subject_type_options%\"\n    methods: [ DELETE ]\n    format: json\n\napi_subject_remove_bookmark_from_list:\n    controller: App\\Controller\\Api\\Bookmark\\BookmarkApiController::subjectRemoveBookmarkFromList\n    path: /api/rbol/{subject_id}/{subject_type}/{list_name}\n    requirements:\n        subject_type: \"%default_subject_type_options%\"\n    methods: [ DELETE ]\n    format: json\n"
  },
  {
    "path": "config/mbin_routes/combined_api.yaml",
    "content": "api_combined_cursor:\n    controller: App\\Controller\\Api\\Combined\\CombinedRetrieveApi::cursorCollection\n    path: /api/combined/v2\n    methods: [ GET ]\n    format: json\n\napi_combined_user_cursor:\n    controller: App\\Controller\\Api\\Combined\\CombinedRetrieveApi::cursorUserCollection\n    path: /api/combined/v2/{collectionType}\n    requirements:\n        collectionType: subscribed|moderated|favourited\n    methods: [ GET ]\n    format: json\n\napi_combined:\n  controller: App\\Controller\\Api\\Combined\\CombinedRetrieveApi::collection\n  path: /api/combined\n  methods: [ GET ]\n  format: json\n\napi_combined_user:\n  controller: App\\Controller\\Api\\Combined\\CombinedRetrieveApi::userCollection\n  path: /api/combined/{collectionType}\n  requirements:\n    collectionType: subscribed|moderated|favourited\n  methods: [ GET ]\n  format: json\n"
  },
  {
    "path": "config/mbin_routes/custom_style.yaml",
    "content": "custom_style:\n    controller: App\\Controller\\CustomStyleController\n    path: /custom-style\n    methods: [ GET ]\n"
  },
  {
    "path": "config/mbin_routes/domain.yaml",
    "content": "domain_entries:\n  controller: App\\Controller\\Domain\\DomainFrontController\n  defaults: { sortBy: hot, time: '∞'}\n  path: /d/{name}/{sortBy}/{time}\n  methods: [ GET ]\n  requirements:\n    sortBy: \"%default_sort_options%\"\n    time: \"%default_time_options%\"\n\ndomain_comments:\n  controller: App\\Controller\\Domain\\DomainCommentFrontController\n  defaults: { sortBy: hot, time: ~ }\n  path: /d/{name}/comments/{sortBy}/{time}\n  methods: [GET]\n  requirements:\n    sortBy: \"%comment_sort_options%\"\n    time: \"%default_time_options%\"\n\ndomain_subscribe:\n  controller: App\\Controller\\Domain\\DomainSubController::subscribe\n  path: /d/{name}/subscribe\n  methods: [ POST ]\n\ndomain_unsubscribe:\n  controller: App\\Controller\\Domain\\DomainSubController::unsubscribe\n  path: /d/{name}/unsubscribe\n  methods: [ POST ]\n\ndomain_block:\n  controller: App\\Controller\\Domain\\DomainBlockController::block\n  path: /d/{name}/block\n  methods: [ POST ]\n\ndomain_unblock:\n  controller: App\\Controller\\Domain\\DomainBlockController::unblock\n  path: /d/{name}/unblock\n  methods: [ POST ]\n"
  },
  {
    "path": "config/mbin_routes/domain_api.yaml",
    "content": "# Get a list of threads from specific domain\napi_domain_entries_retrieve:\n  controller: App\\Controller\\Api\\Entry\\DomainEntriesRetrieveApi\n  path: /api/domain/{domain_id}/entries\n  methods: [ GET ]\n  format: json\n\n# Get a list of comments from specific domain\napi_domain_entry_comments_retrieve:\n  controller: App\\Controller\\Api\\Entry\\Comments\\DomainEntryCommentsRetrieveApi\n  path: /api/domain/{domain_id}/comments\n  methods: [ GET ]\n  format: json\n\n# Get list of domains in instance\napi_domains_retrieve:\n  controller: App\\Controller\\Api\\Domain\\DomainRetrieveApi::collection\n  path: /api/domains\n  methods: [ GET ]\n  format: json\n\n# Get domain info\napi_domain_retrieve:\n  controller: App\\Controller\\Api\\Domain\\DomainRetrieveApi\n  path: /api/domain/{domain_id}\n  methods: [ GET ]\n  format: json\n\n# Get subscribed domains for the current user\napi_domains_retrieve_subscribed:\n  controller: App\\Controller\\Api\\Domain\\DomainRetrieveApi::subscribed\n  path: /api/domains/subscribed\n  methods: [ GET ]\n  format: json\n\n# Get blocked domains for the current user\napi_domains_retrieve_blocked:\n  controller: App\\Controller\\Api\\Domain\\DomainRetrieveApi::blocked\n  path: /api/domains/blocked\n  methods: [ GET ]\n  format: json\n\napi_domain_block:\n  controller: App\\Controller\\Api\\Domain\\DomainBlockApi::block\n  path: /api/domain/{domain_id}/block\n  methods: [ PUT ]\n  format: json\n\napi_domain_unblock:\n  controller: App\\Controller\\Api\\Domain\\DomainBlockApi::unblock\n  path: /api/domain/{domain_id}/unblock\n  methods: [ PUT ]\n  format: json\n\napi_domain_subscribe:\n  controller: App\\Controller\\Api\\Domain\\DomainSubscribeApi::subscribe\n  path: /api/domain/{domain_id}/subscribe\n  methods: [ PUT ]\n  format: json\n\napi_domain_unsubscribe:\n  controller: App\\Controller\\Api\\Domain\\DomainSubscribeApi::unsubscribe\n  path: /api/domain/{domain_id}/unsubscribe\n  methods: [ PUT ]\n  format: json\n"
  },
  {
    "path": "config/mbin_routes/entry.yaml",
    "content": "entry_comment_create:\n  controller: App\\Controller\\Entry\\Comment\\EntryCommentCreateController\n  defaults: { slug: -, parent_comment_id: null }\n  path: /m/{magazine_name}/t/{entry_id}/{slug}/comment/create/{parent_comment_id}\n  methods: [ GET, POST ]\n  requirements:\n    entry_id: \\d+\n    parent_comment_id: \\d+\n\nentry_comment_view:\n  controller: App\\Controller\\Entry\\Comment\\EntryCommentViewController\n  defaults: { slug: -, comment_id: null }\n  path: /m/{magazine_name}/t/{entry_id}/{slug}/comment/{comment_id}\n  methods: [ GET ]\n  requirements:\n    entry_id: \\d+\n    comment_id: \\d+\n\nentry_comment_edit:\n  controller: App\\Controller\\Entry\\Comment\\EntryCommentEditController\n  defaults: { slug: -, }\n  path: /m/{magazine_name}/t/{entry_id}/{slug}/comment/{comment_id}/edit\n  methods: [ GET, POST ]\n  requirements:\n    entry_id: \\d+\n    comment_id: \\d+\n\nentry_comment_delete:\n  controller: App\\Controller\\Entry\\Comment\\EntryCommentDeleteController::delete\n  defaults: { slug: -, }\n  path: /m/{magazine_name}/t/{entry_id}/{slug}/comments/{comment_id}/delete\n  methods: [ POST ]\n  requirements:\n    entry_id: \\d+\n    comment_id: \\d+\n\nentry_comment_restore:\n  controller: App\\Controller\\Entry\\Comment\\EntryCommentDeleteController::restore\n  defaults: { slug: -, }\n  path: /m/{magazine_name}/t/{entry_id}/{slug}/comments/{comment_id}/restore\n  methods: [ POST ]\n  requirements:\n    entry_id: \\d+\n    comment_id: \\d+\n\nentry_comment_purge:\n  controller: App\\Controller\\Entry\\Comment\\EntryCommentDeleteController::purge\n  defaults: { slug: -, }\n  path: /m/{magazine_name}/t/{entry_id}/{slug}/comments/{comment_id}/purge\n  methods: [ POST ]\n  requirements:\n    entry_id: \\d+\n    comment_id: \\d+\n\nentry_comment_change_lang:\n  controller: App\\Controller\\Entry\\Comment\\EntryCommentChangeLangController\n  defaults: { slug: - }\n  path: /m/{magazine_name}/t/{entry_id}/{slug}/comments/{comment_id}/change_lang\n  methods: [ POST ]\n  requirements:\n    entry_id: \\d+\n    comment_id: \\d+\n\nentry_comment_change_adult:\n  controller: App\\Controller\\Entry\\Comment\\EntryCommentChangeAdultController\n  defaults: { slug: - }\n  path: /m/{magazine_name}/t/{entry_id}/{slug}/comments/{comment_id}/change_adult\n  methods: [ POST ]\n  requirements:\n    entry_id: \\d+\n    comment_id: \\d+\n\nentry_comment_image_delete:\n  controller: App\\Controller\\Entry\\Comment\\EntryCommentDeleteImageController\n  defaults: { slug: -, }\n  path: /m/{magazine_name}/t/{entry_id}/{slug}/comments/{comment_id}/delete_image\n  methods: [ POST ]\n  requirements:\n    entry_id: \\d+\n    comment_id: \\d+\n\nentry_comment_voters:\n  controller: App\\Controller\\Entry\\Comment\\EntryCommentVotersController\n  defaults: { slug: -, }\n  path: /m/{magazine_name}/t/{entry_id}/{slug}/comment/{comment_id}/votes/{type}\n  methods: [ GET ]\n  requirements:\n    type: 'up'\n    entry_id: \\d+\n    comment_id: \\d+\n\nentry_comment_favourites:\n  controller: App\\Controller\\Entry\\Comment\\EntryCommentFavouriteController\n  defaults: { slug: -, }\n  path: /m/{magazine_name}/t/{entry_id}/{slug}/comment/{comment_id}/favourites\n  methods: [ GET ]\n  requirements:\n    entry_id: \\d+\n    comment_id: \\d+\n\nentry_comment_moderate:\n  controller: App\\Controller\\Entry\\Comment\\EntryCommentModerateController\n  defaults: { slug: -, }\n  path: /m/{magazine_name}/t/{entry_id}/{slug}/comment/{comment_id}/moderate\n  methods: [ GET ]\n  requirements:\n    entry_id: \\d+\n    comment_id: \\d+\n\nentry_comments_front:\n  controller: App\\Controller\\Entry\\Comment\\EntryCommentFrontController::front\n  defaults: { sortBy: default, time: ~ }\n  path: /comments/{sortBy}/{time}\n  methods: [ GET ]\n  requirements:\n    sortBy: \"%comment_sort_options%\"\n    time: \"%default_time_options%\"\n\nentry_comments_subscribed:\n  controller: App\\Controller\\Entry\\Comment\\EntryCommentFrontController::subscribed\n  defaults: { sortBy: default, time: ~ }\n  path: /sub/comments/{sortBy}/{time}\n  methods: [ GET ]\n  requirements:\n    sortBy: \"%comment_sort_options%\"\n    time: \"%default_time_options%\"\n\nentry_comments_moderated:\n  controller: App\\Controller\\Entry\\Comment\\EntryCommentFrontController::moderated\n  defaults: { sortBy: default, time: ~ }\n  path: /mod/comments/{sortBy}/{time}\n  methods: [ GET ]\n  requirements:\n    sortBy: \"%comment_sort_options%\"\n    time: \"%default_time_options%\"\n\nentry_comments_favourite:\n  controller: App\\Controller\\Entry\\Comment\\EntryCommentFrontController::favourite\n  defaults: { sortBy: default, time: ~ }\n  path: /fav/comments/{sortBy}/{time}\n  methods: [ GET ]\n  requirements:\n    sortBy: \"%comment_sort_options%\"\n    time: \"%default_time_options%\"\n\nmagazine_entry_comments:\n  controller: App\\Controller\\Entry\\Comment\\EntryCommentFrontController::front\n  defaults: { sortBy: default, time: ~ }\n  path: /m/{name}/comments/{sortBy}/{time}\n  methods: [ GET ]\n  requirements:\n    sortBy: \"%comment_sort_options%\"\n    time: \"%default_time_options%\"\n\nentry_comment_vote:\n  controller: App\\Controller\\VoteController\n  defaults: { entityClass: App\\Entity\\EntryComment }\n  path: /ecv/{id}/{choice}\n  methods: [ POST ]\n  requirements:\n    id: \\d+\n\nentry_comment_report:\n  controller: App\\Controller\\ReportController\n  defaults: { entityClass: App\\Entity\\EntryComment }\n  path: /ecr/{id}\n  methods: [ GET, POST ]\n  requirements:\n    id: \\d+\n\nentry_comment_favourite:\n  controller: App\\Controller\\FavouriteController\n  defaults: { entityClass: App\\Entity\\EntryComment }\n  path: /ecf/{id}\n  methods: [ POST ]\n  requirements:\n    id: \\d+\n\nentry_comment_boost:\n  controller: App\\Controller\\BoostController\n  defaults: { entityClass: App\\Entity\\EntryComment }\n  path: /ecb/{id}\n  methods: [ POST ]\n  requirements:\n    id: \\d+\n\nentry_create:\n  controller: App\\Controller\\Entry\\EntryCreateController\n  path: /new_entry\n  methods: [ GET, POST ]\n\nmagazine_entry_create:\n  controller: App\\Controller\\Entry\\EntryCreateController\n  path: /m/{name}/new_entry\n  methods: [ GET, POST ]\n\nentry_edit:\n  controller: App\\Controller\\Entry\\EntryEditController\n  defaults: { slug: -, sortBy: default }\n  path: /m/{magazine_name}/t/{entry_id}/{slug}/edit\n  methods: [ GET, POST ]\n  requirements:\n    entry_id: \\d+\n\nentry_moderate:\n  controller: App\\Controller\\Entry\\EntryModerateController\n  defaults: { slug: -, sortBy: default }\n  path: /m/{magazine_name}/t/{entry_id}/{slug}/moderate\n  methods: [ GET ]\n  requirements:\n    entry_id: \\d+\n\nentry_delete:\n  controller: App\\Controller\\Entry\\EntryDeleteController::delete\n  defaults: { slug: -, sortBy: default }\n  path: /m/{magazine_name}/t/{entry_id}/{slug}/delete\n  methods: [ POST ]\n  requirements:\n    entry_id: \\d+\n\nentry_restore:\n  controller: App\\Controller\\Entry\\EntryDeleteController::restore\n  defaults: { slug: -, sortBy: default }\n  path: /m/{magazine_name}/t/{entry_id}/{slug}/restore\n  methods: [ POST ]\n  requirements:\n    entry_id: \\d+\n\nentry_purge:\n  controller: App\\Controller\\Entry\\EntryDeleteController::purge\n  defaults: { slug: -, sortBy: default }\n  path: /m/{magazine_name}/t/{entry_id}/{slug}/purge\n  methods: [ POST ]\n  requirements:\n    entry_id: \\d+\n\nentry_image_delete:\n  controller: App\\Controller\\Entry\\EntryDeleteImageController\n  defaults: { slug: -, }\n  path: /m/{magazine_name}/e/{entry_id}/{slug}/delete_image\n  methods: [ POST ]\n  requirements:\n    entry_id: \\d+\n\nentry_change_magazine:\n  controller: App\\Controller\\Entry\\EntryChangeMagazineController\n  defaults: { slug: - }\n  path: /m/{magazine_name}/e/{entry_id}/{slug}/change_magazine\n  methods: [ POST ]\n  requirements:\n    entry_id: \\d+\n\nentry_change_lang:\n  controller: App\\Controller\\Entry\\EntryChangeLangController\n  defaults: { slug: - }\n  path: /m/{magazine_name}/e/{entry_id}/{slug}/change_lang\n  methods: [ POST ]\n  requirements:\n    entry_id: \\d+\n\nentry_change_adult:\n  controller: App\\Controller\\Entry\\EntryChangeAdultController\n  defaults: { slug: - }\n  path: /m/{magazine_name}/e/{entry_id}/{slug}/change_adult\n  methods: [ POST ]\n  requirements:\n    entry_id: \\d+\n\nentry_pin:\n  controller: App\\Controller\\Entry\\EntryPinController\n  defaults: { slug: -, sortBy: default }\n  path: /m/{magazine_name}/t/{entry_id}/{slug}/pin\n  methods: [ POST ]\n  requirements:\n    entry_id: \\d+\n\nentry_lock:\n    controller: App\\Controller\\Entry\\EntryLockController\n    defaults: { slug: -, sortBy: default }\n    path: /m/{magazine_name}/t/{entry_id}/{slug}/lock\n    methods: [ POST ]\n    requirements:\n        entry_id: \\d+\n\nentry_voters:\n  controller: App\\Controller\\Entry\\EntryVotersController\n  defaults: { slug: -, sortBy: default }\n  path: /m/{magazine_name}/t/{entry_id}/{slug}/votes/{type}\n  methods: [ GET ]\n  requirements:\n    type: 'up'\n    entry_id: \\d+\n\nentry_fav:\n  controller: App\\Controller\\Entry\\EntryFavouriteController\n  defaults: { slug: -, sortBy: default }\n  path: /m/{magazine_name}/t/{entry_id}/{slug}/favourites\n  methods: [ GET ]\n  requirements:\n    entry_id: \\d+\n\nentry_tips:\n  controller: App\\Controller\\Entry\\EntryTipController\n  defaults: { slug: -, sortBy: default }\n  path: /m/{magazine_name}/t/{entry_id}/{slug}/tips\n  methods: [ GET ]\n  requirements:\n    entry_id: \\d+\n\nentry_single:\n  controller: App\\Controller\\Entry\\EntrySingleController\n  defaults: { slug: -, sortBy: default }\n  path: /m/{magazine_name}/t/{entry_id}/{slug}/{sortBy}\n  methods: [ GET ]\n  requirements:\n    sortBy: \"%comment_sort_options%\"\n    entry_id: \\d+\n\nentry_single_comments:\n  controller: App\\Controller\\Entry\\EntrySingleController\n  defaults: { slug: -, sortBy: default }\n  path: /m/{magazine_name}/t/{entry_id}/{slug}/comments/{sortBy}\n  methods: [ GET ]\n  requirements:\n    sortBy: \"%comment_sort_options%\"\n    entry_id: \\d+\n\nentry_vote:\n  controller: App\\Controller\\VoteController\n  defaults: { entityClass: App\\Entity\\Entry }\n  path: /ev/{id}/{choice}\n  methods: [ POST ]\n  requirements:\n    id: \\d+\n\nentry_report:\n  controller: App\\Controller\\ReportController\n  defaults: { entityClass: App\\Entity\\Entry }\n  path: /er/{id}\n  methods: [ GET, POST ]\n  requirements:\n    id: \\d+\n\nentry_favourite:\n  controller: App\\Controller\\FavouriteController\n  defaults: { entityClass: App\\Entity\\Entry }\n  path: /ef/{id}\n  methods: [ POST ]\n  requirements:\n    id: \\d+\n\nentry_boost:\n  controller: App\\Controller\\BoostController\n  defaults: { entityClass: App\\Entity\\Entry }\n  path: /eb/{id}\n  methods: [ POST ]\n  requirements:\n    id: \\d+\n\nentry_crosspost:\n    controller: App\\Controller\\CrosspostController\n    defaults: { entityClass: App\\Entity\\Entry }\n    path: /crosspost/{id}\n    methods: [ GET ]\n    requirements:\n        id: \\d+\n"
  },
  {
    "path": "config/mbin_routes/entry_api.yaml",
    "content": "# Get information about a thread\napi_entry_retrieve:\n  controller: App\\Controller\\Api\\Entry\\EntriesRetrieveApi\n  path: /api/entry/{entry_id}\n  methods: [ GET ]\n  format: json\n\napi_entry_update:\n  controller: App\\Controller\\Api\\Entry\\EntriesUpdateApi\n  path: /api/entry/{entry_id}\n  methods: [ PUT ]\n  format: json\n\napi_entry_delete:\n  controller: App\\Controller\\Api\\Entry\\EntriesDeleteApi\n  path: /api/entry/{entry_id}\n  methods: [ DELETE ]\n  format: json\n\napi_entry_report:\n  controller: App\\Controller\\Api\\Entry\\EntriesReportApi\n  path: /api/entry/{entry_id}/report\n  methods: [ POST ]\n  format: json\n\napi_entry_vote:\n  controller: App\\Controller\\Api\\Entry\\EntriesVoteApi\n  path: /api/entry/{entry_id}/vote/{choice}\n  methods: [ PUT ]\n  format: json\n\napi_entry_favourite:\n  controller: App\\Controller\\Api\\Entry\\EntriesFavouriteApi\n  path: /api/entry/{entry_id}/favourite\n  methods: [ PUT ]\n  format: json\n\n# Get a list of threads from subscribed magazines\napi_entries_subscribed:\n  controller: App\\Controller\\Api\\Entry\\EntriesRetrieveApi::subscribed\n  path: /api/entries/subscribed\n  methods: [ GET ]\n  format: json\n\n# Get a list of threads from moderated magazines\napi_entries_moderated:\n  controller: App\\Controller\\Api\\Entry\\EntriesRetrieveApi::moderated\n  path: /api/entries/moderated\n  methods: [ GET ]\n  format: json\n\n# Get a list of favourited threads\napi_entries_favourited:\n  controller: App\\Controller\\Api\\Entry\\EntriesRetrieveApi::favourited\n  path: /api/entries/favourited\n  methods: [ GET ]\n  format: json\n\n# Get a list of threads from all magazines\napi_entries_collection:\n  controller: App\\Controller\\Api\\Entry\\EntriesRetrieveApi::collection\n  path: /api/entries\n  methods: [ GET ]\n  format: json\n\n# Get comments for a specific thread\napi_entry_comments:\n  controller: App\\Controller\\Api\\Entry\\Comments\\EntryCommentsRetrieveApi\n  path: /api/entry/{entry_id}/comments\n  methods: [ GET ]\n  format: json\n\n# Create a top level comment on a thread\napi_entry_comment_new:\n  controller: App\\Controller\\Api\\Entry\\Comments\\EntryCommentsCreateApi\n  path: /api/entry/{entry_id}/comments\n  methods: [ POST ]\n  format: json\n\n# Create a top level comment with uploaded image on a thread\napi_entry_comment_new_image:\n  controller: App\\Controller\\Api\\Entry\\Comments\\EntryCommentsCreateApi::uploadImage\n  path: /api/entry/{entry_id}/comments/image\n  methods: [ POST ]\n  format: json\n\n# Create a comment reply on a thread\napi_entry_comment_reply:\n  controller: App\\Controller\\Api\\Entry\\Comments\\EntryCommentsCreateApi\n  path: /api/entry/{entry_id}/comments/{comment_id}/reply\n  methods: [ POST ]\n  format: json\n\n# Create a comment reply with uploaded image on a thread\napi_entry_comment_reply_image:\n  controller: App\\Controller\\Api\\Entry\\Comments\\EntryCommentsCreateApi::uploadImage\n  path: /api/entry/{entry_id}/comments/{comment_id}/reply/image\n  methods: [ POST ]\n  format: json\n\n# Retrieve a comment\napi_comment_retrieve:\n  controller: App\\Controller\\Api\\Entry\\Comments\\EntryCommentsRetrieveApi::single\n  path: /api/comments/{comment_id}\n  methods: [ GET ]\n  format: json\n\n# Update a comment\napi_comment_update:\n  controller: App\\Controller\\Api\\Entry\\Comments\\EntryCommentsUpdateApi\n  path: /api/comments/{comment_id}\n  methods: [ PUT ]\n  format: json\n\n# Delete a comment\napi_comment_delete:\n  controller: App\\Controller\\Api\\Entry\\Comments\\EntryCommentsDeleteApi\n  path: /api/comments/{comment_id}\n  methods: [ DELETE ]\n  format: json\n\napi_comment_report:\n  controller: App\\Controller\\Api\\Entry\\Comments\\EntryCommentsReportApi\n  path: /api/comments/{comment_id}/report\n  methods: [ POST ]\n  format: json\n\n# Vote on a comment\napi_comment_vote:\n  controller: App\\Controller\\Api\\Entry\\Comments\\EntryCommentsVoteApi\n  path: /api/comments/{comment_id}/vote/{choice}\n  methods: [ PUT ]\n  format: json\n\n# Favourite a comment\napi_comment_favourite:\n  controller: App\\Controller\\Api\\Entry\\Comments\\EntryCommentsFavouriteApi\n  path: /api/comments/{comment_id}/favourite\n  methods: [ PUT ]\n  format: json\n\n# boosts and upvotes for entries\napi_entry_activity:\n  controller: App\\Controller\\Api\\Entry\\EntriesActivityApi\n  path: /api/entry/{entry_id}/activity\n  methods: [ GET ]\n  format: json\n\n# boosts and upvotes entry comments\napi_entry_comment_activity:\n  controller: App\\Controller\\Api\\Entry\\Comments\\EntryCommentsActivityApi\n  path: /api/comments/{comment_id}/activity\n  methods: [ GET ]\n  format: json\n"
  },
  {
    "path": "config/mbin_routes/front.yaml",
    "content": "front:\n  controller: App\\Controller\\Entry\\EntryFrontController::front\n  defaults: &front_defaults { subscription: home, content: default, sortBy: default, time: '∞', federation: all }\n  path: /{subscription}/{content}/{sortBy}/{time}/{federation}\n  methods: [GET]\n  requirements: &front_requirement\n    subscription: \"%default_subscription_options%\"\n    sortBy: \"%default_sort_options%\"\n    time: \"%default_time_options%\"\n    federation: \"%default_federation_options%\"\n    content: \"%default_content_options%\"\n\nfront_sub:\n  controller: App\\Controller\\Entry\\EntryFrontController::front\n  defaults: *front_defaults\n  path: /{subscription}/{sortBy}/{time}/{federation}\n  methods: [GET]\n  requirements: *front_requirement\n\nfront_content:\n  controller: App\\Controller\\Entry\\EntryFrontController::front\n  defaults: *front_defaults\n  path: /{content}/{sortBy}/{time}/{federation}\n  methods: [GET]\n  requirements: *front_requirement\n\nfront_short:\n  controller: App\\Controller\\Entry\\EntryFrontController::front\n  defaults: *front_defaults\n  path: /{sortBy}/{time}/{federation}\n  methods: [GET]\n  requirements: *front_requirement\n\nfront_magazine:\n  controller: App\\Controller\\Entry\\EntryFrontController::magazine\n  defaults: &front_magazine_defaults { content: default, sortBy: default, time: '∞', federation: all }\n  path: /m/{name}/{content}/{sortBy}/{time}/{federation}\n  methods: [GET]\n  requirements: *front_requirement\n\nfront_magazine_short:\n  controller: App\\Controller\\Entry\\EntryFrontController::magazine\n  defaults: *front_magazine_defaults\n  path: /m/{name}/{sortBy}/{time}/{federation}\n  methods: [GET]\n  requirements: *front_requirement\n\n# Microblog compatibility stuff, redirects from the old routes' URLs\n\nposts_front:\n  controller: App\\Controller\\Entry\\EntryFrontController::frontRedirect\n  defaults: { sortBy: default, time: '∞', federation: all, content: microblog }\n  path: /microblog/{sortBy}/{time}\n  methods: [ GET ]\n  requirements:\n    sortBy: \"%default_sort_options%\"\n    time: \"%default_time_options%\"\n\nposts_subscribed:\n  controller: App\\Controller\\Entry\\EntryFrontController::frontRedirect\n  defaults: { sortBy: default, time: '∞', federation: all, content: microblog, subscription: 'sub' }\n  path: /sub/microblog/{sortBy}/{time}\n  methods: [ GET ]\n  requirements:\n    sortBy: \"%default_sort_options%\"\n    time: \"%default_time_options%\"\n\nposts_moderated:\n  controller: App\\Controller\\Entry\\EntryFrontController::frontRedirect\n  defaults: { sortBy: default, time: '∞', federation: all, content: microblog, subscription: 'mod' }\n  path: /mod/microblog/{sortBy}/{time}\n  methods: [ GET ]\n  requirements:\n    sortBy: \"%default_sort_options%\"\n    time: \"%default_time_options%\"\n\nposts_favourite:\n  controller: App\\Controller\\Entry\\EntryFrontController::frontRedirect\n  defaults: { sortBy: default, time: '∞', federation: all, content: microblog, subscription: 'fav' }\n  path: /fav/microblog/{sortBy}/{time}\n  methods: [ GET ]\n  requirements:\n    sortBy: \"%default_sort_options%\"\n    time: \"%default_time_options%\"\n\nmagazine_posts:\n  controller: App\\Controller\\Entry\\EntryFrontController::magazineRedirect\n  defaults: { sortBy: default, time: '∞', federation: all, content: microblog }\n  path: /m/{name}/microblog/{sortBy}/{time}/{federation}\n  methods: [ GET ]\n  requirements:\n    sortBy: \"%default_sort_options%\"\n    time: \"%default_time_options%\"\n    federation: \"%default_federation_options%\"\n"
  },
  {
    "path": "config/mbin_routes/instance_api.yaml",
    "content": "api_instance_details_retrieve:\n  controller: App\\Controller\\Api\\Instance\\InstanceDetailsApi\n  path: /api/instance\n  methods: [ GET ]\n  format: json\n\napi_remote_instance_details_retrieve:\n  controller: App\\Controller\\Api\\Instance\\InstanceDetailsApi::retrieveRemoteInstanceDetails\n  path: /api/remoteInstance/{domain}\n  methods: [ GET ]\n  format: json\n\napi_instance_modlog_retrieve:\n  controller: App\\Controller\\Api\\Instance\\InstanceModLogApi::collection\n  path: /api/modlog\n  methods: [ GET ]\n  format: json\n\napi_instance_retrieve_votes:\n  controller: App\\Controller\\Api\\Instance\\InstanceRetrieveStatsApi::votes\n  path: /api/stats/votes\n  methods: [ GET ]\n  format: json\n\napi_instance_retrieve_content:\n  controller: App\\Controller\\Api\\Instance\\InstanceRetrieveStatsApi::content\n  path: /api/stats/content\n  methods: [ GET ]\n  format: json\n\napi_instance_retrieve_defederated_instances:\n  controller: App\\Controller\\Api\\Instance\\InstanceRetrieveFederationApi::getDeFederated\n  path: /api/defederated\n  methods: [ GET ]\n  format: json\n\napi_instance_retrieve_defederated_instances_v2:\n    controller: App\\Controller\\Api\\Instance\\InstanceRetrieveFederationApi::getDeFederatedV2\n    path: /api/defederated/v2\n    methods: [ GET ]\n    format: json\n\napi_instance_retrieve_federated_instances:\n    controller: App\\Controller\\Api\\Instance\\InstanceRetrieveFederationApi::getFederated\n    path: /api/federated\n    methods: [ GET ]\n    format: json\n\napi_instance_retrieve_dead_instances:\n    controller: App\\Controller\\Api\\Instance\\InstanceRetrieveFederationApi::getDead\n    path: /api/dead\n    methods: [ GET ]\n    format: json\n\napi_instance_retrieve_info:\n  controller: App\\Controller\\Api\\Instance\\InstanceRetrieveInfoApi\n  path: /api/info\n  methods: [ GET ]\n  format: json\n"
  },
  {
    "path": "config/mbin_routes/landing.yaml",
    "content": "about:\n  controller: App\\Controller\\AboutController\n  path: /about\n  methods: [ GET ]\n\nagent:\n  controller: App\\Controller\\AgentController\n  path: /agent\n  methods: [ GET ]\n"
  },
  {
    "path": "config/mbin_routes/magazine.yaml",
    "content": "magazine_create:\n    controller: App\\Controller\\Magazine\\MagazineCreateController\n    path: /newMagazine\n    methods: [GET, POST]\n\nmagazine_delete:\n    controller: App\\Controller\\Magazine\\MagazineDeleteController::delete\n    path: /m/{name}/delete\n    methods: [POST]\n\nmagazine_restore:\n    controller: App\\Controller\\Magazine\\MagazineDeleteController::restore\n    path: /m/{name}/restore\n    methods: [POST]\n\nmagazine_purge:\n    controller: App\\Controller\\Magazine\\MagazineDeleteController::purge\n    path: /m/{name}/purge\n    methods: [POST]\n\nmagazine_abandoned:\n    controller: App\\Controller\\Magazine\\MagazineAbandonedController\n    path: /magazines/abandoned\n    methods: [GET]\n\nmagazine_purge_content:\n    controller: App\\Controller\\Magazine\\MagazineDeleteController::purgeContent\n    path: /m/{name}/purge_content\n    methods: [POST]\n\nmagazine_list_all:\n    controller: App\\Controller\\Magazine\\MagazineListController\n    defaults: { sortBy: default, view: table }\n    path: /magazines/{sortBy}/{view}\n    methods: [GET]\n\nmagazine_moderators:\n    controller: App\\Controller\\Magazine\\MagazineModController\n    path: /m/{name}/moderators\n    methods: [GET]\n\nmagazine_modlog:\n    controller: App\\Controller\\ModlogController::magazine\n    path: /m/{name}/modlog\n    methods: [GET]\n\nmagazine_people:\n    controller: App\\Controller\\Magazine\\MagazinePeopleFrontController\n    path: /m/{name}/people\n    methods: [GET]\n\nmagazine_subscribe:\n    controller: App\\Controller\\Magazine\\MagazineSubController::subscribe\n    path: /m/{name}/subscribe\n    methods: [POST]\n\nmagazine_unsubscribe:\n    controller: App\\Controller\\Magazine\\MagazineSubController::unsubscribe\n    path: /m/{name}/unsubscribe\n    methods: [POST]\n\nmagazine_block:\n    controller: App\\Controller\\Magazine\\MagazineBlockController::block\n    path: /m/{name}/block\n    methods: [POST]\n\nmagazine_unblock:\n    controller: App\\Controller\\Magazine\\MagazineBlockController::unblock\n    path: /m/{name}/unblock\n    methods: [POST]\n\nmagazine_remove_subscriptions:\n    controller: App\\Controller\\Magazine\\MagazineRemoveSubscriptionsController\n    path: /m/{name}/remove_subscriptions\n    methods: [POST]\n\nmagazine_moderator_request:\n    controller: App\\Controller\\Magazine\\MagazineModeratorRequestController\n    path: /m/{name}/moderator_request\n    methods: [POST]\n\nmagazine_ownership_request:\n    controller: App\\Controller\\Magazine\\MagazineOwnershipRequestController::toggle\n    path: /m/{name}/ownership_request\n    methods: [POST]\n"
  },
  {
    "path": "config/mbin_routes/magazine_api.yaml",
    "content": "# Create an article entry in a magazine\napi_magazine_entry_create_article:\n  controller: App\\Controller\\Api\\Entry\\MagazineEntryCreateApi::article\n  path: /api/magazine/{magazine_id}/article\n  methods: [ POST ]\n  format: json\n\n# Create a link entry in a magazine\napi_magazine_entry_create_link:\n  controller: App\\Controller\\Api\\Entry\\MagazineEntryCreateApi::link\n  path: /api/magazine/{magazine_id}/link\n  methods: [ POST ]\n  format: json\n\n# Create an image entry in a magazine\napi_magazine_entry_create_image:\n  controller: App\\Controller\\Api\\Entry\\MagazineEntryCreateApi::uploadImage\n  path: /api/magazine/{magazine_id}/image\n  methods: [ POST ]\n  format: json\n\n# Create an image entry in a magazine\napi_magazine_entry_create:\n  controller: App\\Controller\\Api\\Entry\\MagazineEntryCreateApi::entry\n  path: /api/magazine/{magazine_id}/entries\n  methods: [ POST ]\n  format: json\n\n# # Create a video entry in a magazine (videos not yet implemented)\n# api_magazine_entry_create_video:\n#   controller: App\\Controller\\Api\\Entry\\MagazineEntryCreateApi::video\n#   path: /api/magazine/{magazine_id}/entry/new/video\n#   methods: [ POST ]\n#   format: json\n\n# Create post in magazine\napi_magazine_posts_create:\n  controller: App\\Controller\\Api\\Post\\PostsCreateApi\n  path: /api/magazine/{magazine_id}/posts\n  methods: [ POST ]\n  format: json\n\n# Create post with image in magazine\napi_magazine_posts_create_image:\n  controller: App\\Controller\\Api\\Post\\PostsCreateApi::uploadImage\n  path: /api/magazine/{magazine_id}/posts/image\n  methods: [ POST ]\n  format: json\n\n# Get a list of threads from specific magazine\napi_magazine_entries_retrieve:\n  controller: App\\Controller\\Api\\Entry\\MagazineEntriesRetrieveApi\n  path: /api/magazine/{magazine_id}/entries\n  methods: [ GET ]\n  format: json\n\n# Get list of posts in a magazine\napi_magazine_posts_retrieve:\n  controller: App\\Controller\\Api\\Post\\PostsRetrieveApi::byMagazine\n  path: /api/magazine/{magazine_id}/posts\n  methods: [ GET ]\n  format: json\n\n# Get list of magazines in instance\napi_magazines_retrieve:\n  controller: App\\Controller\\Api\\Magazine\\MagazineRetrieveApi::collection\n  path: /api/magazines\n  methods: [ GET ]\n  format: json\n\n# Get subscribed magazines for the current user\napi_magazines_retrieve_subscribed:\n  controller: App\\Controller\\Api\\Magazine\\MagazineRetrieveApi::subscribed\n  path: /api/magazines/subscribed\n  methods: [ GET ]\n  format: json\n\n# Get moderated magazines for the current user\napi_magazines_retrieve_moderated:\n  controller: App\\Controller\\Api\\Magazine\\MagazineRetrieveApi::moderated\n  path: /api/magazines/moderated\n  methods: [ GET ]\n  format: json\n\n# Get blocked magazines for the current user\napi_magazines_retrieve_blocked:\n  controller: App\\Controller\\Api\\Magazine\\MagazineRetrieveApi::blocked\n  path: /api/magazines/blocked\n  methods: [ GET ]\n  format: json\n\n# Get magazine info\napi_magazine_retrieve:\n  controller: App\\Controller\\Api\\Magazine\\MagazineRetrieveApi\n  path: /api/magazine/{magazine_id}\n  methods: [ GET ]\n  format: json\n\n# Get magazine info by name\napi_magazine_retrieve_by_name:\n  controller: App\\Controller\\Api\\Magazine\\MagazineRetrieveApi::byName\n  path: /api/magazine/name/{magazine_name}\n  methods: [ GET ]\n  format: json\n\napi_magazine_block:\n  controller: App\\Controller\\Api\\Magazine\\MagazineBlockApi::block\n  path: /api/magazine/{magazine_id}/block\n  methods: [ PUT ]\n  format: json\n\napi_magazine_unblock:\n  controller: App\\Controller\\Api\\Magazine\\MagazineBlockApi::unblock\n  path: /api/magazine/{magazine_id}/unblock\n  methods: [ PUT ]\n  format: json\n\napi_magazine_subscribe:\n  controller: App\\Controller\\Api\\Magazine\\MagazineSubscribeApi::subscribe\n  path: /api/magazine/{magazine_id}/subscribe\n  methods: [ PUT ]\n  format: json\n\napi_magazine_unsubscribe:\n  controller: App\\Controller\\Api\\Magazine\\MagazineSubscribeApi::unsubscribe\n  path: /api/magazine/{magazine_id}/unsubscribe\n  methods: [ PUT ]\n  format: json\n\napi_magazine_create:\n  controller: App\\Controller\\Api\\Magazine\\Admin\\MagazineCreateApi\n  path: /api/moderate/magazine/new\n  methods: [ POST ]\n  format: json\n\napi_magazine_update:\n  controller: App\\Controller\\Api\\Magazine\\Admin\\MagazineUpdateApi\n  path: /api/moderate/magazine/{magazine_id}\n  methods: [ PUT ]\n  format: json\n\napi_magazine_delete:\n  controller: App\\Controller\\Api\\Magazine\\Admin\\MagazineDeleteApi\n  path: /api/moderate/magazine/{magazine_id}\n  methods: [ DELETE ]\n  format: json\n\napi_magazine_theme:\n  controller: App\\Controller\\Api\\Magazine\\MagazineRetrieveThemeApi\n  path: /api/magazine/{magazine_id}/theme\n  methods: [ GET ]\n  format: json\n\napi_magazine_modlog:\n  controller: App\\Controller\\Api\\Magazine\\MagazineModLogApi::collection\n  path: /api/magazine/{magazine_id}/log\n  methods: [ GET ]\n  format: json\n"
  },
  {
    "path": "config/mbin_routes/magazine_mod_request_api.yaml",
    "content": "api_magazine_modrequest_toggle:\n    controller: App\\Controller\\Api\\Magazine\\Moderate\\MagazineModOwnerRequestApi::toggleModRequest\n    path: /api/moderate/magazine/{magazine_id}/modRequest/toggle\n    methods: [ PUT ]\n    format: json\n\napi_magazine_modrequest_accept:\n    controller: App\\Controller\\Api\\Magazine\\Moderate\\MagazineModOwnerRequestApi::acceptModRequest\n    path: /api/moderate/magazine/{magazine_id}/modRequest/accept/{user_id}\n    methods: [ PUT ]\n    format: json\n\napi_magazine_modrequest_reject:\n    controller: App\\Controller\\Api\\Magazine\\Moderate\\MagazineModOwnerRequestApi::rejectModRequest\n    path: /api/moderate/magazine/{magazine_id}/modRequest/reject/{user_id}\n    methods: [ PUT ]\n    format: json\n\napi_magazine_modrequest_list:\n    controller: App\\Controller\\Api\\Magazine\\Moderate\\MagazineModOwnerRequestApi::getModRequests\n    path: /api/moderate/modRequest/list\n    methods: [ GET ]\n    format: json\n\napi_magazine_ownerrequest_toggle:\n    controller: App\\Controller\\Api\\Magazine\\Moderate\\MagazineModOwnerRequestApi::toggleOwnerRequest\n    path: /api/moderate/magazine/{magazine_id}/ownerRequest/toggle\n    methods: [ PUT ]\n    format: json\n\napi_magazine_ownerrequest_accept:\n    controller: App\\Controller\\Api\\Magazine\\Moderate\\MagazineModOwnerRequestApi::acceptOwnerRequest\n    path: /api/moderate/magazine/{magazine_id}/ownerRequest/accept/{user_id}\n    methods: [ PUT ]\n    format: json\n\napi_magazine_ownerrequest_reject:\n    controller: App\\Controller\\Api\\Magazine\\Moderate\\MagazineModOwnerRequestApi::rejectOwnerRequest\n    path: /api/moderate/magazine/{magazine_id}/ownerRequest/reject/{user_id}\n    methods: [ PUT ]\n    format: json\n\napi_magazine_ownerrequest_list:\n    controller: App\\Controller\\Api\\Magazine\\Moderate\\MagazineModOwnerRequestApi::getOwnerRequests\n    path: /api/moderate/ownerRequest/list\n    methods: [ GET ]\n    format: json\n"
  },
  {
    "path": "config/mbin_routes/magazine_panel.yaml",
    "content": "magazine_panel_bans:\n  controller: App\\Controller\\Magazine\\Panel\\MagazineBanController::bans\n  path: /m/{name}/panel/bans\n  methods: [ GET, POST ]\n\nmagazine_panel_ban:\n  controller: App\\Controller\\Magazine\\Panel\\MagazineBanController::ban\n  defaults: { username: ~ }\n  path: /m/{name}/panel/ban/{username}\n  methods: [ GET, POST ]\n\nmagazine_panel_unban:\n  controller: App\\Controller\\Magazine\\Panel\\MagazineBanController::unban\n  path: /m/{name}/panel/unban/{username}\n  methods: [ POST ]\n\nmagazine_panel_general:\n  controller: App\\Controller\\Magazine\\Panel\\MagazineEditController\n  path: /m/{name}/panel/general\n  methods: [ GET, POST ]\n\nmagazine_panel_moderators:\n  controller: App\\Controller\\Magazine\\Panel\\MagazineModeratorController::moderators\n  path: /m/{name}/panel/moderators\n  methods: [ GET, POST ]\n\nmagazine_panel_moderator_purge:\n  controller: App\\Controller\\Magazine\\Panel\\MagazineModeratorController::remove\n  path: /m/{magazine_name}/panel/{moderator_id}/purge\n  methods: [ POST ]\n\nmagazine_panel_reports:\n  controller: App\\Controller\\Magazine\\Panel\\MagazineReportController::reports\n  path: /m/{name}/panel/reports/{status}\n  defaults: { status: !php/const \\App\\Entity\\Report::STATUS_ANY }\n  methods: [ GET ]\n\nmagazine_panel_report_approve:\n  controller: App\\Controller\\Magazine\\Panel\\MagazineReportController::reportApprove\n  path: /m/{magazine_name}/panel/reports/{report_id}/approve\n  methods: [ POST ]\n\nmagazine_panel_report_reject:\n  controller: App\\Controller\\Magazine\\Panel\\MagazineReportController::reportReject\n  path: /m/{magazine_name}/panel/reports/{report_id}/reject\n  methods: [ POST ]\n\nmagazine_panel_theme:\n  controller: App\\Controller\\Magazine\\Panel\\MagazineThemeController\n  path: /m/{name}/panel/appearance\n  methods: [ GET, POST ]\n\nmagazine_panel_theme_detach_icon:\n    controller: App\\Controller\\Magazine\\Panel\\MagazineThemeController::detachIcon\n    path: /m/{name}/panel/appearance/detachIcon\n    methods: [ POST ]\n\nmagazine_panel_theme_detach_banner:\n    controller: App\\Controller\\Magazine\\Panel\\MagazineThemeController::detachBanner\n    path: /m/{name}/panel/appearance/detachBanner\n    methods: [ POST ]\n\nmagazine_panel_badges:\n  controller: App\\Controller\\Magazine\\Panel\\MagazineBadgeController::badges\n  path: /m/{name}/panel/badges\n  methods: [ GET, POST ]\n\nmagazine_panel_badge_remove:\n  controller: App\\Controller\\Magazine\\Panel\\MagazineBadgeController::remove\n  path: /m/{magazine_name}/panel/badges/{badge_id}/purge\n  methods: [ POST ]\n\nmagazine_panel_tags:\n  controller: App\\Controller\\Magazine\\Panel\\MagazineTagController\n  path: /m/{name}/panel/tags\n  methods: [ GET, POST ]\n\nmagazine_panel_trash:\n  controller: App\\Controller\\Magazine\\Panel\\MagazineTrashController\n  path: /m/{name}/panel/trash\n  methods: [ GET ]\n\nmagazine_panel_stats:\n  controller: App\\Controller\\Magazine\\Panel\\MagazineStatsController\n  defaults: { statsType: content, statsPeriod: 31, withFederated: false }\n  path: /m/{name}/panel/stats/{statsType}/{statsPeriod}/{withFederated}\n  methods: [ GET ]\n\nmagazine_panel_moderator_requests:\n  controller: App\\Controller\\Magazine\\Panel\\MagazineModeratorRequestsController::requests\n  path: /m/{name}/panel/moderator_requests\n  methods: [ GET ]\n\nmagazine_panel_moderator_request_accept:\n  controller: App\\Controller\\Magazine\\Panel\\MagazineModeratorRequestsController::accept\n  path: /m/{name}/moderator_requests/{username}/accept\n  methods: [ POST ]\n\nmagazine_panel_moderator_request_reject:\n  controller: App\\Controller\\Magazine\\Panel\\MagazineModeratorRequestsController::reject\n  path: /m/{name}/panel/moderator_requests/{username}/reject\n  methods: [ POST ]\n\n"
  },
  {
    "path": "config/mbin_routes/message.yaml",
    "content": "messages_front:\n  controller: App\\Controller\\Message\\MessageThreadListController\n  path: /profile/messages\n  methods: [ GET ]\n\nmessages_single:\n  controller: App\\Controller\\Message\\MessageThreadController\n  path: /profile/messages/{id}\n  methods: [ GET, POST ]\n  requirements:\n    id: \\d+\n\nmessages_create:\n  controller: App\\Controller\\Message\\MessageCreateThreadController\n  path: /u/{username}/message\n  methods: [ GET, POST ]\n"
  },
  {
    "path": "config/mbin_routes/message_api.yaml",
    "content": "# Get a specific message\napi_message_retrieve:\n  controller: App\\Controller\\Api\\Message\\MessageRetrieveApi\n  path: /api/messages/{message_id}\n  methods: [ GET ]\n  format: json\n\n# Mark message as read\napi_message_read:\n  controller: App\\Controller\\Api\\Message\\MessageReadApi::read\n  path: /api/messages/{message_id}/read\n  methods: [ PUT ]\n  format: json\n\n# Mark message as not read\napi_message_unread:\n  controller: App\\Controller\\Api\\Message\\MessageReadApi::unread\n  path: /api/messages/{message_id}/unread\n  methods: [ PUT ]\n  format: json\n\n# Retrieve current user's message threads\napi_message_retrieve_threads:\n  controller: App\\Controller\\Api\\Message\\MessageRetrieveApi::collection\n  path: /api/messages\n  methods: [ GET ]\n  format: json\n\n# Create a reply to a thread\napi_message_create_reply:\n  controller: App\\Controller\\Api\\Message\\MessageThreadReplyApi\n  path: /api/messages/thread/{thread_id}/reply\n  methods: [ POST ]\n  format: json\n\n# Retrieve messages from a thread\napi_message_retrieve_thread:\n  controller: App\\Controller\\Api\\Message\\MessageRetrieveApi::thread\n  defaults: { sort: newest }\n  path: /api/messages/thread/{thread_id}/{sort}\n  methods: [ GET ]\n  format: json\n\n# Create a thread with a user\napi_message_create_thread:\n  controller: App\\Controller\\Api\\Message\\MessageThreadCreateApi\n  path: /api/users/{user_id}/message\n  methods: [ POST ]\n  format: json"
  },
  {
    "path": "config/mbin_routes/moderation_api.yaml",
    "content": "api_moderate_entry_toggle_pin:\n  controller: App\\Controller\\Api\\Entry\\Moderate\\EntriesPinApi\n  path: /api/moderate/entry/{entry_id}/pin\n  methods: [ PUT ]\n  format: json\n\napi_moderate_entry_toggle_lock:\n  controller: App\\Controller\\Api\\Entry\\Moderate\\EntriesLockApi\n  path: /api/moderate/entry/{entry_id}/lock\n  methods: [ PUT ]\n  format: json\n\napi_moderate_entry_trash:\n  controller: App\\Controller\\Api\\Entry\\Moderate\\EntriesTrashApi::trash\n  path: /api/moderate/entry/{entry_id}/trash\n  methods: [ PUT ]\n  format: json\n\napi_moderate_entry_restore:\n  controller: App\\Controller\\Api\\Entry\\Moderate\\EntriesTrashApi::restore\n  path: /api/moderate/entry/{entry_id}/restore\n  methods: [ PUT ]\n  format: json\n\napi_moderate_entry_set_adult:\n  controller: App\\Controller\\Api\\Entry\\Moderate\\EntriesSetAdultApi\n  defaults: { adult: true }\n  path: /api/moderate/entry/{entry_id}/adult/{adult}\n  methods: [ PUT ]\n  format: json\n\napi_moderate_entry_set_lang:\n  controller: App\\Controller\\Api\\Entry\\Moderate\\EntriesSetLanguageApi\n  path: /api/moderate/entry/{entry_id}/{lang}\n  methods: [ PUT ]\n  format: json\n\napi_moderate_comment_trash:\n  controller: App\\Controller\\Api\\Entry\\Comments\\Moderate\\EntryCommentsTrashApi::trash\n  path: /api/moderate/comment/{comment_id}/trash\n  methods: [ PUT ]\n  format: json\n\napi_moderate_comment_restore:\n  controller: App\\Controller\\Api\\Entry\\Comments\\Moderate\\EntryCommentsTrashApi::restore\n  path: /api/moderate/comment/{comment_id}/restore\n  methods: [ PUT ]\n  format: json\n\napi_moderate_comment_set_adult:\n  controller: App\\Controller\\Api\\Entry\\Comments\\Moderate\\EntryCommentsSetAdultApi\n  defaults: { adult: true }\n  path: /api/moderate/comment/{comment_id}/adult/{adult}\n  methods: [ PUT ]\n  format: json\n\napi_moderate_comment_set_lang:\n  controller: App\\Controller\\Api\\Entry\\Comments\\Moderate\\EntryCommentsSetLanguageApi\n  path: /api/moderate/comment/{comment_id}/{lang}\n  methods: [ PUT ]\n  format: json\n\napi_moderate_post_toggle_pin:\n  controller: App\\Controller\\Api\\Post\\Moderate\\PostsPinApi\n  path: /api/moderate/post/{post_id}/pin\n  methods: [ PUT ]\n  format: json\n\napi_moderate_post_toggle_lock:\n    controller: App\\Controller\\Api\\Post\\Moderate\\PostsLockApi\n    path: /api/moderate/post/{post_id}/lock\n    methods: [ PUT ]\n    format: json\n\napi_moderate_post_trash:\n  controller: App\\Controller\\Api\\Post\\Moderate\\PostsTrashApi::trash\n  path: /api/moderate/post/{post_id}/trash\n  methods: [ PUT ]\n  format: json\n\napi_moderate_post_restore:\n  controller: App\\Controller\\Api\\Post\\Moderate\\PostsTrashApi::restore\n  path: /api/moderate/post/{post_id}/restore\n  methods: [ PUT ]\n  format: json\n\napi_moderate_post_set_adult:\n  controller: App\\Controller\\Api\\Post\\Moderate\\PostsSetAdultApi\n  path: /api/moderate/post/{post_id}/adult/{adult}\n  methods: [ PUT ]\n  format: json\n\napi_moderate_post_set_lang:\n  controller: App\\Controller\\Api\\Post\\Moderate\\PostsSetLanguageApi\n  path: /api/moderate/post/{post_id}/{lang}\n  methods: [ PUT ]\n  format: json\n\napi_moderate_post_comment_trash:\n  controller: App\\Controller\\Api\\Post\\Comments\\Moderate\\PostCommentsTrashApi::trash\n  path: /api/moderate/post-comment/{comment_id}/trash\n  methods: [ PUT ]\n  format: json\n\napi_moderate_post_comment_restore:\n  controller: App\\Controller\\Api\\Post\\Comments\\Moderate\\PostCommentsTrashApi::restore\n  path: /api/moderate/post-comment/{comment_id}/restore\n  methods: [ PUT ]\n  format: json\n\napi_moderate_post_comment_set_adult:\n  controller: App\\Controller\\Api\\Post\\Comments\\Moderate\\PostCommentsSetAdultApi\n  path: /api/moderate/post-comment/{comment_id}/adult/{adult}\n  methods: [ PUT ]\n  format: json\n\napi_moderate_post_comment_set_lang:\n  controller: App\\Controller\\Api\\Post\\Comments\\Moderate\\PostCommentsSetLanguageApi\n  path: /api/moderate/post-comment/{comment_id}/{lang}\n  methods: [ PUT ]\n  format: json\n\napi_moderate_magazine_ban_user:\n  controller: App\\Controller\\Api\\Magazine\\Moderate\\MagazineUserBanApi::ban\n  path: /api/moderate/magazine/{magazine_id}/ban/{user_id}\n  methods: [ POST ]\n  format: json\n\napi_moderate_magazine_unban_user:\n  controller: App\\Controller\\Api\\Magazine\\Moderate\\MagazineUserBanApi::unban\n  path: /api/moderate/magazine/{magazine_id}/ban/{user_id}\n  methods: [ DELETE ]\n  format: json\n\napi_moderate_magazine_mod_user:\n  controller: App\\Controller\\Api\\Magazine\\Admin\\MagazineAddModeratorsApi\n  path: /api/moderate/magazine/{magazine_id}/mod/{user_id}\n  methods: [ POST ]\n  format: json\n\napi_moderate_magazine_unmod_user:\n  controller: App\\Controller\\Api\\Magazine\\Admin\\MagazineRemoveModeratorsApi\n  path: /api/moderate/magazine/{magazine_id}/mod/{user_id}\n  methods: [ DELETE ]\n  format: json\n\napi_moderate_magazine_add_badge:\n  controller: App\\Controller\\Api\\Magazine\\Admin\\MagazineAddBadgesApi\n  path: /api/moderate/magazine/{magazine_id}/badge\n  methods: [ POST ]\n  format: json\n\napi_moderate_magazine_remove_badge:\n  controller: App\\Controller\\Api\\Magazine\\Admin\\MagazineRemoveBadgesApi\n  path: /api/moderate/magazine/{magazine_id}/badge/{badge_id}\n  methods: [ DELETE ]\n  format: json\n\napi_moderate_magazine_add_tag:\n  controller: App\\Controller\\Api\\Magazine\\Admin\\MagazineAddTagsApi\n  path: /api/moderate/magazine/{magazine_id}/tag/{tag}\n  methods: [ POST ]\n  format: json\n\napi_moderate_magazine_remove_tag:\n  controller: App\\Controller\\Api\\Magazine\\Admin\\MagazineRemoveTagsApi\n  path: /api/moderate/magazine/{magazine_id}/tag/{tag}\n  methods: [ DELETE ]\n  format: json\n\napi_moderate_magazine_retrieve_report:\n  controller: App\\Controller\\Api\\Magazine\\Moderate\\MagazineReportsRetrieveApi\n  path: /api/moderate/magazine/{magazine_id}/reports/{report_id}\n  methods: [ GET ]\n  format: json\n\napi_moderate_magazine_retrieve_reports:\n  controller: App\\Controller\\Api\\Magazine\\Moderate\\MagazineReportsRetrieveApi::collection\n  path: /api/moderate/magazine/{magazine_id}/reports\n  methods: [ GET ]\n  format: json\n\napi_moderate_magazine_accept_report:\n  controller: App\\Controller\\Api\\Magazine\\Moderate\\MagazineReportsAcceptApi\n  path: /api/moderate/magazine/{magazine_id}/reports/{report_id}/accept\n  methods: [ POST ]\n  format: json\n\napi_moderate_magazine_reject_report:\n  controller: App\\Controller\\Api\\Magazine\\Moderate\\MagazineReportsRejectApi\n  path: /api/moderate/magazine/{magazine_id}/reports/{report_id}/reject\n  methods: [ POST ]\n  format: json\n\napi_moderate_magazine_retrieve_bans:\n  controller: App\\Controller\\Api\\Magazine\\Moderate\\MagazineBansRetrieveApi::collection\n  path: /api/moderate/magazine/{magazine_id}/bans\n  methods: [ GET ]\n  format: json\n\napi_moderate_magazine_retrieve_trash:\n  controller: App\\Controller\\Api\\Magazine\\Moderate\\MagazineTrashedRetrieveApi::collection\n  path: /api/moderate/magazine/{magazine_id}/trash\n  methods: [ GET ]\n  format: json\n\napi_moderate_magazine_set_theme:\n  controller: App\\Controller\\Api\\Magazine\\Admin\\MagazineUpdateThemeApi\n  path: /api/moderate/magazine/{magazine_id}/theme\n  methods: [ POST ]\n  format: json\n\napi_moderate_magazine_set_banner:\n    controller: App\\Controller\\Api\\Magazine\\Admin\\MagazineUpdateThemeApi::banner\n    path: /api/moderate/magazine/{magazine_id}/banner\n    methods: [ PUT ]\n    format: json\n\napi_moderate_magazine_delete_icon:\n  controller: App\\Controller\\Api\\Magazine\\Admin\\MagazineDeleteIconApi\n  path: /api/moderate/magazine/{magazine_id}/icon\n  methods: [ DELETE ]\n  format: json\n\napi_moderate_magazine_retrieve_votes:\n  controller: App\\Controller\\Api\\Magazine\\Admin\\MagazineRetrieveStatsApi::votes\n  path: /api/stats/magazine/{magazine_id}/votes\n  methods: [ GET ]\n  format: json\n\napi_moderate_magazine_retrieve_submissions:\n  controller: App\\Controller\\Api\\Magazine\\Admin\\MagazineRetrieveStatsApi::content\n  path: /api/stats/magazine/{magazine_id}/content\n  methods: [ GET ]\n  format: json\n"
  },
  {
    "path": "config/mbin_routes/modlog.yaml",
    "content": "modlog:\n  controller: App\\Controller\\ModlogController::instance\n  path: /modlog\n  methods: [ GET ]\n"
  },
  {
    "path": "config/mbin_routes/notification_api.yaml",
    "content": "api_notification_read:\n  controller: App\\Controller\\Api\\Notification\\NotificationReadApi::read\n  path: /api/notifications/{notification_id}/read\n  methods: [ PUT ]\n  format: json\n\napi_notification_read_all:\n  controller: App\\Controller\\Api\\Notification\\NotificationReadApi::readAll\n  path: /api/notifications/read\n  methods: [ PUT ]\n  format: json\n\napi_notification_unread:\n  controller: App\\Controller\\Api\\Notification\\NotificationReadApi::unread\n  path: /api/notifications/{notification_id}/unread\n  methods: [ PUT ]\n  format: json\n\napi_notification_delete:\n  controller: App\\Controller\\Api\\Notification\\NotificationPurgeApi::purge\n  path: /api/notifications/{notification_id}\n  methods: [ DELETE ]\n  format: json\n\napi_notification_delete_all:\n  controller: App\\Controller\\Api\\Notification\\NotificationPurgeApi::purgeAll\n  path: /api/notifications\n  methods: [ DELETE ]\n  format: json\n\napi_notification_count:\n  controller: App\\Controller\\Api\\Notification\\NotificationRetrieveApi::count\n  path: /api/notifications/count\n  methods: [ GET ]\n  format: json\n\napi_notification_collection:\n  controller: App\\Controller\\Api\\Notification\\NotificationRetrieveApi::collection\n  defaults: { status: all }\n  path: /api/notifications/{status}\n  methods: [ GET ]\n  format: json\n\napi_notification_retrieve:\n  controller: App\\Controller\\Api\\Notification\\NotificationRetrieveApi\n  path: /api/notification/{notification_id}\n  methods: [ GET ]\n  format: json\n\napi_notification_push_register:\n    controller: App\\Controller\\Api\\Notification\\NotificationPushApi::createSubscription\n    path: /api/notification/push\n    methods: [ POST ]\n    format: json\n\napi_notification_push_unregister:\n    controller: App\\Controller\\Api\\Notification\\NotificationPushApi::deleteSubscription\n    path: /api/notification/push\n    methods: [ DELETE ]\n    format: json\n\napi_notification_push_test:\n    controller: App\\Controller\\Api\\Notification\\NotificationPushApi::testSubscription\n    path: /api/notification/push/test\n    methods: [ POST ]\n    format: json\n"
  },
  {
    "path": "config/mbin_routes/notification_settings.yaml",
    "content": "change_notification_setting:\n    controller: App\\Controller\\NotificationSettingsController::changeSetting\n    path: /cns/{subject_type}/{subject_id}/{status}\n    requirements:\n        subject_type: user|magazine|entry|post\n        status: Default|Loud|Muted\n"
  },
  {
    "path": "config/mbin_routes/notification_settings_api.yaml",
    "content": "api_notification_settings_update:\n    controller: App\\Controller\\Api\\Notification\\NotificationSettingApi::update\n    path: /api/notification/update/{targetType}/{targetId}/{setting}\n    requirements:\n        targetType: entry|post|magazine|user\n        setting: Default|Loud|Muted\n    methods: [ PUT ]\n    format: json\n"
  },
  {
    "path": "config/mbin_routes/page.yaml",
    "content": "page_contact:\n  controller: App\\Controller\\ContactController\n  path: /contact\n  methods: [ GET, POST ]\n\npage_faq:\n  controller: App\\Controller\\FaqController\n  path: /faq\n  methods: [ GET ]\n\npage_privacy_policy:\n  controller: App\\Controller\\PrivacyPolicyController\n  path: /privacy-policy\n  methods: [ GET ]\n\npage_terms:\n  controller: App\\Controller\\TermsController\n  path: /terms\n  methods: [ GET ]\n\nstats:\n  controller: App\\Controller\\StatsController\n  defaults: { statsType: general, statsPeriod: -1, withFederated: false }\n  path: /stats/{statsType}/{statsPeriod}/{withFederated}\n  methods: [ GET ]\n\npage_federation:\n  controller: App\\Controller\\FederationController\n  path: /federation\n  methods: [ GET ]\n\nredirect_instances:\n  controller: Symfony\\Bundle\\FrameworkBundle\\Controller\\RedirectController\n  path: /instances\n  defaults:\n    route: page_federation\n    permanent: true\n"
  },
  {
    "path": "config/mbin_routes/people.yaml",
    "content": "people_front:\n  controller: App\\Controller\\People\\PeopleFrontController\n  path: /people\n  methods: [ GET ]\n"
  },
  {
    "path": "config/mbin_routes/post.yaml",
    "content": "post_comment_create:\n  controller: App\\Controller\\Post\\Comment\\PostCommentCreateController\n  defaults: { slug: -, parent_comment_id: null }\n  path: /m/{magazine_name}/p/{post_id}/{slug}/reply/{parent_comment_id}\n  methods: [ GET, POST ]\n  requirements:\n    post_id: \\d+\n    parent_comment_id: \\d+\n\npost_comment_edit:\n  controller: App\\Controller\\Post\\Comment\\PostCommentEditController\n  defaults: { slug: -, }\n  path: /m/{magazine_name}/p/{post_id}/{slug}/reply/{comment_id}/edit\n  methods: [ GET, POST ]\n  requirements:\n    post_id: \\d+\n    comment_id: \\d+\n\npost_comment_moderate:\n  controller: App\\Controller\\Post\\Comment\\PostCommentModerateController\n  defaults: { slug: -, }\n  path: /m/{magazine_name}/p/{post_id}/{slug}/reply/{comment_id}/moderate\n  methods: [ GET, POST ]\n  requirements:\n    post_id: \\d+\n    comment_id: \\d+\n\npost_comment_delete:\n  controller: App\\Controller\\Post\\Comment\\PostCommentDeleteController::delete\n  defaults: { slug: -, }\n  path: /m/{magazine_name}/p/{post_id}/{slug}/reply/{comment_id}/delete\n  methods: [ POST ]\n  requirements:\n    post_id: \\d+\n    comment_id: \\d+\n\npost_comment_restore:\n  controller: App\\Controller\\Post\\Comment\\PostCommentDeleteController::restore\n  defaults: { slug: -, }\n  path: /m/{magazine_name}/p/{post_id}/{slug}/reply/{comment_id}/restore\n  methods: [ POST ]\n  requirements:\n    post_id: \\d+\n    comment_id: \\d+\n\npost_comment_purge:\n  controller: App\\Controller\\Post\\Comment\\PostCommentDeleteController::purge\n  defaults: { slug: -, }\n  path: /m/{magazine_name}/p/{post_id}/{slug}/reply/{comment_id}/purge\n  methods: [ POST ]\n  requirements:\n    post_id: \\d+\n    comment_id: \\d+\n\npost_comment_change_lang:\n  controller: App\\Controller\\Post\\Comment\\PostCommentChangeLangController\n  defaults: { slug: - }\n  path: /m/{magazine_name}/p/{post_id}/{slug}/comments/{comment_id}/change_lang\n  methods: [ POST ]\n  requirements:\n    post_id: \\d+\n    comment_id: \\d+\n\npost_comment_change_adult:\n  controller: App\\Controller\\Post\\Comment\\PostCommentChangeAdultController\n  defaults: { slug: - }\n  path: /m/{magazine_name}/p/{post_id}/{slug}/comments/{comment_id}/change_adult\n  methods: [ POST ]\n  requirements:\n    post_id: \\d+\n    comment_id: \\d+\n\npost_comment_image_delete:\n  controller: App\\Controller\\Post\\Comment\\PostCommentDeleteImageController\n  defaults: { slug: -, }\n  path: /m/{magazine_name}/p/{post_id}/{slug}/reply/{comment_id}/delete_image\n  methods: [ POST ]\n  requirements:\n    post_id: \\d+\n    comment_id: \\d+\n\npost_comment_voters:\n  controller: App\\Controller\\Post\\Comment\\PostCommentVotersController\n  defaults: { slug: -, }\n  path: /m/{magazine_name}/p/{post_id}/{slug}/reply/{comment_id}/votes\n  methods: [ GET ]\n  requirements:\n    post_id: \\d+\n    comment_id: \\d+\n\npost_comment_favourites:\n  controller: App\\Controller\\Post\\Comment\\PostCommentFavouriteController\n  defaults: { slug: -, }\n  path: /m/{magazine_name}/p/{post_id}/{slug}/reply/{comment_id}/favourites\n  methods: [ GET ]\n  requirements:\n    post_id: \\d+\n    comment_id: \\d+\n\npost_comment_vote:\n  controller: App\\Controller\\VoteController\n  defaults: { entityClass: App\\Entity\\PostComment }\n  path: /pcv/{id}/{choice}\n  methods: [ POST ]\n  requirements:\n    id: \\d+\n\npost_comment_report:\n  controller: App\\Controller\\ReportController\n  defaults: { entityClass: App\\Entity\\PostComment }\n  path: /pcr/{id}\n  methods: [ GET, POST ]\n  requirements:\n    id: \\d+\n\npost_comment_favourite:\n  controller: App\\Controller\\FavouriteController\n  defaults: { entityClass: App\\Entity\\PostComment }\n  path: /pcf/{id}\n  methods: [ POST ]\n  requirements:\n    id: \\d+\n\npost_comment_boost:\n  controller: App\\Controller\\BoostController\n  defaults: { entityClass: App\\Entity\\PostComment }\n  path: /pcb/{id}\n  methods: [ POST ]\n  requirements:\n    id: \\d+\n\npost_pin:\n  controller: App\\Controller\\Post\\PostPinController\n  defaults: { slug: - }\n  path: /m/{magazine_name}/p/{post_id}/{slug}/pin\n  methods: [ POST ]\n  requirements:\n    post_id: \\d+\n\npost_lock:\n    controller: App\\Controller\\Post\\PostLockController\n    defaults: { slug: - }\n    path: /m/{magazine_name}/p/{post_id}/{slug}/lock\n    methods: [ POST ]\n    requirements:\n        post_id: \\d+\n\npost_voters:\n  controller: App\\Controller\\Post\\PostVotersController\n  defaults: { slug: -, }\n  path: /m/{magazine_name}/p/{post_id}/{slug}/votes\n  methods: [ GET ]\n  requirements:\n    post_id: \\d+\n\npost_favourites:\n  controller: App\\Controller\\Post\\PostFavouriteController\n  defaults: { slug: -, }\n  path: /m/{magazine_name}/p/{post_id}/{slug}/favourites\n  methods: [ GET ]\n  requirements:\n    post_id: \\d+\n\npost_create:\n  controller: App\\Controller\\Post\\PostCreateController\n  path: /microblog/create\n  methods: [ GET, POST ]\n\npost_edit:\n  controller: App\\Controller\\Post\\PostEditController\n  defaults: { slug: -, }\n  path: /m/{magazine_name}/p/{post_id}/{slug}/edit\n  methods: [ GET, POST ]\n  requirements:\n    post_id: \\d+\n\npost_moderate:\n  controller: App\\Controller\\Post\\PostModerateController\n  defaults: { slug: -, }\n  path: /m/{magazine_name}/p/{post_id}/{slug}/moderate\n  methods: [ GET, POST ]\n  requirements:\n    post_id: \\d+\n\npost_delete:\n  controller: App\\Controller\\Post\\PostDeleteController::delete\n  defaults: { slug: -, }\n  path: /m/{magazine_name}/p/{post_id}/{slug}/delete\n  methods: [ POST ]\n  requirements:\n    post_id: \\d+\n\npost_restore:\n  controller: App\\Controller\\Post\\PostDeleteController::restore\n  defaults: { slug: -, }\n  path: /m/{magazine_name}/p/{post_id}/{slug}/restore\n  methods: [ POST ]\n  requirements:\n    post_id: \\d+\n\npost_purge:\n  controller: App\\Controller\\Post\\PostDeleteController::purge\n  defaults: { slug: -, }\n  path: /m/{magazine_name}/p/{post_id}/{slug}/purge\n  methods: [ POST ]\n  requirements:\n    post_id: \\d+\n\npost_image_delete:\n  controller: App\\Controller\\Post\\PostDeleteImageController\n  defaults: { slug: -, }\n  path: /m/{magazine_name}/p/{post_id}/{slug}/delete_image\n  methods: [ POST ]\n  requirements:\n    post_id: \\d+\n\npost_change_magazine:\n  controller: App\\Controller\\Post\\PostChangeMagazineController\n  defaults: { slug: - }\n  path: /m/{magazine_name}/p/{post_id}/{slug}/change_magazine\n  methods: [ POST ]\n  requirements:\n    post_id: \\d+\n\npost_change_lang:\n  controller: App\\Controller\\Post\\PostChangeLangController\n  defaults: { slug: - }\n  path: /m/{magazine_name}/p/{post_id}/{slug}/change_lang\n  methods: [ POST ]\n  requirements:\n    post_id: \\d+\n\npost_change_adult:\n  controller: App\\Controller\\Post\\PostChangeAdultController\n  defaults: { slug: - }\n  path: /m/{magazine_name}/p/{post_id}/{slug}/change_adult\n  methods: [ POST ]\n  requirements:\n    post_id: \\d+\n\npost_single:\n  controller: App\\Controller\\Post\\PostSingleController\n  defaults: { slug: -, sortBy: default }\n  path: /m/{magazine_name}/p/{post_id}/{slug}/{sortBy}\n  methods: [ GET ]\n  requirements:\n    sortBy: \"%comment_sort_options%\"\n    post_id: \\d+\n\npost_vote:\n  controller: App\\Controller\\VoteController\n  defaults: { entityClass: App\\Entity\\Post }\n  path: /pv/{id}/{choice}\n  methods: [ POST ]\n  requirements:\n    id: \\d+\n\npost_report:\n  controller: App\\Controller\\ReportController\n  defaults: { entityClass: App\\Entity\\Post }\n  path: /pr/{id}\n  methods: [ GET, POST ]\n  requirements:\n    id: \\d+\n\npost_favourite:\n  controller: App\\Controller\\FavouriteController\n  defaults: { entityClass: App\\Entity\\Post }\n  path: /pf/{id}\n  methods: [ POST ]\n  requirements:\n    id: \\d+\n\npost_boost:\n  controller: App\\Controller\\BoostController\n  defaults: { entityClass: App\\Entity\\Post }\n  path: /pb/{id}\n  methods: [ POST ]\n  requirements:\n    id: \\d+\n"
  },
  {
    "path": "config/mbin_routes/post_api.yaml",
    "content": "api_posts_subscribed:\n  controller: App\\Controller\\Api\\Post\\PostsRetrieveApi::subscribed\n  path: /api/posts/subscribed\n  methods: [ GET ]\n  format: json\n\napi_posts_subscribed_with_boost:\n  controller: App\\Controller\\Api\\Post\\PostsRetrieveApi::subscribedWithBoosts\n  path: /api/posts/subscribedWithBoosts\n  methods: [ GET ]\n  format: json\n\napi_posts_moderated:\n  controller: App\\Controller\\Api\\Post\\PostsRetrieveApi::moderated\n  path: /api/posts/moderated\n  methods: [ GET ]\n  format: json\n\napi_posts_favourited:\n  controller: App\\Controller\\Api\\Post\\PostsRetrieveApi::favourited\n  path: /api/posts/favourited\n  methods: [ GET ]\n  format: json\n\napi_posts_collection:\n  controller: App\\Controller\\Api\\Post\\PostsRetrieveApi::collection\n  path: /api/posts\n  methods: [ GET ]\n  format: json\n\n# Get information about a post\napi_post_retrieve:\n  controller: App\\Controller\\Api\\Post\\PostsRetrieveApi\n  path: /api/post/{post_id}\n  methods: [ GET ]\n  format: json\n\napi_posts_update:\n  controller: App\\Controller\\Api\\Post\\PostsUpdateApi\n  path: /api/post/{post_id}\n  methods: [ PUT ]\n  format: json\n\napi_posts_delete:\n  controller: App\\Controller\\Api\\Post\\PostsDeleteApi\n  path: /api/post/{post_id}\n  methods: [ DELETE ]\n  format: json\n\napi_posts_report:\n  controller: App\\Controller\\Api\\Post\\PostsReportApi\n  path: /api/post/{post_id}/report\n  methods: [ POST ]\n  format: json\n\napi_posts_vote:\n  controller: App\\Controller\\Api\\Post\\PostsVoteApi\n  defaults: { choice: 1 }\n  path: /api/post/{post_id}/vote/{choice}\n  methods: [ PUT ]\n  format: json\n\napi_posts_favourite:\n  controller: App\\Controller\\Api\\Post\\PostsFavouriteApi\n  path: /api/post/{post_id}/favourite\n  methods: [ PUT ]\n  format: json\n\n# Get information about a post comment\napi_post_comment_retrieve:\n  controller: App\\Controller\\Api\\Post\\Comments\\PostCommentsRetrieveApi\n  path: /api/post-comments/{comment_id}\n  methods: [ GET ]\n  format: json\n\n# Get comments from a post\napi_post_comments_retrieve:\n  controller: App\\Controller\\Api\\Post\\Comments\\PostCommentsRetrieveApi::collection\n  path: /api/posts/{post_id}/comments\n  methods: [ GET ]\n  format: json\n\n# Add comment to a post\napi_post_comments_create:\n  controller: App\\Controller\\Api\\Post\\Comments\\PostCommentsCreateApi\n  path: /api/posts/{post_id}/comments\n  methods: [ POST ]\n  format: json\n\napi_post_comments_create_image:\n  controller: App\\Controller\\Api\\Post\\Comments\\PostCommentsCreateApi::uploadImage\n  path: /api/posts/{post_id}/comments/image\n  methods: [ POST ]\n  format: json\n\n# Add reply to a post's comment\napi_post_comments_create_reply:\n  controller: App\\Controller\\Api\\Post\\Comments\\PostCommentsCreateApi\n  path: /api/posts/{post_id}/comments/{comment_id}/reply\n  methods: [ POST ]\n  format: json\n\napi_post_comments_create_image_reply:\n  controller: App\\Controller\\Api\\Post\\Comments\\PostCommentsCreateApi::uploadImage\n  path: /api/posts/{post_id}/comments/{comment_id}/reply/image\n  methods: [ POST ]\n  format: json\n\n# Update post comment\napi_post_comments_update:\n  controller: App\\Controller\\Api\\Post\\Comments\\PostCommentsUpdateApi\n  path: /api/post-comments/{comment_id}\n  methods: [ PUT ]\n  format: json\n\n# Delete post comment\napi_post_comments_delete:\n  controller: App\\Controller\\Api\\Post\\Comments\\PostCommentsDeleteApi\n  path: /api/post-comments/{comment_id}\n  methods: [ DELETE ]\n  format: json\n\napi_post_comments_report:\n  controller: App\\Controller\\Api\\Post\\Comments\\PostCommentsReportApi\n  path: /api/post-comments/{comment_id}/report\n  methods: [ POST ]\n  format: json\n\n# Favourite post comment\napi_post_comments_favourite:\n  controller: App\\Controller\\Api\\Post\\Comments\\PostCommentsFavouriteApi\n  path: /api/post-comments/{comment_id}/favourite\n  methods: [ PUT ]\n  format: json\n\n# Vote on post comment\napi_post_comments_vote:\n  controller: App\\Controller\\Api\\Post\\Comments\\PostCommentsVoteApi\n  defaults: { choice: 1 }\n  path: /api/post-comments/{comment_id}/vote/{choice}\n  methods: [ PUT ]\n  format: json\n\n# boosts and upvotes for posts\napi_post_activity:\n    controller: App\\Controller\\Api\\Post\\PostsActivityApi\n    path: /api/post/{post_id}/activity\n    methods: [ GET ]\n    format: json\n\n# boosts and upvotes entry post comments\napi_post_comment_activity:\n    controller: App\\Controller\\Api\\Post\\Comments\\PostCommentsActivityApi\n    path: /api/post-comments/{comment_id}/activity\n    methods: [ GET ]\n    format: json\n"
  },
  {
    "path": "config/mbin_routes/search.yaml",
    "content": "search:\n  controller: App\\Controller\\SearchController\n  defaults: { val: ~, }\n  path: /search\n  methods: [GET]\n"
  },
  {
    "path": "config/mbin_routes/search_api.yaml",
    "content": "api_search:\n  controller: App\\Controller\\Api\\Search\\SearchRetrieveApi::searchV1\n  path: /api/search\n  methods: [ GET ]\n  format: json\n\napi_search_v2:\n  controller: App\\Controller\\Api\\Search\\SearchRetrieveApi::searchV2\n  path: /api/search/v2\n  methods: [ GET ]\n  format: json\n"
  },
  {
    "path": "config/mbin_routes/security.yaml",
    "content": "app_register:\n  controller: App\\Controller\\Security\\RegisterController\n  path: /register\n  methods: [ GET, POST ]\n\napp_verify_email:\n  controller: App\\Controller\\Security\\VerifyEmailController\n  path: /verify/email\n  methods: [ GET ]\n\napp_forgot_password_request:\n  controller: App\\Controller\\Security\\ResetPasswordController::request\n  path: /reset-password\n  methods: [ GET, POST ]\n\napp_check_email:\n  controller: App\\Controller\\Security\\ResetPasswordController::checkEmail\n  path: /reset-password/check-email\n  methods: [ GET, POST ]\n\napp_reset_password:\n  controller: App\\Controller\\Security\\ResetPasswordController::reset\n  defaults: { token: ~ }\n  path: /reset-password/reset/{token}\n  methods: [ GET, POST ]\n\napp_login:\n  controller: App\\Controller\\Security\\LoginController\n  path: /login\n  methods: [ GET, POST ]\n\napp_resend_email_activation:\n  controller: App\\Controller\\Security\\ResendActivationEmailController::resend\n  path: /resend-email-activation/\n\napp_consent:\n  controller: App\\Controller\\Security\\LoginController::consent\n  path: /consent\n  methods: [ GET, POST ]\n\napp_logout:\n  controller: App\\Controller\\Security\\LogoutController\n  path: /logout\n  methods: [ GET ]\n\noauth_azure_connect:\n  controller: App\\Controller\\Security\\AzureController::connect\n  path: /oauth/azure/connect\n  methods: [ GET ]\n\noauth_azure_verify:\n  controller: App\\Controller\\Security\\AzureController::verify\n  path: /oauth/azure/verify\n  methods: [ GET ]\n\noauth_facebook_connect:\n  controller: App\\Controller\\Security\\FacebookController::connect\n  path: /oauth/facebook/connect\n  methods: [ GET ]\n\noauth_facebook_verify:\n  controller: App\\Controller\\Security\\FacebookController::verify\n  path: /oauth/facebook/verify\n  methods: [ GET ]\n\noauth_google_connect:\n  controller: App\\Controller\\Security\\GoogleController::connect\n  path: /oauth/google/connect\n  methods: [ GET ]\n\noauth_google_verify:\n  controller: App\\Controller\\Security\\GoogleController::verify\n  path: /oauth/google/verify\n  methods: [ GET ]\n\noauth_discord_connect:\n  controller: App\\Controller\\Security\\DiscordController::connect\n  path: /oauth/discord/connect\n  methods: [ GET ]\n\noauth_discord_verify:\n  controller: App\\Controller\\Security\\DiscordController::verify\n  path: /oauth/discord/verify\n  methods: [ GET ]\n\noauth_github_connect:\n  controller: App\\Controller\\Security\\GithubController::connect\n  path: /oauth/github/connect\n  methods: [ GET ]\n\noauth_github_verify:\n  controller: App\\Controller\\Security\\GithubController::verify\n  path: /oauth/github/verify\n  methods: [ GET ]\n\noauth_privacyportal_connect:\n  controller: App\\Controller\\Security\\PrivacyPortalController::connect\n  path: /oauth/privacyportal/connect\n  methods: [ GET ]\n\noauth_privacyportal_verify:\n  controller: App\\Controller\\Security\\PrivacyPortalController::verify\n  path: /oauth/privacyportal/verify\n  methods: [ GET ]\n\noauth_keycloak_connect:\n  controller: App\\Controller\\Security\\KeycloakController::connect\n  path: /oauth/keycloak/connect\n  methods: [ GET ]\n\noauth_keycloak_verify:\n  controller: App\\Controller\\Security\\KeycloakController::verify\n  path: /oauth/keycloak/verify\n  methods: [ GET ]\n\noauth_simplelogin_connect:\n  controller: App\\Controller\\Security\\SimpleLoginController::connect\n  path: /oauth/simplelogin/connect\n  methods: [ GET ]\n\noauth_simplelogin_verify:\n  controller: App\\Controller\\Security\\SimpleLoginController::verify\n  path: /oauth/simplelogin/verify\n  methods: [ GET ]\n\noauth_zitadel_connect:\n  controller: App\\Controller\\Security\\ZitadelController::connect\n  path: /oauth/zitadel/connect\n  methods: [ GET ]\n\noauth_zitadel_verify:\n  controller: App\\Controller\\Security\\ZitadelController::verify\n  path: /oauth/zitadel/verify\n  methods: [ GET ]\n\noauth_authentik_connect:\n  controller: App\\Controller\\Security\\AuthentikController::connect\n  path: /oauth/authentik/connect\n  methods: [ GET ]\n\noauth_authentik_verify:\n  controller: App\\Controller\\Security\\AuthentikController::verify\n  path: /oauth/authentik/verify\n  methods: [ GET ]\n\noauth_create_client:\n  controller: App\\Controller\\Api\\OAuth2\\CreateClientApi\n  path: /api/client\n  methods: [ POST ]\n  format: json\n\noauth_create_client_image:\n  controller: App\\Controller\\Api\\OAuth2\\CreateClientApi::uploadImage\n  path: /api/client-with-logo\n  methods: [ POST ]\n  format: json\n\noauth_revoke_token:\n  controller: App\\Controller\\Api\\OAuth2\\RevokeTokenApi\n  path: /api/revoke\n  methods: [ POST ]\n  format: json\n\noauth_delete_client:\n  controller: App\\Controller\\Api\\OAuth2\\DeleteClientApi\n  path: /api/client\n  methods: [ DELETE ]\n  format: json\n"
  },
  {
    "path": "config/mbin_routes/tag.yaml",
    "content": "tag_overview:\n  controller: App\\Controller\\Tag\\TagOverviewController\n  path: /tag/{name}\n  methods: [GET]\n\ntag_entries:\n  controller: App\\Controller\\Tag\\TagEntryFrontController\n  defaults: { sortBy: hot, time: ~ }\n  path: tag/{name}/threads/{sortBy}/{time}\n  methods: [ GET ]\n  requirements: { sortBy: \"%front_sort_options%\" }\n\ntag_comments:\n  controller: App\\Controller\\Tag\\TagCommentFrontController\n  defaults: { sortBy: hot, time: ~ }\n  path: tag/{name}/comments/{sortBy}/{time}\n  methods: [GET]\n  requirements: { sortBy: \"%front_sort_options%\" }\n\ntag_posts:\n  controller: App\\Controller\\Tag\\TagPostFrontController\n  defaults: { sortBy: hot, time: ~ }\n  path: tag/{name}/posts/{sortBy}/{time}\n  methods: [GET]\n  requirements: { sortBy: \"%front_sort_options%\" }\n\ntag_people:\n  controller: App\\Controller\\Tag\\TagPeopleFrontController\n  defaults: { sortBy: hot, time: ~ }\n  path: tag/{name}/people\n  methods: [GET]\n  requirements: { sortBy: \"%front_sort_options%\" }\n\ntag_ban:\n  path: /tag/{name}/ban\n  methods: [POST]\n  controller: App\\Controller\\Tag\\TagBanController::ban\n\ntag_unban:\n  path: /tag/{name}/unban\n  methods: [POST]\n  controller: App\\Controller\\Tag\\TagBanController::unban"
  },
  {
    "path": "config/mbin_routes/user.yaml",
    "content": "user_overview:\n    controller: App\\Controller\\User\\UserFrontController::front\n    path: /u/{username}\n    methods: [GET]\n\nuser_entries:\n    controller: App\\Controller\\User\\UserFrontController::entries\n    path: /u/{username}/threads\n    methods: [GET]\n\nuser_comments:\n    controller: App\\Controller\\User\\UserFrontController::comments\n    path: /u/{username}/comments\n    methods: [GET]\n\nuser_posts:\n    controller: App\\Controller\\User\\UserFrontController::posts\n    path: /u/{username}/posts\n    methods: [GET]\n\nuser_replies:\n    controller: App\\Controller\\User\\UserFrontController::replies\n    path: /u/{username}/replies\n    methods: [GET]\n\nuser_boosts:\n    controller: App\\Controller\\User\\UserFrontController::boosts\n    path: /u/{username}/boosts\n    methods: [GET]\n\nuser_moderated:\n    controller: App\\Controller\\User\\UserFrontController::moderated\n    path: /u/{username}/moderated\n    methods: [GET]\n\nuser_subscriptions:\n    controller: App\\Controller\\User\\UserFrontController::subscriptions\n    path: /u/{username}/subscriptions\n    methods: [GET]\n\nuser_followers:\n    controller: App\\Controller\\User\\UserFrontController::followers\n    path: /u/{username}/followers\n    methods: [GET]\n\nuser_following:\n    controller: App\\Controller\\User\\UserFrontController::following\n    path: /u/{username}/following\n    methods: [GET]\n\nuser_follow:\n    controller: App\\Controller\\User\\UserFollowController::follow\n    path: /u/{username}/follow\n    methods: [POST]\n\nuser_reputation:\n    controller: App\\Controller\\User\\UserReputationController\n    defaults: { reputationType: ~ }\n    path: /u/{username}/reputation/{reputationType}\n    methods: [GET]\n\nuser_unfollow:\n    controller: App\\Controller\\User\\UserFollowController::unfollow\n    path: /u/{username}/unfollow\n    methods: [POST]\n\nuser_block:\n    controller: App\\Controller\\User\\UserBlockController::block\n    path: /u/{username}/block\n    methods: [POST]\n\nuser_unblock:\n    controller: App\\Controller\\User\\UserBlockController::unblock\n    path: /u/{username}/unblock\n    methods: [POST]\n\nuser_delete_account:\n    controller: App\\Controller\\User\\UserDeleteController::deleteAccount\n    path: /u/{username}/delete_account\n    methods: [POST]\n\nschedule_user_delete_account:\n    controller: App\\Controller\\User\\UserDeleteController::scheduleDeleteAccount\n    path: /u/{username}/schedule_delete_account\n    methods: [POST]\n\nremove_schedule_user_delete_account:\n    controller: App\\Controller\\User\\UserDeleteController::removeScheduleDeleteAccount\n    path: /u/{username}/remove_schedule_delete_account\n    methods: [POST]\n\nuser_suspend:\n    controller: App\\Controller\\User\\UserSuspendController::suspend\n    path: /u/{username}/suspend\n    methods: [POST]\n\nuser_unsuspend:\n    controller: App\\Controller\\User\\UserSuspendController::unsuspend\n    path: /u/{username}/unsuspend\n    methods: [POST]\n\nuser_ban:\n    controller: App\\Controller\\User\\UserBanController::ban\n    path: /u/{username}/ban\n    methods: [POST]\n\nuser_unban:\n    controller: App\\Controller\\User\\UserBanController::unban\n    path: /u/{username}/unban\n    methods: [POST]\n\nuser_2fa_remove:\n    controller: App\\Controller\\User\\Profile\\User2FAController::remove\n    path: /u/{username}/remove\n    methods: [POST]\n\nuser_note:\n    controller: App\\Controller\\User\\UserNoteController\n    path: /u/{username}/note\n    methods: [POST]\n\nuser_verify:\n    controller: App\\Controller\\User\\Profile\\UserVerifyController\n    path: /u/{username}/verify\n    methods: [POST]\n\nuser_remove_following:\n    controller: App\\Controller\\User\\UserRemoveFollowing\n    path: /u/{username}/remove_following\n    methods: [POST]\n\nnotifications_front:\n    controller: App\\Controller\\User\\Profile\\UserNotificationController::notifications\n    path: /settings/notifications\n    methods: [GET]\n\nnotifications_read:\n    controller: App\\Controller\\User\\Profile\\UserNotificationController::read\n    path: /settings/notifications/read\n    methods: [POST]\n\nnotifications_clear:\n    controller: App\\Controller\\User\\Profile\\UserNotificationController::clear\n    path: /settings/notifications/clear\n    methods: [POST]\n\nuser_settings_reports:\n    controller: App\\Controller\\User\\Profile\\UserReportsController\n    path: /settings/reports/{status}\n    defaults: { status: !php/const \\App\\Entity\\Report::STATUS_ANY }\n    methods: [GET]\n\nuser_settings_magazine_blocks:\n    controller: App\\Controller\\User\\Profile\\UserBlockController::magazines\n    path: /settings/blocked/magazines\n    methods: [GET]\n\nuser_settings_domain_blocks:\n    controller: App\\Controller\\User\\Profile\\UserBlockController::domains\n    path: /settings/blocked/domains\n    methods: [GET]\n\nuser_settings_user_blocks:\n    controller: App\\Controller\\User\\Profile\\UserBlockController::users\n    path: /settings/blocked/people\n    methods: [GET]\n\nuser_settings_magazine_subscriptions:\n    controller: App\\Controller\\User\\Profile\\UserSubController::magazines\n    path: /settings/subscriptions/magazines\n    methods: [GET]\n\nuser_settings_domain_subscriptions:\n    controller: App\\Controller\\User\\Profile\\UserSubController::domains\n    path: /settings/subscriptions/domains\n    methods: [GET]\n\nuser_settings_user_subscriptions:\n    controller: App\\Controller\\User\\Profile\\UserSubController::users\n    path: /settings/subscriptions/people\n    methods: [GET]\n\nuser_settings_tips:\n    controller: App\\Controller\\User\\Profile\\UserTipController\n    path: /settings/ada\n    methods: [GET, POST]\n\nuser_settings_general:\n    controller: App\\Controller\\User\\Profile\\UserSettingController\n    path: /settings/general\n    methods: [GET, POST]\n\nuser_settings_profile:\n    controller: App\\Controller\\User\\Profile\\UserEditController::profile\n    path: /settings/profile\n    methods: [GET, POST]\n\nuser_settings_email:\n    controller: App\\Controller\\User\\Profile\\UserEditController::email\n    path: /settings/email\n    methods: [GET, POST]\n\nuser_settings_password:\n    controller: App\\Controller\\User\\Profile\\UserEditController::password\n    path: /settings/password\n    methods: [GET, POST]\n\nuser_settings_2fa:\n    controller: App\\Controller\\User\\Profile\\User2FAController::enable\n    path: /settings/2fa\n    methods: [GET, POST]\n\nuser_settings_2fa_disable:\n    controller: App\\Controller\\User\\Profile\\User2FAController::disable\n    path: /settings/2fa/disable\n    methods: [POST]\n\nuser_settings_2fa_qrcode:\n    controller: App\\Controller\\User\\Profile\\User2FAController::qrCode\n    path: /settings/2fa/qrcode\n    methods: [GET]\n\nuser_settings_2fa_backup:\n    controller: App\\Controller\\User\\Profile\\User2FAController::backup\n    path: /settings/2fa/backup\n    methods: [POST]\n\nuser_settings_account_deletion:\n    controller: App\\Controller\\User\\AccountDeletionController\n    path: /settings/account_deletion\n    methods: [GET, POST]\n\nuser_settings_filter_lists:\n    controller: App\\Controller\\User\\FilterListsController\n    path: /settings/filter_lists\n    methods: [GET, POST]\n\nuser_settings_filter_lists_create:\n    controller: App\\Controller\\User\\FilterListsController::create\n    path: /settings/filter_lists/create\n    methods: [GET, POST]\n\nuser_settings_filter_lists_edit:\n    controller: App\\Controller\\User\\FilterListsController::edit\n    path: /settings/filter_lists/edit/{id}\n    methods: [GET, POST]\n\nuser_settings_filter_lists_delete:\n    controller: App\\Controller\\User\\FilterListsController::delete\n    path: /settings/filter_lists/delete/{id}\n    methods: [GET, POST]\n\nuser_settings_avatar_delete:\n    controller: App\\Controller\\User\\UserAvatarDeleteController\n    path: /settings/edit/delete_avatar\n    methods: [POST]\n\nuser_settings_cover_delete:\n    controller: App\\Controller\\User\\UserCoverDeleteController\n    path: /settings/edit/delete_cover\n    methods: [POST]\n\nuser_settings_toggle_theme:\n    controller: App\\Controller\\User\\UserThemeController\n    path: /settings/edit/theme\n    methods: [GET, POST]\n\nuser_settings_stats:\n    controller: App\\Controller\\User\\Profile\\UserStatsController\n    defaults: { statsType: content, statsPeriod: 31, withFederated: false }\n    path: /settings/stats/{statsType}/{statsPeriod}/{withFederated}\n    methods: [GET]\n\ntheme_settings:\n    controller: App\\Controller\\User\\ThemeSettingsController\n    path: /settings/theme/{key}/{value}\n    methods: [GET]\n"
  },
  {
    "path": "config/mbin_routes/user_api.yaml",
    "content": "api_users_collection:\n  controller: App\\Controller\\Api\\User\\UserRetrieveApi::collection\n  path: /api/users\n  methods: [ GET ]\n  format: json\n\napi_admins_collection:\n  controller: App\\Controller\\Api\\User\\UserRetrieveApi::admins\n  path: /api/users/admins\n  methods: [ GET ]\n  format: json\n\napi_moderators_collection:\n    controller: App\\Controller\\Api\\User\\UserRetrieveApi::moderators\n    path: /api/users/moderators\n    methods: [ GET ]\n    format: json\n\napi_user_blocked:\n  controller: App\\Controller\\Api\\User\\UserRetrieveApi::blocked\n  path: /api/users/blocked\n  methods: [ GET ]\n  format: json\n\napi_current_user_followed:\n  controller: App\\Controller\\Api\\User\\UserRetrieveApi::followedByCurrent\n  path: /api/users/followed\n  methods: [ GET ]\n  format: json\n\napi_current_user_followers:\n  controller: App\\Controller\\Api\\User\\UserRetrieveApi::followersOfCurrent\n  path: /api/users/followers\n  methods: [ GET ]\n  format: json\n\napi_user_retrieve_self:\n  controller: App\\Controller\\Api\\User\\UserRetrieveApi::me\n  path: /api/users/me\n  methods: [ GET ]\n  format: json\n\napi_user_retrieve_oauth_consent:\n  controller: App\\Controller\\Api\\User\\UserRetrieveOAuthConsentsApi\n  path: /api/users/consents/{consent_id}\n  methods: [ GET ]\n  format: json\n\napi_user_update_oauth_consent:\n  controller: App\\Controller\\Api\\User\\UserUpdateOAuthConsentsApi\n  path: /api/users/consents/{consent_id}\n  methods: [ PUT ]\n  format: json\n\napi_user_retrieve_oauth_consents:\n  controller: App\\Controller\\Api\\User\\UserRetrieveOAuthConsentsApi::collection\n  path: /api/users/consents\n  methods: [ GET ]\n  format: json\n\napi_user_update_profile:\n  controller: App\\Controller\\Api\\User\\UserUpdateApi::profile\n  path: /api/users/profile\n  methods: [ PUT ]\n  format: json\n\napi_user_retrieve_settings:\n  controller: App\\Controller\\Api\\User\\UserRetrieveApi::settings\n  path: /api/users/settings\n  methods: [ GET ]\n  format: json\n\napi_user_update_settings:\n  controller: App\\Controller\\Api\\User\\UserUpdateApi::settings\n  path: /api/users/settings\n  methods: [ PUT ]\n  format: json\n\napi_user_retrieve_filter_lists:\n    controller: App\\Controller\\Api\\User\\UserFilterListApi::retrieve\n    path: /api/users/filterLists\n    methods: [ GET ]\n    format: json\n\napi_user_retrieve_filter_lists_create:\n    controller: App\\Controller\\Api\\User\\UserFilterListApi::create\n    path: /api/users/filterLists\n    methods: [ POST ]\n    format: json\n\napi_user_retrieve_filter_lists_edit:\n    controller: App\\Controller\\Api\\User\\UserFilterListApi::edit\n    path: /api/users/filterLists/{id}\n    methods: [ PUT ]\n    format: json\n\napi_user_retrieve_filter_lists_delete:\n    controller: App\\Controller\\Api\\User\\UserFilterListApi::delete\n    path: /api/users/filterLists/{id}\n    methods: [ DELETE ]\n    format: json\n\napi_user_update_avatar:\n  controller: App\\Controller\\Api\\User\\UserUpdateImagesApi::avatar\n  path: /api/users/avatar\n  methods: [ POST ]\n  format: json\n\napi_user_update_cover:\n  controller: App\\Controller\\Api\\User\\UserUpdateImagesApi::cover\n  path: /api/users/cover\n  methods: [ POST ]\n  format: json\n\napi_user_delete_avatar:\n  controller: App\\Controller\\Api\\User\\UserDeleteImagesApi::avatar\n  path: /api/users/avatar\n  methods: [ DELETE ]\n  format: json\n\napi_user_delete_cover:\n  controller: App\\Controller\\Api\\User\\UserDeleteImagesApi::cover\n  path: /api/users/cover\n  methods: [ DELETE ]\n  format: json\n\napi_user_retrieve:\n  controller: App\\Controller\\Api\\User\\UserRetrieveApi\n  path: /api/users/{user_id}\n  methods: [ GET ]\n  format: json\n\napi_user_retrieve_by_name:\n  controller: App\\Controller\\Api\\User\\UserRetrieveApi::username\n  path: /api/users/name/{username}\n  methods: [ GET ]\n  format: json\n\napi_user_followed:\n  controller: App\\Controller\\Api\\User\\UserRetrieveApi::followed\n  path: /api/users/{user_id}/followed\n  methods: [ GET ]\n  format: json\n\napi_user_followers:\n  controller: App\\Controller\\Api\\User\\UserRetrieveApi::followers\n  path: /api/users/{user_id}/followers\n  methods: [ GET ]\n  format: json\n\napi_user_block:\n  controller: App\\Controller\\Api\\User\\UserBlockApi::block\n  path: /api/users/{user_id}/block\n  methods: [ PUT ]\n  format: json\n\napi_user_unblock:\n  controller: App\\Controller\\Api\\User\\UserBlockApi::unblock\n  path: /api/users/{user_id}/unblock\n  methods: [ PUT ]\n  format: json\n\napi_user_follow:\n  controller: App\\Controller\\Api\\User\\UserFollowApi::follow\n  path: /api/users/{user_id}/follow\n  methods: [ PUT ]\n  format: json\n\napi_user_unfollow:\n  controller: App\\Controller\\Api\\User\\UserFollowApi::unfollow\n  path: /api/users/{user_id}/unfollow\n  methods: [ PUT ]\n  format: json\n\napi_user_magazine_subscriptions:\n  controller: App\\Controller\\Api\\Magazine\\MagazineRetrieveApi::subscriptions\n  path: /api/users/{user_id}/magazines/subscriptions\n  methods: [ GET ]\n  format: json\n\napi_user_domain_subscriptions:\n  controller: App\\Controller\\Api\\Domain\\DomainRetrieveApi::subscriptions\n  path: /api/users/{user_id}/domains/subscriptions\n  methods: [ GET ]\n  format: json\n\n# Get a list of threads from specific user\napi_user_entries_retrieve:\n  controller: App\\Controller\\Api\\Entry\\UserEntriesRetrieveApi\n  path: /api/users/{user_id}/entries\n  methods: [ GET ]\n  format: json\n\n# Get a list of comments from specific user\napi_user_entry_comments_retrieve:\n  controller: App\\Controller\\Api\\Entry\\Comments\\UserEntryCommentsRetrieveApi\n  path: /api/users/{user_id}/comments\n  methods: [ GET ]\n  format: json\n\n# Get a list of posts from specific user\napi_user_posts_retrieve:\n  controller: App\\Controller\\Api\\Post\\UserPostsRetrieveApi\n  path: /api/users/{user_id}/posts\n  methods: [ GET ]\n  format: json\n\n# Get a list of post comments from specific user\napi_user_post_comments_retrieve:\n  controller: App\\Controller\\Api\\Post\\Comments\\UserPostCommentsRetrieveApi\n  path: /api/users/{user_id}/post-comments\n  methods: [ GET ]\n  format: json\n\napi_user_content_retrieve:\n    controller: App\\Controller\\Api\\User\\UserContentApi::getUserContent\n    path: /api/users/{user_id}/content\n    methods: [ GET ]\n    format: json\n\napi_user_boosts_retrieve:\n    controller: App\\Controller\\Api\\User\\UserContentApi::getBoostedContent\n    path: /api/users/{user_id}/boosts\n    methods: [ GET ]\n    format: json\n\napi_user_moderated_retrieve:\n    controller: App\\Controller\\Api\\User\\UserModeratesApi\n    path: /api/users/{user_id}/moderatedMagazines\n    methods: [ GET ]\n    format: json\n"
  },
  {
    "path": "config/mbin_serialization/badge.yaml",
    "content": "App\\DTO\\BadgeDto:\n  attributes:\n    id:\n      groups: [ 'badge_read' ]\n    name:\n      groups: [ 'badge_read' ]\n"
  },
  {
    "path": "config/mbin_serialization/domain.yaml",
    "content": "App\\DTO\\DomainDto:\n  attributes:\n    name:\n      groups: [ 'domain:collection:get', 'domain:item:get', 'entry:collection:get', 'entry:item:get' ]\n    entryCount:\n      groups: [ 'domain:collection:get', 'domain:item:get', 'entry:item:get' ]\n"
  },
  {
    "path": "config/mbin_serialization/entry.yaml",
    "content": "App\\DTO\\EntryDto:\n  attributes:\n    id:\n      groups: [ 'entry:collection:get', 'entry:item:get', 'entry:comment:collection:get' ]\n    magazine:\n      groups: [ 'entry:collection:get', 'entry:item:get' ]\n    domain:\n      groups: [ 'entry:collection:get', 'entry:item:get' ]\n    user:\n      groups: [ 'entry:collection:get', 'entry:item:get' ]\n    image:\n      groups: [ 'entry:collection:get', 'entry:item:get' ]\n    title:\n      groups: [ 'entry:collection:get', 'entry:item:get', 'entry:comment:collection:get' ]\n    url:\n      groups: [ 'entry:collection:get', 'entry:item:get' ]\n    body:\n      groups: [ 'entry:item:get' ]\n    isAdult:\n      groups: [ 'entry:collection:get', 'entry:item:get' ]\n    hasEmbed:\n      groups: [ 'entry:collection:get', 'entry:item:get' ]\n    type:\n      groups: [ 'entry:collection:get', 'entry:item:get' ]\n    comments:\n      groups: [ 'entry:collection:get', 'entry:item:get' ]\n    uv:\n      groups: [ 'entry:collection:get', 'entry:item:get' ]\n    dv:\n      groups: [ 'entry:collection:get', 'entry:item:get' ]\n    score:\n      groups: [ 'entry:collection:get', 'entry:item:get' ]\n    visibility:\n      groups: [ 'entry:collection:get', 'entry:item:get' ]\n    createdAt:\n      groups: [ 'entry:collection:get', 'entry:item:get' ]\n    lastActive:\n      groups: [ 'entry:collection:get', 'entry:item:get' ]\n"
  },
  {
    "path": "config/mbin_serialization/entry_comment.yaml",
    "content": "App\\DTO\\EntryCommentDto:\n  attributes:\n    id:\n      groups: [ 'entry:comment:collection:get', 'entry:comment:item:get', 'single:entry:comment:collection:get' ]\n    magazine:\n      groups: [ 'entry:comment:collection:get', 'entry:comment:item:get', 'single:entry:comment:collection:get' ]\n    user:\n      groups: [ 'entry:comment:collection:get', 'entry:comment:item:get', 'single:entry:comment:collection:get' ]\n    entry:\n      groups: [ 'entry:comment:collection:get' ]\n    image:\n      groups: [ 'entry:comment:collection:get', 'entry:comment:item:get', 'single:entry:comment:collection:get' ]\n    parent:\n      groups: [ 'entry:comment:collection:get', 'entry:comment:item:get', 'single:entry:comment:collection:get' ]\n    root:\n      groups: [ 'entry:comment:collection:get', 'entry:comment:item:get', 'single:entry:comment:collection:get' ]\n    body:\n      groups: [ 'entry:comment:collection:get', 'entry:comment:item:get', 'single:entry:comment:collection:get' ]\n    uv:\n      groups: [ 'entry:comment:collection:get', 'entry:comment:item:get', 'single:entry:comment:collection:get' ]\n    dv:\n      groups: [ 'entry:comment:collection:get', 'entry:comment:item:get', 'single:entry:comment:collection:get' ]\n    createdAt:\n      groups: [ 'entry:comment:collection:get', 'entry:comment:item:get', 'single:entry:comment:collection:get' ]\n    lastActive:\n      groups: [ 'entry:comment:collection:get', 'entry:comment:item:get', 'single:entry:comment:collection:get' ]\n\n"
  },
  {
    "path": "config/mbin_serialization/image.yaml",
    "content": "App\\DTO\\ImageDto:\n  attributes:\n    id:\n      groups: [ 'image:get' ]\n    filePath:\n      groups: [ 'image:get','magazine:collection:get', 'magazine:item:get', 'entry:collection:get', 'entry:item:get', 'entry:comment:collection:get', 'single:entry:comment:collection:get', 'post:collection:get', 'post:item:get', 'post:comment:collection:get', 'single:post:comment:collection:get' ]\n    width:\n      groups: [ 'image:get','magazine:collection:get', 'magazine:item:get','entry:collection:get', 'entry:item:get', 'entry:comment:collection:get', 'single:entry:comment:collection:get', 'post:collection:get', 'post:item:get', 'post:comment:collection:get', 'single:post:comment:collection:get' ]\n    height:\n      groups: [ 'image:get','magazine:collection:get', 'magazine:item:get','entry:collection:get', 'entry:item:get', 'entry:comment:collection:get', 'single:entry:comment:collection:get', 'post:collection:get', 'post:item:get', 'post:comment:collection:get', 'single:post:comment:collection:get' ]\n"
  },
  {
    "path": "config/mbin_serialization/magazine.yaml",
    "content": "App\\DTO\\MagazineDto:\n  attributes:\n    user:\n      groups: [ 'magazine:item:get', 'magazine:collection:get' ]\n    icon:\n      groups: [ 'magazine:item:get', 'magazine:collection:get' ]\n    name:\n      groups: [ 'magazine:item:get', 'magazine:collection:get', 'entry:collection:get', 'entry:item:get', 'entry:comment:collection:get', 'single:entry:comment:collection:get', 'post:collection:get', 'post:item:get', 'post:comment:collection:get', 'single:post:comment:collection:get' ]\n    title:\n      groups: [ 'magazine:item:get', 'magazine:collection:get' ]\n    description:\n      groups: [ 'magazine:item:get', 'magazine:collection:get' ]\n    rules:\n      groups: [ 'magazine:item:get', 'magazine:collection:get' ]\n    subscriptionsCount:\n      groups: [ 'magazine:item:get', 'magazine:collection:get' ]\n    entryCount:\n      groups: [ 'magazine:item:get', 'magazine:collection:get' ]\n    entryCommentCount:\n      groups: [ 'magazine:item:get', 'magazine:collection:get' ]\n    postCount:\n      groups: [ 'magazine:item:get', 'magazine:collection:get' ]\n    postCommentCount:\n      groups: [ 'magazine:item:get', 'magazine:collection:get' ]\n    isAdult:\n      groups: [ 'magazine:item:get', 'magazine:collection:get' ]\n"
  },
  {
    "path": "config/mbin_serialization/post.yaml",
    "content": "App\\DTO\\PostDto:\n  attributes:\n    id:\n      groups: [ 'post:collection:get', 'post:item:get', 'post:comment:collection:get' ]\n    magazine:\n      groups: [ 'post:collection:get', 'post:item:get' ]\n    user:\n      groups: [ 'post:collection:get', 'post:item:get' ]\n    image:\n      groups: [ 'post:collection:get', 'post:item:get' ]\n    body:\n      groups: [ 'post:collection:get', 'post:item:get' ]\n    isAdult:\n      groups: [ 'post:collection:get', 'post:item:get' ]\n    comments:\n      groups: [ 'post:collection:get', 'post:item:get' ]\n    uv:\n      groups: [ 'post:collection:get', 'post:item:get' ]\n    dv:\n      groups: [ 'post:collection:get', 'post:item:get' ]\n    score:\n      groups: [ 'post:collection:get', 'post:item:get' ]\n    visibility:\n      groups: [ 'post:collection:get', 'post:item:get' ]\n    createdAt:\n      groups: [ 'post:collection:get', 'post:item:get' ]\n    lastActive:\n      groups: [ 'post:collection:get', 'post:item:get' ]\n    bestComments:\n      groups: [ 'post:collection:get', 'post:item:get', 'post:comment:collection:get' ]\n"
  },
  {
    "path": "config/mbin_serialization/post_comment.yaml",
    "content": "App\\DTO\\PostCommentDto:\n  attributes:\n    id:\n      groups: [ 'post:comment:collection:get', 'post:comment:item:get', 'single:post:comment:collection:get', 'post:collection:get' ]\n    magazine:\n      groups: [ 'post:comment:collection:get', 'post:comment:item:get', 'single:post:comment:collection:get' ]\n    user:\n      groups: [ 'post:comment:collection:get', 'post:comment:item:get', 'single:post:comment:collection:get', 'post:collection:get' ]\n    post:\n      groups: [ 'post:comment:collection:get' ]\n    image:\n      groups: [ 'entry:comment:collection:get', 'entry:comment:item:get', 'single:entry:comment:collection:get', 'post:collection:get' ]\n    parent:\n      groups: [ 'post:comment:collection:get', 'post:comment:item:get', 'single:post:comment:collection:get' ]\n    body:\n      groups: [ 'post:comment:collection:get', 'post:comment:item:get', 'single:post:comment:collection:get', 'post:collection:get' ]\n    uv:\n      groups: [ 'post:comment:collection:get', 'post:comment:item:get', 'single:post:comment:collection:get', 'post:collection:get' ]\n    createdAt:\n      groups: [ 'post:comment:collection:get', 'post:comment:item:get', 'single:post:comment:collection:get', 'post:collection:get' ]\n    lastActive:\n      groups: [ 'post:comment:collection:get', 'post:comment:item:get', 'single:post:comment:collection:get', 'post:collection:get' ]\n\n"
  },
  {
    "path": "config/mbin_serialization/user.yaml",
    "content": "App\\DTO\\UserDto:\n  attributes:\n    email:\n      groups: [ 'user:write' ]\n    plainPassword:\n      groups: [ 'user:write' ]\n    username:\n      groups: [ 'user:get', 'magazine:item:get', 'magazine:collection:get', 'entry:collection:get', 'entry:item:get', 'entry:comment:collection:get', 'single:entry:comment:collection:get', 'post:item:get', 'post:collection:get', 'post:comment:collection:get', 'single:post:comment:collection:get' ]\n    avatar:\n      groups: [ 'user:get', 'magazine:item:get', 'entry:item:get', 'entry:comment:collection:get', 'single:entry:comment:collection:get', 'post:item:get', 'post:collection:get', 'single:post:comment:collection:get' ]\n"
  },
  {
    "path": "config/packages/antispam.yaml",
    "content": "#\n# This sample configuration sets up a default anti-spam profile that will already stop a lot of\n# form spam with minimal effort and none to minimal user inconvenience.\n#\n# To get started right away read the Quickstart at https://omines.github.io/antispam-bundle/quickstart/\n#\n# For more details on the options available visit https://omines.github.io/antispam-bundle/configuration/\n#\nantispam:\n    stealth: false\n\n    profiles:\n        default:\n            stealth: false\n\n            # Insert a honeypot called \"full_name\" on all forms to lure bots into filling it in\n            honeypot: full_name\n\n            # Reject all forms that have been submitted either within 6 seconds, or after more than 30 minutes\n            timer:\n                min: 6\n                max: 1800\n\n            # The measures above should already have notable effect on the amount of spam that gets through\n            # your forms. Still getting annoying amounts? Analyze the patterns of uncaught spam, then\n            # consider uncommenting and modifying some of the examples below after careful consideration\n            # about their impact.\n            #\n\n            # Reject text fields that contain (lame attempts at) HTML or BBCode\n            banned_markup: true\n\n            # Reject text fields that consist for more than 40% of Cyrillic (Russian) characters\n            # banned_scripts:\n            #     scripts: [ cyrillic ]\n            #     max_percentage: 40\n\n            # Reject fields that contain more than 3 URLs, or repeat a single URL more than once\n            # url_count:\n            #     max: 3\n            #     max_identical: 1\n\nwhen@test:\n    antispam:\n        # In automated tests the bundle and included components are by default disabled. You can still\n        # enable them for individual test cases via the main AntiSpam service.\n        enabled: false\n"
  },
  {
    "path": "config/packages/babdev_pagerfanta.yaml",
    "content": "babdev_pagerfanta:\n  default_view: twig\n  default_twig_template: 'layout/_pagination.html.twig'"
  },
  {
    "path": "config/packages/cache.yaml",
    "content": "framework:\n    cache:\n        # Unique name of your app: used to compute stable namespaces for cache keys.\n        #prefix_seed: your_vendor_name/app_name\n\n        # The \"app\" cache stores to the filesystem by default.\n        # The data in this cache should persist between deploys.\n        # Other options include:\n\n        # Redis\n        app: cache.adapter.redis_tag_aware\n        default_redis_provider: '%env(REDIS_DNS)%'\n\n        # APCu (not recommended with heavy random-write workloads as memory fragmentation can cause perf issues)\n#        app: cache.tagaware.filesystem\n\n        # Namespaced pools use the above \"app\" backend by default\n        pools:\n            doctrine.second_level_cache_pool:\n                adapter: cache.app\n\n"
  },
  {
    "path": "config/packages/commonmark.yaml",
    "content": "parameters:\n    commonmark.configuration:\n        allow_unsafe_links: false\n        html_input: escape\n        max_nesting_level: 25\n        renderer:\n            soft_break: \"<br/>\\r\\n\"\n        table:\n            wrap:\n                enabled: true\n                tag: \"div\"\n                attributes:\n                    class: \"user-content-table-responsive\"\n    commonmark.allowed_schemes: [http, https]\n\nservices:\n    _defaults:\n        autowire: true\n        public: false\n\n    League\\CommonMark\\Extension\\Autolink\\UrlAutolinkParser:\n        arguments:\n            $allowedProtocols: \"%commonmark.allowed_schemes%\"\n    League\\CommonMark\\Extension\\CommonMark\\CommonMarkCoreExtension: ~\n    League\\CommonMark\\Extension\\Strikethrough\\StrikethroughExtension: ~\n    League\\CommonMark\\Extension\\Table\\TableExtension: ~\n"
  },
  {
    "path": "config/packages/dama_doctrine_test_bundle.yaml",
    "content": "when@test:\n    dama_doctrine_test:\n        enable_static_connection: true\n        enable_static_meta_data_cache: true\n        enable_static_query_cache: true\n"
  },
  {
    "path": "config/packages/debug.yaml",
    "content": "when@dev:\n    debug:\n        # Forwards VarDumper Data clones to a centralized server allowing to inspect dumps on CLI or in your browser.\n        # See the \"server:dump\" command to start a new server.\n        dump_destination: \"tcp://%env(VAR_DUMPER_SERVER)%\"\n"
  },
  {
    "path": "config/packages/dev/rate_limiter.yaml",
    "content": "framework:\n    rate_limiter:\n        anonymous_api_read:\n            policy: \"sliding_window\"\n            limit: 1000\n            interval: \"1 second\"\n        api_oauth_client:\n            policy: \"sliding_window\"\n            limit: 1000\n            interval: \"1 second\"\n        api_oauth_token_revoke:\n            policy: \"sliding_window\"\n            limit: 1000\n            interval: \"1 second\"\n        api_oauth_client_delete:\n            policy: \"sliding_window\"\n            limit: 1000\n            interval: \"1 second\"\n        api_delete:\n            policy: \"sliding_window\"\n            limit: 1000\n            interval: \"1 second\"\n        api_message:\n            policy: \"sliding_window\"\n            limit: 1000\n            interval: \"1 second\"\n        api_report:\n            policy: \"sliding_window\"\n            limit: 1000\n            interval: \"1 second\"\n        api_read:\n            policy: \"sliding_window\"\n            limit: 1000\n            interval: \"1 second\"\n        api_update:\n            policy: \"sliding_window\"\n            limit: 1000\n            interval: \"1 second\"\n        api_vote:\n            policy: \"sliding_window\"\n            limit: 1000\n            interval: \"1 second\"\n        api_entry:\n            policy: \"sliding_window\"\n            limit: 1000\n            interval: \"1 second\"\n        api_image:\n            policy: \"sliding_window\"\n            limit: 1000\n            interval: \"1 second\"\n        api_post:\n            policy: \"sliding_window\"\n            limit: 1000\n            interval: \"1 second\"\n        api_comment:\n            policy: \"sliding_window\"\n            limit: 1000\n            interval: \"1 second\"\n        api_magazine:\n            policy: \"sliding_window\"\n            limit: 1000\n            interval: \"1 second\"\n        api_notification:\n            policy: \"sliding_window\"\n            limit: 1000\n            interval: \"1 second\"\n        api_moderate:\n            policy: \"sliding_window\"\n            limit: 1000\n            interval: \"1 second\"\n        vote:\n            policy: \"sliding_window\"\n            limit: 1000\n            interval: \"1 second\"\n        entry:\n            policy: \"fixed_window\"\n            limit: 1000\n            interval: \"1 second\"\n        entry_comment:\n            policy: \"sliding_window\"\n            limit: 1000\n            interval: \"1 second\"\n        post:\n            policy: \"fixed_window\"\n            limit: 1000\n            interval: \"1 second\"\n        post_comment:\n            policy: \"sliding_window\"\n            limit: 1000\n            interval: \"1 second\"\n        user_register:\n            policy: \"fixed_window\"\n            limit: 1000\n            interval: \"1 second\"\n        magazine:\n            policy: \"fixed_window\"\n            limit: 1000\n            interval: \"1 second\"\n"
  },
  {
    "path": "config/packages/doctrine.yaml",
    "content": "doctrine:\n    dbal:\n        url: '%env(resolve:DATABASE_URL)%'\n        types:\n            citext: App\\DoctrineExtensions\\DBAL\\Types\\Citext\n            enumApplicationStatus: App\\DoctrineExtensions\\DBAL\\Types\\EnumApplicationStatus\n            enumNotificationStatus: App\\DoctrineExtensions\\DBAL\\Types\\EnumNotificationStatus\n            enumSortOptions: App\\DoctrineExtensions\\DBAL\\Types\\EnumSortOptions\n            enumDirectMessageSettings: App\\DoctrineExtensions\\DBAL\\Types\\EnumDirectMessageSettings\n            enumFrontContentOptions: App\\DoctrineExtensions\\DBAL\\Types\\EnumFrontContentOptions\n        mapping_types:\n            user_type: string\n            citext: citext\n            enumApplicationStatus: string\n            enumNotificationStatus: string\n            enumSortOptions: string\n            enumDirectMessageSettings: string\n            enumFrontContentOptions: string\n\n        # IMPORTANT: You MUST configure your server version,\n        # either here or in the DATABASE_URL env var (see .env file)\n        #server_version: '16'\n\n        profiling_collect_backtrace: '%kernel.debug%'\n    orm:\n        dql:\n            string_functions:\n                JSONB_CONTAINS: Scienta\\DoctrineJsonFunctions\\Query\\AST\\Functions\\Postgresql\\JsonbContains\n        validate_xml_mapping: true\n        naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware\n        identity_generation_preferences:\n            Doctrine\\DBAL\\Platforms\\PostgreSQLPlatform: identity\n        auto_mapping: false\n        controller_resolver:\n            auto_mapping: false\n        mappings:\n            App:\n                type: attribute\n                is_bundle: false\n                dir: '%kernel.project_dir%/src/Entity'\n                prefix: 'App\\Entity'\n                alias: App\n        second_level_cache:\n            enabled: true\n            region_cache_driver:\n                type: pool\n                pool: doctrine.second_level_cache_pool\n\nwhen@test:\n    doctrine:\n        dbal:\n            # \"TEST_TOKEN\" is typically set by ParaTest\n            dbname_suffix: '_test%env(default::TEST_TOKEN)%'\n\nwhen@prod:\n    doctrine:\n        orm:\n            query_cache_driver:\n                type: pool\n                pool: doctrine.system_cache_pool\n            result_cache_driver:\n                type: pool\n                pool: doctrine.result_cache_pool\n\n    framework:\n        cache:\n            pools:\n                doctrine.result_cache_pool:\n                    adapter: cache.app\n                doctrine.system_cache_pool:\n                    adapter: cache.system\n"
  },
  {
    "path": "config/packages/doctrine_migrations.yaml",
    "content": "doctrine_migrations:\n    migrations_paths:\n        # namespace is arbitrary but should be different from App\\Migrations\n        # as migrations classes should NOT be autoloaded\n        \"DoctrineMigrations\": \"%kernel.project_dir%/migrations\"\n    enable_profiler: false\n"
  },
  {
    "path": "config/packages/fos_js_routing.yaml",
    "content": "fos_js_routing:\n  routes_to_expose: [\n    'ajax_fetch_entry',\n    'ajax_fetch_entry_comment',\n    'ajax_fetch_post',\n    'ajax_fetch_post_comment',\n    'ajax_fetch_post_comments',\n    'ajax_fetch_user_popup',\n    'ajax_fetch_title',\n    'ajax_fetch_embed',\n    'ajax_fetch_duplicates',\n\n    'theme_settings'\n  ]\n"
  },
  {
    "path": "config/packages/framework.yaml",
    "content": "# see https://symfony.com/doc/current/reference/configuration/framework.html\nframework:\n    secret: '%env(APP_SECRET)%'\n    #csrf_protection: true\n    annotations: false #no longer supported\n    http_method_override: false\n    handle_all_throwables: true\n    trusted_proxies: '%env(string:default::TRUSTED_PROXIES)%'\n    trusted_headers:\n        [\n            'x-forwarded-for',\n            'x-forwarded-proto',\n            'x-forwarded-port',\n            'x-forwarded-prefix',\n        ]\n\n    # Note that the session will be started ONLY if you read or write from it.\n    # Sessions are stored in database, because saving sessions in Redis can give race conditions.\n    # See last paragraph of https://symfony.com/doc/current/session.html#store-sessions-in-a-key-value-database-redis\n    #\n    # PHP session handling is often (in Debian/Ubuntu) not doing gargage collection for sessions\n    # (session.gc_probability option in PHP).\n    # Hence we do also not want to set gc_maxlifetime for idle periods.\n    # We set our cookie session lifetime to the same value as remember_me token.\n    # More info: https://symfony.com/doc/current/session.html#session-idle-time-keep-alive\n    session:\n        handler_id: Symfony\\Component\\HttpFoundation\\Session\\Storage\\Handler\\PdoSessionHandler\n        cookie_secure: auto\n        cookie_samesite: lax\n        cookie_lifetime: 10512000 # 4 months long lifetime\n        storage_factory_id: session.storage.factory.native\n\n    http_client:\n        default_options:\n            headers:\n                'User-Agent': 'Mbin/1.10.0-rc1 (+https://%kbin_domain%/agent)'\n\n    #esi: true\n    #fragments: true\n    php_errors:\n        log: true\n\n    property_info:\n        enabled: true\n        with_constructor_extractor: true\n"
  },
  {
    "path": "config/packages/knpu_oauth2_client.yaml",
    "content": "knpu_oauth2_client:\n  clients:\n    azure:\n      type: azure\n      client_id: '%oauth_azure_id%'\n      client_secret: '%oauth_azure_secret%'\n      tenant: '%oauth_azure_tenant%'\n      redirect_route: oauth_azure_verify\n      redirect_params: { }\n    facebook:\n      type: facebook\n      client_id: '%oauth_facebook_id%'\n      client_secret: '%oauth_facebook_secret%'\n      redirect_route: oauth_facebook_verify\n      redirect_params: { }\n      graph_api_version: v2.12\n    google:\n      type: google\n      client_id: '%oauth_google_id%'\n      client_secret: '%oauth_google_secret%'\n      redirect_route: oauth_google_verify\n      redirect_params: { }\n    discord:\n      type: discord\n      client_id: '%oauth_discord_id%'\n      client_secret: '%oauth_discord_secret%'\n      redirect_route: oauth_discord_verify\n      redirect_params: { }\n    github:\n      type: github\n      client_id: '%oauth_github_id%'\n      client_secret: '%oauth_github_secret%'\n      redirect_route: oauth_github_verify\n      redirect_params: { }\n    privacyportal:\n      type: generic\n      provider_class: League\\OAuth2\\Client\\Provider\\PrivacyPortal\n      client_id: '%oauth_privacyportal_id%'\n      client_secret: '%oauth_privacyportal_secret%'\n      redirect_route: oauth_privacyportal_verify\n      redirect_params: { }\n    keycloak:\n      type: keycloak\n      client_id: '%oauth_keycloak_id%'\n      client_secret: '%oauth_keycloak_secret%'\n      auth_server_url: '%oauth_keycloak_uri%'\n      realm: '%oauth_keycloak_realm%'\n      version: '%oauth_keycloak_version%'\n      redirect_route: oauth_keycloak_verify\n      redirect_params: { }\n    simplelogin:\n      type: generic\n      client_id: '%oauth_simplelogin_id%'\n      client_secret: '%oauth_simplelogin_secret%'\n      redirect_route: oauth_simplelogin_verify\n      redirect_params: { }\n      provider_class: 'App\\Provider\\SimpleLogin'\n    zitadel:\n      type: generic\n      client_id: '%oauth_zitadel_id%'\n      client_secret: '%oauth_zitadel_secret%'\n      provider_options:\n          base_url: '%oauth_zitadel_base_url%'\n      redirect_route: oauth_zitadel_verify\n      redirect_params: { }\n      provider_class: 'App\\Provider\\Zitadel'\n    authentik:\n      type: generic\n      client_id: '%oauth_authentik_id%'\n      client_secret: '%oauth_authentik_secret%'\n      provider_options:\n          base_url: '%oauth_authentik_base_url%'\n      redirect_route: oauth_authentik_verify\n      redirect_params: { }\n      provider_class: 'App\\Provider\\Authentik'"
  },
  {
    "path": "config/packages/league_oauth2_server.yaml",
    "content": "league_oauth2_server:\n    authorization_server:\n        private_key: \"%env(resolve:OAUTH_PRIVATE_KEY)%\"\n        private_key_passphrase: \"%env(resolve:OAUTH_PASSPHRASE)%\"\n        encryption_key: \"%env(resolve:OAUTH_ENCRYPTION_KEY)%\"\n        access_token_ttl: PT1H\n        refresh_token_ttl: P1M\n        auth_code_ttl: PT10M\n        enable_client_credentials_grant: true\n        enable_password_grant: false\n        enable_refresh_token_grant: true\n        enable_auth_code_grant: true\n        require_code_challenge_for_public_clients: true\n    client:\n        classname: App\\Entity\\Client\n    resource_server:\n        public_key: \"%env(resolve:OAUTH_PUBLIC_KEY)%\"\n    scopes:\n        available:\n            [\n                \"read\",\n                \"write\",\n                \"delete\",\n                \"subscribe\",\n                \"block\",\n                \"vote\",\n                \"report\",\n                \"domain\",\n                \"domain:subscribe\",\n                \"domain:block\",\n                \"entry\",\n                \"entry:create\",\n                \"entry:edit\",\n                \"entry:delete\",\n                \"entry:vote\",\n                \"entry:report\",\n                \"entry_comment\",\n                \"entry_comment:create\",\n                \"entry_comment:edit\",\n                \"entry_comment:delete\",\n                \"entry_comment:vote\",\n                \"entry_comment:report\",\n                \"magazine\",\n                \"magazine:subscribe\",\n                \"magazine:block\",\n                \"post\",\n                \"post:create\",\n                \"post:edit\",\n                \"post:delete\",\n                \"post:vote\",\n                \"post:report\",\n                \"post_comment\",\n                \"post_comment:create\",\n                \"post_comment:edit\",\n                \"post_comment:delete\",\n                \"post_comment:vote\",\n                \"post_comment:report\",\n                \"bookmark\",\n                \"bookmark:add\",\n                \"bookmark:remove\",\n                \"bookmark_list\",\n                \"bookmark_list:read\",\n                \"bookmark_list:edit\",\n                \"bookmark_list:delete\",\n                \"user\",\n                \"user:profile\",\n                \"user:profile:read\",\n                \"user:profile:edit\",\n                \"user:message\",\n                \"user:message:read\",\n                \"user:message:create\",\n                \"user:notification\",\n                \"user:notification:read\",\n                \"user:notification:delete\",\n                \"user:notification:edit\",\n                \"user:oauth_clients\",\n                \"user:oauth_clients:read\",\n                \"user:oauth_clients:edit\",\n                \"user:follow\",\n                \"user:block\",\n                \"moderate\",\n                \"moderate:entry\",\n                \"moderate:entry:language\",\n                \"moderate:entry:pin\",\n                \"moderate:entry:lock\",\n                \"moderate:entry:set_adult\",\n                \"moderate:entry:trash\",\n                \"moderate:entry_comment\",\n                \"moderate:entry_comment:language\",\n                \"moderate:entry_comment:set_adult\",\n                \"moderate:entry_comment:trash\",\n                \"moderate:post\",\n                \"moderate:post:language\",\n                \"moderate:post:pin\",\n                \"moderate:post:lock\",\n                \"moderate:post:set_adult\",\n                \"moderate:post:trash\",\n                \"moderate:post_comment\",\n                \"moderate:post_comment:language\",\n                \"moderate:post_comment:set_adult\",\n                \"moderate:post_comment:trash\",\n                \"moderate:magazine\",\n                \"moderate:magazine:ban\",\n                \"moderate:magazine:ban:read\",\n                \"moderate:magazine:ban:create\",\n                \"moderate:magazine:ban:delete\",\n                \"moderate:magazine:list\",\n                \"moderate:magazine:reports\",\n                \"moderate:magazine:reports:read\",\n                \"moderate:magazine:reports:action\",\n                \"moderate:magazine:trash:read\",\n                \"moderate:magazine_admin\",\n                \"moderate:magazine_admin:create\",\n                \"moderate:magazine_admin:delete\",\n                \"moderate:magazine_admin:update\",\n                \"moderate:magazine_admin:theme\",\n                \"moderate:magazine_admin:moderators\",\n                \"moderate:magazine_admin:badges\",\n                \"moderate:magazine_admin:tags\",\n                \"moderate:magazine_admin:stats\",\n                \"admin\",\n                \"admin:entry:purge\",\n                \"admin:entry_comment:purge\",\n                \"admin:post:purge\",\n                \"admin:post_comment:purge\",\n                \"admin:magazine\",\n                \"admin:magazine:move_entry\",\n                \"admin:magazine:purge\",\n                \"admin:magazine:moderate\",\n                \"admin:user\",\n                \"admin:user:ban\",\n                \"admin:user:verify\",\n                \"admin:user:delete\",\n                \"admin:user:purge\",\n                \"admin:instance\",\n                \"admin:instance:stats\",\n                \"admin:instance:settings\",\n                \"admin:instance:settings:read\",\n                \"admin:instance:settings:edit\",\n                \"admin:instance:information:edit\",\n                \"admin:federation\",\n                \"admin:federation:read\",\n                \"admin:federation:update\",\n                \"admin:oauth_clients\",\n                \"admin:oauth_clients:read\",\n                \"admin:oauth_clients:revoke\",\n            ]\n        default: [\"read\"]\n    persistence:\n        doctrine:\n            entity_manager: default\n\nwhen@test:\n    league_oauth2_server:\n        persistence:\n            doctrine:\n                entity_manager: default\n"
  },
  {
    "path": "config/packages/liip_imagine.yaml",
    "content": "# Documentation on how to configure the bundle can be found at: https://symfony.com/doc/current/bundles/LiipImagineBundle/basic-usage.html\n\nliip_imagine:\n    resolvers:\n        kbin.liip_resolver:\n            flysystem:\n                filesystem_service: oneup_flysystem.public_uploads_filesystem_filesystem\n                root_url: '%kbin_storage_url%'\n                cache_prefix: cache\n                visibility: public\n\n    loaders:\n        kbin.liip_loader:\n            flysystem:\n                filesystem_service: oneup_flysystem.public_uploads_filesystem_filesystem\n\n    driver: gd\n    cache: kbin.liip_resolver\n    data_loader: kbin.liip_loader\n    default_image: null\n    twig:\n        mode: lazy\n    default_filter_set_settings:\n        quality: 90\n\n    controller:\n        # Set this value to 301 if you want to enable image resolve redirects using 301 *cached* responses (eg. when behind Nginx)\n        redirect_response_code: 302\n\n    webp:\n        generate: true\n        quality: 90\n        cache: ~\n        data_loader: ~\n        post_processors: []\n\n    filter_sets:\n        entry_thumb:\n            filters:\n                auto_rotate: ~\n                thumbnail: { size: [380, 380], mode: inset }\n        avatar_thumb:\n            filters:\n                auto_rotate: ~\n                thumbnail: { size: [100, 100], mode: fixed }\n        post_thumb:\n            filters:\n                auto_rotate: ~\n                thumbnail: { size: [600, 500], mode: inset }\n        user_cover:\n            filters:\n                auto_rotate: ~\n                thumbnail: { size: [1500, 500], mode: fixed }\n        magazine_banner:\n            filters:\n                auto_rotate: ~\n                thumbnail: { size: [1500, 300], mode: fixed }\n"
  },
  {
    "path": "config/packages/lock.yaml",
    "content": "framework:\n    lock: \"%env(LOCK_DSN)%\"\n"
  },
  {
    "path": "config/packages/mailer.yaml",
    "content": "framework:\n    mailer:\n        dsn: \"%env(MAILER_DSN)%\"\n"
  },
  {
    "path": "config/packages/mercure.yaml",
    "content": "mercure:\n    hubs:\n        default:\n            url: \"%env(MERCURE_URL)%\"\n            public_url: \"%env(MERCURE_PUBLIC_URL)%\"\n            jwt:\n                secret: \"%env(MERCURE_JWT_SECRET)%\"\n                publish: \"*\"\n"
  },
  {
    "path": "config/packages/messenger.yaml",
    "content": "framework:\n    messenger:\n        # Uncomment this (and the failed transport below) to send failed messages to this transport for later handling.\n        failure_transport: failed\n\n        transports:\n            # https://symfony.com/doc/current/messenger.html#transport-configuration\n            sync: \"sync://\"\n            async:\n                dsn: \"%env(MESSENGER_TRANSPORT_DSN)%\"\n                options:\n                    queues:\n                        async:\n                            arguments:\n                                x-queue-version: 2\n                                x-queue-type: 'classic'\n                    exchange:\n                        name: async\n                retry_strategy:\n                    max_retries: 5\n                    delay: 300000\n                    multiplier: 4\n                    max_delay: 76800000\n                    jitter: 0\n                serializer: messenger.transport.symfony_serializer\n            inbox:\n                dsn: \"%env(MESSENGER_TRANSPORT_DSN)%\"\n                options:\n                    queues:\n                        inbox:\n                            arguments:\n                                x-queue-version: 2\n                                x-queue-type: 'classic'\n                    exchange:\n                        name: inbox\n                retry_strategy:\n                    max_retries: 5\n                    delay: 300000\n                    multiplier: 4\n                    max_delay: 76800000\n                    jitter: 0\n                serializer: messenger.transport.symfony_serializer\n            receive:\n                dsn: \"%env(MESSENGER_TRANSPORT_DSN)%\"\n                options:\n                    queues:\n                        receive:\n                            arguments:\n                                x-queue-version: 2\n                                x-queue-type: 'classic'\n                    exchange:\n                        name: receive\n                retry_strategy:\n                    max_retries: 5\n                    delay: 300000\n                    multiplier: 4\n                    max_delay: 76800000\n                    jitter: 0\n                serializer: messenger.transport.symfony_serializer\n            deliver:\n                dsn: \"%env(MESSENGER_TRANSPORT_DSN)%\"\n                options:\n                    queues:\n                        deliver:\n                            arguments:\n                                x-queue-version: 2\n                                x-queue-type: 'classic'\n                    exchange:\n                        name: deliver\n                retry_strategy:\n                    max_retries: 5\n                    delay: 300000\n                    multiplier: 4\n                    max_delay: 76800000\n                    jitter: 0\n                serializer: messenger.transport.symfony_serializer\n            outbox:\n                dsn: \"%env(MESSENGER_TRANSPORT_DSN)%\"\n                options:\n                    queues:\n                        outbox:\n                            arguments:\n                                x-queue-version: 2\n                                x-queue-type: 'classic'\n                    exchange:\n                        name: outbox\n                retry_strategy:\n                    max_retries: 5\n                    delay: 300000\n                    multiplier: 4\n                    max_delay: 76800000\n                    jitter: 0\n                serializer: messenger.transport.symfony_serializer\n            resolve:\n                dsn: \"%env(MESSENGER_TRANSPORT_DSN)%\"\n                options:\n                    queues:\n                        resolve:\n                            arguments:\n                                x-queue-version: 2\n                                x-queue-type: 'classic'\n                    exchange:\n                        name: resolve\n                retry_strategy:\n                    max_retries: 5\n                    delay: 300000\n                    multiplier: 4\n                    max_delay: 76800000\n                    jitter: 0\n                serializer: messenger.transport.symfony_serializer\n            old:\n                dsn: \"%env(MESSENGER_TRANSPORT_DSN)%\"\n                options:\n                    queues:\n                        messages: ~\n                retry_strategy:\n                    max_retries: 5\n                    delay: 300000\n                    multiplier: 4\n                    max_delay: 76800000\n                    jitter: 0\n                serializer: messenger.transport.symfony_serializer\n            failed:\n                failure_transport: dead\n                retry_strategy:\n                    max_retries: 3\n                    delay: 1800000\n                    multiplier: 2\n                    jitter: 0\n                dsn: \"doctrine://default?queue_name=failed\"\n                serializer: messenger.transport.symfony_serializer\n            dead:\n                dsn: \"doctrine://default?queue_name=dead\"\n                serializer: messenger.transport.symfony_serializer\n\n\n        routing:\n            # Route your messages to the transports\n            App\\Message\\Contracts\\AsyncMessageInterface: async\n            App\\Message\\Contracts\\ActivityPubInboxInterface: inbox\n            App\\Message\\Contracts\\ActivityPubInboxReceiveInterface: receive\n            App\\Message\\Contracts\\ActivityPubOutboxDeliverInterface: deliver\n            App\\Message\\Contracts\\ActivityPubOutboxInterface: outbox\n            App\\Message\\Contracts\\ActivityPubResolveInterface: resolve\n            # Consider adding SendEmail from Mailer via async messenger as well:\n            #Symfony\\Component\\Mailer\\Messenger\\SendEmailMessage: async\n            #App\\Message\\Contracts\\SendConfirmationEmailInterface: async\n# when@test:\n#    framework:\n#        messenger:\n#            transports:\n#                # replace with your transport name here (e.g., my_transport: 'in-memory://')\n#                # For more Messenger testing tools, see https://github.com/zenstruck/messenger-test\n#                async: 'in-memory://'\n"
  },
  {
    "path": "config/packages/meteo_concept_h_captcha.yaml",
    "content": "meteo_concept_h_captcha:\n    hcaptcha:\n        site_key: \"%hcaptcha_site_key%\"\n        secret: \"%hcaptcha_secret%\"\n"
  },
  {
    "path": "config/packages/monolog.yaml",
    "content": "monolog:\n    channels:\n        - deprecation # Deprecations are logged in the dedicated \"deprecation\" channel when it exists\n\nwhen@dev:\n    monolog:\n        handlers:\n            main:\n                type: service\n                id: log_filter_handler\n                handler: rotating\n            rotating:\n                type: rotating_file\n                path: '%kernel.logs_dir%/%kernel.environment%.log'\n                # Or use: \"debug\" instead of \"info\" for more verbose log (debug) messages\n                level: info\n                # Enable full stacktrace, set this to false to disable stacktraces\n                include_stacktraces: true\n                max_files: 10\n                channels: ['!event']\n            stderr:\n                type: stream\n                path: '%kernel.logs_dir%/%kernel.environment%.log'\n                level: info\n                channels: ['!event']\n            # uncomment to get logging in your browser\n            # you may have to allow bigger header sizes in your Web server configuration\n            #firephp:\n            #    type: firephp\n            #    level: info\n            #chromephp:\n            #    type: chromephp\n            #    level: info\n            console:\n                type: console\n                process_psr_3_messages: false\n                channels: ['!event', '!doctrine', '!console']\n            # uncomment if you wish to see depreciation messages to console\n            # by default it's already logged to the log file\n            #deprecation:\n            #    type: stream\n            #    channels: [deprecation]\n            #    path: php://stderr\n            #    formatter: monolog.formatter.json\n\nwhen@test:\n    monolog:\n        handlers:\n            main:\n                type: fingers_crossed\n                action_level: error\n                handler: filtered\n                excluded_http_codes: [404, 405]\n                channels: ['!event']\n            filtered:\n                type: service\n                id: log_filter_handler\n                handler: nested\n            nested:\n                type: stream\n                path: '%kernel.logs_dir%/%kernel.environment%.log'\n                level: debug\n\nwhen@prod:\n    monolog:\n        handlers:\n            main:\n                type: fingers_crossed\n                action_level: error\n                handler: filtered\n                excluded_http_codes: [404, 405]\n                channels: [\"!deprecation\"]\n                buffer_size: 50 # How many messages should be saved? Prevent memory leaks\n            filtered:\n                type: service\n                id: log_filter_handler\n                handler: nested\n            nested:\n                type: group\n                members: [nested_file, nested_stderr]\n            nested_file:\n                type: rotating_file\n                max_files: 7\n                path: '%kernel.logs_dir%/%kernel.environment%.log'\n                level: warning\n                formatter: monolog.formatter.json\n            nested_stderr:\n                type: stream\n                path: 'php://stderr'\n                level: warning\n                formatter: monolog.formatter.json\n            console:\n                type: console\n                process_psr_3_messages: false\n                channels: ['!event', '!doctrine']\n            deprecation:\n                type: stream\n                channels: [deprecation]\n                path: php://stderr\n                formatter: monolog.formatter.json\n"
  },
  {
    "path": "config/packages/nelmio_api_doc.yaml",
    "content": "nelmio_api_doc:\n    documentation:\n        info:\n            title: Mbin API\n            description: Documentation for interacting with content on Mbin through the API\n            version: 1.0.0\n        paths:\n            /authorize:\n                get:\n                    tags:\n                        - oauth\n                    summary: Begin an oauth2 authorization_code grant flow\n                    parameters:\n                        - name: response_type\n                          in: query\n                          schema:\n                              type: string\n                              default: code\n                              enum:\n                                  - code\n                          required: true\n                        - name: client_id\n                          in: query\n                          schema:\n                              type: string\n                          required: true\n                        - name: redirect_uri\n                          in: query\n                          description: One of the valid redirect_uris that were registered for your client during client creation.\n                          schema:\n                              type: string\n                              format: uri\n                          required: true\n                        - name: scope\n                          in: query\n                          description: A space delimited list of requested scopes\n                          schema:\n                              type: string\n                          required: true\n                        - name: state\n                          in: query\n                          description: A randomly generated state variable to be used to prevent CSRF attacks\n                          schema:\n                              type: string\n                          required: true\n                        - name: code_challenge\n                          in: query\n                          description: Required for public clients, begins PKCE flow when present\n                          schema:\n                              type: string\n                        - name: code_challenge_method\n                          in: query\n                          description: Required for public clients, sets the type of code challenge used\n                          schema:\n                              type: string\n                              enum:\n                                  - S256\n                                  - plain\n            /token:\n                post:\n                    tags:\n                        - oauth\n                    summary: Used to retrieve a Bearer token after receiving consent from the user\n                    requestBody:\n                        content:\n                            multipart/form-data:\n                                schema:\n                                    required:\n                                        - grant_type\n                                        - client_id\n                                    properties:\n                                        grant_type:\n                                            type: string\n                                            description: One of the three grant types available\n                                            enum:\n                                                - authorization_code\n                                                - refresh_token\n                                                - client_credentials\n                                        client_id:\n                                            type: string\n                                        client_secret:\n                                            type: string\n                                            description: Required if using the client_credentials or authorization_code flow with a confidential client\n                                        code_verifier:\n                                            type: string\n                                            description: Required if using the PKCE extension to authorization_code flow\n                                        code:\n                                            type: string\n                                            description: Required during authorization_code flow. The code retrieved after redirect during authorization_code flow.\n                                        refresh_token:\n                                            type: string\n                                            description: Required during refresh_token flow. This is the refresh token obtained after a successful authorization_code flow.\n                                        redirect_uri:\n                                            type: string\n                                            description: Required during authorization_code flow. One of the valid redirect_uris that were registered for your client during client creation.\n                                        scope:\n                                            type: string\n                                            description: Required during client_credentials flow. A space-delimited list of scopes the client token will be provided.\n        components:\n            securitySchemes:\n                oauth2:\n                    type: oauth2\n                    flows:\n                        clientCredentials:\n                            tokenUrl: /token\n                            scopes:\n                                read: Read all content you have access to.\n                                write: Create or edit any of your threads, posts, or comments.\n                                delete: Delete any of your threads, posts, or comments.\n                                report: Report threads, posts, or comments.\n                                vote: Upvote, downvote, or boost threads, posts, or comments.\n                                subscribe: Subscribe or follow any magazine, domain, or user, and view the magazines, domains, and users you subscribe to.\n                                block: Block or unblock any magazine, domain, or user, and view the magazines, domains, and users you have blocked.\n                                domain: Subscribe to or block domains, and view the domains you subscribe to or block.\n                                domain:subscribe: Subscribe or unsubscribe to domains and view the domains you subscribe to.\n                                domain:block: Block or unblock domains and view the domains you have blocked.\n                                entry: Create, edit, or delete your threads, and vote, boost, or report any thread.\n                                entry:create: Create new threads.\n                                entry:edit: Edit your existing threads.\n                                entry:vote: Vote or boost threads.\n                                entry:delete: Delete your existing threads.\n                                entry:report: Report any thread.\n                                entry_comment: Create, edit, or delete your comments in threads, and vote, boost, or report any comment in a thread.\n                                entry_comment:create: Create new comments in threads.\n                                entry_comment:edit: Edit your existing comments in threads.\n                                entry_comment:vote: Vote or boost comments in threads.\n                                entry_comment:delete: Delete your existing comments in threads.\n                                entry_comment:report: Report any comment in a thread.\n                                magazine: Subscribe to or block magazines, and view the magazines you subscribe to or block.\n                                magazine:subscribe: Subscribe or unsubscribe to magazines and view the magazines you subscribe to.\n                                magazine:block: Block or unblock magazines and view the magazines you have blocked.\n                                post: Create, edit, or delete your microblogs, and vote, boost, or report any microblog.\n                                post:create: Create new posts.\n                                post:edit: Edit your existing posts.\n                                post:vote: Vote or boost posts.\n                                post:delete: Delete your existing posts.\n                                post:report: Report any post.\n                                post_comment: Create, edit, or delete your comments on posts, and vote, boost, or report any comment on a post.\n                                post_comment:create: Create new comments on posts.\n                                post_comment:edit: Edit your existing comments on posts.\n                                post_comment:vote: Vote or boost comments on posts.\n                                post_comment:delete: Delete your existing comments on posts.\n                                post_comment:report: Report any comment on a post.\n                                user: Read and edit your profile, messages, notifications; follow or block other users; view lists of users you follow or block.\n                                user:profile: Read and edit your profile.\n                                user:profile:read: Read your profile.\n                                user:profile:edit: Edit your profile.\n                                user:message: Read your messages and send messages to other users.\n                                user:message:read: Read your messages.\n                                user:message:create: Send messages to other users.\n                                user:notification: Read and clear your notifications.\n                                user:notification:read: Read your notifications, including message notifications.\n                                user:notification:delete: Clear notifications.\n                                user:follow: Follow or unfollow users, and read a list of users you follow.\n                                user:block: Block or unblock users, and read a list of users you block.\n                                moderate: Perform any moderation action you have permission to perform in your moderated magazines.\n                                moderate:entry: Moderate threads in your moderated magazines.\n                                moderate:entry:language: Change the language of threads in your moderated magazines.\n                                moderate:entry:pin: Pin threads to the top of your moderated magazines.\n                                moderate:entry:set_adult: Mark threads as NSFW in your moderated magazines.\n                                moderate:entry:trash: Trash or restore threads in your moderated magazines.\n                                moderate:entry_comment: Moderate comments in threads in your moderated magazines.\n                                moderate:entry_comment:language: Change the language of comments in threads in your moderated magazines.\n                                moderate:entry_comment:set_adult: Mark comments in threads as NSFW in your moderated magazines.\n                                moderate:entry_comment:trash: Trash or restore comments in threads in your moderated magazines.\n                                moderate:post: Moderate posts in your moderated magazines.\n                                moderate:post:language: Change the language of posts in your moderated magazines.\n                                moderate:post:pin: Pin posts to the top of your moderated magazines.\n                                moderate:post:set_adult: Mark posts as NSFW in your moderated magazines.\n                                moderate:post:trash: Trash or restore posts in your moderated magazines.\n                                moderate:post_comment: Moderate comments on posts in your moderated magazines.\n                                moderate:post_comment:language: Change the language of comments on posts in your moderated magazines.\n                                moderate:post_comment:set_adult: Mark comments on posts as NSFW in your moderated magazines.\n                                moderate:post_comment:trash: Trash or restore comments on posts in your moderated magazines.\n                                moderate:magazine: Manage bans, reports, and view trashed items in your moderated magazines.\n                                moderate:magazine:ban: Manage banned users in your moderated magazines.\n                                moderate:magazine:ban:read: View banned users in your moderated magazines.\n                                moderate:magazine:ban:create: Ban users in your moderated magazines.\n                                moderate:magazine:ban:delete: Unban users in your moderated magazines.\n                                moderate:magazine:list: Read a list of your moderated magazines.\n                                moderate:magazine:reports: Manage reports in your moderated magazines.\n                                moderate:magazine:reports:read: Read reports in your moderated magazines.\n                                moderate:magazine:reports:action: Accept or reject reports in your moderated magazines.\n                                moderate:magazine:trash:read: View trashed content in your moderated magazines.\n                                moderate:magazine_admin: Create, edit, or delete your owned magazines.\n                                moderate:magazine_admin:create: Create new magazines.\n                                moderate:magazine_admin:delete: Delete any of your owned magazines.\n                                moderate:magazine_admin:update: Edit any of your owned magazines' rules, description, NSFW status, or icon.\n                                moderate:magazine_admin:theme: Edit the custom CSS of any of your owned magazines.\n                                moderate:magazine_admin:moderators: Add or remove moderators of any of your owned magazines.\n                                moderate:magazine_admin:badges: Create or remove badges from your owned magazines.\n                                moderate:magazine_admin:tags: Create or remove tags from your owned magazines.\n                                moderate:magazine_admin:stats: View the content, vote, and view stats of your owned magazines.\n                                admin: Perform any administrative action on your instance.\n                                admin:entry:purge: Completely delete any thread from your instance.\n                                admin:entry_comment:purge: Completely delete any comment in a thread from your instance.\n                                admin:post:purge: Completely delete any post from your instance.\n                                admin:post_comment:purge: Completely delete any comment on a post from your instance.\n                                admin:magazine: Move threads between, manage moderators of or completely delete magazines on your instance.\n                                admin:magazine:move_entry: Move threads between magazines on your instance.\n                                admin:magazine:purge: Completely delete magazines on your instance.\n                                admin:magazine:moderate: Manage moderators and owners of magazines.\n                                admin:user: Ban, verify, or completely delete users on your instance.\n                                admin:user:ban: Ban or unban users from your instance.\n                                admin:user:verify: Verify users on your instance.\n                                admin:user:delete: Delete a user from your instance, leaving a record of their username.\n                                admin:user:purge: Completely delete a user from your instance.\n                                admin:instance: View your instance's stats and settings, or update instance settings or information.\n                                admin:instance:stats: View your instance's stats.\n                                admin:instance:settings: View or update settings on your instance.\n                                admin:instance:settings:read: View settings on your instance.\n                                admin:instance:settings:edit: Update settings on your instance.\n                                admin:instance:information:edit: Update the About, FAQ, Contact, Terms of Service, and Privacy Policy on your instance.\n                                admin:federation: View and update current (de)federation settings of other instances on your instance.\n                                admin:federation:read: View a list of defederated instances on your instance.\n                                admin:federation:update: Add or remove instances to the list of defederated instances.\n                                admin:oauth_clients: View or revoke OAuth2 clients that exist on your instance.\n                                admin:oauth_clients:read: View the OAuth2 clients that exist on your instance, and their usage stats.\n                                admin:oauth_clients:revoke: Revoke access to OAuth2 clients on your instance.\n                        authorizationCode:\n                            authorizationUrl: /authorize\n                            tokenUrl: /token\n                            scopes:\n                                read: Read all content you have access to.\n                                write: Create or edit any of your threads, posts, or comments.\n                                delete: Delete any of your threads, posts, or comments.\n                                subscribe: Report threads, posts, or comments.\n                                block: Upvote, downvote, or boost threads, posts, or comments.\n                                vote: Subscribe or follow any magazine, domain, or user, and view the magazines, domains, and users you subscribe to.\n                                report: Block or unblock any magazine, domain, or user, and view the magazines, domains, and users you have blocked.\n                                domain: Subscribe to or block domains, and view the domains you subscribe to or block.\n                                domain:subscribe: Subscribe or unsubscribe to domains and view the domains you subscribe to.\n                                domain:block: Block or unblock domains and view the domains you have blocked.\n                                entry: Create, edit, or delete your threads, and vote, boost, or report any thread.\n                                entry:create: Create new threads.\n                                entry:edit: Edit your existing threads.\n                                entry:delete: Delete your existing threads.\n                                entry:vote: Upvote, boost, or downvote any thread.\n                                entry:report: Report any thread.\n                                entry_comment: Create, edit, or delete your comments in threads, and vote, boost, or report any comment in a thread.\n                                entry_comment:create: Create new comments in threads.\n                                entry_comment:edit: Edit your existing comments in threads.\n                                entry_comment:delete: Delete your existing comments in threads.\n                                entry_comment:vote: Upvote, boost, or downvote any comment in a thread.\n                                entry_comment:report: Report any comment in a thread.\n                                magazine: Subscribe to or block magazines, and view the magazines you subscribe to or block.\n                                magazine:subscribe: Subscribe or unsubscribe to magazines and view the magazines you subscribe to.\n                                magazine:block: Block or unblock magazines and view the magazines you have blocked.\n                                post: Create, edit, or delete your microblogs, and vote, boost, or report any microblog.\n                                post:create: Create new posts.\n                                post:edit: Edit your existing posts.\n                                post:delete: Delete your existing posts.\n                                post:vote: Upvote, boost, or downvote any post.\n                                post:report: Report any post.\n                                post_comment: Create, edit, or delete your comments on posts, and vote, boost, or report any comment on a post.\n                                post_comment:create: Create new comments on posts.\n                                post_comment:edit: Edit your existing comments on posts.\n                                post_comment:delete: Delete your existing comments on posts.\n                                post_comment:vote: Upvote, boost, or downvote any comment on a post.\n                                post_comment:report: Report any comment on a post.\n                                user: Read and edit your profile, messages, notifications; follow or block other users; view lists of users you follow or block.\n                                user:profile: Read and edit your profile.\n                                user:profile:read: Read your profile.\n                                user:profile:edit: Edit your profile.\n                                user:message: Read your messages and send messages to other users.\n                                user:message:read: Read your messages.\n                                user:message:create: Send messages to other users.\n                                user:notification: Read and clear your notifications.\n                                user:notification:read: Read your notifications, including message notifications.\n                                user:notification:delete: Clear notifications.\n                                user:follow: Follow or unfollow users, and read a list of users you follow.\n                                user:block: Block or unblock users, and read a list of users you block.\n                                moderate: Perform any moderation action you have permission to perform in your moderated magazines.\n                                moderate:entry: Moderate threads in your moderated magazines.\n                                moderate:entry:language: Change the language of threads in your moderated magazines.\n                                moderate:entry:pin: Pin threads to the top of your moderated magazines.\n                                moderate:entry:set_adult: Mark threads as NSFW in your moderated magazines.\n                                moderate:entry:trash: Trash or restore threads in your moderated magazines.\n                                moderate:entry_comment: Moderate comments in threads in your moderated magazines.\n                                moderate:entry_comment:language: Change the language of comments in threads in your moderated magazines.\n                                moderate:entry_comment:set_adult: Mark comments in threads as NSFW in your moderated magazines.\n                                moderate:entry_comment:trash: Trash or restore comments in threads in your moderated magazines.\n                                moderate:post: Moderate posts in your moderated magazines.\n                                moderate:post:language: Change the language of posts in your moderated magazines.\n                                moderate:post:set_adult: Mark posts as NSFW in your moderated magazines.\n                                moderate:post:trash: Trash or restore posts in your moderated magazines.\n                                moderate:post_comment: Moderate comments on posts in your moderated magazines.\n                                moderate:post_comment:language: Change the language of comments on posts in your moderated magazines.\n                                moderate:post_comment:set_adult: Mark comments on posts as NSFW in your moderated magazines.\n                                moderate:post_comment:trash: Trash or restore comments on posts in your moderated magazines.\n                                moderate:magazine: Manage bans, reports, and view trashed items in your moderated magazines.\n                                moderate:magazine:ban: Manage banned users in your moderated magazines.\n                                moderate:magazine:ban:read: View banned users in your moderated magazines.\n                                moderate:magazine:ban:create: Ban users in your moderated magazines.\n                                moderate:magazine:ban:delete: Unban users in your moderated magazines.\n                                moderate:magazine:list: Read a list of your moderated magazines.\n                                moderate:magazine:reports: Manage reports in your moderated magazines.\n                                moderate:magazine:reports:read: Read reports in your moderated magazines.\n                                moderate:magazine:reports:action: Accept or reject reports in your moderated magazines.\n                                moderate:magazine:trash:read: View trashed content in your moderated magazines.\n                                moderate:magazine_admin: Create, edit, or delete your owned magazines.\n                                moderate:magazine_admin:create: Create new magazines.\n                                moderate:magazine_admin:delete: Delete any of your owned magazines.\n                                moderate:magazine_admin:update: Edit any of your owned magazines' rules, description, NSFW status, or icon.\n                                moderate:magazine_admin:theme: Edit the custom CSS of any of your owned magazines.\n                                moderate:magazine_admin:moderators: Add or remove moderators of any of your owned magazines.\n                                moderate:magazine_admin:badges: Create or remove badges from your owned magazines.\n                                moderate:magazine_admin:tags: Create or remove tags from your owned magazines.\n                                moderate:magazine_admin:stats: View the content, vote, and view stats of your owned magazines.\n                                admin: Perform any administrative action on your instance.\n                                admin:entry:purge: Completely delete any thread from your instance.\n                                admin:entry_comment:purge: Completely delete any comment in a thread from your instance.\n                                admin:post:purge: Completely delete any post from your instance.\n                                admin:post_comment:purge: Completely delete any comment on a post from your instance.\n                                admin:magazine: Move threads between, manage moderators of or completely delete magazines on your instance.\n                                admin:magazine:move_entry: Move threads between magazines on your instance.\n                                admin:magazine:purge: Completely delete magazines on your instance.\n                                admin:magazine:moderate: Manage moderators and owners of magazines.\n                                admin:user: Ban, verify, or completely delete users on your instance.\n                                admin:user:ban: Ban or unban users from your instance.\n                                admin:user:verify: Verify users on your instance.\n                                admin:user:delete: Delete a user from your instance, leaving a record of their username.\n                                admin:user:purge: Completely delete a user from your instance.\n                                admin:instance: View your instance's stats and settings, or update instance settings or information.\n                                admin:instance:stats: View your instance's stats.\n                                admin:instance:settings: View or update settings on your instance.\n                                admin:instance:settings:read: View settings on your instance.\n                                admin:instance:settings:edit: Update settings on your instance.\n                                admin:instance:information:edit: Update the About, FAQ, Contact, Terms of Service, and Privacy Policy on your instance.\n                                admin:federation: View and update current (de)federation settings of other instances on your instance.\n                                admin:federation:read: View a list of defederated instances on your instance.\n                                admin:federation:update: Add or remove instances to the list of defederated instances.\n                                admin:oauth_clients: View or revoke OAuth2 clients that exist on your instance.\n                                admin:oauth_clients:read: View the OAuth2 clients that exist on your instance, and their usage stats.\n                                admin:oauth_clients:revoke: Revoke access to OAuth2 clients on your instance.\n\n    areas: # to filter documented areas\n        path_patterns:\n            - ^/api(?!(/doc$|/\\.well-known.*|/docs.*|/doc\\.json$|/\\{index\\}|/contexts.*)) # Accepts routes under /api except /api/doc\n"
  },
  {
    "path": "config/packages/nelmio_cors.yaml",
    "content": "nelmio_cors:\n    defaults:\n        origin_regex: true\n        allow_origin: [\"%env(CORS_ALLOW_ORIGIN)%\"]\n        allow_methods: [\"GET\", \"OPTIONS\", \"POST\", \"PUT\", \"PATCH\", \"DELETE\"]\n        allow_headers: [\"Content-Type\", \"Authorization\"]\n        max_age: 3600\n    paths:\n        \"^/api/\":\n            allow_origin: [\"*\"]\n            allow_headers: [\"X-Custom-Auth\"]\n            allow_methods: [\"POST\", \"PUT\", \"GET\", \"DELETE\"]\n            expose_headers: [\"Link\"]\n            max_age: 3600\n        \"^/.well-known/|^/nodeinfo/\":\n            allow_origin: [\"*\"]\n            allow_methods: [\"GET\"]\n            max_age: 3600\n"
  },
  {
    "path": "config/packages/nyholm_psr7.yaml",
    "content": "services:\n    # Register nyholm/psr7 services for autowiring with PSR-17 (HTTP factories)\n    Psr\\Http\\Message\\RequestFactoryInterface: \"@nyholm.psr7.psr17_factory\"\n    Psr\\Http\\Message\\ResponseFactoryInterface: \"@nyholm.psr7.psr17_factory\"\n    Psr\\Http\\Message\\ServerRequestFactoryInterface: \"@nyholm.psr7.psr17_factory\"\n    Psr\\Http\\Message\\StreamFactoryInterface: \"@nyholm.psr7.psr17_factory\"\n    Psr\\Http\\Message\\UploadedFileFactoryInterface: \"@nyholm.psr7.psr17_factory\"\n    Psr\\Http\\Message\\UriFactoryInterface: \"@nyholm.psr7.psr17_factory\"\n\n    nyholm.psr7.psr17_factory:\n        class: Nyholm\\Psr7\\Factory\\Psr17Factory\n"
  },
  {
    "path": "config/packages/oneup_flysystem.yaml",
    "content": "# Read the documentation: https://github.com/1up-lab/OneupFlysystemBundle\noneup_flysystem:\n  adapters:\n    default_adapter:\n      local:\n        location: \"%kernel.project_dir%/public/%uploads_dir_name%\"\n\n    kbin.s3_adapter:\n     awss3v3:\n       client: kbin.s3_client\n       bucket: \"%amazon.s3.bucket%\"\n       options:\n         ACL: public-read\n    # If using an s3 bucket with owner-full-control and no ACL, the following may work:\n    #    options:\n    #      ACL: ''\n\n  filesystems:\n    public_uploads_filesystem:\n      adapter: default_adapter\n      #adapter: kbin.s3_adapter\n      alias: League\\Flysystem\\Filesystem\n"
  },
  {
    "path": "config/packages/prod/routing.yaml",
    "content": "framework:\n    router:\n        strict_requirements: null\n"
  },
  {
    "path": "config/packages/rate_limiter.yaml",
    "content": "framework:\n  rate_limiter:\n    # 1 anonymous read per second\n    anonymous_api_read:\n      policy: 'sliding_window'\n      limit: 60\n      interval: '60 seconds'\n    # 2 API clients created every 6 hours per IP\n    api_oauth_client:\n      policy: 'sliding_window'\n      limit: 2\n      interval: '360 minutes'\n    # 2 tokens revoked every second, bursting up to 60\n    api_oauth_token_revoke:\n      policy: 'sliding_window'\n      limit: 60\n      interval: '30 seconds'\n    # 1 clients deleted every minute, bursting up to 5\n    api_oauth_client_delete:\n      policy: 'sliding_window'\n      limit: 5\n      interval: '5 minutes'\n    # 2 deletes per second, bursting up to 240 in 2 minutes\n    api_delete:\n      policy: 'sliding_window'\n      limit: 240\n      interval: '2 minutes'\n    # 2 messages per second, bursting up to 120\n    api_message:\n      policy: 'sliding_window'\n      limit: 120\n      interval: '60 seconds'\n    # 2 reports per minute, bursting up to 10\n    api_report:\n      policy: 'sliding_window'\n      limit: 10\n      interval: '300 seconds'\n    # 2 reads per second, bursting up to 240\n    api_read:\n      policy: 'sliding_window'\n      limit: 240\n      interval: '2 minutes'\n    # 1 update per second, bursting up to 120\n    api_update:\n      policy: 'sliding_window'\n      limit: 120\n      interval: '2 minutes'\n    # 3.6 votes per minute, bursting up to 220 every hour\n    api_vote:\n      policy: 'sliding_window'\n      limit: 220\n      interval: '60 minutes'\n    # 1 entry per 3 minutes, bursting up to 2 (same rate as normal user)\n    api_entry:\n      policy: 'sliding_window'\n      limit: 2\n      interval: '6 minutes'\n    # 1 post/microblog per 2 minutes, bursting up to 10\n    api_post:\n      policy: 'sliding_window'\n      limit: 10\n      interval: '20 minutes'\n    # 1 image upload every 6 minutes, bursting up to 5 in 30 minutes\n    api_image:\n      policy: 'sliding_window'\n      limit: 5\n      interval: '30 minutes'\n    # 2 post or entry comments per minute, bursting up to 20\n    api_comment:\n      policy: 'sliding_window'\n      limit: 20\n      interval: '10 minutes'\n    # 2 notification reads/updates/deletes per second, bursting up to 240\n    api_notification:\n      policy: 'sliding_window'\n      limit: 240\n      interval: '2 minutes'\n    # 3 moderation actions per second, bursting up to 360\n    api_moderate:\n      policy: 'sliding_window'\n      limit: 360\n      interval: '2 minutes'\n    vote:\n      policy: 'sliding_window'\n      limit: 220\n      interval: '60 minutes'\n    entry:\n      policy: 'fixed_window'\n      limit: 20\n      interval: '60 minutes'\n    entry_comment:\n      policy: 'sliding_window'\n      limit: 30\n      interval: '60 minutes'\n    post:\n      policy: 'fixed_window'\n      limit: 30\n      interval: '60 minutes'\n    post_comment:\n      policy: 'sliding_window'\n      limit: 40\n      interval: '60 minutes'\n    user_register:\n      policy: 'fixed_window'\n      limit: 2\n      interval: '360 minutes'\n    magazine:\n      policy: 'fixed_window'\n      limit: 3\n      interval: '360 minutes'\n    contact:\n      policy: 'fixed_window'\n      limit: 3\n      interval: '2 minutes'\n    user_delete:\n      policy: 'fixed_window'\n      limit: 4\n      interval: '1 day'\n    ap_update_actor:\n      policy: 'sliding_window'\n      limit: 1\n      interval: '5 minutes'\n"
  },
  {
    "path": "config/packages/reset_password.yaml",
    "content": "symfonycasts_reset_password:\n    request_password_repository: App\\Repository\\ResetPasswordRequestRepository\n"
  },
  {
    "path": "config/packages/routing.yaml",
    "content": "framework:\n    router:\n        utf8: true\n\n        # Configure how to generate URLs in non-HTTP contexts, such as CLI commands.\n        # See https://symfony.com/doc/current/routing.html#generating-urls-in-commands\n        #default_uri: %env(DEFAULT_URI)%\n\nwhen@prod:\n    framework:\n        router:\n            strict_requirements: null\n"
  },
  {
    "path": "config/packages/rss_atom.yaml",
    "content": "debril_rss_atom:\n    # switch to true if you need to set cache-control: private\n    private: false\n    # switch to true if you need to always send status 200\n    force_refresh: true\n    content_type_xml: application/rss+xml\n"
  },
  {
    "path": "config/packages/scheb_2fa.yaml",
    "content": "# See the configuration reference at https://symfony.com/bundles/SchebTwoFactorBundle/6.x/configuration.html\nscheb_two_factor:\n    security_tokens:\n        - Symfony\\Component\\Security\\Core\\Authentication\\Token\\UsernamePasswordToken\n        - Symfony\\Component\\Security\\Http\\Authenticator\\Token\\PostAuthenticationToken\n\n    totp:\n        enabled: true\n        issuer: '%kbin_title%'\n        template: user/2fa.html.twig\n        leeway: 15 # allow codes window seconds away from the current time window (i.e. codes from before and after)\n        parameters:\n            image: 'https://%kbin_domain%/assets/icons/icon-144x144.png'\n\n    backup_codes:\n        enabled: true"
  },
  {
    "path": "config/packages/security.yaml",
    "content": "security:\n    # https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords\n    password_hashers:\n        Symfony\\Component\\Security\\Core\\User\\PasswordAuthenticatedUserInterface: 'auto'\n        App\\Entity\\User:\n            algorithm: auto\n\n    # https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider\n    providers:\n        app_user_provider:\n            entity:\n                class: App\\Entity\\User\n\n    firewalls:\n        dev:\n            # Ensure dev tools and static assets are always allowed\n            pattern: ^/(_(profiler|wdt)|css|images|js)/\n            security: false\n        api_token:\n            pattern: ^/token$\n            security: false\n        api:\n            pattern: ^/api\n            security: true\n            stateless: true\n            oauth2: true\n        image_resolver:\n            pattern: ^/media/cache/resolve\n            security: false\n        ap_contexts:\n            pattern: ^/contexts(\\.jsonld)?$\n            security: false\n        main:\n            lazy: true\n            provider: app_user_provider\n            # Activate different ways to authenticate:\n            # https://symfony.com/doc/current/security.html#the-firewall\n\n            # https://symfony.com/doc/current/security/impersonating_user.html\n            # switch_user: true\n            custom_authenticators:\n                - App\\Security\\KbinAuthenticator\n                - App\\Security\\AzureAuthenticator\n                - App\\Security\\DiscordAuthenticator\n                - App\\Security\\FacebookAuthenticator\n                - App\\Security\\GoogleAuthenticator\n                - App\\Security\\GithubAuthenticator\n                - App\\Security\\PrivacyPortalAuthenticator\n                - App\\Security\\KeycloakAuthenticator\n                - App\\Security\\SimpleLoginAuthenticator\n                - App\\Security\\ZitadelAuthenticator\n                - App\\Security\\AuthentikAuthenticator\n            logout:\n                enable_csrf: true\n                path: app_logout\n            user_checker: App\\Security\\UserChecker\n            remember_me:\n                secret: '%kernel.secret%'\n                lifetime: 10512000 # 4 Months\n                path: /\n                token_provider:\n                    doctrine: true\n                # see https://symfony.com/doc/current/security/remember_me.html#using-signed-remember-me-tokens\n                signature_properties: ['username', 'password', 'totpSecret', 'isBanned', 'isDeleted', 'markedForDeletionAt']\n            two_factor:\n                auth_form_path: 2fa_login\n                check_path: 2fa_login_check\n                enable_csrf: true\n                csrf_parameter: _csrf_token\n                csrf_token_id: 2fa\n\n            # https://symfony.com/doc/current/security/impersonating_user.html\n            # switch_user: true\n\n    # Note: Only the *first* matching rule is applied\n    access_control:\n        # This makes the logout route accessible during two-factor authentication. Allows the user to\n        # cancel two-factor authentication, if they need to.\n        - { path: ^/logout, role: PUBLIC_ACCESS }\n        # This makes the login form and oauth routes accessible even when private instance is enabled\n        - { path: ^/register, role: PUBLIC_ACCESS }\n        - { path: ^/verify/email, role: PUBLIC_ACCESS }\n        - { path: ^/reset-password, role: PUBLIC_ACCESS }\n        - { path: ^/login, role: PUBLIC_ACCESS }\n        - { path: ^/resend-email-activation, role: PUBLIC_ACCESS }\n        - { path: ^/oauth, role: PUBLIC_ACCESS }\n        - { path: ^/terms, role: PUBLIC_ACCESS }\n        - { path: ^/privacy-policy, role: PUBLIC_ACCESS }\n        # Allow ActivityPub routes to work publicly\n        - { attributes: { '_route': 'ap_webfinger' }, role: PUBLIC_ACCESS }\n        - { attributes: { '_route': 'ap_hostmeta' }, role: PUBLIC_ACCESS }\n        - { attributes: { '_route': 'ap_node_info' }, role: PUBLIC_ACCESS }\n        - { attributes: { '_route': 'ap_node_info_v2' }, role: PUBLIC_ACCESS }\n        - { attributes: { '_route': 'ap_instance' }, role: PUBLIC_ACCESS }\n        - { attributes: { '_route': 'ap_instance_front' }, role: PUBLIC_ACCESS }\n        - { attributes: { '_route': 'ap_instance_inbox' }, role: PUBLIC_ACCESS }\n        - {\n              attributes: { '_route': 'ap_instance_outbox' },\n              role: PUBLIC_ACCESS,\n          }\n        - { attributes: { '_route': 'ap_shared_inbox' }, role: PUBLIC_ACCESS }\n        - { attributes: { '_route': 'ap_object' }, role: PUBLIC_ACCESS }\n        - { attributes: { '_route': 'ap_user' }, role: PUBLIC_ACCESS }\n        - { attributes: { '_route': 'ap_user_inbox' }, role: PUBLIC_ACCESS }\n        - { attributes: { '_route': 'ap_user_outbox' }, role: PUBLIC_ACCESS }\n        - { attributes: { '_route': 'ap_user_followers' }, role: PUBLIC_ACCESS }\n        - { attributes: { '_route': 'ap_user_following' }, role: PUBLIC_ACCESS }\n        - { attributes: { '_route': 'ap_magazine' }, role: PUBLIC_ACCESS }\n        - { attributes: { '_route': 'ap_magazine_inbox' }, role: PUBLIC_ACCESS }\n        - {\n              attributes: { '_route': 'ap_magazine_outbox' },\n              role: PUBLIC_ACCESS,\n          }\n        - {\n              attributes: { '_route': 'ap_magazine_followers' },\n              role: PUBLIC_ACCESS,\n          }\n        - {\n              attributes: { '_route': 'ap_magazine_moderators' },\n              role: PUBLIC_ACCESS,\n          }\n        - { attributes: { '_route': 'ap_entry' }, role: PUBLIC_ACCESS }\n        - { attributes: { '_route': 'ap_entry_comment' }, role: PUBLIC_ACCESS }\n        - { attributes: { '_route': 'ap_post' }, role: PUBLIC_ACCESS }\n        - { attributes: { '_route': 'ap_post_comment' }, role: PUBLIC_ACCESS }\n        - { attributes: { '_route': 'ap_report' }, role: PUBLIC_ACCESS }\n        - { attributes: { '_route': 'ap_contexts' }, role: PUBLIC_ACCESS }\n        # allow custom style route access during two-factor authentication\n        # to avoid redirecting (wrongly) to this route after two-factor authentication is completed\n        - { path: ^/custom-style, role: PUBLIC_ACCESS }\n        # This ensures that the form can only be accessed when two-factor authentication is in progress.\n        - { path: ^/2fa, role: IS_AUTHENTICATED_2FA_IN_PROGRESS }\n\n        - { path: ^/admin, roles: [ROLE_ADMIN, ROLE_MODERATOR] }\n        - { path: ^/authorize, roles: IS_AUTHENTICATED_REMEMBERED }\n        - { path: ^/token, roles: PUBLIC_ACCESS }\n        - { path: ^/api/doc, roles: PUBLIC_ACCESS }\n        - { path: ^/api/client, roles: PUBLIC_ACCESS }\n        - { path: ^/api/info, roles: PUBLIC_ACCESS }\n        - { path: ^/api/instance, roles: PUBLIC_ACCESS }\n        - { path: ^/api/federated, roles: PUBLIC_ACCESS }\n        - { path: ^/api/defederated, roles: PUBLIC_ACCESS }\n        - { path: ^/api/dead, roles: PUBLIC_ACCESS }\n        - { path: ^/api/users/admins, role: PUBLIC_ACCESS }\n        - { path: ^/api/users/moderators, role: PUBLIC_ACCESS }\n        - { path: ^/, roles: PUBLIC_ACCESS_UNLESS_PRIVATE_INSTANCE }\n\n    role_hierarchy:\n        ROLE_OAUTH2_WRITE:\n            [\n                'ROLE_OAUTH2_ENTRY:CREATE',\n                'ROLE_OAUTH2_ENTRY:EDIT',\n                'ROLE_OAUTH2_ENTRY_COMMENT:CREATE',\n                'ROLE_OAUTH2_ENTRY_COMMENT:EDIT',\n                'ROLE_OAUTH2_POST:CREATE',\n                'ROLE_OAUTH2_POST:EDIT',\n                'ROLE_OAUTH2_POST_COMMENT:CREATE',\n                'ROLE_OAUTH2_POST_COMMENT:EDIT',\n                'ROLE_OAUTH2_BOOKMARK:ADD',\n                'ROLE_OAUTH2_BOOKMARK:REMOVE',\n                'ROLE_OAUTH2_BOOKMARK_LIST:EDIT',\n            ]\n        ROLE_OAUTH2_DELETE:\n            [\n                'ROLE_OAUTH2_ENTRY:DELETE',\n                'ROLE_OAUTH2_ENTRY_COMMENT:DELETE',\n                'ROLE_OAUTH2_POST:DELETE',\n                'ROLE_OAUTH2_POST_COMMENT:DELETE',\n                'ROLE_OAUTH2_BOOKMARK_LIST:DELETE',\n            ]\n        ROLE_OAUTH2_REPORT:\n            [\n                'ROLE_OAUTH2_ENTRY:REPORT',\n                'ROLE_OAUTH2_ENTRY_COMMENT:REPORT',\n                'ROLE_OAUTH2_POST:REPORT',\n                'ROLE_OAUTH2_POST_COMMENT:REPORT',\n            ]\n        ROLE_OAUTH2_VOTE:\n            [\n                'ROLE_OAUTH2_ENTRY:VOTE',\n                'ROLE_OAUTH2_ENTRY_COMMENT:VOTE',\n                'ROLE_OAUTH2_POST:VOTE',\n                'ROLE_OAUTH2_POST_COMMENT:VOTE',\n            ]\n        ROLE_OAUTH2_SUBSCRIBE:\n            [\n                'ROLE_OAUTH2_DOMAIN:SUBSCRIBE',\n                'ROLE_OAUTH2_MAGAZINE:SUBSCRIBE',\n                'ROLE_OAUTH2_USER:FOLLOW',\n            ]\n        'ROLE_OAUTH2_BOOKMARK':\n            [\n              'ROLE_OAUTH2_BOOKMARK:ADD',\n              'ROLE_OAUTH2_BOOKMARK:REMOVE',\n            ]\n        'ROLE_OAUTH2_BOOKMARK_LIST':\n            [\n              'ROLE_OAUTH2_BOOKMARK_LIST:READ',\n              'ROLE_OAUTH2_BOOKMARK_LIST:EDIT',\n              'ROLE_OAUTH2_BOOKMARK_LIST:DELETE',\n            ]\n        ROLE_OAUTH2_BLOCK:\n            [\n                'ROLE_OAUTH2_DOMAIN:BLOCK',\n                'ROLE_OAUTH2_MAGAZINE:BLOCK',\n                'ROLE_OAUTH2_USER:BLOCK',\n            ]\n        ROLE_OAUTH2_DOMAIN:\n            ['ROLE_OAUTH2_DOMAIN:SUBSCRIBE', 'ROLE_OAUTH2_DOMAIN:BLOCK']\n        ROLE_OAUTH2_ENTRY:\n            [\n                'ROLE_OAUTH2_ENTRY:CREATE',\n                'ROLE_OAUTH2_ENTRY:EDIT',\n                'ROLE_OAUTH2_ENTRY:DELETE',\n                'ROLE_OAUTH2_ENTRY:VOTE',\n                'ROLE_OAUTH2_ENTRY:REPORT',\n            ]\n        ROLE_OAUTH2_ENTRY_COMMENT:\n            [\n                'ROLE_OAUTH2_ENTRY_COMMENT:CREATE',\n                'ROLE_OAUTH2_ENTRY_COMMENT:EDIT',\n                'ROLE_OAUTH2_ENTRY_COMMENT:DELETE',\n                'ROLE_OAUTH2_ENTRY_COMMENT:VOTE',\n                'ROLE_OAUTH2_ENTRY_COMMENT:REPORT',\n            ]\n        ROLE_OAUTH2_MAGAZINE:\n            ['ROLE_OAUTH2_MAGAZINE:SUBSCRIBE', 'ROLE_OAUTH2_MAGAZINE:BLOCK']\n        ROLE_OAUTH2_POST:\n            [\n                'ROLE_OAUTH2_POST:CREATE',\n                'ROLE_OAUTH2_POST:EDIT',\n                'ROLE_OAUTH2_POST:DELETE',\n                'ROLE_OAUTH2_POST:VOTE',\n                'ROLE_OAUTH2_POST:REPORT',\n            ]\n        ROLE_OAUTH2_POST_COMMENT:\n            [\n                'ROLE_OAUTH2_POST_COMMENT:CREATE',\n                'ROLE_OAUTH2_POST_COMMENT:EDIT',\n                'ROLE_OAUTH2_POST_COMMENT:DELETE',\n                'ROLE_OAUTH2_POST_COMMENT:VOTE',\n                'ROLE_OAUTH2_POST_COMMENT:REPORT',\n            ]\n        ROLE_OAUTH2_USER:\n            [\n                'ROLE_OAUTH2_USER:PROFILE',\n                'ROLE_OAUTH2_USER:MESSAGE',\n                'ROLE_OAUTH2_USER:NOTIFICATION',\n                'ROLE_OAUTH2_USER:OAUTH_CLIENTS',\n                'ROLE_OAUTH2_USER:FOLLOW',\n                'ROLE_OAUTH2_USER:BLOCK',\n            ]\n        'ROLE_OAUTH2_USER:PROFILE':\n            ['ROLE_OAUTH2_USER:PROFILE:READ', 'ROLE_OAUTH2_USER:PROFILE:EDIT']\n        'ROLE_OAUTH2_USER:MESSAGE':\n            ['ROLE_OAUTH2_USER:MESSAGE:READ', 'ROLE_OAUTH2_USER:MESSAGE:CREATE']\n        'ROLE_OAUTH2_USER:NOTIFICATION':\n            [\n                'ROLE_OAUTH2_USER:NOTIFICATION:READ',\n                'ROLE_OAUTH2_USER:NOTIFICATION:DELETE',\n                'ROLE_OAUTH2_USER:NOTIFICATION:EDIT',\n            ]\n        'ROLE_OAUTH2_USER:OAUTH_CLIENTS':\n            [\n                'ROLE_OAUTH2_USER:OAUTH_CLIENTS:READ',\n                'ROLE_OAUTH2_USER:OAUTH_CLIENTS:EDIT',\n            ]\n        'ROLE_OAUTH2_MODERATE':\n            [\n                'ROLE_OAUTH2_MODERATE:ENTRY',\n                'ROLE_OAUTH2_MODERATE:ENTRY_COMMENT',\n                'ROLE_OAUTH2_MODERATE:POST',\n                'ROLE_OAUTH2_MODERATE:POST_COMMENT',\n                'ROLE_OAUTH2_MODERATE:MAGAZINE',\n                'ROLE_OAUTH2_MODERATE:MAGAZINE_ADMIN',\n            ]\n        'ROLE_OAUTH2_MODERATE:ENTRY':\n            [\n                'ROLE_OAUTH2_MODERATE:ENTRY:LANGUAGE',\n                'ROLE_OAUTH2_MODERATE:ENTRY:PIN',\n                'ROLE_OAUTH2_MODERATE:ENTRY:SET_ADULT',\n                'ROLE_OAUTH2_MODERATE:ENTRY:TRASH',\n                'ROLE_OAUTH2_MODERATE:ENTRY:LOCK',\n            ]\n        'ROLE_OAUTH2_MODERATE:ENTRY_COMMENT':\n            [\n                'ROLE_OAUTH2_MODERATE:ENTRY_COMMENT:LANGUAGE',\n                'ROLE_OAUTH2_MODERATE:ENTRY_COMMENT:SET_ADULT',\n                'ROLE_OAUTH2_MODERATE:ENTRY_COMMENT:TRASH',\n            ]\n        'ROLE_OAUTH2_MODERATE:POST':\n            [\n                'ROLE_OAUTH2_MODERATE:POST:LANGUAGE',\n                'ROLE_OAUTH2_MODERATE:POST:PIN',\n                'ROLE_OAUTH2_MODERATE:POST:LOCK',\n                'ROLE_OAUTH2_MODERATE:POST:SET_ADULT',\n                'ROLE_OAUTH2_MODERATE:POST:TRASH',\n            ]\n        'ROLE_OAUTH2_MODERATE:POST_COMMENT':\n            [\n                'ROLE_OAUTH2_MODERATE:POST_COMMENT:LANGUAGE',\n                'ROLE_OAUTH2_MODERATE:POST_COMMENT:SET_ADULT',\n                'ROLE_OAUTH2_MODERATE:POST_COMMENT:TRASH',\n            ]\n        'ROLE_OAUTH2_MODERATE:MAGAZINE':\n            [\n                'ROLE_OAUTH2_MODERATE:MAGAZINE:BAN',\n                'ROLE_OAUTH2_MODERATE:MAGAZINE:LIST',\n                'ROLE_OAUTH2_MODERATE:MAGAZINE:REPORTS',\n                'ROLE_OAUTH2_MODERATE:MAGAZINE:TRASH:READ',\n            ]\n        'ROLE_OAUTH2_MODERATE:MAGAZINE:BAN':\n            [\n                'ROLE_OAUTH2_MODERATE:MAGAZINE:BAN:READ',\n                'ROLE_OAUTH2_MODERATE:MAGAZINE:BAN:CREATE',\n                'ROLE_OAUTH2_MODERATE:MAGAZINE:BAN:DELETE',\n            ]\n        'ROLE_OAUTH2_MODERATE:MAGAZINE:REPORTS':\n            [\n                'ROLE_OAUTH2_MODERATE:MAGAZINE:REPORTS:READ',\n                'ROLE_OAUTH2_MODERATE:MAGAZINE:REPORTS:ACTION',\n            ]\n        'ROLE_OAUTH2_MODERATE:MAGAZINE_ADMIN':\n            [\n                'ROLE_OAUTH2_MODERATE:MAGAZINE_ADMIN:CREATE',\n                'ROLE_OAUTH2_MODERATE:MAGAZINE_ADMIN:DELETE',\n                'ROLE_OAUTH2_MODERATE:MAGAZINE_ADMIN:UPDATE',\n                'ROLE_OAUTH2_MODERATE:MAGAZINE_ADMIN:THEME',\n                'ROLE_OAUTH2_MODERATE:MAGAZINE_ADMIN:MODERATORS',\n                'ROLE_OAUTH2_MODERATE:MAGAZINE_ADMIN:BADGES',\n                'ROLE_OAUTH2_MODERATE:MAGAZINE_ADMIN:TAGS',\n                'ROLE_OAUTH2_MODERATE:MAGAZINE_ADMIN:STATS',\n            ]\n        'ROLE_OAUTH2_ADMIN':\n            [\n                'ROLE_OAUTH2_ADMIN:ENTRY:PURGE',\n                'ROLE_OAUTH2_ADMIN:ENTRY_COMMENT:PURGE',\n                'ROLE_OAUTH2_ADMIN:POST:PURGE',\n                'ROLE_OAUTH2_ADMIN:POST_COMMENT:PURGE',\n                'ROLE_OAUTH2_ADMIN:MAGAZINE',\n                'ROLE_OAUTH2_ADMIN:USER',\n                'ROLE_OAUTH2_ADMIN:INSTANCE',\n                'ROLE_OAUTH2_ADMIN:FEDERATION',\n                'ROLE_OAUTH2_ADMIN:OAUTH_CLIENTS',\n            ]\n        'ROLE_OAUTH2_ADMIN:MAGAZINE':\n            [\n                'ROLE_OAUTH2_ADMIN:MAGAZINE:MOVE_ENTRY',\n                'ROLE_OAUTH2_ADMIN:MAGAZINE:PURGE',\n                'ROLE_OAUTH2_ADMIN:MAGAZINE:MODERATE',\n            ]\n        'ROLE_OAUTH2_ADMIN:USER':\n            [\n                'ROLE_OAUTH2_ADMIN:USER:BAN',\n                'ROLE_OAUTH2_ADMIN:USER:VERIFY',\n                'ROLE_OAUTH2_ADMIN:USER:DELETE',\n                'ROLE_OAUTH2_ADMIN:USER:PURGE',\n            ]\n        'ROLE_OAUTH2_ADMIN:INSTANCE':\n            [\n                'ROLE_OAUTH2_ADMIN:INSTANCE:STATS',\n                'ROLE_OAUTH2_ADMIN:INSTANCE:SETTINGS',\n                'ROLE_OAUTH2_ADMIN:INSTANCE:INFORMATION:EDIT',\n            ]\n        'ROLE_OAUTH2_ADMIN:INSTANCE:SETTINGS':\n            [\n                'ROLE_OAUTH2_ADMIN:INSTANCE:SETTINGS:READ',\n                'ROLE_OAUTH2_ADMIN:INSTANCE:SETTINGS:EDIT',\n            ]\n        'ROLE_OAUTH2_ADMIN:FEDERATION':\n            [\n                'ROLE_OAUTH2_ADMIN:FEDERATION:READ',\n                'ROLE_OAUTH2_ADMIN:FEDERATION:UPDATE',\n            ]\n        'ROLE_OAUTH2_ADMIN:OAUTH_CLIENTS':\n            [\n                'ROLE_OAUTH2_ADMIN:OAUTH_CLIENTS:READ',\n                'ROLE_OAUTH2_ADMIN:OAUTH_CLIENTS:REVOKE',\n            ]\n        # - { path: ^/admin, roles: ROLE_ADMIN }\n        # - { path: ^/profile, roles: ROLE_USER }\n\nwhen@test:\n    security:\n        password_hashers:\n            # By default, password hashers are resource intensive and take time. This is\n            # important to generate secure password hashes. In tests however, secure hashes\n            # are not important, waste resources and increase test times. The following\n            # reduces the work factor to the lowest possible values.\n            Symfony\\Component\\Security\\Core\\User\\PasswordAuthenticatedUserInterface:\n                algorithm: auto\n                cost: 4 # Lowest possible value for bcrypt\n                time_cost: 3 # Lowest possible value for argon\n                memory_cost: 10 # Lowest possible value for argon\n"
  },
  {
    "path": "config/packages/test/framework.yaml",
    "content": "framework:\n    test: true\n    session:\n        storage_factory_id: session.storage.factory.mock_file\n"
  },
  {
    "path": "config/packages/test/messenger.yaml",
    "content": "framework:\n    messenger:\n        routing:\n            # Route your messages to the transports\n            App\\Message\\Contracts\\AsyncMessageInterface: sync\n            App\\Message\\Contracts\\ActivityPubInboxInterface: sync\n            App\\Message\\Contracts\\ActivityPubInboxReceiveInterface: sync\n            App\\Message\\Contracts\\ActivityPubOutboxDeliverInterface: sync\n            App\\Message\\Contracts\\ActivityPubOutboxInterface: sync\n            App\\Message\\Contracts\\ActivityPubResolveInterface: sync\n            # Consider adding SendEmail from Mailer via async messenger as well:\n            Symfony\\Component\\Mailer\\Messenger\\SendEmailMessage: sync\n            App\\Message\\Contracts\\SendConfirmationEmailInterface: sync"
  },
  {
    "path": "config/packages/test/rate_limiter.yaml",
    "content": "framework:\n  rate_limiter:\n    anonymous_api_read:\n      policy: 'sliding_window'\n      limit: 1000\n      interval: '1 second'\n    api_oauth_client:\n      policy: 'sliding_window'\n      limit: 1000\n      interval: '1 second'\n    api_oauth_token_revoke:\n      policy: 'sliding_window'\n      limit: 1000\n      interval: '1 second'\n    api_oauth_client_delete:\n      policy: 'sliding_window'\n      limit: 1000\n      interval: '1 second'\n    api_delete:\n      policy: 'sliding_window'\n      limit: 1000\n      interval: '1 second'\n    api_message:\n      policy: 'sliding_window'\n      limit: 1000\n      interval: '1 second'\n    api_report:\n      policy: 'sliding_window'\n      limit: 1000\n      interval: '1 second'\n    api_read:\n      policy: 'sliding_window'\n      limit: 1000\n      interval: '1 second'\n    api_update:\n      policy: 'sliding_window'\n      limit: 1000\n      interval: '1 second'\n    api_vote:\n      policy: 'sliding_window'\n      limit: 1000\n      interval: '1 second'\n    api_entry:\n      policy: 'sliding_window'\n      limit: 1000\n      interval: '1 second'\n    api_image:\n      policy: 'sliding_window'\n      limit: 1000\n      interval: '1 second'\n    api_post:\n      policy: 'sliding_window'\n      limit: 1000\n      interval: '1 second'\n    api_comment:\n      policy: 'sliding_window'\n      limit: 1000\n      interval: '1 second'\n    api_magazine:\n      policy: 'sliding_window'\n      limit: 1000\n      interval: '1 second'\n    api_notification:\n      policy: 'sliding_window'\n      limit: 1000\n      interval: '1 second'\n    api_moderate:\n      policy: 'sliding_window'\n      limit: 1000\n      interval: '1 second'\n    vote:\n      policy: 'sliding_window'\n      limit: 1000\n      interval: '1 second'\n    entry:\n      policy: 'fixed_window'\n      limit: 1000\n      interval: '1 second'\n    entry_comment:\n      policy: 'sliding_window'\n      limit: 1000\n      interval: '1 second'\n    post:\n      policy: 'fixed_window'\n      limit: 1000\n      interval: '1 second'\n    post_comment:\n      policy: 'sliding_window'\n      limit: 1000\n      interval: '1 second'\n    user_register:\n      policy: 'fixed_window'\n      limit: 1000\n      interval: '1 second'\n    magazine:\n      policy: 'fixed_window'\n      limit: 1000\n      interval: '1 second'\n"
  },
  {
    "path": "config/packages/test/twig.yaml",
    "content": "twig:\n    strict_variables: true\n"
  },
  {
    "path": "config/packages/translation.yaml",
    "content": "framework:\n    default_locale: '%env(KBIN_DEFAULT_LANG)%'\n    translator:\n        default_path: '%kernel.project_dir%/translations'\n        fallbacks:\n            - en\n        providers:\n"
  },
  {
    "path": "config/packages/twig.yaml",
    "content": "twig:\n    globals:\n        mercure_public_url: '%env(MERCURE_PUBLIC_URL)%'\n\n    file_name_pattern: '*.twig'\n    form_themes:\n        [\n            'form_div_layout.html.twig',\n            '@MeteoConceptHCaptcha/hcaptcha_form.html.twig',\n        ]\n\nwhen@test:\n    twig:\n        strict_variables: true\n"
  },
  {
    "path": "config/packages/twig_component.yaml",
    "content": "twig_component:\n    anonymous_template_directory: 'components/'\n    defaults:\n        # Namespace & directory for components\n        App\\Twig\\Components\\: 'components/'\n"
  },
  {
    "path": "config/packages/uid.yaml",
    "content": "framework:\n    uid:\n        default_uuid_version: 7\n        time_based_uuid_version: 7\n"
  },
  {
    "path": "config/packages/validator.yaml",
    "content": "framework:\n    validation:\n        email_validation_mode: html5\n\n        # Enables validator auto-mapping support.\n        # For instance, basic validation constraints will be inferred from Doctrine's metadata.\n        #auto_mapping:\n        #    App\\Entity\\: []\n\nwhen@test:\n    framework:\n        validation:\n            not_compromised_password: false\n"
  },
  {
    "path": "config/packages/web_profiler.yaml",
    "content": "when@dev:\n    web_profiler:\n        toolbar: true\n\n    framework:\n        profiler:\n            collect_serializer_data: true\n\nwhen@test:\n    framework:\n        profiler:\n            collect: false\n            collect_serializer_data: true\n"
  },
  {
    "path": "config/packages/webpack_encore.yaml",
    "content": "webpack_encore:\n    # The path where Encore is building the assets - i.e. Encore.setOutputPath()\n    output_path: '%kernel.project_dir%/public/build'\n    # If multiple builds are defined (as shown below), you can disable the default build:\n    # output_path: false\n\n    # Set attributes that will be rendered on all script and link tags\n    script_attributes:\n        defer: true\n        # Uncomment (also under link_attributes) if using Turbo Drive\n        # https://turbo.hotwired.dev/handbook/drive#reloading-when-assets-change\n        # 'data-turbo-track': reload\n    # link_attributes:\n        # Uncomment if using Turbo Drive\n        # 'data-turbo-track': reload\n\n    # If using Encore.enableIntegrityHashes() and need the crossorigin attribute (default: false, or use 'anonymous' or 'use-credentials')\n    # crossorigin: 'anonymous'\n\n    # Preload all rendered script and link tags automatically via the HTTP/2 Link header\n    # preload: true\n\n    # Throw an exception if the entrypoints.json file is missing or an entry is missing from the data\n    # strict_mode: false\n\n    # If you have multiple builds:\n    # builds:\n        # frontend: '%kernel.project_dir%/public/frontend/build'\n\n        # pass the build name as the 3rd argument to the Twig functions\n        # {{ encore_entry_script_tags('entry1', null, 'frontend') }}\n\nframework:\n    assets:\n        json_manifest_path: '%kernel.project_dir%/public/build/manifest.json'\n\n#when@prod:\n#    webpack_encore:\n#        # Cache the entrypoints.json (rebuild Symfony's cache when entrypoints.json changes)\n#        # Available in version 1.2\n#        cache: true\n\n#when@test:\n#    webpack_encore:\n#        strict_mode: false\n"
  },
  {
    "path": "config/packages/workflow.yaml",
    "content": "framework:\n    workflows:\n        reports:\n            type: 'state_machine'\n            audit_trail:\n                enabled: true\n            marking_store:\n                type: 'method'\n                property: 'status'\n            supports:\n                - App\\Entity\\Report\n            initial_marking: pending\n            places:\n                - pending\n                - approved\n                - rejected\n                - appeal\n                - closed\n            transitions:\n                approve:\n                    from: pending\n                    to: approved\n                reject:\n                    from: pending\n                    to: rejected\n                appeal:\n                    from: rejected\n                    to: appeal\n                close:\n                    from: appeal\n                    to: closed\n"
  },
  {
    "path": "config/preload.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nif (file_exists(\\dirname(__DIR__).'/var/cache/prod/App_KernelProdContainer.preload.php')) {\n    require \\dirname(__DIR__).'/var/cache/prod/App_KernelProdContainer.preload.php';\n}\n"
  },
  {
    "path": "config/routes/dev/framework.yaml",
    "content": "_errors:\n    resource: '@FrameworkBundle/Resources/config/routing/errors.php'\n    prefix: /_error\n"
  },
  {
    "path": "config/routes/fos_js_routing.yaml",
    "content": "fos_js_routing:\n    resource: \"@FOSJsRoutingBundle/Resources/config/routing/routing.php\"\n"
  },
  {
    "path": "config/routes/framework.yaml",
    "content": "_errors:\n    resource: '@FrameworkBundle/Resources/config/routing/errors.php'\n    prefix: /_error\n"
  },
  {
    "path": "config/routes/league_oauth2_server.yaml",
    "content": "league_oauth2_server:\n    resource: '@LeagueOAuth2ServerBundle/config/routes.php'\n    type: php\n"
  },
  {
    "path": "config/routes/liip_imagine.yaml",
    "content": "_liip_imagine:\n    resource: '@LiipImagineBundle/Resources/config/routing.yaml'\n"
  },
  {
    "path": "config/routes/nelmio_api_doc.yaml",
    "content": "# Expose your documentation as JSON swagger compliant\napp.swagger:\n    path: /api/doc.json\n    methods: GET\n    defaults: { _controller: nelmio_api_doc.controller.swagger }\n\n## Requires the Asset component and the Twig bundle\n## $ composer require twig asset\n#app.swagger_ui:\n#    path: /api/doc\n#    methods: GET\n#    defaults: { _controller: nelmio_api_doc.controller.swagger_ui }\n"
  },
  {
    "path": "config/routes/rss_atom.yaml",
    "content": "rss_atom_bundle:\n    resource: \"@DebrilRssAtomBundle/Resources/config/routing.yml\"\n"
  },
  {
    "path": "config/routes/scheb_2fa.yaml",
    "content": "2fa_login:\n    path: /2fa\n    defaults:\n        _controller: \"scheb_two_factor.form_controller::form\"\n\n2fa_login_check:\n    path: /2fa_check\n"
  },
  {
    "path": "config/routes/security.yaml",
    "content": "_security_logout:\n    resource: security.route_loader.logout\n    type: service\n"
  },
  {
    "path": "config/routes/ux_autocomplete.yaml",
    "content": "ux_autocomplete:\n    resource: '@AutocompleteBundle/config/routes.php'\n    prefix: '/autocomplete'\n"
  },
  {
    "path": "config/routes/web_profiler.yaml",
    "content": "when@dev:\n    web_profiler_wdt:\n        resource: '@WebProfilerBundle/Resources/config/routing/wdt.php'\n        prefix: /_wdt\n\n    web_profiler_profiler:\n        resource: '@WebProfilerBundle/Resources/config/routing/profiler.php'\n        prefix: /_profiler\n"
  },
  {
    "path": "config/routes.yaml",
    "content": "# yaml-language-server: $schema=../vendor/symfony/routing/Loader/schema/routing.schema.json\n\n# This file is the entry point to configure the routes of your app.\n# Methods with the #[Route] attribute are automatically imported.\n# See also https://symfony.com/doc/current/routing.html\n\n# To list all registered routes, run the following command:\n#   bin/console debug:router\n\ncontrollers:\n    resource: routing.controllers\n_liip_imagine:\n  resource: \"@LiipImagineBundle/Resources/config/routing.yaml\"\n"
  },
  {
    "path": "config/services.yaml",
    "content": "framework:\n    serializer:\n        mapping:\n            paths: ['%kernel.project_dir%/config/mbin_serialization']\n\n# yaml-language-server: $schema=../vendor/symfony/dependency-injection/Loader/schema/services.schema.json\n\n# This file is the entry point to configure your own services.\n# Files in the packages/ subdirectory configure your dependencies.\n# See also https://symfony.com/doc/current/service_container/import.html\n\n# Put parameters here that don't need to change on each machine where the app is deployed\n# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration\nparameters:\n    kbin_domain: '%env(KBIN_DOMAIN)%'\n    kbin_title: '%env(KBIN_TITLE)%'\n    kbin_meta_title: '%env(KBIN_META_TITLE)%'\n    kbin_meta_description: '%env(KBIN_META_DESCRIPTION)%'\n    kbin_meta_keywords: '%env(KBIN_META_KEYWORDS)%'\n    kbin_contact_email: '%env(KBIN_CONTACT_EMAIL)%'\n    kbin_sender_email: '%env(KBIN_SENDER_EMAIL)%'\n    kbin_default_lang: '%env(KBIN_DEFAULT_LANG)%'\n    kbin_api_items_per_page: '%env(KBIN_API_ITEMS_PER_PAGE)%'\n    kbin_js_enabled: '%env(bool:KBIN_JS_ENABLED)%'\n    kbin_federation_enabled: '%env(KBIN_FEDERATION_ENABLED)%'\n    kbin_registrations_enabled: '%env(KBIN_REGISTRATIONS_ENABLED)%'\n    kbin_ap_route_condition: 'request.getAcceptableContentTypes() and request.getAcceptableContentTypes()[0] in [\"application/activity+json\", \"application/ld+json\", \"application/json\", \"application/ld+json;profile=https://www.w3.org/ns/activitystreams\"]'\n    kbin_storage_url: '%env(KBIN_STORAGE_URL)%'\n\n    # Grab the default theme to use from the MBIN_DEFAULT_THEME env var\n    # with a fall back of light/dark auto detection based on user setting\n    default_theme: default\n    mbin_default_theme: '%env(default:default_theme:MBIN_DEFAULT_THEME)%'\n\n    amazon.s3.key: '%env(S3_KEY)%'\n    amazon.s3.secret: '%env(S3_SECRET)%'\n    amazon.s3.bucket: '%env(S3_BUCKET)%'\n    amazon.s3.region: '%env(S3_REGION)%'\n    amazon.s3.version: '%env(S3_VERSION)%'\n    amazon.s3.endpoint: '%env(S3_ENDPOINT)%'\n\n    hcaptcha_site_key: '%env(resolve:HCAPTCHA_SITE_KEY)%'\n    hcaptcha_secret: '%env(resolve:HCAPTCHA_SECRET)%'\n\n    oauth_azure_id: '%env(default::OAUTH_AZURE_ID)%'\n    oauth_azure_secret: '%env(OAUTH_AZURE_SECRET)%'\n    oauth_azure_tenant: '%env(OAUTH_AZURE_TENANT)%'\n\n    oauth_facebook_id: '%env(default::OAUTH_FACEBOOK_ID)%'\n    oauth_facebook_secret: '%env(OAUTH_FACEBOOK_SECRET)%'\n\n    oauth_google_id: '%env(default::OAUTH_GOOGLE_ID)%'\n    oauth_google_secret: '%env(OAUTH_GOOGLE_SECRET)%'\n\n    oauth_discord_id: '%env(default::OAUTH_DISCORD_ID)%'\n    oauth_discord_secret: '%env(OAUTH_DISCORD_SECRET)%'\n\n    oauth_github_id: '%env(default::OAUTH_GITHUB_ID)%'\n    oauth_github_secret: '%env(OAUTH_GITHUB_SECRET)%'\n\n    oauth_privacyportal_id: '%env(default::OAUTH_PRIVACYPORTAL_ID)%'\n    oauth_privacyportal_secret: '%env(OAUTH_PRIVACYPORTAL_SECRET)%'\n\n    oauth_keycloak_id: '%env(default::OAUTH_KEYCLOAK_ID)%'\n    oauth_keycloak_secret: '%env(OAUTH_KEYCLOAK_SECRET)%'\n    oauth_keycloak_uri: '%env(OAUTH_KEYCLOAK_URI)%'\n    oauth_keycloak_realm: '%env(OAUTH_KEYCLOAK_REALM)%'\n    oauth_keycloak_version: '%env(OAUTH_KEYCLOAK_VERSION)%'\n\n    oauth_simplelogin_id: '%env(default::OAUTH_SIMPLELOGIN_ID)%'\n    oauth_simplelogin_secret: '%env(OAUTH_SIMPLELOGIN_SECRET)%'\n\n    oauth_zitadel_id: '%env(default::OAUTH_ZITADEL_ID)%'\n    oauth_zitadel_secret: '%env(OAUTH_ZITADEL_SECRET)%'\n    oauth_zitadel_base_url: '%env(OAUTH_ZITADEL_BASE_URL)%'\n\n    oauth_authentik_id: '%env(default::OAUTH_AUTHENTIK_ID)%'\n    oauth_authentik_secret: '%env(OAUTH_AUTHENTIK_SECRET)%'\n    oauth_authentik_base_url: '%env(OAUTH_AUTHENTIK_BASE_URL)%'\n\n    router.request_context.host: '%env(KBIN_DOMAIN)%'\n    router.request_context.scheme: https\n\n    html5_validation: true\n\n    front_sort_options: top|hot|active|newest|oldest|commented # TODO remove fallback after tag rework\n    default_sort_options: default|top|hot|active|newest|oldest|commented\n    default_time_options: 3h|6h|12h|1d|1w|1m|1y|all|∞\n    default_type_options: article|articles|link|links|video|videos|photo|photos|image|images|all\n    default_subscription_options: sub|fav|mod|all|home\n    default_federation_options: local|all\n    default_content_options: default|combined|threads|microblog\n    default_subject_type_options: entry|entry_comment|post|post_comment\n\n    comment_sort_options: top|hot|active|newest|oldest|default\n\n    stats_type: general|content|votes\n\n    number_regex: '[1-9][0-9]{0,17}'\n    username_regex: '\\w{2,25}|!deleted\\d+'\n\n    uploads_dir_name: 'media'\n    uploads_base_url: '/'\n\n    mercure_public_url: '%env(MERCURE_PUBLIC_URL)%'\n    mercure_subscriptions_token: '%env(MERCURE_JWT_SECRET)%'\n\n    sso_only_mode: '%env(bool:default::SSO_ONLY_MODE)%'\n\n    exif_default_uploaded: 'sanitize'\n    exif_default_external: 'none'\n\n    exif_clean_mode_uploaded: '%env(enum:\\App\\Utils\\ExifCleanMode:default:exif_default_uploaded:EXIF_CLEAN_MODE_UPLOADED)%'\n    exif_clean_mode_external: '%env(enum:\\App\\Utils\\ExifCleanMode:default:exif_default_external:EXIF_CLEAN_MODE_EXTERNAL)%'\n    exif_exiftool_path: '%env(default::EXIF_EXIFTOOL_PATH)%'\n    exif_exiftool_timeout: '%env(int:default::EXIF_EXIFTOOL_TIMEOUT)%'\n\n    mbin_max_image_bytes: '%env(int:default:mbin_max_image_bytes_default:MBIN_MAX_IMAGE_BYTES)%'\n    mbin_max_image_bytes_default: 6000000\n    mbin_image_compression_quality: '%env(float:default:mbin_image_compression_quality_default:MBIN_IMAGE_COMPRESSION_QUALITY)%'\n    mbin_image_compression_quality_default: -1\n\n    mbin_downvotes_mode_default: 'enabled'\n    mbin_downvotes_mode: '%env(enum:\\App\\Utils\\DownvotesMode:default:mbin_downvotes_mode_default:MBIN_DOWNVOTES_MODE)%'\n\n    mbin_new_users_need_approval: '%env(bool:default::MBIN_NEW_USERS_NEED_APPROVAL)%'\n    mbin_use_federation_allow_list: '%env(bool:default::MBIN_USE_FEDERATION_ALLOW_LIST)%'\n\n    trueVal: 'true'\n    mbin_monitoring_enabled: '%env(bool:default::MBIN_MONITORING_ENABLED)%'\n    mbin_monitoring_query_parameters_enabled: '%env(bool:default::MBIN_MONITORING_QUERY_PARAMETERS_ENABLED)%'\n    mbin_monitoring_queries_enabled: '%env(bool:default:trueVal:MBIN_MONITORING_QUERIES_ENABLED)%'\n    mbin_monitoring_query_persisting_enabled: '%env(bool:default::MBIN_MONITORING_QUERY_PERSISTING_ENABLED)%'\n    mbin_monitoring_twig_renders_enabled: '%env(bool:default:trueVal:MBIN_MONITORING_TWIG_RENDERS_ENABLED)%'\n    mbin_monitoring_twig_render_persisting_enabled: '%env(bool:default::MBIN_MONITORING_TWIG_RENDER_PERSISTING_ENABLED)%'\n    mbin_monitoring_curl_requests_enabled: '%env(bool:default:trueVal:MBIN_MONITORING_CURL_REQUESTS_ENABLED)%'\n    mbin_monitoring_curl_request_persisting_enabled: '%env(bool:default::MBIN_MONITORING_CURL_REQUEST_PERSISTING_ENABLED)%'\n\nservices:\n    # default configuration for services in *this* file\n    _defaults:\n        autowire: true      # Automatically injects dependencies in your services.\n        autoconfigure: true # Automatically registers your services as commands, event subscribers, etc.\n        bind:\n            $kbinDomain: '%kbin_domain%'\n            $html5Validation: '%html5_validation%'\n            $uploadedAssetsBaseUrl: '%uploads_base_url%'\n            $mercurePublicUrl: '%mercure_public_url%'\n            $mercureSubscriptionsToken: '%mercure_subscriptions_token%'\n            $kbinApiItemsPerPage: '%kbin_api_items_per_page%'\n            $storageUrl: '%kbin_storage_url%'\n            $publicDir: '%kernel.project_dir%/public'\n            $monitoringEnabled: '%mbin_monitoring_enabled%'\n            $monitoringQueryParametersEnabled: '%mbin_monitoring_query_parameters_enabled%'\n            $monitoringQueriesEnabled: '%mbin_monitoring_queries_enabled%'\n            $monitoringQueriesPersistingEnabled: '%mbin_monitoring_query_persisting_enabled%'\n            $monitoringTwigRendersEnabled: '%mbin_monitoring_twig_renders_enabled%'\n            $monitoringTwigRendersPersistingEnabled: '%mbin_monitoring_twig_render_persisting_enabled%'\n            $monitoringCurlRequestsEnabled: '%mbin_monitoring_curl_requests_enabled%'\n            $monitoringCurlRequestPersistingEnabled: '%mbin_monitoring_curl_request_persisting_enabled%'\n            $imageCompressionQuality: '%mbin_image_compression_quality%'\n\n    kbin.s3_client:\n        class: Aws\\S3\\S3Client\n        arguments:\n            - version: '%amazon.s3.version%'\n              region: '%amazon.s3.region%'\n              endpoint: '%amazon.s3.endpoint%'\n              #use_path_style_endpoint: true\n              credentials:\n                  key: '%amazon.s3.key%'\n                  secret: '%amazon.s3.secret%'\n                  #proxies: [ 'https://media.domain.tld' ]\n\n    # makes classes in src/ available to be used as services\n    # this creates a service per class whose id is the fully-qualified class name\n    App\\:\n        resource: '../src/'\n        exclude:\n            - '../src/DependencyInjection/'\n            - '../src/Entity/'\n            - '../src/Kernel.php'\n\n    # add more service definitions when explicit configuration is needed\n    # please note that last definitions always *replace* previous ones\n    App\\Controller\\:\n        resource: '../src/Controller/'\n        tags: ['controller.service_arguments']\n\n    #  App\\Http\\RequestDTOResolver:\n    #    arguments:\n    #      - '@validator'\n    #    tags:\n    #      - { name: controller.request_value_resolver, priority: 50 }\n\n    # Instance settings\n    App\\Service\\SettingsManager:\n        arguments:\n            $kbinTitle: '%kbin_title%'\n            $kbinMetaTitle: '%kbin_meta_title%'\n            $kbinMetaDescription: '%kbin_meta_description%'\n            $kbinMetaKeywords: '%kbin_meta_keywords%'\n            $kbinDefaultLang: '%kbin_default_lang%'\n            $kbinContactEmail: '%kbin_contact_email%'\n            $kbinSenderEmail: '%kbin_sender_email%'\n            $mbinDefaultTheme: '%mbin_default_theme%'\n            $kbinJsEnabled: '%env(bool:KBIN_JS_ENABLED)%'\n            $kbinFederationEnabled: '%env(bool:KBIN_FEDERATION_ENABLED)%'\n            $kbinRegistrationsEnabled: '%env(bool:KBIN_REGISTRATIONS_ENABLED)%'\n            $kbinHeaderLogo: '%env(bool:KBIN_HEADER_LOGO)%'\n            $kbinCaptchaEnabled: '%env(bool:KBIN_CAPTCHA_ENABLED)%'\n            $kbinFederationPageEnabled: '%env(bool:KBIN_FEDERATION_PAGE_ENABLED)%'\n            $kbinAdminOnlyOauthClients: '%env(bool:KBIN_ADMIN_ONLY_OAUTH_CLIENTS)%'\n            $mbinSsoOnlyMode: '%sso_only_mode%'\n            $mbinMaxImageBytes: '%mbin_max_image_bytes%'\n            $mbinDownvotesMode: '%mbin_downvotes_mode%'\n            $mbinNewUsersNeedApproval: '%mbin_new_users_need_approval%'\n            $mbinUseFederationAllowList: '%mbin_use_federation_allow_list%'\n\n    # Markdown\n    App\\Markdown\\Factory\\EnvironmentFactory:\n        arguments:\n            $container: !service_locator\n                League\\CommonMark\\Extension\\Autolink\\UrlAutolinkParser: '@League\\CommonMark\\Extension\\Autolink\\UrlAutolinkParser'\n                League\\CommonMark\\Extension\\CommonMark\\CommonMarkCoreExtension: '@League\\CommonMark\\Extension\\CommonMark\\CommonMarkCoreExtension'\n                League\\CommonMark\\Extension\\Strikethrough\\StrikethroughExtension: '@League\\CommonMark\\Extension\\Strikethrough\\StrikethroughExtension'\n                League\\CommonMark\\Extension\\Table\\TableExtension: '@League\\CommonMark\\Extension\\Table\\TableExtension'\n                App\\Markdown\\MarkdownExtension: '@App\\Markdown\\MarkdownExtension'\n            $config: '%commonmark.configuration%'\n\n    # Language\n    App\\EventListener\\LanguageListener:\n        tags:\n            - {\n                  name: kernel.event_listener,\n                  event: kernel.request,\n                  priority: 200,\n              }\n        arguments: ['%kbin_default_lang%']\n\n    # Federation\n    App\\EventListener\\FederationStatusListener:\n        tags:\n            - {\n                  name: kernel.event_listener,\n                  event: kernel.controller,\n                  priority: -5,\n              }\n\n    App\\EventListener\\UserActivityListener:\n        tags:\n            - {\n                  name: kernel.event_listener,\n                  event: kernel.controller,\n                  priority: -5,\n              }\n\n    # Notifications\n    App\\EventListener\\ContentNotificationPurgeListener:\n        tags:\n            - { name: doctrine.event_listener, event: preRemove }\n\n    # Magazine\n    App\\EventListener\\MagazineVisibilityListener:\n        tags:\n            - {\n                  name: kernel.event_listener,\n                  event: kernel.controller_arguments,\n              }\n\n    # Monolog handlers\n    log_filter_handler:\n      class: App\\Service\\MonologFilterHandler\n      public: false\n\n    # Feeds\n    debril.rss_atom.provider:\n        class: App\\Feed\\Provider\n        arguments: ['@App\\Service\\FeedManager']\n\n    messenger.failure.add_error_details_stamp_listener:\n        class: App\\Utils\\AddErrorDetailsStampListener\n\n    # Store session in database using PdoSessionHandler, by providing the DB DSN\n    Symfony\\Component\\HttpFoundation\\Session\\Storage\\Handler\\PdoSessionHandler:\n        arguments:\n            - '%env(DATABASE_URL)%'\n"
  },
  {
    "path": "docker/Caddyfile",
    "content": "{\n\t{$CADDY_GLOBAL_OPTIONS}\n\n\tfrankenphp {\n\t\t{$FRANKENPHP_CONFIG}\n\n\t\tworker {\n\t\t\tfile ./public/index.php\n\t\t\tenv APP_RUNTIME Runtime\\FrankenPhpSymfony\\Runtime\n\t\t\t{$FRANKENPHP_WORKER_CONFIG}\n\t\t}\n\t}\n}\n\n{$CADDY_EXTRA_CONFIG}\n\n{$SERVER_NAME} {\n\tlog {\n\t\t{$CADDY_SERVER_LOG_OPTIONS}\n\t\t# Redact the authorization query parameter that can be set by Mercure\n\t\tformat filter {\n\t\t\trequest>uri query {\n\t\t\t\treplace authorization REDACTED\n\t\t\t}\n\t\t}\n\t}\n\n\troot /app/public\n\tencode zstd br gzip\n\n\tmercure {\n\t\t# The transport to use\n\t\ttransport bolt {\n\t\t\tpath {$MERCURE_BOLT_PATH:/data/mercure.db}\n\t\t\t{$MERCURE_BOLT_EXTRA_DIRECTIVES}\n\t\t}\n\t\t# Publisher JWT key\n\t\tpublisher_jwt {env.MERCURE_PUBLISHER_JWT_KEY} {env.MERCURE_PUBLISHER_JWT_ALG}\n\t\t# Subscriber JWT key\n\t\tsubscriber_jwt {env.MERCURE_SUBSCRIBER_JWT_KEY} {env.MERCURE_SUBSCRIBER_JWT_ALG}\n\t\t# Allow anonymous subscribers (double-check that it's what you want)\n\t\tanonymous\n\t\t# Enable the subscription API (double-check that it's what you want)\n\t\tsubscriptions\n\t\t# Extra directives\n\t\t{$MERCURE_EXTRA_DIRECTIVES}\n\t}\n\n\tvulcain\n\n\t{$CADDY_SERVER_EXTRA_DIRECTIVES}\n\n\t# Disable Topics tracking if not enabled explicitly: https://github.com/jkarlin/topics\n\theader ?Permissions-Policy \"browsing-topics=()\"\n\n\t@phpRoute {\n\t\tnot path /.well-known/mercure*\n\t\tnot file {path}\n\t}\n\trewrite @phpRoute index.php\n\n\t@frontController path index.php\n\tphp @frontController\n\n\tfile_server {\n\t\thide *.php\n\t}\n}\n"
  },
  {
    "path": "docker/Dockerfile",
    "content": "#syntax=docker/dockerfile:1\n\n\n# Base FrankenPHP image\nFROM dunglas/frankenphp:1-php8.4-trixie AS base\n\nWORKDIR /app\n\nVOLUME /app/var/\n\n# persistent / runtime deps\n# hadolint ignore=DL3008\nRUN apt-get update && apt-get install -y --no-install-recommends \\\n\tacl \\\n\tfile \\\n\tgettext \\\n\tgit \\\n\tgosu \\\n    procps \\\n\t&& rm -rf /var/lib/apt/lists/*\n\nRUN set -eux; \\\n\tinstall-php-extensions \\\n\t\t@composer \\\n\t\tamqp \\\n\t\tbcmath \\\n\t\tpgsql \\\n\t\tpdo_pgsql \\\n\t\tgd \\\n\t\tcurl \\\n\t\tsimplexml \\\n\t\tdom \\\n\t\txml \\\n\t\tredis \\\n\t\tintl \\\n\t\topcache \\\n\t\tapcu \\\n\t\tpcntl \\\n\t\texif \\\n\t\tzip \\\n\t;\n\n# https://getcomposer.org/doc/03-cli.md#composer-allow-superuser\nENV COMPOSER_ALLOW_SUPERUSER=1\n\nENV PHP_INI_SCAN_DIR=\":$PHP_INI_DIR/app.conf.d\"\n\nCOPY --link docker/conf.d/10-app.ini $PHP_INI_DIR/app.conf.d/\nCOPY --link --chmod=755 docker/docker-entrypoint.sh /usr/local/bin/docker-entrypoint\nCOPY --link docker/Caddyfile /etc/caddy/Caddyfile\n\nENTRYPOINT [\"docker-entrypoint\"]\n\nHEALTHCHECK --start-period=60s CMD curl -f http://localhost:2019/metrics || exit 1\nCMD [ \"frankenphp\", \"run\", \"--config\", \"/etc/caddy/Caddyfile\" ]\n\n\n# Dev FrankenPHP image\nFROM base AS dev\n\nENV APP_ENV=dev\nENV XDEBUG_MODE=off\nENV FRANKENPHP_WORKER_CONFIG=watch\n\nRUN mv \"$PHP_INI_DIR/php.ini-development\" \"$PHP_INI_DIR/php.ini\"\n\nRUN set -eux; \\\n\tinstall-php-extensions \\\n\t\txdebug \\\n\t;\n\nCOPY --link docker/conf.d/20-app.dev.ini $PHP_INI_DIR/app.conf.d/\n\nCMD [ \"frankenphp\", \"run\", \"--config\", \"/etc/caddy/Caddyfile\", \"--watch\" ]\n\n\n# Prod FrankenPHP image\nFROM base AS prod_deps\n\nENV APP_ENV=prod\n\n# prevent the reinstallation of vendors at every changes in the source code\nCOPY --link composer.* symfony.* ./\nRUN set -eux; \\\n\tcomposer install --no-cache --prefer-dist --no-dev --no-autoloader --no-scripts --no-progress\n\n\n# Node assets builder\nFROM node:24-trixie-slim AS prod_node\n\nRUN mkdir /app\nWORKDIR /app\n\nCOPY --link ./package.json package-lock.json ./\n\nRUN npm ci\n\nCOPY --link ./webpack.config.js ./\nCOPY --link ./assets ./assets\nCOPY --link ./public/js ./public/js\nCOPY --link --from=prod_deps /app/vendor ./vendor\n\nRUN npm run build\n\n\nFROM prod_deps AS prod\n\nRUN mv \"$PHP_INI_DIR/php.ini-production\" \"$PHP_INI_DIR/php.ini\"\n\nCOPY --link docker/conf.d/20-app.prod.ini $PHP_INI_DIR/app.conf.d/\n\nCOPY --link . ./\nRUN rm -Rf docker/\nRUN cp .env.example_docker .env\n\nRUN set -eux; \\\n\tmkdir -p var/cache var/log; \\\n\tcomposer dump-autoload --classmap-authoritative --no-dev; \\\n\tcomposer dump-env prod; \\\n\tcomposer run-script --no-dev post-install-cmd; \\\n\tchmod +x bin/console; sync;\n\nCOPY --link --from=prod_node /app/public/build /app/public/build\n"
  },
  {
    "path": "docker/conf.d/10-app.ini",
    "content": "expose_php = 0\ndate.timezone = UTC\napc.enable_cli = 1\nsession.use_strict_mode = 1\nzend.detect_unicode = 0\n\n; Maximum execution time of each script, in seconds\nmax_execution_time = 120\n; Both max file size and post body size are personal preferences\nupload_max_filesize = 12M\npost_max_size = 12M\n; Remember the memory limit is per child process\nmemory_limit = 512M\n; maximum memory allocated to store the results\nrealpath_cache_size = 4096K\n; save the results for 10 minutes (600 seconds)\nrealpath_cache_ttl = 600\n"
  },
  {
    "path": "docker/conf.d/20-app.dev.ini",
    "content": "; See https://docs.docker.com/desktop/networking/#i-want-to-connect-from-a-container-to-a-service-on-the-host\n; See https://github.com/docker/for-linux/issues/264\n; The `client_host` below may optionally be replaced with `discover_client_host=yes`\n; Add `start_with_request=yes` to start debug session on each request\nxdebug.client_host = host.docker.internal\n"
  },
  {
    "path": "docker/conf.d/20-app.prod.ini",
    "content": "opcache.enable = 1\nopcache.enable_cli = 1\nopcache.preload = /app/config/preload.php\nopcache.preload_user = root\n; Memory consumption (in MBs), personal preference\nopcache.memory_consumption = 512\n; Internal string buffer (in MBs), personal preference\nopcache.interned_strings_buffer = 128\nopcache.max_accelerated_files = 100000\nopcache.validate_timestamps = 0\nopcache.enable_file_override = 1\n; Enable PHP JIT with all optimizations\nopcache.jit = 1255\nopcache.jit_buffer_size = 128M\n"
  },
  {
    "path": "docker/docker-entrypoint.sh",
    "content": "#!/bin/sh\nset -e\n\nif [ \"$1\" = 'frankenphp' ] || [ \"$1\" = 'php' ] || [ \"$1\" = 'bin/console' ]; then\n\t# Install dependencies if missing (needed for development)\n\tif [ -z \"$(ls -A 'vendor/' 2>/dev/null)\" ]; then\n\t\tcomposer install --prefer-dist --no-progress --no-interaction\n\tfi\n\n\t# Display information about the current project\n\t# Or about an error in project initialization\n\tphp bin/console -V\n\n\t# Additional Mbin docker configurations (only for production)\n\tif [ \"$APP_ENV\" = 'prod' ]; then\n\t\t# Use 301 response for image redirects to reduce server load\n\t\tsed -i 's|redirect_response_code: 302|redirect_response_code: 301|' config/packages/liip_imagine.yaml\n\n\t\t# Override log level when PHP_LOG_LEVEL is not empty\n\t\tif [ -n \"$PHP_LOG_LEVEL\" ]; then\n\t\t\tsed -i \"s|action_level: error|action_level: $PHP_LOG_LEVEL|\" config/packages/monolog.yaml\n\t\tfi\n\n\t\t# Use S3 file system adapter when S3_KEY is not empty\n\t\tif [ -n \"$S3_KEY\" ]; then\n\t\t\tsed -i 's|adapter: default_adapter|adapter: kbin.s3_adapter|' config/packages/oneup_flysystem.yaml\n\t\tfi\n\tfi\n\n\t# Needed to apply the above config changes\n\tphp bin/console cache:clear\n\n\tif grep -q ^DATABASE_URL= .env; then\n\t\techo 'Waiting for database to be ready...'\n\t\tATTEMPTS_LEFT_TO_REACH_DATABASE=60\n\t\tuntil [ $ATTEMPTS_LEFT_TO_REACH_DATABASE -eq 0 ] || DATABASE_ERROR=$(php bin/console dbal:run-sql -q \"SELECT 1\" 2>&1); do\n\t\t\tif [ $? -eq 255 ]; then\n\t\t\t\t# If the Doctrine command exits with 255, an unrecoverable error occurred\n\t\t\t\tATTEMPTS_LEFT_TO_REACH_DATABASE=0\n\t\t\t\tbreak\n\t\t\tfi\n\t\t\tsleep 1\n\t\t\tATTEMPTS_LEFT_TO_REACH_DATABASE=$((ATTEMPTS_LEFT_TO_REACH_DATABASE - 1))\n\t\t\techo \"Still waiting for database to be ready... Or maybe the database is not reachable. $ATTEMPTS_LEFT_TO_REACH_DATABASE attempts left.\"\n\t\tdone\n\n\t\tif [ $ATTEMPTS_LEFT_TO_REACH_DATABASE -eq 0 ]; then\n\t\t\techo 'The database is not up or not reachable:'\n\t\t\techo \"$DATABASE_ERROR\"\n\t\t\texit 1\n\t\telse\n\t\t\techo 'The database is now ready and reachable'\n\t\tfi\n\n\t\tphp bin/console doctrine:migrations:migrate --no-interaction --all-or-nothing\n\tfi\n\n\t# Solution to allow non-root users, given here: https://github.com/dunglas/symfony-docker/issues/679#issuecomment-2501369223\n\tchown -R $MBIN_USER var /data /config\n\n\techo 'PHP app ready!'\nfi\n\nexec /usr/sbin/gosu $MBIN_USER \"$@\"\n"
  },
  {
    "path": "docker/setup.sh",
    "content": "#!/usr/bin/env bash\n\n# Ensure script is always ran in Mbin's root directory.\ncd \"$(dirname \"$0\")/..\"\n\nif [[ \"$1\" == \"\" || \"$1\" == \"-h\" || \"$1\" == \"--help\" ]]; then\n    cat << EOF\nUsage: ./docker/setup.sh MODE DOMAIN\nAutomate your Mbin docker setup!\n\nMODE needs to be either \"prod\" or \"dev\".\nDOMAIN will set the correct domain related fields in the .env file. Use \"localhost\" if you are just testing locally.\n\nExamples:\n  ./docker/setup.sh prod mbin.domain.tld\n  ./docker/setup.sh dev localhost\nEOF\n  exit 0\nfi\n\ncase $1 in\n    prod)\n        mode=prod\n        ;;\n    dev)\n        mode=dev\n        ;;\n    *)\n        echo \"Invalid mode provided: $1\"\n        echo \"Must be either prod (recommended for most cases) or dev.\"\n        exit 1\n        ;;\nesac\n\ndomain=$2\nif [[ -z $domain ]]; then\n  echo \"DOMAIN must be provided. Use \\\"localhost\\\" if you are just testing locally.\"\n  exit 1\nfi\n\nverify_no_file () {\n  if [ -f \"$1\" ]; then\n    echo \"ERROR: $1 file already exists. Cannot continue setup.\"\n    exit 1\n  fi\n}\nverify_no_dir () {\n  if [ -d \"$1\" ]; then\n    echo \"ERROR: $1 directory already exists. Cannot continue setup.\"\n    exit 1\n  fi\n}\n\nverify_no_file .env\nverify_no_file compose.override.yaml\nverify_no_dir storage\n\necho \"Starting Mbin $mode setup...\"\necho\n\necho \"Generating .env file with passwords...\"\nGEN_PASSWORD_LENGTH=32\nGEN_PASSWORD_REGEX='!Change\\w*!'\nwhile IFS= read -r line; do\n  # Replace instances of !ChangeAnything! with a generated password.\n  if [[ $line =~ $GEN_PASSWORD_REGEX ]]; then\n    PASSWORD=$(tr -dc A-Za-z0-9 < /dev/urandom | head -c $GEN_PASSWORD_LENGTH)\n    # Save oauth password for later\n    if [[ ${BASH_REMATCH[0]} == '!ChangeThisOauthPass!' ]]; then\n      OAUTH_PASS=$PASSWORD\n    fi\n    line=${line/${BASH_REMATCH[0]}/$PASSWORD}\n  fi\n\n  # Replace \"mbin.domain.tld\" with passed in domain\n  if [[ -n $domain ]]; then\n    line=${line/mbin.domain.tld/$domain}\n  fi\n\n  # Populate MBIN_USER field\n  if [[ $line == 'MBIN_USER=1000:1000' ]]; then\n    line=\"MBIN_USER=$(id -u):$(id -g)\"\n  fi\n\n  # Populate OAUTH_ENCRYPTION_KEY field\n  if [[ $line == 'OAUTH_ENCRYPTION_KEY=' ]]; then\n    line=\"$line$(openssl rand -hex 16)\"\n  fi\n\n  echo \"$line\" >> .env\ndone < .env.example_docker\n\necho \"Creating compose.override.yaml file... Any additional customizations to the compose setup should be added here.\"\nif [[ $mode == \"dev\" ]]; then\n   cat > compose.override.yaml << EOF\ninclude:\n  - compose.dev.yaml\nEOF\nelse\n   cat > compose.override.yaml << EOF\n# Customizations to the docker compose should be added here.\n# Hint: If you want to combine multiple configurations, be sure to only define the services once (php & messenger).\n\n# Uncomment the following to pin Mbin image docker tag to a specific release (example: v1.8.2).\n# services:\n#   php:\n#     image: ghcr.io/mbinorg/mbin:v1.8.2\n#   messenger:\n#     image: ghcr.io/mbinorg/mbin:v1.8.2\n\n# Uncomment the following to build the Mbin image locally.\n# services:\n#   php:\n#     pull_policy: build\n#   messenger:\n#     pull_policy: build\n\n# Uncomment the following to use Mbin behind a reverse proxy.\n# services:\n#   php:\n#     environment:\n#       CADDY_GLOBAL_OPTIONS: auto_https off\n#     ports: !override\n#       - 8080:80\nEOF\nfi\n\necho \"Setting up storage directories...\"\nmkdir -p storage/{caddy_config,caddy_data,media,messenger_logs,oauth,php_logs,postgres,rabbitmq_data,rabbitmq_logs}\necho \"Configuring OAuth2 keys...\"\nopenssl genrsa -des3 -out ./storage/oauth/private.pem -passout \"pass:$OAUTH_PASS\" 4096\nopenssl rsa -in ./storage/oauth/private.pem --outform PEM -pubout -out ./storage/oauth/public.pem -passin \"pass:$OAUTH_PASS\"\n\necho\necho \"Mbin environment setup complete!\"\necho \"Please refer back to the documentation for finishing touches.\"\n"
  },
  {
    "path": "docker/tests/compose.yaml",
    "content": "services:\n    db:\n        image: postgres:${POSTGRES_VERSION:-17}-trixie\n        container_name: mbin-tests-db\n        shm_size: 128mb\n        restart: unless-stopped\n        ports:\n            - '5433:5432'\n        environment:\n            - POSTGRES_DB=mbin_test\n            - POSTGRES_USER=mbin\n            - POSTGRES_PASSWORD=ChangeThisPostgresPass\n    valkey:\n        image: valkey/valkey:trixie\n        container_name: mbin-tests-valkey\n        restart: unless-stopped\n        ports:\n            - '6380:6379'\n        healthcheck:\n            test: ['CMD', 'redis-cli', 'ping']\n"
  },
  {
    "path": "docker/valkey.conf",
    "content": "# NETWORK\ntimeout 300\ntcp-keepalive 300\n\n# MEMORY MANAGEMENT\nmaxmemory 1gb\nmaxmemory-policy volatile-ttl\n\n# LAZY FREEING\nlazyfree-lazy-eviction yes\nlazyfree-lazy-expire yes\nlazyfree-lazy-server-del yes\nreplica-lazy-flush yes\n\n# THREADED I/O\nio-threads 4\nio-threads-do-reads yes\n\n# DISABLE SNAPSHOTS\nsave \"\"\n"
  },
  {
    "path": "docs/01-user/01-user_guide.md",
    "content": "# User guide\n\nMbin is a decentralized content aggregator and microblogging platform running on the Fediverse network. It can\ncommunicate with many other ActivityPub services, including Mastodon, Lemmy, Pleroma, Peertube.\n\nThe initiative aims to promote a free and open internet.\n\n## Introduction\n\nThe platform is divided into thematic categories called magazines. By default, any user can create their own magazine\nand automatically become its owner. Then they receive a number of administrative tools that will help them personalize\nand moderate the magazine, including appointing moderators among other interested users.\n\nContent from the Fediverse is also cataloged based on groups or tags. A registered user can follow magazines, other\nusers or domains and create his own personalized homepage. There is also the option to block undesired topics.\n\nContent can be posted on the main page - external links and more relevant articles or on microblog section - aggregating\nshort posts. All content can be additionally categorized and labeled. There is a good facility to search for interesting\ntopics and people, something that distinguishes mbin.\n\nThe platform is equally suitable for a small personal instance for friends and family, a school or university community,\ncompany platform or a general instance with thousands of active users.\n\n## User guide\n\n### Customization\n\nEveryone has the ability to customize the appearance to suit your preferences. In the sidebar, you'll find an options\nbutton that allows you to adjust a variety of settings, including the ability to choose from several templates,\nenabling automatic refreshing of posts and comments, activating infinite scroll, and enabling automatic media previews.\n\nBy using these options, you can completely transform the appearance of the platform to fit your personal needs. Whether\nyou prefer a minimalist design or a more colorful and lively look, you can easily make the changes that will make your\nexperience on platform more enjoyable.\n\nSo don't be afraid to experiment with the various options available in the sidebar. You might be surprised by how\nmuch you can change the appearance of the platform to suit your preferences.\n\n(pic1)\n\n### Register account\n\nThe process of registering for a user account on a platform usually involves providing a username (which will also serve\nas your identifier in the fediverse), password, and email address to receive an activation link.\n\nAnother option is to create an account through social media platforms such as Google, Facebook, Github, or PrivacyPortal. In this case,\nyou can use your social media login credentials to sign up, but you will need to visit your user panel and set up your\nusername before you can take any actions on the platform. However, **you will have only up to an hour after registration\n** to set up your default username before this option expires go to (Settings > Profile).\n\n(pic2)\n\n### User settings\n\nYou are now ready to start using /mbin to connect with others. You can access your account settings at any time by clicking on your username located in the header.\n\n(pic3)\n\nWe've included a wide range of options that will allow you to customize your experience. Take your time to check all the\noptions.\n\n- **General:** In this section, you can set your preferred home page (all, subscribed, moderated, favorites), hide adult\n  content, set user tagging options, adjust privacy settings, and configure notification settings.\n\n- **Profile:** Here, you can write a few words about yourself (which will be visible in the \"People\" section), add an\n  avatar and cover image.\n\n- **Email:** In this section, you can change your email address. After changing to a new email, you will receive an\n  activation link.\n\n- **Password:** In this section, you can change your account password.\n\n- **Blocks:** Here, you can manage blocked accounts, magazines, and domains.\n\n- **Subscriptions:** In this section, you can manage subscriptions to other user accounts, magazines, and domains.\n\n- **Reports:** In this section, you can manage reports from moderated magazines.\n\n- **Statistics:** Here, you can find some charts and numbers related to your account.\n\n### Feed Timelines\n\n### Fediverse\n"
  },
  {
    "path": "docs/01-user/02-FAQ.md",
    "content": "# FAQ\n\n## What is Mbin?\n\nMbin is an _open-source federated link aggregation, content rating and discussion_ software that is built on top of _ActivityPub_.\n\n## What is ActivityPub (AP)?\n\nActivityPub is a open standard protocol that empowers the creation of decentralized social networks, allowing different servers to interact and share content while giving users control over their data.  \nIt fosters a more user-centric and distributed approach to social networking, promoting interoperability across platforms and safeguarding user privacy and choice.\n\nThis protocol is vital for building a more open, inclusive, and user-empowered digital social landscape.\n\n## I have an issue!\n\nYou can [join our Matrix community](https://matrix.to/#/#mbin:melroy.org) and ask for help, and/or make an [issue ticket](https://github.com/MbinOrg/mbin/issues) in GitHub if that adds value (always check for duplicates).\n"
  },
  {
    "path": "docs/01-user/README.md",
    "content": "# User\n\nThanks for using Mbin!\n\nDo you want to learn more?  \nSee our [user guide](01-user_guide.md) and [FAQ](02-FAQ.md) pages.\n"
  },
  {
    "path": "docs/02-admin/01-installation/01-bare_metal.md",
    "content": "# Bare Metal/VM Installation\n\nBelow is a step-by-step guide of the process for creating your own Mbin instance from the moment a new VPS/VM is created or directly on bare-metal.  \nThis is a preliminary outline that will help you launch an instance for your own needs.\n\nThis guide is aimed for Debian / Ubuntu distribution servers, but it could run on any modern Linux distro. This guide will however uses the `apt` commands.\n\n> [!NOTE]\n> In this document a few services that are specific to the bare metal installation are configured.\n> You do need to follow the configuration guide as well. It describes the configuration of services shared between bare metal and docker.\n\n## Minimum hardware requirements\n\n- **vCPU:** 4 virtual cores (>= 2GHz, _more is recommended_ on larger instances)\n- **RAM:** 6GB (_more is recommended_ for large instances)\n- **Storage:** 40GB (_more is recommended_, especially if you have a lot of remote/local magazines and/or have a lot of (local) users)\n\nYou can start with a smaller server and add more resources later if you are using a VPS for example. Our _recommendation_ is to have 12 vCPUs with 32GB of RAM.\n\n## Software Requirements\n\n- Debian 12 or Ubuntu 22.04 LTS or later\n- PHP v8.3 or higher\n- NodeJS v22 or higher\n- Valkey / KeyDB / Redis (pick one)\n- PostgreSQL\n- Supervisor\n- RabbitMQ\n- AMQProxy\n- Nginx / OpenResty (pick one)\n- _Optionally:_ Mercure\n\nThis guide will show you how-to install and configure all of the above. Except for Mercure and Nginx, for Mercure see the [optional features page](../03-optional-features/README.md).\n\n> [!TIP]\n> Once the installation is completed, also check out the [additional configuration guides](../02-configuration/README.md) (including the Nginx setup).\n\n## System Prerequisites\n\nBring your system up-to-date:\n\n```bash\nsudo apt-get update && sudo apt-get upgrade -y\n```\n\nInstall prequirements:\n\n```bash\nsudo apt-get install lsb-release ca-certificates curl wget unzip gnupg apt-transport-https software-properties-common python3-launchpadlib git redis-server postgresql postgresql-contrib nginx acl -y\n```\n\nOn **Ubuntu 22.04 LTS** or older, prepare latest PHP package repositoy (8.4) by using a Ubuntu PPA (this step is optional for Ubuntu 23.10 or later) via:\n\n```bash\nsudo add-apt-repository ppa:ondrej/php -y\n```\n\nOn **Debian 12** or later, you can install the latest PHP package repository (this step is optional for Debian 13 or later) via:\n\n```bash\nsudo apt-get -y install lsb-release ca-certificates curl\nsudo curl -sSLo /tmp/debsuryorg-archive-keyring.deb https://packages.sury.org/debsuryorg-archive-keyring.deb\nsudo dpkg -i /tmp/debsuryorg-archive-keyring.deb\nsudo sh -c 'echo \"deb [signed-by=/usr/share/keyrings/deb.sury.org-php.gpg] https://packages.sury.org/php/ $(lsb_release -sc) main\" > /etc/apt/sources.list.d/php.list'\n```\n\nInstall _PHP 8.4_ with the required additional PHP extensions:\n\n```bash\nsudo apt-get update\nsudo apt-get install php8.4 php8.4-common php8.4-fpm php8.4-cli php8.4-amqp php8.4-bcmath php8.4-pgsql php8.4-gd php8.4-curl php8.4-xml php8.4-redis php8.4-mbstring php8.4-zip php8.4-bz2 php8.4-intl php8.4-bcmath -y\n```\n\n> [!NOTE]\n> If you are upgrading to PHP 8.3 from an older version, please re-review the [PHP configuration](#php) section of this guide as existing `ini` settings are NOT automatically copied to new versions. Additionally review which php-fpm version is configured in your Nginx site.\n\n> [!IMPORTANT]\n> **Never** even install `xdebug` PHP extension in production environments. Even if you don't enabled it but only installed `xdebug` can give massive performance issues.\n\nInstall Composer:\n\n```bash\nsudo curl -sS https://getcomposer.org/installer -o /tmp/composer-setup.php\nsudo php /tmp/composer-setup.php --install-dir=/usr/local/bin --filename=composer\n```\n\n## Nginx / OpenResty\n\nMbin bare metal setup requires a reverse proxy called Nginx (or OpenResty) to be installed and configured correctly. This is a requirement for Mbin to work safe, properly and to scale well.\n\nFor Nginx/OpenResty setup see the [Nginx configuration](../02-configuration/02-nginx.md).\n\n## Firewall\n\nIf you have a firewall installed (or you're behind a NAT), be sure to open port `443` for the web server. As said above, Mbin should run behind a reverse proxy like Nginx or OpenResty.\n\n## Install Node.JS (frontend tools)\n\n1. Prepare & download keyring:\n\n> [!NOTE]\n> This assumes you already installed all the prerequisites packages from the \"System prerequisites\" chapter.\n\n2. Setup the Nodesource repository:\n\n```bash\ncurl -fsSL https://deb.nodesource.com/setup_24.x | sudo bash -\n```\n\n3. Install Node.JS:\n\n```bash\nsudo apt-get install nodejs -y\n```\n\n## Create new user\n\n```bash\nsudo adduser mbin\nsudo usermod -aG sudo mbin\nsudo usermod -aG www-data mbin\nsudo su - mbin\n```\n\n## Create folder\n\n```bash\nsudo mkdir -p /var/www/mbin\ncd /var/www/mbin\nsudo chown mbin:www-data /var/www/mbin\n```\n\n## Generate Secrets\n\n> [!NOTE]\n> This will generate several valid tokens for the Mbin setup, you will need quite a few.\n\n```bash\nfor counter in {1..2}; do node -e \"console.log(require('crypto').randomBytes(16).toString('hex'))\"; done && for counter in {1..3}; do node -e \"console.log(require('crypto').randomBytes(32).toString('hex'))\"; done\n```\n\n## First setup steps\n\n### Clone git repository\n\n```bash\ncd /var/www/mbin\ngit clone https://github.com/MbinOrg/mbin.git .\n```\n\n> [!TIP]\n> You might now want to switch to the latest stable release tag instead of using the `main` branch.\n> Try: `git checkout v1.7.4` (v1.7.4 might **not** be the latest version: [lookup the latest version](https://github.com/MbinOrg/mbin/releases))\n\n### Create & configure media directory\n\n```bash\ncd /var/www/mbin\nmkdir public/media\nsudo chmod -R 775 public/media\nsudo chown -R mbin:www-data public/media\n```\n\n### Configure `var` directory\n\nCreate & set permissions to the `var` directory (used for cache and log files):\n\n```bash\ncd /var/www/mbin\nmkdir var\n\n# See also: https://symfony.com/doc/current/setup/file_permissions.html\n# if the following commands don't work, try adding `-n` option to `setfacl`\nHTTPDUSER=$(ps axo user,comm | grep -E '[a]pache|[h]ttpd|[_]www|[w]ww-data|[n]ginx' | grep -v root | head -1 | cut -d\\  -f1)\n\n# Set permissions for future files and folders\nsudo setfacl -dR -m u:\"$HTTPDUSER\":rwX -m u:$(whoami):rwX var\n\n# Set permissions on the existing files and folders\nsudo setfacl -R -m u:\"$HTTPDUSER\":rwX -m u:$(whoami):rwX var\n```\n\n### The dot env file\n\nThe `.env` file holds a lot of environment variables and is the main point for configuring mbin.\nWe suggest you place your variables in the `.env.local` file and have a 'clean' default one as the `.env` file.\nEach time this documentation talks about the `.env` file be sure to edit the `.env.local` file if you decided to use that.\n\n> In all environments, the following files are loaded if they exist, the latter taking precedence over the former:\n>\n> - .env contains default values for the environment variables needed by the app\n> - .env.local uncommitted file with local overrides\n\nMake a copy of the `.env.example` to `.env` and `.env.local` and edit the `.env.local` file:\n\n```bash\ncp .env.example .env\ncp .env.example .env.local\nnano .env.local\n```\n\n#### Service Passwords\n\nMake sure you have substituted all the passwords and configured the basic services in `.env` file.\n\n> [!NOTE]\n> The snippet below are to variables inside the `.env` file. Using the keys generated in the section above \"Generating Secrets\" fill in the values. You should fully review this file to ensure everything is configured correctly.\n\n```ini\nREDIS_PASSWORD=\"{!SECRET!!KEY!-32_1-!}\"\nAPP_SECRET=\"{!SECRET!!KEY-16_1-!}\"\nPOSTGRES_PASSWORD={!SECRET!!KEY!-32_2-!}\nRABBITMQ_PASSWORD=\"{!SECRET!!KEY!-16_2-!}\"\nMERCURE_JWT_SECRET=\"{!SECRET!!KEY!-32_3-!}\"\n```\n\n#### Other important `.env` configs:\n\n```ini\n# Configure your media URL correctly:\nKBIN_STORAGE_URL=https://domain.tld/media\n\n# Ubuntu 22.04 installs PostgreSQL v14 by default, Debian 12 PostgreSQL v15 is the default\nPOSTGRES_VERSION=14\n\n# Configure email, eg. using SMTP\nMAILER_DSN=smtp://127.0.0.1 # When you have a local SMTP server listening\n# But if already have Postfix configured, just use sendmail:\nMAILER_DSN=sendmail://default\n# Or Gmail (%40 = @-sign) use:\nMAILER_DSN=gmail+smtp://user%40domain.com:pass@smtp.gmail.com\n# Or remote SMTP with TLS on port 587:\nMAILER_DSN=smtp://username:password@smtpserver.tld:587?encryption=tls&auth_mode=log\n# Or remote SMTP with SSL on port 465:\nMAILER_DSN=smtp://username:password@smtpserver.tld:465?encryption=ssl&auth_mode=log\n```\n\n### OAuth2 keys for API credential grants\n\n1. Create an RSA key pair using OpenSSL:\n\n```bash\nmkdir ./config/oauth2/\n# If you protect the key with a passphrase, make sure to remember it!\n# You will need it later\nopenssl genrsa -des3 -out ./config/oauth2/private.pem 4096\nopenssl rsa -in ./config/oauth2/private.pem --outform PEM -pubout -out ./config/oauth2/public.pem\n```\n\n2. Generate a random hex string for the OAuth2 encryption key:\n\n```bash\nopenssl rand -hex 16\n```\n\n3. Add the public and private key paths to `.env`:\n\n```ini\nOAUTH_PRIVATE_KEY=%kernel.project_dir%/config/oauth2/private.pem\nOAUTH_PUBLIC_KEY=%kernel.project_dir%/config/oauth2/public.pem\nOAUTH_PASSPHRASE=<Your (optional) passphrase from above here>\nOAUTH_ENCRYPTION_KEY=<Hex string generated in previous step>\n```\n\nSee also: [Mbin config files](../02-configuration/01-mbin_config_files.md) for more configuration options.\n\n## Service Configuration\n\n### PHP\n\nEdit some PHP settings within your `php.ini` file:\n\n```bash\nsudo nano /etc/php/8.4/fpm/php.ini\n```\n\n```ini\n; Maximum execution time of each script, in seconds\nmax_execution_time = 60\n; Both max file size and post body size are personal preferences\nupload_max_filesize = 12M\npost_max_size = 12M\n; Remember the memory limit is per child process\nmemory_limit = 512M\n; maximum memory allocated to store the results\nrealpath_cache_size = 4096K\n; save the results for 10 minutes (600 seconds)\nrealpath_cache_ttl = 600\n```\n\nOptionally also enable OPCache for improved performances with PHP (recommended for both fpm and cli ini files):\n\n```ini\nopcache.enable = 1\nopcache.enable_cli = 1\nopcache.preload = /var/www/mbin/config/preload.php\nopcache.preload_user = www-data\n; Memory consumption (in MBs), personal preference\nopcache.memory_consumption = 512\n; Internal string buffer (in MBs), personal preference\nopcache.interned_strings_buffer = 128\nopcache.max_accelerated_files = 100000\nopcache.validate_timestamps = 0\n; Enable PHP JIT with all optimizations\nopcache.jit = 1255\nopcache.jit_buffer_size = 500M\n```\n\n> [!CAUTION]\n> Be aware that activating `opcache.preload` can lead to errors if you run multiple sites\n> (because of re-declaring classes).\n\nMore info: [Symfony Performance docs](https://symfony.com/doc/current/performance.html)\n\nEdit your PHP `www.conf` file as well, to increase the amount of PHP child processes (optional):\n\n```bash\nsudo nano /etc/php/8.4/fpm/pool.d/www.conf\n```\n\nWith the content (these are personal preferences, adjust to your needs):\n\n```ini\npm = dynamic\npm.max_children = 70\npm.start_servers = 10\npm.min_spare_servers = 5\npm.max_spare_servers = 10\n```\n\nBe sure to restart (or reload) the PHP-FPM service after you applied any changing to the `php.ini` file:\n\n```bash\nsudo systemctl restart php8.4-fpm.service\n```\n\n### Composer\n\nSetup composer in production mode:\n\n```bash\ncomposer install --no-dev\ncomposer dump-env prod\nAPP_ENV=prod APP_DEBUG=0 php bin/console cache:clear\ncomposer clear-cache\n```\n\n> [!CAUTION]\n> When running Symfony in _development mode_, your instance may _expose sensitive information_ to the public,\n> including database credentials, through the debug toolbar and stack traces.\n> **NEVER** expose your development instance to the Internet — doing so can lead to serious security risks.\n\n### Caching\n\nYou can choose between either Valkey, KeyDB or Redis.\n\n> [!TIP]\n> More Valkey/KeyDB/Redis fine-tuning settings can be found in the [Valkey / KeyDB / Redis configuration guide](../02-configuration/05-redis.md).\n\n#### Valkey / KeyDB or Redis\n\nEdit `redis.conf` file (or the corresponding Valkey or KeyDB config file):\n\n```bash\nsudo nano /etc/redis/redis.conf\n\n# Search on (ctrl + w): requirepass foobared\n# Remove the #, change foobared to the new {!SECRET!!KEY!-32_1-!} password, generated earlier\n\n# Search on (ctrl + w): supervised no\n# Change no to systemd, considering Ubuntu is using systemd\n```\n\nSave and exit (ctrl+x) the file.\n\nRestart Redis:\n\n```bash\nsudo systemctl restart redis.service\n```\n\nWithin your `.env` file set your Redis password:\n\n```ini\nREDIS_PASSWORD={!SECRET!!KEY!-32_1-!}\nREDIS_DNS=redis://${REDIS_PASSWORD}@$127.0.0.1:6379\n\n# Or if you want to use socket file:\n#REDIS_DNS=redis://${REDIS_PASSWORD}/var/run/redis/redis-server.sock\n# Or KeyDB socket file:\n#REDIS_DNS=redis://${REDIS_PASSWORD}/var/run/keydb/keydb.sock\n```\n\n#### KeyDB\n\n[KeyDB](https://github.com/Snapchat/KeyDB) is a fork of Redis. If you wish to use KeyDB instead, that is possible. Do **NOT** run both Redis & KeyDB, just pick one. After KeyDB run on the same default port 6379 (IANA #815344).\n\nBe sure you disabled redis first:\n\n```bash\nsudo systemctl stop redis\nsudo systemctl disable redis\n```\n\nOr even removed Redis: `sudo apt purge redis-server`\n\nFor Debian/Ubuntu you can install KeyDB package repository via:\n\n```bash\necho \"deb https://download.keydb.dev/open-source-dist $(lsb_release -sc) main\" | sudo tee /etc/apt/sources.list.d/keydb.list\nsudo wget -O /etc/apt/trusted.gpg.d/keydb.gpg https://download.keydb.dev/open-source-dist/keyring.gpg\nsudo apt update\nsudo apt install keydb\n```\n\nDuring the install you can choose between different installation methods, I advice to pick: \"keydb\", which comes with systemd files as well as the CLI tools (eg. `keydb-cli`).\n\nStart & enable the service if it isn't already:\n\n```bash\nsudo systemctl start keydb-server\nsudo systemctl enable keydb-server\n```\n\nConfiguration file is located at: `/etc/keydb/keydb.conf`. See also: [config documentation](https://docs.keydb.dev/docs/config-file).  \nFor example, you can also configure Unix socket files if you wish:\n\n```ini\nunixsocket /var/run/keydb/keydb.sock\nunixsocketperm 777\n```\n\nOptionally, if you want to set a password with KeyDB, _also add_ the following option to the bottom of the file:\n\n```ini\n# Replace {!SECRET!!KEY!-32_1-!} with the password generated earlier\nrequirepass \"{!SECRET!!KEY!-32_1-!}\"\n```\n\n### PostgreSQL (Database)\n\nCreate new `mbin` database user, using the password, `{!SECRET!!KEY!-32_2-!}`, you generated earlier:\n\n```bash\nsudo -u postgres createuser --createdb --createrole --pwprompt mbin\n```\n\nCreate tables and database structure:\n\n```bash\ncd /var/www/mbin\nphp bin/console doctrine:database:create\nphp bin/console doctrine:migrations:migrate\n```\n\n> [!IMPORTANT]\n> Check out the [PostgreSQL configuration page](../02-configuration/04-postgresql.md). You should not run the default PostgreSQL configuration in production!\n\n## Message Handling\n\n### RabbitMQ\n\nRabbitMQ is a feature rich, multi-protocol messaging and streaming broker, used by Mbin to process outgoing and incoming messages. \n\nRead also [What is RabbitMQ](../FAQ.md#what-is-rabbitmq) and [Symfony Messenger Queues](../04-running-mbin/04-messenger.md) for more information.\n\n#### Installing RabbitMQ\n\nSee also: [RabbitMQ Install](https://www.rabbitmq.com/docs/install-debian#apt-quick-start).\n\n> [!NOTE]\n> This assumes you already installed all the prerequisites packages from the \"System prerequisites\" chapter.\n\n```bash\n## Team RabbitMQ's signing key\ncurl -1sLf \"https://keys.openpgp.org/vks/v1/by-fingerprint/0A9AF2115F4687BD29803A206B73A36E6026DFCA\" | sudo gpg --dearmor | sudo tee /usr/share/keyrings/com.rabbitmq.team.gpg > /dev/null\n\n## Add apt repositories maintained by Team RabbitMQ\nsudo tee /etc/apt/sources.list.d/rabbitmq.list <<EOF\n## Modern Erlang/OTP releases\n##\ndeb [arch=amd64 signed-by=/usr/share/keyrings/com.rabbitmq.team.gpg] https://deb1.rabbitmq.com/rabbitmq-erlang/ubuntu/jammy jammy main\ndeb [arch=amd64 signed-by=/usr/share/keyrings/com.rabbitmq.team.gpg] https://deb2.rabbitmq.com/rabbitmq-erlang/ubuntu/jammy jammy main\n\n## Latest RabbitMQ releases\n##\ndeb [arch=amd64 signed-by=/usr/share/keyrings/com.rabbitmq.team.gpg] https://deb1.rabbitmq.com/rabbitmq-server/ubuntu/jammy jammy main\ndeb [arch=amd64 signed-by=/usr/share/keyrings/com.rabbitmq.team.gpg] https://deb2.rabbitmq.com/rabbitmq-server/ubuntu/jammy jammy main\nEOF\n\n## Update package indices\nsudo apt-get update -y\n\n## Install Erlang packages\nsudo apt-get install -y erlang-base \\\n                        erlang-asn1 erlang-crypto erlang-eldap erlang-ftp erlang-inets \\\n                        erlang-mnesia erlang-os-mon erlang-parsetools erlang-public-key \\\n                        erlang-runtime-tools erlang-snmp erlang-ssl \\\n                        erlang-syntax-tools erlang-tftp erlang-tools erlang-xmerl\n\n## Install rabbitmq-server and its dependencies\nsudo apt-get install rabbitmq-server -y --fix-missing\n```\n\nNow, we will add a new `mbin` user with the correct permissions:\n\n```bash\nsudo rabbitmqctl add_user 'mbin' '{!SECRET!!KEY!-16_2-!}'\nsudo rabbitmqctl set_permissions -p / mbin \".*\" \".*\" \".*\"\n```\n\nRemove the `guest` account:\n\n```bash\nsudo rabbitmqctl delete_user 'guest'\n```\n\n### AMQProxy\n\nInstalling and using AMQProxy is _optional_, however we highly recommend using AMQProxy for better performance and reduced protocol overhead.\n\nAMQProxy is proxy server for the AMQP protcol (one of the protocols supported by RabbitMQ) with channel pooling and channel reusing. Allows PHP clients to keep long lived connections to upstream servers, increasing publishing speed.\n\nSee also [What is AMQProxy](../FAQ.md#what-is-amqproxy)\n\n#### Installing AMQProxy\n\n```bash\ncurl -fsSL https://packagecloud.io/cloudamqp/amqproxy/gpgkey | gpg --dearmor | sudo tee /usr/share/keyrings/amqproxy.gpg > /dev/null\n. /etc/os-release\necho \"deb [signed-by=/usr/share/keyrings/amqproxy.gpg] https://packagecloud.io/cloudamqp/amqproxy/$ID $VERSION_CODENAME main\" | sudo tee /etc/apt/sources.list.d/amqproxy.list\nsudo apt-get update\nsudo apt-get install amqproxy\n```\n\n### Configure Queue Messenger Handler\n\n```bash\ncd /var/www/mbin\nnano .env\n```\n\nWe recommend to use RabbitMQ together with AMQProxy, AMQProxy is listening on port `5673` by default (you could also directly use RabbitMQ, but that is *not* recommended):\n\n```ini\n# Use RabbitMQ (recommended for production):\nRABBITMQ_PASSWORD=!ChangeThisRabbitPass!\n# Use RabbitMQ with AMQProxy (port 5673, recommended for production):\nMESSENGER_TRANSPORT_DSN=amqp://mbin:${RABBITMQ_PASSWORD}@127.0.0.1:5673/%2f/messages\n# Directly connect to RabbitMQ, without proxy (port 5672)\n#MESSENGER_TRANSPORT_DSN=amqp://mbin:${RABBITMQ_PASSWORD}@127.0.0.1:5672/%2f/messages\n\n# or Redis/KeyDB:\n#MESSENGER_TRANSPORT_DSN=redis://${REDIS_PASSWORD}@127.0.0.1:6379/messages\n# or PostgreSQL Database (Doctrine):\n#MESSENGER_TRANSPORT_DSN=doctrine://default\n```\n\n### Setup Supervisor\n\nWe use Supervisor to run our background workers, aka. \"Messengers\", which are processes that work together with RabbitMQ to consume the actual data.\n\nInstall Supervisor:\n\n```bash\nsudo apt-get install supervisor\n```\n\nConfigure the messenger jobs:\n\n```bash\nsudo nano /etc/supervisor/conf.d/messenger-worker.conf\n```\n\nWith the following content:\n\n```ini\n[program:messenger]\ncommand=php /var/www/mbin/bin/console messenger:consume scheduler_default old async outbox deliver inbox resolve receive failed --time-limit=3600\n#stdout_logfile=NONE\n#redirect_stderr=true\nuser=www-data\nnumprocs=6\nstartsecs=0\nautostart=true\nautorestart=true\nstartretries=10\nprocess_name=%(program_name)s_%(process_num)02d\n```\n\n> [!IMPORTANT]\n> Uncomment the `stdout_logfile` and `redirect_stderr` lines if you do **not** want the Supervisor worker logs being written to `/var/log/supervisor`. After all the same log entries will be written to the Mbin production log.\n\n> [!NOTE]\n> You can increase the number of running messenger jobs if your queue is building up (i.e. more messages are coming in than your messengers can handle).\n\nSave and close the file. Restart supervisor jobs:\n\n```bash\nsudo supervisorctl reread && sudo supervisorctl update && sudo supervisorctl start all\n```\n\n> [!TIP]\n> If you wish to restart your supervisor jobs in the future, use:\n>\n> ```bash\n> sudo supervisorctl restart all\n> ```\n"
  },
  {
    "path": "docs/02-admin/01-installation/02-docker.md",
    "content": "# Docker Installation\n\n## Minimum hardware requirements\n\n- **vCPU:** 4 virtual cores (>= 2GHz, _more is recommended_ on larger instances)\n- **RAM:** 6GB (_more is recommended_ for large instances)\n- **Storage:** 40GB (_more is recommended_, especially if you have a lot of remote/local magazines and/or have a lot of (local) users)\n\nYou can start with a smaller server and add more resources later if you are using a VPS for example.\n\n## System Prerequisites\n\n- Docker Engine\n- Docker Compose V2\n\n  > If you are using Compose V1, replace `docker compose` with `docker-compose` in those commands below.\n\n### Docker Install\n\nThe most convenient way to install docker is using an official [convenience script](https://docs.docker.com/engine/install/ubuntu/#install-using-the-convenience-script)\nprovided at [get.docker.com](https://get.docker.com/):\n\n```bash\ncurl -fsSL https://get.docker.com -o get-docker.sh\nsudo sh get-docker.sh\n```\n\nAlternatively, you can follow the official [Docker install documentation](https://docs.docker.com/engine/install/) for your platform.\n\nOnce Docker is installed on your system, it is recommended to create a `docker` group and add it to your user:\n\n```bash\nsudo groupadd docker\nsudo usermod -aG docker $USER\n```\n\n## Mbin Installation\n\n### Preparation\n\nClone git repository:\n\n```bash\ngit clone https://github.com/MbinOrg/mbin.git\ncd mbin\n```\n\n### Environment configuration\n\nUse either the automatic environment setup script _OR_ manually configure the `.env`, `compose.override.yaml`, and OAuth2 keys. Select one of the two options.\n\n> [!TIP]\n> Everything configured for your specific instance is in `.env`, `compose.override.yaml`, and `storage/` (assuming you haven't modified anything else). If you'd like to backup, or even completely reset/delete your instance, then these are the files to do so with.\n\n#### Automatic setup script\n\nRun the setup script and pass in a mode (either `prod` or `dev`) and your domain (which can be `localhost` if you plan to just test locally):\n\n```bash\n./docker/setup.sh prod mbin.domain.tld\n```\n\n> [!NOTE]\n> Once the script has been run, you will not be able to run it again, in order to prevent data loss. You can always edit the `.env` and `compose.override.yaml` files manually if you'd like to make changes.\n\nContinue on to the [_Docker image preparation_](#docker-image-preparation) section for the next steps.\n\n#### Manually configure `.env` and `compose.override.yaml`\n\nCreate config files and storage directories:\n\n```bash\ncp .env.example_docker .env\ntouch compose.override.yaml\nmkdir -p storage/{caddy_config,caddy_data,media,messenger_logs,oauth,php_logs,postgres,rabbitmq_data,rabbitmq_logs}\n```\n\n1. Choose your Valkey password, PostgreSQL password, RabbitMQ password, and Mercure password.\n2. Place the passwords in the corresponding variables in `.env`.\n3. Update the `SERVER_NAME`, `KBIN_DOMAIN` and `KBIN_STORAGE_URL` in `.env`.\n4. Update `APP_SECRET` in `.env`, see the note below to generate one.\n5. Update `MBIN_USER` in `.env` to match your user and group id (`id -u` & `id -g`).\n6. _Optionally_: Use a newer PostgreSQL version. Update/set the `POSTGRES_VERSION` variable in your `.env`.\n\n> [!NOTE]\n> To generate a random password or secret, use the following command:\n>\n> ```bash\n> tr -dc A-Za-z0-9 < /dev/urandom | head -c 32 && echo\n> ```\n\n##### Configure OAuth2 keys\n\n1. Create an RSA key pair using OpenSSL:\n\n```bash\n# If you protect the key with a passphrase, make sure to remember it!\n# You will need it later\nopenssl genrsa -des3 -out ./storage/oauth/private.pem 4096\nopenssl rsa -in ./storage/oauth/private.pem --outform PEM -pubout -out ./storage/oauth/public.pem\n```\n\n2. Generate a random hex string for the OAuth2 encryption key:\n\n```bash\nopenssl rand -hex 16\n```\n\n3. Add the public and private key paths to `.env`:\n\n```env\nOAUTH_PRIVATE_KEY=%kernel.project_dir%/config/oauth2/private.pem\nOAUTH_PUBLIC_KEY=%kernel.project_dir%/config/oauth2/public.pem\nOAUTH_PASSPHRASE=<Your passphrase from above here>\nOAUTH_ENCRYPTION_KEY=<Hex string generated in previous step>\n```\n\n### Docker image preparation\n\n> [!NOTE]\n> If you're using a version of Docker Engine earlier than 23.0, run `export DOCKER_BUILDKIT=1`, prior to building the image. This does not apply to users running Docker Desktop. More info can be found [here](https://docs.docker.com/build/buildkit/#getting-started)\n\nUse the Mbin provided Docker image (default) _OR_ build the docker image locally. Select one of the two options.\n\nThe default is to use our prebuilt images from [ghcr.io](https://github.com/MbinOrg/mbin/pkgs/container/mbin). Reference the next section if you'd like to build the Docker image locally instead.\n\n> [!IMPORTANT]\n> In **production** a recommended practice is to pin the image tag to a specific release (example: v1.8.2) _instead_ of using `latest`.\n>\n\nPinning the docker image version can be done by editing the `compose.override.yaml` file and uncommenting the following lines\n(update the version number to one you want to pin to and is available on [ghcr.io](https://github.com/MbinOrg/mbin/pkgs/container/mbin)):\n\n```yaml\nservices:\n  php:\n    image: ghcr.io/mbinorg/mbin:v1.8.2\n  messenger:\n    image: ghcr.io/mbinorg/mbin:v1.8.2\n```\n\n#### Build your own image\n\nIf you want to build your own image, add `pull_policy: build` to both the `php` and `messenger` services in `compose.override.yaml`:\n\n```yaml\nservices:\n  php:\n    pull_policy: build\n  messenger:\n    pull_policy: build\n```\n\nOnce that's done, run `docker compose build --no-cache` in order to build the Mbin Docker image.\n\n### Uploaded media files\n\nUploaded media files (e.g. photos uploaded by users) will be stored on the host directory `storage/media`. They will be served by the web server in the `php` container as static files.\n\nMake sure `KBIN_STORAGE_URL` in your `.env` configuration file is set to be `https://yourdomain.tld/media`.\n\nYou can also serve those media files on another server by mirroring the files at `storage/media` and changing `KBIN_STORAGE_URL` correspondingly.\n\n> [!TIP]\n> S3 can also be utilized to store images in the cloud. Just fill in the `S3_` fields in `.env` and Mbin will take care of the rest. See [this page](../03-optional-features/06-s3_storage.md) for more info.\n\n### Running behind a reverse proxy\n\nA reverse proxy is unneeded with this Docker setup, as HTTPS is automatically applied through the built in Caddy server. If you'd like to use a reverse proxy regardless, then you'll need to make a few changes:\n\n1. In `.env`, change your `SERVER_NAME` to `\":80\"`:\n\n```env\nSERVER_NAME=\":80\"\n```\n\n2. In `compose.override.yaml`, add `CADDY_GLOBAL_OPTIONS: auto_https off` to your php service environment:\n\n```yaml\nservices:\n  php:\n    environment:\n      CADDY_GLOBAL_OPTIONS: auto_https off\n```\n\n3. Also in `compose.override.yaml`, add `!override` to your php `ports` to override the current configuration and add your own based on what your reverse proxy needs:\n\n```yaml\nservices:\n  php:\n    ports: !override\n      - 8080:80\n```\n\nIn this example, port `8080` will connect to your Mbin server.\n\n4. Make sure your reverse proxy correctly sets the common `X-Forwarded` headers (especially `X-Forwarded-Proto`). This is needed so that both rate limiting works correctly, but especially so that your server can detect its correct outward facing protocol (HTTP vs HTTPS).\n\n> [!WARNING]\n> `TRUSTED_PROXIES` in `.env` needs to be a valid value (which is the default) in order for your server to work correctly behind a reverse proxy.\n\n> [!TIP]\n> In order to verify your server is correctly detecting it's public protocol (HTTP vs HTTPS), visit `/.well-known/nodeinfo` and look at which protocol is being used in the `href` fields. A public server should always be using HTTPS and not contain port numbers (i.e., `https://DOMAINHERE/`).\n\n### Additional configuration (Optional)\n\nIf you run a larger Mbin instance, its recommended to increase the `shm_size` value of the `postgres` service (first try the default `2gb`!). Although you can also decrease the number if you wish on smaller instances. `shm_size` sets the size of the shared memory (`/dev/shm`) and used for dynamic memory allocation. PostgreSQL is using this for buffering the write-ahead logs (also known as \"WALL buffer\").\n\nThe following step is **optional** and also depends on how much RAM you have left as well as how many parallel workers, table sizes, expected concurrent users and more. You can first use the default `2gb`, which should be sufficient, however below is explained how to further increase this number.\n\nIn `compose.override.yaml`, add `shm_size` to the `postgres` service (`4gb` is an example here):\n\n```yaml\nservices:\n  postgres:\n    shm_size: '4gb'\n```\n\n## Running the containers\n\nBy default `docker compose` will execute the `compose.yaml` and `compose.override.yaml` files.\n\nRun the container in the background (`-d` means detach, but this can also be omitted for testing or debugging purposes):\n\n```bash\ndocker compose up -d\n```\n\nSee your running containers via: `docker ps`.\n\nThis docker setup comes with automatic HTTPS support. Assuming you have set up your DNS and firewall (allow ports `80` & `443`) configured correctly, then you should be able to access the new instance via your domain.\n\n> [!NOTE]\n> If you specified `localhost` as your domain, then a self signed HTTPS certificate is provided and you should be able to access your instance here: [https://localhost](https://localhost).\n\nYou can also access the RabbitMQ management UI via [http://localhost:15672](http://localhost:15672).\n\n> [!WARNING]\n> Be sure not to forget the [Mbin first setup](../04-running-mbin/01-first_setup.md) instructions in order to create your admin user, `random` magazine, and AP & Push Notification keys.\n"
  },
  {
    "path": "docs/02-admin/01-installation/README.md",
    "content": "# Installation\n\nYou can choose between server production installations:\n\n- [Bare metal/VM installation](01-bare_metal.md)\n\nOr:\n\n- [Docker installation](02-docker.md)\n"
  },
  {
    "path": "docs/02-admin/02-configuration/01-mbin_config_files.md",
    "content": "# Mbin configuration files\n\nThese are additional configuration YAML file changes in the `config` directory.\n\n## Image redirect response code\n\n> [!NOTE]\n> The Docker setup already utilizes permanent image redirects, so you can safely ignore the following.\n\nAssuming you **are using Nginx** (as described above, with the correct configs), you can reduce the server load by changing the image redirect response code from `302` to `301`, which allows the client to cache the complete response. Edit the following file (from the root directory of Mbin):\n\n```bash\nnano config/packages/liip_imagine.yaml\n```\n\nAnd now change: `redirect_response_code: 302` to: `redirect_response_code: 301`. If you are experience image loading issues, validate your Nginx configuration or revert back your changes to `302`.\n\n---\n\n> [!TIP]\n> There are also other configuration files, eg. `config/packages/monolog.yaml` where you can change logging settings if you wish, but this is not required (these defaults are fine for production).\n"
  },
  {
    "path": "docs/02-admin/02-configuration/02-nginx.md",
    "content": "# NGINX\n\nWe will use NGINX as a reverse proxy between the public site and various backend services (static files, PHP and Mercure).\n\n## General NGINX configs\n\nGenerate DH parameters (used later):\n\n```bash\nsudo openssl dhparam -dsaparam -out /etc/nginx/dhparam.pem 4096\n```\n\nSet the correct permissions:\n\n```bash\nsudo chmod 644 /etc/nginx/dhparam.pem\n```\n\nEdit the main NGINX config file: `sudo nano /etc/nginx/nginx.conf` with the following content within the `http {}` section (replace when needed):\n\n```nginx\nssl_protocols TLSv1.2 TLSv1.3; # Requires nginx >= 1.13.0 else only use TLSv1.2\nssl_dhparam /etc/nginx/dhparam.pem;\nssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384:DHE-RSA-CHACHA20-POLY1305;\nssl_prefer_server_ciphers off;\nssl_ecdh_curve secp521r1:secp384r1:secp256k1; # Requires nginx >= 1.1.0\n\nssl_session_timeout 1d;\nssl_session_cache shared:MozSSL:10m;  # about 40000 sessions\nssl_session_tickets off; # Requires nginx >= 1.5.9\n\nssl_stapling on; # Requires nginx >= 1.3.7\nssl_stapling_verify on; # Requires nginx => 1.3.7\n\n# This is an example resolver configuration (replace the DNS IPs if you prefer)\nresolver 1.1.1.1 9.9.9.9 valid=300s;\nresolver_timeout 5s;\n\n# Gzip compression\ngzip            on;\ngzip_disable    msie6;\n\ngzip_vary       on;\ngzip_comp_level 5;\ngzip_min_length 256;\ngzip_buffers    16 8k;\ngzip_proxied    any;\ngzip_types\n        text/css\n        text/plain\n        text/javascript\n        text/cache-manifest\n        text/vcard\n        text/vnd.rim.location.xloc\n        text/vtt\n        text/x-component\n        text/x-cross-domain-policy\n        application/javascript\n        application/json\n        application/x-javascript\n        application/ld+json\n        application/xml\n        application/xml+rss\n        application/xhtml+xml\n        application/x-font-ttf\n        application/x-font-opentype\n        application/vnd.ms-fontobject\n        application/manifest+json\n        application/rss+xml\n        application/atom_xml\n        application/vnd.geo+json\n        application/x-web-app-manifest+json\n        image/svg+xml\n        image/x-icon\n        image/bmp\n        font/opentype;\n```\n\n## Mbin Nginx Server Block\n\n```bash\nsudo nano /etc/nginx/sites-available/mbin.conf\n```\n\nWith the content:\n\n```nginx\nupstream mercure {\n    server 127.0.0.1:3000;\n    keepalive 10;\n}\n\n# Map instance requests vs the rest\nmap \"$http_accept:$request\" $mbinInstanceRequest {\n    ~^.*:GET\\ \\/.well-known\\/.+                                                                       1;\n    ~^.*:GET\\ \\/nodeinfo\\/.+                                                                          1;\n    ~^.*:GET\\ \\/i\\/actor                                                                              1;\n    ~^.*:POST\\ \\/i\\/inbox                                                                             1;\n    ~^.*:POST\\ \\/i\\/outbox                                                                            1;\n    ~^.*:POST\\ \\/f\\/inbox                                                                             1;\n    ~^(?:application\\/activity\\+json|application\\/ld\\+json|application\\/json).*:GET\\ \\/               1;\n    ~^(?:application\\/activity\\+json|application\\/ld\\+json|application\\/json).*:GET\\ \\/f\\/object\\/.+  1;\n    default                                                                                           0;\n}\n\n# Map user requests vs the rest\nmap \"$http_accept:$request\" $mbinUserRequest {\n    ~^(?:application\\/activity\\+json|application\\/ld\\+json|application\\/json).*:GET\\ \\/u\\/.+   1;\n    ~^(?:application\\/activity\\+json|application\\/ld\\+json|application\\/json).*:POST\\ \\/u\\/.+  1;\n    default                                                                                    0;\n}\n\n# Map magazine requests vs the rest\nmap \"$http_accept:$request\" $mbinMagazineRequest {\n    ~^(?:application\\/activity\\+json|application\\/ld\\+json|application\\/json).*:GET\\ \\/m\\/.+   1;\n    ~^(?:application\\/activity\\+json|application\\/ld\\+json|application\\/json).*:POST\\ \\/m\\/.+  1;\n    default                                                                                    0;\n}\n\n# Miscellaneous requests\nmap \"$http_accept:$request\" $mbinMiscRequest {\n    ~^(?:application\\/activity\\+json|application\\/ld\\+json|application\\/json).*:GET\\ \\/reports\\/.+  1;\n    ~^(?:application\\/activity\\+json|application\\/ld\\+json|application\\/json).*:GET\\ \\/message\\/.+  1;\n    ~^.*:GET\\ \\/contexts\\..+                                                                        1;\n    default                                                                                         0;\n}\n\n# Determine if a request should go into the regular log\nmap \"$mbinInstanceRequest$mbinUserRequest$mbinMagazineRequest$mbinMiscRequest\" $mbinRegularRequest {\n    0000    1; # Regular requests\n    default 0; # Other requests\n}\n\nmap $mbinRegularRequest $mbin_limit_key {\n    0 \"\";\n    1 $binary_remote_addr;\n}\n\n# Two stage rate limit (10 MB zone): 5 requests/second limit (=second stage)\nlimit_req_zone $mbin_limit_key zone=mbin_limit:10m rate=5r/s;\n\n# Redirect HTTP to HTTPS\nserver {\n    server_name domain.tld;\n    listen 80;\n\n    return 301 https://$host$request_uri;\n}\n\nserver {\n    listen 443 ssl http2;\n    server_name domain.tld;\n\n    root /var/www/mbin/public;\n\n    index index.php;\n\n    charset utf-8;\n\n    # TLS\n    ssl_certificate /etc/letsencrypt/live/domain.tld/fullchain.pem;\n    ssl_certificate_key /etc/letsencrypt/live/domain.tld/privkey.pem;\n\n    # Don't leak powered-by\n    fastcgi_hide_header X-Powered-By;\n\n    # Security headers\n    add_header X-Frame-Options \"DENY\" always;\n    add_header X-XSS-Protection \"1; mode=block\" always;\n    add_header X-Content-Type-Options \"nosniff\" always;\n    add_header Referrer-Policy \"same-origin\" always;\n    add_header X-Download-Options \"noopen\" always;\n    add_header X-Permitted-Cross-Domain-Policies \"none\" always;\n    add_header Strict-Transport-Security \"max-age=31536000; includeSubDomains; preload\" always;\n\n    client_max_body_size 20M; # Max size of a file that a user can upload\n\n    # Two stage rate limit\n    limit_req zone=mbin_limit burst=300 delay=200;\n\n    # Error log (if you want you can add \"warn\" at the end of error_log to also log warnings)\n    error_log /var/log/nginx/mbin_error.log;\n\n    # Access logs\n    access_log /var/log/nginx/mbin_access.log combined if=$mbinRegularRequest;\n    access_log /var/log/nginx/mbin_instance.log combined if=$mbinInstanceRequest buffer=32k flush=5m;\n    access_log /var/log/nginx/mbin_user.log combined if=$mbinUserRequest buffer=32k flush=5m;\n    access_log /var/log/nginx/mbin_magazine.log combined if=$mbinMagazineRequest buffer=32k flush=5m;\n    access_log /var/log/nginx/mbin_misc.log combined if=$mbinMiscRequest buffer=32k flush=5m;\n\n    open_file_cache          max=1000 inactive=20s;\n    open_file_cache_valid    60s;\n    open_file_cache_min_uses 2;\n    open_file_cache_errors   on;\n\n    location / {\n        # try to serve file directly, fallback to index.php\n        try_files $uri /index.php$is_args$args;\n    }\n\n    location = /favicon.ico { access_log off; log_not_found off; }\n    location = /robots.txt  { allow all; access_log off; log_not_found off; }\n\n    location /.well-known/mercure {\n        proxy_pass http://mercure$request_uri;\n        # Increase this time-out if you want clients have a Mercure connection open for longer (eg. 24h)\n        proxy_read_timeout 2h;\n        proxy_http_version 1.1;\n        proxy_set_header Connection \"\";\n\n        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n        proxy_set_header X-Forwarded-Host $host;\n        proxy_set_header X-Forwarded-Proto $scheme;\n    }\n\n    location ~ ^/index\\.php(/|$) {\n        default_type application/x-httpd-php;\n        fastcgi_pass unix:/var/run/php/php-fpm.sock;\n        fastcgi_split_path_info ^(.+\\.php)(/.*)$;\n        include fastcgi_params;\n        fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;\n        fastcgi_param DOCUMENT_ROOT $realpath_root;\n\n        # Prevents URIs that include the front controller. This will 404:\n        # http://domain.tld/index.php/some-path\n        # Remove the internal directive to allow URIs like this\n        internal;\n    }\n\n    # bypass thumbnail cache image files\n    location ~ ^/media/cache/resolve {\n      expires 1M;\n      access_log off;\n      add_header Cache-Control \"public\";\n      try_files $uri $uri/ /index.php?$query_string;\n    }\n\n    # Static assets\n    location ~* \\.(?:css(\\.map)?|js(\\.map)?|jpe?g|png|tgz|gz|rar|bz2|doc|pdf|ptt|tar|gif|ico|cur|heic|webp|tiff?|mp3|m4a|aac|ogg|midi?|wav|mp4|mov|webm|mpe?g|avi|ogv|flv|wmv|svgz?|ttf|ttc|otf|eot|woff2?)$ {\n        expires    30d;\n        add_header Access-Control-Allow-Origin \"*\";\n        add_header Cache-Control \"public, no-transform\";\n        access_log off;\n    }\n\n    # return 404 for all other php files not matching the front controller\n    # this prevents access to other php files you don't want to be accessible.\n    location ~ \\.php$ {\n        return 404;\n    }\n\n    # Deny dot folders and files, except for the .well-known folder\n    location ~ /\\.(?!well-known).* {\n        deny all;\n    }\n}\n```\n\n> [!TIP]\n> If you have multiple PHP versions installed. You can switch the PHP version that Nginx is using (`/var/run/php/php-fpm.sock`) via the the following command:\n> `sudo update-alternatives --config php-fpm.sock`\n>\n> Same is true for the PHP CLI command (`/usr/bin/php`), via the following command:\n> `sudo update-alternatives --config php`\n\n> [!WARNING]\n> If also want to configure your `www.domain.tld` subdomain; our advice is to use a HTTP 301 redirect from the `www` subdomain towards the root domain. Do _NOT_ try to setup a second instance (you want to _avoid_ that ActivityPub will see `www` as a separate instance). See Nginx example below:\n\n```nginx\n# Example of a 301 redirect response for the www subdomain\nserver {\n    listen 80;\n    server_name www.domain.tld;\n    if ($host = www.domain.tld) {\n        return 301 https://domain.tld$request_uri;\n    }\n}\n\nserver {\n    listen 443 ssl;\n    http2 on;\n    server_name www.domain.tld;\n\n    # TLS\n    ssl_certificate /etc/letsencrypt/live/domain.tld/fullchain.pem;\n    ssl_certificate_key /etc/letsencrypt/live/domain.tld/privkey.pem;\n\n    # Don't leak powered-by\n    fastcgi_hide_header X-Powered-By;\n\n    return 301 https://domain.tld$request_uri;\n}\n```\n\nEnable the NGINX site, using a symlink:\n\n```bash\nsudo ln -s /etc/nginx/sites-available/mbin.conf /etc/nginx/sites-enabled/\n```\n\nRestart (or reload) NGINX:\n\n```bash\nsudo systemctl restart nginx\n```\n\n## Trusted Proxies\n\nIf you are using a reverse proxy, you need to configure your trusted proxies to use the `X-Forwarded-For` header. Mbin already configures the following trusted headers: `x-forwarded-for`, `x-forwarded-proto`, `x-forwarded-port` and `x-forwarded-prefix`.\n\nTrusted proxies can be configured in the `.env` file (or your `.env.local` file):\n\n```sh\nnano /var/www/mbin/.env\n```\n\nYou can configure a single IP address and/or a range of IP addresses (this configuration should be sufficient if you are running Nginx yourself):\n\n```ini\n# Change the IP range if needed, this is just an example\nTRUSTED_PROXIES=127.0.0.1,192.168.1.0/24\n```\n\nOr if the IP address is dynamic, you can set the `REMOTE_ADDR` string which will be replaced at runtime by `$_SERVER['REMOTE_ADDR']`:\n\n```ini\nTRUSTED_PROXIES=127.0.0.1,REMOTE_ADDR\n```\n\n> [!WARNING]\n> In this last example, be sure that you configure the web server to _not_\n> respond to traffic from _any_ clients other than your trusted load balancers\n> (eg. within AWS this can be achieved via security groups).\n\nFinally run the `post-upgrade` script to dump the `.env` to the `.env.local.php` and clear any cache:\n\n```sh\n./bin/post-upgrade\n```\n\nMore detailed info can be found at: [Symfony Trusted Proxies docs](https://symfony.com/doc/current/deployment/proxies.html)\n\n## Media reverse proxy\n\nWe suggest that you do not use this configuration:\n\n```ini\nKBIN_STORAGE_URL=https://mbin.domain.tld/media\n```\n\nInstead we suggest to use a subdomain for serving your media files:\n\n```ini\nKBIN_STORAGE_URL=https://media.mbin.domain.tld\n```\n\nThat way you can let nginx cache media assets and seamlessly switch to an object storage provider later.\n\n```bash\nsudo nano /etc/nginx/sites-available/mbin-media.conf\n```\n\n```nginx\nproxy_cache_path /var/cache/nginx levels=1:2 keys_zone=CACHE:10m inactive=7d max_size=10g;\n\nserver {\n    server_name media.mbin.domain.tld;\n    root /var/www/mbin/public/media;\n\n    listen 80;\n}\n```\n\nMake sure the `root /path` is correct (you may be using `/var/www/mbin/public`).\n\nEnable the NGINX site, using a symlink:\n\n```bash\nsudo ln -s /etc/nginx/sites-available/mbin-media.conf /etc/nginx/sites-enabled/\n```\n\n> [!TIP]\n> Before reloading nginx in a production environment you can run `nginx -t` to test your configuration.\n> If your configuration is faulty and you run `systemctl reload nginx` it will cause Nginx to stop instead of reloading cleanly.\n\nRun `systemctl reload nginx` so the site configuration is reloaded.\nFor it to be a usable HTTPS site, you must run: `certbot --nginx` and select the media domain or supply your certificates manually.\n\n> [!TIP]\n> Don't forget to enable HTTP/2 by adding `http2 on;` after certbot ran (underneath the `listen 443 ssl;` line). It used to be part of the same line, however in recent NGINX versions `http2 on` is a separate directive for enabling the HTTP/2 protocol.\n"
  },
  {
    "path": "docs/02-admin/02-configuration/03-lets_encrypt.md",
    "content": "# Let's Encrypt (TLS)\n\n> [!TIP]\n> The Certbot authors recommend installing through snap as some distros' versions from APT tend to fall out-of-date; see https://eff-certbot.readthedocs.io/en/latest/install.html#snap-recommended for more.\n\nInstall Snapd:\n\n```bash\nsudo apt-get install snapd\n```\n\nInstall Certbot:\n\n```bash\nsudo snap install core; sudo snap refresh core\nsudo snap install --classic certbot\n```\n\nAdd symlink:\n\n```bash\nsudo ln -s /snap/bin/certbot /usr/bin/certbot\n```\n\nFollow the prompts to create TLS certificates for your domain(s). If you don't already have NGINX up, you can use standalone mode.\n\n```bash\nsudo certbot certonly\n\n# Or if you wish not to use the standalone mode but the Nginx plugin:\nsudo certbot --nginx -d domain.tld\n```\n"
  },
  {
    "path": "docs/02-admin/02-configuration/04-postgresql.md",
    "content": "# PostgreSQL\n\nPostgreSQL is used as the database for Mbin.\n\nFor production, you **do** want to change the default PostgreSQL settings (since the default settings are _not_ recommended).\n\nEdit your PostgreSQL configuration file (assuming you're running PostgreSQL v16 or up):\n\n```bash\nsudo nano /etc/postgresql/16/main/postgresql.conf\n```\n\nThese settings below are more **an indication and heavily depends on your server specifications**. As well as if you are running other services on the same server.\n\nHowever, the following settings are a good starting point when your serve is around 12 vCPUs and 32GB of RAM. Be sure to fune-tune these settings to your needs.\n\n```ini\n# Increase max connections\nmax_connections = 200\n\n# Increase shared buffers\nshared_buffers = 8GB\n# Enable huge pages (Be sure to check the note down below in order to enable huge pages!)\n# This will fail if you didn't configure huge pages under Linux\n# (if you do NOT want to use huge pages, set it to: \"try\" instead of: \"on\")\nhuge_pages = on\n\n# Increase work memory\nwork_mem = 15MB\n# Increase maintenance work memory\nmaintenance_work_mem = 2GB\n\n# Should be posix under Linux anyway, just to be sure...\ndynamic_shared_memory_type = posix\n\n# Increase the number of IO current disk operations (especially useful for SSDs)\neffective_io_concurrency = 200\n\n# Increase the number of work processes (do not exceed your number of CPU cores)\n# Adjusting this setting, means you should also change:\n# max_parallel_workers, max_parallel_maintenance_workers and max_parallel_workers_per_gather\nmax_worker_processes = 16\n\n# Increase parallel workers per gather\nmax_parallel_workers_per_gather = 4\nmax_parallel_maintenance_workers = 4\n# Maximum number of work processes that can be used in parallel operations (we set it the same as max_worker_processes)\n# You should *not* increase this value more than max_worker_processes\nmax_parallel_workers = 16\n\n# Boost transaction speeds and reduce I/O wait for writes (with the risk of losing un-flushed data in case of a crash)\n# If you do not want to take that risk, keep it to: \"on\".\nsynchronous_commit = off\n\n# Group write commits to combine multiple transactions by a single flush (this is a time delay in μs)\ncommit_delay = 300\n\n# Increase the checkpoint timeout (time between two checkpoints) to reduce the disk I/O\n# This will significantly reduce the disk I/O and speed-up the write times to disk. The only downside is time needed for crash recovery.\ncheckpoint_timeout = 40min\ncheckpoint_completion_target = 0.9\n# Write ahead log sizes (so the WAL file can contain around 1 hour of data)\nmax_wal_size = 10GB\nmin_wal_size = 2GB\n\n# Query tuning\n# Set to 1.1 for SSDs.\n# Increase this number (eg. 4.0) if you are running on slow spinning disks\nrandom_page_cost = 1.1\n\n# Increase the cache size, increasing the likelihood of index scans (if we have enough RAM memory)\n# Try to aim for: RAM size * 0.8 (on a dedicated DB server)\neffective_cache_size = 24GB\n```\n\nFor reference check out [PGTune](https://pgtune.leopard.in.ua/) (this tool will **not** cover all the settings mentioned above, so be aware of that).\n\n> [!NOTE]\n> We try to set `huge_pages` to: `on` in PostgreSQL, in order to make this work you will need to [enable huge pages under Linux (click here)](https://www.enterprisedb.com/blog/tuning-debian-ubuntu-postgresql) as well! Please follow that guide. And play around with your kernel configurations.\n"
  },
  {
    "path": "docs/02-admin/02-configuration/05-redis.md",
    "content": "# Redis / KeyDB / Valkey\n\nThis documentation is valid for both Redis as well as KeyDB and Valkey. Both Valkey and KeyDB are forks of Redis, but should work mostly in the same manner.\n\n## Configure Redis\n\nEdit the Redis instance for Mbin: `sudo nano /etc/redis/redis.conf`:\n\n```ruby\n# NETWORK\ntimeout 300\ntcp-keepalive 300\n\n# MEMORY MANAGEMENT\nmaxmemory 1gb\nmaxmemory-policy volatile-ttl\n\n# LAZY FREEING\nlazyfree-lazy-eviction yes\nlazyfree-lazy-expire yes\nlazyfree-lazy-server-del yes\nreplica-lazy-flush yes\n```\n\nFeel free to adjust the memory settings to your liking.\n\n> [!WARNING]\n> Mbin (more specifically Symfony RedisTagAwareAdapter) only support `noeviction` and `volatile-*` settings for the `maxmemory-policy` Redis setting.\n\n## Multithreading\n\nConfigure multiple threads in Redis/Valkey by setting the following two lines:\n\n```ruby\n# THREADED I/O\nio-threads 4\nio-threads-do-reads yes\n```\n\nHowever, when using **KeyDB**, you need to update the following line (`io-threads` doesn't exists in KeyDB):\n\n```ruby\n# WORKER THREADS\nserver-threads 4\n```\n\n## Redis as a cache\n\n_Optionally:_ If you are using this Redis instance only for Mbin as a cache, you can disable snapshots in Redis/Valkey/KeyDB. Which will no longer dump the database to disk and reduce the amount of disk space used as well the disk I/O.\n\nFirst comment out existing \"save lines\" in the Redis/Valkey/KeyDB configuration file:\n\n```ruby\n#save 900 1\n#save 300 10\n#save 60 10000\n```\n\nThen add the following line to disable snapshots fully:\n\n```ruby\nsave \"\"\n```\n"
  },
  {
    "path": "docs/02-admin/02-configuration/README.md",
    "content": "# Configuration\n\nThese configuration guides can help you configure specific services in more detail.  \nAssuming you have already Mbin installed and followed the installation guide.\n\nCurrently, the following configuration guides are provided:\n\n- [Mbin config](./01-mbin_config_files.md)\n- [Nginx](./02-nginx.md)\n- [Let's Encrypt](./03-lets_encrypt.md)\n- [PostgreSQL](./04-postgresql.md)\n- [Valkey / KeyDB / Redis](./05-redis.md)\n"
  },
  {
    "path": "docs/02-admin/03-optional-features/01-mercure.md",
    "content": "# Mercure\n\nMore info: [Mercure Website](https://mercure.rocks/), Mercure is used in Mbin for real-time communication between the server and the clients.\n\n### Caddybundle\n\nDownload and install Mercure (we are using [Caddyserver.com](https://caddyserver.com/download?package=github.com%2Fdunglas%2Fmercure) mirror to download Mercure):\n\n```bash\nsudo wget \"https://caddyserver.com/api/download?os=linux&arch=amd64&p=github.com%2Fdunglas%2Fmercure%2Fcaddy&idempotency=69982897825265\" -O /usr/local/bin/mercure\n\nsudo chmod +x /usr/local/bin/mercure\n```\n\nPrepare folder structure with the correct permissions:\n\n```bash\ncd /var/www/mbin\nmkdir -p metal/caddy\nsudo chmod -R 775 metal/caddy\nsudo chown -R mbin:www-data metal/caddy\n```\n\n[Caddyfile Global Options](https://caddyserver.com/docs/caddyfile/options)\n\n> [!NOTE]\n> Caddyfiles: The one provided should work for most people, edit as needed via the previous link. Combination of mercure.conf and Caddyfile\n\nAdd new `Caddyfile` file:\n\n```bash\nnano metal/caddy/Caddyfile\n```\n\nThe content of the `Caddyfile`:\n\n```conf\n{\n        {$GLOBAL_OPTIONS}\n        # No SSL needed\n        auto_https off\n        http_port {$HTTP_PORT}\n        persist_config off\n\n        log {\n                # DEBUG, INFO, WARN, ERROR, PANIC, and FATAL\n                level WARN\n                output discard\n                output file /var/www/mbin/var/log/mercure.log {\n                        roll_size 50MiB\n                        roll_keep 3\n                }\n\n                format filter {\n                        wrap console\n                        fields {\n                                uri query {\n                                        replace authorization REDACTED\n                                }\n                        }\n                }\n        }\n}\n\n{$SERVER_NAME:localhost}\n\n{$EXTRA_DIRECTIVES}\n\nroute {\n\tmercure {\n\t\t# Transport to use (default to Bolt with max 1000 events)\n\t\ttransport_url {$MERCURE_TRANSPORT_URL:bolt://mercure.db?size=1000}\n\t\t# Publisher JWT key\n\t\tpublisher_jwt {env.MERCURE_PUBLISHER_JWT_KEY} {env.MERCURE_PUBLISHER_JWT_ALG}\n\t\t# Subscriber JWT key\n\t\tsubscriber_jwt {env.MERCURE_SUBSCRIBER_JWT_KEY} {env.MERCURE_SUBSCRIBER_JWT_ALG}\n    # Workaround for now\n\t\tanonymous\n\t\t# Extra directives\n\t\t{$MERCURE_EXTRA_DIRECTIVES}\n\t}\n\n\trespond /healthz 200\n\trespond \"Not Found\" 404\n}\n```\n\nEnsure not random formatting errors in the Caddyfile\n\n```bash\nmercure fmt metal/caddy/Caddyfile --overwrite\n```\n\n### Supervisor Job\n\nWe use supervisor for running the Mercure job:\n\n```bash\nsudo nano /etc/supervisor/conf.d/mercure.conf\n```\n\nWith the following content:\n\n```ini\n[program:mercure]\ncommand=/usr/local/bin/mercure run --config /var/www/mbin/metal/caddy/Caddyfile\nprocess_name=%(program_name)s_%(process_num)s\nnumprocs=1\nenvironment=MERCURE_PUBLISHER_JWT_KEY=\"{!SECRET!!KEY!-32_3-!}\",MERCURE_SUBSCRIBER_JWT_KEY=\"{!SECRET!!KEY!-32_3-!}\",SERVER_NAME=\":3000\",HTTP_PORT=\"3000\"\ndirectory=/var/www/mbin/metal/caddy\nautostart=true\nautorestart=true\nstartsecs=5\nstartretries=10\nuser=www-data\nredirect_stderr=false\nstdout_syslog=true\n```\n\nAfterwards let supervisor reread the configuration and update the processing groups:\n```bash\nsudo supervisorctl reread && sudo supervisorctl update && sudo supervisorctl start all\n```\n"
  },
  {
    "path": "docs/02-admin/03-optional-features/02-sso.md",
    "content": "# SSO (Single Sign On) Providers\n\nSSOs are used to simplify the registration flow. You authorize the server to use an existing account from one\nof the available SSO providers.\n\nMbin supports a multitude of SSO providers:\n\n- Google\n- Facebook\n- GitHub\n- Keycloak\n- Zitadel\n- SimpleLogin\n- Discord\n- Authentik\n- Privacy Portal\n- Azure\n\nTo enable an SSO provider you (usually) have to create a developer account on the specific platform, create an app\nand provide the app/client ID and a secret. These have to be entered in the correct environment variable\nin the `.env`|`.env.local` file\n\n### Google\n\nhttps://developers.google.com/\n\n```ini\nOAUTH_GOOGLE_ID=AS2easdioh912 # your client ID\nOAUTH_GOOGLE_SECRET=sdfpsajh329ura39ßseaoßjf30u # your client secret\n```\n\n### Facebook\n\nhttps://developers.facebook.com\n\n```ini\nOAUTH_FACEBOOK_ID=AS2easdioh912 # your client ID\nOAUTH_FACEBOOK_SECRET=sdfpsajh329ura39ßseaoßjf30u # your client secret\n```\n\n### GitHub\n\nYou need a GitHub account, if you do no have one, yet, go and create one: https://github.com/signup\n\n1. Go to https://github.com/settings/developers\n2. Click on \"New OAuth App\"\n3. Enter the app name, description and Homepage URL (just your instance URL)\n4. Insert `https://YOURINSTANCE/oauth/github/verify` as the \"Authorization callback URL\" (replace `YOURINSTANCE` with the URL of your instance)\n5. Scroll down and click \"Register application\"\n6. Now you have the chance to upload an icon (at the bottom of the page)\n7. Click \"Generate a new client secret\"\n8. Insert the \"Client ID\" and the generated client secret into the `.env` file:\n\n```ini\nOAUTH_GITHUB_ID=AS2easdioh912 # your client ID\nOAUTH_GITHUB_SECRET=sdfpsajh329ura39ßseaoßjf30u # your client secret\n```\n\n### Keycloak\n\nSelf-hosted, https://www.keycloak.org/\n\n```ini\nOAUTH_KEYCLOAK_ID=AS2easdioh912 # your client ID\nOAUTH_KEYCLOAK_SECRET=sdfpsajh329ura39ßseaoßjf30u # your client secret\nOAUTH_KEYCLOAK_URI=\nOAUTH_KEYCLOAK_REALM=\nOAUTH_KEYCLOAK_VERSION=\n```\n\n### Zitadel\n\nSelf-hosted, https://zitadel.com/\n\n```ini\nOAUTH_ZITADEL_ID=AS2easdioh912 # your client ID\nOAUTH_ZITADEL_SECRET=sdfpsajh329ura39ßseaoßjf30u # your client secret\nOAUTH_ZITADEL_BASE_URL=\n```\n\n### SimpleLogin\n\nYou need a SimpleLogin account, if you do not have one, yet, go and create one: https://app.simplelogin.io/auth/register\n\n1. Go to https://app.simplelogin.io/developer and click on \"New website\"\n2. Enter the name of your instance and the url to your instance\n3. Choose an icon (if you want to)\n4. Click on \"OAuth Settings\" on the right\n5. Insert the client ID (\"AppID / OAuth2 Client ID\") and the client secret (\"AppSecret / OAuth2 Client Secret\")\n   in your `.env` file\n\n```ini\nOAUTH_SIMPLELOGIN_ID=gehirneimer.de-vycjfiaznc # your client ID\nOAUTH_SIMPLELOGIN_SECRET=fdiuasdfusdfsdfpsdagofweopf # your client secret\n```\n\n6. Back in the browser, scroll down to \"Authorized Redirect URIs\" and click on \"Add new uri\"\n\n### Discord\n\nYou need a Discord account, if you do not have one, yet, go and create one: https://discord.com/register\n\n1. Go to https://discord.com/developers/applications and create a new application. If you want, add an image and a description.\n2. Click the \"OAuth2\" tab on the left\n3. Under \"Client information\" click \"Reset Secret\"\n4. The newly generated secret and the \"Client ID\" need to go in our `.env` file:\n\n```ini\nOAUTH_DISCORD_ID=3245498543 # your client ID\nOAUTH_DISCORD_SECRET=xJHGApsadOPUIAsdoih # your client secret\n```\n\n5. Back in the browser: click on \"Add Redirect\"\n6. enter the URL: `https://YOURINSTANCE/oauth/discord/verify`, replace `YOURINSTANCE` with your instance domain\n7. If you are on docker, restart the containers, on bare metal execute the `post-upgrade` script\n8. When you go to the login page you should see a button to \"Continue with Discord\"\n\n### Authentik\n\nSelf-hosted, https://goauthentik.io/\n\n```ini\nOAUTH_AUTHENTIK_ID=3245498543 # your client ID\nOAUTH_AUTHENTIK_SECRET=xJHGApsadOPUIAsdoih # your client secret\nOAUTH_AUTHENTIK_BASE_URL=\n```\n\n### Privacy Portal\n\nYou need a Privacy Portal account, if you do not have one, yet, go and create one: https://app.privacyportal.org/\n\n1. Go to https://app.privacyportal.org/settings/developers and create a new application. Add a meaningful name.\n   - Insert `https://YOURINSTANCE` as the \"Homepage URL\" (replace `YOURINSTANCE` with the URL of your instance).\n   - Insert `https://YOURINSTANCE/oauth/privacyportal/verify` as the \"Callback URL\" (replace `YOURINSTANCE` with the URL of your instance).\n2. Click \"Register\" to save the application.\n3. You may change icon, homepage URL and callback URL in the \"App info\" tab.\n4. Enable \"Public access\" in the \"Access management\" tab, so other Privacy Portal users can log into your instance.\n5. In the \"Credentials\" tab, generate a new secret. This secret and the client ID from the same tab will go into your `.env` file:\n\n```ini\nOAUTH_PRIVACYPORTAL_ID=3245498543 # your client ID\nOAUTH_PRIVACYPORTAL_SECRET=xJHGApsadOPUIAsdoih # your client secret\n```\n\n### Azure\n\nhttps://login.microsoftonline.com\n\n```ini\nOAUTH_AZURE_ID=3245498543 # your client ID\nOAUTH_AZURE_SECRET=xJHGApsadOPUIAsdoih # your client secret\nOAUTH_AZURE_TENANT=\n```\n"
  },
  {
    "path": "docs/02-admin/03-optional-features/03-captcha.md",
    "content": "# Captcha\n\nGo to [hcaptcha.com](https://www.hcaptcha.com) and create a free account. Make a sitekey and a secret. Add domain.tld to the sitekey.\nOptionally, increase the difficulty threshold. Making it even harder for bots.\n\nEdit your `.env` file:\n\n```ini\nKBIN_CAPTCHA_ENABLED=true\nHCAPTCHA_SITE_KEY=sitekey\nHCAPTCHA_SECRET=secret\n```\n\nThen dump-env your configuration file:\n\n```bash\ncomposer dump-env prod\n```\n\nor:\n\n```bash\ncomposer dump-env dev\n```\n\nFinally, go to the admin panel, settings tab and check \"Captcha enabled\" and press \"Save\".\n"
  },
  {
    "path": "docs/02-admin/03-optional-features/04-user_application.md",
    "content": "# Manually Approving New Users\n\nMbin allows you to manually approve new users before they can log into your server.\n\nIf you want to manually approve users before they can log into your server, \nyou can either tick the 'New users have to be approved by an admin before they can log in' checkbox in the admin settings.   \nOr put this in the `.env` file:\n\n```ini\nMBIN_NEW_USERS_NEED_APPROVAL=true\n```\n\nThe admin will then see a new 'Signup request' panel in the admin interface where new user registrations will appear pending your approval or denial.\n\nWhen an administrator approves or denies an user application, the user will receive an email notification about the decision.\n"
  },
  {
    "path": "docs/02-admin/03-optional-features/05-image_metadata_cleaning.md",
    "content": "# Image metadata cleaning with `exiftool`\n\nIt is possible to configure Mbin to remove meta-data from images.\n\nTo use this feature, install `exiftool` (`libimage-exiftool-perl` package for Ubuntu/Debian)\nand make sure `exiftool` executable exist and and visible in PATH\n\nAvailable options in `.env`:\n\n```bash\n# available modes: none, sanitize, scrub\n# can be set differently for user uploaded and external media\nEXIF_CLEAN_MODE_UPLOADED=sanitize\nEXIF_CLEAN_MODE_EXTERNAL=none\n# path to exiftool binary, leave blank for auto PATH search\nEXIF_EXIFTOOL_PATH=\n# max execution time for exiftool in seconds, defaults to 10 seconds\nEXIF_EXIFTOOL_TIMEOUT=10\n```\n\nAvailable cleaning modes are:\n\n- `none`: no metadata cleaning occurs.\n- `sanitize`: GPS and serial number metadata is removed. This is the default for uploaded images.\n- `scrub`: most metadata is removed, except for the metadata required for proper image rendering\n  and XMP IPTC attribution metadata.\n\nMore detailed information can [be found in the source-code](https://github.com/MbinOrg/mbin/blob/de20877d2d10e085bb35e1e1716ea393b7b8b9fc/src/Utils/ExifCleaner.php#L16) (for example look at `EXIFTOOL_ARGS_SCRUB`). Showing which arguments are passed to the `exiftool` CLI command.\n"
  },
  {
    "path": "docs/02-admin/03-optional-features/06-s3_storage.md",
    "content": "# S3 Images storage\n\n## Migrating the media files\n\nIf you're starting a new instance, you can skip this part.\n\nTo migrate to S3 storage we have to sync the media files located at `/var/www/mbin/public/media` into our S3 bucket.\nWe suggest running the sync once while your instance is still up and using the local storage for media, then shutting mbin down,\nconfigure it to use the S3 storage and do another sync to get all the files created during the initial sync.\n\nTo actually do the file sync you can use different tools, like `aws-cli`, `rclone` and others,\njust search for it and you will find plenty tutorials on how to do that\n\n## Configuring Mbin\n\nEdit your `.env` file:\n\n```ini\nS3_KEY=$AWS_ACCESS_KEY_ID\nS3_SECRET=$AWS_SECRET_ACCESS_KEY\nS3_BUCKET=bucket-name\n# safe default for S3 deployments like minio or single zone ceph/radosgw\nS3_REGION=us-east-1\n# set if not using aws S3, note that the scheme is also required\nS3_ENDPOINT=https://endpoint.domain.tld\nS3_VERSION=latest\n```\n\nThen edit the: `config/packages/oneup_flysystem.yaml` file (only needed on bare metal/VM, not Docker):\n\n```yaml\noneup_flysystem:\n  adapters:\n    default_adapter:\n      local:\n        location: \"%kernel.project_dir%/public/%uploads_dir_name%\"\n\n    kbin.s3_adapter:\n      awss3v3:\n        client: kbin.s3_client\n        bucket: \"%amazon.s3.bucket%\"\n        options:\n          ACL: public-read\n\n  filesystems:\n    public_uploads_filesystem:\n      # switch the adapter to s3 adapter\n      #adapter: default_adapter\n      adapter: kbin.s3_adapter\n      alias: League\\Flysystem\\Filesystem\n```\n\n## NGINX reverse proxy\n\nIf you are using an object storage provider, we strongly advise you to use a media reverse proxy.\nThat way media URLs will not change and break links on remote instances when you decide to switch providers\nand it hides your S3 endpoint from users of your instance.\n\nThis replaces the media reverse proxy from [NGINX](../02-configuration/02-nginx.md).\n\nIf you already had a reverse proxy for your media, then you only have to change the NGINX config,\notherwise please follow the steps in our [media-reverse-proxy](../02-configuration/02-nginx.md) docs\n\nThis config is heavily inspired by [Mastodons Nginx config](https://docs.joinmastodon.org/admin/optional/object-storage-proxy/).\n\n```nginx\nproxy_cache_path /var/cache/nginx levels=1:2 keys_zone=CACHE:10m inactive=7d max_size=10g;\n\nserver {\n    server_name https://media.mbin.domain.tld;\n\n    location / {\n        try_files $uri @s3;\n    }\n\n    set $s3_backend 'https://your.s3.endpoint.tld';\n\n    location @s3 {\n        limit_except GET {\n            deny all;\n        }\n\n        resolver 1.1.1.1;\n\n        proxy_set_header Accept 'image/*';\n        proxy_set_header Connection '';\n        proxy_set_header Authorization '';\n        proxy_hide_header Set-Cookie;\n        proxy_hide_header 'Access-Control-Allow-Origin';\n        proxy_hide_header 'Access-Control-Allow-Methods';\n        proxy_hide_header 'Access-Control-Allow-Headers';\n        proxy_hide_header x-amz-id-2;\n        proxy_hide_header x-amz-request-id;\n        proxy_hide_header x-amz-meta-server-side-encryption;\n        proxy_hide_header x-amz-server-side-encryption;\n        proxy_hide_header x-amz-bucket-region;\n        proxy_hide_header x-amzn-requestid;\n        proxy_ignore_headers Set-Cookie;\n        proxy_pass $s3_backend$uri;\n        proxy_intercept_errors off;\n\n        proxy_cache CACHE;\n        proxy_cache_valid 200 48h;\n        proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;\n        proxy_cache_lock on;\n\n        expires 1y;\n        add_header Cache-Control public;\n        add_header 'Access-Control-Allow-Origin' '*';\n        add_header X-Cache-Status $upstream_cache_status;\n        add_header X-Content-Type-Options nosniff;\n        add_header Content-Security-Policy \"default-src 'none'; form-action 'none'\";\n    }\n    listen 80;\n}\n```\n\nFor it to be a usable HTTPS site you have to run `certbot` or supply your certificates manually.\n\n> [!TIP]\n> Do not forget to enable http2 by adding `http2 on;` after certbot ran successfully.\n"
  },
  {
    "path": "docs/02-admin/03-optional-features/07-anubis.md",
    "content": "# Anubis setup for Mbin\n\n### Why?\n\nAnubis is a program that attempts to block bots by presenting proof-of-work challenges to the user. A normal browser will simply solve the challenges (provided JavaScript is enabled), while AI scrapers will most likely just accept the challenge as the response.\n\nThe simple answer is: because it's better than entirely blocking anonymous access to Mbin.\n\n### How does it work?\n\nSee the official [How Anubis works](https://anubis.techaro.lol/docs/design/how-anubis-works) web page.\n\n# Bare metal with Nginx\n\n## External links\n\n- [Installation (Official documentation)](https://anubis.techaro.lol/docs/admin/installation)\n- [Native installation (Official documentation)](https://anubis.techaro.lol/docs/admin/native-install)\n- [Best practices for unix socket (on Anubis GitHub.com project)](https://github.com/TecharoHQ/anubis/discussions/541)\n\n## Anubis setup\n\n### Installation\n\nDownload the package for your system from [the most recent release on GitHub](https://github.com/TecharoHQ/anubis/releases) and install the package via your package manager:\n\n- `deb`: `sudo apt install ./anubis-$VERSION-$ARCH.deb`\n- `rpm`: `sudo dnf -y install ./anubis-$VERSION.$ARCH.rpm`\n\n### Configuration\n\nThen create the environment file `/etc/anubis/mbin.env` with the following content:\n\n```dotenv\nBIND=/run/anubis/mbin.sock\nBIND_NETWORK=unix\nSOCKET_MODE=0666\nDIFFICULTY=4\nMETRICS_BIND=:4673\nSERVE_ROBOTS_TXT=0\nTARGET=unix:///run/nginx/mbin.sock\nPOLICY_FNAME=/etc/anubis/mbin.botPolicies.yaml\n```\n\nCopy the content from [default bot policy](https://github.com/TecharoHQ/anubis/blob/main/data/botPolicies.yaml) to `/etc/anubis/mbin.botPolicies.yaml`.\n\nIn the `bots` section of the `mbin.botPolicies.yaml` file, prepend the following (has to be in front of the other rules) to explicitly allow all API, RSS, and ActivityPub requests:\n\n```yaml\n  - name: mbin-activity-pub\n    headers_regex:\n      Accept: application\\/activity\\+json|application\\/ld\\+json\n    action: ALLOW\n  - name: mbin-api\n    headers_regex:\n      Accept: application\\/json\n    action: ALLOW\n  - name: mbin-rss\n    headers_regex:\n      Accept: application\\/rss\\+xml\n    action: ALLOW\n  - name: nodeinfo\n    path_regex: ^\\/nodeinfo\\/.*$\n    action: ALLOW\n```\n\nYou should also switch the store backend to something different from the default in-memory one. If you want to use a local Bolt database, by using the `bbolt` package ([see alternatives](https://anubis.techaro.lol/docs/admin/policies#storage-backends)), change the `store` section to the following (in `mbin.botPolicies.yaml`):\n\n```yaml\nstore:\n  backend: bbolt\n  parameters:\n    path: /opt/anubis/mbin.bdb\n```\n\nAdjust the `thresholds` section to match this (the only difference is that the `preact` type of challenge is removed):\n\n```yaml\nthresholds:\n  # By default Anubis ships with the following thresholds:\n  - name: minimal-suspicion # This client is likely fine, its soul is lighter than a feather\n    expression: weight <= 0 # a feather weighs zero units\n    action: ALLOW # Allow the traffic through\n  # For clients that had some weight reduced through custom rules, give them a\n  # lightweight challenge.\n  - name: mild-suspicion\n    expression:\n      all:\n        - weight > 0\n        - weight < 10\n    action: CHALLENGE\n    challenge:\n      # https://anubis.techaro.lol/docs/admin/configuration/challenges/metarefresh\n      algorithm: metarefresh\n      difficulty: 1\n      report_as: 1\n  # For clients that are browser-like but have either gained points from custom rules or\n  # report as a standard browser.\n  - name: moderate-suspicion\n    expression:\n      all:\n        - weight >= 10\n        - weight < 30\n    action: CHALLENGE\n    challenge:\n      # https://anubis.techaro.lol/docs/admin/configuration/challenges/proof-of-work\n      algorithm: fast\n      difficulty: 2 # two leading zeros, very fast for most clients\n      report_as: 2\n  # For clients that are browser like and have gained many points from custom rules\n  - name: extreme-suspicion\n    expression: weight >= 30\n    action: CHALLENGE\n    challenge:\n      # https://anubis.techaro.lol/docs/admin/configuration/challenges/proof-of-work\n      algorithm: fast\n      difficulty: 4\n      report_as: 4\n```\n\nThe default config includes a few snippets that require a subscription. To avoid any warning messages, you should comment out any sections that contain \"Requires a subscription to Thoth to use\" (just search for it in the file).\n\nFor Anubis to be able to access the socket that we will use later, we will have to change the service file (`/usr/lib/systemd/system/anubis@.service`) and run the Anubis service under the `www-data` user:\n\n1. Remove: `DynamicUser=yes`\n2. Add: `User=www-data`\n\nThere are some paths that need to be created and then owned by `www-data` user and group:\n\n```bash\nsudo mkdir -p /opt/anubis/ && sudo  mkdir -p /run/anubis/ && sudo mkdir -p /run/nginx/\nsudo chown -R www-data.www-data /opt/anubis/ && sudo chown -R www-data.www-data /run/anubis/ && sudo chown -R www-data.www-data /run/nginx/\n```\n\n### Starting it\n\nStart the Anubis service with the following command:\n\n```bash\nsudo systemctl enable --now anubis@mbin.service\n```\n\nTest it to make sure Anibus is running by using `curl`:\n\n```bash\ncurl http://localhost:4673/metrics\n```\n\nIf you need to restart Anubis, just run:\n\n```bash\nsudo systemctl restart anubis@mbin.service\n```\n\n## Nginx preparations\n\nCreate an nginx upstream to anubis ([anubis docs](https://anubis.techaro.lol/docs/admin/environments/nginx)):\n```nginx\nupstream anubis {\n  # Make sure this matches the values you set for `BIND` and `BIND_NETWORK`.\n  # If this does not match, your services will not be protected by Anubis.\n\n  # Try Anubis first over a UNIX socket\n  server unix:/run/anubis/mbin.sock;\n  #server 127.0.0.1:8923;\n\n  # Optional: fall back to serving the websites directly. This allows your\n  # websites to be resilient against Anubis failing, at the risk of exposing\n  # them to the raw internet without protection. This is a tradeoff and can\n  # be worth it in some edge cases.\n  #server unix:/run/nginx.sock backup;\n}\n```\n\nYou can just put it in `/etc/nginx/conf.d/anubis.conf`, for example, and the default Nginx configuration will then import this file.\n\n## Change nginx mbin.conf\n\nNow we need to modify the Nginx configuration that is serving Mbin. We will use the default config as an example.\n\n### Short Explainer Version\n\n**Without** Anubis:\n\n```nginx\n# Redirect HTTP to HTTPS\nserver {\n    server_name domain.tld;\n    listen 80;\n\n    return 301 https://$host$request_uri;\n}\n\nserver {\n    listen 443 ssl http2;\n    server_name domain.tld;\n\n    root /var/www/mbin/public;\n\n    location / {\n        # try to serve file directly, fallback to index.php\n        try_files $uri /index.php$is_args$args;\n    }\n}\n```\n\n**With** Anubis:\n\n```nginx\n# Redirect HTTP to HTTPS\nserver {\n    server_name domain.tld;\n    listen 80;\n\n    return 301 https://$host$request_uri;\n}\n\nserver {\n    listen 443 ssl http2;\n    server_name domain.tld;\n\n    location / {\n        proxy_set_header Host $host;\n        proxy_set_header X-Real-IP $remote_addr;\n        proxy_set_header X-Http-Version $server_protocol;\n        proxy_pass http://anubis;\n    }\n}\n\nserver {\n    listen unix:/run/nginx/mbin.sock;\n    server_name domain.tld;\n    root /var/www/mbin/public;\n\n    # Get the visiting IP from the TLS termination server\n    set_real_ip_from unix:;\n    real_ip_header   X-Real-IP;\n\n    location / {\n        # try to serve file directly, fallback to index.php\n        try_files $uri /index.php$is_args$args;\n        \n        # lie to Symfony that the request is an HTTPS one, so it generates HTTPS URLs\n        fastcgi_param SERVER_PORT \"443\";\n        fastcgi_param HTTPS \"on\";\n    }\n}\n```\n\nAs you can see, instead of serving Mbin directly, we proxy it through the Anubis service. Anubis will then decide whether to call the UNIX socket that the actual Mbin site is served over, or if it will present a challenge to the client (or straight up deny it).\n\nDuring the actual Mbin call, we lie to Symfony that the request is coming from port 443 (`fastcgi_param SERVER_PORT`) and that HTTPS is being used (`fastcgi_param HTTPS`).\nThe reason is that it will otherwise generate HTTP (non-secure) URLs that are incompatible with some other Fediverse software, such as Lemmy.\n\n### The long one\n\n```nginx\nupstream mercure {\n    server 127.0.0.1:3000;\n    keepalive 10;\n}\n\n# Map instance requests vs the rest\nmap \"$http_accept:$request\" $mbinInstanceRequest {\n    ~^.*:GET\\ \\/.well-known\\/.+                                                                       1;\n    ~^.*:GET\\ \\/nodeinfo\\/.+                                                                          1;\n    ~^.*:GET\\ \\/i\\/actor                                                                              1;\n    ~^.*:POST\\ \\/i\\/inbox                                                                             1;\n    ~^.*:POST\\ \\/i\\/outbox                                                                            1;\n    ~^.*:POST\\ \\/f\\/inbox                                                                             1;\n    ~^(?:application\\/activity\\+json|application\\/ld\\+json|application\\/json).*:GET\\ \\/               1;\n    ~^(?:application\\/activity\\+json|application\\/ld\\+json|application\\/json).*:GET\\ \\/f\\/object\\/.+  1;\n    default                                                                                           0;\n}\n\n# Map user requests vs the rest\nmap \"$http_accept:$request\" $mbinUserRequest {\n    ~^(?:application\\/activity\\+json|application\\/ld\\+json|application\\/json).*:GET\\ \\/u\\/.+   1;\n    ~^(?:application\\/activity\\+json|application\\/ld\\+json|application\\/json).*:POST\\ \\/u\\/.+  1;\n    default                                                                                    0;\n}\n\n# Map magazine requests vs the rest\nmap \"$http_accept:$request\" $mbinMagazineRequest {\n    ~^(?:application\\/activity\\+json|application\\/ld\\+json|application\\/json).*:GET\\ \\/m\\/.+   1;\n    ~^(?:application\\/activity\\+json|application\\/ld\\+json|application\\/json).*:POST\\ \\/m\\/.+  1;\n    default                                                                                    0;\n}\n\n# Miscellaneous requests\nmap \"$http_accept:$request\" $mbinMiscRequest {\n    ~^(?:application\\/activity\\+json|application\\/ld\\+json|application\\/json).*:GET\\ \\/reports\\/.+  1;\n    ~^(?:application\\/activity\\+json|application\\/ld\\+json|application\\/json).*:GET\\ \\/message\\/.+  1;\n    ~^.*:GET\\ \\/contexts\\..+                                                                        1;\n    default                                                                                         0;\n}\n\n# Determine if a request should go into the regular log\nmap \"$mbinInstanceRequest$mbinUserRequest$mbinMagazineRequest$mbinMiscRequest\" $mbinRegularRequest {\n    0000    1; # Regular requests\n    default 0; # Other requests\n}\n\nmap $mbinRegularRequest $mbin_limit_key {\n    0 \"\";\n    1 $binary_remote_addr;\n}\n\n# Two stage rate limit (10 MB zone): 5 requests/second limit (=second stage)\nlimit_req_zone $mbin_limit_key zone=mbin_limit:10m rate=5r/s;\n\n# Redirect HTTP to HTTPS\nserver {\n    server_name domain.tld;\n    listen 80;\n\n    return 301 https://$host$request_uri;\n}\n\nserver {\n    listen 443 ssl http2;\n    server_name domain.tld;    \n    \n    # uncomment for troubleshooting purposes\n    #access_log /var/log/nginx/anubis_mbin_access.log combined;\n\n\n    location / {\n        proxy_set_header Host $host;\n        proxy_set_header X-Real-IP $remote_addr;\n        proxy_set_header X-Http-Version $server_protocol;\n        proxy_pass http://anubis;\n    }\n}\n\nserver {\n    listen unix:/run/nginx/mbin.sock;\n    server_name domain.tld;\n    root /var/www/mbin/public;\n\n    # Get the visiting IP from the TLS termination server\n    set_real_ip_from unix:;\n    real_ip_header   X-Real-IP;\n\n    index index.php;\n\n    charset utf-8;\n\n    # TLS\n    ssl_certificate /etc/letsencrypt/live/domain.tld/fullchain.pem;\n    ssl_certificate_key /etc/letsencrypt/live/domain.tld/privkey.pem;\n\n    # Don't leak powered-by\n    fastcgi_hide_header X-Powered-By;\n\n    # Security headers\n    add_header X-Frame-Options \"DENY\" always;\n    add_header X-XSS-Protection \"1; mode=block\" always;\n    add_header X-Content-Type-Options \"nosniff\" always;\n    add_header Referrer-Policy \"same-origin\" always;\n    add_header X-Download-Options \"noopen\" always;\n    add_header X-Permitted-Cross-Domain-Policies \"none\" always;\n    add_header Strict-Transport-Security \"max-age=31536000; includeSubDomains; preload\" always;\n\n    client_max_body_size 20M; # Max size of a file that a user can upload\n\n    # Two stage rate limit\n    limit_req zone=mbin_limit burst=300 delay=200;\n\n    # Error log (if you want you can add \"warn\" at the end of error_log to also log warnings)\n    error_log /var/log/nginx/mbin_error.log;\n\n    # Access logs\n    access_log /var/log/nginx/mbin_access.log combined if=$mbinRegularRequest;\n    access_log /var/log/nginx/mbin_instance.log combined if=$mbinInstanceRequest buffer=32k flush=5m;\n    access_log /var/log/nginx/mbin_user.log combined if=$mbinUserRequest buffer=32k flush=5m;\n    access_log /var/log/nginx/mbin_magazine.log combined if=$mbinMagazineRequest buffer=32k flush=5m;\n    access_log /var/log/nginx/mbin_misc.log combined if=$mbinMiscRequest buffer=32k flush=5m;\n\n    open_file_cache          max=1000 inactive=20s;\n    open_file_cache_valid    60s;\n    open_file_cache_min_uses 2;\n    open_file_cache_errors   on;\n\n    location / {\n        # try to serve file directly, fallback to index.php\n        try_files $uri /index.php$is_args$args;\n    }\n\n    location = /favicon.ico { access_log off; log_not_found off; }\n    location = /robots.txt  { allow all; access_log off; log_not_found off; }\n\n    location /.well-known/mercure {\n        proxy_pass http://mercure$request_uri;\n        # Increase this time-out if you want clients have a Mercure connection open for longer (eg. 24h)\n        proxy_read_timeout 2h;\n        proxy_http_version 1.1;\n        proxy_set_header Connection \"\";\n\n        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n        proxy_set_header X-Forwarded-Host $host;\n        proxy_set_header X-Forwarded-Proto $scheme;\n    }\n\n    location ~ ^/index\\.php(/|$) {\n        default_type application/x-httpd-php;\n        fastcgi_pass unix:/var/run/php/php-fpm.sock;\n        fastcgi_split_path_info ^(.+\\.php)(/.*)$;\n        include fastcgi_params;\n        fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;\n        fastcgi_param DOCUMENT_ROOT $realpath_root;\n        \n        # lie to Symfony that the request is an HTTPS one, so it generates HTTPS URLs\n        fastcgi_param SERVER_PORT \"443\";\n        fastcgi_param HTTPS \"on\";\n\n        # Prevents URIs that include the front controller. This will 404:\n        # http://domain.tld/index.php/some-path\n        # Remove the internal directive to allow URIs like this\n        internal;\n    }\n\n    # bypass thumbs cache image files\n    location ~ ^/media/cache/resolve {\n      expires 1M;\n      access_log off;\n      add_header Cache-Control \"public\";\n      try_files $uri $uri/ /index.php?$query_string;\n    }\n\n    # Static assets\n    location ~* \\.(?:css(\\.map)?|js(\\.map)?|jpe?g|png|tgz|gz|rar|bz2|doc|pdf|ptt|tar|gif|ico|cur|heic|webp|tiff?|mp3|m4a|aac|ogg|midi?|wav|mp4|mov|webm|mpe?g|avi|ogv|flv|wmv|svgz?|ttf|ttc|otf|eot|woff2?)$ {\n        expires    30d;\n        add_header Access-Control-Allow-Origin \"*\";\n        add_header Cache-Control \"public, no-transform\";\n        access_log off;\n    }\n\n    # return 404 for all other php files not matching the front controller\n    # this prevents access to other php files you don't want to be accessible.\n    location ~ \\.php$ {\n        return 404;\n    }\n\n    # Deny dot folders and files, except for the .well-known folder\n    location ~ /\\.(?!well-known).* {\n        deny all;\n    }\n}\n```\n\nTo test whether Mbin correctly uses the HTTPS scheme, you can run this command (replaced with your URL and username):\n\n```bash\ncurl --header \"Accept: application/activity+json\" https://example.mbin/u/admin | jq\n```\n\nThe `| jq` part outputs formatted JSON, which should make this easier to view. There should not be any `http://` URLs in this output.  \n\n### Take it live\n\nTo start routing traffic through Anubis, Nginx must be restarted (not just reloaded) due to the new socket that needs to be created. However, before we do that, we should check the config for validity:\n\n```bash\nsudo nginx -t\n```\n\nIf `nginx -t` runs successfully, then you should ensure Anubis is also running without any issues:\n\n```bash\nsystemctl status anubis@mbin.service\n```\n\nYou can finally restart Nginx with:\n\n```bash\nsudo systemctl restart nginx\n```\n\nOnce you reload the Mbin website, you should see the Anubis challenge page that checks your browser.\n\n### Troubleshooting\n\nUse the following to view the Anubis logs:\n\n```bash\njournalctl -ru anubis@mbin.service\n```\n\nIn the Nginx config for Mbin, you can uncomment the access log line to see the access logs for the Anubis upstream. If you combine that with changing the status codes in the Anubis policy (just open the policy and search for `status_codes`) this is a good way to check whether RSS, API and ActivityPub requests still make it through.\n"
  },
  {
    "path": "docs/02-admin/03-optional-features/08-monitoring.md",
    "content": "# Internal Mbin Monitoring\n\nWe have a few environment variables that can enable monitoring of the mbin server, \nspecifically the executed database queries, rendered html components and requested web resources during the execution of\nan HTTP request or a message handler (a background job).\nThis allows the admin to collect performance metrics for optimizing the server settings,\nor developers to optimize the code.  \n\nEnabling monitoring on your server will have a performance impact. It is not necessarily noticeable for your users, \nbut it will increase the resource consumption.  \nDuring an execution context (request or messenger) the server will collect monitoring information according to your settings.\nAfter the execution is finished the collected information will be saved to the DB according to your settings\n(which is the main performance impact and happens after a request is finished).\n\nThe available settings are:\n- `MBIN_MONITORING_ENABLED`: Whether monitoring is enabled at all, if `false` then the other settings do not matter\n- `MBIN_MONITORING_QUERIES_ENABLED`: Whether to monitor query execution, defaults to true\n- `MBIN_MONITORING_QUERY_PERSISTING_ENABLED`: Whether the monitored queries are persisted to the database. If this is disabled only the total query time will be persisted.\n- `MBIN_MONITORING_QUERY_PARAMETERS_ENABLED`: Whether the parameter of database queries should be saved. If enabled the spaces used might increase a lot.\n- `MBIN_MONITORING_TWIG_RENDERS_ENABLED`: Whether to monitor twig rendering, defaults to true\n- `MBIN_MONITORING_TWIG_RENDER_PERSISTING_ENABLED`: Whether to persist the monitored twig renders. If this is disabled only the total rendering time will be persisted.\n- `MBIN_MONITORING_CURL_REQUESTS_ENABLED`: Whether to monitor curl requests, defaults to true\n- `MBIN_MONITORING_CURL_REQUEST_PERSISTING_ENABLED`: Whether to persist the monitored curl requests. If this is disabled only total request time will be persisted. \n\nIf the monitoring of e.g. queries is enabled, but the persistence is not, \nthen the execution context will have a total duration of the executed queries, \nbut you cannot inspect the executed queries.\n\nDepending on your persistence settings the monitoring can take up a lot of space. \nThe largest amount will come from the queries, then the twig renders and at last the curl requests.\n\n> [!TIP]\n> To delete you monitoring data see [cli docs](../04-running-mbin/05-cli.md#delete-monitoring-data).\n\n## UI\n\nAt `/admin/monitoring` is the overview of the monitoring. There you can see a chart of the longest taking execution contexts.\nUnderneath that is a table containing the most recent execution contexts according to your filter settings. \nThe chart also takes the filter settings into account.\n\nBy clicking the alphanumeric string in the first column of the table (part of the GUID of the execution context)\nyou get to the overview of that context, containing the meta information about this context.\n\n> [!TIP]\n> The percentage numbers of \"SQL Queries\", \"Twig Renders\" and \"Curl Requests\" do not necessarily add up to 100% or even below 100%,\n> because \"Twig Renders\" could execute database queries for example.\n"
  },
  {
    "path": "docs/02-admin/03-optional-features/09-image-compression.md",
    "content": "# Image compression\n\nYou can enable compression of images uploaded to mbin by users or downloaded from remote instances, \nfor increased compatibility, to save on size and for a better user experience.\n\nTo enable image compression set `MBIN_IMAGE_COMPRESSION_QUALITY` in your `.env` file to a value between 0.1 and 0.95.\nThis setting is used as a starting point to compress the image. It is gradually lowered (in 0.05 steps) until the maximum size is no longer exceeded.\n\n> [!HINT]\n> The maximum file size is determined by the `MBIN_MAX_IMAGE_BYTES` setting in your `.env` file\n\n> [!NOTE]\n> Enabling this setting can cause a higher memory usage\n\n## Better compatibility\n\nIf another instance shares a thread with an image attached that exceeds your maximum image size, it will not be downloaded,\nbut instead loaded directly from the other instance. This works most of the time, \nbut sometimes website settings will block it and thus your users will see an image that cannot be loaded.\nThis behavior also introduces web requests to other servers, which may unintentionally leak information to the remote instance. \n\nIf instead your server compresses the image and saves it locally this will never happen.\n\n## Saving space\n\nWhen image compression is enabled you can reduce your maximum image size to, lets say 1MB. \nWithout the compression this might not be suitable, because too many images exceed that size,\nand you don't want to risk compatibility problems, \nbut with it enabled the images will just be compressed, saving space.\n\n## A better user experience\n\nNormally there is a maximum image size your users must adhere to, but if image compression is enabled,\ninstead of showing your user an error that the image exceeds that size, the upload goes through and the image is compressed.\n"
  },
  {
    "path": "docs/02-admin/03-optional-features/README.md",
    "content": "# Optional Features\n\nThere are several options features in Mbin, which you may want to configure.\n\nLike setting-up:\n\n- [Mercure](01-mercure.md) - Mercure is used to provide real-time data from the server towards the clients.\n- [Single sign-on (SSO)](02-sso.md) - SSO can be configured to allow registrations via other SSO providers.\n- [Captcha](03-captcha.md) - Captcha protection against spam and anti-bot.\n- [User application approval](04-user_application.md) - Manually approve users before they can log into your server (eg. to avoid spam accounts).\n- [Image metadata cleaning](05-image_metadata_cleaning.md) - Clean-up and remove metadata from images using `exiftool`.\n- [S3 storage](06-s3_storage.md) - Configure an object storage service (S3) compatible bucket for storing images.\n- [Anubis](07-anubis.md) - A service for weighing the incoming requests and may present them with a proof-of-work challenge. It is useful if your instance gets hit a lot of bot traffic that you're tired of filtering through\n- [Monitoring](08-monitoring.md) - Internal monitoring of requests and messengers\n- [Image compression](09-image-compression.md) - compress images if they exceed your maximum image size\n"
  },
  {
    "path": "docs/02-admin/04-running-mbin/01-first_setup.md",
    "content": "# Mbin first setup\n\n> [!TIP]\n> If you are running docker, then you have to prefix the following commands with\n> `docker compose exec php`.\n\nCreate new admin user (without email verification), please change the `username`, `email` and `password` below:\n\n```bash\nphp bin/console mbin:user:create <username> <email@example.com> <password>\nphp bin/console mbin:user:admin <username>\n```\n\n```bash\nphp bin/console mbin:ap:keys:update\n```\n\nNext, log in and create a magazine named `random` to which unclassified content from the fediverse will flow.\n\n> [!IMPORTANT]\n> Creating a `random` magazine is a requirement to getting microblog posts that don't fall under an existing magazine.\n\n```bash\nphp bin/console mbin:magazine:create random\n```\n\n### Manual user activation\n\nActivate a user account (bypassing email verification), please change the `username` below:\n\n```bash\nphp bin/console mbin:user:verify <username> -a\n```\n\n### Mercure\n\nIf you are not going to use Mercure, you have to disable it in the admin panel.\n\n### NPM (bare metal only)\n\n```bash\ncd /var/www/mbin\nnpm install # Installs all NPM dependencies\nnpm run build # Builds frontend\n```\n\nMake sure you have substituted all the passwords and configured the basic services.\n\n### Push Notification setup\n\nThe push notification system needs encryption keys to work. They have to be generated only once, by running\n\n```bash\nphp bin/console mbin:push:keys:update\n```\n"
  },
  {
    "path": "docs/02-admin/04-running-mbin/02-backup.md",
    "content": "# Backup and restore\n\n## Bare Metal\n\n### Backup\n\n```bash\nPGPASSWORD=\"YOUR_PASSWORD\" pg_dump -U mbin mbin > dump.sql\n```\n\n### Restore\n\n```bash\npsql -U mbin mbin < dump.sql\n```\n\n## Docker\n\n### Backup:\n\n```bash\ndocker compose exec -it postgres pg_dump -U mbin mbin > dump.sql\n```\n\n### Restore:\n\n```bash\ndocker compose exec -T postgres psql -U mbin mbin < dump.sql\n```\n"
  },
  {
    "path": "docs/02-admin/04-running-mbin/03-upgrades.md",
    "content": "# Upgrades\n\n## Bare Metal\n\nIf you perform a Mbin upgrade (eg. `git pull`), be aware to _always_ execute the following Bash script:\n\n```bash\n./bin/post-upgrade\n```\n\n### Clear Cache\n\nAnd when needed also execute: `sudo redis-cli FLUSHDB` to get rid of Redis/KeyDB cache issues. And reload the PHP FPM service if you have OPCache enabled.\n\n## Docker\n\n1. Pull the latest Docker image:\n\n```bash\ndocker compose pull\n```\n\nOr, if you are building locally, then you'll need to rebuild the Mbin docker image (without using cached layers):\n\n```bash\ndocker compose build --no-cache\n```\n\n2. Bring down the containers and up again (with `-d` for detach):\n\n```bash\ndocker compose down\ndocker compose up -d\n```\n"
  },
  {
    "path": "docs/02-admin/04-running-mbin/04-messenger.md",
    "content": "# Symfony Messenger (Queues)\n\nThe symphony messengers are background workers for a lot of different task, the biggest one being handling all the ActivityPub traffic.  \nWe have a few different queues:\n\n1. `receive` [RabbitMQ]: everything any remote instance sends to us will first end up in this queue.\n   When processing it will be determined what kind of message it is (creation of a thread, a new comment, etc.)\n2. `inbox` [RabbitMQ]: messages from `receive` with the determined kind of incoming message will end up here and the necessary actions will be executed.\n   This is the place where the thread or comment will actually be created\n3. `outbox` [RabbitMQ]: when a user creates a thread or a comment, a message will be created and send to the outbox queue\n   to build the ActivityPub object that will be sent to remote instances.\n   After the object is built and the inbox addresses of all the remote instances who are interested in the message are gathered,\n   we will create a `DeliverMessage` for every one of them, which will be sent to the `deliver` queue\n4. `deliver` [RabbitMQ]: Actually sending out the ActivityPub objects to other instances\n5. `resolve` [RabbitMQ]: Resolving dependencies or ActivityPub actors.\n   For example if your instance gets a like message for a post that is not on your instance a message resolving that dependency will be dispatched to this queue\n6. `async` [RabbitMQ]: messages in async are local actions that are relevant to this instance, e.g. creating notifications, fetching embedded images, etc.\n7. `old` [RabbitMQ]: the standard messages queue that existed before. This exists solely for compatibility purposes and might be removed later on\n8. `failed` [PostgreSQL]: jobs from the other queues that have been retried, but failed. They get retried a few times again, before they end up in\n9. `dead` [PostgreSQL]: dead jobs that will not be retried\n\nWe need the `dead` queue so that messages that throw a `UnrecoverableMessageHandlingException`, which is used to indicate that a message should not be retried and go straight to the supplied failure queue\n\n## Remove failed messages\n\nWe created a simple command to clean-up all the failed messages from the database at once:\n\n```bash\n./bin/console mbin:messenger:failed:remove_all\n```\n\nAnd to remove the dead messages from the database at once:\n\n```bash\n./bin/console mbin:messenger:dead:remove_all\n```\n\nHowever, most messages stored in the database are most likely failed messages. So it is advised to regularly run the `./bin/console mbin:messenger:failed:remove_all` command to clean-up the database.\n"
  },
  {
    "path": "docs/02-admin/04-running-mbin/05-cli.md",
    "content": "# CLI\n\nWhen you are the server administrator of the Mbin instance and you have access to the terminal / shell (via SSH for example), Mbin provides you several console commands.\n\n> [!WARNING]\n> We assume you are in the root directory of the Mbin instance (eg. `/var/www/mbin`),\n> this is the directory location where you want to execute console commands listed below. \n\n> [!WARNING]\n> Run the commands as the correct user. In case you used the `mbin` user during setup,\n> switch to the mbin user fist via: `sudo -u mbin bash`\n> (or if you used the `www-data` user during setup: `sudo -u www-data bash`).\n> This prevent potential unwanted file permission issues.\n\n## Getting started\n\nList all available console commands, execute:\n\n```bash\nphp bin/console\n```\n\nIn the next chapters the focus is on the `mbin` section of the `bin/console` console commands and go into more detail.\n\n## User Management\n\n### User-Create\n\nThis command allows you to create user, optionally granting administrator or global moderator privileges.\n\nUsage:\n\n```bash\nphp bin/console mbin:user:create [-r|--remove] [--admin] [--moderator] <username> <email> <password>\n```\n\nArguments:\n- `username`: the username that should be created.\n- `email`: the email for the user, keep in mind that this command will automatically verify the created user, so no email will be sent.\n- `password`: the password for the user.\n\nOptions:\n- `-r|--remove`: purge the user from the database, **without notifying the rest of the fediverse about it**. \n  If you want the rest of the fediverse notified please use the `mbin:user:delete` command instead.\n- `--admin`: make the created user an admin.\n- `--moderator`: make the created user a global moderator.\n\n### User-Admin\n\nThis command allows you to grant administrator privileges to the user.\n\nUsage:\n\n```bash\nphp bin/console mbin:user:admin [-r|--remove] <username>\n```\n\nArguments:\n- `username`: the username whose rights are to be modified.\n\nOptions:\n- `-r|--remove`: instead of granting privileges, remove them.\n\n### User-delete\nThis command will delete the supplied user and notify the fediverse about it. This is an asynchronous job, \nso you will not see the change immediately.\n\nUsage:\n\n```bash\nphp bin/console mbin:user:delete <user>\n```\n\nArguments:\n- `user`: the username of the user.\n\n### User-Moderator\nThis command allows you to grant global moderator privileges to the user.\n\nUsage:\n\n```bash\nphp bin/console mbin:user:moderator [-r|--remove] <username>\n```\n\nArguments:\n- `username`: the username whose rights are to be modified.\n\nOptions:\n- `-r|--remove`: instead of granting privileges, remove them.\n\n### User-Password\nThis command allows you to manually set or reset a users' password.\n\nUsage:\n\n```bash\nphp bin/console mbin:user:password <username> <password>\n```\n\nArguments:\n- `username`: the username whose password should be changed.\n- `password`: the password to change to.\n\n### User-Verify\nThis command allows you to manually activate or deactivate a user, bypassing email verification requirement.\n\nUsage:\n\n```bash\nphp bin/console mbin:user:verify [-a|--activate] [-d|--deactivate] <username>\n```\n\nArguments:\n- `username`: the user to activate (verify) or deactivate (remove verification).\n\nOptions:\n- `-a|--activate`: Activate user, bypass email verification.\n- `-d|--deactivate`: Deactivate user, require email (re)verification.\n\n> [!NOTE] \n> If neither `--activate` nor `--deactivate` are provided, the current verification status will be returned\n\n\n### Rotate users private keys\n\n> [!WARNING]\n> After running this command it can take up to 24 hours for other instances to update their stored public keys.\n> In this timeframe federation might be impacted by this, \n> as those services cannot successfully verify the identity of your users.\n> Please inform your users about this when you're running this command.\n\nThis command allows you to rotate the private keys of your users with which the activities sent by them are authenticated.\nIf private keys have been leaked you should rotate the private keys to avoid the potential for impersonation.\n\nUsage:\n\n```bash\nphp bin/console mbin:user:private-keys:rotate [-a|--all-local-users] [-r|--revert] [<username>]\n```\n\nArguments:\n- `username`: the single user for which this command should be executed (not required when using the `-a` / `--all-local-users` option, see below)\n\nOptions:\n- `-a|--all-local-users`: Rotate private keys of all local users\n- `-r|--revert`: revert to the old private and public keys\n\n### User-Unsub\n\n> [!NOTE]\n> This command is old and should probably not be used\n\nRemoves all followers from a user.\n\nUsage:\n\n```bash\nphp bin/console mbin:user:unsub <username>\n```\n\nArguments:\n- `username`: the user from which to remove all local followers.\n\n### Fix user duplicates\n\nThis command allows you to fix duplicate usernames. There is a unique index on the usernames, but it is case-sensitive.\nThis command will go through all the users with duplicate case-insensitive usernames,\nwhere the username is not part of the public id (meaning the original URL) or handle (you will be asked what to use for deduplication)\nand update them from the remote server.\nAfter that it will go through the rest of the duplicates and ask you whether you want to merge matching pairs (if you deduplicate by handle) \nor delete some of them (if you deduplicate by URL).\n\nUsage:\n\n```bash\nphp bin/console mbin:check:duplicates-users-magazines [--dry-run]\n# then select 'users'\n# then select either 'handle' or 'profileUrl'\n```\n\nOptions:\n- `--dry-run`: don't change anything in the DB\n\n\n## Magazine Management\n\n### Magazine-Create\n\nThis command allows you to create, delete and purge magazines.\n\nUsage:\n\n```bash\nphp bin/console mbin:magazine:create [-o|--owner OWNER] [-r|--remove] [--purge] [--restricted] [-t|--title TITLE] [-d|--description DESCRIPTION] <name>\n```\n\nArguments:\n- `name`: the name of the magazine that is part of the URL\n\nOptions:\n- `-o|--owner OWNER`: makes the supplied username the owner of the newly created magazine. \n  If this is omitted the admin account will be the owner of the magazine.\n- `--restricted`: create the magazine with posting restricted to the moderators of this magazine.\n- `-r|--remove`: instead of creating the magazine, remove it (not notifying the rest of the fediverse).\n- `--purge`: completely remove the magazine from the db (not notifying the rest of the fediverse).\n  If this and `--remove` are supplied, `--remove` has precedence over this.\n- `-t|--title TITLE`: makes the supplied string the title of the magazine (aka. the display name).\n- `-d|--description DESCRIPTION`: makes the supplied string the description of the magazine.\n\n### Magazine-Sub\n\nThis command allows to subscribe a user to a magazine.\n\nUsage:\n\n```bash\nphp bin/console mbin:magazine:sub [-u|--unsub] <magazine> <username>\n```\n\nArguments:\n- `magazine`: the magazine name to subscribe the user to.\n- `username`: the user that should be subscribed to the magazine.\n\nOptions:\n- `-u|--unsub`: instead of subscribing to the magazine, unsubscribe the user from the magazine.\n\n### Magazine-Unsub\n\nRemove all the subscribers from a magazine.\n\nUsage:\n\n```bash\nphp bin/console mbin:magazine:unsub <magazine>\n```\n\nArguments:\n- `magazine`: the magazine name from which to remove all the subscribers.\n\n## Direct Messages\n\n### Remove-and-Ban\n\nSearch for direct messages using the body input parameter. List all the found matches, and ask for permission to continue.\n\nIf you agree to continue, *all* the sender users will be **banned** and *all* the direct messages will be **removed**!\n\n> [!WARNING]\n> This action cannot be undone (once you confirmed with `yes`)!\n\nUsage:\n\n```bash\nphp bin/console mbin:messages:remove_and_ban \"<body>\"\n```\n\nArguments:\n- `body`: the direct message body to search for.\n\n## Post Management\n\n### Entries-Move\n\n> [!WARNING]\n> This command should not be used, as none of the changes will be federated.\n\nThis command allows you to move entries to a new magazine based on their tag.\n\nUsage:\n\n```bash\nphp bin/console mbin:entries:move <magazine> <tag>\n```\n\nArguments:\n- `magazine`: the magazine to which the entries should be moved\n- `tag`: the (hash)tag based on which the entries should be moved \n\n### Posts-Move\n\n> [!WARNING]\n> This command should not be used, as none of the changes will be federated.\n\nThis command allows you to move posts to a new magazine based on their tag.\n\nUsage:\n\n```bash\nphp bin/console mbin:posts:move <magazine> <tag>\n```\n\nArguments:\n- `magazine`: the magazine to which the posts should be moved\n- `tag`: the (hash)tag based on which the posts should be moved\n\n### Posts-Magazine\n\n> [!WARNING]\n> This command should not be used. Posts are automatically assigned to a magazine based on their tag.\n\nThis command will assign magazines to posts based on their tags.\n\nUsage:\n\n```bash\nphp bin/console mbin:posts:magazines\n```\n\n## Activity Pub\n\n### Actor update\n\n> [!NOTE]\n> This command will trigger **asynchronous** updates of remote users or magazines\n\nThis command will allow you to update remote actor (user/magazine) info.\n\nUsage:\n\n```bash\nphp bin/console mbin:actor:update [--users] [--magazines] [--force] [<user>]\n```\n\nArguments:\n- `user`: the username to dispatch an update for. \n\nOptions:\n- `--users`: if this options is provided up to 10,000 remote users ordered by their last update time will be updated\n- `--magazines`: if this options is provided up to 10,000 remote magazines ordered by their last update time will be updated\n\n### ActivityPub resource import\n\n> [!NOTE]\n> This command will trigger an **asynchronous** import\n\nThis command allows you to import an AP resource.\n\nUsage:\n\n```bash\nphp bin/console mbin:ap:import <url>\n```\n\nArguments:\n- `url`: the \"id\" of the ActivityPub object to import\n\n## Images\n\n### Remove cached remote media\n\nThis command allows you to remove the cached file of remote media, **without** deleting the reference.\nYou can run this command as a cron job to only keep cached media from the last 30 days for example.\n\n> [!TIP]\n> If a thread or microblog is opened without a local cache of the attached image existing, the image will be downloaded again.\n> Once an image is downloaded again, it will not get deleted for the number of days you set as a parameter.\n\n> [!NOTE]\n> User avatars and covers and magazine icons and banners are not affected by this command, \n> only images from threads, microblogs and comments.\n\nUsage:\n\n```bash\nphp bin/console mbin:images:remove-remote [--days|-d] [--batch-size] [--dry-run]\n```\n\nOptions:\n- `--days`|`-d`: the number of days of media you want to keep. Everything older than the amount of days will be deleted\n- `--batch-size` (default `10000`): the number of images to retrieve per query from the DB. A higher number means less queries, but higher memory usage.\n- `--dry-run`: if set, no images will be deleted\n\n### Refresh the meta data of stored images\n\nThis command allows you to refresh the filesize of the stored media, as well as the status.\nIf an image is no longer present on storage this command adjusts it in the DB.\n\nUsage:\n\n```bash\nphp bin/console mbin:images:refresh-meta [--batch-size] [--dry-run]\n```\n\nOptions:\n- `--batch-size` (default `10000`): the number of images to retrieve per query from the DB. A higher number means less queries, but higher memory usage.\n- `--dry-run`: if set, no metadata will be changed\n\n### Remove old federated images\n\nThis command allows you to remove old federated images, without removing the content.  \nThe image delete command works in batches, by default it will remove 800 images for each type.\n\nThe image(s) will be removed from the database as well as from disk / storage.\n\n> [!WARNING]\n> This action cannot be undone!\n\nUsage:\n\n```bash\nphp bin/console mbin:images:delete\n```\n\nArguments:\n- `type`: type of images that will get deleted, either: `all` (except for user images), `threads`, `thread_comments`, `posts`, `post_comments` or `users`. (default: `all`)\n- `monthsAgo`: Delete images older than given months are getting deleted (default: `12`)\n\nOptions:\n- `--noActivity`: delete images that doesn't have recorded activity. Like comments, updates and/or boosts. (default: `false`)\n- `--batchSize`: the number of images to delete for each type at a time. (default: `800`)\n\n### Remove orphaned media\n\nThis command iterates over your media filesystem and deletes all files that do not appear in the database.\n\n```bash\nphp bin/console mbin:images:remove-orphaned\n```\n\nOptions:\n- `--ignored-paths=IGNORED-PATHS`: A comma seperated list of paths to be ignored in this process. If the path starts with one of the supplied strings it will be skipped. e.g. \"/cache\" [default: \"\"]\n- `--dry-run`: Dry run, don't delete anything\n\n### Rebuild image cache\n\nThis command allows you to rebuild image thumbnail cache.\nIt executes the `liip:imagine:cache:resolve` command for every user- and magazine-avatar and linked image in entries and posts.\n\n> [!NOTE]\n> This command will trigger **a lot** of processing if you execute it on a long-running server.\n\nUsage:\n\n```bash\nphp bin/console mbin:cache:build\n```\n\n## Miscellaneous\n\n### Delete monitoring data\n\n> [!HINT]\n> For information about monitoring see [Optional Features/Monitoring](../03-optional-features/08-monitoring.md).\n\nThis command allows you to delete monitoring data according to the passed parameters.\n\nUsage:\n\n```bash\nphp bin/console mbin:monitoring:delete-data [-a|--all] [--queries] [--twig] [--requests] [--before [BEFORE]]\n```\n\nOptions:\n- `-a`|`--all`: delete all contexts, including all linked data (queries, twig renders and curl requests)\n- `--queries`: delete all query data (this is the most space consuming data)\n- `--twig`: delete all twig rendering data (this is the second most space consuming data)\n- `--requests`: delete all curl request data\n- `--before [BEFORE]]`: if you want to limit the data deleted by their creation date, including via the `-a|--all` option. You can pass something like _\"now - 1 day\"_\n\nAs an example you could delete all query data by running\n`php bin/console mbin:monitoring:delete-data --queries --before \"now - 8 hours\"`.\nThis way you could still view the average request times without the query data for every request older than 8 hours\nand the newer requests would not be affected at all. This way you can limit the space consumed by query data.\nYou can also mix and match the `--queries`, `--twig` and `--requests` options.\n\n### Search for duplicate magazines or users and remove them\n\nThis command provides a guided tour to search for, and remove duplicate magazines or users.\nThis has been added to make the creation of unique indexes easier if the migration failed.\n\nUsage:\n\n```bash\nphp bin/console mbin:check:duplicates-users-magazines\n```\n\n### Users-Remove-Marked-For-Deletion\n\n> [!NOTE]\n> The same job is executed on a daily schedule automatically. There should be no need to execute this command.\n\nRemoves all accounts that are marked for deletion today or in the past.\n\nUsage:\n\n```bash\nphp bin/console mbin:users:remove-marked-for-deletion\n```\n\n### Messengers-Failed-Remove-All\n\n> [!NOTE]\n> The same job is executed on a daily schedule automatically. There should be no need to execute this command.\n \nThis command removes all failed messages from the failed queue (database).\n\nUsage:\n\n```bash\nphp bin/console mbin:messenger:failed:remove_all\n```\n\n### Messengers-Dead-Remove-All\n\n> [!NOTE]\n> The same job is executed on a daily schedule automatically. There should be no need to execute this command.\n\nThis command removes all dead messages from the dead queue (database).\n\nUsage:\n\n```bash\nphp bin/console mbin:messenger:dead:remove_all\n```\n\n### Post-Remove-Duplicates\n\nThis command removes post and user duplicates by their ActivityPub ID.\n\n> [!NOTE]\n> We've had a unique index on the ActivityPub ID for a while, hence this command should not do anything\n\nUsage:\n\n```bash\nphp bin/console mbin:user:create [-r|--remove] [--admin] [--moderator] <username> <email> <password>\n```\n\n### Update-Local-Domain\n\nThis command will remove all remote posts from belonging to the local domain. This command is only relevant for instances\ncreated before v1.7.4 as the local domain was the fallback if no domain could be extracted from a post.\n\nUsage:\n```bash\nphp bin/console mbin:update:local-domain\n```\n"
  },
  {
    "path": "docs/02-admin/04-running-mbin/README.md",
    "content": "# Running Mbin\n\nWhen you're the server admin, you mind find the following pages useful:\n\n- [First setup](01-first_setup.md) - Helps creating your admin first account on Mbin and more...\n- [Back-up](02-backup.md) - How to back-up your databases?\n- [Upgrades](03-upgrades.md) - Where to think about when performing Mbin upgrades.\n- [Messenger jobs](04-messenger.md) - When running Symfony Messenger & RabbitMQ\n- [CLI commands](05-cli.md) - Available Mbin console commands\n"
  },
  {
    "path": "docs/02-admin/05-troubleshooting/01-bare_metal.md",
    "content": "# Troubleshooting Bare Metal\n\n## Logs\n\nRabbitMQ:\n\n- `sudo tail -f /var/log/rabbitmq/rabbit@*.log`\n\nSupervisor:\n\n- `sudo tail -f /var/log/supervisor/supervisord.log`\n\nSupervisor jobs (Mercure and Messenger):\n\n- `sudo tail -f /var/log/supervisor/mercure*.log`\n- `sudo tail -f /var/log/supervisor/messenger*.log`\n\nThe separate Mercure log:\n\n- `sudo tail -f /var/www/mbin/var/log/mercure.log`\n\nApplication Logs (prod or dev logs):\n\n- `tail -f /var/www/mbin/var/log/prod-{YYYY-MM-DD}.log`\n\nOr:\n\n- `tail -f /var/www/mbin/var/log/dev-{YYYY-MM-DD}.log`\n\nWeb-server (Nginx):\n\n- Normal access log: `sudo tail -f /var/log/nginx/mbin_access.log`\n- Inbox access log: `sudo tail -f /var/log/nginx/mbin_inbox.log`\n- Error log: `sudo tail -f /var/log/nginx/mbin_error.log`\n\n### A useful command to view the logs\n\nIf you have `tail`, `jq` and `awk` installed you can run this command to turn the json log into a color coded\nhuman-readable log:\n\n```shell\ntail -f /var/www/mbin/var/log/prod-2025-08-11.log  | jq -r '\"\\(.datetime) \\(.level_name) \\(.channel): \\(.message)\"' | awk '\n{\n  level=$2\n  color_reset=\"\\033[0m\"\n  color_info=\"\\033[36m\"      # Cyan\n  color_warning=\"\\033[33m\"   # Yellow\n  color_error=\"\\033[31m\"     # Red\n  color_debug=\"\\033[35m\"     # Magenta\n\n  if (level == \"INFO\") color=color_info\n  else if (level == \"WARNING\") color=color_warning\n  else if (level == \"ERROR\") color=color_error\n  else if (level == \"DEBUG\") color=color_debug\n  else color=color_reset\n\n  print color $0 color_reset\n}'\n```\n\n## Debugging\n\n**Please, check the logs above first.** If you are really stuck, visit to our [Matrix space](https://matrix.to/#/%23mbin:melroy.org), there is a 'General' room and dedicated room for 'Issues/Support'.\n\nTest PostgreSQL connections if using a remote server, same with Redis (or KeyDB is you are using that instead). Ensure no firewall rules blocking are any incoming or out-coming traffic (eg. port on 80 and 443).\n"
  },
  {
    "path": "docs/02-admin/05-troubleshooting/02-docker.md",
    "content": "# Troubleshooting Docker\n\n## Debugging / Logging\n\n1. List the running service containers with `docker compose ps`.\n2. You can see the logs with `docker compose logs -f <service>` (use `-f` to follow the output).\n3. For `php` and `messenger` services, the application log is also available at `storage/php_logs/` & `storage/messenger_logs/` on the host.\n"
  },
  {
    "path": "docs/02-admin/05-troubleshooting/README.md",
    "content": "# Troubleshooting\n\nFor troubleshooting, see also the [FAQ page we have created](../FAQ.md)!\n\nAnd to get more (debug) **logging output** see:\n\n- [Bare metal setup troubleshooting page](./01-bare_metal.md)\n\nOr:\n\n- [Docker troubleshooting page](./02-docker.md)\n"
  },
  {
    "path": "docs/02-admin/FAQ.md",
    "content": "# FAQ\n\nSee below our Frequently Asked Questions (FAQ). The questions (and corresponding answers) below are in random order.\n\n## Where can I find more info about AP?\n\nThere exists an official [ActivityPub specification](https://www.w3.org/TR/activitypub/), as well as [several AP extensions](https://codeberg.org/fediverse/fep/) on this specification.\n\nThere is also a **very good** [forum post on activitypub.rocks](https://socialhub.activitypub.rocks/t/guide-for-new-activitypub-implementers/479), containing a lot of links and resources to various documentation and information pages.\n\n## How to setup my own Mbin instance?\n\nHave a look at our guides. Both a bare metal/VM setup and a Docker setup is provided.\n\n## I have an issue!\n\nYou can [join our Matrix community](https://matrix.to/#/#mbin:melroy.org) and ask for help, and/or make an [issue ticket](https://github.com/MbinOrg/mbin/issues) in GitHub if that adds value (always check for duplicates).\n\nSee also our [contributing page](../03-contributing/README.md).\n\n## How can I contribute?\n\nNew contributors are always _warmly welcomed_ to join us. The most valuable contributions come from helping with bug fixes and features through Pull Requests.\nAs well as helping out with [translations](https://hosted.weblate.org/engage/mbin/) and documentation.\n\nRead more on our [contributing page](../03-contributing/README.md).\n\nDo _not_ forget to [join our Matrix community](https://matrix.to/#/#mbin:melroy.org).\n\n## What is Matrix?\n\nMatrix is an open-standard, decentralized, and federated communication protocol. You can the [download clients for various platforms here](https://matrix.org/ecosystem/clients/).\n\nAs a part of our software development and discussions, Matrix is our primary platform.\n\n## What is Mercure?\n\nMercure is a _real-time communication protocol_ and server that facilitates server-sent _events_ for web applications. It enables _real-time updates_ by allowing clients to subscribe and receiving updates pushed by the server.\n\nMbin uses Mercure (optionally), on very large instances you might want to consider disabling Mercure whenever it _degrades_ our server performance.\n\n## What is Redis?\n\nRedis is a _persinstent key-value store_ that runs in-memory, which can help for caching purposes or other storage requirements. We **recommend** to setup Redis/Valkey or KeyDB (pick one) when running Mbin.\n\n## What is RabbitMQ?\n\nRabbitMQ is an open-source _message broker_ software that facilitates the exchange of messages between different server instances (in our case ActivityPub messages), using queues to store and manage messages.\n\nWe highly **recommend** to setup RabbitMQ on your Mbin instance, but RabbitMQ is optional. Failed messages are no longer stored in RabbitMQ, but in PostgreSQL instead (table: `public.messenger_messages`).\n\nRead more below about AMQProxy.\n\n## What is AMQProxy?\n\nAMQProxy is a proxy service for AMQP (Advanced Message Queuing Protocol) most used with message brokers like RabbitMQ. It allows for channel pooling and reusing, hence reducing the AMQP protocol (TCP packages) overhead.\n\nAMQProxy is a proxy service for AMQP (Advanced Message Queuing Protocol), most often used with message brokers like RabbitMQ. It allows for channel pooling and reuse, significantly reducing AMQP protocol overhead and TCP connection.  \nBy maintaining persistent connections to the broker, AMQProxy minimizes connection setup latency and resource consumption, improving throughput and scalability for high-load applications. It also simplifies client configuration and load balancing by acting as a single entry point between multiple clients and one (or more) RabbitMQ instances.\n\nTherefor we highly **recommend** to use RabbitMQ together with AMQProxy for more efficient messenger processing and higher performance. Especially when all services are hosted on the same server.\n\n## How do I know Redis is working?\n\nExecute: `sudo redis-cli ping` expect a PONG back. If it requires authentication, add the following flags: `--askpass` to the `redis-cli` command.\n\nEnsure you do not see any connection errors in your `var/log/prod.log` file.\n\nIn the Mbin Admin settings, be sure to also enable Mercure:\n\n![image](https://github.com/MbinOrg/mbin/assets/628926/7a955912-57c1-4d5a-b0bc-4aab6e436cb4)\n\nWhen you visit your own Mbin instance domain, you can validate whether a connection was successfully established between your browser (client) and Mercure (server), by going to the browser developer toolbar and visit the \"Network\" tab.\n\nThe browser should successfully connect to the `https://<yourdomain>/.well-known/mercure` URL (thus without any errors). Since it's streaming data, don't expect any response from Mercure.\n\n## How do I know RabbitMQ is working?\n\nExecute: `sudo rabbitmqctl status`, that should provide details about your RabbitMQ instance. The output should also contain information about which plugins are installed, various usages and on which ports it is listening on (eg. `5672` for AMQP protocol).\n\nEnsure you do not see any connection errors in your `var/log/prod-{YYYY-MM-DD}.log` file.\n\nTalking about plugins, we advise to also enable the `rabbitmq_management` plugin by executing:\n\n```sh\nsudo rabbitmq-plugins enable rabbitmq_management\n```\n\nLet's create a new admin user in RabbitMQ (replace `<user>` and `password` with a username & password you like to use):\n\n```sh\nsudo rabbitmqctl add_user <user> <password>\n```\n\nGive this new user administrator permissions (`-p /` is the virtual host path of RabbitMQ, which is `/` by default):\n\n```sh\n# Again don't forget to change <user> to your username in the lines below\nsudo rabbitmqctl set_user_tags <user> administrator\nsudo rabbitmqctl set_permissions -p / <user> \".*\" \".*\" \".*\"\n```\n\nNow you can open the RabbitMQ management page: (insecure connection!) `http://<server-ip>:15672` with the username and the password provided earlier. [More info can be found here](https://www.rabbitmq.com/management.html#getting-started). See screenshot below of a typical small instance of Mbin running RabbitMQ management interface (\"Queued message\" of 4k or even 10k is normal after recent Mbin changes, see down below for more info):\n\n![Typical load on very small instances](../images/rabbit_small_load_typical.png)\n\n## Messenger Queue is building up even though my messengers are idling\n\nWe recently changed the messenger config to retry failed messages 3 times, instead of sending them straight to the `failed` queue.\nRabbitMQ will now have new queues being added for the different delays (so a message does not get retried 5 times per second):\n\n![Queue overview](../images/rabbit_queue_tab_cut.png)\n\nThe global overview from RabbitMQ shows the ready messages for all queues combined. Messages in the retry queues count as ready messages the whole time they are in there,\nso for a correct ready count you have to go to the queue specific overview.\n\n| Overview                                                | Queue Tab                                         | \"Message\" Queue Overview                                          |\n| ------------------------------------------------------- | ------------------------------------------------- | ----------------------------------------------------------------- |\n| ![Queued messages](../images/rabbit_queue_overview.png) | ![Queue overview](../images/rabbit_queue_tab.png) | ![Message Queue Overview](../images/rabbit_messages_overview.png) |\n\n## RabbitMQ Prometheus exporter\n\nSee [RabbitMQ Docs](https://rabbitmq.com/prometheus.html)\n\nIf you are running the prometheus exporter plugin you do not have queue specific metrics by default.\nThere is another endpoint with the default config that you can scrape, that will return queue metrics for our default virtual host `/`: `/metrics/detailed?vhost=%2F&family=queue_metrics`\n\nExample scrape config:\n\n```yaml\nscrape_configs:\n  - job_name: \"mbin-rabbit_queues\"\n    static_configs:\n      - targets: [\"example.org\"]\n    metrics_path: \"/metrics/detailed\"\n    params:\n      vhost: [\"/\"]\n      family:\n        [\n          \"queue_coarse_metrics\",\n          \"queue_consumer_count\",\n          \"channel_queue_metrics\",\n        ]\n```\n\n## How to clean-up all failed messages?\n\nIf you want to delete all failed messages (`failed` queue) you can execute the following command:\n\n```bash\n./bin/console mbin:messenger:failed:remove_all\n```\n\nAnd if you want to delete the dead messages (`dead` queue) you can execute the following command:\n\n```bash\n./bin/console mbin:messenger:dead:remove_all\n```\n\n_Hint:_ Most messages that are stored in the database are most likely in the `failed` queue, thus running the first command (`mbin:messenger:failed:remove_all`) will most likely delete all messages in the `messenger_messages` table. Regularly running this command will keep your database clean.\n\n## Where can I find my logging?\n\nYou can find the Mbin logging in the `var/log/` directory from the root folder of the Mbin installation. When running production the file is called `prod-{YYYY-MM-DD}.log`, when running development the log file is called `dev-{YYYY-MM-DD}.log`.\n\nSee also [troubleshooting (bare metal)](./05-troubleshooting/01-bare_metal.md).\n\n## Should I run development mode?\n\n**NO!** Try to avoid running development mode when you are hosting our own _public_ instance. Running in development mode can cause sensitive data to be leaked, such as secret keys or passwords (eg. via development console). Development mode will log a lot of messages to disk (incl. stacktraces).\n\nThat said, if you are _experiencing serious issues_ with your instance which you cannot resolve by looking at the log file (`prod-{YYYY-MM-DD}.log`) or server logs, you can try running in development mode to debug the problem or issue you are having. Enabling development mode **during development** is also very useful.\n\n## I changed my .env configuration but the error still appears/new config doesn't seem to be applied?\n\nAfter you edited your `.env` configuration file on a bare metal/VM setup, you always need to execute the `composer dump-env` command (in Docker you just restart the containers).\n\nRunning the `post-upgrade` script will also execute `composer dump-env` for you:\n\n```bash\n./bin/post-upgrade\n```\n\n**Important:** If you want to switch between `prod` to `dev` (or vice versa), you need explicitly execute: `composer dump-env dev` or `composer dump-env prod` respectively.\n\nFollowed by restarting the services that are depending on the (new) configuration:\n\n```bash\n# Clear PHP Opcache by restarting the PHP FPM service\nsudo systemctl restart php8.4-fpm.service\n\n# Restarting the PHP messenger jobs and Mercure service (also reread the latest configuration)\nsudo supervisorctl reread && sudo supervisorctl update && sudo supervisorctl restart all\n```\n\n## How to retrieve missing/update remote user data?\n\nIf you want to update all the remote users on your instance, you can execute the following command (which will also re-download the avatars):\n\n```bash\n./bin/console mbin:ap:actor:update\n```\n\n_Important:_ This might have quite a performance impact (temporarily), if you are running a very large instance. Due to the huge amount of remote users.\n\n## Running `php bin/console mbin:ap:keys:update` does not appear to set keys\n\nIf you're seeing this error in logs:\n\n> getInstancePrivateKey(): Return value must be of type string, null returned\n\nAt time of writing, `getInstancePrivateKey()` [calls out to the Redis cache](https://github.com/MbinOrg/mbin/blob/main/src/Service/ActivityPub/ApHttpClient.php#L348)\nfirst, so any updates to the keys requires a `DEL instance_private_key instance_public_key` (or `FLUSHDB` to be certain, as documented here: [bare metal](04-running-mbin/03-upgrades.md#clear-cache) and [docker](04-running-mbin/03-upgrades.md#clear-cache-1))\n\n## RabbitMQ shows a really high publishing rate\n\nFirst thing you should do to debug the issue is looking at the \"Queues and Streams\" tab to find out what queues have the high publishing rate.\nIf the queue/s in question are `inbox` and `resolve` it is most likely a circulating `ChainActivityMessage`.\nTo verify that assumption:\n\n1. stop all messengers\n   - if you're on bare metal, as root: `supervisorctl stop messenger:*`\n   - if you're on docker: `docker compose down messenger`\n2. look again at the publishing rate. If it has gone down, then it definitely is a circulating message\n\nTo fix the problem:\n\n1. start the messengers if they are not already started\n2. go to the `resolve` queue\n3. open the \"Get Message\" panel\n4. change the `Ack Mode` to `Automatic Ack`\n5. As long as your publishing rate is still high, press the `Get Message` button. It might take a few tries before you got all of them and you might get a \"Queue is empty\" message a few times\n\n### Discarding queued messages\n\nIf you believe you have a queued message that is infinitely looping / stuck, you can discard it by setting the `Get messages` `Ack mode` in RabbitMQ to `Reject requeue false` with a `Messages` setting of `1` and clicking `Get message(s)`.\n\n> [!WARNING]\n> This will permanently discard the payload\n\n![Rabbit discard payload](../images/rabbit_reject_requeue_false.png)\n\n## Performance hints\n\n- [Resolve cache images in background](https://symfony.com/bundles/LiipImagineBundle/current/optimizations/resolve-cache-images-in-background.html#symfony-messenger)\n\n## References\n\n- [https://symfony.com/doc/current/setup.html](https://symfony.com/doc/current/setup.html)\n- [https://symfony.com/doc/current/deployment.html](https://symfony.com/doc/current/deployment.html)\n- [https://symfony.com/doc/current/setup/web_server_configuration.html](https://symfony.com/doc/current/setup/web_server_configuration.html)\n- [https://symfony.com/doc/current/messenger.html#deploying-to-production](https://symfony.com/doc/current/messenger.html#deploying-to-production)\n- [https://codingstories.net/how-to/how-to-install-and-use-mercure/](https://codingstories.net/how-to/how-to-install-and-use-mercure/)\n"
  },
  {
    "path": "docs/02-admin/README.md",
    "content": "# Admin\n\nWelcome to the admin section of the Mbin documentation.\n\n## Installation\n\nYou can install Mbin via:\n\n- [Bare metal](01-installation/01-bare_metal.md)\n- or via [Docker](01-installation/02-docker.md)\n\n## Configuration\n\n- [Mbin configuration files (Symfony)](02-configuration/01-mbin_config_files.md)\n- [Nginx configuration](02-configuration/02-nginx.md)\n- [Let's Encrypt](02-configuration/03-lets_encrypt.md)\n- [PostgreSQL database](02-configuration/04-postgresql.md)\n- [Redis, KeyDB, Valkey cache configuration](02-configuration/05-redis.md)\n\n## Optional features\n\nOptional features like Mercure, SSO, Captcha, Image metadata cleaning or S3 storage:\n\n[More information on the optional features page](./03-optional-features/README.md)\n\n## Running Mbin\n\n- [First setup](04-running-mbin/01-first_setup.md)\n- [Backup](04-running-mbin/02-backup.md)\n- [Upgrades](04-running-mbin/03-upgrades.md)\n- [Symfony messenger](04-running-mbin/04-messenger.md)\n- [Command-line maintenance tool (CLI)](04-running-mbin/05-cli.md)\n\n## Troubleshooting\n\nSee [FAQ](FAQ.md). And how-to get logging information see either:\n\n- [Bare metal troubleshooting](05-troubleshooting/01-bare_metal.md)\n- [Docker troubleshooting](05-troubleshooting/02-docker.md)\n\n## FAQ\n\nSee [Frequently Asked Questions (FAQ) page](./FAQ.md).\n"
  },
  {
    "path": "docs/03-contributing/01-getting_started.md",
    "content": "# Getting started as a developer\n\nThere are several ways to get started. Like using the Docker setup or use the development server, which is explained in detail below.\n\nThe code is mainly written in PHP using the Symfony framework with Twig templating and a bit of JavaScript & CSS and of course HTML.\n\n## Docker as a dev server\n\nTo save yourself much time setting up a development server, you can use our Docker setup instead of a manual configuration:\n\n1. Make sure you are currently in the root of your Mbin directory.\n2. Run the auto setup script with `./docker/setup.sh dev localhost` to configure `.env`, `compose.override.yaml`, and `storage/`.\n\n> [!NOTE]\n> The Docker setup uses ports `80` and `443` by default. If you'd prefer to use a different port for development on your device, then in `.env` you'll need to update `KBIN_DOMAIN` and `KBIN_STORAGE_URL` to include the port number (e.g., `localhost:8443`). Additionally, add the following to `compose.dev.yaml` under the `php` service:\n>\n> ```yaml\n> ports: !override\n>   - 8443:443\n> ```\n\n3. Run `docker compose up` to build and start the Docker containers. Please note that the first time you start the containers, they will need an extra minute or so to install dependencies before becoming available.\n4. From here, you should be able to access your server at [https://localhost/](https://localhost/). Any edits to the source files will automatically rebuild your server.\n5. Optionally, follow the [Mbin first setup](../02-admin/04-running-mbin/01-first_setup.md) instructions.\n6. If you'd like to enable federation capabilities, then in `compose.dev.yaml`, change the two lines from `replicas: 0` to `replicas: 1` (under the `messenger` and `rabbitmq` services). Make sure you've ran the containers at least once before doing this, to give the `php` service a chance to install dependencies without overlap.\n\n> [!NOTE]\n> If you'd prefer to manually configure your Docker environment (instead of using the setup script) then follow the manual environment setup steps in the [Docker install guide](../02-admin/01-installation/02-docker.md), but while you're creating `compose.override.yaml`, use the following:\n>\n> ```yaml\n> include:\n>   - compose.dev.yaml\n> ```\n\n> [!TIP]\n> Once you are done with your development server and would like to shutdown the Docker containers, hit `Ctrl+C` in your terminal.\n\nIf you'd prefer a development setup without using Docker, then continue on the section [Bare metal installation](#bare-metal-installation).\n\n## Dev Container\n\nThis project also provides a configuration to create a Dev Container which can be launched from IDEs which support it.\nTo use it, follow these steps:\n\n1. If you are using Podman, then uncomment the lines below `Uncomment if you are using Podman` in `./.devcontainer/devcontainer.json`\n2. Adjust values in: `./.devcontainer/.env.devcontainer`:\n   1. `SERVER_NAME`: change `mbin.domain.tld` to `localhost`\n   2. `KBIN_DOMAIN`: change to `localhost`\n   3. `KBIN_STORAGE_URL`: change `https://mbin.domain.tld/media` to `http://localhost:8080/media` (or whatever port you set in `devcontainer.json`)\n3. Start and open the Dev Container\n4. Run `chmod o+rwx public/`\n5. Check if all needed services are running: `sudo service --status-all`; services which should have a `+`:\n   - apache2\n   - apache-htcacheclean\n   - postgresql\n   - rabbitmq-server\n   - redis-server\n6. If some service are not running, try:\n   - Start it with `sudo service <service name> start`\n   - If postgres fails: `sudo chmod -R postgres:postgres /var/lib/postgresql/`\n7. Run `bin/console doctrine:migrations:migrate`\n8. Run `npm install && npm run dev`\n9. Open `http://localhost:8080` in a browser; you should see some status page or the Mbin startpage\n10. Run `sudo find public/ -type d -exec chgrp www-data '{}' \\;` and `sudo find public/ -type d -exec chmod g+rwx '{}' \\;`\n11. You can now follow the [initial configuration guide](../02-admin/04-running-mbin/01-first_setup.md)\n\n> [!TIP]\n> If you get at some point an error with `Expected to find class <class name and path> while importing services from resource \"../src/\", but it was not found!`\n> you can fix this by running `composer dump-autoload`.\n\n### OAuth keys\n\nIf you want to use OAuth for the API, do the following **before** creating the Dev Container:\n1. Generate the key material by following *OAuth2 keys for API credential grants* in [Bare Metal/VM Installation](../02-admin/01-installation/01-bare_metal.md)\n2. Configure the described env variables in: `./.devcontainer/.env.devcontainer` (they are already declared at the end of the file)\n3. After the Dev Container is created and opened:\n   1. Run `chgrp www-data ./config/oauth2/private.pem`\n   2. Run `chmod g+r ./config/oauth2/private.pem`\n\n### Running tests\n\nTo run test inside the Dev Container, some preparation is needed.\nThese steps have to be repeated after every recreation of the Container:\n\n1. Run: `sudo pg_createcluster 18 tests --port=5433 --start`\n2. Run: `sudo su postgres -c 'psql -p 5433 -U postgres -d postgres'`\n3. Inside the SQL shell, run: `CREATE USER mbin WITH PASSWORD 'ChangeThisPostgresPass' SUPERUSER;`\n\nNow the testsuite can be launched with:\n`SYMFONY_DEPRECATIONS_HELPER=disabled ./bin/phpunit tests/Unit`\nor: `SYMFONY_DEPRECATIONS_HELPER=disabled ./bin/phpunit tests/Functional/<path to tests to run>`.\\\nFor more information, read the [Testing](#testing) section on this page.\n\n## Bare metal installation\n\n### Initial setup\n\nRequirements:\n\n- PHP v8.3 or higher\n- NodeJS v20 or higher\n- Valkey / KeyDB / Redis (pick one)\n- PostgreSQL\n- _Optionally:_ Mercure\n- _Optionally:_ Symfony CLI\n\nFirst install some generic packages you will need:\n\n```sh\nsudo apt update\nsudo apt install lsb-release ca-certificates curl wget unzip gnupg apt-transport-https software-properties-common git valkey-server\n```\n\n### Clone the code\n\nWith an account on [GitHub](https://github.com) you will be able to [fork this repository](https://github.com/MbinOrg/mbin).\n\nOnce you forked the GitHub repository you can clone it locally (our advice is to use SSH to clone repositories from GitHub):\n\n```sh\ngit clone git-repository-url\n```\n\nFor example:\n\n```sh\ngit clone git@github.com:MbinOrg/mbin.git\n```\n\n> [!TIP]\n> You do not need to fork the GitHub repository if you are member of our Mbin Organisation on GitHub. Just create a new branch right away.\n\n### Prepare PHP\n\n1. Install PHP + additional PHP extensions:\n\n```sh\nsudo apt install php8.4 php8.4-common php8.4-fpm php8.4-cli php8.4-amqp php8.4-bcmath php8.4-pgsql php8.4-gd php8.4-curl php8.4-xml php8.4-redis php8.4-mbstring php8.4-zip php8.4-bz2 php8.4-intl php8.4-bcmath -y\n```\n\n2. Fine-tune PHP settings:\n\n- Increase execution time in PHP config file: `/etc/php/8.4/fpm/php.ini`:\n\n```ini\nmax_execution_time = 120\n```\n\n- _Optional:_ Increase/set max_nesting_level in `/etc/php/8.4/fpm/conf.d/20-xdebug.ini` (in case you have the `xdebug` extension installed):\n\n```ini\nxdebug.max_nesting_level=512\n```\n\n3. Restart the PHP-FPM service:\n\n```sh\nsudo systemctl restart php8.4-fpm.service\n```\n\n### Prepare PostgreSQL DB\n\n1. Install PostgreSQL:\n\n```sh\nsudo apt-get install postgresql postgresql-contrib\n```\n\n2. Connect to PostgreSQL using the postgres user:\n\n```sh\nsudo -u postgres psql\n```\n\n3. Create a new `mbin` database user with database:\n\n```sql\nsudo -u postgres createuser --createdb --createrole --pwprompt mbin\n```\n\n4. If you are using `127.0.0.1` to connect to the PostgreSQL server, edit the following file: `/etc/postgresql/<VERSION>/main/pg_hba.conf` and add:\n\n```conf\nlocal   mbin            mbin                                    md5\n```\n\n5. Finally, restart the PostgreSQL server:\n\n```\nsudo systemctl restart postgresql\n```\n\n### Prepare dotenv file\n\n1. Change to the `mbin` git repository directory (if you weren't there already).\n2. Copy the dot env file: `cp .env.example .env`. And let's configure the `.env` file to your needs. Pay attention to the following changes:\n\n```ini\n# Set domain to 127.0.0.1:8000\nSERVER_NAME=127.0.0.1:8000\nKBIN_DOMAIN=127.0.0.1:8000\nKBIN_STORAGE_URL=http://127.0.0.1:8000/media\n\n# Valkey/Redis (without password)\nREDIS_DNS=redis://127.0.0.1:6379\n\n# Set App configs\nAPP_ENV=dev\nAPP_SECRET=427f5e2940e5b2472c1b44b2d06e0525\n\n# Configure PostgreSQL\nPOSTGRES_DB=mbin\nPOSTGRES_USER=mbin\n# Change your PostgreSQL password for Mbin user\nPOSTGRES_PASSWORD=<password>\n\n# Set messenger to Doctrine (= PostgresQL DB)\nMESSENGER_TRANSPORT_DSN=doctrine://default\n```\n\n### Change yaml configuration\n\nIn case you are using Doctrine as the messenger transport (see `MESSENGER_TRANSPORT_DSN` above), then you will also need to comment-out all the `options:` sections in the `config/packages/messenger.yaml` file. So the whole section, for example:\n\n```yaml\n    # options:\n    #     queues:\n    #         receive:\n    #             arguments:\n    #                 x-queue-version: 2\n    #                 x-queue-type: 'classic'\n    #     exchange:\n    #         name: receive\n```\n\nThis is because those options are only meant for AMQP transport (like with RabbitMQ), but these options can **not** be used with Doctrine transport.\n\n### Install Symfony CLI tool\n\n1. Install Symfony CLI: `wget https://get.symfony.com/cli/installer -O - | bash`\n2. Check the requirements: `symfony check:requirements`\n\n### Fill Database\n\n1. Assuming you are still in the `mbin` directory.\n2. Create the database: `php bin/console doctrine:database:create`\n3. Create tables and database structure: `php bin/console doctrine:migrations:migrate`\n\n### Fixtures\n\n> [!TIP]\n> This fixtures section is optional. Feel free to skip this section.\n\nYou might want to load random data to database instead of manually adding magazines, users, posts, comments etc.\nTo do so, execute:\n\n```sh\nphp bin/console doctrine:fixtures:load --append --no-debug\n```\n\n---\n\nIf you have messenger jobs configured, be sure to stop them:\n\n- Docker: `docker compose stop messenger`\n- Bare Metal: `supervisorctl stop messenger:*`\n\nIf you are using the Docker setup and want to load the fixture, execute:\n\n```sh\ndocker compose exec php bin/console doctrine:fixtures:load --append --no-debug\n```\n\nPlease note, that the command may take some time and data will not be visible during the process, but only after the finish.\n\n- Omit `--append` flag to override data currently stored in the database\n- Customize inserted data by editing files inside `src/DataFixtures` directory\n\n### Starting the development server\n\nPrepare the server:\n\n1. Build frontend assets: `npm install && npm run dev`\n2. Install dependencies: `composer install`\n3. Dump `.env` into `.env.local.php` via: `composer dump-env dev`\n4. _Optionally:_ Increase verbosity log level in: `config/packages/monolog.yaml` in the `when@dev` section: `level: debug` (instead of `level: info`),\n5. **Important:** clear Symfony cache: `APP_ENV=dev APP_DEBUG=1 php bin/console cache:clear -n`\n6. _Optionally:_ clear the Composer cache: `composer clear-cache`\n\nStart the development server:\n\n6. Start Mbin: `symfony server:start`\n7. Go to: [http://127.0.0.1:8000](http://127.0.0.1:8000/)\n\n> [!TIP]\n> Once you are done with your development server and would like to shut it down, hit `Ctrl+C` in your terminal.\n\nYou might want to also follow the [Mbin first setup](../02-admin/04-running-mbin/01-first_setup.md). This explains how to create a user.\n\nThis will give you a minimal working frontend with PostgreSQL setup. Keep in mind: this will _not_ start federating.\n\n_Optionally:_ If you want to start federating, you will also need to messenger jobs + RabbitMQ and host your server behind a reverse proxy with valid SSL certificate. Generally speaking, it's **not** required to setup federation for development purposes.\n\nMore info: [Contributing guide](https://github.com/MbinOrg/mbin/blob/main/CONTRIBUTING.md), [Admin guide](../02-admin/README.md) and [Symfony Local Web Server](https://symfony.com/doc/current/setup/symfony_server.html)\n\n## Testing\n\nWhen fixing a bug or implementing a new feature or improvement, we expect that test code will also be included with every delivery of production code. There are three levels of tests that we distinguish between:\n\n- Unit Tests: test a specific unit (SUT), mock external functions/classes/database calls, etc. Unit-tests are fast, isolated and repeatable\n- Integration Tests: test larger part of the code, combining multiple units together (classes, services or alike).\n- Application Tests: test high-level functionality, APIs or web calls.\n\nFor more info read: [Symfony Testing guide](https://symfony.com/doc/current/testing.html).\n\n### Prepare testing\n\n1. First increase execution time in your PHP config file: `/etc/php/8.4/fpm/php.ini`:\n\n```ini\nmax_execution_time = 120\n```\n\n2. _Optional:_ Increase/set max_nesting_level in `/etc/php/8.4/fpm/conf.d/20-xdebug.ini` (in case you have the `xdebug` extension installed):\n\n```ini\nxdebug.max_nesting_level=512\n```\n\n3. Restart the PHP-FPM service: `sudo systemctl restart php8.4-fpm.service`\n4. Copy the dot env file (if you haven't already): `cp .env.example .env`\n5. Install composer packages: `composer install --no-scripts`\n\n### Running unit tests\n\nRunning the unit tests can be done by executing:\n\n```sh\nSYMFONY_DEPRECATIONS_HELPER=disabled ./bin/phpunit tests/Unit\n```\n\n### Running integration tests\n\nOur integration tests depend on a database and a caching server (Valkey / KeyDB / Redis).\nThe database and cache are cleared / dumped every test run.\n\nTo start the services in the background:\n\n```sh\ndocker compose -f docker/tests/compose.yaml up -d\n```\n\nThen run all the integration test(s):\n\n```sh\nSYMFONY_DEPRECATIONS_HELPER=disabled ./bin/phpunit tests/Functional\n```\n\nOr maybe better, run the non-thread-safe group using `phpunit`:\n\n```sh\nSYMFONY_DEPRECATIONS_HELPER=disabled ./bin/phpunit tests/Functional --group NonThreadSafe\n```\n\nAnd run the remaining thread-safe integration tests using `paratest`, which runs the test in parallel:\n\n```sh\nSYMFONY_DEPRECATIONS_HELPER=disabled php vendor/bin/paratest tests/Functional --exclude-group NonThreadSafe\n```\n\n## Linting\n\nFor linting see the [linting documentation page](02-linting.md).\n"
  },
  {
    "path": "docs/03-contributing/02-linting.md",
    "content": "# Linting\n\n## PHP Code\n\nWe use [php-cs-fixer](https://cs.symfony.com/) to automatically fix code style issues according to [Symfony coding standard](https://symfony.com/doc/current/contributing/code/standards.html).\n\nInstall tooling via:\n\n```sh\ncomposer -d tools install\n```\n\nTry to automatically fix linting errors:\n\n```sh\n./tools/vendor/bin/php-cs-fixer fix\n```\n\n_Note:_ First time you run the linter, it might take a while. After a hot cache, linting will be much faster.\n\n## JavaScript Code\n\nFor JavaScript inside the `assets/` directory, we use ESLint for linting and potentially fix the code style issues.\n\nInstall Eslint and its required packages by:\n\n```sh\nnpm install\n```\n\nRun the following command to perform linting:\n\n```sh\nnpm run lint\n```\n\nRun the following command to attempt auto-fix linting issues:\n\n```sh\nnpm run lint-fix\n```\n\nNote that unlike PHP-CS-Fixer, _not all linting problems could be automatically fixed_, some of these would requires manually fixing them as appropriate, be sure to do those.\n"
  },
  {
    "path": "docs/03-contributing/03-project-overview.md",
    "content": "# Project Overview\n\nMbin is a big project with a lot of code. We do not use an existing library to handle ActivityPub requests, \ntherefore we have a lot of code to handle that. \nWhile that is more error-prone it is also a lot more flexible.\n\n## Directory Structure\n\n- `.devcontainer` - Docker containers that are configured to provide a fully featured development environment.\n- `.github` - our GitHub specific CI workflows are stored here.\n- `assets` - the place for all our frontend code, that includes JavaScript and SCSS.\n- `bin` - only the Symfony console, PHPUnit and our `post-upgrade` script are stores here.\n- `ci` - Storing our CI/CD helper code / Dockerfiles.\n- `config` - the config files for Symfony are stored here.\n   - `config/mbin_routes` the HTTP routes to our controllers are defined here.\n   - `config/packages` all Symfony add-ons are configured here.\n-  `docker` - some docker configs that are partly outdated. The one still in use is in `docker/tests`.\n- `docs` - you guessed it our documentation is stored here.\n- `LICENSES` - third party licenses.\n- `migrations` - all SQL migrations are stored here.\n- `public` - this is the publicly accessible directory through the webserver. There should mostly be compiled files in here.\n- `src` - that is where our PHP files are stored and the directory you will modify the most files.\n    - `src/ActivityPub` - some things that are ActivityPub related and do not fit in another directory.\n    - `src/ArgumentValueResolver`\n    - `src/Command` - Every command that is executable via the symfone cli (`php bin/console`).\n    - `src/Controller` - Every Controller, meaning every HTTP endpoint, belongs in the directory.\n    - `src/DataFixtures` - The classes responsible for generating test data.\n    - `src/DoctrineExtensions` - Some doctrine extensions, mainly to handle enums.\n    - `src/Document`\n    - `src/DTO` - **D**ata **T**ransport **O**bjects are exactly that, a form for the data that is transferable (e.g.: via API) .\n    - `src/Entity` - The classes to represent the data stored in the database, a.k.a. database entities.\n    - `src/Enums` - self-explanatory.\n    - `src/Event` - self-explanatory.\n    - `src/EventListener` - classes that listens on framework events.\n    - `src/EventSubscriber` - classes subscribing to our own events.\n    - `src/Exception` - self-explanatory.\n    - `src/Factory` - classes that transform objects. Mostly entities to DTOs and ActivityPub objects to JSON.\n    - `src/Feed` - The home for our RSS feed provider\n    - `src/Form` - All form types belong to here, also other things related to forms.\n    - `src/Markdown` - Everything markdown related: converter, extensions, events, etc.\n    - `src/Message` - All classes sent to RabbitMQ (messaging queue system), they should always only contain primitives and never objects. \n    - `src/MessageHandler` - Our background workers fetching messages from RabbitMQ, getting the `Message` objects, are stored here\n    - `src/PageView` - page views are a collection of criteria to query for a specific view\n    - `src/Pagination` - some extensions to the `PagerFanta`\n    - `src/Payloads` - some objects passed via request body to controllers\n    - `src/Provider` - some OAuth providers are stored here\n    - `src/Repository` - the classes used to fetch data from the database\n    - `src/Scheduler` - the schedule provider (regularly running tasks)\n    - `src/Schema` - some OpenAPI schemas are stored here\n    - `src/Security` - everything related to authentication and authorization should be stored here, that includes OAuth providers\n    - `src/Service` - every service should be stored here. A service should be something that manipulates data or is checking for visibility, etc.\n    - `src/Twig` - the PHP code related to Twig is stored here. That includes runtime extensions and component classes. \n    - `src/Utils` - some general utils\n    - `src/Validator`\n- `templates` - the Twig folder. All Twig files are stored in here.\n- `tests` - everything relating to testing is stored here.\n- `translations` - self-explanatory.\n\n## Writing Code\n\nOur linter adds `declare(strict_types=1);` to every class, so the parameter typing has to be correct.\nEvery class in the `src` directory can be injected in the constructor of a class. Be aware of cyclic dependencies.\n\nWe will go over some common things one might want to add and further down we'll explain some concepts that Mbin makes use of.\n\n### Changing the database schema\n\nTo change the database schema one does not really need to do much. Change the corresponding `Entity`.\nFor some info on doctrine, check out [their documentation](https://www.doctrine-project.org/projects/doctrine-orm/en/3.5/reference/basic-mapping.html).\n\nAfter you have changed the entity, open a terminal and go to the mbin repo and run:\n\n```bash\nphp bin:console doctrine:migrations:diff\n```\n\nThis will create a class in the `migrations` directory. It might contain things really not relevant to you, \nso you have to manually check the changes created. \n\n> [!NOTE]\n> The `up` and `down` methods both have to be implemented.\n\nAfter modifying the migration to your needs, you can either have them be executed by running the `bin/post_upgrade` script\nor restarting the docker containers or manually execute them by running:\n\n```bash\nphp bin/console doctrine:migrations:execute [YOUR MIGRATION HERE]\n```\n\nAfter that your changes should have been applied to the database.\n\n> [!NOTE]\n> If your handling enums it is a bit more complicated as doctrine needs to know how to decode it. \n\n### Adding a controller\n\nAdding a controller is very simple. You just need to add a class to the `src/Controller/` directory \n(and the subdirectory that can be applied) and then extend `AbstractController`. \n\nIf your controller is a only-one-endpoint-controller then you can override the `__invoke` methode, \nbut you can also just create a normal methode, that is up to you.\n\nAfter you've created the controller you have to configure a route from which this controller can be accessed.\nFor that you have to go into the `config/mbin_routes` directory and pick a `yaml` file which fits your controller\n(or create a new one if none of them fit).\n\nThen you just add something like this:\n\n```yaml\nyour_route_name:\n    controller: App\\Controller\\YourControllerName::yourMethodeName\n    path: /path/to/your/controller\n    methods: [GET]\n```\n\n> [!TIP]\n> You can look at other examples in there or look at [Symfony's documentation](https://symfony.com/doc/current/routing.html#creating-routes-in-yaml-xml-or-php-files).\n\n> [!NOTE]\n> We do not use the attribute style for defining routes.\n\nYour controller needs to return a response. The most common way to do that is to return a rendered Twig template:\n\n```php\nreturn $this->render('some_template.html.twig')\n```\n\n> [!TIP]\n> You can also pass parameters/variables to the Twig template so it has access to it.\n\nYou also have to think about permissions a user needs to access an endpoint. \nOn \"normal\" controllers we do that by added an `IsGranted` attribute like this:\n\n```php\n#[IsGranted('ROLE_USER')]\npublic function someControllerMethod(): Response\n```\n\nThe options there are (OAuth has a lot more of them):\n\n1. `ROLE_USER`: a logged-in user, anonymouse access is not allowed\n2. `ROLE_MODERATOR`: a global moderator\n3. `ROLE_ADMIN`: an instance admin\n\n> [!NOTE]\n> There are also so called `Voters` which can determine whether a user has access to specific content, \n> which we mostly use in the API at the moment (the syntax is `#[IsGranted('expression', 'subject')]`).  \n> [Symfony documentation](https://symfony.com/doc/current/security/voters.html)\n\n### Adding an API controller\n\nThis is much the same as the \"normal\" controller, except that you extend `BaseApi` (or another class derived from that) \ninstead of `AbstractController`.\n\nAdditionally, you have to return a `JsonResponse` instead of rendering a Twig template\nand declare the correct OpenAPI attributes on your controller methods, so that the OpenAPI definition is generated accordingly. \nTo check for that you can visit `/api/docs` on your local instance and check for your method and how it is documented there.\n\n## Explanation of some concepts\n\nIn this paragraph we'll explain some of our core concepts and nomenclature we use in our code.\n\nSome Mbin terms:\n\n1. `Entry`: an entry is the database representation of a thread. We use the same object for Threads, Links or images.\n2. `Post`: a post is called \"Microblog\" in the UI. The main differentiator from an `Entry` is the missing `title` property.\n3. `Favourite`: we have the `favourite` table which contains all the upvotes of entries and all the likes of posts.\n4. `*_votes`: the tables `entry_votes`, `entry_comment_votes`, `post_votes` and `post_comment_votes` contain all the downvotes and **boosts**. \nThis is very confusing and will be changed in the future. The `choice` property can either be `1`, `0` or `-1`. \nIt is at `0` if the user had voted, but decided to undo that. `1` equals a boost, while `-1` equals a downvote. \nThat does of course mean that a user cannot downvote and boost content at the same time. \n\n### Federation\n\nFederation is generally handled by our `MessageHandler`s in the background.\nWhen talking about federation we generally need to differentiate between incoming/inbound/inbox federation and outgoing/outbound/outbox federation. \nBecause of that a lof `MessageHandler`s with the same name exist in an `Inbox` and an `Outbox` directory, doing completely different things.\nThe name of the message handler is usually the type of activity it handles (see sources in [Federation](./04-about-federation.md)) followed by `Handler`\n(e.g.: `AnnounceHandler`, `LikeHandler`, `CreateHandler`, etc.).\n\nOutgoing federation is usually triggered by some event subscriber (e.g.: `UserEditedSubscriber`), which sends a specific `Message`\nto the `MessageBusInterface`, meaning (in our case) to RabbitMQ.\n\nIn the background (handled by another docker container or supervisor) we have some processes retrieving messages from RabbitMQ to process them.\n\nInbox federation is triggered by other instances sending activities to an inbox (`InstanceInboxController`, `SharedInboxController`, \n`UserInboxController` or `MagazineInboxController`), which sends a new `ActivityMessage` to RabbitMQ. \nThis is then handled by the `ActivityHandler`, which then determines whether it is valid, has a correct signature, etc.\nand sends another message to RabbitMQ depending on the type of activity it received.\n\n### The markdown compilation process\n\nSince this process is implemented in a complicated way we are going to explain it here.\nThe classes relevant here are \n\n1. `ConvertMarkdown` - the event used to transfer the data to different event subscribers\n2. `MarkdownConverter` - the class that needs to be called to convert markdown to html\n3. `CacheMarkdownListener` - handling the caching of converted markdown\n4. `ConvertMarkdownListener` - the class actually converting the markdown\n\nThe important thing is the priority of the registered event listeners, which are:\n\n1. At `-64`: `CacheMarkdownListener::preConvertMarkdown`\n2. At `0`: `ConvertMarkdownListener::onConvertMarkdown`\n3. At `64`: `CacheMarkdownListener::postConvertMarkdown`\n\nSo what is happening here is simply: check if we already cached the result of the request, \nif so: return it, if not then compile it and safe the result to the cache.\n\n### The random magazine\n\nEvery Mbin server has to have a magazine with the name `random`. The cause of this is simple: every `Entry` and `Post`\nhas to have a magazine assigned to it. Since microblog posts coming from other platforms such as Mastodon \ndo not necessarily have a magazine associated with them (though they might via mentioning) we have this fallback.\n\nIt is not the right way to do it, since the software should just be able to handle content without a magazine, \nbut we are not there, yet.\n\nThe random magazine has a bit of a special treatment in some places:\n1. Nobody from another server can subscribe to it\n2. The magazine does not announce anything (you could previously subscribe to it, so this was an additional safeguard\nagainst announcing every incoming microblog to other servers)\n3. It cannot be found via webfinger request\n"
  },
  {
    "path": "docs/03-contributing/04-about-federation.md",
    "content": "# About Federation\n\n## Official Documents\n\n- [ActivityPub standard](https://www.w3.org/TR/activitypub/)\n- [ActivityPub vocabulary](https://www.w3.org/TR/activitystreams-vocabulary/)\n- [Activity Streams](https://www.w3.org/TR/activitystreams-core/)\n\n## Unofficial Sources\n\n- [A highly opinionated guide to learning about ActivityPub](https://tinysubversions.com/notes/reading-activitypub/)\n- [ActivityPub as it has been understood](https://flak.tedunangst.com/post/ActivityPub-as-it-has-been-understood)\n- [Schema Generator 3: A Step Towards Redecentralizing the Web!](https://dunglas.fr/2021/01/schema-generator-3-a-step-towards-redecentralizing-the-web/)\n- [API Platform ActivityPub](https://github.com/api-platform/activity-pub)\n"
  },
  {
    "path": "docs/03-contributing/README.md",
    "content": "# Contributing\n\nThanks for considering contributing to Mbin! We appreciate your interest in helping us improve the project.\n\n## Code\n\nMbin uses a number of frameworks for different purposes:\n\n1. Symfony - the PHP framework that runs it all. This is our backend framework, which is handling all requests and connecting all the other frameworks together.\n2. Doctrine - our ORM to make it easier to make calls to the database.\n3. Stimulus - the frontend framework that ties in nicely to Twig and Symfony. It is very vanilla JavaScripty, but allows for easily reusable component code.\n4. Twig - a simple templating language to write reusable components that are rendered through PHP, so on the server-side.\n\nFollow the [getting started instructions](01-getting_started.md) to setup a development server.\n\n## Coding Style\n\nPlease, follow the [linting guide](02-linting.md).\n\n## Way of Working\n\nComply with **our version** of [Collective Code Construction Contract (C4)](03-C4.md) specification. Read this document to understand how we work together and how the development process works at Mbin.\n\n## Translations\n\nTranslations are done in [Weblate](https://hosted.weblate.org/projects/mbin/).\n\n## Documentation\n\nDocumentation is stored at in the [`docs` folder](https://github.com/MbinOrg/mbin/tree/main/docs) within git. Create a [new pull request](https://github.com/MbinOrg/mbin/pulls) with changes to the documentation files.\n\n## Community\n\nWe have a very active [Matrix community](https://matrix.to/#/#mbin:melroy.org). Feel free to join our community, ask questions, share your ideas or help others!\n\n## Reporting Issues\n\nIf you observe an error or any other issue, [create an new issue](https://github.com/MbinOrg/mbin/issues) in GitHub. And select the correct issue template.\n\n## Reporting Security Vulnerability\n\nContact Melroy (`@melroy:melroy.org`) or any other community member you trust via Matrix chat, using an encrypted room.\n"
  },
  {
    "path": "docs/04-app_developers/README.md",
    "content": "# App Developers\n\nIf you wish to develop an app that uses the Mbin API end-points, that is possible by using the OAuth2.\n\n## API Endpoints\n\nFor all the API endpoints go to the [API documentation page](https://docs.joinmbin.org/api/).\n\nOr use the Swagger documentation on an existing Mbin instance: `https://mbin_site.com/api/docs`. Assuming you setup the server and the API correctly.\n\n## OAuth2 Guide\n\n### Available Grants\n\n1. `client_credentials`\n   - [documentation here](https://www.oauth.com/oauth2-servers/access-tokens/client-credentials/)\n   - Best used for bots and clients that only ever need to authenticate as a single user, from a trusted device.\n   - Note that bots authenticating with this grant type will be distinguished as bots and will not be allowed to vote on content.\n2. `authorization_code`\n   - [documentation here](https://www.oauth.com/oauth2-servers/access-tokens/authorization-code-request/)\n   - public clients must use [PKCE](https://www.oauth.com/oauth2-servers/pkce/) to authenticate.\n   - A public client is any client that will be installed on a device that is not controlled by the client's creator\n     - Native apps\n     - Single page web apps\n     - Or similar\n3. `refresh_token`\n   - [documentation here](https://www.oauth.com/oauth2-servers/making-authenticated-requests/refreshing-an-access-token/)\n   - Refresh tokens are used with the `authorization_code` grant type to reduce the number of times the user must log in.\n\n### Obtaining OAuth2 credentials from a new server\n\n> [!NOTE]\n> Some of these structures contain comments that need to be removed before making the API calls. Copy/paste with care.\n\n1. Create a private OAuth2 client (for use in secure environments that you control), the post request body should be JSON.\n\n```\nPOST /api/client\n\n{\n  \"name\": \"My OAuth2 Authorization Code Client\",\n  \"contactEmail\": \"contact@some.dev\",\n  \"description\": \"A client that I will be using to authenticate to /mbin's API\",\n  \"public\": false,\n  \"redirectUris\": [\n    \"https://localhost:3000/redirect\",\n    \"myapp://redirect\"\n  ],\n  \"grants\": [\n    \"authorization_code\",\n    \"refresh_token\"\n  ],\n  # All the scopes the client will be allowed to request\n  # See following section for a list of available fine grained scopes.\n  \"scopes\": [\n    \"read\"\n  ]\n}\n```\n\n2. Save the identifier and secret returned by this API call - this will be the only time you can access the secret for a private client.\n\n```json\n{\n    \"identifier\": \"someRandomString\",\n    \"secret\": \"anEvenLongerRandomStringThatYouShouldKeepSafe\",\n    ... # more info about the client that just confirms what you've created\n}\n```\n\n3. Use the OAuth2 client id (`identifier`) and `secret` you just created to obtain credentials for a user (This is a standard authorization_code OAuth2 flow, which is supported by many libraries for your preferred language)\n\n   1. Begin authorization_code OAuth2 flow, by providing the `/authorize` endpint with the following query parameters:\n\n   ```\n   GET /authorize?response_type=code&client_id=(the client id generated at client creation)&redirect_uri=(One of the URIs added during client creation)&scope=(space-delimited list of scopes)&state=(random string for CSRF protection)\n   ```\n\n   2. The user will be directed to log in to their account and grant their consent for the scopes you have requested.\n   3. When the user grants their consent, their browser will be redirected to the given redirect_uri with a `code` query parameter, as long as it matches one of the URIs provided when the client was created.\n   4. After obtaining the code, obtain an authorization token with a `multipart/form-data` POST request towards the `/token` endpoint:\n\n   ```\n   POST /token\n\n   grant_type=authorization_code\n   client_id=(the client id generated at client creation)\n   client_secret=(the client secret generated at client creation)\n   code=(OAuth2 code received from redirect)\n   redirect_uri=(One of the URIs added during client creation)\n   ```\n\n   5. The `/token` endpoint will respond with the access token, refresh token and information about it:\n\n   ```json\n   {\n     \"token_type\": \"Bearer\",\n     \"expires_in\": 3600, // seconds\n     \"access_token\": \"aLargeEncodedTokenToBeUsedInTheAuthorizationHeader\",\n     \"refresh_token\": \"aLargeEncodedTokenToBeUsedInTheRefreshTokenFlow\"\n   }\n   ```\n\n  6. Once you have obtained an access token, you can use it to make authenticated requests to the API end-points that need authentication. This is done by adding the `Authorization` header to the request with the value: `Bearer <access_token>`.\n\n### Available Scopes\n\n#### Scope tree\n\n1. `read` - Allows retrieval of threads from the user's subscribed magazines/domains and viewing the user's favorited entries.\n2. `write` - Provides all of the following nested scopes\n   - `entry:create`\n   - `entry:edit`\n   - `entry_comment:create`\n   - `entry_comment:edit`\n   - `post:create`\n   - `post:edit`\n   - `post_comment:create`\n   - `post_comment:edit`\n3. `delete` - Provides all of the following nested scopes, for deleting the current user's content\n   - `entry:delete`\n   - `entry_comment:delete`\n   - `post:delete`\n   - `post_comment:delete`\n4. `subscribe` - Provides the following nested scopes\n   - `domain:subscribe`\n     - Allows viewing and editing domain subscriptions\n   - `magazine:subscribe`\n     - Allows viewing and editing magazine subscriptions\n   - `user:follow`\n     - Allows viewing and editing user follows\n5. `block` - Provides the following nested scopes\n   - `domain:block`\n     - Allows viewing and editing domain blocks\n   - `magazine:block`\n     - Allows viewing and editing magazine blocks\n   - `user:block`\n     - Allows viewing and editing user blocks\n6. `vote` - Provides the following nested scopes, for up/down voting and boosting content\n   - `entry:vote`\n   - `entry_comment:vote`\n   - `post:vote`\n   - `post_comment:vote`\n7. `report` - Provides the following nested scopes\n   - `entry:report`\n   - `entry_comment:report`\n   - `post:report`\n   - `post_comment:report`\n8. `domain` - Provides all domain scopes\n   - `domain:subscribe`\n   - `domain:block`\n9. `entry` - Provides all entry scopes\n   - `entry:create`\n   - `entry:edit`\n   - `entry:delete`\n   - `entry:vote`\n   - `entry:report`\n10. `entry_comment` - Provides all entry comment scopes\n    - `entry_comment:create`\n    - `entry_comment:edit`\n    - `entry_comment:delete`\n    - `entry_comment:vote`\n    - `entry_comment:report`\n11. `magazine` - Provides all magazine user level scopes\n    - `magazine:subscribe`\n    - `magazine:block`\n12. `post` - Provides all post scopes\n    - `post:create`\n    - `post:edit`\n    - `post:delete`\n    - `post:vote`\n    - `post:report`\n13. `post_comment` - Provides all post comment scopes\n    - `post_comment:create`\n    - `post_comment:edit`\n    - `post_comment:delete`\n    - `post_comment:vote`\n    - `post_comment:report`\n14. `user` - Provides all user access scopes\n    - `user:profile`\n      - `user:profile:read`\n        - Allows access to current user's settings and profile via the `/api/user/me` endpoint\n      - `user:profile:edit`\n        - Allows updating the current user's settings and profile\n    - `user:message`\n      - `user:message:read`\n        - Allows the client to view the current user's messages\n        - Also allows the client to mark unread messages as read or read messages as unread\n      - `user:message:create`\n        - Allows the client to create new messages to other users or reply to existing messages\n    - `user:notification`\n      - `user:notification:read`\n        - Allows the client to read notifications about threads, posts, or comments being replied to, as well as moderation notifications.\n        - Does not allow the client to read the content of messages. Message notifications will have their content censored unless the `user:message:read` scope is granted.\n        - Allows the client to read the number of unread notifications, and mark them as read/unread\n      - `user:notification:delete`\n        - Allows the client to clear notifications\n15. `moderate` - grants all moderation permissions. The user must be a moderator to perform these actions\n    - `moderate:entry` - Allows the client to retrieve a list of threads from magazines moderated by the user\n      - `moderate:entry:language`\n        - Allows changing the language of threads moderated by the user\n      - `moderate:entry:pin`\n        - Allows pinning/unpinning threads to the top of magazines moderated by the user\n      - `moderate:entry:lock`\n        - Allows locking/unlocking of threads\n      - `moderate:entry:set_adult`\n        - Allows toggling the NSFW status of threads moderated by the user\n      - `moderate:entry:trash`\n        - Allows soft deletion or restoration of threads moderated by the user\n    - `moderate:entry_comment`\n      - `moderate:entry_comment:language`\n        - Allows changing the language of comments in threads moderated by the user\n      - `moderate:entry_comment:set_adult`\n        - Allows toggling the NSFW status of comments in threads moderated by the user\n      - `moderate:entry_comment:trash`\n        - Allows soft deletion or restoration of comments in threads moderated by the user\n    - `moderate:post`\n      - `moderate:post:language`\n        - Allows changing the language of posts moderated by the user\n      - `moderate:post:set_adult`\n        - Allows toggling the NSFW status of posts moderated by the user\n      - `moderate:post:trash`\n        - Allows soft deletion or restoration of posts moderated by the user\n      - `moderate:post:pin`\n          - Allows pinning/unpinning posts to the top of magazines moderated by the user\n      - `moderate:post:lock`\n          - Allows locking/unlocking of posts\n    - `moderate:post_comment`\n      - `moderate:post_comment:language`\n        - Allows changing the language of comments on posts moderated by the user\n      - `moderate:post_comment:set_adult`\n        - Allows toggling the NSFW status of comments on posts moderated by the user\n      - `moderate:post_comment:trash`\n        - Allows soft deletion or restoration of comments on posts moderated by the user\n    - `moderate:magazine`\n      - `moderate:magazine:ban`\n        - `moderate:magazine:ban:read`\n          - Allows viewing the users banned from the magazine\n        - `moderate:magazine:ban:create`\n          - Allows the client to ban a user from the magazine\n        - `moderate:magazine:ban:delete`\n          - Allows the client to unban a user from the magazine\n      - `moderate:magazine:list`\n        - Allows the client to view a list of magazines the user moderates\n      - `moderate:magazine:reports`\n        - `moderate:magazine:reports:read`\n          - Allows the client to read reports about content from magazines the user moderates\n        - `moderate:magazine:reports:action`\n          - Allows the client to take action on reports, either accepting or rejecting them\n      - `moderate:magazine:trash:read`\n        - Allows viewing the removed content of a moderated magazine\n    - `moderate:magazine_admin`\n      - `moderate:magazine_admin:create`\n        - Allows the creation of new magazines\n      - `moderate:magazine_admin:delete`\n        - Allows the deletion of magazines the user has permission to delete\n      - `moderate:magazine_admin:update`\n        - Allows magazine rules, description, settings, title, etc to be updated\n      - `moderate:magazine_admin:theme`\n        - Allows updates to the magazine theme\n      - `moderate:magazine_admin:moderators`\n        - Allows the addition or removal of moderators to/from an owned magazine\n      - `moderate:magazine_admin:badges`\n        - Allows the addition or removal of badges to/from an owned magazine\n      - `moderate:magazine_admin:tags`\n        - Allows the addition or removal of tags to/from an owned magazine\n      - `moderate:magazine_admin:stats`\n        - Allows the client to view stats from an owned magazine\n16. `admin` - All scopes require the instance admin role to perform\n    - `admin:entry:purge`\n      - Allows threads to be completely removed from the instance\n    - `admin:entry_comment:purge`\n      - Allows comments in threads to be completely removed from the instance\n    - `admin:post:purge`\n      - Allows posts to be completely removed from the instance\n    - `admin:post_comment:purge`\n      - Allows post comments to be completely removed from the instance\n    - `admin:magazine`\n      - `admin:magazine:move_entry`\n        - Allows an admin to move an entry to another magazine\n      - `admin:magazine:purge`\n        - Allows an admin to completely purge a magazine from the instance\n      - `admin:magazine:moderate`\n        - Allows an admin to accept or reject moderator and ownership requests of magazines\n    - `admin:user`\n      - `admin:user:ban`\n        - Allows the admin to ban or unban users from the instance\n      - `admin:user:verify`\n        - Allows the admin to verify a user on the instance\n      - `admin:user:purge`\n        - Allows the admin to completely purge a user from the instance\n    - `admin:instance`\n      - `admin:instance:settings`\n        - `admin:instance:settings:read`\n          - Allows the admin to read instance settings\n        - `admin:instance:settings:edit`\n          - Allows the admin to update instance settings\n      - `admin:instance:information:edit`\n        - Allows the admin to update information on the About, Contact, FAQ, Privacy Policy, and Terms of Service pages.\n    - `admin:federation`\n      - `admin:federation:read`\n        - Allows the admin to read a list of defederated instances\n      - `admin:federation:update`\n        - Allows the admin to edit the list of defederated instances\n    - `admin:oauth_clients`\n      - `admin:oauth_clients:read`\n        - Allows the admin to read usage stats of oauth clients, as well as list clients on the instance\n      - `admin:oauth_clients:revoke`\n        - Allows the admin to revoke a client's permission to access the instance\n"
  },
  {
    "path": "docs/05-fediverse_developers/README.md",
    "content": "# Fediverse Developers\n\nThis page is mainly for outlining the activities and circumstances Mbin sends out activities and \nhow activities, objects and actors are represented.\nTo communicate between instances, Mbin utilizes the ActivityPub protocol \n([ActivityPub standard](https://www.w3.org/TR/activitypub/), [ActivityPub vocabulary](https://www.w3.org/TR/activitystreams-vocabulary/))\nand the [FEP Group federation](https://codeberg.org/fediverse/fep/src/branch/main/feps/fep-1b12.md).\n\n## Context\n\nThe `@context` property for all Mbin payloads **should** be this:\n\n```json\n%@context%\n```\n\nThe `/contexts` endpoint resolves to this:\n\n```json\n%@context_additional%\n```\n\n## Actors\n\nThe actors Mbin uses are\n\n- the Instance actor (AP `Application`)\n- the User actor (AP `Person`)\n- the Magazine actor (AP `Group`)\n\n### Instance Actor\n\nEach instance has an instance actor at `https://instance.tld/i/actor` and `https://instance.tld` (they are the same):\n\n```json\n%actor_instance%\n```\n\n### User actor\n\nEach registered user has an AP actor at `https://instance.tld/u/username`:\n\n```json\n%actor_user%\n```\n\n### Magazine actor\n\nEach magazine has an AP actor at `https://instance.tld/m/name`:\n\n```json\n%actor_magazine%\n```\n\n## Objects\n\n### Threads\n\n```json\n%object_entry%\n```\n\n### Comments on threads\n\n```json\n%object_entry_comment%\n```\n\n### Microblogs\n\n```json\n%object_post%\n```\n\n### Comments on microblogs\n\n```json\n%object_post_comment%\n```\n\n### Private message\n\n```json\n%object_message%\n```\n\n## Collections\n\n### User Outbox\n\n```json\n%collection_user_outbox%\n```\n\nFirst Page:\n\n```json\n%collection_items_user_outbox%\n```\n\n### User Followers\n\n```json\n%collection_user_followers%\n```\n\n### User Followings\n\n```json\n%collection_user_followings%\n```\n\n### Magazine Outbox\n\nThe magazine outbox endpoint does technically exist, but it just returns an empty JSON object at the moment.\n```json\n%collection_magazine_outbox%\n```\n\n### Magazine Moderators\n\nThe moderators collection contains all moderators and is not paginated:\n\n```json\n%collection_magazine_moderators%\n```\n\n### Magazine Featured\n\nThe featured collection contains all threads and is not paginated:\n\n```json\n%collection_magazine_featured%\n```\n\n### Magazine Followers\n\nThe followers collection does not contain items, it only shows the number of subscribed users:\n\n```json\n%collection_magazine_followers%\n```\n\n## User Activities\n\n### Follow and unfollow\n\nIf the user wants to follow another user or magazine:\n\n```json\n%activity_user_follow%\n```\n\nIf the user stops following another user or magazine:\n\n```json\n%activity_user_undo_follow%\n```\n\n### Accept and Reject\n\nMbin automatically sends an `Accept` activity when a user receives a `Follow` activity.\n\n```json\n%activity_user_accept%\n```\n\n### Create\n\n```json\n%activity_user_create%\n```\n\n### Report\n\n```json\n%activity_user_flag%\n```\n\n### Vote\n\nWhen a user votes it is translated to a `Like` activity for an up-vote and a `Dislike` activity for a down-vote. \nDown-votes are not federated, yet.\n\n```json\n%activity_user_like%\n```\n\nIf the vote is removed:\n\n```json\n%activity_user_undo_like%\n```\n\n### Boost\n\nIf a user boosts content:\n\n```json\n%activity_user_announce%\n```\n\n### Edit account\n\n```json\n%activity_user_update_user%\n```\n\n### Edit content\n\n```json\n%activity_user_update_content%\n```\n\n### Delete content\n\n```json\n%activity_user_delete%\n```\n\n### Delete own account\n\n```json\n%activity_user_delete_account%\n```\n\n### Lock own content\n\nOnly top level content (meaning no comments) can be locked.\nWhen content is locked, comments can no longer be created for it.\n\n```json\n%activity_user_lock%\n```\n\n## Moderator Activities\n\n### Add or Remove moderator\n\nWhen a moderator is added:\n\n```json\n%activity_mod_add_mod%\n```\n\nWhen a moderator is removed:\n\n```json\n%activity_mod_remove_mod%\n```\n\n### Pin or Unpin a thread\n\nWhen a thread is pinned:\n\n```json\n%activity_mod_add_pin%\n```\n\nWhen a thread is unpinned:\n\n```json\n%activity_mod_remove_pin%\n```\n\n### Delete content\n\n```json\n%activity_mod_delete%\n```\n\n### Ban user from magazine\n\n```json\n%activity_mod_ban%\n```\n\n### Lock content\n\nWhen content is locked, comments can no longer be created for it.\n\n```json\n%activity_mod_lock%\n```\n\n## Admin Activities\n\n### Ban user from instance\n\n```json\n%activity_admin_ban%\n```\n\n### Delete account\n\nIf an admin deletes another user's account the activity actually does not reflect that, it looks exactly as if the user deleted their own account.\n\n```json\n%activity_admin_delete_account%\n```\n\n## Magazine Activities\n\n### Announce activities\n\nThe magazine is mainly there to announce the activities users do with it as the audience.\nThe announced type can be `Create`, `Update`, `Add`, `Remove`, `Announce`, `Delete`, `Like`, `Dislike`, `Flag` and `Lock`.\n`Announce(Flag)` activities are only sent to instances with moderators of this magazine on them. \n\n```json\n%activity_mag_announce%\n```\n"
  },
  {
    "path": "docs/README.md",
    "content": "# Documentation\n\nWe split up the documentation for:\n\n- [End users](01-user/README.md) - End user guide\n- [Admins](02-admin/README.md) - How-to guides for admins\n- [Contributing](03-contributing/README.md) - How-to contribute to the Mbin project\n- [App developers](04-app_developers/README.md) - How-to use the Mbin API\n- [Fediverse developers](05-fediverse_developers/README.md) - How Mbin is using the ActivityPub protocol to federate with other servers\n"
  },
  {
    "path": "docs/postman/kbin.postman_collection.json",
    "content": "{\n\t\"info\": {\n\t\t\"_postman_id\": \"0a2a252b-440c-421a-8702-5019985fb288\",\n\t\t\"name\": \"mbin\",\n\t\t\"schema\": \"https://schema.getpostman.com/json/collection/v2.1.0/collection.json\",\n\t\t\"_exporter_id\": \"13837673\",\n\t\t\"_collection_link\": \"https://red-crater-797471.postman.co/workspace/My-Workspace~e6f4fbde-d09f-4847-8a63-b7491fde7dc1/collection/13837673-0a2a252b-440c-421a-8702-5019985fb288?action=share&source=collection_link&creator=13837673\"\n\t},\n\t\"item\": [\n\t\t{\n\t\t\t\"name\": \"Authentication\",\n\t\t\t\"item\": [\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Authorization Code\",\n\t\t\t\t\t\"item\": [\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"name\": \"OAuth2 Authorize\",\n\t\t\t\t\t\t\t\"event\": [\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"listen\": \"prerequest\",\n\t\t\t\t\t\t\t\t\t\"script\": {\n\t\t\t\t\t\t\t\t\t\t\"exec\": [\n\t\t\t\t\t\t\t\t\t\t\t\"pm.environment.set(\\\"oauth_state\\\", pm.variables.replaceIn(\\\"{{$randomPassword}}\\\"));\\r\",\n\t\t\t\t\t\t\t\t\t\t\t\"\"\n\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\"type\": \"text/javascript\"\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\t\t\"method\": \"GET\",\n\t\t\t\t\t\t\t\t\"header\": [],\n\t\t\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/authorize?response_type=code&client_id={{oauth_client_id}}&redirect_uri=http://localhost:3001&scope=read write admin:oauth_clients:read&state={{oauth_state}}\",\n\t\t\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\t\t\"authorize\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"query\": [\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"response_type\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"code\"\n\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"client_id\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"{{oauth_client_id}}\"\n\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"redirect_uri\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"http://localhost:3001\"\n\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"scope\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"read write admin:oauth_clients:read\"\n\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"state\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"{{oauth_state}}\"\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"response\": []\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"name\": \"OAuth2 Token\",\n\t\t\t\t\t\t\t\"event\": [\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"listen\": \"test\",\n\t\t\t\t\t\t\t\t\t\"script\": {\n\t\t\t\t\t\t\t\t\t\t\"exec\": [\n\t\t\t\t\t\t\t\t\t\t\t\"pm.test(\\\"Has a token\\\", function () {\\r\",\n\t\t\t\t\t\t\t\t\t\t\t\"    var jsonData = pm.response.json();\\r\",\n\t\t\t\t\t\t\t\t\t\t\t\"    pm.expect(jsonData.access_token).to.exist;\\r\",\n\t\t\t\t\t\t\t\t\t\t\t\"    pm.environment.set(\\\"token\\\", jsonData.access_token);\\r\",\n\t\t\t\t\t\t\t\t\t\t\t\"    pm.expect(jsonData.refresh_token).to.exist;\\r\",\n\t\t\t\t\t\t\t\t\t\t\t\"    pm.environment.set(\\\"oauth_refresh_token\\\", jsonData.refresh_token);\\r\",\n\t\t\t\t\t\t\t\t\t\t\t\"});\"\n\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\"type\": \"text/javascript\"\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\t\t\"method\": \"POST\",\n\t\t\t\t\t\t\t\t\"header\": [],\n\t\t\t\t\t\t\t\t\"body\": {\n\t\t\t\t\t\t\t\t\t\"mode\": \"formdata\",\n\t\t\t\t\t\t\t\t\t\"formdata\": [\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"grant_type\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"authorization_code\",\n\t\t\t\t\t\t\t\t\t\t\t\"type\": \"text\"\n\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"client_id\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"{{oauth_client_id}}\",\n\t\t\t\t\t\t\t\t\t\t\t\"type\": \"text\"\n\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"client_secret\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"{{oauth_client_secret}}\",\n\t\t\t\t\t\t\t\t\t\t\t\"type\": \"text\"\n\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"code\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"{{oauth_code}}\",\n\t\t\t\t\t\t\t\t\t\t\t\"type\": \"text\"\n\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"redirect_uri\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"http://localhost:3001\",\n\t\t\t\t\t\t\t\t\t\t\t\"type\": \"text\"\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/token\",\n\t\t\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\t\t\"token\"\n\t\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"response\": []\n\t\t\t\t\t\t}\n\t\t\t\t\t]\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Auth Code with PKCE\",\n\t\t\t\t\t\"item\": [\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"name\": \"OAuth2 Authorize PKCE\",\n\t\t\t\t\t\t\t\"event\": [\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"listen\": \"prerequest\",\n\t\t\t\t\t\t\t\t\t\"script\": {\n\t\t\t\t\t\t\t\t\t\t\"exec\": [\n\t\t\t\t\t\t\t\t\t\t\t\"// hash is a Uint8Array\\r\",\n\t\t\t\t\t\t\t\t\t\t\t\"function base64_urlencode(hash) {\\r\",\n\t\t\t\t\t\t\t\t\t\t\t\"    return btoa(String.fromCharCode.apply(null, hash)).replace(/\\\\+/g, '-').replace(/\\\\//g, '_').replace(/=+$/, '');\\r\",\n\t\t\t\t\t\t\t\t\t\t\t\"}\\r\",\n\t\t\t\t\t\t\t\t\t\t\t\"\\r\",\n\t\t\t\t\t\t\t\t\t\t\t\"function sha256_to_array(sha256) {\\r\",\n\t\t\t\t\t\t\t\t\t\t\t\"    const array = new Uint8Array(sha256.sigBytes);\\r\",\n\t\t\t\t\t\t\t\t\t\t\t\"    for(var i = 0; i < sha256.sigBytes; i++) {\\r\",\n\t\t\t\t\t\t\t\t\t\t\t\"        array[i] = (sha256.words[i >>> 2] >>> (24 - (i % 4) * 8)) & 0xff;\\r\",\n\t\t\t\t\t\t\t\t\t\t\t\"    }\\r\",\n\t\t\t\t\t\t\t\t\t\t\t\"    return array;\\r\",\n\t\t\t\t\t\t\t\t\t\t\t\"}\\r\",\n\t\t\t\t\t\t\t\t\t\t\t\"\\r\",\n\t\t\t\t\t\t\t\t\t\t\t\"pm.environment.set(\\\"oauth_state\\\", pm.variables.replaceIn(\\\"{{$randomPassword}}\\\"));\\r\",\n\t\t\t\t\t\t\t\t\t\t\t\"\\r\",\n\t\t\t\t\t\t\t\t\t\t\t\"const array = new Uint8Array(64);\\r\",\n\t\t\t\t\t\t\t\t\t\t\t\"array.forEach((_, index) => {\\r\",\n\t\t\t\t\t\t\t\t\t\t\t\"    array[index] = Math.floor(Math.random() * 256);\\r\",\n\t\t\t\t\t\t\t\t\t\t\t\"});\\r\",\n\t\t\t\t\t\t\t\t\t\t\t\"const alphabet = \\\"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~\\\";\\r\",\n\t\t\t\t\t\t\t\t\t\t\t\"var verifier = \\\"\\\";\\r\",\n\t\t\t\t\t\t\t\t\t\t\t\"array.forEach((value) => {\\r\",\n\t\t\t\t\t\t\t\t\t\t\t\"    verifier += alphabet[value % alphabet.length];\\r\",\n\t\t\t\t\t\t\t\t\t\t\t\"});\\r\",\n\t\t\t\t\t\t\t\t\t\t\t\"\\r\",\n\t\t\t\t\t\t\t\t\t\t\t\"pm.environment.set(\\\"oauth_code_verifier\\\", verifier);\\r\",\n\t\t\t\t\t\t\t\t\t\t\t\"\\r\",\n\t\t\t\t\t\t\t\t\t\t\t\"const challenge = base64_urlencode(sha256_to_array(CryptoJS.SHA256(verifier)));\\r\",\n\t\t\t\t\t\t\t\t\t\t\t\"\\r\",\n\t\t\t\t\t\t\t\t\t\t\t\"pm.environment.set(\\\"oauth_code_challenge\\\", challenge);\"\n\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\"type\": \"text/javascript\"\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\t\t\"method\": \"GET\",\n\t\t\t\t\t\t\t\t\"header\": [],\n\t\t\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/authorize?response_type=code&client_id={{oauth_public_client_id}}&redirect_uri=http://localhost:3001&scope=read write&state={{oauth_state}}&code_challenge={{oauth_code_challenge}}&code_challenge_method=S256\",\n\t\t\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\t\t\"authorize\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"query\": [\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"response_type\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"code\"\n\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"client_id\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"{{oauth_public_client_id}}\"\n\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"redirect_uri\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"http://localhost:3001\"\n\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"scope\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"read write\"\n\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"state\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"{{oauth_state}}\"\n\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"code_challenge\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"{{oauth_code_challenge}}\"\n\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"code_challenge_method\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"S256\"\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"response\": []\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"name\": \"OAuth2 Token PKCE\",\n\t\t\t\t\t\t\t\"event\": [\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"listen\": \"test\",\n\t\t\t\t\t\t\t\t\t\"script\": {\n\t\t\t\t\t\t\t\t\t\t\"exec\": [\n\t\t\t\t\t\t\t\t\t\t\t\"pm.test(\\\"Has a token\\\", function () {\\r\",\n\t\t\t\t\t\t\t\t\t\t\t\"    var jsonData = pm.response.json();\\r\",\n\t\t\t\t\t\t\t\t\t\t\t\"    pm.expect(jsonData.access_token).to.exist;\\r\",\n\t\t\t\t\t\t\t\t\t\t\t\"    pm.environment.set(\\\"token\\\", jsonData.access_token);\\r\",\n\t\t\t\t\t\t\t\t\t\t\t\"    pm.expect(jsonData.refresh_token).to.exist;\\r\",\n\t\t\t\t\t\t\t\t\t\t\t\"    pm.environment.set(\\\"oauth_public_refresh_token\\\", jsonData.refresh_token);\\r\",\n\t\t\t\t\t\t\t\t\t\t\t\"});\"\n\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\"type\": \"text/javascript\"\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\t\t\"method\": \"POST\",\n\t\t\t\t\t\t\t\t\"header\": [],\n\t\t\t\t\t\t\t\t\"body\": {\n\t\t\t\t\t\t\t\t\t\"mode\": \"formdata\",\n\t\t\t\t\t\t\t\t\t\"formdata\": [\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"grant_type\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"authorization_code\",\n\t\t\t\t\t\t\t\t\t\t\t\"type\": \"text\"\n\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"client_id\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"{{oauth_public_client_id}}\",\n\t\t\t\t\t\t\t\t\t\t\t\"type\": \"text\"\n\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"code_verifier\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"{{oauth_code_verifier}}\",\n\t\t\t\t\t\t\t\t\t\t\t\"type\": \"text\"\n\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"code\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"{{oauth_code}}\",\n\t\t\t\t\t\t\t\t\t\t\t\"type\": \"text\"\n\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"redirect_uri\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"http://localhost:3001\",\n\t\t\t\t\t\t\t\t\t\t\t\"type\": \"text\"\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/token\",\n\t\t\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\t\t\"token\"\n\t\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"response\": []\n\t\t\t\t\t\t}\n\t\t\t\t\t]\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Refresh Token\",\n\t\t\t\t\t\"item\": [\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"name\": \"OAuth2 Refresh Token\",\n\t\t\t\t\t\t\t\"event\": [\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"listen\": \"test\",\n\t\t\t\t\t\t\t\t\t\"script\": {\n\t\t\t\t\t\t\t\t\t\t\"exec\": [\n\t\t\t\t\t\t\t\t\t\t\t\"pm.test(\\\"Has a token\\\", function () {\\r\",\n\t\t\t\t\t\t\t\t\t\t\t\"    var jsonData = pm.response.json();\\r\",\n\t\t\t\t\t\t\t\t\t\t\t\"    pm.expect(jsonData.access_token).to.exist;\\r\",\n\t\t\t\t\t\t\t\t\t\t\t\"    pm.environment.set(\\\"token\\\", jsonData.access_token);\\r\",\n\t\t\t\t\t\t\t\t\t\t\t\"    pm.expect(jsonData.refresh_token).to.exist;\\r\",\n\t\t\t\t\t\t\t\t\t\t\t\"    pm.environment.set(\\\"oauth_refresh_token\\\", jsonData.refresh_token);\\r\",\n\t\t\t\t\t\t\t\t\t\t\t\"});\"\n\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\"type\": \"text/javascript\"\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\t\t\"method\": \"POST\",\n\t\t\t\t\t\t\t\t\"header\": [],\n\t\t\t\t\t\t\t\t\"body\": {\n\t\t\t\t\t\t\t\t\t\"mode\": \"formdata\",\n\t\t\t\t\t\t\t\t\t\"formdata\": [\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"grant_type\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"refresh_token\",\n\t\t\t\t\t\t\t\t\t\t\t\"type\": \"text\"\n\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"client_id\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"{{oauth_client_id}}\",\n\t\t\t\t\t\t\t\t\t\t\t\"type\": \"text\"\n\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"client_secret\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"{{oauth_client_secret}}\",\n\t\t\t\t\t\t\t\t\t\t\t\"type\": \"text\"\n\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"refresh_token\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"{{oauth_refresh_token}}\",\n\t\t\t\t\t\t\t\t\t\t\t\"type\": \"text\"\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/token\",\n\t\t\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\t\t\"token\"\n\t\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"response\": []\n\t\t\t\t\t\t}\n\t\t\t\t\t]\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Client Credentials\",\n\t\t\t\t\t\"item\": [\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"name\": \"OAuth2 Token Client Credentials\",\n\t\t\t\t\t\t\t\"event\": [\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"listen\": \"test\",\n\t\t\t\t\t\t\t\t\t\"script\": {\n\t\t\t\t\t\t\t\t\t\t\"exec\": [\n\t\t\t\t\t\t\t\t\t\t\t\"pm.test(\\\"Has a token\\\", function () {\\r\",\n\t\t\t\t\t\t\t\t\t\t\t\"    var jsonData = pm.response.json();\\r\",\n\t\t\t\t\t\t\t\t\t\t\t\"    pm.expect(jsonData.access_token).to.exist;\\r\",\n\t\t\t\t\t\t\t\t\t\t\t\"    pm.environment.set(\\\"token\\\", jsonData.access_token);\\r\",\n\t\t\t\t\t\t\t\t\t\t\t\"});\"\n\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\"type\": \"text/javascript\"\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\t\t\"method\": \"POST\",\n\t\t\t\t\t\t\t\t\"header\": [],\n\t\t\t\t\t\t\t\t\"body\": {\n\t\t\t\t\t\t\t\t\t\"mode\": \"formdata\",\n\t\t\t\t\t\t\t\t\t\"formdata\": [\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"grant_type\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"client_credentials\",\n\t\t\t\t\t\t\t\t\t\t\t\"type\": \"text\"\n\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"client_id\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"{{oauth_client_creds_id}}\",\n\t\t\t\t\t\t\t\t\t\t\t\"type\": \"text\"\n\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"client_secret\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"{{oauth_client_creds_secret}}\",\n\t\t\t\t\t\t\t\t\t\t\t\"type\": \"text\"\n\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"scope\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"read write\",\n\t\t\t\t\t\t\t\t\t\t\t\"type\": \"text\"\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/token\",\n\t\t\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\t\t\"token\"\n\t\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"response\": []\n\t\t\t\t\t\t}\n\t\t\t\t\t]\n\t\t\t\t}\n\t\t\t]\n\t\t},\n\t\t{\n\t\t\t\"name\": \"Domain\",\n\t\t\t\"item\": [\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Get domain by id\",\n\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"GET\",\n\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/domain/:domain_id\",\n\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"domain\",\n\t\t\t\t\t\t\t\t\":domain_id\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"domain_id\",\n\t\t\t\t\t\t\t\t\t\"value\": \"1\"\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Block domain by id\",\n\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"PUT\",\n\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/domain/:domain_id/block\",\n\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"domain\",\n\t\t\t\t\t\t\t\t\":domain_id\",\n\t\t\t\t\t\t\t\t\"block\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"domain_id\",\n\t\t\t\t\t\t\t\t\t\"value\": \"1\"\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Unblock domain by id\",\n\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"PUT\",\n\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/domain/:domain_id/unblock\",\n\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"domain\",\n\t\t\t\t\t\t\t\t\":domain_id\",\n\t\t\t\t\t\t\t\t\"unblock\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"domain_id\",\n\t\t\t\t\t\t\t\t\t\"value\": \"1\"\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Subscribe to domain by id\",\n\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"PUT\",\n\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/domain/:domain_id/subscribe\",\n\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"domain\",\n\t\t\t\t\t\t\t\t\":domain_id\",\n\t\t\t\t\t\t\t\t\"subscribe\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"domain_id\",\n\t\t\t\t\t\t\t\t\t\"value\": \"1\"\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Unsubscribe to domain by id\",\n\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"PUT\",\n\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/domain/:domain_id/unsubscribe\",\n\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"domain\",\n\t\t\t\t\t\t\t\t\":domain_id\",\n\t\t\t\t\t\t\t\t\"unsubscribe\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"domain_id\",\n\t\t\t\t\t\t\t\t\t\"value\": \"1\"\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Get domains\",\n\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"GET\",\n\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/domains\",\n\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"domains\"\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Get subscribed domains\",\n\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"GET\",\n\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/domains/subscribed\",\n\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"domains\",\n\t\t\t\t\t\t\t\t\"subscribed\"\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Get blocked domains\",\n\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"GET\",\n\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/domains/blocked\",\n\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"domains\",\n\t\t\t\t\t\t\t\t\"blocked\"\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t}\n\t\t\t]\n\t\t},\n\t\t{\n\t\t\t\"name\": \"Entry\",\n\t\t\t\"item\": [\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Comments\",\n\t\t\t\t\t\"item\": [\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"name\": \"Create comment on entry\",\n\t\t\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\t\t\"method\": \"POST\",\n\t\t\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\"body\": {\n\t\t\t\t\t\t\t\t\t\"mode\": \"raw\",\n\t\t\t\t\t\t\t\t\t\"raw\": \"{\\r\\n    \\\"body\\\": \\\"Test API comment\\\",\\r\\n    \\\"tags\\\": [\\r\\n        \\\"bot\\\",\\r\\n        \\\"api\\\"\\r\\n    ],\\r\\n    \\\"lang\\\": \\\"en\\\"\\r\\n}\",\n\t\t\t\t\t\t\t\t\t\"options\": {\n\t\t\t\t\t\t\t\t\t\t\"raw\": {\n\t\t\t\t\t\t\t\t\t\t\t\"language\": \"json\"\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/entry/:entry/comments\",\n\t\t\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\t\t\"entry\",\n\t\t\t\t\t\t\t\t\t\t\":entry\",\n\t\t\t\t\t\t\t\t\t\t\"comments\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"entry\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"163\"\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"response\": []\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"name\": \"Create comment on entry with image\",\n\t\t\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\t\t\"method\": \"POST\",\n\t\t\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\"body\": {\n\t\t\t\t\t\t\t\t\t\"mode\": \"formdata\",\n\t\t\t\t\t\t\t\t\t\"formdata\": [\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"body\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"\",\n\t\t\t\t\t\t\t\t\t\t\t\"type\": \"text\"\n\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"lang\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"en\",\n\t\t\t\t\t\t\t\t\t\t\t\"type\": \"text\"\n\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"alt\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"A test cat image\",\n\t\t\t\t\t\t\t\t\t\t\t\"type\": \"text\"\n\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"entry_comment\",\n\t\t\t\t\t\t\t\t\t\t\t\"type\": \"file\",\n\t\t\t\t\t\t\t\t\t\t\t\"src\": \"/C:/Users/Ryan/Pictures/kbin Test Images/large.jpg\"\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/entry/:entry/comments/image\",\n\t\t\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\t\t\"entry\",\n\t\t\t\t\t\t\t\t\t\t\":entry\",\n\t\t\t\t\t\t\t\t\t\t\"comments\",\n\t\t\t\t\t\t\t\t\t\t\"image\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"entry\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"163\"\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"response\": []\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"name\": \"Create comment reply\",\n\t\t\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\t\t\"method\": \"POST\",\n\t\t\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\"body\": {\n\t\t\t\t\t\t\t\t\t\"mode\": \"raw\",\n\t\t\t\t\t\t\t\t\t\"raw\": \"{\\r\\n    \\\"body\\\": \\\"Test API comment reply #bot\\\",\\r\\n    \\\"lang\\\": \\\"en\\\"\\r\\n}\",\n\t\t\t\t\t\t\t\t\t\"options\": {\n\t\t\t\t\t\t\t\t\t\t\"raw\": {\n\t\t\t\t\t\t\t\t\t\t\t\"language\": \"json\"\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/entry/:entry/comments/:comment/reply\",\n\t\t\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\t\t\"entry\",\n\t\t\t\t\t\t\t\t\t\t\":entry\",\n\t\t\t\t\t\t\t\t\t\t\"comments\",\n\t\t\t\t\t\t\t\t\t\t\":comment\",\n\t\t\t\t\t\t\t\t\t\t\"reply\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"entry\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"163\"\n\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"comment\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"29\"\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"response\": []\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"name\": \"Create comment reply with image\",\n\t\t\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\t\t\"method\": \"POST\",\n\t\t\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\"body\": {\n\t\t\t\t\t\t\t\t\t\"mode\": \"formdata\",\n\t\t\t\t\t\t\t\t\t\"formdata\": [\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"body\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"\",\n\t\t\t\t\t\t\t\t\t\t\t\"type\": \"text\"\n\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"lang\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"en\",\n\t\t\t\t\t\t\t\t\t\t\t\"type\": \"text\"\n\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"alt\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"A test cat image\",\n\t\t\t\t\t\t\t\t\t\t\t\"type\": \"text\"\n\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"entry_comment\",\n\t\t\t\t\t\t\t\t\t\t\t\"type\": \"file\",\n\t\t\t\t\t\t\t\t\t\t\t\"src\": \"/C:/Users/Ryan/Pictures/kbin Test Images/stock.jpg\"\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/entry/:entry/comments/:comment/reply/image\",\n\t\t\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\t\t\"entry\",\n\t\t\t\t\t\t\t\t\t\t\":entry\",\n\t\t\t\t\t\t\t\t\t\t\"comments\",\n\t\t\t\t\t\t\t\t\t\t\":comment\",\n\t\t\t\t\t\t\t\t\t\t\"reply\",\n\t\t\t\t\t\t\t\t\t\t\"image\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"entry\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"163\"\n\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"comment\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"31\"\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"response\": []\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"name\": \"Retrieve comment\",\n\t\t\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\t\t\"method\": \"GET\",\n\t\t\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/comments/:comment?d=0\",\n\t\t\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\t\t\"comments\",\n\t\t\t\t\t\t\t\t\t\t\":comment\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"query\": [\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"d\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"0\"\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"comment\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"30\"\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"response\": []\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"name\": \"Get comments from entry\",\n\t\t\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\t\t\"method\": \"GET\",\n\t\t\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/entry/:entry/comments?d=1&sort=hot&time=∞&p=1&perPage=10\",\n\t\t\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\t\t\"entry\",\n\t\t\t\t\t\t\t\t\t\t\":entry\",\n\t\t\t\t\t\t\t\t\t\t\"comments\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"query\": [\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"d\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"1\"\n\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"sort\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"hot\"\n\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"time\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"∞\"\n\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"p\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"1\"\n\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"perPage\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"10\"\n\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"lang[]\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"en\",\n\t\t\t\t\t\t\t\t\t\t\t\"disabled\": true\n\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"usePreferredLangs\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"false\",\n\t\t\t\t\t\t\t\t\t\t\t\"disabled\": true\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"entry\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"9\"\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"response\": []\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"name\": \"Get comments in domain\",\n\t\t\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\t\t\"method\": \"GET\",\n\t\t\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/domain/:domain_id/comments?d=1\",\n\t\t\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\t\t\"domain\",\n\t\t\t\t\t\t\t\t\t\t\":domain_id\",\n\t\t\t\t\t\t\t\t\t\t\"comments\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"query\": [\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"d\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"1\"\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"domain_id\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"1\"\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"response\": []\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"name\": \"Get comments from user\",\n\t\t\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\t\t\"method\": \"GET\",\n\t\t\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/users/:user_id/comments?d=1\",\n\t\t\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\t\t\"users\",\n\t\t\t\t\t\t\t\t\t\t\":user_id\",\n\t\t\t\t\t\t\t\t\t\t\"comments\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"query\": [\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"d\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"1\"\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"user_id\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"1\"\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"response\": []\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"name\": \"Update comment\",\n\t\t\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\t\t\"method\": \"PUT\",\n\t\t\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\"body\": {\n\t\t\t\t\t\t\t\t\t\"mode\": \"raw\",\n\t\t\t\t\t\t\t\t\t\"raw\": \"{\\r\\n    \\\"body\\\": \\\"Test API comment reply updated once again\\\",\\r\\n    \\\"isAdult\\\": false,\\r\\n    \\\"lang\\\": \\\"en\\\",\\r\\n    \\\"imageAlt\\\": \\\"cat\\\",\\r\\n    \\\"imageUrl\\\": \\\"https://i.imgur.com/ThloWfz.jpeg\\\"\\r\\n}\",\n\t\t\t\t\t\t\t\t\t\"options\": {\n\t\t\t\t\t\t\t\t\t\t\"raw\": {\n\t\t\t\t\t\t\t\t\t\t\t\"language\": \"json\"\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/comments/:comment?d=0\",\n\t\t\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\t\t\"comments\",\n\t\t\t\t\t\t\t\t\t\t\":comment\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"query\": [\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"d\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"0\"\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"comment\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"11\"\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"response\": []\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"name\": \"Report comment by id\",\n\t\t\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\t\t\"method\": \"POST\",\n\t\t\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\"body\": {\n\t\t\t\t\t\t\t\t\t\"mode\": \"raw\",\n\t\t\t\t\t\t\t\t\t\"raw\": \"{\\r\\n    \\\"reason\\\": \\\"It's a terrible meme\\\"\\r\\n}\",\n\t\t\t\t\t\t\t\t\t\"options\": {\n\t\t\t\t\t\t\t\t\t\t\"raw\": {\n\t\t\t\t\t\t\t\t\t\t\t\"language\": \"json\"\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/comments/:comment/report\",\n\t\t\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\t\t\"comments\",\n\t\t\t\t\t\t\t\t\t\t\":comment\",\n\t\t\t\t\t\t\t\t\t\t\"report\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"comment\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"1\"\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"response\": []\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"name\": \"Delete comment\",\n\t\t\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\t\t\"method\": \"DELETE\",\n\t\t\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/comments/:comment\",\n\t\t\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\t\t\"comments\",\n\t\t\t\t\t\t\t\t\t\t\":comment\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"comment\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"12\"\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"response\": []\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"name\": \"Vote comment by id\",\n\t\t\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\t\t\"method\": \"PUT\",\n\t\t\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/comments/:comment/vote/:vote\",\n\t\t\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\t\t\"comments\",\n\t\t\t\t\t\t\t\t\t\t\":comment\",\n\t\t\t\t\t\t\t\t\t\t\"vote\",\n\t\t\t\t\t\t\t\t\t\t\":vote\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"comment\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"11\"\n\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"vote\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"1\"\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"response\": []\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"name\": \"Favourite comment by id\",\n\t\t\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\t\t\"method\": \"PUT\",\n\t\t\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/comments/:comment/favourite\",\n\t\t\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\t\t\"comments\",\n\t\t\t\t\t\t\t\t\t\t\":comment\",\n\t\t\t\t\t\t\t\t\t\t\"favourite\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"comment\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"11\"\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"response\": []\n\t\t\t\t\t\t}\n\t\t\t\t\t]\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Create article in magazine\",\n\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"POST\",\n\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\"value\": \"application/json\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"body\": {\n\t\t\t\t\t\t\t\"mode\": \"raw\",\n\t\t\t\t\t\t\t\"raw\": \"{\\r\\n    \\\"title\\\": \\\"Test Bot Post\\\",\\r\\n    \\\"body\\\": \\\"I'm making this from the API again, using client credentials, and the role is set too!\\\",\\r\\n    \\\"tags\\\": [\\r\\n        \\\"bot\\\",\\r\\n        \\\"api\\\",\\r\\n        \\\"magazine\\\"\\r\\n    ],\\r\\n    \\\"isAdult\\\": false,\\r\\n    \\\"isOc\\\": true,\\r\\n    \\\"lang\\\": \\\"en\\\"\\r\\n}\",\n\t\t\t\t\t\t\t\"options\": {\n\t\t\t\t\t\t\t\t\"raw\": {\n\t\t\t\t\t\t\t\t\t\"language\": \"json\"\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t},\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/magazine/:magazine_id/article\",\n\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"magazine\",\n\t\t\t\t\t\t\t\t\":magazine_id\",\n\t\t\t\t\t\t\t\t\"article\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"magazine_id\",\n\t\t\t\t\t\t\t\t\t\"value\": \"1\"\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Create link in magazine\",\n\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"POST\",\n\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\"value\": \"application/json\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"body\": {\n\t\t\t\t\t\t\t\"mode\": \"raw\",\n\t\t\t\t\t\t\t\"raw\": \"{\\r\\n    \\\"title\\\": \\\"Test link post\\\",\\r\\n    \\\"tags\\\": [\\r\\n        \\\"bot\\\",\\r\\n        \\\"api\\\",\\r\\n        \\\"magazine\\\"\\r\\n    ],\\r\\n    \\\"isAdult\\\": false,\\r\\n    \\\"isOc\\\": true,\\r\\n    \\\"lang\\\": \\\"en\\\",\\r\\n    \\\"url\\\": \\\"https://i.imgur.com/ThloWfz.jpeg\\\"\\r\\n}\",\n\t\t\t\t\t\t\t\"options\": {\n\t\t\t\t\t\t\t\t\"raw\": {\n\t\t\t\t\t\t\t\t\t\"language\": \"json\"\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t},\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/magazine/:magazine_id/link\",\n\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"magazine\",\n\t\t\t\t\t\t\t\t\":magazine_id\",\n\t\t\t\t\t\t\t\t\"link\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"magazine_id\",\n\t\t\t\t\t\t\t\t\t\"value\": \"1\"\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Create image in magazine\",\n\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"POST\",\n\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\"value\": \"application/json\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"body\": {\n\t\t\t\t\t\t\t\"mode\": \"formdata\",\n\t\t\t\t\t\t\t\"formdata\": [\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"title\",\n\t\t\t\t\t\t\t\t\t\"value\": \"Image posted from the API! 2\",\n\t\t\t\t\t\t\t\t\t\"type\": \"text\"\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"tags[]\",\n\t\t\t\t\t\t\t\t\t\"value\": \"image\",\n\t\t\t\t\t\t\t\t\t\"type\": \"text\"\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"tags[]\",\n\t\t\t\t\t\t\t\t\t\"value\": \"api\",\n\t\t\t\t\t\t\t\t\t\"type\": \"text\"\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"badges[]\",\n\t\t\t\t\t\t\t\t\t\"value\": \"\",\n\t\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\t\"disabled\": true\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"isOc\",\n\t\t\t\t\t\t\t\t\t\"value\": \"false\",\n\t\t\t\t\t\t\t\t\t\"type\": \"text\"\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"lang\",\n\t\t\t\t\t\t\t\t\t\"value\": \"en\",\n\t\t\t\t\t\t\t\t\t\"type\": \"text\"\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"isAdult\",\n\t\t\t\t\t\t\t\t\t\"value\": \"false\",\n\t\t\t\t\t\t\t\t\t\"type\": \"text\"\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"alt\",\n\t\t\t\t\t\t\t\t\t\"value\": \"An image of a cat\",\n\t\t\t\t\t\t\t\t\t\"type\": \"text\"\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"uploadImage\",\n\t\t\t\t\t\t\t\t\t\"type\": \"file\",\n\t\t\t\t\t\t\t\t\t\"src\": \"/D:/Users/Ryan/Pictures/concerned_cat.png\"\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t},\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/magazine/:magazine_id/image\",\n\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"magazine\",\n\t\t\t\t\t\t\t\t\":magazine_id\",\n\t\t\t\t\t\t\t\t\"image\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"magazine_id\",\n\t\t\t\t\t\t\t\t\t\"value\": \"1\"\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Get entries in magazine\",\n\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"GET\",\n\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/magazine/:magazine_id/entries?sort=hot&time=∞\",\n\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"magazine\",\n\t\t\t\t\t\t\t\t\":magazine_id\",\n\t\t\t\t\t\t\t\t\"entries\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"query\": [\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"sort\",\n\t\t\t\t\t\t\t\t\t\"value\": \"hot\"\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"time\",\n\t\t\t\t\t\t\t\t\t\"value\": \"∞\"\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"magazine_id\",\n\t\t\t\t\t\t\t\t\t\"value\": \"1\"\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Get entries in domain\",\n\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"GET\",\n\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/domain/:domain_id/entries?sort=hot&time=∞&p=1&perPage=25\",\n\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"domain\",\n\t\t\t\t\t\t\t\t\":domain_id\",\n\t\t\t\t\t\t\t\t\"entries\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"query\": [\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"sort\",\n\t\t\t\t\t\t\t\t\t\"value\": \"hot\"\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"time\",\n\t\t\t\t\t\t\t\t\t\"value\": \"∞\"\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"p\",\n\t\t\t\t\t\t\t\t\t\"value\": \"1\"\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"perPage\",\n\t\t\t\t\t\t\t\t\t\"value\": \"25\"\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"domain_id\",\n\t\t\t\t\t\t\t\t\t\"value\": \"1\"\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Get entries from user\",\n\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"GET\",\n\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/users/:user_id/entries?sort=hot&time=∞&p=1&perPage=10\",\n\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"users\",\n\t\t\t\t\t\t\t\t\":user_id\",\n\t\t\t\t\t\t\t\t\"entries\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"query\": [\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"sort\",\n\t\t\t\t\t\t\t\t\t\"value\": \"hot\"\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"time\",\n\t\t\t\t\t\t\t\t\t\"value\": \"∞\"\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"p\",\n\t\t\t\t\t\t\t\t\t\"value\": \"1\"\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"perPage\",\n\t\t\t\t\t\t\t\t\t\"value\": \"10\"\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"user_id\",\n\t\t\t\t\t\t\t\t\t\"value\": \"1\"\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Get entries in magazine anonymous\",\n\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"auth\": {\n\t\t\t\t\t\t\t\"type\": \"noauth\"\n\t\t\t\t\t\t},\n\t\t\t\t\t\t\"method\": \"GET\",\n\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/magazine/:magazine_id/entries\",\n\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"magazine\",\n\t\t\t\t\t\t\t\t\":magazine_id\",\n\t\t\t\t\t\t\t\t\"entries\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"magazine_id\",\n\t\t\t\t\t\t\t\t\t\"value\": \"1\"\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Get entries in instance\",\n\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"GET\",\n\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\",\n\t\t\t\t\t\t\t\t\"type\": \"text\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/entries?sort=hot&time=∞&p=1&perPage=10\",\n\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"entries\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"query\": [\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"sort\",\n\t\t\t\t\t\t\t\t\t\"value\": \"hot\"\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"time\",\n\t\t\t\t\t\t\t\t\t\"value\": \"∞\"\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"p\",\n\t\t\t\t\t\t\t\t\t\"value\": \"1\"\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"perPage\",\n\t\t\t\t\t\t\t\t\t\"value\": \"10\"\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Get entries in subscribed magazines\",\n\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"GET\",\n\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\",\n\t\t\t\t\t\t\t\t\"type\": \"text\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/entries/subscribed?sort=hot&time=∞&p=1&perPage=10\",\n\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"entries\",\n\t\t\t\t\t\t\t\t\"subscribed\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"query\": [\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"sort\",\n\t\t\t\t\t\t\t\t\t\"value\": \"hot\"\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"time\",\n\t\t\t\t\t\t\t\t\t\"value\": \"∞\"\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"p\",\n\t\t\t\t\t\t\t\t\t\"value\": \"1\"\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"perPage\",\n\t\t\t\t\t\t\t\t\t\"value\": \"10\"\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Get entries in moderated magazines\",\n\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"GET\",\n\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\",\n\t\t\t\t\t\t\t\t\"type\": \"text\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/entries/moderated?sort=hot&time=∞&p=1&perPage=10\",\n\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"entries\",\n\t\t\t\t\t\t\t\t\"moderated\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"query\": [\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"sort\",\n\t\t\t\t\t\t\t\t\t\"value\": \"hot\"\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"time\",\n\t\t\t\t\t\t\t\t\t\"value\": \"∞\"\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"p\",\n\t\t\t\t\t\t\t\t\t\"value\": \"1\"\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"perPage\",\n\t\t\t\t\t\t\t\t\t\"value\": \"10\"\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Get favourited entries\",\n\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"GET\",\n\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\",\n\t\t\t\t\t\t\t\t\"type\": \"text\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/entries/favourited?sort=hot&time=∞&p=1&perPage=10\",\n\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"entries\",\n\t\t\t\t\t\t\t\t\"favourited\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"query\": [\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"sort\",\n\t\t\t\t\t\t\t\t\t\"value\": \"hot\"\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"time\",\n\t\t\t\t\t\t\t\t\t\"value\": \"∞\"\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"p\",\n\t\t\t\t\t\t\t\t\t\"value\": \"1\"\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"perPage\",\n\t\t\t\t\t\t\t\t\t\"value\": \"10\"\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Get entry by id\",\n\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"GET\",\n\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/entry/:entry_id\",\n\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"entry\",\n\t\t\t\t\t\t\t\t\":entry_id\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"entry_id\",\n\t\t\t\t\t\t\t\t\t\"value\": \"60405\"\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Update entry by id\",\n\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"PUT\",\n\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"body\": {\n\t\t\t\t\t\t\t\"mode\": \"raw\",\n\t\t\t\t\t\t\t\"raw\": \"{\\r\\n    \\\"body\\\": \\\"Test body updated again\\\"\\r\\n}\",\n\t\t\t\t\t\t\t\"options\": {\n\t\t\t\t\t\t\t\t\"raw\": {\n\t\t\t\t\t\t\t\t\t\"language\": \"json\"\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t},\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/entry/:entry\",\n\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"entry\",\n\t\t\t\t\t\t\t\t\":entry\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"entry\",\n\t\t\t\t\t\t\t\t\t\"value\": \"1\"\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Report entry by id\",\n\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"POST\",\n\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"body\": {\n\t\t\t\t\t\t\t\"mode\": \"raw\",\n\t\t\t\t\t\t\t\"raw\": \"{\\r\\n    \\\"reason\\\": \\\"It's a terrible meme\\\"\\r\\n}\",\n\t\t\t\t\t\t\t\"options\": {\n\t\t\t\t\t\t\t\t\"raw\": {\n\t\t\t\t\t\t\t\t\t\"language\": \"json\"\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t},\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/entry/:entry/report\",\n\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"entry\",\n\t\t\t\t\t\t\t\t\":entry\",\n\t\t\t\t\t\t\t\t\"report\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"entry\",\n\t\t\t\t\t\t\t\t\t\"value\": \"1\"\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Delete entry by id\",\n\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"DELETE\",\n\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/entry/:entry\",\n\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"entry\",\n\t\t\t\t\t\t\t\t\":entry\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"entry\",\n\t\t\t\t\t\t\t\t\t\"value\": \"5\"\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Vote entry by id\",\n\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"PUT\",\n\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/entry/:entry/vote/:vote\",\n\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"entry\",\n\t\t\t\t\t\t\t\t\":entry\",\n\t\t\t\t\t\t\t\t\"vote\",\n\t\t\t\t\t\t\t\t\":vote\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"entry\",\n\t\t\t\t\t\t\t\t\t\"value\": \"5\"\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"vote\",\n\t\t\t\t\t\t\t\t\t\"value\": \"1\"\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Favourite entry by id\",\n\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"PUT\",\n\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/entry/:entry/favourite\",\n\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"entry\",\n\t\t\t\t\t\t\t\t\":entry\",\n\t\t\t\t\t\t\t\t\"favourite\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"entry\",\n\t\t\t\t\t\t\t\t\t\"value\": \"5\"\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t}\n\t\t\t]\n\t\t},\n\t\t{\n\t\t\t\"name\": \"Instance\",\n\t\t\t\"item\": [\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Admin\",\n\t\t\t\t\t\"item\": [\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"name\": \"Get instance settings\",\n\t\t\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\t\t\"method\": \"GET\",\n\t\t\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/instance/settings\",\n\t\t\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\t\t\"instance\",\n\t\t\t\t\t\t\t\t\t\t\"settings\"\n\t\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"response\": []\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"name\": \"Update instance settings\",\n\t\t\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\t\t\"method\": \"PUT\",\n\t\t\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\"body\": {\n\t\t\t\t\t\t\t\t\t\"mode\": \"raw\",\n\t\t\t\t\t\t\t\t\t\"raw\": \"{\\r\\n    \\\"KBIN_ADMIN_ONLY_OAUTH_CLIENTS\\\": false\\r\\n}\",\n\t\t\t\t\t\t\t\t\t\"options\": {\n\t\t\t\t\t\t\t\t\t\t\"raw\": {\n\t\t\t\t\t\t\t\t\t\t\t\"language\": \"json\"\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/instance/settings\",\n\t\t\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\t\t\"instance\",\n\t\t\t\t\t\t\t\t\t\t\"settings\"\n\t\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"response\": []\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"name\": \"Update instance about page\",\n\t\t\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\t\t\"method\": \"PUT\",\n\t\t\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\"body\": {\n\t\t\t\t\t\t\t\t\t\"mode\": \"raw\",\n\t\t\t\t\t\t\t\t\t\"raw\": \"{\\r\\n    \\\"body\\\": \\\"about\\\"\\r\\n}\",\n\t\t\t\t\t\t\t\t\t\"options\": {\n\t\t\t\t\t\t\t\t\t\t\"raw\": {\n\t\t\t\t\t\t\t\t\t\t\t\"language\": \"json\"\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/instance/about\",\n\t\t\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\t\t\"instance\",\n\t\t\t\t\t\t\t\t\t\t\"about\"\n\t\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"response\": []\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"name\": \"Update instance contact page\",\n\t\t\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\t\t\"method\": \"PUT\",\n\t\t\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\"body\": {\n\t\t\t\t\t\t\t\t\t\"mode\": \"raw\",\n\t\t\t\t\t\t\t\t\t\"raw\": \"{\\r\\n    \\\"body\\\": \\\"contact\\\"\\r\\n}\",\n\t\t\t\t\t\t\t\t\t\"options\": {\n\t\t\t\t\t\t\t\t\t\t\"raw\": {\n\t\t\t\t\t\t\t\t\t\t\t\"language\": \"json\"\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/instance/contact\",\n\t\t\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\t\t\"instance\",\n\t\t\t\t\t\t\t\t\t\t\"contact\"\n\t\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"response\": []\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"name\": \"Update instance faq page\",\n\t\t\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\t\t\"method\": \"PUT\",\n\t\t\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\"body\": {\n\t\t\t\t\t\t\t\t\t\"mode\": \"raw\",\n\t\t\t\t\t\t\t\t\t\"raw\": \"{\\r\\n    \\\"body\\\": \\\"Frequently asked questions\\\"\\r\\n}\",\n\t\t\t\t\t\t\t\t\t\"options\": {\n\t\t\t\t\t\t\t\t\t\t\"raw\": {\n\t\t\t\t\t\t\t\t\t\t\t\"language\": \"json\"\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/instance/faq\",\n\t\t\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\t\t\"instance\",\n\t\t\t\t\t\t\t\t\t\t\"faq\"\n\t\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"response\": []\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"name\": \"Update instance privacy policy page\",\n\t\t\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\t\t\"method\": \"PUT\",\n\t\t\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\"body\": {\n\t\t\t\t\t\t\t\t\t\"mode\": \"raw\",\n\t\t\t\t\t\t\t\t\t\"raw\": \"{\\r\\n    \\\"body\\\": \\\"privacy policy\\\"\\r\\n}\",\n\t\t\t\t\t\t\t\t\t\"options\": {\n\t\t\t\t\t\t\t\t\t\t\"raw\": {\n\t\t\t\t\t\t\t\t\t\t\t\"language\": \"json\"\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/instance/privacyPolicy\",\n\t\t\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\t\t\"instance\",\n\t\t\t\t\t\t\t\t\t\t\"privacyPolicy\"\n\t\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"response\": []\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"name\": \"Update instance terms page\",\n\t\t\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\t\t\"method\": \"PUT\",\n\t\t\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\"body\": {\n\t\t\t\t\t\t\t\t\t\"mode\": \"raw\",\n\t\t\t\t\t\t\t\t\t\"raw\": \"{\\r\\n    \\\"body\\\": \\\"terms\\\"\\r\\n}\",\n\t\t\t\t\t\t\t\t\t\"options\": {\n\t\t\t\t\t\t\t\t\t\t\"raw\": {\n\t\t\t\t\t\t\t\t\t\t\t\"language\": \"json\"\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/instance/terms\",\n\t\t\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\t\t\"instance\",\n\t\t\t\t\t\t\t\t\t\t\"terms\"\n\t\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"response\": []\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"name\": \"Update instance federation list\",\n\t\t\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\t\t\"method\": \"PUT\",\n\t\t\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\"body\": {\n\t\t\t\t\t\t\t\t\t\"mode\": \"raw\",\n\t\t\t\t\t\t\t\t\t\"raw\": \"{\\r\\n    \\\"instances\\\": [\\r\\n        \\\"www.lemmygrad.ml\\\",\\r\\n        \\\"exploding-heads.com\\\"\\r\\n    ]\\r\\n}\",\n\t\t\t\t\t\t\t\t\t\"options\": {\n\t\t\t\t\t\t\t\t\t\t\"raw\": {\n\t\t\t\t\t\t\t\t\t\t\t\"language\": \"json\"\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/defederated\",\n\t\t\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\t\t\"defederated\"\n\t\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"response\": []\n\t\t\t\t\t\t}\n\t\t\t\t\t]\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Get instance info\",\n\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"GET\",\n\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/instance\",\n\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"instance\"\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Get instance modlog\",\n\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"GET\",\n\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/modlog\",\n\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"modlog\"\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Get instance view stats\",\n\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"GET\",\n\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/stats/views?resolution=day&start=2023-07-01T00:00:00\",\n\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"stats\",\n\t\t\t\t\t\t\t\t\"views\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"query\": [\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"resolution\",\n\t\t\t\t\t\t\t\t\t\"value\": \"day\"\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"start\",\n\t\t\t\t\t\t\t\t\t\"value\": \"2023-07-01T00:00:00\"\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Get instance vote stats\",\n\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"GET\",\n\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/stats/votes?resolution=year\",\n\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"stats\",\n\t\t\t\t\t\t\t\t\"votes\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"query\": [\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"resolution\",\n\t\t\t\t\t\t\t\t\t\"value\": \"year\"\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Get instance content stats\",\n\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"GET\",\n\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/stats/content?resolution=year\",\n\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"stats\",\n\t\t\t\t\t\t\t\t\"content\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"query\": [\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"resolution\",\n\t\t\t\t\t\t\t\t\t\"value\": \"year\"\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Get instance federation list\",\n\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"GET\",\n\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/defederated\",\n\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"defederated\"\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t}\n\t\t\t]\n\t\t},\n\t\t{\n\t\t\t\"name\": \"Magazine\",\n\t\t\t\"item\": [\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Admin\",\n\t\t\t\t\t\"item\": [\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"name\": \"Create magazine\",\n\t\t\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\t\t\"method\": \"POST\",\n\t\t\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\"body\": {\n\t\t\t\t\t\t\t\t\t\"mode\": \"raw\",\n\t\t\t\t\t\t\t\t\t\"raw\": \"{\\r\\n  \\\"name\\\": \\\"apimade\\\",\\r\\n  \\\"title\\\": \\\"Created using the API\\\",\\r\\n  \\\"description\\\": \\\"A magazine created on the API\\\",\\r\\n  \\\"rules\\\": \\\"Anarchy\\\",\\r\\n  \\\"isAdult\\\": false\\r\\n}\",\n\t\t\t\t\t\t\t\t\t\"options\": {\n\t\t\t\t\t\t\t\t\t\t\"raw\": {\n\t\t\t\t\t\t\t\t\t\t\t\"language\": \"json\"\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/moderate/magazine/new\",\n\t\t\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\t\t\"moderate\",\n\t\t\t\t\t\t\t\t\t\t\"magazine\",\n\t\t\t\t\t\t\t\t\t\t\"new\"\n\t\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"response\": []\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"name\": \"Update magazine\",\n\t\t\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\t\t\"method\": \"PUT\",\n\t\t\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\"body\": {\n\t\t\t\t\t\t\t\t\t\"mode\": \"raw\",\n\t\t\t\t\t\t\t\t\t\"raw\": \"{\\r\\n  \\\"title\\\": \\\"title\\\",\\r\\n  \\\"description\\\": \\\"description\\\",\\r\\n  \\\"rules\\\": \\\"rules\\\",\\r\\n  \\\"isAdult\\\": true\\r\\n}\",\n\t\t\t\t\t\t\t\t\t\"options\": {\n\t\t\t\t\t\t\t\t\t\t\"raw\": {\n\t\t\t\t\t\t\t\t\t\t\t\"language\": \"json\"\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/moderate/magazine/:magazine_id\",\n\t\t\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\t\t\"moderate\",\n\t\t\t\t\t\t\t\t\t\t\"magazine\",\n\t\t\t\t\t\t\t\t\t\t\":magazine_id\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"magazine_id\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"7\"\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"response\": []\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"name\": \"Update magazine theme\",\n\t\t\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\t\t\"method\": \"POST\",\n\t\t\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\"body\": {\n\t\t\t\t\t\t\t\t\t\"mode\": \"formdata\",\n\t\t\t\t\t\t\t\t\t\"formdata\": [\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"customCss\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"\",\n\t\t\t\t\t\t\t\t\t\t\t\"description\": \"Custom css to be applied to the magazine\",\n\t\t\t\t\t\t\t\t\t\t\t\"type\": \"text\"\n\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"backgroundImage\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"\",\n\t\t\t\t\t\t\t\t\t\t\t\"description\": \"One of 'shape1' or 'shape2' or empty\",\n\t\t\t\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\t\t\t\"disabled\": true\n\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"uploadImage\",\n\t\t\t\t\t\t\t\t\t\t\t\"description\": \"The icon of the magazine\",\n\t\t\t\t\t\t\t\t\t\t\t\"type\": \"file\",\n\t\t\t\t\t\t\t\t\t\t\t\"src\": [],\n\t\t\t\t\t\t\t\t\t\t\t\"disabled\": true\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/moderate/magazine/:magazine_id/theme\",\n\t\t\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\t\t\"moderate\",\n\t\t\t\t\t\t\t\t\t\t\"magazine\",\n\t\t\t\t\t\t\t\t\t\t\":magazine_id\",\n\t\t\t\t\t\t\t\t\t\t\"theme\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"magazine_id\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"1\"\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"response\": []\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"name\": \"Delete magazine\",\n\t\t\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\t\t\"method\": \"DELETE\",\n\t\t\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/moderate/magazine/:magazine_id\",\n\t\t\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\t\t\"moderate\",\n\t\t\t\t\t\t\t\t\t\t\"magazine\",\n\t\t\t\t\t\t\t\t\t\t\":magazine_id\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"magazine_id\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"4\"\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"response\": []\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"name\": \"Delete magazine icon\",\n\t\t\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\t\t\"method\": \"DELETE\",\n\t\t\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/moderate/magazine/:magazine_id/icon\",\n\t\t\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\t\t\"moderate\",\n\t\t\t\t\t\t\t\t\t\t\"magazine\",\n\t\t\t\t\t\t\t\t\t\t\":magazine_id\",\n\t\t\t\t\t\t\t\t\t\t\"icon\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"magazine_id\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"4\"\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"response\": []\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"name\": \"Purge magazine\",\n\t\t\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\t\t\"method\": \"DELETE\",\n\t\t\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/admin/magazine/:magazine_id/purge\",\n\t\t\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\t\t\"admin\",\n\t\t\t\t\t\t\t\t\t\t\"magazine\",\n\t\t\t\t\t\t\t\t\t\t\":magazine_id\",\n\t\t\t\t\t\t\t\t\t\t\"purge\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"magazine_id\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"7\"\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"response\": []\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"name\": \"Add moderator to magazine\",\n\t\t\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\t\t\"method\": \"POST\",\n\t\t\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/moderate/magazine/:magazine_id/mod/:user_id\",\n\t\t\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\t\t\"moderate\",\n\t\t\t\t\t\t\t\t\t\t\"magazine\",\n\t\t\t\t\t\t\t\t\t\t\":magazine_id\",\n\t\t\t\t\t\t\t\t\t\t\"mod\",\n\t\t\t\t\t\t\t\t\t\t\":user_id\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"magazine_id\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"2\"\n\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"user_id\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"3\"\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"response\": []\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"name\": \"Remove moderator from magazine\",\n\t\t\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\t\t\"method\": \"DELETE\",\n\t\t\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/moderate/magazine/:magazine_id/mod/:user_id\",\n\t\t\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\t\t\"moderate\",\n\t\t\t\t\t\t\t\t\t\t\"magazine\",\n\t\t\t\t\t\t\t\t\t\t\":magazine_id\",\n\t\t\t\t\t\t\t\t\t\t\"mod\",\n\t\t\t\t\t\t\t\t\t\t\":user_id\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"magazine_id\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"2\"\n\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"user_id\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"3\"\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"response\": []\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"name\": \"Add badge to magazine\",\n\t\t\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\t\t\"method\": \"POST\",\n\t\t\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\"body\": {\n\t\t\t\t\t\t\t\t\t\"mode\": \"raw\",\n\t\t\t\t\t\t\t\t\t\"raw\": \"{\\r\\n    \\\"name\\\": \\\"good\\\"\\r\\n}\",\n\t\t\t\t\t\t\t\t\t\"options\": {\n\t\t\t\t\t\t\t\t\t\t\"raw\": {\n\t\t\t\t\t\t\t\t\t\t\t\"language\": \"json\"\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/moderate/magazine/:magazine_id/badge\",\n\t\t\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\t\t\"moderate\",\n\t\t\t\t\t\t\t\t\t\t\"magazine\",\n\t\t\t\t\t\t\t\t\t\t\":magazine_id\",\n\t\t\t\t\t\t\t\t\t\t\"badge\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"magazine_id\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"2\"\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"response\": []\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"name\": \"Remove badge from magazine\",\n\t\t\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\t\t\"method\": \"DELETE\",\n\t\t\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/moderate/magazine/:magazine_id/badge/:badge_id\",\n\t\t\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\t\t\"moderate\",\n\t\t\t\t\t\t\t\t\t\t\"magazine\",\n\t\t\t\t\t\t\t\t\t\t\":magazine_id\",\n\t\t\t\t\t\t\t\t\t\t\"badge\",\n\t\t\t\t\t\t\t\t\t\t\":badge_id\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"magazine_id\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"2\"\n\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"badge_id\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"3\"\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"response\": []\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"name\": \"Add tag to magazine\",\n\t\t\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\t\t\"method\": \"POST\",\n\t\t\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/moderate/magazine/:magazine_id/tag/:tag\",\n\t\t\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\t\t\"moderate\",\n\t\t\t\t\t\t\t\t\t\t\"magazine\",\n\t\t\t\t\t\t\t\t\t\t\":magazine_id\",\n\t\t\t\t\t\t\t\t\t\t\"tag\",\n\t\t\t\t\t\t\t\t\t\t\":tag\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"magazine_id\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"2\"\n\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"tag\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"sometag\"\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"response\": []\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"name\": \"Remove tag from magazine\",\n\t\t\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\t\t\"method\": \"DELETE\",\n\t\t\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/moderate/magazine/:magazine_id/tag/:tag\",\n\t\t\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\t\t\"moderate\",\n\t\t\t\t\t\t\t\t\t\t\"magazine\",\n\t\t\t\t\t\t\t\t\t\t\":magazine_id\",\n\t\t\t\t\t\t\t\t\t\t\"tag\",\n\t\t\t\t\t\t\t\t\t\t\":tag\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"magazine_id\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"2\"\n\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"tag\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"sometag\"\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"response\": []\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"name\": \"Get views from magazine\",\n\t\t\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\t\t\"method\": \"GET\",\n\t\t\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/stats/magazine/:magazine_id/views?start=2023-06-02T00:00:00&end=2023-07-08T23:59:59&resolution=hour&local=false\",\n\t\t\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\t\t\"stats\",\n\t\t\t\t\t\t\t\t\t\t\"magazine\",\n\t\t\t\t\t\t\t\t\t\t\":magazine_id\",\n\t\t\t\t\t\t\t\t\t\t\"views\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"query\": [\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"start\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"2023-06-02T00:00:00\"\n\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"end\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"2023-07-08T23:59:59\"\n\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"resolution\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"hour\"\n\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"local\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"false\"\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"magazine_id\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"1\"\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"response\": []\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"name\": \"Get votes from magazine\",\n\t\t\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\t\t\"method\": \"GET\",\n\t\t\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/stats/magazine/:magazine_id/votes?start=2023-06-02T00:00:00&end=2023-07-08T23:59:59&resolution=all&local=false\",\n\t\t\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\t\t\"stats\",\n\t\t\t\t\t\t\t\t\t\t\"magazine\",\n\t\t\t\t\t\t\t\t\t\t\":magazine_id\",\n\t\t\t\t\t\t\t\t\t\t\"votes\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"query\": [\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"start\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"2023-06-02T00:00:00\"\n\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"end\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"2023-07-08T23:59:59\"\n\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"resolution\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"all\"\n\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"local\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"false\"\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"magazine_id\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"1\"\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"response\": []\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"name\": \"Get submission stats from magazine\",\n\t\t\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\t\t\"method\": \"GET\",\n\t\t\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/stats/magazine/:magazine_id/content?start=2023-06-02T00:00:00&end=2023-07-09T23:59:59&resolution=hour&local=false\",\n\t\t\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\t\t\"stats\",\n\t\t\t\t\t\t\t\t\t\t\"magazine\",\n\t\t\t\t\t\t\t\t\t\t\":magazine_id\",\n\t\t\t\t\t\t\t\t\t\t\"content\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"query\": [\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"start\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"2023-06-02T00:00:00\"\n\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"end\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"2023-07-09T23:59:59\"\n\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"resolution\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"hour\"\n\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"local\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"false\"\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"magazine_id\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"1\"\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"response\": []\n\t\t\t\t\t\t}\n\t\t\t\t\t]\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Moderate\",\n\t\t\t\t\t\"item\": [\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"name\": \"Get reports in magazine\",\n\t\t\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\t\t\"method\": \"GET\",\n\t\t\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/moderate/magazine/:magazine_id/reports?status=approved\",\n\t\t\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\t\t\"moderate\",\n\t\t\t\t\t\t\t\t\t\t\"magazine\",\n\t\t\t\t\t\t\t\t\t\t\":magazine_id\",\n\t\t\t\t\t\t\t\t\t\t\"reports\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"query\": [\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"status\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"approved\"\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"magazine_id\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"2\"\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"response\": []\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"name\": \"Get report by id in magazine\",\n\t\t\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\t\t\"method\": \"GET\",\n\t\t\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/moderate/magazine/:magazine_id/reports/:report_id\",\n\t\t\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\t\t\"moderate\",\n\t\t\t\t\t\t\t\t\t\t\"magazine\",\n\t\t\t\t\t\t\t\t\t\t\":magazine_id\",\n\t\t\t\t\t\t\t\t\t\t\"reports\",\n\t\t\t\t\t\t\t\t\t\t\":report_id\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"magazine_id\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"1\"\n\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"report_id\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"7\"\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"response\": []\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"name\": \"Accept report by id in magazine\",\n\t\t\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\t\t\"method\": \"POST\",\n\t\t\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/moderate/magazine/:magazine_id/reports/:report_id/accept\",\n\t\t\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\t\t\"moderate\",\n\t\t\t\t\t\t\t\t\t\t\"magazine\",\n\t\t\t\t\t\t\t\t\t\t\":magazine_id\",\n\t\t\t\t\t\t\t\t\t\t\"reports\",\n\t\t\t\t\t\t\t\t\t\t\":report_id\",\n\t\t\t\t\t\t\t\t\t\t\"accept\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"magazine_id\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"2\"\n\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"report_id\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"6\"\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"response\": []\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"name\": \"Reject report by id in magazine\",\n\t\t\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\t\t\"method\": \"POST\",\n\t\t\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/moderate/magazine/:magazine_id/reports/:report_id/reject\",\n\t\t\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\t\t\"moderate\",\n\t\t\t\t\t\t\t\t\t\t\"magazine\",\n\t\t\t\t\t\t\t\t\t\t\":magazine_id\",\n\t\t\t\t\t\t\t\t\t\t\"reports\",\n\t\t\t\t\t\t\t\t\t\t\":report_id\",\n\t\t\t\t\t\t\t\t\t\t\"reject\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"magazine_id\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"1\"\n\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"report_id\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"7\"\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"response\": []\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"name\": \"Ban user from magazine\",\n\t\t\t\t\t\t\t\"event\": [\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"listen\": \"prerequest\",\n\t\t\t\t\t\t\t\t\t\"script\": {\n\t\t\t\t\t\t\t\t\t\t\"exec\": [\n\t\t\t\t\t\t\t\t\t\t\t\"var time = new Date();\\r\",\n\t\t\t\t\t\t\t\t\t\t\t\"time.setMinutes(time.getMinutes() + 5);\\r\",\n\t\t\t\t\t\t\t\t\t\t\t\"\\r\",\n\t\t\t\t\t\t\t\t\t\t\t\"pm.environment.set('banExpiry', time.toISOString());\"\n\t\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\t\"type\": \"text/javascript\"\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\t\t\"method\": \"POST\",\n\t\t\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\"body\": {\n\t\t\t\t\t\t\t\t\t\"mode\": \"raw\",\n\t\t\t\t\t\t\t\t\t\"raw\": \"{\\r\\n  \\\"reason\\\": \\\"Testing\\\",\\r\\n  \\\"expiredAt\\\": \\\"{{banExpiry}}\\\"\\r\\n}\",\n\t\t\t\t\t\t\t\t\t\"options\": {\n\t\t\t\t\t\t\t\t\t\t\"raw\": {\n\t\t\t\t\t\t\t\t\t\t\t\"language\": \"json\"\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/moderate/magazine/:magazine_id/ban/:user_id\",\n\t\t\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\t\t\"moderate\",\n\t\t\t\t\t\t\t\t\t\t\"magazine\",\n\t\t\t\t\t\t\t\t\t\t\":magazine_id\",\n\t\t\t\t\t\t\t\t\t\t\"ban\",\n\t\t\t\t\t\t\t\t\t\t\":user_id\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"magazine_id\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"1\"\n\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"user_id\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"3\"\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"response\": []\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"name\": \"Unban user from magazine\",\n\t\t\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\t\t\"method\": \"DELETE\",\n\t\t\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/moderate/magazine/:magazine_id/ban/:user_id\",\n\t\t\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\t\t\"moderate\",\n\t\t\t\t\t\t\t\t\t\t\"magazine\",\n\t\t\t\t\t\t\t\t\t\t\":magazine_id\",\n\t\t\t\t\t\t\t\t\t\t\"ban\",\n\t\t\t\t\t\t\t\t\t\t\":user_id\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"magazine_id\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"1\"\n\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"user_id\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"3\"\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"response\": []\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"name\": \"Get bans in magazine\",\n\t\t\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\t\t\"method\": \"GET\",\n\t\t\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/moderate/magazine/:magazine_id/bans\",\n\t\t\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\t\t\"moderate\",\n\t\t\t\t\t\t\t\t\t\t\"magazine\",\n\t\t\t\t\t\t\t\t\t\t\":magazine_id\",\n\t\t\t\t\t\t\t\t\t\t\"bans\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"magazine_id\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"2\"\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"response\": []\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"name\": \"Get trash in magazine\",\n\t\t\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\t\t\"method\": \"GET\",\n\t\t\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/moderate/magazine/:magazine_id/trash\",\n\t\t\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\t\t\"moderate\",\n\t\t\t\t\t\t\t\t\t\t\"magazine\",\n\t\t\t\t\t\t\t\t\t\t\":magazine_id\",\n\t\t\t\t\t\t\t\t\t\t\"trash\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"magazine_id\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"1\"\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"response\": []\n\t\t\t\t\t\t}\n\t\t\t\t\t]\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Get magazine by id\",\n\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"GET\",\n\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/magazine/:magazine_id\",\n\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"magazine\",\n\t\t\t\t\t\t\t\t\":magazine_id\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"magazine_id\",\n\t\t\t\t\t\t\t\t\t\"value\": \"2\"\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Get magazine theme by id\",\n\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"GET\",\n\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/magazine/:magazine_id/theme\",\n\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"magazine\",\n\t\t\t\t\t\t\t\t\":magazine_id\",\n\t\t\t\t\t\t\t\t\"theme\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"magazine_id\",\n\t\t\t\t\t\t\t\t\t\"value\": \"2\"\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Get magazine by name\",\n\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"GET\",\n\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/magazine/name/:magazine_name\",\n\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"magazine\",\n\t\t\t\t\t\t\t\t\"name\",\n\t\t\t\t\t\t\t\t\":magazine_name\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"magazine_name\",\n\t\t\t\t\t\t\t\t\t\"value\": \"testing\"\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Get magazine moderation log\",\n\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"GET\",\n\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/magazine/:magazine_id/log\",\n\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"magazine\",\n\t\t\t\t\t\t\t\t\":magazine_id\",\n\t\t\t\t\t\t\t\t\"log\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"magazine_id\",\n\t\t\t\t\t\t\t\t\t\"value\": \"1\"\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Block magazine by id\",\n\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"PUT\",\n\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/magazine/:magazine_id/block\",\n\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"magazine\",\n\t\t\t\t\t\t\t\t\":magazine_id\",\n\t\t\t\t\t\t\t\t\"block\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"magazine_id\",\n\t\t\t\t\t\t\t\t\t\"value\": \"2\"\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Unblock magazine by id\",\n\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"PUT\",\n\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/magazine/:magazine_id/unblock\",\n\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"magazine\",\n\t\t\t\t\t\t\t\t\":magazine_id\",\n\t\t\t\t\t\t\t\t\"unblock\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"magazine_id\",\n\t\t\t\t\t\t\t\t\t\"value\": \"2\"\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Subscribe to magazine by id\",\n\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"PUT\",\n\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/magazine/:magazine_id/subscribe\",\n\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"magazine\",\n\t\t\t\t\t\t\t\t\":magazine_id\",\n\t\t\t\t\t\t\t\t\"subscribe\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"magazine_id\",\n\t\t\t\t\t\t\t\t\t\"value\": \"2\"\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Unsubscribe to magazine by id\",\n\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"PUT\",\n\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/magazine/:magazine_id/unsubscribe\",\n\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"magazine\",\n\t\t\t\t\t\t\t\t\":magazine_id\",\n\t\t\t\t\t\t\t\t\"unsubscribe\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"magazine_id\",\n\t\t\t\t\t\t\t\t\t\"value\": \"2\"\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Get magazines\",\n\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"GET\",\n\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/magazines?p=1&perPage=10&sort=active&federation=all&hide_adult=hide\",\n\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"magazines\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"query\": [\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"p\",\n\t\t\t\t\t\t\t\t\t\"value\": \"1\"\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"perPage\",\n\t\t\t\t\t\t\t\t\t\"value\": \"10\"\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"q\",\n\t\t\t\t\t\t\t\t\t\"value\": null,\n\t\t\t\t\t\t\t\t\t\"disabled\": true\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"sort\",\n\t\t\t\t\t\t\t\t\t\"value\": \"active\"\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"federation\",\n\t\t\t\t\t\t\t\t\t\"value\": \"all\"\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"hide_adult\",\n\t\t\t\t\t\t\t\t\t\"value\": \"hide\"\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Get subscribed magazines\",\n\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"GET\",\n\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/magazines/subscribed\",\n\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"magazines\",\n\t\t\t\t\t\t\t\t\"subscribed\"\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Get moderated magazines\",\n\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"GET\",\n\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/magazines/moderated\",\n\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"magazines\",\n\t\t\t\t\t\t\t\t\"moderated\"\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Get blocked magazines\",\n\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"GET\",\n\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/magazines/blocked\",\n\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"magazines\",\n\t\t\t\t\t\t\t\t\"blocked\"\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t}\n\t\t\t]\n\t\t},\n\t\t{\n\t\t\t\"name\": \"Message\",\n\t\t\t\"item\": [\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Get message by id\",\n\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"GET\",\n\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/messages/:message_id\",\n\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"messages\",\n\t\t\t\t\t\t\t\t\":message_id\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"message_id\",\n\t\t\t\t\t\t\t\t\t\"value\": \"1\"\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Read message by id\",\n\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"PUT\",\n\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/messages/:message_id/read\",\n\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"messages\",\n\t\t\t\t\t\t\t\t\":message_id\",\n\t\t\t\t\t\t\t\t\"read\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"message_id\",\n\t\t\t\t\t\t\t\t\t\"value\": \"1\"\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Unread message by id\",\n\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"PUT\",\n\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/messages/:message_id/unread\",\n\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"messages\",\n\t\t\t\t\t\t\t\t\":message_id\",\n\t\t\t\t\t\t\t\t\"unread\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"message_id\",\n\t\t\t\t\t\t\t\t\t\"value\": \"1\"\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Get message threads\",\n\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"GET\",\n\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/messages\",\n\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"messages\"\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Get messages in thread by id\",\n\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"GET\",\n\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/messages/thread/:thread_id/:sort?p=1&perPage=25\",\n\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"messages\",\n\t\t\t\t\t\t\t\t\"thread\",\n\t\t\t\t\t\t\t\t\":thread_id\",\n\t\t\t\t\t\t\t\t\":sort\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"query\": [\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"p\",\n\t\t\t\t\t\t\t\t\t\"value\": \"1\"\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"perPage\",\n\t\t\t\t\t\t\t\t\t\"value\": \"25\"\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"thread_id\",\n\t\t\t\t\t\t\t\t\t\"value\": \"1\"\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"sort\",\n\t\t\t\t\t\t\t\t\t\"value\": \"oldest\"\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Reply to thread\",\n\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"POST\",\n\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"body\": {\n\t\t\t\t\t\t\t\"mode\": \"raw\",\n\t\t\t\t\t\t\t\"raw\": \"{\\r\\n    \\\"body\\\": \\\"Message from the API!\\\"\\r\\n}\",\n\t\t\t\t\t\t\t\"options\": {\n\t\t\t\t\t\t\t\t\"raw\": {\n\t\t\t\t\t\t\t\t\t\"language\": \"json\"\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t},\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/messages/thread/:thread_id/reply\",\n\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"messages\",\n\t\t\t\t\t\t\t\t\"thread\",\n\t\t\t\t\t\t\t\t\":thread_id\",\n\t\t\t\t\t\t\t\t\"reply\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"thread_id\",\n\t\t\t\t\t\t\t\t\t\"value\": \"1\"\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Create new thread\",\n\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"POST\",\n\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"body\": {\n\t\t\t\t\t\t\t\"mode\": \"raw\",\n\t\t\t\t\t\t\t\"raw\": \"{\\r\\n    \\\"body\\\": \\\"A new message thread, made by the API!\\\"\\r\\n}\",\n\t\t\t\t\t\t\t\"options\": {\n\t\t\t\t\t\t\t\t\"raw\": {\n\t\t\t\t\t\t\t\t\t\"language\": \"json\"\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t},\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/users/:user_id/message\",\n\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"users\",\n\t\t\t\t\t\t\t\t\":user_id\",\n\t\t\t\t\t\t\t\t\"message\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"user_id\",\n\t\t\t\t\t\t\t\t\t\"value\": \"3\"\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t}\n\t\t\t]\n\t\t},\n\t\t{\n\t\t\t\"name\": \"Notification\",\n\t\t\t\"item\": [\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Get notification by id\",\n\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"GET\",\n\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/notification/:notification_id\",\n\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"notification\",\n\t\t\t\t\t\t\t\t\":notification_id\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"notification_id\",\n\t\t\t\t\t\t\t\t\t\"value\": \"12\"\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Mark notification as read\",\n\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"PUT\",\n\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/notifications/:notification_id/read\",\n\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"notifications\",\n\t\t\t\t\t\t\t\t\":notification_id\",\n\t\t\t\t\t\t\t\t\"read\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"notification_id\",\n\t\t\t\t\t\t\t\t\t\"value\": \"13\"\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Mark notification as unread\",\n\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"PUT\",\n\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/notifications/:notification_id/unread\",\n\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"notifications\",\n\t\t\t\t\t\t\t\t\":notification_id\",\n\t\t\t\t\t\t\t\t\"unread\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"notification_id\",\n\t\t\t\t\t\t\t\t\t\"value\": \"13\"\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Mark all notifications as read\",\n\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"PUT\",\n\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/notifications/read\",\n\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"notifications\",\n\t\t\t\t\t\t\t\t\"read\"\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Delete notification\",\n\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"DELETE\",\n\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/notifications/:notification_id\",\n\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"notifications\",\n\t\t\t\t\t\t\t\t\":notification_id\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"notification_id\",\n\t\t\t\t\t\t\t\t\t\"value\": \"12\"\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Delete all notifications\",\n\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"DELETE\",\n\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/notifications\",\n\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"notifications\"\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Get notifications by status\",\n\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"GET\",\n\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/notifications/:status\",\n\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"notifications\",\n\t\t\t\t\t\t\t\t\":status\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"status\",\n\t\t\t\t\t\t\t\t\t\"value\": \"all\"\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Get unread notifications count\",\n\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"GET\",\n\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/notifications/count\",\n\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"notifications\",\n\t\t\t\t\t\t\t\t\"count\"\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t}\n\t\t\t]\n\t\t},\n\t\t{\n\t\t\t\"name\": \"OAuth2\",\n\t\t\t\"item\": [\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Admin\",\n\t\t\t\t\t\"item\": [\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"name\": \"Get OAuth2 client stats\",\n\t\t\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\t\t\"method\": \"GET\",\n\t\t\t\t\t\t\t\t\"header\": [],\n\t\t\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/clients/stats?resolution=minute&start=2023-07-15T00:00:00&end={{$isoTimestamp}}\",\n\t\t\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\t\t\"clients\",\n\t\t\t\t\t\t\t\t\t\t\"stats\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"query\": [\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"resolution\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"minute\"\n\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"start\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"2023-07-15T00:00:00\"\n\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"end\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"{{$isoTimestamp}}\"\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"response\": []\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"name\": \"Get OAuth2 client by identifier\",\n\t\t\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\t\t\"method\": \"GET\",\n\t\t\t\t\t\t\t\t\"header\": [],\n\t\t\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/clients/:identifier\",\n\t\t\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\t\t\"clients\",\n\t\t\t\t\t\t\t\t\t\t\":identifier\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"identifier\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": null\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"response\": []\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"name\": \"Get OAuth2 clients\",\n\t\t\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\t\t\"method\": \"GET\",\n\t\t\t\t\t\t\t\t\"header\": [],\n\t\t\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/clients\",\n\t\t\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\t\t\"clients\"\n\t\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"response\": []\n\t\t\t\t\t\t}\n\t\t\t\t\t]\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Create OAuth2 Auth Code Application\",\n\t\t\t\t\t\"event\": [\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"listen\": \"test\",\n\t\t\t\t\t\t\t\"script\": {\n\t\t\t\t\t\t\t\t\"exec\": [\n\t\t\t\t\t\t\t\t\t\"pm.test(\\\"Set Client ID and Secret\\\", function () {\\r\",\n\t\t\t\t\t\t\t\t\t\"    pm.response.to.have.status(201);\\r\",\n\t\t\t\t\t\t\t\t\t\"\\r\",\n\t\t\t\t\t\t\t\t\t\"    var jsonData = pm.response.json();\\r\",\n\t\t\t\t\t\t\t\t\t\"    pm.expect(jsonData.identifier).to.not.be.empty;\\r\",\n\t\t\t\t\t\t\t\t\t\"    pm.expect(jsonData.secret).to.not.be.empty;\\r\",\n\t\t\t\t\t\t\t\t\t\"    \\r\",\n\t\t\t\t\t\t\t\t\t\"    pm.environment.set(\\\"oauth_client_id\\\", jsonData.identifier);\\r\",\n\t\t\t\t\t\t\t\t\t\"    pm.environment.set(\\\"oauth_client_secret\\\", jsonData.secret);\\r\",\n\t\t\t\t\t\t\t\t\t\"});\"\n\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\"type\": \"text/javascript\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t],\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"auth\": {\n\t\t\t\t\t\t\t\"type\": \"noauth\"\n\t\t\t\t\t\t},\n\t\t\t\t\t\t\"method\": \"POST\",\n\t\t\t\t\t\t\"header\": [],\n\t\t\t\t\t\t\"body\": {\n\t\t\t\t\t\t\t\"mode\": \"raw\",\n\t\t\t\t\t\t\t\"raw\": \"{\\r\\n    \\\"name\\\": \\\"My private kbin application\\\",\\r\\n    \\\"contactEmail\\\": \\\"contact@some.dev\\\",\\r\\n    \\\"username\\\": null,\\r\\n    \\\"public\\\": false,\\r\\n    \\\"redirectUris\\\": [\\r\\n        \\\"https://localhost:3001\\\"\\r\\n    ],\\r\\n    \\\"grants\\\": [\\r\\n        \\\"authorization_code\\\",\\r\\n        \\\"refresh_token\\\"\\r\\n    ],\\r\\n    \\\"scopes\\\": [\\r\\n        \\\"read\\\",\\r\\n        \\\"write\\\"\\r\\n    ]\\r\\n}\",\n\t\t\t\t\t\t\t\"options\": {\n\t\t\t\t\t\t\t\t\"raw\": {\n\t\t\t\t\t\t\t\t\t\"language\": \"json\"\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t},\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/client\",\n\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"client\"\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Create OAuth2 Public Auth Code Application\",\n\t\t\t\t\t\"event\": [\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"listen\": \"test\",\n\t\t\t\t\t\t\t\"script\": {\n\t\t\t\t\t\t\t\t\"exec\": [\n\t\t\t\t\t\t\t\t\t\"pm.test(\\\"Set Client ID and Secret\\\", function () {\\r\",\n\t\t\t\t\t\t\t\t\t\"    pm.response.to.have.status(201);\\r\",\n\t\t\t\t\t\t\t\t\t\"\\r\",\n\t\t\t\t\t\t\t\t\t\"    var jsonData = pm.response.json();\\r\",\n\t\t\t\t\t\t\t\t\t\"    pm.expect(jsonData.identifier).to.not.be.empty;\\r\",\n\t\t\t\t\t\t\t\t\t\"    pm.expect(jsonData.secret).to.be.empty;\\r\",\n\t\t\t\t\t\t\t\t\t\"    \\r\",\n\t\t\t\t\t\t\t\t\t\"    pm.environment.set(\\\"oauth_public_client_id\\\", jsonData.identifier);\\r\",\n\t\t\t\t\t\t\t\t\t\"});\"\n\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\"type\": \"text/javascript\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t],\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"auth\": {\n\t\t\t\t\t\t\t\"type\": \"noauth\"\n\t\t\t\t\t\t},\n\t\t\t\t\t\t\"method\": \"POST\",\n\t\t\t\t\t\t\"header\": [],\n\t\t\t\t\t\t\"body\": {\n\t\t\t\t\t\t\t\"mode\": \"raw\",\n\t\t\t\t\t\t\t\"raw\": \"{\\r\\n    \\\"name\\\": \\\"My public kbin application\\\",\\r\\n    \\\"contactEmail\\\": \\\"contact@some.dev\\\",\\r\\n    \\\"username\\\": null,\\r\\n    \\\"public\\\": true,\\r\\n    \\\"redirectUris\\\": [\\r\\n        \\\"https://localhost:3001\\\"\\r\\n    ],\\r\\n    \\\"grants\\\": [\\r\\n        \\\"authorization_code\\\",\\r\\n        \\\"refresh_token\\\"\\r\\n    ],\\r\\n    \\\"scopes\\\": [\\r\\n        \\\"read\\\",\\r\\n        \\\"write\\\"\\r\\n    ]\\r\\n}\",\n\t\t\t\t\t\t\t\"options\": {\n\t\t\t\t\t\t\t\t\"raw\": {\n\t\t\t\t\t\t\t\t\t\"language\": \"json\"\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t},\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/client\",\n\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"client\"\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Create OAuth2 Client Creds Application\",\n\t\t\t\t\t\"event\": [\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"listen\": \"test\",\n\t\t\t\t\t\t\t\"script\": {\n\t\t\t\t\t\t\t\t\"exec\": [\n\t\t\t\t\t\t\t\t\t\"pm.test(\\\"Set Client ID and Secret\\\", function () {\\r\",\n\t\t\t\t\t\t\t\t\t\"    pm.response.to.have.status(201);\\r\",\n\t\t\t\t\t\t\t\t\t\"\\r\",\n\t\t\t\t\t\t\t\t\t\"    var jsonData = pm.response.json();\\r\",\n\t\t\t\t\t\t\t\t\t\"    pm.expect(jsonData.identifier).to.not.be.empty;\\r\",\n\t\t\t\t\t\t\t\t\t\"    pm.expect(jsonData.secret).to.not.be.empty;\\r\",\n\t\t\t\t\t\t\t\t\t\"    \\r\",\n\t\t\t\t\t\t\t\t\t\"    pm.environment.set(\\\"oauth_client_creds_id\\\", jsonData.identifier);\\r\",\n\t\t\t\t\t\t\t\t\t\"    pm.environment.set(\\\"oauth_client_creds_secret\\\", jsonData.secret);\\r\",\n\t\t\t\t\t\t\t\t\t\"});\"\n\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\"type\": \"text/javascript\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t],\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"auth\": {\n\t\t\t\t\t\t\t\"type\": \"noauth\"\n\t\t\t\t\t\t},\n\t\t\t\t\t\t\"method\": \"POST\",\n\t\t\t\t\t\t\"header\": [],\n\t\t\t\t\t\t\"body\": {\n\t\t\t\t\t\t\t\"mode\": \"raw\",\n\t\t\t\t\t\t\t\"raw\": \"{\\r\\n    \\\"name\\\": \\\"My client creds kbin application\\\",\\r\\n    \\\"contactEmail\\\": \\\"contact@some.dev\\\",\\r\\n    \\\"username\\\": \\\"{{$randomUserName}}\\\",\\r\\n    \\\"public\\\": false,\\r\\n    \\\"redirectUris\\\": [\\r\\n        \\\"https://localhost:3001\\\"\\r\\n    ],\\r\\n    \\\"grants\\\": [\\r\\n        \\\"client_credentials\\\"\\r\\n    ],\\r\\n    \\\"scopes\\\": [\\r\\n        \\\"read\\\",\\r\\n        \\\"write\\\"\\r\\n    ]\\r\\n}\",\n\t\t\t\t\t\t\t\"options\": {\n\t\t\t\t\t\t\t\t\"raw\": {\n\t\t\t\t\t\t\t\t\t\"language\": \"json\"\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t},\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/client\",\n\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"client\"\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Create OAuth2 Application with logo\",\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"POST\",\n\t\t\t\t\t\t\"header\": [],\n\t\t\t\t\t\t\"body\": {\n\t\t\t\t\t\t\t\"mode\": \"raw\",\n\t\t\t\t\t\t\t\"raw\": \"{\\r\\n    \\\"name\\\": \\\"kbot application\\\",\\r\\n    \\\"contactEmail\\\": \\\"kbotbuilder@gmail.com\\\",\\r\\n    \\\"username\\\": \\\"kbot\\\",\\r\\n    \\\"redirectUris\\\": [\\r\\n        \\\"https://localhost:3001\\\"\\r\\n    ],\\r\\n    \\\"grants\\\": [\\r\\n        \\\"client_credentials\\\",\\r\\n        \\\"authorization_code\\\",\\r\\n        \\\"refresh_token\\\"\\r\\n    ],\\r\\n    \\\"scopes\\\": [\\r\\n        \\\"read\\\",\\r\\n        \\\"write\\\"\\r\\n    ]\\r\\n}\",\n\t\t\t\t\t\t\t\"options\": {\n\t\t\t\t\t\t\t\t\"raw\": {\n\t\t\t\t\t\t\t\t\t\"language\": \"json\"\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t},\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/client\",\n\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"client\"\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Delete OAuth2 Application\",\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"DELETE\",\n\t\t\t\t\t\t\"header\": [],\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/client?client_id={{oauth_client_id}}&client_secret={{oauth_client_secret}}\",\n\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"client\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"query\": [\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"client_id\",\n\t\t\t\t\t\t\t\t\t\"value\": \"{{oauth_client_id}}\"\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"client_secret\",\n\t\t\t\t\t\t\t\t\t\"value\": \"{{oauth_client_secret}}\"\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Revoke User and Client Tokens\",\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"POST\",\n\t\t\t\t\t\t\"header\": [],\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/revoke\",\n\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"revoke\"\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t}\n\t\t\t]\n\t\t},\n\t\t{\n\t\t\t\"name\": \"Post\",\n\t\t\t\"item\": [\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Comment\",\n\t\t\t\t\t\"item\": [\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"name\": \"Get post comment by id\",\n\t\t\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\t\t\"method\": \"GET\",\n\t\t\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/post-comments/:comment_id?d=-1\",\n\t\t\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\t\t\"post-comments\",\n\t\t\t\t\t\t\t\t\t\t\":comment_id\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"query\": [\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"d\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"-1\"\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"comment_id\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"7\"\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"response\": []\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"name\": \"Get post's comments\",\n\t\t\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\t\t\"method\": \"GET\",\n\t\t\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/posts/:post_id/comments?d=1&sort=hot&time=∞&p=1&perPage=10\",\n\t\t\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\t\t\"posts\",\n\t\t\t\t\t\t\t\t\t\t\":post_id\",\n\t\t\t\t\t\t\t\t\t\t\"comments\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"query\": [\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"d\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"1\"\n\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"sort\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"hot\"\n\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"time\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"∞\"\n\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"p\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"1\"\n\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"perPage\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"10\"\n\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"lang[]\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"en\",\n\t\t\t\t\t\t\t\t\t\t\t\"disabled\": true\n\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"usePreferredLangs\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"false\",\n\t\t\t\t\t\t\t\t\t\t\t\"disabled\": true\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"post_id\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"3\"\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"response\": []\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"name\": \"Get post comments from user\",\n\t\t\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\t\t\"method\": \"GET\",\n\t\t\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/users/:user_id/post-comments?d=1&sort=hot&time=∞&p=1&perPage=10\",\n\t\t\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\t\t\"users\",\n\t\t\t\t\t\t\t\t\t\t\":user_id\",\n\t\t\t\t\t\t\t\t\t\t\"post-comments\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"query\": [\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"d\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"1\"\n\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"sort\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"hot\"\n\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"time\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"∞\"\n\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"p\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"1\"\n\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"perPage\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"10\"\n\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"lang[]\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"en\",\n\t\t\t\t\t\t\t\t\t\t\t\"disabled\": true\n\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"usePreferredLangs\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"false\",\n\t\t\t\t\t\t\t\t\t\t\t\"disabled\": true\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"user_id\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"1\"\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"response\": []\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"name\": \"Update post comment by id\",\n\t\t\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\t\t\"method\": \"PUT\",\n\t\t\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\"body\": {\n\t\t\t\t\t\t\t\t\t\"mode\": \"raw\",\n\t\t\t\t\t\t\t\t\t\"raw\": \"{\\r\\n    \\\"body\\\": \\\"Updated post comment\\\"\\r\\n}\",\n\t\t\t\t\t\t\t\t\t\"options\": {\n\t\t\t\t\t\t\t\t\t\t\"raw\": {\n\t\t\t\t\t\t\t\t\t\t\t\"language\": \"json\"\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/post-comments/:comment_id?d=0\",\n\t\t\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\t\t\"post-comments\",\n\t\t\t\t\t\t\t\t\t\t\":comment_id\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"query\": [\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"d\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"0\"\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"comment_id\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"4\"\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"response\": []\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"name\": \"Report post comment by id\",\n\t\t\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\t\t\"method\": \"POST\",\n\t\t\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\"body\": {\n\t\t\t\t\t\t\t\t\t\"mode\": \"raw\",\n\t\t\t\t\t\t\t\t\t\"raw\": \"{\\r\\n    \\\"reason\\\": \\\"It's a terrible meme\\\"\\r\\n}\",\n\t\t\t\t\t\t\t\t\t\"options\": {\n\t\t\t\t\t\t\t\t\t\t\"raw\": {\n\t\t\t\t\t\t\t\t\t\t\t\"language\": \"json\"\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/post-comments/:comment/report\",\n\t\t\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\t\t\"post-comments\",\n\t\t\t\t\t\t\t\t\t\t\":comment\",\n\t\t\t\t\t\t\t\t\t\t\"report\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"comment\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"1\"\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"response\": []\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"name\": \"Delete post comment by id\",\n\t\t\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\t\t\"method\": \"DELETE\",\n\t\t\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/post-comments/:comment_id\",\n\t\t\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\t\t\"post-comments\",\n\t\t\t\t\t\t\t\t\t\t\":comment_id\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"comment_id\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"6\"\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"response\": []\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"name\": \"Vote post comment by id\",\n\t\t\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\t\t\"method\": \"PUT\",\n\t\t\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/post-comments/:comment_id/vote/:choice\",\n\t\t\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\t\t\"post-comments\",\n\t\t\t\t\t\t\t\t\t\t\":comment_id\",\n\t\t\t\t\t\t\t\t\t\t\"vote\",\n\t\t\t\t\t\t\t\t\t\t\":choice\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"comment_id\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"4\"\n\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"choice\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"1\"\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"response\": []\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"name\": \"Favourite post comment by id\",\n\t\t\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\t\t\"method\": \"PUT\",\n\t\t\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/post-comments/:comment_id/favourite\",\n\t\t\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\t\t\"post-comments\",\n\t\t\t\t\t\t\t\t\t\t\":comment_id\",\n\t\t\t\t\t\t\t\t\t\t\"favourite\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"comment_id\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"4\"\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"response\": []\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"name\": \"Create comment on post\",\n\t\t\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\t\t\"method\": \"POST\",\n\t\t\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\"body\": {\n\t\t\t\t\t\t\t\t\t\"mode\": \"raw\",\n\t\t\t\t\t\t\t\t\t\"raw\": \"{\\r\\n    \\\"body\\\": \\\"Test post comment\\\"\\r\\n}\",\n\t\t\t\t\t\t\t\t\t\"options\": {\n\t\t\t\t\t\t\t\t\t\t\"raw\": {\n\t\t\t\t\t\t\t\t\t\t\t\"language\": \"json\"\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/posts/:post_id/comments\",\n\t\t\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\t\t\"posts\",\n\t\t\t\t\t\t\t\t\t\t\":post_id\",\n\t\t\t\t\t\t\t\t\t\t\"comments\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"post_id\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"3\"\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"response\": []\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"name\": \"Create image comment on post\",\n\t\t\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\t\t\"method\": \"POST\",\n\t\t\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\"body\": {\n\t\t\t\t\t\t\t\t\t\"mode\": \"formdata\",\n\t\t\t\t\t\t\t\t\t\"formdata\": [\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"body\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"Dang that's a cute cat\",\n\t\t\t\t\t\t\t\t\t\t\t\"type\": \"text\"\n\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"lang\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"en\",\n\t\t\t\t\t\t\t\t\t\t\t\"type\": \"text\"\n\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"alt\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"also a cute cat\",\n\t\t\t\t\t\t\t\t\t\t\t\"type\": \"text\"\n\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"uploadImage\",\n\t\t\t\t\t\t\t\t\t\t\t\"type\": \"file\",\n\t\t\t\t\t\t\t\t\t\t\t\"src\": \"/C:/Users/Ryan/Pictures/kbin Test Images/sleepy.jpg\"\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/posts/:post_id/comments/image\",\n\t\t\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\t\t\"posts\",\n\t\t\t\t\t\t\t\t\t\t\":post_id\",\n\t\t\t\t\t\t\t\t\t\t\"comments\",\n\t\t\t\t\t\t\t\t\t\t\"image\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"post_id\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"3\"\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"response\": []\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"name\": \"Create reply on post comment\",\n\t\t\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\t\t\"method\": \"POST\",\n\t\t\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\"body\": {\n\t\t\t\t\t\t\t\t\t\"mode\": \"raw\",\n\t\t\t\t\t\t\t\t\t\"raw\": \"{\\r\\n    \\\"body\\\": \\\"Thanks @rideranton@kbintesting.duckdns.org\\\"\\r\\n}\",\n\t\t\t\t\t\t\t\t\t\"options\": {\n\t\t\t\t\t\t\t\t\t\t\"raw\": {\n\t\t\t\t\t\t\t\t\t\t\t\"language\": \"json\"\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/posts/:post/comments/:comment/reply\",\n\t\t\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\t\t\"posts\",\n\t\t\t\t\t\t\t\t\t\t\":post\",\n\t\t\t\t\t\t\t\t\t\t\"comments\",\n\t\t\t\t\t\t\t\t\t\t\":comment\",\n\t\t\t\t\t\t\t\t\t\t\"reply\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"post\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"3\"\n\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"comment\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"9\"\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"response\": []\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"name\": \"Create image reply on post comment\",\n\t\t\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\t\t\"method\": \"POST\",\n\t\t\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\"body\": {\n\t\t\t\t\t\t\t\t\t\"mode\": \"formdata\",\n\t\t\t\t\t\t\t\t\t\"formdata\": [\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"body\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"I love him\",\n\t\t\t\t\t\t\t\t\t\t\t\"type\": \"text\"\n\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"lang\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"en\",\n\t\t\t\t\t\t\t\t\t\t\t\"type\": \"text\"\n\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"alt\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"<3\",\n\t\t\t\t\t\t\t\t\t\t\t\"type\": \"text\"\n\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"uploadImage\",\n\t\t\t\t\t\t\t\t\t\t\t\"type\": \"file\",\n\t\t\t\t\t\t\t\t\t\t\t\"src\": \"/C:/Users/Ryan/Pictures/kbin Test Images/yawn.jpg\"\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/posts/:post/comments/:comment/reply/image\",\n\t\t\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\t\t\"posts\",\n\t\t\t\t\t\t\t\t\t\t\":post\",\n\t\t\t\t\t\t\t\t\t\t\"comments\",\n\t\t\t\t\t\t\t\t\t\t\":comment\",\n\t\t\t\t\t\t\t\t\t\t\"reply\",\n\t\t\t\t\t\t\t\t\t\t\"image\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"post\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"3\"\n\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"comment\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"7\"\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"response\": []\n\t\t\t\t\t\t}\n\t\t\t\t\t]\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Get post by id\",\n\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"GET\",\n\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/post/:post_id\",\n\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"post\",\n\t\t\t\t\t\t\t\t\":post_id\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"post_id\",\n\t\t\t\t\t\t\t\t\t\"value\": \"1\"\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Get posts from instance\",\n\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"GET\",\n\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/posts?sort=hot&time=∞&p=1&perPage=10\",\n\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"posts\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"query\": [\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"sort\",\n\t\t\t\t\t\t\t\t\t\"value\": \"hot\"\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"time\",\n\t\t\t\t\t\t\t\t\t\"value\": \"∞\"\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"p\",\n\t\t\t\t\t\t\t\t\t\"value\": \"1\"\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"perPage\",\n\t\t\t\t\t\t\t\t\t\"value\": \"10\"\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Get posts from magazine\",\n\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"GET\",\n\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/magazine/:magazine/posts?sort=hot&time=∞&p=1&perPage=10\",\n\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"magazine\",\n\t\t\t\t\t\t\t\t\":magazine\",\n\t\t\t\t\t\t\t\t\"posts\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"query\": [\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"sort\",\n\t\t\t\t\t\t\t\t\t\"value\": \"hot\"\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"time\",\n\t\t\t\t\t\t\t\t\t\"value\": \"∞\"\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"p\",\n\t\t\t\t\t\t\t\t\t\"value\": \"1\"\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"perPage\",\n\t\t\t\t\t\t\t\t\t\"value\": \"10\"\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"magazine\",\n\t\t\t\t\t\t\t\t\t\"value\": \"1\"\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Get posts from moderated magazines\",\n\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"GET\",\n\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/posts/moderated?sort=hot&time=∞&p=1&perPage=10\",\n\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"posts\",\n\t\t\t\t\t\t\t\t\"moderated\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"query\": [\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"sort\",\n\t\t\t\t\t\t\t\t\t\"value\": \"hot\"\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"time\",\n\t\t\t\t\t\t\t\t\t\"value\": \"∞\"\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"p\",\n\t\t\t\t\t\t\t\t\t\"value\": \"1\"\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"perPage\",\n\t\t\t\t\t\t\t\t\t\"value\": \"10\"\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Get posts from subscribed magazines\",\n\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"GET\",\n\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/posts/subscribed?sort=hot&time=∞&p=1&perPage=10\",\n\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"posts\",\n\t\t\t\t\t\t\t\t\"subscribed\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"query\": [\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"sort\",\n\t\t\t\t\t\t\t\t\t\"value\": \"hot\"\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"time\",\n\t\t\t\t\t\t\t\t\t\"value\": \"∞\"\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"p\",\n\t\t\t\t\t\t\t\t\t\"value\": \"1\"\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"perPage\",\n\t\t\t\t\t\t\t\t\t\"value\": \"10\"\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Get favourited posts\",\n\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"GET\",\n\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/posts/favourited?sort=hot&time=∞&p=1&perPage=10\",\n\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"posts\",\n\t\t\t\t\t\t\t\t\"favourited\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"query\": [\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"sort\",\n\t\t\t\t\t\t\t\t\t\"value\": \"hot\"\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"time\",\n\t\t\t\t\t\t\t\t\t\"value\": \"∞\"\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"p\",\n\t\t\t\t\t\t\t\t\t\"value\": \"1\"\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"perPage\",\n\t\t\t\t\t\t\t\t\t\"value\": \"10\"\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Get posts from user\",\n\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"GET\",\n\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/users/:user_id/posts?sort=hot&time=∞&p=1&perPage=10\",\n\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"users\",\n\t\t\t\t\t\t\t\t\":user_id\",\n\t\t\t\t\t\t\t\t\"posts\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"query\": [\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"sort\",\n\t\t\t\t\t\t\t\t\t\"value\": \"hot\"\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"time\",\n\t\t\t\t\t\t\t\t\t\"value\": \"∞\"\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"p\",\n\t\t\t\t\t\t\t\t\t\"value\": \"1\"\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"perPage\",\n\t\t\t\t\t\t\t\t\t\"value\": \"10\"\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"user_id\",\n\t\t\t\t\t\t\t\t\t\"value\": \"3\"\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Create post in magazine\",\n\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"POST\",\n\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"body\": {\n\t\t\t\t\t\t\t\"mode\": \"raw\",\n\t\t\t\t\t\t\t\"raw\": \"{\\r\\n  \\\"body\\\": \\\"Microblogging from the API!\\\",\\r\\n  \\\"lang\\\": \\\"en\\\",\\r\\n  \\\"isAdult\\\": false\\r\\n}\",\n\t\t\t\t\t\t\t\"options\": {\n\t\t\t\t\t\t\t\t\"raw\": {\n\t\t\t\t\t\t\t\t\t\"language\": \"json\"\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t},\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/magazine/:magazine/posts\",\n\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"magazine\",\n\t\t\t\t\t\t\t\t\":magazine\",\n\t\t\t\t\t\t\t\t\"posts\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"magazine\",\n\t\t\t\t\t\t\t\t\t\"value\": \"1\"\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Create post in magazine with image\",\n\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"POST\",\n\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"body\": {\n\t\t\t\t\t\t\t\"mode\": \"formdata\",\n\t\t\t\t\t\t\t\"formdata\": [\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"body\",\n\t\t\t\t\t\t\t\t\t\"value\": \"Hey, here's a cat pic from the API\",\n\t\t\t\t\t\t\t\t\t\"type\": \"text\"\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"lang\",\n\t\t\t\t\t\t\t\t\t\"value\": \"en\",\n\t\t\t\t\t\t\t\t\t\"type\": \"text\"\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"isAdult\",\n\t\t\t\t\t\t\t\t\t\"value\": \"false\",\n\t\t\t\t\t\t\t\t\t\"type\": \"text\"\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"alt\",\n\t\t\t\t\t\t\t\t\t\"value\": \"A cute cat on a microblog\",\n\t\t\t\t\t\t\t\t\t\"type\": \"text\"\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"uploadImage\",\n\t\t\t\t\t\t\t\t\t\"type\": \"file\",\n\t\t\t\t\t\t\t\t\t\"src\": \"/C:/Users/Ryan/Pictures/kbin Test Images/kitten.gif\"\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t},\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/magazine/:magazine/posts/image\",\n\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"magazine\",\n\t\t\t\t\t\t\t\t\":magazine\",\n\t\t\t\t\t\t\t\t\"posts\",\n\t\t\t\t\t\t\t\t\"image\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"magazine\",\n\t\t\t\t\t\t\t\t\t\"value\": \"1\"\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Update post by id\",\n\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"PUT\",\n\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"body\": {\n\t\t\t\t\t\t\t\"mode\": \"raw\",\n\t\t\t\t\t\t\t\"raw\": \"{\\r\\n  \\\"body\\\": \\\"test, updated!\\\",\\r\\n  \\\"lang\\\": \\\"en\\\",\\r\\n  \\\"isAdult\\\": false\\r\\n}\",\n\t\t\t\t\t\t\t\"options\": {\n\t\t\t\t\t\t\t\t\"raw\": {\n\t\t\t\t\t\t\t\t\t\"language\": \"json\"\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t},\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/post/:post\",\n\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"post\",\n\t\t\t\t\t\t\t\t\":post\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"post\",\n\t\t\t\t\t\t\t\t\t\"value\": \"1\"\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Report post by id\",\n\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"POST\",\n\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"body\": {\n\t\t\t\t\t\t\t\"mode\": \"raw\",\n\t\t\t\t\t\t\t\"raw\": \"{\\r\\n    \\\"reason\\\": \\\"It's a terrible meme\\\"\\r\\n}\",\n\t\t\t\t\t\t\t\"options\": {\n\t\t\t\t\t\t\t\t\"raw\": {\n\t\t\t\t\t\t\t\t\t\"language\": \"json\"\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t},\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/post/:post/report\",\n\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"post\",\n\t\t\t\t\t\t\t\t\":post\",\n\t\t\t\t\t\t\t\t\"report\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"post\",\n\t\t\t\t\t\t\t\t\t\"value\": null\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Delete post by id\",\n\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"DELETE\",\n\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/post/:post_id\",\n\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"post\",\n\t\t\t\t\t\t\t\t\":post_id\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"post_id\",\n\t\t\t\t\t\t\t\t\t\"value\": \"1\"\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Vote on post by id\",\n\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"PUT\",\n\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/post/:post_id/vote/:choice\",\n\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"post\",\n\t\t\t\t\t\t\t\t\":post_id\",\n\t\t\t\t\t\t\t\t\"vote\",\n\t\t\t\t\t\t\t\t\":choice\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"post_id\",\n\t\t\t\t\t\t\t\t\t\"value\": \"3\"\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"choice\",\n\t\t\t\t\t\t\t\t\t\"value\": \"1\"\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Favourite post by id\",\n\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"PUT\",\n\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/post/:post_id/favourite\",\n\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"post\",\n\t\t\t\t\t\t\t\t\":post_id\",\n\t\t\t\t\t\t\t\t\"favourite\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"post_id\",\n\t\t\t\t\t\t\t\t\t\"value\": \"3\"\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t}\n\t\t\t]\n\t\t},\n\t\t{\n\t\t\t\"name\": \"Search\",\n\t\t\t\"item\": [\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Get Results\",\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"GET\",\n\t\t\t\t\t\t\"header\": [],\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/search?q=test&p=1&perPage=25\",\n\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"search\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"query\": [\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"q\",\n\t\t\t\t\t\t\t\t\t\"value\": \"test\"\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"p\",\n\t\t\t\t\t\t\t\t\t\"value\": \"1\"\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"perPage\",\n\t\t\t\t\t\t\t\t\t\"value\": \"25\"\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t}\n\t\t\t]\n\t\t},\n\t\t{\n\t\t\t\"name\": \"User\",\n\t\t\t\"item\": [\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Admin\",\n\t\t\t\t\t\"item\": [\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"name\": \"Retrieve banned users\",\n\t\t\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\t\t\"method\": \"GET\",\n\t\t\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/admin/users/banned\",\n\t\t\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\t\t\"admin\",\n\t\t\t\t\t\t\t\t\t\t\"users\",\n\t\t\t\t\t\t\t\t\t\t\"banned\"\n\t\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"response\": []\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"name\": \"Ban user by id\",\n\t\t\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\t\t\"method\": \"POST\",\n\t\t\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/admin/users/:user_id/ban\",\n\t\t\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\t\t\"admin\",\n\t\t\t\t\t\t\t\t\t\t\"users\",\n\t\t\t\t\t\t\t\t\t\t\":user_id\",\n\t\t\t\t\t\t\t\t\t\t\"ban\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"user_id\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"3\"\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"response\": []\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"name\": \"Unban user by id\",\n\t\t\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\t\t\"method\": \"POST\",\n\t\t\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/admin/users/:user_id/unban\",\n\t\t\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\t\t\"admin\",\n\t\t\t\t\t\t\t\t\t\t\"users\",\n\t\t\t\t\t\t\t\t\t\t\":user_id\",\n\t\t\t\t\t\t\t\t\t\t\"unban\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"user_id\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"3\"\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"response\": []\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"name\": \"Delete user by id\",\n\t\t\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\t\t\"method\": \"DELETE\",\n\t\t\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/admin/users/:user_id/delete\",\n\t\t\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\t\t\"admin\",\n\t\t\t\t\t\t\t\t\t\t\"users\",\n\t\t\t\t\t\t\t\t\t\t\":user_id\",\n\t\t\t\t\t\t\t\t\t\t\"delete\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"user_id\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"3\"\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"response\": []\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"name\": \"Purge user by id\",\n\t\t\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\t\t\"method\": \"DELETE\",\n\t\t\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/admin/users/:user_id/purge\",\n\t\t\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\t\t\"admin\",\n\t\t\t\t\t\t\t\t\t\t\"users\",\n\t\t\t\t\t\t\t\t\t\t\":user_id\",\n\t\t\t\t\t\t\t\t\t\t\"purge\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"user_id\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"3\"\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"response\": []\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"name\": \"Verify user by id\",\n\t\t\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\t\t\"method\": \"PUT\",\n\t\t\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/admin/users/:user_id/verify\",\n\t\t\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\t\t\"admin\",\n\t\t\t\t\t\t\t\t\t\t\"users\",\n\t\t\t\t\t\t\t\t\t\t\":user_id\",\n\t\t\t\t\t\t\t\t\t\t\"verify\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"user_id\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"3\"\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"response\": []\n\t\t\t\t\t\t}\n\t\t\t\t\t]\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"ActivityPub\",\n\t\t\t\t\t\"item\": [\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"name\": \"Post to inbox\",\n\t\t\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\t\t\"method\": \"POST\",\n\t\t\t\t\t\t\t\t\"header\": [],\n\t\t\t\t\t\t\t\t\"body\": {\n\t\t\t\t\t\t\t\t\t\"mode\": \"raw\",\n\t\t\t\t\t\t\t\t\t\"raw\": \"{\\r\\n    \\r\\n}\",\n\t\t\t\t\t\t\t\t\t\"options\": {\n\t\t\t\t\t\t\t\t\t\t\"raw\": {\n\t\t\t\t\t\t\t\t\t\t\t\"language\": \"json\"\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/u/:username/inbox\",\n\t\t\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\t\t\"u\",\n\t\t\t\t\t\t\t\t\t\t\":username\",\n\t\t\t\t\t\t\t\t\t\t\"inbox\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"username\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": null\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"response\": []\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\"name\": \"Get outbox\",\n\t\t\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\t\t\"method\": \"GET\",\n\t\t\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\t\t\"value\": \"application/activity+json\",\n\t\t\t\t\t\t\t\t\t\t\"type\": \"text\"\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/u/:username/outbox\",\n\t\t\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\t\t\"u\",\n\t\t\t\t\t\t\t\t\t\t\":username\",\n\t\t\t\t\t\t\t\t\t\t\"outbox\"\n\t\t\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\"key\": \"username\",\n\t\t\t\t\t\t\t\t\t\t\t\"value\": \"rideranton\"\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"response\": []\n\t\t\t\t\t\t}\n\t\t\t\t\t]\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Get user by id\",\n\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"GET\",\n\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/users/:user_id\",\n\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"users\",\n\t\t\t\t\t\t\t\t\":user_id\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"user_id\",\n\t\t\t\t\t\t\t\t\t\"value\": \"1\"\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Get users\",\n\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"GET\",\n\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/users\",\n\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"users\"\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Get blocked users\",\n\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"GET\",\n\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/users/blocked\",\n\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"users\",\n\t\t\t\t\t\t\t\t\"blocked\"\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Get user by name\",\n\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"GET\",\n\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/users/name/:username\",\n\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"users\",\n\t\t\t\t\t\t\t\t\"name\",\n\t\t\t\t\t\t\t\t\":username\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"username\",\n\t\t\t\t\t\t\t\t\t\"value\": \"rideranton\"\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Get current user\",\n\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"GET\",\n\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/users/me\",\n\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"users\",\n\t\t\t\t\t\t\t\t\"me\"\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Get current user settings\",\n\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"GET\",\n\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/users/settings\",\n\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"users\",\n\t\t\t\t\t\t\t\t\"settings\"\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Get current user's oauth consents\",\n\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"GET\",\n\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/users/consents\",\n\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"users\",\n\t\t\t\t\t\t\t\t\"consents\"\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Get oauth consent by id\",\n\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"GET\",\n\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/users/consents/:consent_id\",\n\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"users\",\n\t\t\t\t\t\t\t\t\"consents\",\n\t\t\t\t\t\t\t\t\":consent_id\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"consent_id\",\n\t\t\t\t\t\t\t\t\t\"value\": \"2\"\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Update oauth consent by id\",\n\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"PUT\",\n\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"body\": {\n\t\t\t\t\t\t\t\"mode\": \"raw\",\n\t\t\t\t\t\t\t\"raw\": \"{\\r\\n    \\\"scopes\\\": [\\r\\n        \\\"delete\\\",\\r\\n        \\\"subscribe\\\",\\r\\n        \\\"block\\\",\\r\\n        \\\"vote\\\",\\r\\n        \\\"report\\\",\\r\\n        \\\"user\\\",\\r\\n        \\\"moderate\\\",\\r\\n        \\\"admin\\\",\\r\\n        \\\"user:oauth_clients:edit\\\",\\r\\n        \\\"user:oauth_clients:read\\\",\\r\\n        \\\"read\\\",\\r\\n        \\\"write\\\",\\r\\n        \\\"admin:oauth_clients:read\\\"\\r\\n    ]\\r\\n}\",\n\t\t\t\t\t\t\t\"options\": {\n\t\t\t\t\t\t\t\t\"raw\": {\n\t\t\t\t\t\t\t\t\t\"language\": \"json\"\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t},\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/users/consents/:consent_id\",\n\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"users\",\n\t\t\t\t\t\t\t\t\"consents\",\n\t\t\t\t\t\t\t\t\":consent_id\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"consent_id\",\n\t\t\t\t\t\t\t\t\t\"value\": \"2\"\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Update current user settings\",\n\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"PUT\",\n\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"body\": {\n\t\t\t\t\t\t\t\"mode\": \"raw\",\n\t\t\t\t\t\t\t\"raw\": \"{\\r\\n    \\\"notifyOnNewEntry\\\": true,\\r\\n    \\\"notifyOnNewEntryReply\\\": false,\\r\\n    \\\"preferredLanguages\\\": [\\r\\n        \\\"en\\\"\\r\\n    ]\\r\\n}\",\n\t\t\t\t\t\t\t\"options\": {\n\t\t\t\t\t\t\t\t\"raw\": {\n\t\t\t\t\t\t\t\t\t\"language\": \"json\"\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t},\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/users/settings\",\n\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"users\",\n\t\t\t\t\t\t\t\t\"settings\"\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Update current user profile\",\n\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"PUT\",\n\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"body\": {\n\t\t\t\t\t\t\t\"mode\": \"raw\",\n\t\t\t\t\t\t\t\"raw\": \"{\\r\\n    \\\"about\\\": \\\"Updated from the API once more!\\\"\\r\\n}\",\n\t\t\t\t\t\t\t\"options\": {\n\t\t\t\t\t\t\t\t\"raw\": {\n\t\t\t\t\t\t\t\t\t\"language\": \"json\"\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t},\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/users/profile\",\n\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"users\",\n\t\t\t\t\t\t\t\t\"profile\"\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Update current user avatar\",\n\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"POST\",\n\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"body\": {\n\t\t\t\t\t\t\t\"mode\": \"formdata\",\n\t\t\t\t\t\t\t\"formdata\": [\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"uploadImage\",\n\t\t\t\t\t\t\t\t\t\"type\": \"file\",\n\t\t\t\t\t\t\t\t\t\"src\": \"/C:/Users/ryans/Pictures/kbin/robot-face_1f916.png\"\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t},\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/users/avatar\",\n\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"users\",\n\t\t\t\t\t\t\t\t\"avatar\"\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Delete current user avatar\",\n\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"DELETE\",\n\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/users/avatar\",\n\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"users\",\n\t\t\t\t\t\t\t\t\"avatar\"\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Update current user cover\",\n\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"POST\",\n\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"body\": {\n\t\t\t\t\t\t\t\"mode\": \"formdata\",\n\t\t\t\t\t\t\t\"formdata\": [\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"uploadImage\",\n\t\t\t\t\t\t\t\t\t\"type\": \"file\",\n\t\t\t\t\t\t\t\t\t\"src\": \"/C:/Users/ryans/Documents/blender/ceres_hydro_lights.png\"\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t},\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/users/cover\",\n\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"users\",\n\t\t\t\t\t\t\t\t\"cover\"\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Delete current user cover\",\n\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"DELETE\",\n\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/users/cover\",\n\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"users\",\n\t\t\t\t\t\t\t\t\"cover\"\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Get followers by id\",\n\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"GET\",\n\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/users/:user_id/followers\",\n\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"users\",\n\t\t\t\t\t\t\t\t\":user_id\",\n\t\t\t\t\t\t\t\t\"followers\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"user_id\",\n\t\t\t\t\t\t\t\t\t\"value\": \"1\"\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Get current user's followers\",\n\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"GET\",\n\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/users/followers\",\n\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"users\",\n\t\t\t\t\t\t\t\t\"followers\"\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Get followed by id\",\n\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"GET\",\n\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/users/:user_id/followed\",\n\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"users\",\n\t\t\t\t\t\t\t\t\":user_id\",\n\t\t\t\t\t\t\t\t\"followed\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"user_id\",\n\t\t\t\t\t\t\t\t\t\"value\": \"3\"\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Get current user's followed\",\n\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"GET\",\n\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/users/followed\",\n\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"users\",\n\t\t\t\t\t\t\t\t\"followed\"\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Get magazine subscriptions by id\",\n\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"GET\",\n\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/users/:user_id/magazines/subscribed\",\n\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"users\",\n\t\t\t\t\t\t\t\t\":user_id\",\n\t\t\t\t\t\t\t\t\"magazines\",\n\t\t\t\t\t\t\t\t\"subscribed\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"user_id\",\n\t\t\t\t\t\t\t\t\t\"value\": \"1\"\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Get domain subscriptions by id\",\n\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"GET\",\n\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/users/:user_id/domains/subscribed\",\n\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"users\",\n\t\t\t\t\t\t\t\t\":user_id\",\n\t\t\t\t\t\t\t\t\"domains\",\n\t\t\t\t\t\t\t\t\"subscribed\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"user_id\",\n\t\t\t\t\t\t\t\t\t\"value\": \"1\"\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Follow by id\",\n\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"PUT\",\n\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/users/:user_id/follow\",\n\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"users\",\n\t\t\t\t\t\t\t\t\":user_id\",\n\t\t\t\t\t\t\t\t\"follow\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"user_id\",\n\t\t\t\t\t\t\t\t\t\"value\": \"3\"\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Unfollow by id\",\n\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"PUT\",\n\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/users/:user_id/unfollow\",\n\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"users\",\n\t\t\t\t\t\t\t\t\":user_id\",\n\t\t\t\t\t\t\t\t\"unfollow\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"user_id\",\n\t\t\t\t\t\t\t\t\t\"value\": \"3\"\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Block by id\",\n\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"PUT\",\n\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/users/:user_id/block\",\n\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"users\",\n\t\t\t\t\t\t\t\t\":user_id\",\n\t\t\t\t\t\t\t\t\"block\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"user_id\",\n\t\t\t\t\t\t\t\t\t\"value\": \"3\"\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\t\"name\": \"Unblock by id\",\n\t\t\t\t\t\"protocolProfileBehavior\": {\n\t\t\t\t\t\t\"disabledSystemHeaders\": {\n\t\t\t\t\t\t\t\"accept\": true\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"request\": {\n\t\t\t\t\t\t\"method\": \"PUT\",\n\t\t\t\t\t\t\"header\": [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"key\": \"Accept\",\n\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\"value\": \"application/ld+json\"\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t],\n\t\t\t\t\t\t\"url\": {\n\t\t\t\t\t\t\t\"raw\": \"https://{{host}}/api/users/:user_id/unblock\",\n\t\t\t\t\t\t\t\"protocol\": \"https\",\n\t\t\t\t\t\t\t\"host\": [\n\t\t\t\t\t\t\t\t\"{{host}}\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"path\": [\n\t\t\t\t\t\t\t\t\"api\",\n\t\t\t\t\t\t\t\t\"users\",\n\t\t\t\t\t\t\t\t\":user_id\",\n\t\t\t\t\t\t\t\t\"unblock\"\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\t\"variable\": [\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\"key\": \"user_id\",\n\t\t\t\t\t\t\t\t\t\"value\": \"3\"\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t\"response\": []\n\t\t\t\t}\n\t\t\t]\n\t\t}\n\t],\n\t\"auth\": {\n\t\t\"type\": \"oauth2\",\n\t\t\"oauth2\": [\n\t\t\t{\n\t\t\t\t\"key\": \"state\",\n\t\t\t\t\"value\": \"{{$randomColor}}\",\n\t\t\t\t\"type\": \"string\"\n\t\t\t},\n\t\t\t{\n\t\t\t\t\"key\": \"scope\",\n\t\t\t\t\"value\": \"read write delete subscribe block vote report user moderate admin\",\n\t\t\t\t\"type\": \"string\"\n\t\t\t},\n\t\t\t{\n\t\t\t\t\"key\": \"tokenName\",\n\t\t\t\t\"value\": \"Full Access\",\n\t\t\t\t\"type\": \"string\"\n\t\t\t},\n\t\t\t{\n\t\t\t\t\"key\": \"accessTokenUrl\",\n\t\t\t\t\"value\": \"https://{{host}}/token\",\n\t\t\t\t\"type\": \"string\"\n\t\t\t},\n\t\t\t{\n\t\t\t\t\"key\": \"authUrl\",\n\t\t\t\t\"value\": \"https://{{host}}/authorize\",\n\t\t\t\t\"type\": \"string\"\n\t\t\t},\n\t\t\t{\n\t\t\t\t\"key\": \"redirect_uri\",\n\t\t\t\t\"value\": \"https://localhost:3001\",\n\t\t\t\t\"type\": \"string\"\n\t\t\t},\n\t\t\t{\n\t\t\t\t\"key\": \"refreshRequestParams\",\n\t\t\t\t\"value\": [],\n\t\t\t\t\"type\": \"any\"\n\t\t\t},\n\t\t\t{\n\t\t\t\t\"key\": \"tokenRequestParams\",\n\t\t\t\t\"value\": [],\n\t\t\t\t\"type\": \"any\"\n\t\t\t},\n\t\t\t{\n\t\t\t\t\"key\": \"authRequestParams\",\n\t\t\t\t\"value\": [],\n\t\t\t\t\"type\": \"any\"\n\t\t\t},\n\t\t\t{\n\t\t\t\t\"key\": \"challengeAlgorithm\",\n\t\t\t\t\"value\": \"S256\",\n\t\t\t\t\"type\": \"string\"\n\t\t\t},\n\t\t\t{\n\t\t\t\t\"key\": \"grant_type\",\n\t\t\t\t\"value\": \"authorization_code\",\n\t\t\t\t\"type\": \"string\"\n\t\t\t},\n\t\t\t{\n\t\t\t\t\"key\": \"clientSecret\",\n\t\t\t\t\"value\": \"{{oauth_client_secret}}\",\n\t\t\t\t\"type\": \"string\"\n\t\t\t},\n\t\t\t{\n\t\t\t\t\"key\": \"clientId\",\n\t\t\t\t\"value\": \"{{oauth_client_id}}\",\n\t\t\t\t\"type\": \"string\"\n\t\t\t},\n\t\t\t{\n\t\t\t\t\"key\": \"addTokenTo\",\n\t\t\t\t\"value\": \"header\",\n\t\t\t\t\"type\": \"string\"\n\t\t\t},\n\t\t\t{\n\t\t\t\t\"key\": \"client_authentication\",\n\t\t\t\t\"value\": \"header\",\n\t\t\t\t\"type\": \"string\"\n\t\t\t}\n\t\t]\n\t}\n}\n"
  },
  {
    "path": "docs/postman/kbin.postman_environment.json",
    "content": "{\n\t\"id\": \"963a0881-8a56-4ca3-ae07-b4ad4435f304\",\n\t\"name\": \"mbin dev\",\n\t\"values\": [\n\t\t{\n\t\t\t\"key\": \"host\",\n\t\t\t\"value\": \"localhost\",\n\t\t\t\"enabled\": true\n\t\t},\n\t\t{\n\t\t\t\"key\": \"oauth_client_id\",\n\t\t\t\"value\": \"\",\n\t\t\t\"type\": \"default\",\n\t\t\t\"enabled\": true\n\t\t},\n\t\t{\n\t\t\t\"key\": \"oauth_client_secret\",\n\t\t\t\"value\": \"\",\n\t\t\t\"type\": \"secret\",\n\t\t\t\"enabled\": true\n\t\t},\n\t\t{\n\t\t\t\"key\": \"oauth_client_creds_id\",\n\t\t\t\"value\": \"\",\n\t\t\t\"type\": \"default\",\n\t\t\t\"enabled\": true\n\t\t},\n\t\t{\n\t\t\t\"key\": \"oauth_client_creds_secret\",\n\t\t\t\"value\": \"\",\n\t\t\t\"type\": \"secret\",\n\t\t\t\"enabled\": true\n\t\t},\n\t\t{\n\t\t\t\"key\": \"oauth_public_client_id\",\n\t\t\t\"value\": \"\",\n\t\t\t\"type\": \"default\",\n\t\t\t\"enabled\": true\n\t\t},\n\t\t{\n\t\t\t\"key\": \"token\",\n\t\t\t\"value\": \"\",\n\t\t\t\"enabled\": true\n\t\t},\n\t\t{\n\t\t\t\"key\": \"oauth_state\",\n\t\t\t\"value\": \"\",\n\t\t\t\"enabled\": true\n\t\t},\n\t\t{\n\t\t\t\"key\": \"oauth_code\",\n\t\t\t\"value\": \"\",\n\t\t\t\"type\": \"default\",\n\t\t\t\"enabled\": true\n\t\t},\n\t\t{\n\t\t\t\"key\": \"oauth_refresh_token\",\n\t\t\t\"value\": \"\",\n\t\t\t\"type\": \"secret\",\n\t\t\t\"enabled\": true\n\t\t},\n\t\t{\n\t\t\t\"key\": \"oauth_code_challenge\",\n\t\t\t\"value\": \"\",\n\t\t\t\"enabled\": true\n\t\t},\n\t\t{\n\t\t\t\"key\": \"oauth_code_verifier\",\n\t\t\t\"value\": \"\",\n\t\t\t\"type\": \"any\",\n\t\t\t\"enabled\": true\n\t\t},\n\t\t{\n\t\t\t\"key\": \"oauth_public_refresh_token\",\n\t\t\t\"value\": \"\",\n\t\t\t\"type\": \"any\",\n\t\t\t\"enabled\": true\n\t\t},\n\t\t{\n\t\t\t\"key\": \"banExpiry\",\n\t\t\t\"value\": \"\",\n\t\t\t\"type\": \"any\",\n\t\t\t\"enabled\": true\n\t\t}\n\t],\n\t\"_postman_variable_scope\": \"environment\",\n\t\"_postman_exported_at\": \"2023-07-24T08:59:15.566Z\",\n\t\"_postman_exported_using\": \"Postman/10.16.0\"\n}\n"
  },
  {
    "path": "eslint.config.mjs",
    "content": "import js from \"@eslint/js\";\nimport globals from \"globals\";\nimport stylistic from \"@stylistic/eslint-plugin\";\n\nexport default [\n    {\n        ignores: [\"*\", \"!assets\"],\n    },\n    js.configs.recommended,\n    {\n        plugins: {\n            \"@stylistic\": stylistic,\n        },\n        languageOptions: {\n            globals: {\n                ...globals.browser,\n                ...globals.node,\n                ...globals.es2021\n            },\n            ecmaVersion: \"latest\",\n            sourceType: \"module\",\n        },\n        rules: {\n            \"@stylistic/indent\": [\"error\", 4],\n            \"@stylistic/linebreak-style\": [\"error\", \"unix\"],\n            \"@stylistic/eol-last\": [\"error\", \"always\"],\n            \"@stylistic/arrow-parens\": [\"error\", \"always\"],\n            \"@stylistic/brace-style\": [\"error\", \"1tbs\"],\n            \"@stylistic/comma-dangle\": [\"error\", \"always-multiline\"],\n            \"@stylistic/comma-spacing\": [\"error\"],\n            \"@stylistic/keyword-spacing\": [\"error\"],\n            \"@stylistic/no-multiple-empty-lines\": [\"error\", {\n                max: 2,\n                maxEOF: 0,\n                maxBOF: 0,\n            }],\n            \"@stylistic/no-trailing-spaces\": [\"error\"],\n            \"@stylistic/no-multi-spaces\": [\"error\"],\n            \"@stylistic/object-curly-spacing\": [\"error\", \"always\"],\n            \"@stylistic/quotes\": [\"error\", \"single\", {\n                avoidEscape: true,\n            }],\n            \"@stylistic/semi\": [\"error\", \"always\"],\n            \"@stylistic/space-before-blocks\": [\"error\"],\n            \"@stylistic/space-in-parens\": [\"error\", \"never\"],\n            camelcase: [\"error\", {\n                ignoreImports: true,\n            }],\n            curly: [\"error\", \"all\"],\n            eqeqeq: [\"error\", \"always\"],\n            \"no-console\": [\"off\"],\n            \"no-duplicate-imports\": [\"error\"],\n            \"no-empty\": [\"error\", {\n                allowEmptyCatch: true,\n            }],\n            \"no-eval\": [\"error\"],\n            \"no-implied-eval\": [\"error\"],\n            \"prefer-const\": [\"error\"],\n            \"sort-imports\": [\"error\"],\n            yoda: [\"error\", \"always\"],\n            \"no-undef\": [\"warn\"],\n        },\n    }\n];\n"
  },
  {
    "path": "migrations/.gitignore",
    "content": ""
  },
  {
    "path": "migrations/Version20210527210529.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\nfinal class Version20210527210529 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return '';\n    }\n\n    public function up(Schema $schema): void\n    {\n        $this->addSql('CREATE SEQUENCE badge_id_seq INCREMENT BY 1 MINVALUE 1 START 1');\n        $this->addSql('CREATE SEQUENCE domain_id_seq INCREMENT BY 1 MINVALUE 1 START 1');\n        $this->addSql('CREATE SEQUENCE entry_id_seq INCREMENT BY 1 MINVALUE 1 START 1');\n        $this->addSql('CREATE SEQUENCE entry_badge_id_seq INCREMENT BY 1 MINVALUE 1 START 1');\n        $this->addSql('CREATE SEQUENCE entry_comment_id_seq INCREMENT BY 1 MINVALUE 1 START 1');\n        $this->addSql('CREATE SEQUENCE entry_comment_vote_id_seq INCREMENT BY 1 MINVALUE 1 START 1');\n        $this->addSql('CREATE SEQUENCE entry_vote_id_seq INCREMENT BY 1 MINVALUE 1 START 1');\n        $this->addSql('CREATE SEQUENCE image_id_seq INCREMENT BY 1 MINVALUE 1 START 1');\n        $this->addSql('CREATE SEQUENCE magazine_id_seq INCREMENT BY 1 MINVALUE 1 START 1');\n        $this->addSql('CREATE SEQUENCE magazine_ban_id_seq INCREMENT BY 1 MINVALUE 1 START 1');\n        $this->addSql('CREATE SEQUENCE magazine_block_id_seq INCREMENT BY 1 MINVALUE 1 START 1');\n        $this->addSql('CREATE SEQUENCE magazine_log_id_seq INCREMENT BY 1 MINVALUE 1 START 1');\n        $this->addSql('CREATE SEQUENCE magazine_subscription_id_seq INCREMENT BY 1 MINVALUE 1 START 1');\n        $this->addSql('CREATE SEQUENCE message_id_seq INCREMENT BY 1 MINVALUE 1 START 1');\n        $this->addSql('CREATE SEQUENCE message_thread_id_seq INCREMENT BY 1 MINVALUE 1 START 1');\n        $this->addSql('CREATE SEQUENCE moderator_id_seq INCREMENT BY 1 MINVALUE 1 START 1');\n        $this->addSql('CREATE SEQUENCE notification_id_seq INCREMENT BY 1 MINVALUE 1 START 1');\n        $this->addSql('CREATE SEQUENCE post_id_seq INCREMENT BY 1 MINVALUE 1 START 1');\n        $this->addSql('CREATE SEQUENCE post_comment_id_seq INCREMENT BY 1 MINVALUE 1 START 1');\n        $this->addSql('CREATE SEQUENCE post_comment_vote_id_seq INCREMENT BY 1 MINVALUE 1 START 1');\n        $this->addSql('CREATE SEQUENCE post_vote_id_seq INCREMENT BY 1 MINVALUE 1 START 1');\n        $this->addSql('CREATE SEQUENCE report_id_seq INCREMENT BY 1 MINVALUE 1 START 1');\n        $this->addSql('CREATE SEQUENCE site_id_seq INCREMENT BY 1 MINVALUE 1 START 1');\n        $this->addSql('CREATE SEQUENCE \"user_id_seq\" INCREMENT BY 1 MINVALUE 1 START 1');\n        $this->addSql('CREATE SEQUENCE user_block_id_seq INCREMENT BY 1 MINVALUE 1 START 1');\n        $this->addSql('CREATE SEQUENCE user_follow_id_seq INCREMENT BY 1 MINVALUE 1 START 1');\n        $this->addSql('CREATE SEQUENCE view_counter_id_seq INCREMENT BY 1 MINVALUE 1 START 1');\n        $this->addSql('CREATE TABLE badge (id INT NOT NULL, magazine_id INT NOT NULL, name VARCHAR(255) DEFAULT NULL, PRIMARY KEY(id))');\n        $this->addSql('CREATE INDEX IDX_FEF0481D3EB84A1D ON badge (magazine_id)');\n        $this->addSql('CREATE TABLE domain (id INT NOT NULL, name VARCHAR(255) NOT NULL, entry_count INT NOT NULL, PRIMARY KEY(id))');\n        $this->addSql('CREATE UNIQUE INDEX domain_name_idx ON domain (name)');\n        $this->addSql('CREATE TABLE entry (id INT NOT NULL, user_id INT NOT NULL, magazine_id INT NOT NULL, image_id INT DEFAULT NULL, domain_id INT DEFAULT NULL, slug VARCHAR(255) DEFAULT NULL, title VARCHAR(255) NOT NULL, url VARCHAR(2048) DEFAULT NULL, body TEXT DEFAULT NULL, type VARCHAR(255) NOT NULL, has_embed BOOLEAN NOT NULL, comment_count INT NOT NULL, score INT NOT NULL, views INT DEFAULT NULL, is_adult BOOLEAN DEFAULT NULL, sticky BOOLEAN NOT NULL, last_active TIMESTAMP(0) WITH TIME ZONE DEFAULT NULL, ip VARCHAR(255) DEFAULT NULL, up_votes INT NOT NULL, down_votes INT NOT NULL, ranking INT NOT NULL, visibility TEXT DEFAULT \\'visible\\' NOT NULL, created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, PRIMARY KEY(id))');\n        $this->addSql('CREATE INDEX IDX_2B219D70A76ED395 ON entry (user_id)');\n        $this->addSql('CREATE INDEX IDX_2B219D703EB84A1D ON entry (magazine_id)');\n        $this->addSql('CREATE INDEX IDX_2B219D703DA5256D ON entry (image_id)');\n        $this->addSql('CREATE INDEX IDX_2B219D70115F0EE5 ON entry (domain_id)');\n        $this->addSql('COMMENT ON COLUMN entry.created_at IS \\'(DC2Type:datetimetz_immutable)\\'');\n        $this->addSql('CREATE TABLE entry_badge (id INT NOT NULL, badge_id INT DEFAULT NULL, entry_id INT DEFAULT NULL, PRIMARY KEY(id))');\n        $this->addSql('CREATE INDEX IDX_7AEA2BBBF7A2C2FC ON entry_badge (badge_id)');\n        $this->addSql('CREATE INDEX IDX_7AEA2BBBBA364942 ON entry_badge (entry_id)');\n        $this->addSql('CREATE TABLE entry_comment (id INT NOT NULL, user_id INT NOT NULL, entry_id INT NOT NULL, magazine_id INT NOT NULL, image_id INT DEFAULT NULL, parent_id INT DEFAULT NULL, root_id INT DEFAULT NULL, body TEXT DEFAULT NULL, last_active TIMESTAMP(0) WITH TIME ZONE NOT NULL, ip VARCHAR(255) DEFAULT NULL, up_votes INT NOT NULL, down_votes INT NOT NULL, visibility TEXT DEFAULT \\'visible\\' NOT NULL, created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, PRIMARY KEY(id))');\n        $this->addSql('CREATE INDEX IDX_B892FDFBA76ED395 ON entry_comment (user_id)');\n        $this->addSql('CREATE INDEX IDX_B892FDFBBA364942 ON entry_comment (entry_id)');\n        $this->addSql('CREATE INDEX IDX_B892FDFB3EB84A1D ON entry_comment (magazine_id)');\n        $this->addSql('CREATE INDEX IDX_B892FDFB3DA5256D ON entry_comment (image_id)');\n        $this->addSql('CREATE INDEX IDX_B892FDFB727ACA70 ON entry_comment (parent_id)');\n        $this->addSql('CREATE INDEX IDX_B892FDFB79066886 ON entry_comment (root_id)');\n        $this->addSql('COMMENT ON COLUMN entry_comment.created_at IS \\'(DC2Type:datetimetz_immutable)\\'');\n        $this->addSql('CREATE TABLE entry_comment_vote (id INT NOT NULL, comment_id INT NOT NULL, user_id INT NOT NULL, author_id INT NOT NULL, choice INT NOT NULL, created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, PRIMARY KEY(id))');\n        $this->addSql('CREATE INDEX IDX_9E561267F8697D13 ON entry_comment_vote (comment_id)');\n        $this->addSql('CREATE INDEX IDX_9E561267A76ED395 ON entry_comment_vote (user_id)');\n        $this->addSql('CREATE INDEX IDX_9E561267F675F31B ON entry_comment_vote (author_id)');\n        $this->addSql('CREATE UNIQUE INDEX user_entry_comment_vote_idx ON entry_comment_vote (user_id, comment_id)');\n        $this->addSql('COMMENT ON COLUMN entry_comment_vote.created_at IS \\'(DC2Type:datetimetz_immutable)\\'');\n        $this->addSql('CREATE TABLE entry_vote (id INT NOT NULL, entry_id INT NOT NULL, user_id INT NOT NULL, author_id INT NOT NULL, choice INT NOT NULL, created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, PRIMARY KEY(id))');\n        $this->addSql('CREATE INDEX IDX_FE32FD77BA364942 ON entry_vote (entry_id)');\n        $this->addSql('CREATE INDEX IDX_FE32FD77A76ED395 ON entry_vote (user_id)');\n        $this->addSql('CREATE INDEX IDX_FE32FD77F675F31B ON entry_vote (author_id)');\n        $this->addSql('CREATE UNIQUE INDEX user_entry_vote_idx ON entry_vote (user_id, entry_id)');\n        $this->addSql('COMMENT ON COLUMN entry_vote.created_at IS \\'(DC2Type:datetimetz_immutable)\\'');\n        $this->addSql('CREATE TABLE image (id INT NOT NULL, file_path VARCHAR(255) NOT NULL, file_name VARCHAR(255) NOT NULL, sha256 BYTEA NOT NULL, width INT DEFAULT NULL, height INT DEFAULT NULL, PRIMARY KEY(id))');\n        $this->addSql('CREATE UNIQUE INDEX images_file_name_idx ON image (file_name)');\n        $this->addSql('CREATE UNIQUE INDEX images_sha256_idx ON image (sha256)');\n        $this->addSql('CREATE TABLE magazine (id INT NOT NULL, cover_id INT DEFAULT NULL, name VARCHAR(25) NOT NULL, title VARCHAR(50) DEFAULT NULL, description TEXT DEFAULT NULL, rules TEXT DEFAULT NULL, subscriptions_count INT NOT NULL, entry_count INT NOT NULL, entry_comment_count INT NOT NULL, post_count INT NOT NULL, post_comment_count INT NOT NULL, is_adult BOOLEAN DEFAULT NULL, custom_css TEXT DEFAULT NULL, custom_js TEXT DEFAULT NULL, visibility TEXT DEFAULT \\'visible\\' NOT NULL, created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, PRIMARY KEY(id))');\n        $this->addSql('CREATE INDEX IDX_378C2FE4922726E9 ON magazine (cover_id)');\n        $this->addSql('CREATE UNIQUE INDEX magazine_name_idx ON magazine (name)');\n        $this->addSql('COMMENT ON COLUMN magazine.created_at IS \\'(DC2Type:datetimetz_immutable)\\'');\n        $this->addSql('CREATE TABLE magazine_ban (id INT NOT NULL, magazine_id INT NOT NULL, user_id INT NOT NULL, banned_by_id INT NOT NULL, reason TEXT DEFAULT NULL, expired_at TIMESTAMP(0) WITH TIME ZONE DEFAULT NULL, created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, PRIMARY KEY(id))');\n        $this->addSql('CREATE INDEX IDX_6A126CE53EB84A1D ON magazine_ban (magazine_id)');\n        $this->addSql('CREATE INDEX IDX_6A126CE5A76ED395 ON magazine_ban (user_id)');\n        $this->addSql('CREATE INDEX IDX_6A126CE5386B8E7 ON magazine_ban (banned_by_id)');\n        $this->addSql('COMMENT ON COLUMN magazine_ban.created_at IS \\'(DC2Type:datetimetz_immutable)\\'');\n        $this->addSql('CREATE TABLE magazine_block (id INT NOT NULL, user_id INT NOT NULL, magazine_id INT NOT NULL, created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, PRIMARY KEY(id))');\n        $this->addSql('CREATE INDEX IDX_41CC6069A76ED395 ON magazine_block (user_id)');\n        $this->addSql('CREATE INDEX IDX_41CC60693EB84A1D ON magazine_block (magazine_id)');\n        $this->addSql('CREATE UNIQUE INDEX magazine_block_idx ON magazine_block (user_id, magazine_id)');\n        $this->addSql('COMMENT ON COLUMN magazine_block.created_at IS \\'(DC2Type:datetimetz_immutable)\\'');\n        $this->addSql('CREATE TABLE magazine_log (id INT NOT NULL, magazine_id INT NOT NULL, user_id INT NOT NULL, entry_id INT DEFAULT NULL, entry_comment_id INT DEFAULT NULL, post_id INT DEFAULT NULL, post_comment_id INT DEFAULT NULL, ban_id INT DEFAULT NULL, created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, log_type TEXT NOT NULL, meta VARCHAR(255) DEFAULT NULL, PRIMARY KEY(id))');\n        $this->addSql('CREATE INDEX IDX_87D3D4C53EB84A1D ON magazine_log (magazine_id)');\n        $this->addSql('CREATE INDEX IDX_87D3D4C5A76ED395 ON magazine_log (user_id)');\n        $this->addSql('CREATE INDEX IDX_87D3D4C5BA364942 ON magazine_log (entry_id)');\n        $this->addSql('CREATE INDEX IDX_87D3D4C560C33421 ON magazine_log (entry_comment_id)');\n        $this->addSql('CREATE INDEX IDX_87D3D4C54B89032C ON magazine_log (post_id)');\n        $this->addSql('CREATE INDEX IDX_87D3D4C5DB1174D2 ON magazine_log (post_comment_id)');\n        $this->addSql('CREATE INDEX IDX_87D3D4C51255CD1D ON magazine_log (ban_id)');\n        $this->addSql('COMMENT ON COLUMN magazine_log.created_at IS \\'(DC2Type:datetimetz_immutable)\\'');\n        $this->addSql('CREATE TABLE magazine_subscription (id INT NOT NULL, user_id INT NOT NULL, magazine_id INT NOT NULL, created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, PRIMARY KEY(id))');\n        $this->addSql('CREATE INDEX IDX_ACCE935A76ED395 ON magazine_subscription (user_id)');\n        $this->addSql('CREATE INDEX IDX_ACCE9353EB84A1D ON magazine_subscription (magazine_id)');\n        $this->addSql('CREATE UNIQUE INDEX magazine_subsription_idx ON magazine_subscription (user_id, magazine_id)');\n        $this->addSql('COMMENT ON COLUMN magazine_subscription.created_at IS \\'(DC2Type:datetimetz_immutable)\\'');\n        $this->addSql('CREATE TABLE message (id INT NOT NULL, thread_id INT NOT NULL, sender_id INT NOT NULL, body TEXT NOT NULL, status VARCHAR(255) NOT NULL, created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, PRIMARY KEY(id))');\n        $this->addSql('CREATE INDEX IDX_B6BD307FE2904019 ON message (thread_id)');\n        $this->addSql('CREATE INDEX IDX_B6BD307FF624B39D ON message (sender_id)');\n        $this->addSql('COMMENT ON COLUMN message.created_at IS \\'(DC2Type:datetimetz_immutable)\\'');\n        $this->addSql('CREATE TABLE message_thread (id INT NOT NULL, updated_at TIMESTAMP(0) WITH TIME ZONE DEFAULT NULL, PRIMARY KEY(id))');\n        $this->addSql('COMMENT ON COLUMN message_thread.updated_at IS \\'(DC2Type:datetimetz_immutable)\\'');\n        $this->addSql('CREATE TABLE message_thread_participants (message_thread_id INT NOT NULL, user_id INT NOT NULL, PRIMARY KEY(message_thread_id, user_id))');\n        $this->addSql('CREATE INDEX IDX_F2DE92908829462F ON message_thread_participants (message_thread_id)');\n        $this->addSql('CREATE INDEX IDX_F2DE9290A76ED395 ON message_thread_participants (user_id)');\n        $this->addSql('CREATE TABLE moderator (id INT NOT NULL, user_id INT NOT NULL, magazine_id INT NOT NULL, is_owner BOOLEAN NOT NULL, is_confirmed BOOLEAN NOT NULL, created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, PRIMARY KEY(id))');\n        $this->addSql('CREATE INDEX IDX_6A30B268A76ED395 ON moderator (user_id)');\n        $this->addSql('CREATE INDEX IDX_6A30B2683EB84A1D ON moderator (magazine_id)');\n        $this->addSql('CREATE UNIQUE INDEX moderator_magazine_user_idx ON moderator (magazine_id, user_id)');\n        $this->addSql('COMMENT ON COLUMN moderator.created_at IS \\'(DC2Type:datetimetz_immutable)\\'');\n        $this->addSql('CREATE TABLE notification (id INT NOT NULL, user_id INT NOT NULL, entry_id INT DEFAULT NULL, entry_comment_id INT DEFAULT NULL, post_id INT DEFAULT NULL, post_comment_id INT DEFAULT NULL, message_id INT DEFAULT NULL, ban_id INT DEFAULT NULL, status VARCHAR(255) NOT NULL, created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, notification_type TEXT NOT NULL, PRIMARY KEY(id))');\n        $this->addSql('CREATE INDEX IDX_BF5476CAA76ED395 ON notification (user_id)');\n        $this->addSql('CREATE INDEX IDX_BF5476CABA364942 ON notification (entry_id)');\n        $this->addSql('CREATE INDEX IDX_BF5476CA60C33421 ON notification (entry_comment_id)');\n        $this->addSql('CREATE INDEX IDX_BF5476CA4B89032C ON notification (post_id)');\n        $this->addSql('CREATE INDEX IDX_BF5476CADB1174D2 ON notification (post_comment_id)');\n        $this->addSql('CREATE INDEX IDX_BF5476CA537A1329 ON notification (message_id)');\n        $this->addSql('CREATE INDEX IDX_BF5476CA1255CD1D ON notification (ban_id)');\n        $this->addSql('COMMENT ON COLUMN notification.created_at IS \\'(DC2Type:datetimetz_immutable)\\'');\n        $this->addSql('CREATE TABLE post (id INT NOT NULL, user_id INT NOT NULL, magazine_id INT NOT NULL, image_id INT DEFAULT NULL, slug VARCHAR(255) DEFAULT NULL, body TEXT DEFAULT NULL, comment_count INT NOT NULL, score INT NOT NULL, is_adult BOOLEAN DEFAULT NULL, last_active TIMESTAMP(0) WITH TIME ZONE DEFAULT NULL, ip VARCHAR(255) DEFAULT NULL, up_votes INT NOT NULL, down_votes INT NOT NULL, ranking INT NOT NULL, visibility TEXT DEFAULT \\'visible\\' NOT NULL, created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, PRIMARY KEY(id))');\n        $this->addSql('CREATE INDEX IDX_5A8A6C8DA76ED395 ON post (user_id)');\n        $this->addSql('CREATE INDEX IDX_5A8A6C8D3EB84A1D ON post (magazine_id)');\n        $this->addSql('CREATE INDEX IDX_5A8A6C8D3DA5256D ON post (image_id)');\n        $this->addSql('COMMENT ON COLUMN post.created_at IS \\'(DC2Type:datetimetz_immutable)\\'');\n        $this->addSql('CREATE TABLE post_comment (id INT NOT NULL, user_id INT NOT NULL, post_id INT NOT NULL, magazine_id INT NOT NULL, image_id INT DEFAULT NULL, parent_id INT DEFAULT NULL, body TEXT DEFAULT NULL, last_active TIMESTAMP(0) WITH TIME ZONE NOT NULL, ip VARCHAR(255) DEFAULT NULL, up_votes INT NOT NULL, down_votes INT NOT NULL, visibility TEXT DEFAULT \\'visible\\' NOT NULL, created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, PRIMARY KEY(id))');\n        $this->addSql('CREATE INDEX IDX_A99CE55FA76ED395 ON post_comment (user_id)');\n        $this->addSql('CREATE INDEX IDX_A99CE55F4B89032C ON post_comment (post_id)');\n        $this->addSql('CREATE INDEX IDX_A99CE55F3EB84A1D ON post_comment (magazine_id)');\n        $this->addSql('CREATE INDEX IDX_A99CE55F3DA5256D ON post_comment (image_id)');\n        $this->addSql('CREATE INDEX IDX_A99CE55F727ACA70 ON post_comment (parent_id)');\n        $this->addSql('COMMENT ON COLUMN post_comment.created_at IS \\'(DC2Type:datetimetz_immutable)\\'');\n        $this->addSql('CREATE TABLE post_comment_vote (id INT NOT NULL, comment_id INT NOT NULL, user_id INT NOT NULL, author_id INT NOT NULL, choice INT NOT NULL, created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, PRIMARY KEY(id))');\n        $this->addSql('CREATE INDEX IDX_D71B5A5BF8697D13 ON post_comment_vote (comment_id)');\n        $this->addSql('CREATE INDEX IDX_D71B5A5BA76ED395 ON post_comment_vote (user_id)');\n        $this->addSql('CREATE INDEX IDX_D71B5A5BF675F31B ON post_comment_vote (author_id)');\n        $this->addSql('CREATE UNIQUE INDEX user_post_comment_vote_idx ON post_comment_vote (user_id, comment_id)');\n        $this->addSql('COMMENT ON COLUMN post_comment_vote.created_at IS \\'(DC2Type:datetimetz_immutable)\\'');\n        $this->addSql('CREATE TABLE post_vote (id INT NOT NULL, post_id INT NOT NULL, user_id INT NOT NULL, author_id INT NOT NULL, choice INT NOT NULL, created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, PRIMARY KEY(id))');\n        $this->addSql('CREATE INDEX IDX_9345E26F4B89032C ON post_vote (post_id)');\n        $this->addSql('CREATE INDEX IDX_9345E26FA76ED395 ON post_vote (user_id)');\n        $this->addSql('CREATE INDEX IDX_9345E26FF675F31B ON post_vote (author_id)');\n        $this->addSql('CREATE UNIQUE INDEX user_post_vote_idx ON post_vote (user_id, post_id)');\n        $this->addSql('COMMENT ON COLUMN post_vote.created_at IS \\'(DC2Type:datetimetz_immutable)\\'');\n        $this->addSql('CREATE TABLE report (id INT NOT NULL, magazine_id INT NOT NULL, reporting_id INT NOT NULL, reported_id INT NOT NULL, considered_by_id INT DEFAULT NULL, entry_id INT DEFAULT NULL, entry_comment_id INT DEFAULT NULL, post_id INT DEFAULT NULL, post_comment_id INT DEFAULT NULL, reason VARCHAR(255) DEFAULT NULL, weight INT NOT NULL, considered_at TIMESTAMP(0) WITH TIME ZONE DEFAULT NULL, status VARCHAR(255) NOT NULL, created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, report_type TEXT NOT NULL, PRIMARY KEY(id))');\n        $this->addSql('CREATE INDEX IDX_C42F77843EB84A1D ON report (magazine_id)');\n        $this->addSql('CREATE INDEX IDX_C42F778427EE0E60 ON report (reporting_id)');\n        $this->addSql('CREATE INDEX IDX_C42F778494BDEEB6 ON report (reported_id)');\n        $this->addSql('CREATE INDEX IDX_C42F7784607E02EB ON report (considered_by_id)');\n        $this->addSql('CREATE INDEX IDX_C42F7784BA364942 ON report (entry_id)');\n        $this->addSql('CREATE INDEX IDX_C42F778460C33421 ON report (entry_comment_id)');\n        $this->addSql('CREATE INDEX IDX_C42F77844B89032C ON report (post_id)');\n        $this->addSql('CREATE INDEX IDX_C42F7784DB1174D2 ON report (post_comment_id)');\n        $this->addSql('COMMENT ON COLUMN report.created_at IS \\'(DC2Type:datetimetz_immutable)\\'');\n        $this->addSql('CREATE TABLE site (id INT NOT NULL, title VARCHAR(255) NOT NULL, enabled BOOLEAN NOT NULL, registration_open BOOLEAN NOT NULL, PRIMARY KEY(id))');\n        $this->addSql('CREATE TABLE \"user\" (id INT NOT NULL, avatar_id INT DEFAULT NULL, email VARCHAR(180) DEFAULT NULL, roles JSONB NOT NULL, username VARCHAR(35) NOT NULL, followers_count INT NOT NULL, theme VARCHAR(255) DEFAULT \\'light\\' NOT NULL, notify_on_new_entry BOOLEAN NOT NULL, notify_on_new_entry_reply BOOLEAN NOT NULL, notify_on_new_entry_comment_reply BOOLEAN NOT NULL, notify_on_new_post BOOLEAN NOT NULL, notify_on_new_post_reply BOOLEAN NOT NULL, notify_on_new_post_comment_reply BOOLEAN NOT NULL, is_verified BOOLEAN NOT NULL, password VARCHAR(255) NOT NULL, created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, PRIMARY KEY(id))');\n        $this->addSql('CREATE UNIQUE INDEX UNIQ_8D93D649E7927C74 ON \"user\" (email)');\n        $this->addSql('CREATE INDEX IDX_8D93D64986383B10 ON \"user\" (avatar_id)');\n        $this->addSql('COMMENT ON COLUMN \"user\".created_at IS \\'(DC2Type:datetimetz_immutable)\\'');\n        $this->addSql('CREATE TABLE user_block (id INT NOT NULL, blocker_id INT NOT NULL, blocked_id INT NOT NULL, created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, PRIMARY KEY(id))');\n        $this->addSql('CREATE INDEX IDX_61D96C7A548D5975 ON user_block (blocker_id)');\n        $this->addSql('CREATE INDEX IDX_61D96C7A21FF5136 ON user_block (blocked_id)');\n        $this->addSql('CREATE UNIQUE INDEX user_block_idx ON user_block (blocker_id, blocked_id)');\n        $this->addSql('COMMENT ON COLUMN user_block.created_at IS \\'(DC2Type:datetimetz_immutable)\\'');\n        $this->addSql('CREATE TABLE user_follow (id INT NOT NULL, follower_id INT NOT NULL, following_id INT NOT NULL, created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, PRIMARY KEY(id))');\n        $this->addSql('CREATE INDEX IDX_D665F4DAC24F853 ON user_follow (follower_id)');\n        $this->addSql('CREATE INDEX IDX_D665F4D1816E3A3 ON user_follow (following_id)');\n        $this->addSql('CREATE UNIQUE INDEX user_follows_idx ON user_follow (follower_id, following_id)');\n        $this->addSql('COMMENT ON COLUMN user_follow.created_at IS \\'(DC2Type:datetimetz_immutable)\\'');\n        $this->addSql('CREATE TABLE view_counter (id INT NOT NULL, entry_id INT NOT NULL, ip TEXT NOT NULL, view_date TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY(id))');\n        $this->addSql('CREATE INDEX IDX_E87F8182BA364942 ON view_counter (entry_id)');\n        $this->addSql('ALTER TABLE badge ADD CONSTRAINT FK_FEF0481D3EB84A1D FOREIGN KEY (magazine_id) REFERENCES magazine (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE entry ADD CONSTRAINT FK_2B219D70A76ED395 FOREIGN KEY (user_id) REFERENCES \"user\" (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE entry ADD CONSTRAINT FK_2B219D703EB84A1D FOREIGN KEY (magazine_id) REFERENCES magazine (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE entry ADD CONSTRAINT FK_2B219D703DA5256D FOREIGN KEY (image_id) REFERENCES image (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE entry ADD CONSTRAINT FK_2B219D70115F0EE5 FOREIGN KEY (domain_id) REFERENCES domain (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE entry_badge ADD CONSTRAINT FK_7AEA2BBBF7A2C2FC FOREIGN KEY (badge_id) REFERENCES badge (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE entry_badge ADD CONSTRAINT FK_7AEA2BBBBA364942 FOREIGN KEY (entry_id) REFERENCES entry (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE entry_comment ADD CONSTRAINT FK_B892FDFBA76ED395 FOREIGN KEY (user_id) REFERENCES \"user\" (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE entry_comment ADD CONSTRAINT FK_B892FDFBBA364942 FOREIGN KEY (entry_id) REFERENCES entry (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE entry_comment ADD CONSTRAINT FK_B892FDFB3EB84A1D FOREIGN KEY (magazine_id) REFERENCES magazine (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE entry_comment ADD CONSTRAINT FK_B892FDFB3DA5256D FOREIGN KEY (image_id) REFERENCES image (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE entry_comment ADD CONSTRAINT FK_B892FDFB727ACA70 FOREIGN KEY (parent_id) REFERENCES entry_comment (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE entry_comment ADD CONSTRAINT FK_B892FDFB79066886 FOREIGN KEY (root_id) REFERENCES entry_comment (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE entry_comment_vote ADD CONSTRAINT FK_9E561267F8697D13 FOREIGN KEY (comment_id) REFERENCES entry_comment (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE entry_comment_vote ADD CONSTRAINT FK_9E561267A76ED395 FOREIGN KEY (user_id) REFERENCES \"user\" (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE entry_comment_vote ADD CONSTRAINT FK_9E561267F675F31B FOREIGN KEY (author_id) REFERENCES \"user\" (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE entry_vote ADD CONSTRAINT FK_FE32FD77BA364942 FOREIGN KEY (entry_id) REFERENCES entry (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE entry_vote ADD CONSTRAINT FK_FE32FD77A76ED395 FOREIGN KEY (user_id) REFERENCES \"user\" (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE entry_vote ADD CONSTRAINT FK_FE32FD77F675F31B FOREIGN KEY (author_id) REFERENCES \"user\" (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE magazine ADD CONSTRAINT FK_378C2FE4922726E9 FOREIGN KEY (cover_id) REFERENCES image (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE magazine_ban ADD CONSTRAINT FK_6A126CE53EB84A1D FOREIGN KEY (magazine_id) REFERENCES magazine (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE magazine_ban ADD CONSTRAINT FK_6A126CE5A76ED395 FOREIGN KEY (user_id) REFERENCES \"user\" (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE magazine_ban ADD CONSTRAINT FK_6A126CE5386B8E7 FOREIGN KEY (banned_by_id) REFERENCES \"user\" (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE magazine_block ADD CONSTRAINT FK_41CC6069A76ED395 FOREIGN KEY (user_id) REFERENCES \"user\" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE magazine_block ADD CONSTRAINT FK_41CC60693EB84A1D FOREIGN KEY (magazine_id) REFERENCES magazine (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE magazine_log ADD CONSTRAINT FK_87D3D4C53EB84A1D FOREIGN KEY (magazine_id) REFERENCES magazine (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE magazine_log ADD CONSTRAINT FK_87D3D4C5A76ED395 FOREIGN KEY (user_id) REFERENCES \"user\" (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE magazine_log ADD CONSTRAINT FK_87D3D4C5BA364942 FOREIGN KEY (entry_id) REFERENCES entry (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE magazine_log ADD CONSTRAINT FK_87D3D4C560C33421 FOREIGN KEY (entry_comment_id) REFERENCES entry_comment (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE magazine_log ADD CONSTRAINT FK_87D3D4C54B89032C FOREIGN KEY (post_id) REFERENCES post (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE magazine_log ADD CONSTRAINT FK_87D3D4C5DB1174D2 FOREIGN KEY (post_comment_id) REFERENCES post_comment (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE magazine_log ADD CONSTRAINT FK_87D3D4C51255CD1D FOREIGN KEY (ban_id) REFERENCES magazine_ban (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE magazine_subscription ADD CONSTRAINT FK_ACCE935A76ED395 FOREIGN KEY (user_id) REFERENCES \"user\" (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE magazine_subscription ADD CONSTRAINT FK_ACCE9353EB84A1D FOREIGN KEY (magazine_id) REFERENCES magazine (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE message ADD CONSTRAINT FK_B6BD307FE2904019 FOREIGN KEY (thread_id) REFERENCES message_thread (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE message ADD CONSTRAINT FK_B6BD307FF624B39D FOREIGN KEY (sender_id) REFERENCES \"user\" (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE message_thread_participants ADD CONSTRAINT FK_F2DE92908829462F FOREIGN KEY (message_thread_id) REFERENCES message_thread (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE message_thread_participants ADD CONSTRAINT FK_F2DE9290A76ED395 FOREIGN KEY (user_id) REFERENCES \"user\" (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE moderator ADD CONSTRAINT FK_6A30B268A76ED395 FOREIGN KEY (user_id) REFERENCES \"user\" (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE moderator ADD CONSTRAINT FK_6A30B2683EB84A1D FOREIGN KEY (magazine_id) REFERENCES magazine (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE notification ADD CONSTRAINT FK_BF5476CAA76ED395 FOREIGN KEY (user_id) REFERENCES \"user\" (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE notification ADD CONSTRAINT FK_BF5476CABA364942 FOREIGN KEY (entry_id) REFERENCES entry (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE notification ADD CONSTRAINT FK_BF5476CA60C33421 FOREIGN KEY (entry_comment_id) REFERENCES entry_comment (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE notification ADD CONSTRAINT FK_BF5476CA4B89032C FOREIGN KEY (post_id) REFERENCES post (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE notification ADD CONSTRAINT FK_BF5476CADB1174D2 FOREIGN KEY (post_comment_id) REFERENCES post_comment (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE notification ADD CONSTRAINT FK_BF5476CA537A1329 FOREIGN KEY (message_id) REFERENCES message (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE notification ADD CONSTRAINT FK_BF5476CA1255CD1D FOREIGN KEY (ban_id) REFERENCES magazine_ban (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE post ADD CONSTRAINT FK_5A8A6C8DA76ED395 FOREIGN KEY (user_id) REFERENCES \"user\" (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE post ADD CONSTRAINT FK_5A8A6C8D3EB84A1D FOREIGN KEY (magazine_id) REFERENCES magazine (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE post ADD CONSTRAINT FK_5A8A6C8D3DA5256D FOREIGN KEY (image_id) REFERENCES image (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE post_comment ADD CONSTRAINT FK_A99CE55FA76ED395 FOREIGN KEY (user_id) REFERENCES \"user\" (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE post_comment ADD CONSTRAINT FK_A99CE55F4B89032C FOREIGN KEY (post_id) REFERENCES post (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE post_comment ADD CONSTRAINT FK_A99CE55F3EB84A1D FOREIGN KEY (magazine_id) REFERENCES magazine (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE post_comment ADD CONSTRAINT FK_A99CE55F3DA5256D FOREIGN KEY (image_id) REFERENCES image (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE post_comment ADD CONSTRAINT FK_A99CE55F727ACA70 FOREIGN KEY (parent_id) REFERENCES post_comment (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE post_comment_vote ADD CONSTRAINT FK_D71B5A5BF8697D13 FOREIGN KEY (comment_id) REFERENCES post_comment (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE post_comment_vote ADD CONSTRAINT FK_D71B5A5BA76ED395 FOREIGN KEY (user_id) REFERENCES \"user\" (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE post_comment_vote ADD CONSTRAINT FK_D71B5A5BF675F31B FOREIGN KEY (author_id) REFERENCES \"user\" (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE post_vote ADD CONSTRAINT FK_9345E26F4B89032C FOREIGN KEY (post_id) REFERENCES post (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE post_vote ADD CONSTRAINT FK_9345E26FA76ED395 FOREIGN KEY (user_id) REFERENCES \"user\" (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE post_vote ADD CONSTRAINT FK_9345E26FF675F31B FOREIGN KEY (author_id) REFERENCES \"user\" (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE report ADD CONSTRAINT FK_C42F77843EB84A1D FOREIGN KEY (magazine_id) REFERENCES magazine (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE report ADD CONSTRAINT FK_C42F778427EE0E60 FOREIGN KEY (reporting_id) REFERENCES \"user\" (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE report ADD CONSTRAINT FK_C42F778494BDEEB6 FOREIGN KEY (reported_id) REFERENCES \"user\" (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE report ADD CONSTRAINT FK_C42F7784607E02EB FOREIGN KEY (considered_by_id) REFERENCES \"user\" (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE report ADD CONSTRAINT FK_C42F7784BA364942 FOREIGN KEY (entry_id) REFERENCES entry (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE report ADD CONSTRAINT FK_C42F778460C33421 FOREIGN KEY (entry_comment_id) REFERENCES entry_comment (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE report ADD CONSTRAINT FK_C42F77844B89032C FOREIGN KEY (post_id) REFERENCES post (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE report ADD CONSTRAINT FK_C42F7784DB1174D2 FOREIGN KEY (post_comment_id) REFERENCES post_comment (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE \"user\" ADD CONSTRAINT FK_8D93D64986383B10 FOREIGN KEY (avatar_id) REFERENCES image (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE user_block ADD CONSTRAINT FK_61D96C7A548D5975 FOREIGN KEY (blocker_id) REFERENCES \"user\" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE user_block ADD CONSTRAINT FK_61D96C7A21FF5136 FOREIGN KEY (blocked_id) REFERENCES \"user\" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE user_follow ADD CONSTRAINT FK_D665F4DAC24F853 FOREIGN KEY (follower_id) REFERENCES \"user\" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE user_follow ADD CONSTRAINT FK_D665F4D1816E3A3 FOREIGN KEY (following_id) REFERENCES \"user\" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE view_counter ADD CONSTRAINT FK_E87F8182BA364942 FOREIGN KEY (entry_id) REFERENCES entry (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n    }\n\n    public function down(Schema $schema): void\n    {\n        $this->addSql('CREATE SCHEMA public');\n        $this->addSql('ALTER TABLE entry_badge DROP CONSTRAINT FK_7AEA2BBBF7A2C2FC');\n        $this->addSql('ALTER TABLE entry DROP CONSTRAINT FK_2B219D70115F0EE5');\n        $this->addSql('ALTER TABLE entry_badge DROP CONSTRAINT FK_7AEA2BBBBA364942');\n        $this->addSql('ALTER TABLE entry_comment DROP CONSTRAINT FK_B892FDFBBA364942');\n        $this->addSql('ALTER TABLE entry_vote DROP CONSTRAINT FK_FE32FD77BA364942');\n        $this->addSql('ALTER TABLE magazine_log DROP CONSTRAINT FK_87D3D4C5BA364942');\n        $this->addSql('ALTER TABLE notification DROP CONSTRAINT FK_BF5476CABA364942');\n        $this->addSql('ALTER TABLE report DROP CONSTRAINT FK_C42F7784BA364942');\n        $this->addSql('ALTER TABLE view_counter DROP CONSTRAINT FK_E87F8182BA364942');\n        $this->addSql('ALTER TABLE entry_comment DROP CONSTRAINT FK_B892FDFB727ACA70');\n        $this->addSql('ALTER TABLE entry_comment DROP CONSTRAINT FK_B892FDFB79066886');\n        $this->addSql('ALTER TABLE entry_comment_vote DROP CONSTRAINT FK_9E561267F8697D13');\n        $this->addSql('ALTER TABLE magazine_log DROP CONSTRAINT FK_87D3D4C560C33421');\n        $this->addSql('ALTER TABLE notification DROP CONSTRAINT FK_BF5476CA60C33421');\n        $this->addSql('ALTER TABLE report DROP CONSTRAINT FK_C42F778460C33421');\n        $this->addSql('ALTER TABLE entry DROP CONSTRAINT FK_2B219D703DA5256D');\n        $this->addSql('ALTER TABLE entry_comment DROP CONSTRAINT FK_B892FDFB3DA5256D');\n        $this->addSql('ALTER TABLE magazine DROP CONSTRAINT FK_378C2FE4922726E9');\n        $this->addSql('ALTER TABLE post DROP CONSTRAINT FK_5A8A6C8D3DA5256D');\n        $this->addSql('ALTER TABLE post_comment DROP CONSTRAINT FK_A99CE55F3DA5256D');\n        $this->addSql('ALTER TABLE \"user\" DROP CONSTRAINT FK_8D93D64986383B10');\n        $this->addSql('ALTER TABLE badge DROP CONSTRAINT FK_FEF0481D3EB84A1D');\n        $this->addSql('ALTER TABLE entry DROP CONSTRAINT FK_2B219D703EB84A1D');\n        $this->addSql('ALTER TABLE entry_comment DROP CONSTRAINT FK_B892FDFB3EB84A1D');\n        $this->addSql('ALTER TABLE magazine_ban DROP CONSTRAINT FK_6A126CE53EB84A1D');\n        $this->addSql('ALTER TABLE magazine_block DROP CONSTRAINT FK_41CC60693EB84A1D');\n        $this->addSql('ALTER TABLE magazine_log DROP CONSTRAINT FK_87D3D4C53EB84A1D');\n        $this->addSql('ALTER TABLE magazine_subscription DROP CONSTRAINT FK_ACCE9353EB84A1D');\n        $this->addSql('ALTER TABLE moderator DROP CONSTRAINT FK_6A30B2683EB84A1D');\n        $this->addSql('ALTER TABLE post DROP CONSTRAINT FK_5A8A6C8D3EB84A1D');\n        $this->addSql('ALTER TABLE post_comment DROP CONSTRAINT FK_A99CE55F3EB84A1D');\n        $this->addSql('ALTER TABLE report DROP CONSTRAINT FK_C42F77843EB84A1D');\n        $this->addSql('ALTER TABLE magazine_log DROP CONSTRAINT FK_87D3D4C51255CD1D');\n        $this->addSql('ALTER TABLE notification DROP CONSTRAINT FK_BF5476CA1255CD1D');\n        $this->addSql('ALTER TABLE notification DROP CONSTRAINT FK_BF5476CA537A1329');\n        $this->addSql('ALTER TABLE message DROP CONSTRAINT FK_B6BD307FE2904019');\n        $this->addSql('ALTER TABLE message_thread_participants DROP CONSTRAINT FK_F2DE92908829462F');\n        $this->addSql('ALTER TABLE magazine_log DROP CONSTRAINT FK_87D3D4C54B89032C');\n        $this->addSql('ALTER TABLE notification DROP CONSTRAINT FK_BF5476CA4B89032C');\n        $this->addSql('ALTER TABLE post_comment DROP CONSTRAINT FK_A99CE55F4B89032C');\n        $this->addSql('ALTER TABLE post_vote DROP CONSTRAINT FK_9345E26F4B89032C');\n        $this->addSql('ALTER TABLE report DROP CONSTRAINT FK_C42F77844B89032C');\n        $this->addSql('ALTER TABLE magazine_log DROP CONSTRAINT FK_87D3D4C5DB1174D2');\n        $this->addSql('ALTER TABLE notification DROP CONSTRAINT FK_BF5476CADB1174D2');\n        $this->addSql('ALTER TABLE post_comment DROP CONSTRAINT FK_A99CE55F727ACA70');\n        $this->addSql('ALTER TABLE post_comment_vote DROP CONSTRAINT FK_D71B5A5BF8697D13');\n        $this->addSql('ALTER TABLE report DROP CONSTRAINT FK_C42F7784DB1174D2');\n        $this->addSql('ALTER TABLE entry DROP CONSTRAINT FK_2B219D70A76ED395');\n        $this->addSql('ALTER TABLE entry_comment DROP CONSTRAINT FK_B892FDFBA76ED395');\n        $this->addSql('ALTER TABLE entry_comment_vote DROP CONSTRAINT FK_9E561267A76ED395');\n        $this->addSql('ALTER TABLE entry_comment_vote DROP CONSTRAINT FK_9E561267F675F31B');\n        $this->addSql('ALTER TABLE entry_vote DROP CONSTRAINT FK_FE32FD77A76ED395');\n        $this->addSql('ALTER TABLE entry_vote DROP CONSTRAINT FK_FE32FD77F675F31B');\n        $this->addSql('ALTER TABLE magazine_ban DROP CONSTRAINT FK_6A126CE5A76ED395');\n        $this->addSql('ALTER TABLE magazine_ban DROP CONSTRAINT FK_6A126CE5386B8E7');\n        $this->addSql('ALTER TABLE magazine_block DROP CONSTRAINT FK_41CC6069A76ED395');\n        $this->addSql('ALTER TABLE magazine_log DROP CONSTRAINT FK_87D3D4C5A76ED395');\n        $this->addSql('ALTER TABLE magazine_subscription DROP CONSTRAINT FK_ACCE935A76ED395');\n        $this->addSql('ALTER TABLE message DROP CONSTRAINT FK_B6BD307FF624B39D');\n        $this->addSql('ALTER TABLE message_thread_participants DROP CONSTRAINT FK_F2DE9290A76ED395');\n        $this->addSql('ALTER TABLE moderator DROP CONSTRAINT FK_6A30B268A76ED395');\n        $this->addSql('ALTER TABLE notification DROP CONSTRAINT FK_BF5476CAA76ED395');\n        $this->addSql('ALTER TABLE post DROP CONSTRAINT FK_5A8A6C8DA76ED395');\n        $this->addSql('ALTER TABLE post_comment DROP CONSTRAINT FK_A99CE55FA76ED395');\n        $this->addSql('ALTER TABLE post_comment_vote DROP CONSTRAINT FK_D71B5A5BA76ED395');\n        $this->addSql('ALTER TABLE post_comment_vote DROP CONSTRAINT FK_D71B5A5BF675F31B');\n        $this->addSql('ALTER TABLE post_vote DROP CONSTRAINT FK_9345E26FA76ED395');\n        $this->addSql('ALTER TABLE post_vote DROP CONSTRAINT FK_9345E26FF675F31B');\n        $this->addSql('ALTER TABLE report DROP CONSTRAINT FK_C42F778427EE0E60');\n        $this->addSql('ALTER TABLE report DROP CONSTRAINT FK_C42F778494BDEEB6');\n        $this->addSql('ALTER TABLE report DROP CONSTRAINT FK_C42F7784607E02EB');\n        $this->addSql('ALTER TABLE user_block DROP CONSTRAINT FK_61D96C7A548D5975');\n        $this->addSql('ALTER TABLE user_block DROP CONSTRAINT FK_61D96C7A21FF5136');\n        $this->addSql('ALTER TABLE user_follow DROP CONSTRAINT FK_D665F4DAC24F853');\n        $this->addSql('ALTER TABLE user_follow DROP CONSTRAINT FK_D665F4D1816E3A3');\n        $this->addSql('DROP SEQUENCE badge_id_seq CASCADE');\n        $this->addSql('DROP SEQUENCE domain_id_seq CASCADE');\n        $this->addSql('DROP SEQUENCE entry_id_seq CASCADE');\n        $this->addSql('DROP SEQUENCE entry_badge_id_seq CASCADE');\n        $this->addSql('DROP SEQUENCE entry_comment_id_seq CASCADE');\n        $this->addSql('DROP SEQUENCE entry_comment_vote_id_seq CASCADE');\n        $this->addSql('DROP SEQUENCE entry_vote_id_seq CASCADE');\n        $this->addSql('DROP SEQUENCE image_id_seq CASCADE');\n        $this->addSql('DROP SEQUENCE magazine_id_seq CASCADE');\n        $this->addSql('DROP SEQUENCE magazine_ban_id_seq CASCADE');\n        $this->addSql('DROP SEQUENCE magazine_block_id_seq CASCADE');\n        $this->addSql('DROP SEQUENCE magazine_log_id_seq CASCADE');\n        $this->addSql('DROP SEQUENCE magazine_subscription_id_seq CASCADE');\n        $this->addSql('DROP SEQUENCE message_id_seq CASCADE');\n        $this->addSql('DROP SEQUENCE message_thread_id_seq CASCADE');\n        $this->addSql('DROP SEQUENCE moderator_id_seq CASCADE');\n        $this->addSql('DROP SEQUENCE notification_id_seq CASCADE');\n        $this->addSql('DROP SEQUENCE post_id_seq CASCADE');\n        $this->addSql('DROP SEQUENCE post_comment_id_seq CASCADE');\n        $this->addSql('DROP SEQUENCE post_comment_vote_id_seq CASCADE');\n        $this->addSql('DROP SEQUENCE post_vote_id_seq CASCADE');\n        $this->addSql('DROP SEQUENCE report_id_seq CASCADE');\n        $this->addSql('DROP SEQUENCE site_id_seq CASCADE');\n        $this->addSql('DROP SEQUENCE \"user_id_seq\" CASCADE');\n        $this->addSql('DROP SEQUENCE user_block_id_seq CASCADE');\n        $this->addSql('DROP SEQUENCE user_follow_id_seq CASCADE');\n        $this->addSql('DROP SEQUENCE view_counter_id_seq CASCADE');\n        $this->addSql('DROP TABLE badge');\n        $this->addSql('DROP TABLE domain');\n        $this->addSql('DROP TABLE entry');\n        $this->addSql('DROP TABLE entry_badge');\n        $this->addSql('DROP TABLE entry_comment');\n        $this->addSql('DROP TABLE entry_comment_vote');\n        $this->addSql('DROP TABLE entry_vote');\n        $this->addSql('DROP TABLE image');\n        $this->addSql('DROP TABLE magazine');\n        $this->addSql('DROP TABLE magazine_ban');\n        $this->addSql('DROP TABLE magazine_block');\n        $this->addSql('DROP TABLE magazine_log');\n        $this->addSql('DROP TABLE magazine_subscription');\n        $this->addSql('DROP TABLE message');\n        $this->addSql('DROP TABLE message_thread');\n        $this->addSql('DROP TABLE message_thread_participants');\n        $this->addSql('DROP TABLE moderator');\n        $this->addSql('DROP TABLE notification');\n        $this->addSql('DROP TABLE post');\n        $this->addSql('DROP TABLE post_comment');\n        $this->addSql('DROP TABLE post_comment_vote');\n        $this->addSql('DROP TABLE post_vote');\n        $this->addSql('DROP TABLE report');\n        $this->addSql('DROP TABLE site');\n        $this->addSql('DROP TABLE \"user\"');\n        $this->addSql('DROP TABLE user_block');\n        $this->addSql('DROP TABLE user_follow');\n        $this->addSql('DROP TABLE view_counter');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20210830133327.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\nfinal class Version20210830133327 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return '';\n    }\n\n    public function up(Schema $schema): void\n    {\n        $this->addSql('ALTER TABLE \"user\" ADD mode VARCHAR(255) DEFAULT \\'normal\\' NOT NULL');\n    }\n\n    public function down(Schema $schema): void\n    {\n        $this->addSql('CREATE SCHEMA public');\n        $this->addSql('ALTER TABLE \"user\" DROP mode');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20211016124104.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\nfinal class Version20211016124104 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return '';\n    }\n\n    public function up(Schema $schema): void\n    {\n        $this->addSql('ALTER TABLE badge ALTER magazine_id DROP NOT NULL');\n        $this->addSql('ALTER TABLE badge ALTER name SET NOT NULL');\n        $this->addSql('ALTER TABLE entry ALTER is_adult SET NOT NULL');\n        $this->addSql('ALTER TABLE entry ALTER last_active SET NOT NULL');\n        $this->addSql('ALTER TABLE entry_comment ALTER body SET NOT NULL');\n        $this->addSql('ALTER TABLE magazine ALTER title SET NOT NULL');\n        $this->addSql('ALTER TABLE magazine ALTER is_adult SET NOT NULL');\n        $this->addSql('ALTER TABLE message_thread ALTER updated_at SET NOT NULL');\n        $this->addSql('ALTER TABLE post ALTER is_adult SET NOT NULL');\n        $this->addSql('ALTER TABLE post ALTER last_active SET NOT NULL');\n        $this->addSql('ALTER TABLE post_comment ALTER body SET NOT NULL');\n        $this->addSql('ALTER TABLE site ADD domain VARCHAR(255) NOT NULL');\n        $this->addSql('ALTER TABLE site ADD description TEXT NOT NULL');\n        $this->addSql('ALTER TABLE \"user\" ALTER email SET NOT NULL');\n        $this->addSql('ALTER TABLE view_counter ALTER entry_id DROP NOT NULL');\n    }\n\n    public function down(Schema $schema): void\n    {\n        $this->addSql('CREATE SCHEMA public');\n        $this->addSql('ALTER TABLE site DROP domain');\n        $this->addSql('ALTER TABLE site DROP description');\n        $this->addSql('ALTER TABLE \"user\" ALTER email DROP NOT NULL');\n        $this->addSql('ALTER TABLE magazine ALTER title DROP NOT NULL');\n        $this->addSql('ALTER TABLE magazine ALTER is_adult DROP NOT NULL');\n        $this->addSql('ALTER TABLE badge ALTER magazine_id SET NOT NULL');\n        $this->addSql('ALTER TABLE badge ALTER name DROP NOT NULL');\n        $this->addSql('ALTER TABLE entry ALTER is_adult DROP NOT NULL');\n        $this->addSql('ALTER TABLE entry ALTER last_active DROP NOT NULL');\n        $this->addSql('ALTER TABLE entry_comment ALTER body DROP NOT NULL');\n        $this->addSql('ALTER TABLE post ALTER is_adult DROP NOT NULL');\n        $this->addSql('ALTER TABLE post ALTER last_active DROP NOT NULL');\n        $this->addSql('ALTER TABLE post_comment ALTER body DROP NOT NULL');\n        $this->addSql('ALTER TABLE message_thread ALTER updated_at DROP NOT NULL');\n        $this->addSql('ALTER TABLE view_counter ALTER entry_id SET NOT NULL');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20211107140830.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\nfinal class Version20211107140830 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return '';\n    }\n\n    public function up(Schema $schema): void\n    {\n        $this->addSql('ALTER TABLE magazine ADD last_active TIMESTAMP(0) WITH TIME ZONE DEFAULT NULL');\n    }\n\n    public function down(Schema $schema): void\n    {\n        $this->addSql('CREATE SCHEMA public');\n        $this->addSql('ALTER TABLE magazine DROP last_active');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20211113102713.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\nfinal class Version20211113102713 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return '';\n    }\n\n    public function up(Schema $schema): void\n    {\n        $this->addSql('ALTER TABLE \"user\" ADD cardano_wallet_id VARCHAR(255) DEFAULT NULL');\n    }\n\n    public function down(Schema $schema): void\n    {\n        $this->addSql('CREATE SCHEMA public');\n        $this->addSql('ALTER TABLE \"user\" DROP cardano_wallet_id');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20211117170048.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\n/**\n * Auto-generated Migration: Please modify to your needs!\n */\nfinal class Version20211117170048 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return '';\n    }\n\n    public function up(Schema $schema): void\n    {\n        $this->addSql('CREATE SEQUENCE cardano_payment_init_id_seq INCREMENT BY 1 MINVALUE 1 START 1');\n        $this->addSql('CREATE TABLE cardano_payment_init (id INT NOT NULL, magazine_id INT DEFAULT NULL, user_id INT DEFAULT NULL, entry_id INT DEFAULT NULL, created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, cpi_type TEXT NOT NULL, PRIMARY KEY(id))');\n        $this->addSql('CREATE INDEX IDX_AFB29E403EB84A1D ON cardano_payment_init (magazine_id)');\n        $this->addSql('CREATE INDEX IDX_AFB29E40A76ED395 ON cardano_payment_init (user_id)');\n        $this->addSql('CREATE INDEX IDX_AFB29E40BA364942 ON cardano_payment_init (entry_id)');\n        $this->addSql('COMMENT ON COLUMN cardano_payment_init.created_at IS \\'(DC2Type:datetimetz_immutable)\\'');\n        $this->addSql('ALTER TABLE cardano_payment_init ADD CONSTRAINT FK_AFB29E403EB84A1D FOREIGN KEY (magazine_id) REFERENCES magazine (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE cardano_payment_init ADD CONSTRAINT FK_AFB29E40A76ED395 FOREIGN KEY (user_id) REFERENCES \"user\" (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE cardano_payment_init ADD CONSTRAINT FK_AFB29E40BA364942 FOREIGN KEY (entry_id) REFERENCES entry (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n    }\n\n    public function down(Schema $schema): void\n    {\n        $this->addSql('CREATE SCHEMA public');\n        $this->addSql('DROP SEQUENCE cardano_payment_init_id_seq CASCADE');\n        $this->addSql('DROP TABLE cardano_payment_init');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20211121182824.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\n/**\n * Auto-generated Migration: Please modify to your needs!\n */\nfinal class Version20211121182824 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return '';\n    }\n\n    public function up(Schema $schema): void\n    {\n        // this up() migration is auto-generated, please modify it to your needs\n        $this->addSql('DROP SEQUENCE cardano_payment_init_id_seq CASCADE');\n        $this->addSql('CREATE SEQUENCE cardano_tx_id_seq INCREMENT BY 1 MINVALUE 1 START 1');\n        $this->addSql('CREATE SEQUENCE cardano_tx_init_id_seq INCREMENT BY 1 MINVALUE 1 START 1');\n        $this->addSql('CREATE TABLE cardano_tx (id INT NOT NULL, magazine_id INT DEFAULT NULL, receiver_id INT DEFAULT NULL, sender_id INT DEFAULT NULL, entry_id INT DEFAULT NULL, amount INT NOT NULL, tx_hash VARCHAR(255) NOT NULL, created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, ctx_type TEXT NOT NULL, PRIMARY KEY(id))');\n        $this->addSql('CREATE INDEX IDX_F74C620E3EB84A1D ON cardano_tx (magazine_id)');\n        $this->addSql('CREATE INDEX IDX_F74C620ECD53EDB6 ON cardano_tx (receiver_id)');\n        $this->addSql('CREATE INDEX IDX_F74C620EF624B39D ON cardano_tx (sender_id)');\n        $this->addSql('CREATE INDEX IDX_F74C620EBA364942 ON cardano_tx (entry_id)');\n        $this->addSql('COMMENT ON COLUMN cardano_tx.created_at IS \\'(DC2Type:datetimetz_immutable)\\'');\n        $this->addSql('CREATE TABLE cardano_tx_init (id INT NOT NULL, magazine_id INT DEFAULT NULL, user_id INT DEFAULT NULL, entry_id INT DEFAULT NULL, session_id VARCHAR(255) NOT NULL, created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, cpi_type TEXT NOT NULL, PRIMARY KEY(id))');\n        $this->addSql('CREATE INDEX IDX_973316583EB84A1D ON cardano_tx_init (magazine_id)');\n        $this->addSql('CREATE INDEX IDX_97331658A76ED395 ON cardano_tx_init (user_id)');\n        $this->addSql('CREATE INDEX IDX_97331658BA364942 ON cardano_tx_init (entry_id)');\n        $this->addSql('COMMENT ON COLUMN cardano_tx_init.created_at IS \\'(DC2Type:datetimetz_immutable)\\'');\n        $this->addSql('ALTER TABLE cardano_tx ADD CONSTRAINT FK_F74C620E3EB84A1D FOREIGN KEY (magazine_id) REFERENCES magazine (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE cardano_tx ADD CONSTRAINT FK_F74C620ECD53EDB6 FOREIGN KEY (receiver_id) REFERENCES \"user\" (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE cardano_tx ADD CONSTRAINT FK_F74C620EF624B39D FOREIGN KEY (sender_id) REFERENCES \"user\" (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE cardano_tx ADD CONSTRAINT FK_F74C620EBA364942 FOREIGN KEY (entry_id) REFERENCES entry (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE cardano_tx_init ADD CONSTRAINT FK_973316583EB84A1D FOREIGN KEY (magazine_id) REFERENCES magazine (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE cardano_tx_init ADD CONSTRAINT FK_97331658A76ED395 FOREIGN KEY (user_id) REFERENCES \"user\" (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE cardano_tx_init ADD CONSTRAINT FK_97331658BA364942 FOREIGN KEY (entry_id) REFERENCES entry (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('DROP TABLE cardano_payment_init');\n        $this->addSql('ALTER TABLE entry ADD ada_amount INT DEFAULT 0 NOT NULL');\n        $this->addSql('ALTER TABLE \"user\" ADD cardano_wallet_address VARCHAR(255) DEFAULT NULL');\n    }\n\n    public function down(Schema $schema): void\n    {\n        // this down() migration is auto-generated, please modify it to your needs\n        $this->addSql('CREATE SCHEMA public');\n        $this->addSql('DROP SEQUENCE cardano_tx_id_seq CASCADE');\n        $this->addSql('DROP SEQUENCE cardano_tx_init_id_seq CASCADE');\n        $this->addSql('CREATE SEQUENCE cardano_payment_init_id_seq INCREMENT BY 1 MINVALUE 1 START 1');\n        $this->addSql('CREATE TABLE cardano_payment_init (id INT NOT NULL, magazine_id INT DEFAULT NULL, user_id INT DEFAULT NULL, entry_id INT DEFAULT NULL, created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, cpi_type TEXT NOT NULL, PRIMARY KEY(id))');\n        $this->addSql('CREATE INDEX idx_afb29e40ba364942 ON cardano_payment_init (entry_id)');\n        $this->addSql('CREATE INDEX idx_afb29e40a76ed395 ON cardano_payment_init (user_id)');\n        $this->addSql('CREATE INDEX idx_afb29e403eb84a1d ON cardano_payment_init (magazine_id)');\n        $this->addSql('COMMENT ON COLUMN cardano_payment_init.created_at IS \\'(DC2Type:datetimetz_immutable)\\'');\n        $this->addSql('ALTER TABLE cardano_payment_init ADD CONSTRAINT fk_afb29e403eb84a1d FOREIGN KEY (magazine_id) REFERENCES magazine (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE cardano_payment_init ADD CONSTRAINT fk_afb29e40a76ed395 FOREIGN KEY (user_id) REFERENCES \"user\" (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE cardano_payment_init ADD CONSTRAINT fk_afb29e40ba364942 FOREIGN KEY (entry_id) REFERENCES entry (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('DROP TABLE cardano_tx');\n        $this->addSql('DROP TABLE cardano_tx_init');\n        $this->addSql('ALTER TABLE \"user\" DROP cardano_wallet_address');\n        $this->addSql('ALTER TABLE entry DROP ada_amount');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20211205133802.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\n/**\n * Auto-generated Migration: Please modify to your needs!\n */\nfinal class Version20211205133802 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return '';\n    }\n\n    public function up(Schema $schema): void\n    {\n        $this->addSql('ALTER TABLE notification DROP CONSTRAINT FK_BF5476CABA364942');\n        $this->addSql('ALTER TABLE notification DROP CONSTRAINT FK_BF5476CA60C33421');\n        $this->addSql('ALTER TABLE notification DROP CONSTRAINT FK_BF5476CA4B89032C');\n        $this->addSql('ALTER TABLE notification DROP CONSTRAINT FK_BF5476CADB1174D2');\n        $this->addSql('ALTER TABLE notification DROP CONSTRAINT FK_BF5476CA537A1329');\n        $this->addSql('ALTER TABLE notification DROP CONSTRAINT FK_BF5476CA1255CD1D');\n        $this->addSql('ALTER TABLE notification ADD CONSTRAINT FK_BF5476CABA364942 FOREIGN KEY (entry_id) REFERENCES entry (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE notification ADD CONSTRAINT FK_BF5476CA60C33421 FOREIGN KEY (entry_comment_id) REFERENCES entry_comment (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE notification ADD CONSTRAINT FK_BF5476CA4B89032C FOREIGN KEY (post_id) REFERENCES post (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE notification ADD CONSTRAINT FK_BF5476CADB1174D2 FOREIGN KEY (post_comment_id) REFERENCES post_comment (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE notification ADD CONSTRAINT FK_BF5476CA537A1329 FOREIGN KEY (message_id) REFERENCES message (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE notification ADD CONSTRAINT FK_BF5476CA1255CD1D FOREIGN KEY (ban_id) REFERENCES magazine_ban (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n    }\n\n    public function down(Schema $schema): void\n    {\n        $this->addSql('CREATE SCHEMA public');\n        $this->addSql('ALTER TABLE notification DROP CONSTRAINT fk_bf5476caba364942');\n        $this->addSql('ALTER TABLE notification DROP CONSTRAINT fk_bf5476ca60c33421');\n        $this->addSql('ALTER TABLE notification DROP CONSTRAINT fk_bf5476ca4b89032c');\n        $this->addSql('ALTER TABLE notification DROP CONSTRAINT fk_bf5476cadb1174d2');\n        $this->addSql('ALTER TABLE notification DROP CONSTRAINT fk_bf5476ca537a1329');\n        $this->addSql('ALTER TABLE notification DROP CONSTRAINT fk_bf5476ca1255cd1d');\n        $this->addSql('ALTER TABLE notification ADD CONSTRAINT fk_bf5476caba364942 FOREIGN KEY (entry_id) REFERENCES entry (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE notification ADD CONSTRAINT fk_bf5476ca60c33421 FOREIGN KEY (entry_comment_id) REFERENCES entry_comment (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE notification ADD CONSTRAINT fk_bf5476ca4b89032c FOREIGN KEY (post_id) REFERENCES post (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE notification ADD CONSTRAINT fk_bf5476cadb1174d2 FOREIGN KEY (post_comment_id) REFERENCES post_comment (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE notification ADD CONSTRAINT fk_bf5476ca537a1329 FOREIGN KEY (message_id) REFERENCES message (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE notification ADD CONSTRAINT fk_bf5476ca1255cd1d FOREIGN KEY (ban_id) REFERENCES magazine_ban (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20211220092653.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\n/**\n * Auto-generated Migration: Please modify to your needs!\n */\nfinal class Version20211220092653 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return '';\n    }\n\n    public function up(Schema $schema): void\n    {\n        $this->addSql('ALTER TABLE notification DROP CONSTRAINT FK_BF5476CABA364942');\n        $this->addSql('ALTER TABLE notification DROP CONSTRAINT FK_BF5476CA60C33421');\n        $this->addSql('ALTER TABLE notification DROP CONSTRAINT FK_BF5476CA4B89032C');\n        $this->addSql('ALTER TABLE notification DROP CONSTRAINT FK_BF5476CADB1174D2');\n        $this->addSql('ALTER TABLE notification DROP CONSTRAINT FK_BF5476CA537A1329');\n        $this->addSql('ALTER TABLE notification DROP CONSTRAINT FK_BF5476CA1255CD1D');\n        $this->addSql('ALTER TABLE notification ADD CONSTRAINT FK_BF5476CABA364942 FOREIGN KEY (entry_id) REFERENCES entry (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE notification ADD CONSTRAINT FK_BF5476CA60C33421 FOREIGN KEY (entry_comment_id) REFERENCES entry_comment (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE notification ADD CONSTRAINT FK_BF5476CA4B89032C FOREIGN KEY (post_id) REFERENCES post (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE notification ADD CONSTRAINT FK_BF5476CADB1174D2 FOREIGN KEY (post_comment_id) REFERENCES post_comment (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE notification ADD CONSTRAINT FK_BF5476CA537A1329 FOREIGN KEY (message_id) REFERENCES message (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE notification ADD CONSTRAINT FK_BF5476CA1255CD1D FOREIGN KEY (ban_id) REFERENCES magazine_ban (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE \"user\" ADD is_banned BOOLEAN DEFAULT \\'false\\' NOT NULL');\n    }\n\n    public function down(Schema $schema): void\n    {\n        $this->addSql('CREATE SCHEMA public');\n        $this->addSql('ALTER TABLE \"user\" DROP is_banned');\n        $this->addSql('ALTER TABLE notification DROP CONSTRAINT fk_bf5476caba364942');\n        $this->addSql('ALTER TABLE notification DROP CONSTRAINT fk_bf5476ca60c33421');\n        $this->addSql('ALTER TABLE notification DROP CONSTRAINT fk_bf5476ca4b89032c');\n        $this->addSql('ALTER TABLE notification DROP CONSTRAINT fk_bf5476cadb1174d2');\n        $this->addSql('ALTER TABLE notification DROP CONSTRAINT fk_bf5476ca537a1329');\n        $this->addSql('ALTER TABLE notification DROP CONSTRAINT fk_bf5476ca1255cd1d');\n        $this->addSql('ALTER TABLE notification ADD CONSTRAINT fk_bf5476caba364942 FOREIGN KEY (entry_id) REFERENCES entry (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE notification ADD CONSTRAINT fk_bf5476ca60c33421 FOREIGN KEY (entry_comment_id) REFERENCES entry_comment (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE notification ADD CONSTRAINT fk_bf5476ca4b89032c FOREIGN KEY (post_id) REFERENCES post (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE notification ADD CONSTRAINT fk_bf5476cadb1174d2 FOREIGN KEY (post_comment_id) REFERENCES post_comment (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE notification ADD CONSTRAINT fk_bf5476ca537a1329 FOREIGN KEY (message_id) REFERENCES message (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE notification ADD CONSTRAINT fk_bf5476ca1255cd1d FOREIGN KEY (ban_id) REFERENCES magazine_ban (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20211231174542.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\n/**\n * Auto-generated Migration: Please modify to your needs!\n */\nfinal class Version20211231174542 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return '';\n    }\n\n    public function up(Schema $schema): void\n    {\n        // this up() migration is auto-generated, please modify it to your needs\n        $this->addSql('ALTER TABLE \"user\" ADD hide_images BOOLEAN DEFAULT \\'false\\' NOT NULL');\n        $this->addSql('ALTER TABLE \"user\" ADD show_profile_subscriptions BOOLEAN DEFAULT \\'false\\' NOT NULL');\n        $this->addSql('ALTER TABLE \"user\" ADD show_profile_followings BOOLEAN DEFAULT \\'false\\' NOT NULL');\n    }\n\n    public function down(Schema $schema): void\n    {\n        // this down() migration is auto-generated, please modify it to your needs\n        $this->addSql('CREATE SCHEMA public');\n        $this->addSql('ALTER TABLE \"user\" DROP hide_images');\n        $this->addSql('ALTER TABLE \"user\" DROP show_profile_subscriptions');\n        $this->addSql('ALTER TABLE \"user\" DROP show_profile_followings');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20220116141404.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\n/**\n * Auto-generated Migration: Please modify to your needs!\n */\nfinal class Version20220116141404 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return '';\n    }\n\n    public function up(Schema $schema): void\n    {\n        $this->addSql('ALTER TABLE \"user\" ADD right_pos_images BOOLEAN DEFAULT \\'false\\' NOT NULL');\n    }\n\n    public function down(Schema $schema): void\n    {\n        $this->addSql('CREATE SCHEMA public');\n        $this->addSql('ALTER TABLE \"user\" DROP right_pos_images');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20220123173726.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\n/**\n * Auto-generated Migration: Please modify to your needs!\n */\nfinal class Version20220123173726 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return '';\n    }\n\n    public function up(Schema $schema): void\n    {\n        // this up() migration is auto-generated, please modify it to your needs\n        $this->addSql('ALTER TABLE entry ADD lang VARCHAR(255) DEFAULT NULL');\n        $this->addSql('ALTER TABLE entry ADD is_oc BOOLEAN DEFAULT false NOT NULL');\n    }\n\n    public function down(Schema $schema): void\n    {\n        // this down() migration is auto-generated, please modify it to your needs\n        $this->addSql('CREATE SCHEMA public');\n        $this->addSql('ALTER TABLE entry DROP lang');\n        $this->addSql('ALTER TABLE entry DROP is_oc');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20220125212007.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\n/**\n * Auto-generated Migration: Please modify to your needs!\n */\nfinal class Version20220125212007 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return '';\n    }\n\n    public function up(Schema $schema): void\n    {\n        // this up() migration is auto-generated, please modify it to your needs\n        $this->addSql('ALTER TABLE entry ADD tags TEXT DEFAULT NULL');\n        $this->addSql('COMMENT ON COLUMN entry.tags IS \\'(DC2Type:array)\\'');\n        $this->addSql('ALTER TABLE entry_comment ADD tags TEXT DEFAULT NULL');\n        $this->addSql('COMMENT ON COLUMN entry_comment.tags IS \\'(DC2Type:array)\\'');\n        $this->addSql('ALTER TABLE post ADD tags TEXT DEFAULT NULL');\n        $this->addSql('COMMENT ON COLUMN post.tags IS \\'(DC2Type:array)\\'');\n        $this->addSql('ALTER TABLE post_comment ADD tags TEXT DEFAULT NULL');\n        $this->addSql('COMMENT ON COLUMN post_comment.tags IS \\'(DC2Type:array)\\'');\n    }\n\n    public function down(Schema $schema): void\n    {\n        // this down() migration is auto-generated, please modify it to your needs\n        $this->addSql('CREATE SCHEMA public');\n        $this->addSql('ALTER TABLE entry_comment DROP tags');\n        $this->addSql('ALTER TABLE post DROP tags');\n        $this->addSql('ALTER TABLE post_comment DROP tags');\n        $this->addSql('ALTER TABLE entry DROP tags');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20220131190012.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\n/**\n * Auto-generated Migration: Please modify to your needs!\n */\nfinal class Version20220131190012 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return '';\n    }\n\n    public function up(Schema $schema): void\n    {\n        // this up() migration is auto-generated, please modify it to your needs\n        $this->addSql('ALTER TABLE \"user\" ADD hide_adult BOOLEAN DEFAULT true NOT NULL');\n    }\n\n    public function down(Schema $schema): void\n    {\n        // this down() migration is auto-generated, please modify it to your needs\n        $this->addSql('CREATE SCHEMA public');\n        $this->addSql('ALTER TABLE \"user\" DROP hide_adult');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20220204202829.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\n/**\n * Auto-generated Migration: Please modify to your needs!\n */\nfinal class Version20220204202829 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return '';\n    }\n\n    public function up(Schema $schema): void\n    {\n        $this->addSql('CREATE SEQUENCE domain_block_id_seq INCREMENT BY 1 MINVALUE 1 START 1');\n        $this->addSql('CREATE SEQUENCE domain_subscription_id_seq INCREMENT BY 1 MINVALUE 1 START 1');\n        $this->addSql('CREATE TABLE domain_block (id INT NOT NULL, user_id INT NOT NULL, domain_id INT NOT NULL, created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, PRIMARY KEY(id))');\n        $this->addSql('CREATE INDEX IDX_5060BFF4A76ED395 ON domain_block (user_id)');\n        $this->addSql('CREATE INDEX IDX_5060BFF4115F0EE5 ON domain_block (domain_id)');\n        $this->addSql('CREATE UNIQUE INDEX domain_block_idx ON domain_block (user_id, domain_id)');\n        $this->addSql('COMMENT ON COLUMN domain_block.created_at IS \\'(DC2Type:datetimetz_immutable)\\'');\n        $this->addSql('CREATE TABLE domain_subscription (id INT NOT NULL, user_id INT NOT NULL, domain_id INT NOT NULL, created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, PRIMARY KEY(id))');\n        $this->addSql('CREATE INDEX IDX_3AC9125EA76ED395 ON domain_subscription (user_id)');\n        $this->addSql('CREATE INDEX IDX_3AC9125E115F0EE5 ON domain_subscription (domain_id)');\n        $this->addSql('CREATE UNIQUE INDEX domain_subsription_idx ON domain_subscription (user_id, domain_id)');\n        $this->addSql('COMMENT ON COLUMN domain_subscription.created_at IS \\'(DC2Type:datetimetz_immutable)\\'');\n        $this->addSql('ALTER TABLE domain_block ADD CONSTRAINT FK_5060BFF4A76ED395 FOREIGN KEY (user_id) REFERENCES \"user\" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE domain_block ADD CONSTRAINT FK_5060BFF4115F0EE5 FOREIGN KEY (domain_id) REFERENCES domain (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE domain_subscription ADD CONSTRAINT FK_3AC9125EA76ED395 FOREIGN KEY (user_id) REFERENCES \"user\" (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE domain_subscription ADD CONSTRAINT FK_3AC9125E115F0EE5 FOREIGN KEY (domain_id) REFERENCES domain (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE domain ADD subscriptions_count INT DEFAULT 0 NOT NULL');\n    }\n\n    public function down(Schema $schema): void\n    {\n        $this->addSql('CREATE SCHEMA public');\n        $this->addSql('DROP SEQUENCE domain_block_id_seq CASCADE');\n        $this->addSql('DROP SEQUENCE domain_subscription_id_seq CASCADE');\n        $this->addSql('DROP TABLE domain_block');\n        $this->addSql('DROP TABLE domain_subscription');\n        $this->addSql('ALTER TABLE domain DROP subscriptions_count');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20220206143129.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\n/**\n * Auto-generated Migration: Please modify to your needs!\n */\nfinal class Version20220206143129 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return '';\n    }\n\n    public function up(Schema $schema): void\n    {\n        $this->addSql('CREATE SEQUENCE user_note_id_seq INCREMENT BY 1 MINVALUE 1 START 1');\n        $this->addSql('CREATE TABLE user_note (id INT NOT NULL, user_id INT NOT NULL, target_id INT NOT NULL, body TEXT NOT NULL, created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, PRIMARY KEY(id))');\n        $this->addSql('CREATE INDEX IDX_B53CB6DDA76ED395 ON user_note (user_id)');\n        $this->addSql('CREATE INDEX IDX_B53CB6DD158E0B66 ON user_note (target_id)');\n        $this->addSql('CREATE UNIQUE INDEX user_noted_idx ON user_note (user_id, target_id)');\n        $this->addSql('COMMENT ON COLUMN user_note.created_at IS \\'(DC2Type:datetimetz_immutable)\\'');\n        $this->addSql('ALTER TABLE user_note ADD CONSTRAINT FK_B53CB6DDA76ED395 FOREIGN KEY (user_id) REFERENCES \"user\" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE user_note ADD CONSTRAINT FK_B53CB6DD158E0B66 FOREIGN KEY (target_id) REFERENCES \"user\" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n    }\n\n    public function down(Schema $schema): void\n    {\n        $this->addSql('CREATE SCHEMA public');\n        $this->addSql('DROP SEQUENCE user_note_id_seq CASCADE');\n        $this->addSql('DROP TABLE user_note');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20220208192443.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\n/**\n * Auto-generated Migration: Please modify to your needs!\n */\nfinal class Version20220208192443 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return '';\n    }\n\n    public function up(Schema $schema): void\n    {\n        $this->addSql('CREATE SEQUENCE favourite_id_seq INCREMENT BY 1 MINVALUE 1 START 1');\n        $this->addSql('CREATE TABLE favourite (id INT NOT NULL, magazine_id INT NOT NULL, user_id INT NOT NULL, entry_id INT DEFAULT NULL, entry_comment_id INT DEFAULT NULL, post_id INT DEFAULT NULL, post_comment_id INT DEFAULT NULL, created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, favourite_type TEXT NOT NULL, PRIMARY KEY(id))');\n        $this->addSql('CREATE INDEX IDX_62A2CA193EB84A1D ON favourite (magazine_id)');\n        $this->addSql('CREATE INDEX IDX_62A2CA19A76ED395 ON favourite (user_id)');\n        $this->addSql('CREATE INDEX IDX_62A2CA19BA364942 ON favourite (entry_id)');\n        $this->addSql('CREATE INDEX IDX_62A2CA1960C33421 ON favourite (entry_comment_id)');\n        $this->addSql('CREATE INDEX IDX_62A2CA194B89032C ON favourite (post_id)');\n        $this->addSql('CREATE INDEX IDX_62A2CA19DB1174D2 ON favourite (post_comment_id)');\n        $this->addSql('COMMENT ON COLUMN favourite.created_at IS \\'(DC2Type:datetimetz_immutable)\\'');\n        $this->addSql('ALTER TABLE favourite ADD CONSTRAINT FK_62A2CA193EB84A1D FOREIGN KEY (magazine_id) REFERENCES magazine (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE favourite ADD CONSTRAINT FK_62A2CA19A76ED395 FOREIGN KEY (user_id) REFERENCES \"user\" (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE favourite ADD CONSTRAINT FK_62A2CA19BA364942 FOREIGN KEY (entry_id) REFERENCES entry (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE favourite ADD CONSTRAINT FK_62A2CA1960C33421 FOREIGN KEY (entry_comment_id) REFERENCES entry_comment (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE favourite ADD CONSTRAINT FK_62A2CA194B89032C FOREIGN KEY (post_id) REFERENCES post (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE favourite ADD CONSTRAINT FK_62A2CA19DB1174D2 FOREIGN KEY (post_comment_id) REFERENCES post_comment (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE entry ADD favourite_count INT DEFAULT 0 NOT NULL');\n        $this->addSql('ALTER TABLE entry_comment ADD favourite_count INT DEFAULT 0 NOT NULL');\n        $this->addSql('ALTER TABLE post ADD favourite_count INT DEFAULT 0 NOT NULL');\n        $this->addSql('ALTER TABLE post_comment ADD favourite_count INT DEFAULT 0 NOT NULL');\n    }\n\n    public function down(Schema $schema): void\n    {\n        $this->addSql('CREATE SCHEMA public');\n        $this->addSql('DROP SEQUENCE favourite_id_seq CASCADE');\n        $this->addSql('DROP TABLE favourite');\n        $this->addSql('ALTER TABLE entry DROP favourite_count');\n        $this->addSql('ALTER TABLE entry_comment DROP favourite_count');\n        $this->addSql('ALTER TABLE post DROP favourite_count');\n        $this->addSql('ALTER TABLE post_comment DROP favourite_count');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20220216211707.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\n/**\n * Auto-generated Migration: Please modify to your needs!\n */\nfinal class Version20220216211707 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return '';\n    }\n\n    public function up(Schema $schema): void\n    {\n        $this->addSql('ALTER TABLE \"user\" ADD homepage VARCHAR(255) DEFAULT \\'front_subscribed\\' NOT NULL');\n    }\n\n    public function down(Schema $schema): void\n    {\n        $this->addSql('CREATE SCHEMA public');\n        $this->addSql('ALTER TABLE \"user\" DROP homepage');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20220218220935.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\n/**\n * Auto-generated Migration: Please modify to your needs!\n */\nfinal class Version20220218220935 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return '';\n    }\n\n    public function up(Schema $schema): void\n    {\n        $this->addSql('ALTER TABLE site ADD terms TEXT DEFAULT NULL');\n        $this->addSql('ALTER TABLE site ADD privacy_policy TEXT DEFAULT NULL');\n        $this->addSql('ALTER TABLE site ALTER description DROP NOT NULL');\n    }\n\n    public function down(Schema $schema): void\n    {\n        $this->addSql('CREATE SCHEMA public');\n        $this->addSql('ALTER TABLE site DROP terms');\n        $this->addSql('ALTER TABLE site DROP privacy_policy');\n        $this->addSql('ALTER TABLE site ALTER description SET NOT NULL');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20220306181222.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\n/**\n * Auto-generated Migration: Please modify to your needs!\n */\nfinal class Version20220306181222 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return '';\n    }\n\n    public function up(Schema $schema): void\n    {\n        $this->addSql('ALTER TABLE \"user\" ADD oauth_github_id VARCHAR(255) DEFAULT NULL');\n        $this->addSql('ALTER TABLE \"user\" ADD oauth_google_id VARCHAR(255) DEFAULT NULL');\n        $this->addSql('ALTER TABLE \"user\" ADD oauth_facebook_id VARCHAR(255) DEFAULT NULL');\n    }\n\n    public function down(Schema $schema): void\n    {\n        $this->addSql('CREATE SCHEMA public');\n        $this->addSql('ALTER TABLE \"user\" DROP oauth_github_id');\n        $this->addSql('ALTER TABLE \"user\" DROP oauth_google_id');\n        $this->addSql('ALTER TABLE \"user\" DROP oauth_facebook_id');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20220308201003.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\n/**\n * Auto-generated Migration: Please modify to your needs!\n */\nfinal class Version20220308201003 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return '';\n    }\n\n    public function up(Schema $schema): void\n    {\n        $this->addSql('ALTER TABLE \"user\" ADD featured_magazines TEXT DEFAULT NULL');\n        $this->addSql('COMMENT ON COLUMN \"user\".featured_magazines IS \\'(DC2Type:array)\\'');\n    }\n\n    public function down(Schema $schema): void\n    {\n        // this down() migration is auto-generated, please modify it to your needs\n        $this->addSql('CREATE SCHEMA public');\n        $this->addSql('ALTER TABLE \"user\" DROP featured_magazines');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20220320191810.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\n/**\n * Auto-generated Migration: Please modify to your needs!\n */\nfinal class Version20220320191810 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return '';\n    }\n\n    public function up(Schema $schema): void\n    {\n        // this up() migration is auto-generated, please modify it to your needs\n        $this->addSql('CREATE SEQUENCE award_id_seq INCREMENT BY 1 MINVALUE 1 START 1');\n        $this->addSql('CREATE SEQUENCE award_type_id_seq INCREMENT BY 1 MINVALUE 1 START 1');\n        $this->addSql('CREATE TABLE award (id INT NOT NULL, user_id INT NOT NULL, magazine_id INT DEFAULT NULL, type_id INT DEFAULT NULL, created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, PRIMARY KEY(id))');\n        $this->addSql('CREATE INDEX IDX_8A5B2EE7A76ED395 ON award (user_id)');\n        $this->addSql('CREATE INDEX IDX_8A5B2EE73EB84A1D ON award (magazine_id)');\n        $this->addSql('CREATE INDEX IDX_8A5B2EE7C54C8C93 ON award (type_id)');\n        $this->addSql('COMMENT ON COLUMN award.created_at IS \\'(DC2Type:datetimetz_immutable)\\'');\n        $this->addSql('CREATE TABLE award_type (id INT NOT NULL, name VARCHAR(255) NOT NULL, category VARCHAR(255) NOT NULL, count INT DEFAULT 0 NOT NULL, attributes TEXT DEFAULT NULL, PRIMARY KEY(id))');\n        $this->addSql('COMMENT ON COLUMN award_type.attributes IS \\'(DC2Type:array)\\'');\n        $this->addSql('ALTER TABLE award ADD CONSTRAINT FK_8A5B2EE7A76ED395 FOREIGN KEY (user_id) REFERENCES \"user\" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE award ADD CONSTRAINT FK_8A5B2EE73EB84A1D FOREIGN KEY (magazine_id) REFERENCES magazine (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE award ADD CONSTRAINT FK_8A5B2EE7C54C8C93 FOREIGN KEY (type_id) REFERENCES award_type (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n    }\n\n    public function down(Schema $schema): void\n    {\n        // this down() migration is auto-generated, please modify it to your needs\n        $this->addSql('CREATE SCHEMA public');\n        $this->addSql('ALTER TABLE award DROP CONSTRAINT FK_8A5B2EE7C54C8C93');\n        $this->addSql('DROP SEQUENCE award_id_seq CASCADE');\n        $this->addSql('DROP SEQUENCE award_type_id_seq CASCADE');\n        $this->addSql('DROP TABLE award');\n        $this->addSql('DROP TABLE award_type');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20220404185534.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\n/**\n * Auto-generated Migration: Please modify to your needs!\n */\nfinal class Version20220404185534 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return '';\n    }\n\n    public function up(Schema $schema): void\n    {\n        // this up() migration is auto-generated, please modify it to your needs\n        $this->addSql('ALTER TABLE \"user\" ADD hide_user_avatars BOOLEAN DEFAULT true NOT NULL');\n        $this->addSql('ALTER TABLE \"user\" ADD hide_magazine_avatars BOOLEAN DEFAULT true NOT NULL');\n    }\n\n    public function down(Schema $schema): void\n    {\n        // this down() migration is auto-generated, please modify it to your needs\n        $this->addSql('CREATE SCHEMA public');\n        $this->addSql('ALTER TABLE \"user\" DROP hide_user_avatars');\n        $this->addSql('ALTER TABLE \"user\" DROP hide_magazine_avatars');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20220407171552.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\n/**\n * Auto-generated Migration: Please modify to your needs!\n */\nfinal class Version20220407171552 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return '';\n    }\n\n    public function up(Schema $schema): void\n    {\n        // this up() migration is auto-generated, please modify it to your needs\n        $this->addSql('ALTER TABLE \"user\" ADD entry_popup BOOLEAN DEFAULT false NOT NULL');\n        $this->addSql('ALTER TABLE \"user\" ADD post_popup BOOLEAN DEFAULT false NOT NULL');\n    }\n\n    public function down(Schema $schema): void\n    {\n        // this down() migration is auto-generated, please modify it to your needs\n        $this->addSql('CREATE SCHEMA public');\n        $this->addSql('ALTER TABLE \"user\" DROP entry_popup');\n        $this->addSql('ALTER TABLE \"user\" DROP post_popup');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20220408100230.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\n/**\n * Auto-generated Migration: Please modify to your needs!\n */\nfinal class Version20220408100230 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return '';\n    }\n\n    public function up(Schema $schema): void\n    {\n        // this up() migration is auto-generated, please modify it to your needs\n        $this->addSql('ALTER TABLE entry ADD edited_at TIMESTAMP(0) WITH TIME ZONE DEFAULT NULL');\n        $this->addSql('COMMENT ON COLUMN entry.edited_at IS \\'(DC2Type:datetimetz_immutable)\\'');\n        $this->addSql('ALTER TABLE entry_comment ADD edited_at TIMESTAMP(0) WITH TIME ZONE DEFAULT NULL');\n        $this->addSql('COMMENT ON COLUMN entry_comment.edited_at IS \\'(DC2Type:datetimetz_immutable)\\'');\n        $this->addSql('ALTER TABLE post ADD edited_at TIMESTAMP(0) WITH TIME ZONE DEFAULT NULL');\n        $this->addSql('COMMENT ON COLUMN post.edited_at IS \\'(DC2Type:datetimetz_immutable)\\'');\n        $this->addSql('ALTER TABLE post_comment ADD edited_at TIMESTAMP(0) WITH TIME ZONE DEFAULT NULL');\n        $this->addSql('COMMENT ON COLUMN post_comment.edited_at IS \\'(DC2Type:datetimetz_immutable)\\'');\n    }\n\n    public function down(Schema $schema): void\n    {\n        // this down() migration is auto-generated, please modify it to your needs\n        $this->addSql('CREATE SCHEMA public');\n        $this->addSql('ALTER TABLE entry DROP edited_at');\n        $this->addSql('ALTER TABLE entry_comment DROP edited_at');\n        $this->addSql('ALTER TABLE post DROP edited_at');\n        $this->addSql('ALTER TABLE post_comment DROP edited_at');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20220411203149.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\n/**\n * Auto-generated Migration: Please modify to your needs!\n */\nfinal class Version20220411203149 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return '';\n    }\n\n    public function up(Schema $schema): void\n    {\n        // this up() migration is auto-generated, please modify it to your needs\n        $this->addSql('CREATE SEQUENCE reset_password_request_id_seq INCREMENT BY 1 MINVALUE 1 START 1');\n        $this->addSql('CREATE TABLE reset_password_request (id INT NOT NULL, user_id INT NOT NULL, selector VARCHAR(20) NOT NULL, hashed_token VARCHAR(100) NOT NULL, requested_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, expires_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY(id))');\n        $this->addSql('CREATE INDEX IDX_7CE748AA76ED395 ON reset_password_request (user_id)');\n        $this->addSql('COMMENT ON COLUMN reset_password_request.requested_at IS \\'(DC2Type:datetime_immutable)\\'');\n        $this->addSql('COMMENT ON COLUMN reset_password_request.expires_at IS \\'(DC2Type:datetime_immutable)\\'');\n        $this->addSql('ALTER TABLE reset_password_request ADD CONSTRAINT FK_7CE748AA76ED395 FOREIGN KEY (user_id) REFERENCES \"user\" (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n    }\n\n    public function down(Schema $schema): void\n    {\n        // this down() migration is auto-generated, please modify it to your needs\n        $this->addSql('CREATE SCHEMA public');\n        $this->addSql('DROP SEQUENCE reset_password_request_id_seq CASCADE');\n        $this->addSql('DROP TABLE reset_password_request');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20220421082111.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\n/**\n * Auto-generated Migration: Please modify to your needs!\n */\nfinal class Version20220421082111 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return '';\n    }\n\n    public function up(Schema $schema): void\n    {\n        // this up() migration is auto-generated, please modify it to your needs\n        $this->addSql('ALTER TABLE \"user\" ALTER hide_magazine_avatars SET DEFAULT false');\n    }\n\n    public function down(Schema $schema): void\n    {\n        // this down() migration is auto-generated, please modify it to your needs\n        $this->addSql('CREATE SCHEMA public');\n        $this->addSql('ALTER TABLE \"user\" ALTER hide_magazine_avatars SET DEFAULT true');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20220621144628.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\n/**\n * Auto-generated Migration: Please modify to your needs!\n */\nfinal class Version20220621144628 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return '';\n    }\n\n    public function up(Schema $schema): void\n    {\n        $this->addSql('CREATE SEQUENCE settings_id_seq INCREMENT BY 1 MINVALUE 1 START 1');\n        $this->addSql('CREATE TABLE settings (id INT NOT NULL, name VARCHAR(255) NOT NULL, value VARCHAR(255) DEFAULT NULL, PRIMARY KEY(id))');\n    }\n\n    public function down(Schema $schema): void\n    {\n        $this->addSql('CREATE SCHEMA public');\n        $this->addSql('DROP SEQUENCE settings_id_seq CASCADE');\n        $this->addSql('DROP TABLE settings');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20220705184724.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\n/**\n * Auto-generated Migration: Please modify to your needs!\n */\nfinal class Version20220705184724 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return '';\n    }\n\n    public function up(Schema $schema): void\n    {\n        $this->addSql('ALTER TABLE entry ADD mentions JSONB DEFAULT NULL');\n        $this->addSql('ALTER TABLE entry_comment ADD mentions JSONB DEFAULT NULL');\n        $this->addSql('ALTER TABLE post ADD mentions JSONB DEFAULT NULL');\n        $this->addSql('ALTER TABLE post_comment ADD mentions JSONB DEFAULT NULL');\n    }\n\n    public function down(Schema $schema): void\n    {\n        $this->addSql('CREATE SCHEMA public');\n        $this->addSql('ALTER TABLE entry DROP mentions');\n        $this->addSql('ALTER TABLE post_comment DROP mentions');\n        $this->addSql('ALTER TABLE entry_comment DROP mentions');\n        $this->addSql('ALTER TABLE post DROP mentions');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20220716120139.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\n/**\n * Auto-generated Migration: Please modify to your needs!\n */\nfinal class Version20220716120139 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return '';\n    }\n\n    public function up(Schema $schema): void\n    {\n        // this up() migration is auto-generated, please modify it to your needs\n        $this->addSql('CREATE SEQUENCE ap_inbox_id_seq INCREMENT BY 1 MINVALUE 1 START 1');\n        $this->addSql('CREATE SEQUENCE ap_outbox_id_seq INCREMENT BY 1 MINVALUE 1 START 1');\n        $this->addSql('CREATE TABLE ap_inbox (id INT NOT NULL, body JSON NOT NULL, created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, PRIMARY KEY(id))');\n        $this->addSql('COMMENT ON COLUMN ap_inbox.created_at IS \\'(DC2Type:datetimetz_immutable)\\'');\n        $this->addSql('CREATE TABLE ap_outbox (id INT NOT NULL, user_id INT NOT NULL, magazine_id INT DEFAULT NULL, type VARCHAR(255) NOT NULL, created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, PRIMARY KEY(id))');\n        $this->addSql('CREATE INDEX IDX_FF9FD54A76ED395 ON ap_outbox (user_id)');\n        $this->addSql('CREATE INDEX IDX_FF9FD543EB84A1D ON ap_outbox (magazine_id)');\n        $this->addSql('COMMENT ON COLUMN ap_outbox.created_at IS \\'(DC2Type:datetimetz_immutable)\\'');\n        $this->addSql('ALTER TABLE ap_outbox ADD CONSTRAINT FK_FF9FD54A76ED395 FOREIGN KEY (user_id) REFERENCES \"user\" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE ap_outbox ADD CONSTRAINT FK_FF9FD543EB84A1D FOREIGN KEY (magazine_id) REFERENCES magazine (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n    }\n\n    public function down(Schema $schema): void\n    {\n        // this down() migration is auto-generated, please modify it to your needs\n        $this->addSql('CREATE SCHEMA public');\n        $this->addSql('DROP SEQUENCE ap_inbox_id_seq CASCADE');\n        $this->addSql('DROP SEQUENCE ap_outbox_id_seq CASCADE');\n        $this->addSql('DROP TABLE ap_inbox');\n        $this->addSql('DROP TABLE ap_outbox');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20220716142146.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\n/**\n * Auto-generated Migration: Please modify to your needs!\n */\nfinal class Version20220716142146 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return '';\n    }\n\n    public function up(Schema $schema): void\n    {\n        // this up() migration is auto-generated, please modify it to your needs\n        $this->addSql('ALTER TABLE ap_outbox ADD subject_id VARCHAR(255) NOT NULL');\n        $this->addSql('ALTER TABLE ap_outbox ADD body JSONB DEFAULT NULL');\n    }\n\n    public function down(Schema $schema): void\n    {\n        // this down() migration is auto-generated, please modify it to your needs\n        $this->addSql('CREATE SCHEMA public');\n        $this->addSql('ALTER TABLE ap_outbox DROP subject_id');\n        $this->addSql('ALTER TABLE ap_outbox DROP body');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20220717101149.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\n/**\n * Auto-generated Migration: Please modify to your needs!\n */\nfinal class Version20220717101149 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return '';\n    }\n\n    public function up(Schema $schema): void\n    {\n        // this up() migration is auto-generated, please modify it to your needs\n        $this->addSql('ALTER TABLE magazine ADD private_key TEXT DEFAULT NULL');\n        $this->addSql('ALTER TABLE magazine ADD public_key TEXT DEFAULT NULL');\n        $this->addSql('ALTER TABLE \"user\" ADD private_key TEXT DEFAULT NULL');\n        $this->addSql('ALTER TABLE \"user\" ADD public_key TEXT DEFAULT NULL');\n    }\n\n    public function down(Schema $schema): void\n    {\n        // this down() migration is auto-generated, please modify it to your needs\n        $this->addSql('CREATE SCHEMA public');\n        $this->addSql('ALTER TABLE magazine DROP private_key');\n        $this->addSql('ALTER TABLE magazine DROP public_key');\n        $this->addSql('ALTER TABLE \"user\" DROP private_key');\n        $this->addSql('ALTER TABLE \"user\" DROP public_key');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20220723095813.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\n/**\n * Auto-generated Migration: Please modify to your needs!\n */\nfinal class Version20220723095813 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return '';\n    }\n\n    public function up(Schema $schema): void\n    {\n        // this up() migration is auto-generated, please modify it to your needs\n        $this->addSql('ALTER TABLE magazine ADD ap_id VARCHAR(255) DEFAULT NULL');\n        $this->addSql('ALTER TABLE magazine ADD ap_profile_id VARCHAR(255) DEFAULT NULL');\n        $this->addSql('ALTER TABLE \"user\" ADD ap_id VARCHAR(255) DEFAULT NULL');\n        $this->addSql('ALTER TABLE \"user\" ADD ap_profile_id VARCHAR(255) DEFAULT NULL');\n        $this->addSql('ALTER TABLE \"user\" ALTER email TYPE VARCHAR(255)');\n        $this->addSql('ALTER TABLE \"user\" ALTER username TYPE VARCHAR(255)');\n    }\n\n    public function down(Schema $schema): void\n    {\n        // this down() migration is auto-generated, please modify it to your needs\n        $this->addSql('CREATE SCHEMA public');\n        $this->addSql('ALTER TABLE magazine DROP ap_id');\n        $this->addSql('ALTER TABLE magazine DROP ap_profile_id');\n        $this->addSql('ALTER TABLE \"user\" DROP ap_id');\n        $this->addSql('ALTER TABLE \"user\" DROP ap_profile_id');\n        $this->addSql('ALTER TABLE \"user\" ALTER email TYPE VARCHAR(180)');\n        $this->addSql('ALTER TABLE \"user\" ALTER username TYPE VARCHAR(35)');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20220723182602.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\n/**\n * Auto-generated Migration: Please modify to your needs!\n */\nfinal class Version20220723182602 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return '';\n    }\n\n    public function up(Schema $schema): void\n    {\n        // this up() migration is auto-generated, please modify it to your needs\n        $this->addSql('CREATE SEQUENCE embed_id_seq INCREMENT BY 1 MINVALUE 1 START 1');\n        $this->addSql('CREATE TABLE embed (id INT NOT NULL, url VARCHAR(255) NOT NULL, has_embed BOOLEAN NOT NULL, created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, PRIMARY KEY(id))');\n        $this->addSql('CREATE UNIQUE INDEX url_idx ON embed (url)');\n        $this->addSql('COMMENT ON COLUMN embed.created_at IS \\'(DC2Type:datetimetz_immutable)\\'');\n    }\n\n    public function down(Schema $schema): void\n    {\n        // this down() migration is auto-generated, please modify it to your needs\n        $this->addSql('CREATE SCHEMA public');\n        $this->addSql('DROP SEQUENCE embed_id_seq CASCADE');\n        $this->addSql('DROP TABLE embed');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20220801085018.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\n/**\n * Auto-generated Migration: Please modify to your needs!\n */\nfinal class Version20220801085018 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return '';\n    }\n\n    public function up(Schema $schema): void\n    {\n        // this up() migration is auto-generated, please modify it to your needs\n        $this->addSql('DROP SEQUENCE ap_inbox_id_seq CASCADE');\n        $this->addSql('DROP SEQUENCE ap_outbox_id_seq CASCADE');\n        $this->addSql('CREATE SEQUENCE ap_activity_id_seq INCREMENT BY 1 MINVALUE 1 START 1');\n        $this->addSql('CREATE TABLE ap_activity (id INT NOT NULL, user_id INT NOT NULL, magazine_id INT DEFAULT NULL, subject_id VARCHAR(255) NOT NULL, type VARCHAR(255) NOT NULL, body JSONB DEFAULT NULL, created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, PRIMARY KEY(id))');\n        $this->addSql('CREATE INDEX IDX_68292518A76ED395 ON ap_activity (user_id)');\n        $this->addSql('CREATE INDEX IDX_682925183EB84A1D ON ap_activity (magazine_id)');\n        $this->addSql('COMMENT ON COLUMN ap_activity.created_at IS \\'(DC2Type:datetimetz_immutable)\\'');\n        $this->addSql('ALTER TABLE ap_activity ADD CONSTRAINT FK_68292518A76ED395 FOREIGN KEY (user_id) REFERENCES \"user\" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE ap_activity ADD CONSTRAINT FK_682925183EB84A1D FOREIGN KEY (magazine_id) REFERENCES magazine (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('DROP TABLE ap_inbox');\n        $this->addSql('DROP TABLE ap_outbox');\n    }\n\n    public function down(Schema $schema): void\n    {\n        // this down() migration is auto-generated, please modify it to your needs\n        $this->addSql('CREATE SCHEMA public');\n        $this->addSql('DROP SEQUENCE ap_activity_id_seq CASCADE');\n        $this->addSql('CREATE SEQUENCE ap_inbox_id_seq INCREMENT BY 1 MINVALUE 1 START 1');\n        $this->addSql('CREATE SEQUENCE ap_outbox_id_seq INCREMENT BY 1 MINVALUE 1 START 1');\n        $this->addSql('CREATE TABLE ap_inbox (id INT NOT NULL, body JSON NOT NULL, created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, PRIMARY KEY(id))');\n        $this->addSql('COMMENT ON COLUMN ap_inbox.created_at IS \\'(DC2Type:datetimetz_immutable)\\'');\n        $this->addSql('CREATE TABLE ap_outbox (id INT NOT NULL, user_id INT NOT NULL, magazine_id INT DEFAULT NULL, type VARCHAR(255) NOT NULL, created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, subject_id VARCHAR(255) NOT NULL, body JSONB DEFAULT NULL, PRIMARY KEY(id))');\n        $this->addSql('CREATE INDEX idx_ff9fd543eb84a1d ON ap_outbox (magazine_id)');\n        $this->addSql('CREATE INDEX idx_ff9fd54a76ed395 ON ap_outbox (user_id)');\n        $this->addSql('COMMENT ON COLUMN ap_outbox.created_at IS \\'(DC2Type:datetimetz_immutable)\\'');\n        $this->addSql('ALTER TABLE ap_outbox ADD CONSTRAINT fk_ff9fd54a76ed395 FOREIGN KEY (user_id) REFERENCES \"user\" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE ap_outbox ADD CONSTRAINT fk_ff9fd543eb84a1d FOREIGN KEY (magazine_id) REFERENCES magazine (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('DROP TABLE ap_activity');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20220808150935.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\n/**\n * Auto-generated Migration: Please modify to your needs!\n */\nfinal class Version20220808150935 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return '';\n    }\n\n    public function up(Schema $schema): void\n    {\n        // this up() migration is auto-generated, please modify it to your needs\n        $this->addSql('ALTER TABLE entry ADD ap_id VARCHAR(255) DEFAULT NULL');\n        $this->addSql('ALTER TABLE entry_comment ADD ap_id VARCHAR(255) DEFAULT NULL');\n        $this->addSql('ALTER TABLE post ADD ap_id VARCHAR(255) DEFAULT NULL');\n        $this->addSql('ALTER TABLE post_comment ADD ap_id VARCHAR(255) DEFAULT NULL');\n    }\n\n    public function down(Schema $schema): void\n    {\n        // this down() migration is auto-generated, please modify it to your needs\n        $this->addSql('CREATE SCHEMA public');\n        $this->addSql('ALTER TABLE entry DROP ap_id');\n        $this->addSql('ALTER TABLE entry_comment DROP ap_id');\n        $this->addSql('ALTER TABLE post DROP ap_id');\n        $this->addSql('ALTER TABLE post_comment DROP ap_id');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20220903070858.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\n/**\n * Auto-generated Migration: Please modify to your needs!\n */\nfinal class Version20220903070858 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return '';\n    }\n\n    public function up(Schema $schema): void\n    {\n        // this up() migration is auto-generated, please modify it to your needs\n        $this->addSql('ALTER TABLE \"user\" ADD cover_id INT DEFAULT NULL');\n        $this->addSql('ALTER TABLE \"user\" ADD about VARCHAR(255) DEFAULT NULL');\n        $this->addSql('ALTER TABLE \"user\" ADD fields JSON DEFAULT NULL');\n        $this->addSql('ALTER TABLE \"user\" ADD CONSTRAINT FK_8D93D649922726E9 FOREIGN KEY (cover_id) REFERENCES image (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('CREATE INDEX IDX_8D93D649922726E9 ON \"user\" (cover_id)');\n    }\n\n    public function down(Schema $schema): void\n    {\n        // this down() migration is auto-generated, please modify it to your needs\n        $this->addSql('CREATE SCHEMA public');\n        $this->addSql('ALTER TABLE \"user\" DROP CONSTRAINT FK_8D93D649922726E9');\n        $this->addSql('DROP INDEX IDX_8D93D649922726E9');\n        $this->addSql('ALTER TABLE \"user\" DROP cover_id');\n        $this->addSql('ALTER TABLE \"user\" DROP about');\n        $this->addSql('ALTER TABLE \"user\" DROP fields');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20220911120737.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\n/**\n * Auto-generated Migration: Please modify to your needs!\n */\nfinal class Version20220911120737 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return '';\n    }\n\n    public function up(Schema $schema): void\n    {\n        // this up() migration is auto-generated, please modify it to your needs\n        $this->addSql('ALTER TABLE \"user\" ADD ap_public_url VARCHAR(255) DEFAULT NULL');\n        $this->addSql('ALTER TABLE \"user\" ADD ap_fetched_at TIMESTAMP(0) WITH TIME ZONE DEFAULT NULL');\n        $this->addSql('ALTER TABLE \"user\" ALTER email TYPE VARCHAR(500)');\n        $this->addSql('ALTER TABLE \"user\" ALTER username TYPE VARCHAR(500)');\n        $this->addSql('ALTER TABLE \"user\" ALTER about TYPE TEXT');\n        $this->addSql('CREATE UNIQUE INDEX UNIQ_8D93D649F85E0677 ON \"user\" (username)');\n    }\n\n    public function down(Schema $schema): void\n    {\n        // this down() migration is auto-generated, please modify it to your needs\n        $this->addSql('CREATE SCHEMA public');\n        $this->addSql('DROP INDEX UNIQ_8D93D649F85E0677');\n        $this->addSql('ALTER TABLE \"user\" DROP ap_public_url');\n        $this->addSql('ALTER TABLE \"user\" DROP ap_fetched_at');\n        $this->addSql('ALTER TABLE \"user\" ALTER email TYPE VARCHAR(255)');\n        $this->addSql('ALTER TABLE \"user\" ALTER username TYPE VARCHAR(255)');\n        $this->addSql('ALTER TABLE \"user\" ALTER about TYPE VARCHAR(255)');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20220917102655.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\n/**\n * Auto-generated Migration: Please modify to your needs!\n */\nfinal class Version20220917102655 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return '';\n    }\n\n    public function up(Schema $schema): void\n    {\n        // this up() migration is auto-generated, please modify it to your needs\n        $this->addSql('CREATE SEQUENCE user_follow_request_id_seq INCREMENT BY 1 MINVALUE 1 START 1');\n        $this->addSql('CREATE TABLE user_follow_request (id INT NOT NULL, follower_id INT NOT NULL, following_id INT NOT NULL, created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, PRIMARY KEY(id))');\n        $this->addSql('CREATE INDEX IDX_EE70876AC24F853 ON user_follow_request (follower_id)');\n        $this->addSql('CREATE INDEX IDX_EE708761816E3A3 ON user_follow_request (following_id)');\n        $this->addSql('CREATE UNIQUE INDEX user_follow_requests_idx ON user_follow_request (follower_id, following_id)');\n        $this->addSql('COMMENT ON COLUMN user_follow_request.created_at IS \\'(DC2Type:datetimetz_immutable)\\'');\n        $this->addSql('ALTER TABLE user_follow_request ADD CONSTRAINT FK_EE70876AC24F853 FOREIGN KEY (follower_id) REFERENCES \"user\" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE user_follow_request ADD CONSTRAINT FK_EE708761816E3A3 FOREIGN KEY (following_id) REFERENCES \"user\" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n    }\n\n    public function down(Schema $schema): void\n    {\n        // this down() migration is auto-generated, please modify it to your needs\n        $this->addSql('CREATE SCHEMA public');\n        $this->addSql('DROP SEQUENCE user_follow_request_id_seq CASCADE');\n        $this->addSql('ALTER TABLE user_follow_request DROP CONSTRAINT FK_EE70876AC24F853');\n        $this->addSql('ALTER TABLE user_follow_request DROP CONSTRAINT FK_EE708761816E3A3');\n        $this->addSql('DROP TABLE user_follow_request');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20220918140533.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\n/**\n * Auto-generated Migration: Please modify to your needs!\n */\nfinal class Version20220918140533 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return '';\n    }\n\n    public function up(Schema $schema): void\n    {\n        // this up() migration is auto-generated, please modify it to your needs\n        $this->addSql('ALTER TABLE magazine ADD ap_followers_url VARCHAR(255) DEFAULT NULL');\n        $this->addSql('ALTER TABLE magazine ADD ap_preferred_username VARCHAR(255) DEFAULT NULL');\n        $this->addSql('ALTER TABLE magazine ADD ap_discoverable BOOLEAN DEFAULT NULL');\n        $this->addSql('ALTER TABLE magazine ADD ap_manually_approves_followers BOOLEAN DEFAULT NULL');\n        $this->addSql('ALTER TABLE \"user\" ADD ap_followers_url VARCHAR(255) DEFAULT NULL');\n        $this->addSql('ALTER TABLE \"user\" ADD ap_preferred_username VARCHAR(255) DEFAULT NULL');\n        $this->addSql('ALTER TABLE \"user\" ADD ap_discoverable BOOLEAN DEFAULT NULL');\n        $this->addSql('ALTER TABLE \"user\" ADD ap_manually_approves_followers BOOLEAN DEFAULT NULL');\n    }\n\n    public function down(Schema $schema): void\n    {\n        // this down() migration is auto-generated, please modify it to your needs\n        $this->addSql('CREATE SCHEMA public');\n        $this->addSql('ALTER TABLE magazine DROP ap_followers_url');\n        $this->addSql('ALTER TABLE magazine DROP ap_preferred_username');\n        $this->addSql('ALTER TABLE magazine DROP ap_discoverable');\n        $this->addSql('ALTER TABLE magazine DROP ap_manually_approves_followers');\n        $this->addSql('ALTER TABLE \"user\" DROP ap_followers_url');\n        $this->addSql('ALTER TABLE \"user\" DROP ap_preferred_username');\n        $this->addSql('ALTER TABLE \"user\" DROP ap_discoverable');\n        $this->addSql('ALTER TABLE \"user\" DROP ap_manually_approves_followers');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20220924182955.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\n/**\n * Auto-generated Migration: Please modify to your needs!\n */\nfinal class Version20220924182955 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return '';\n    }\n\n    public function up(Schema $schema): void\n    {\n        // this up() migration is auto-generated, please modify it to your needs\n        $this->addSql('ALTER TABLE magazine ADD ap_public_url VARCHAR(255) DEFAULT NULL');\n        $this->addSql('ALTER TABLE magazine ADD ap_fetched_at TIMESTAMP(0) WITH TIME ZONE DEFAULT NULL');\n    }\n\n    public function down(Schema $schema): void\n    {\n        // this down() migration is auto-generated, please modify it to your needs\n        $this->addSql('CREATE SCHEMA public');\n        $this->addSql('ALTER TABLE magazine DROP ap_public_url');\n        $this->addSql('ALTER TABLE magazine DROP ap_fetched_at');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20221015120344.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\n/**\n * Auto-generated Migration: Please modify to your needs!\n */\nfinal class Version20221015120344 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return '';\n    }\n\n    public function up(Schema $schema): void\n    {\n        $this->addSql('ALTER TABLE image ADD blurhash VARCHAR(255) DEFAULT NULL');\n        $this->addSql('ALTER TABLE image ADD alt_text VARCHAR(255) DEFAULT NULL');\n        $this->addSql('ALTER TABLE image ADD source_url VARCHAR(255) DEFAULT NULL');\n    }\n\n    public function down(Schema $schema): void\n    {\n        $this->addSql('CREATE SCHEMA public');\n        $this->addSql('ALTER TABLE image DROP blurhash');\n        $this->addSql('ALTER TABLE image DROP alt_text');\n        $this->addSql('ALTER TABLE image DROP source_url');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20221030095047.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\n/**\n * Auto-generated Migration: Please modify to your needs!\n */\nfinal class Version20221030095047 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return '';\n    }\n\n    public function up(Schema $schema): void\n    {\n        $this->addSql('ALTER TABLE \"user\" ADD last_active TIMESTAMP(0) WITH TIME ZONE NOT NULL DEFAULT NOW()');\n    }\n\n    public function down(Schema $schema): void\n    {\n        $this->addSql('CREATE SCHEMA public');\n        $this->addSql('ALTER TABLE \"user\" DROP last_active');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20221108164813.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\n/**\n * Auto-generated Migration: Please modify to your needs!\n */\nfinal class Version20221108164813 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return '';\n    }\n\n    public function up(Schema $schema): void\n    {\n        // this up() migration is auto-generated, please modify it to your needs\n        $this->addSql('CREATE UNIQUE INDEX UNIQ_2B219D70904F155E ON entry (ap_id)');\n        $this->addSql('CREATE UNIQUE INDEX UNIQ_B892FDFB904F155E ON entry_comment (ap_id)');\n        $this->addSql('CREATE UNIQUE INDEX UNIQ_378C2FE4904F155E ON magazine (ap_id)');\n        $this->addSql('CREATE UNIQUE INDEX UNIQ_5A8A6C8D904F155E ON post (ap_id)');\n        $this->addSql('CREATE UNIQUE INDEX UNIQ_A99CE55F904F155E ON post_comment (ap_id)');\n        $this->addSql('ALTER TABLE \"user\" ALTER last_active DROP DEFAULT');\n        $this->addSql('CREATE UNIQUE INDEX UNIQ_8D93D649904F155E ON \"user\" (ap_id)');\n    }\n\n    public function down(Schema $schema): void\n    {\n        // this down() migration is auto-generated, please modify it to your needs\n        $this->addSql('CREATE SCHEMA public');\n        $this->addSql('DROP INDEX UNIQ_B892FDFB904F155E');\n        $this->addSql('DROP INDEX UNIQ_378C2FE4904F155E');\n        $this->addSql('DROP INDEX UNIQ_2B219D70904F155E');\n        $this->addSql('DROP INDEX UNIQ_5A8A6C8D904F155E');\n        $this->addSql('DROP INDEX UNIQ_8D93D649904F155E');\n        $this->addSql('ALTER TABLE \"user\" ALTER last_active SET DEFAULT \\'now()\\'');\n        $this->addSql('DROP INDEX UNIQ_A99CE55F904F155E');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20221109161753.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\n/**\n * Auto-generated Migration: Please modify to your needs!\n */\nfinal class Version20221109161753 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return '';\n    }\n\n    public function up(Schema $schema): void\n    {\n        // this up() migration is auto-generated, please modify it to your needs\n        $this->addSql('ALTER TABLE magazine ADD tags JSONB DEFAULT NULL');\n    }\n\n    public function down(Schema $schema): void\n    {\n        // this down() migration is auto-generated, please modify it to your needs\n        $this->addSql('CREATE SCHEMA public');\n        $this->addSql('ALTER TABLE magazine DROP tags');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20221116150037.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\n/**\n * Auto-generated Migration: Please modify to your needs!\n */\nfinal class Version20221116150037 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return '';\n    }\n\n    public function up(Schema $schema): void\n    {\n        $this->addSql('ALTER TABLE \"user\" ALTER show_profile_subscriptions SET DEFAULT true');\n        $this->addSql('ALTER TABLE \"user\" ALTER show_profile_followings SET DEFAULT true');\n    }\n\n    public function down(Schema $schema): void\n    {\n        $this->addSql('CREATE SCHEMA public');\n        $this->addSql('ALTER TABLE \"user\" ALTER show_profile_subscriptions SET DEFAULT false');\n        $this->addSql('ALTER TABLE \"user\" ALTER show_profile_followings SET DEFAULT false');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20221121125723.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\n/**\n * Auto-generated Migration: Please modify to your needs!\n */\nfinal class Version20221121125723 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return '';\n    }\n\n    public function up(Schema $schema): void\n    {\n        // this up() migration is auto-generated, please modify it to your needs\n        $this->addSql('ALTER TABLE magazine ALTER name TYPE VARCHAR(255)');\n        $this->addSql('ALTER TABLE magazine ALTER title TYPE VARCHAR(255)');\n        $this->addSql('ALTER TABLE \"user\" ALTER email TYPE VARCHAR(255)');\n        $this->addSql('ALTER TABLE \"user\" ALTER username TYPE VARCHAR(255)');\n    }\n\n    public function down(Schema $schema): void\n    {\n        // this down() migration is auto-generated, please modify it to your needs\n        $this->addSql('CREATE SCHEMA public');\n        $this->addSql('ALTER TABLE magazine ALTER name TYPE VARCHAR(25)');\n        $this->addSql('ALTER TABLE magazine ALTER title TYPE VARCHAR(50)');\n        $this->addSql('ALTER TABLE \"user\" ALTER email TYPE VARCHAR(500)');\n        $this->addSql('ALTER TABLE \"user\" ALTER username TYPE VARCHAR(500)');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20221124162526.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\n/**\n * Auto-generated Migration: Please modify to your needs!\n */\nfinal class Version20221124162526 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return '';\n    }\n\n    public function up(Schema $schema): void\n    {\n        // this up() migration is auto-generated, please modify it to your needs\n        $this->addSql('ALTER TABLE magazine_log DROP CONSTRAINT FK_87D3D4C51255CD1D');\n        $this->addSql('ALTER TABLE magazine_log DROP CONSTRAINT FK_87D3D4C54B89032C');\n        $this->addSql('ALTER TABLE magazine_log DROP CONSTRAINT FK_87D3D4C560C33421');\n        $this->addSql('ALTER TABLE magazine_log DROP CONSTRAINT FK_87D3D4C5BA364942');\n        $this->addSql('ALTER TABLE magazine_log DROP CONSTRAINT FK_87D3D4C5DB1174D2');\n        $this->addSql('ALTER TABLE magazine_log ADD CONSTRAINT FK_87D3D4C51255CD1D FOREIGN KEY (ban_id) REFERENCES magazine_ban (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE magazine_log ADD CONSTRAINT FK_87D3D4C54B89032C FOREIGN KEY (post_id) REFERENCES post (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE magazine_log ADD CONSTRAINT FK_87D3D4C560C33421 FOREIGN KEY (entry_comment_id) REFERENCES entry_comment (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE magazine_log ADD CONSTRAINT FK_87D3D4C5BA364942 FOREIGN KEY (entry_id) REFERENCES entry (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE magazine_log ADD CONSTRAINT FK_87D3D4C5DB1174D2 FOREIGN KEY (post_comment_id) REFERENCES post_comment (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE notification DROP CONSTRAINT fk_bf5476caa76ed395');\n        $this->addSql('ALTER TABLE notification ADD CONSTRAINT fk_bf5476caa76ed395 FOREIGN KEY (user_id) REFERENCES \"user\" (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE notification DROP CONSTRAINT FK_BF5476CAA76ED395');\n        $this->addSql('ALTER TABLE notification ADD CONSTRAINT FK_BF5476CAA76ED395 FOREIGN KEY (user_id) REFERENCES \"user\" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n    }\n\n    public function down(Schema $schema): void\n    {\n        // this down() migration is auto-generated, please modify it to your needs\n        $this->addSql('CREATE SCHEMA public');\n        $this->addSql('ALTER TABLE magazine_log DROP CONSTRAINT fk_87d3d4c5ba364942');\n        $this->addSql('ALTER TABLE magazine_log DROP CONSTRAINT fk_87d3d4c560c33421');\n        $this->addSql('ALTER TABLE magazine_log DROP CONSTRAINT fk_87d3d4c54b89032c');\n        $this->addSql('ALTER TABLE magazine_log DROP CONSTRAINT fk_87d3d4c5db1174d2');\n        $this->addSql('ALTER TABLE magazine_log DROP CONSTRAINT fk_87d3d4c51255cd1d');\n        $this->addSql('ALTER TABLE magazine_log ADD CONSTRAINT fk_87d3d4c5ba364942 FOREIGN KEY (entry_id) REFERENCES entry (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE magazine_log ADD CONSTRAINT fk_87d3d4c560c33421 FOREIGN KEY (entry_comment_id) REFERENCES entry_comment (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE magazine_log ADD CONSTRAINT fk_87d3d4c54b89032c FOREIGN KEY (post_id) REFERENCES post (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE magazine_log ADD CONSTRAINT fk_87d3d4c5db1174d2 FOREIGN KEY (post_comment_id) REFERENCES post_comment (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE magazine_log ADD CONSTRAINT fk_87d3d4c51255cd1d FOREIGN KEY (ban_id) REFERENCES magazine_ban (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE notification DROP CONSTRAINT fk_bf5476caa76ed395');\n        $this->addSql('ALTER TABLE notification ADD CONSTRAINT fk_bf5476caa76ed395 FOREIGN KEY (user_id) REFERENCES \"user\" (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20221128212959.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\n/**\n * Auto-generated Migration: Please modify to your needs!\n */\nfinal class Version20221128212959 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return '';\n    }\n\n    public function up(Schema $schema): void\n    {\n        // this up() migration is auto-generated, please modify it to your needs\n        $this->addSql('ALTER TABLE \"user\" ALTER theme SET DEFAULT \\'dark\\'');\n    }\n\n    public function down(Schema $schema): void\n    {\n        // this down() migration is auto-generated, please modify it to your needs\n        $this->addSql('CREATE SCHEMA public');\n        $this->addSql('ALTER TABLE \"user\" ALTER theme SET DEFAULT \\'light\\'');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20221202114605.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\n/**\n * Auto-generated Migration: Please modify to your needs!\n */\nfinal class Version20221202114605 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return '';\n    }\n\n    public function up(Schema $schema): void\n    {\n        // this up() migration is auto-generated, please modify it to your needs\n        $this->addSql('ALTER TABLE entry ADD tags_tmp JSONB DEFAULT NULL');\n        $this->addSql('ALTER TABLE entry_comment ADD tags_tmp JSONB DEFAULT NULL');\n        $this->addSql('ALTER TABLE post ADD tags_tmp JSONB DEFAULT NULL');\n        $this->addSql('ALTER TABLE post_comment ADD tags_tmp JSONB DEFAULT NULL');\n    }\n\n    public function down(Schema $schema): void\n    {\n        // this down() migration is auto-generated, please modify it to your needs\n        $this->addSql('CREATE SCHEMA public');\n        $this->addSql('ALTER TABLE entry_comment DROP tags_tmp');\n        $this->addSql('ALTER TABLE entry DROP tags_tmp');\n        $this->addSql('ALTER TABLE post DROP tags_tmp');\n        $this->addSql('ALTER TABLE post_comment DROP tags_tmp');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20221202134944.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\n/**\n * Auto-generated Migration: Please modify to your needs!\n */\nfinal class Version20221202134944 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return '';\n    }\n\n    public function up(Schema $schema): void\n    {\n        // this up() migration is auto-generated, please modify it to your needs\n        $this->addSql('ALTER TABLE entry DROP tags');\n        $this->addSql('ALTER TABLE entry_comment DROP tags');\n        $this->addSql('ALTER TABLE post DROP tags');\n        $this->addSql('ALTER TABLE post_comment DROP tags');\n    }\n\n    public function down(Schema $schema): void\n    {\n        // this down() migration is auto-generated, please modify it to your needs\n        $this->addSql('CREATE SCHEMA public');\n        $this->addSql('ALTER TABLE post_comment ADD tags TEXT DEFAULT NULL');\n        $this->addSql('COMMENT ON COLUMN post_comment.tags IS \\'(DC2Type:array)\\'');\n        $this->addSql('ALTER TABLE entry ADD tags TEXT DEFAULT NULL');\n        $this->addSql('COMMENT ON COLUMN entry.tags IS \\'(DC2Type:array)\\'');\n        $this->addSql('ALTER TABLE post ADD tags TEXT DEFAULT NULL');\n        $this->addSql('COMMENT ON COLUMN post.tags IS \\'(DC2Type:array)\\'');\n        $this->addSql('ALTER TABLE entry_comment ADD tags TEXT DEFAULT NULL');\n        $this->addSql('COMMENT ON COLUMN entry_comment.tags IS \\'(DC2Type:array)\\'');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20221202140020.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\n/**\n * Auto-generated Migration: Please modify to your needs!\n */\nfinal class Version20221202140020 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return '';\n    }\n\n    public function up(Schema $schema): void\n    {\n        $this->addSql('ALTER TABLE entry RENAME COLUMN tags_tmp TO tags');\n        $this->addSql('ALTER TABLE entry_comment RENAME COLUMN tags_tmp TO tags');\n        $this->addSql('ALTER TABLE post RENAME COLUMN tags_tmp TO tags');\n        $this->addSql('ALTER TABLE post_comment RENAME COLUMN tags_tmp TO tags');\n    }\n\n    public function down(Schema $schema): void\n    {\n        $this->addSql('CREATE SCHEMA public');\n        $this->addSql('ALTER TABLE entry RENAME COLUMN tags TO tags_tmp');\n        $this->addSql('ALTER TABLE entry_comment RENAME COLUMN tags TO tags_tmp');\n        $this->addSql('ALTER TABLE post RENAME COLUMN tags TO tags_tmp');\n        $this->addSql('ALTER TABLE post_comment RENAME COLUMN tags TO tags_tmp');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20221214153611.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\n/**\n * Auto-generated Migration: Please modify to your needs!\n */\nfinal class Version20221214153611 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return '';\n    }\n\n    public function up(Schema $schema): void\n    {\n        // this up() migration is auto-generated, please modify it to your needs\n        $this->addSql('ALTER INDEX domain_subsription_idx RENAME TO domain_subscription_idx');\n        $this->addSql('ALTER TABLE magazine_log DROP CONSTRAINT FK_87D3D4C51255CD1D');\n        $this->addSql('ALTER TABLE magazine_log ADD CONSTRAINT FK_87D3D4C51255CD1D FOREIGN KEY (ban_id) REFERENCES magazine_ban (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE message_thread_participants DROP CONSTRAINT FK_F2DE92908829462F');\n        $this->addSql('ALTER TABLE message_thread_participants DROP CONSTRAINT FK_F2DE9290A76ED395');\n        $this->addSql('ALTER TABLE message_thread_participants ADD CONSTRAINT FK_F2DE92908829462F FOREIGN KEY (message_thread_id) REFERENCES message_thread (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE message_thread_participants ADD CONSTRAINT FK_F2DE9290A76ED395 FOREIGN KEY (user_id) REFERENCES \"user\" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE notification DROP CONSTRAINT fk_bf5476cadb1174d2');\n        $this->addSql('ALTER TABLE \"user\" ALTER hide_user_avatars SET DEFAULT false');\n    }\n\n    public function down(Schema $schema): void\n    {\n        // this down() migration is auto-generated, please modify it to your needs\n        $this->addSql('CREATE SCHEMA public');\n        $this->addSql('ALTER TABLE notification ADD CONSTRAINT fk_bf5476cadb1174d2 FOREIGN KEY (post_comment_id) REFERENCES post_comment (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER INDEX domain_subscription_idx RENAME TO domain_subsription_idx');\n        $this->addSql('ALTER TABLE \"user\" ALTER hide_user_avatars SET DEFAULT true');\n        $this->addSql('ALTER TABLE magazine_log DROP CONSTRAINT fk_87d3d4c51255cd1d');\n        $this->addSql('ALTER TABLE magazine_log ADD CONSTRAINT fk_87d3d4c51255cd1d FOREIGN KEY (ban_id) REFERENCES magazine_ban (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE message_thread_participants DROP CONSTRAINT fk_f2de92908829462f');\n        $this->addSql('ALTER TABLE message_thread_participants DROP CONSTRAINT fk_f2de9290a76ed395');\n        $this->addSql('ALTER TABLE message_thread_participants ADD CONSTRAINT fk_f2de92908829462f FOREIGN KEY (message_thread_id) REFERENCES message_thread (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE message_thread_participants ADD CONSTRAINT fk_f2de9290a76ed395 FOREIGN KEY (user_id) REFERENCES \"user\" (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20221222124812.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\n/**\n * Auto-generated Migration: Please modify to your needs!\n */\nfinal class Version20221222124812 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return '';\n    }\n\n    public function up(Schema $schema): void\n    {\n        $this->addSql('ALTER TABLE magazine ADD ap_deleted_at TIMESTAMP(0) WITH TIME ZONE DEFAULT NULL');\n        $this->addSql('ALTER TABLE \"user\" ADD ap_deleted_at TIMESTAMP(0) WITH TIME ZONE DEFAULT NULL');\n    }\n\n    public function down(Schema $schema): void\n    {\n        $this->addSql('CREATE SCHEMA public');\n        $this->addSql('ALTER TABLE \"user\" DROP ap_deleted_at');\n        $this->addSql('ALTER TABLE magazine DROP ap_deleted_at');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20221229160511.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\n/**\n * Auto-generated Migration: Please modify to your needs!\n */\nfinal class Version20221229160511 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return '';\n    }\n\n    public function up(Schema $schema): void\n    {\n        // this up() migration is auto-generated, please modify it to your needs\n        $this->addSql('ALTER TABLE site DROP domain');\n        $this->addSql('ALTER TABLE site DROP title');\n        $this->addSql('ALTER TABLE site DROP enabled');\n        $this->addSql('ALTER TABLE site DROP registration_open');\n        $this->addSql('ALTER TABLE site DROP description');\n    }\n\n    public function down(Schema $schema): void\n    {\n        // this down() migration is auto-generated, please modify it to your needs\n        $this->addSql('CREATE SCHEMA public');\n        $this->addSql('ALTER TABLE site ADD domain VARCHAR(255) NOT NULL');\n        $this->addSql('ALTER TABLE site ADD title VARCHAR(255) NOT NULL');\n        $this->addSql('ALTER TABLE site ADD enabled BOOLEAN NOT NULL');\n        $this->addSql('ALTER TABLE site ADD registration_open BOOLEAN NOT NULL');\n        $this->addSql('ALTER TABLE site ADD description TEXT DEFAULT NULL');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20221229162448.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\n/**\n * Auto-generated Migration: Please modify to your needs!\n */\nfinal class Version20221229162448 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return '';\n    }\n\n    public function up(Schema $schema): void\n    {\n        // this up() migration is auto-generated, please modify it to your needs\n        $this->addSql('ALTER TABLE site ADD faq TEXT DEFAULT NULL');\n    }\n\n    public function down(Schema $schema): void\n    {\n        // this down() migration is auto-generated, please modify it to your needs\n        $this->addSql('CREATE SCHEMA public');\n        $this->addSql('ALTER TABLE site DROP faq');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20230125123959.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\n/**\n * Auto-generated Migration: Please modify to your needs!\n */\nfinal class Version20230125123959 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return '';\n    }\n\n    public function up(Schema $schema): void\n    {\n        // this up() migration is auto-generated, please modify it to your needs\n        $this->addSql('ALTER TABLE notification ADD CONSTRAINT FK_BF5476CADB1174D2 FOREIGN KEY (post_comment_id) REFERENCES post_comment (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE post_comment ADD root_id INT DEFAULT NULL');\n        $this->addSql('ALTER TABLE post_comment ADD update_mark BOOLEAN DEFAULT false NOT NULL');\n        $this->addSql('ALTER TABLE post_comment ADD CONSTRAINT FK_A99CE55F79066886 FOREIGN KEY (root_id) REFERENCES post_comment (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('CREATE INDEX IDX_A99CE55F79066886 ON post_comment (root_id)');\n    }\n\n    public function down(Schema $schema): void\n    {\n        // this down() migration is auto-generated, please modify it to your needs\n        $this->addSql('CREATE SCHEMA public');\n        $this->addSql('ALTER TABLE notification DROP CONSTRAINT FK_BF5476CADB1174D2');\n        $this->addSql('ALTER TABLE post_comment DROP CONSTRAINT FK_A99CE55F79066886');\n        $this->addSql('DROP INDEX IDX_A99CE55F79066886');\n        $this->addSql('ALTER TABLE post_comment DROP root_id');\n        $this->addSql('ALTER TABLE post_comment DROP update_mark');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20230306134010.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\n/**\n * Auto-generated Migration: Please modify to your needs!\n */\nfinal class Version20230306134010 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return '';\n    }\n\n    public function up(Schema $schema): void\n    {\n        // this up() migration is auto-generated, please modify it to your needs\n        $this->addSql('ALTER TABLE \"user\" DROP theme');\n        $this->addSql('ALTER TABLE \"user\" DROP mode');\n        $this->addSql('ALTER TABLE \"user\" DROP right_pos_images');\n        $this->addSql('ALTER TABLE \"user\" DROP hide_user_avatars');\n        $this->addSql('ALTER TABLE \"user\" DROP hide_magazine_avatars');\n        $this->addSql('ALTER TABLE \"user\" DROP entry_popup');\n        $this->addSql('ALTER TABLE \"user\" DROP post_popup');\n    }\n\n    public function down(Schema $schema): void\n    {\n        // this down() migration is auto-generated, please modify it to your needs\n        $this->addSql('CREATE SCHEMA public');\n        $this->addSql('ALTER TABLE \"user\" ADD theme VARCHAR(255) DEFAULT \\'dark\\' NOT NULL');\n        $this->addSql('ALTER TABLE \"user\" ADD mode VARCHAR(255) DEFAULT \\'normal\\' NOT NULL');\n        $this->addSql('ALTER TABLE \"user\" ADD right_pos_images BOOLEAN DEFAULT false NOT NULL');\n        $this->addSql('ALTER TABLE \"user\" ADD hide_user_avatars BOOLEAN DEFAULT false NOT NULL');\n        $this->addSql('ALTER TABLE \"user\" ADD hide_magazine_avatars BOOLEAN DEFAULT false NOT NULL');\n        $this->addSql('ALTER TABLE \"user\" ADD entry_popup BOOLEAN DEFAULT false NOT NULL');\n        $this->addSql('ALTER TABLE \"user\" ADD post_popup BOOLEAN DEFAULT false NOT NULL');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20230314134010.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\n/**\n * Auto-generated Migration: Please modify to your needs!\n */\nfinal class Version20230314134010 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return '';\n    }\n\n    public function up(Schema $schema): void\n    {\n        // this up() migration is auto-generated, please modify it to your needs\n        $this->addSql(\"INSERT INTO \\\"public\\\".\\\"award_type\\\" (\\\"id\\\", \\\"name\\\", \\\"category\\\", \\\"count\\\", \\\"attributes\\\") VALUES\n(1, 'bronze_autobiographer', 'bronze', 0, 'a:0:{}'),\n(2, 'bronze_personality', 'bronze', 0, 'a:0:{}'),\n(3, 'bronze_commentator', 'bronze', 0, 'a:0:{}'),\n(4, 'bronze_scout', 'bronze', 0, 'a:0:{}'),\n(5, 'bronze_redactor', 'bronze', 0, 'a:0:{}'),\n(6, 'bronze_poster', 'bronze', 0, 'a:0:{}'),\n(7, 'bronze_link', 'bronze', 0, 'a:0:{}'),\n(8, 'bronze_article', 'bronze', 0, 'a:0:{}'),\n(9, 'bronze_photo', 'bronze', 0, 'a:0:{}'),\n(10, 'bronze_comment', 'bronze', 0, 'a:0:{}'),\n(11, 'bronze_post', 'bronze', 0, 'a:0:{}'),\n(12, 'bronze_ranking', 'bronze', 0, 'a:0:{}'),\n(13, 'bronze_popular_entry', 'bronze', 0, 'a:0:{}'),\n(14, 'bronze_magazine', 'bronze', 0, 'a:0:{}'),\n(15, 'silver_personality', 'silver', 0, 'a:0:{}'),\n(16, 'silver_commentator', 'silver', 0, 'a:0:{}'),\n(17, 'silver_scout', 'silver', 0, 'a:0:{}'),\n(18, 'silver_redactor', 'silver', 0, 'a:0:{}'),\n(19, 'silver_poster', 'silver', 0, 'a:0:{}'),\n(20, 'silver_link', 'silver', 0, 'a:0:{}'),\n(21, 'silver_article', 'silver', 0, 'a:0:{}'),\n(22, 'silver_photo', 'silver', 0, 'a:0:{}'),\n(23, 'silver_comment', 'silver', 0, 'a:0:{}'),\n(24, 'silver_post', 'silver', 0, 'a:0:{}'),\n(25, 'silver_ranking', 'silver', 0, 'a:0:{}'),\n(26, 'silver_popular_entry', 'silver', 0, 'a:0:{}'),\n(27, 'silver_magazine', 'silver', 0, 'a:0:{}'),\n(28, 'silver_entry_week', 'silver', 0, 'a:0:{}'),\n(29, 'silver_comment_week', 'silver', 0, 'a:0:{}'),\n(30, 'silver_post_week', 'silver', 0, 'a:0:{}'),\n(31, 'gold_personality', 'gold', 0, 'a:0:{}'),\n(32, 'gold_commentator', 'gold', 0, 'a:0:{}'),\n(33, 'gold_scout', 'gold', 0, 'a:0:{}'),\n(34, 'gold_redactor', 'gold', 0, 'a:0:{}'),\n(35, 'gold_poster', 'gold', 0, 'a:0:{}'),\n(36, 'gold_link', 'gold', 0, 'a:0:{}'),\n(37, 'gold_article', 'gold', 0, 'a:0:{}'),\n(38, 'gold_photo', 'gold', 0, 'a:0:{}'),\n(39, 'gold_comment', 'gold', 0, 'a:0:{}'),\n(40, 'gold_post', 'gold', 0, 'a:0:{}'),\n(41, 'gold_ranking', 'gold', 0, 'a:0:{}'),\n(42, 'gold_popular_entry', 'gold', 0, 'a:0:{}'),\n(43, 'gold_magazine', 'gold', 0, 'a:0:{}'),\n(44, 'gold_entry_month', 'gold', 0, 'a:0:{}'),\n(45, 'gold_comment_month', 'gold', 0, 'a:0:{}'),\n(46, 'gold_post_month', 'gold', 0, 'a:0:{}');\");\n    }\n\n    public function down(Schema $schema): void\n    {\n    }\n}\n"
  },
  {
    "path": "migrations/Version20230323160934.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\n/**\n * Auto-generated Migration: Please modify to your needs!\n */\nfinal class Version20230323160934 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return '';\n    }\n\n    public function up(Schema $schema): void\n    {\n        // this up() migration is auto-generated, please modify it to your needs\n        $this->addSql('ALTER TABLE entry ALTER lang SET NOT NULL');\n        $this->addSql('ALTER TABLE entry_comment ADD lang VARCHAR(255) DEFAULT \\'pl\\' NOT NULL');\n        $this->addSql('ALTER TABLE post ADD lang VARCHAR(255) DEFAULT \\'pl\\' NOT NULL');\n        $this->addSql('ALTER TABLE post_comment ADD lang VARCHAR(255) DEFAULT \\'pl\\' NOT NULL');\n    }\n\n    public function down(Schema $schema): void\n    {\n        // this down() migration is auto-generated, please modify it to your needs\n        $this->addSql('CREATE SCHEMA public');\n        $this->addSql('ALTER TABLE post_comment DROP lang');\n        $this->addSql('ALTER TABLE entry_comment DROP lang');\n        $this->addSql('ALTER TABLE entry ALTER lang DROP NOT NULL');\n        $this->addSql('ALTER TABLE post DROP lang');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20230323170745.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\n/**\n * Auto-generated Migration: Please modify to your needs!\n */\nfinal class Version20230323170745 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return '';\n    }\n\n    public function up(Schema $schema): void\n    {\n        // this up() migration is auto-generated, please modify it to your needs\n        $this->addSql('ALTER TABLE entry_comment ADD is_adult BOOLEAN DEFAULT false NOT NULL');\n        $this->addSql('ALTER TABLE entry_comment ALTER lang DROP DEFAULT');\n        $this->addSql('ALTER TABLE post ALTER lang DROP DEFAULT');\n        $this->addSql('ALTER TABLE post_comment ADD is_adult BOOLEAN DEFAULT false NOT NULL');\n        $this->addSql('ALTER TABLE post_comment ALTER lang DROP DEFAULT');\n    }\n\n    public function down(Schema $schema): void\n    {\n        // this down() migration is auto-generated, please modify it to your needs\n        $this->addSql('CREATE SCHEMA public');\n        $this->addSql('ALTER TABLE post_comment DROP is_adult');\n        $this->addSql('ALTER TABLE post_comment ALTER lang SET DEFAULT \\'pl\\'');\n        $this->addSql('ALTER TABLE entry_comment DROP is_adult');\n        $this->addSql('ALTER TABLE entry_comment ALTER lang SET DEFAULT \\'pl\\'');\n        $this->addSql('ALTER TABLE post ALTER lang SET DEFAULT \\'pl\\'');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20230325084833.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\n/**\n * Auto-generated Migration: Please modify to your needs!\n */\nfinal class Version20230325084833 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return '';\n    }\n\n    public function up(Schema $schema): void\n    {\n        // this up() migration is auto-generated, please modify it to your needs\n        $this->addSql('ALTER TABLE entry_comment ALTER is_adult DROP DEFAULT');\n        $this->addSql('ALTER TABLE magazine DROP CONSTRAINT fk_378c2fe4922726e9');\n        $this->addSql('DROP INDEX idx_378c2fe4922726e9');\n        $this->addSql('ALTER TABLE magazine RENAME COLUMN cover_id TO icon_id');\n        $this->addSql('ALTER TABLE magazine ADD CONSTRAINT FK_378C2FE454B9D732 FOREIGN KEY (icon_id) REFERENCES image (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('CREATE INDEX IDX_378C2FE454B9D732 ON magazine (icon_id)');\n        $this->addSql('ALTER TABLE post_comment ALTER is_adult DROP DEFAULT');\n        $this->addSql('ALTER TABLE \"user\" DROP hide_images');\n    }\n\n    public function down(Schema $schema): void\n    {\n        // this down() migration is auto-generated, please modify it to your needs\n        $this->addSql('CREATE SCHEMA public');\n        $this->addSql('ALTER TABLE entry_comment ALTER is_adult SET DEFAULT false');\n        $this->addSql('ALTER TABLE magazine DROP CONSTRAINT FK_378C2FE454B9D732');\n        $this->addSql('DROP INDEX IDX_378C2FE454B9D732');\n        $this->addSql('ALTER TABLE magazine RENAME COLUMN icon_id TO cover_id');\n        $this->addSql('ALTER TABLE magazine ADD CONSTRAINT fk_378c2fe4922726e9 FOREIGN KEY (cover_id) REFERENCES image (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('CREATE INDEX idx_378c2fe4922726e9 ON magazine (cover_id)');\n        $this->addSql('ALTER TABLE post_comment ALTER is_adult SET DEFAULT false');\n        $this->addSql('ALTER TABLE \"user\" ADD hide_images BOOLEAN DEFAULT false NOT NULL');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20230325101955.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\n/**\n * Auto-generated Migration: Please modify to your needs!\n */\nfinal class Version20230325101955 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return '';\n    }\n\n    public function up(Schema $schema): void\n    {\n        // this up() migration is auto-generated, please modify it to your needs\n        $this->addSql('CREATE UNIQUE INDEX badge_magazine_name_idx ON badge (name, magazine_id)');\n    }\n\n    public function down(Schema $schema): void\n    {\n        // this down() migration is auto-generated, please modify it to your needs\n        $this->addSql('CREATE SCHEMA public');\n        $this->addSql('DROP INDEX badge_magazine_name_idx');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20230404080956.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\n/**\n * Auto-generated Migration: Please modify to your needs!\n */\nfinal class Version20230404080956 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return '';\n    }\n\n    public function up(Schema $schema): void\n    {\n        // this up() migration is auto-generated, please modify it to your needs\n        $this->addSql('ALTER TABLE \"user\" ADD add_mentions_entries BOOLEAN DEFAULT false NOT NULL');\n        $this->addSql('ALTER TABLE \"user\" ADD add_mentions_posts BOOLEAN DEFAULT true NOT NULL');\n    }\n\n    public function down(Schema $schema): void\n    {\n        // this down() migration is auto-generated, please modify it to your needs\n        $this->addSql('CREATE SCHEMA public');\n        $this->addSql('ALTER TABLE \"user\" DROP add_mentions_entries');\n        $this->addSql('ALTER TABLE \"user\" DROP add_mentions_posts');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20230411133416.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\n/**\n * Auto-generated Migration: Please modify to your needs!\n */\nfinal class Version20230411133416 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return '';\n    }\n\n    public function up(Schema $schema): void\n    {\n        // this up() migration is auto-generated, please modify it to your needs\n        $this->addSql('ALTER TABLE site ADD about TEXT DEFAULT NULL');\n    }\n\n    public function down(Schema $schema): void\n    {\n        // this down() migration is auto-generated, please modify it to your needs\n        $this->addSql('CREATE SCHEMA public');\n        $this->addSql('ALTER TABLE site DROP about');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20230411143354.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\n/**\n * Auto-generated Migration: Please modify to your needs!\n */\nfinal class Version20230411143354 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return '';\n    }\n\n    public function up(Schema $schema): void\n    {\n        // this up() migration is auto-generated, please modify it to your needs\n        $this->addSql('ALTER TABLE site ADD contact TEXT DEFAULT NULL');\n    }\n\n    public function down(Schema $schema): void\n    {\n        // this down() migration is auto-generated, please modify it to your needs\n        $this->addSql('CREATE SCHEMA public');\n        $this->addSql('ALTER TABLE site DROP contact');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20230412211534.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\n/**\n * Auto-generated Migration: Please modify to your needs!\n */\nfinal class Version20230412211534 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return '';\n    }\n\n    public function up(Schema $schema): void\n    {\n        // this up() migration is auto-generated, please modify it to your needs\n        $this->addSql('CREATE SEQUENCE magazine_subscription_request_id_seq INCREMENT BY 1 MINVALUE 1 START 1');\n        $this->addSql('CREATE TABLE magazine_subscription_request (id INT NOT NULL, user_id INT NOT NULL, magazine_id INT NOT NULL, created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, PRIMARY KEY(id))');\n        $this->addSql('CREATE INDEX IDX_38501651A76ED395 ON magazine_subscription_request (user_id)');\n        $this->addSql('CREATE INDEX IDX_385016513EB84A1D ON magazine_subscription_request (magazine_id)');\n        $this->addSql('CREATE UNIQUE INDEX magazine_subscription_requests_idx ON magazine_subscription_request (user_id, magazine_id)');\n        $this->addSql('COMMENT ON COLUMN magazine_subscription_request.created_at IS \\'(DC2Type:datetimetz_immutable)\\'');\n        $this->addSql('ALTER TABLE magazine_subscription_request ADD CONSTRAINT FK_38501651A76ED395 FOREIGN KEY (user_id) REFERENCES \"user\" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE magazine_subscription_request ADD CONSTRAINT FK_385016513EB84A1D FOREIGN KEY (magazine_id) REFERENCES magazine (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n    }\n\n    public function down(Schema $schema): void\n    {\n        // this down() migration is auto-generated, please modify it to your needs\n        $this->addSql('CREATE SCHEMA public');\n        $this->addSql('DROP SEQUENCE magazine_subscription_request_id_seq CASCADE');\n        $this->addSql('ALTER TABLE magazine_subscription_request DROP CONSTRAINT FK_38501651A76ED395');\n        $this->addSql('ALTER TABLE magazine_subscription_request DROP CONSTRAINT FK_385016513EB84A1D');\n        $this->addSql('DROP TABLE magazine_subscription_request');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20230425103236.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\n/**\n * Auto-generated Migration: Please modify to your needs!\n */\nfinal class Version20230425103236 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return '';\n    }\n\n    public function up(Schema $schema): void\n    {\n        // this up() migration is auto-generated, please modify it to your needs\n        $this->addSql('ALTER TABLE image ALTER alt_text TYPE TEXT');\n    }\n\n    public function down(Schema $schema): void\n    {\n        // this down() migration is auto-generated, please modify it to your needs\n        $this->addSql('CREATE SCHEMA public');\n        $this->addSql('ALTER TABLE image ALTER alt_text TYPE VARCHAR(255)');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20230428130129.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\n/**\n * Auto-generated Migration: Please modify to your needs!\n */\nfinal class Version20230428130129 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return '';\n    }\n\n    public function up(Schema $schema): void\n    {\n        // this up() migration is auto-generated, please modify it to your needs\n        $this->addSql('ALTER TABLE magazine ADD ap_inbox_url VARCHAR(255) DEFAULT NULL');\n        $this->addSql('ALTER TABLE magazine ADD ap_domain VARCHAR(255) DEFAULT NULL');\n        $this->addSql('ALTER TABLE \"user\" ADD ap_inbox_url VARCHAR(255) DEFAULT NULL');\n        $this->addSql('ALTER TABLE \"user\" ADD ap_domain VARCHAR(255) DEFAULT NULL');\n    }\n\n    public function down(Schema $schema): void\n    {\n        // this down() migration is auto-generated, please modify it to your needs\n        $this->addSql('CREATE SCHEMA public');\n        $this->addSql('ALTER TABLE \"user\" DROP ap_inbox_url');\n        $this->addSql('ALTER TABLE \"user\" DROP ap_domain');\n        $this->addSql('ALTER TABLE magazine DROP ap_inbox_url');\n        $this->addSql('ALTER TABLE magazine DROP ap_domain');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20230429053840.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\n/**\n * Auto-generated Migration: Please modify to your needs!\n */\nfinal class Version20230429053840 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return '';\n    }\n\n    public function up(Schema $schema): void\n    {\n        // this up() migration is auto-generated, please modify it to your needs\n        $this->addSql('ALTER TABLE magazine ADD ap_timeout_at TIMESTAMP(0) WITH TIME ZONE DEFAULT NULL');\n        $this->addSql('ALTER TABLE \"user\" ADD ap_timeout_at TIMESTAMP(0) WITH TIME ZONE DEFAULT NULL');\n    }\n\n    public function down(Schema $schema): void\n    {\n        // this down() migration is auto-generated, please modify it to your needs\n        $this->addSql('CREATE SCHEMA public');\n        $this->addSql('ALTER TABLE \"user\" DROP ap_timeout_at');\n        $this->addSql('ALTER TABLE magazine DROP ap_timeout_at');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20230429143017.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\n/**\n * Auto-generated Migration: Please modify to your needs!\n */\nfinal class Version20230429143017 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return '';\n    }\n\n    public function up(Schema $schema): void\n    {\n        // this up() migration is auto-generated, please modify it to your needs\n        $this->addSql('ALTER TABLE site ADD private_key TEXT DEFAULT NULL');\n        $this->addSql('ALTER TABLE site ADD public_key TEXT DEFAULT NULL');\n    }\n\n    public function down(Schema $schema): void\n    {\n        // this down() migration is auto-generated, please modify it to your needs\n        $this->addSql('CREATE SCHEMA public');\n        $this->addSql('ALTER TABLE site DROP private_key');\n        $this->addSql('ALTER TABLE site DROP public_key');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20230504124307.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\n/**\n * Auto-generated Migration: Please modify to your needs!\n */\nfinal class Version20230504124307 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return '';\n    }\n\n    public function up(Schema $schema): void\n    {\n        // this up() migration is auto-generated, please modify it to your needs\n        $this->addSql('ALTER TABLE \"user\" ALTER homepage SET DEFAULT \\'front\\'');\n    }\n\n    public function down(Schema $schema): void\n    {\n        // this down() migration is auto-generated, please modify it to your needs\n        $this->addSql('CREATE SCHEMA public');\n        $this->addSql('ALTER TABLE \"user\" ALTER homepage SET DEFAULT \\'front_subscribed\\'');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20230514143119.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\n/**\n * Auto-generated Migration: Please modify to your needs!\n */\nfinal class Version20230514143119 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return '';\n    }\n\n    public function up(Schema $schema): void\n    {\n        // this up() migration is auto-generated, please modify it to your needs\n        $this->addSql('ALTER TABLE message_thread_participants DROP CONSTRAINT FK_F2DE92908829462F');\n        $this->addSql('ALTER TABLE message_thread_participants DROP CONSTRAINT FK_F2DE9290A76ED395');\n        $this->addSql('ALTER TABLE message_thread_participants ADD CONSTRAINT FK_F2DE92908829462F FOREIGN KEY (message_thread_id) REFERENCES message_thread (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE message_thread_participants ADD CONSTRAINT FK_F2DE9290A76ED395 FOREIGN KEY (user_id) REFERENCES \"user\" (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE settings ADD json JSONB DEFAULT NULL');\n    }\n\n    public function down(Schema $schema): void\n    {\n        // this down() migration is auto-generated, please modify it to your needs\n        $this->addSql('CREATE SCHEMA public');\n        $this->addSql('ALTER TABLE settings DROP json');\n        $this->addSql('ALTER TABLE message_thread_participants DROP CONSTRAINT fk_f2de92908829462f');\n        $this->addSql('ALTER TABLE message_thread_participants DROP CONSTRAINT fk_f2de9290a76ed395');\n        $this->addSql('ALTER TABLE message_thread_participants ADD CONSTRAINT fk_f2de92908829462f FOREIGN KEY (message_thread_id) REFERENCES message_thread (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE message_thread_participants ADD CONSTRAINT fk_f2de9290a76ed395 FOREIGN KEY (user_id) REFERENCES \"user\" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20230521145244.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\n/**\n * Auto-generated Migration: Please modify to your needs!\n */\nfinal class Version20230521145244 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return '';\n    }\n\n    public function up(Schema $schema): void\n    {\n        // this up() migration is auto-generated, please modify it to your needs\n        $this->addSql('ALTER TABLE \"user\" ADD is_deleted BOOLEAN DEFAULT false NOT NULL');\n    }\n\n    public function down(Schema $schema): void\n    {\n        // this down() migration is auto-generated, please modify it to your needs\n        $this->addSql('CREATE SCHEMA public');\n        $this->addSql('ALTER TABLE \"user\" DROP is_deleted');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20230522135602.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\n/**\n * Auto-generated Migration: Please modify to your needs!\n */\nfinal class Version20230522135602 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return '';\n    }\n\n    public function up(Schema $schema): void\n    {\n        // this up() migration is auto-generated, please modify it to your needs\n        $this->addSql('CREATE INDEX entry_visibility_adult_idx ON entry (visibility, is_adult)');\n        $this->addSql('CREATE INDEX entry_visibility_idx ON entry (visibility)');\n        $this->addSql('CREATE INDEX entry_adult_idx ON entry (is_adult)');\n        $this->addSql('CREATE INDEX entry_ranking_idx ON entry (ranking)');\n        $this->addSql('CREATE INDEX entry_created_at_idx ON entry (created_at)');\n        $this->addSql('CREATE INDEX magazine_visibility_adult_idx ON magazine (visibility, is_adult)');\n        $this->addSql('CREATE INDEX magazine_visibility_idx ON magazine (visibility)');\n        $this->addSql('CREATE INDEX magazine_adult_idx ON magazine (is_adult)');\n        $this->addSql('CREATE INDEX post_visibility_adult_idx ON post (visibility, is_adult)');\n        $this->addSql('CREATE INDEX post_visibility_idx ON post (visibility)');\n        $this->addSql('CREATE INDEX post_adult_idx ON post (is_adult)');\n        $this->addSql('CREATE INDEX post_ranking_idx ON post (ranking)');\n        $this->addSql('CREATE INDEX post_created_at_idx ON post (created_at)');\n    }\n\n    public function down(Schema $schema): void\n    {\n        // this down() migration is auto-generated, please modify it to your needs\n        $this->addSql('CREATE SCHEMA public');\n        $this->addSql('DROP INDEX post_visibility_adult_idx');\n        $this->addSql('DROP INDEX post_visibility_idx');\n        $this->addSql('DROP INDEX post_adult_idx');\n        $this->addSql('DROP INDEX post_ranking_idx');\n        $this->addSql('DROP INDEX post_created_at_idx');\n        $this->addSql('DROP INDEX magazine_visibility_adult_idx');\n        $this->addSql('DROP INDEX magazine_visibility_idx');\n        $this->addSql('DROP INDEX magazine_adult_idx');\n        $this->addSql('DROP INDEX entry_visibility_adult_idx');\n        $this->addSql('DROP INDEX entry_visibility_idx');\n        $this->addSql('DROP INDEX entry_adult_idx');\n        $this->addSql('DROP INDEX entry_ranking_idx');\n        $this->addSql('DROP INDEX entry_created_at_idx');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20230525203803.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\n/**\n * Auto-generated Migration: Please modify to your needs!\n */\nfinal class Version20230525203803 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return '';\n    }\n\n    public function up(Schema $schema): void\n    {\n        $this->addSql(\"ALTER TABLE entry ADD COLUMN title_ts tsvector GENERATED ALWAYS AS (to_tsvector('english', title)) STORED;\");\n        $this->addSql(\"ALTER TABLE entry ADD COLUMN body_ts tsvector GENERATED ALWAYS AS (to_tsvector('english', body)) STORED;\");\n        $this->addSql(\"ALTER TABLE post ADD COLUMN body_ts tsvector GENERATED ALWAYS AS (to_tsvector('english', body)) STORED;\");\n        $this->addSql(\"ALTER TABLE post_comment ADD COLUMN body_ts tsvector GENERATED ALWAYS AS (to_tsvector('english', body)) STORED;\");\n        $this->addSql(\"ALTER TABLE entry_comment ADD COLUMN body_ts tsvector GENERATED ALWAYS AS (to_tsvector('english', body)) STORED;\");\n\n        $this->addSql('CREATE INDEX entry_title_ts_idx ON entry USING GIN (title_ts);');\n        $this->addSql('CREATE INDEX entry_body_ts_idx ON entry USING GIN (body_ts);');\n        $this->addSql('CREATE INDEX post_body_ts_idx ON post USING GIN (body_ts);');\n        $this->addSql('CREATE INDEX post_comment_body_ts_idx ON post_comment USING GIN (body_ts);');\n        $this->addSql('CREATE INDEX entry_comment_body_ts_idx ON entry_comment USING GIN (body_ts);');\n    }\n\n    public function down(Schema $schema): void\n    {\n        $this->addSql('CREATE SCHEMA public');\n        $this->addSql('ALTER TABLE entry DROP title_ts');\n        $this->addSql('ALTER TABLE entry DROP body_ts');\n        $this->addSql('ALTER TABLE post DROP body_ts');\n        $this->addSql('ALTER TABLE post_comment DROP body_ts');\n        $this->addSql('ALTER TABLE entry_comment DROP body_ts');\n\n        $this->addSql('DROP INDEX entry_title_ts_idx');\n        $this->addSql('DROP INDEX entry_body_ts_idx');\n        $this->addSql('DROP INDEX post_body_ts_idx');\n        $this->addSql('DROP INDEX post_comment_body_ts_idx');\n        $this->addSql('DROP INDEX entry_comment_body_ts_idx');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20230615085154.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\n/**\n * Auto-generated Migration: Please modify to your needs!\n */\nfinal class Version20230615085154 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return '';\n    }\n\n    public function up(Schema $schema): void\n    {\n        // this up() migration is auto-generated, please modify it to your needs\n        $this->addSql('DROP INDEX entry_title_ts_idx');\n        $this->addSql('DROP INDEX entry_body_ts_idx');\n        $this->addSql('ALTER TABLE entry DROP title_ts');\n        $this->addSql('ALTER TABLE entry DROP body_ts');\n        $this->addSql('CREATE INDEX entry_score_idx ON entry (score)');\n        $this->addSql('CREATE INDEX entry_comment_count_idx ON entry (comment_count)');\n        $this->addSql('DROP INDEX entry_comment_body_ts_idx');\n        $this->addSql('ALTER TABLE entry_comment DROP body_ts');\n        $this->addSql('DROP INDEX post_body_ts_idx');\n        $this->addSql('ALTER TABLE post DROP body_ts');\n        $this->addSql('CREATE INDEX post_score_idx ON post (score)');\n        $this->addSql('CREATE INDEX post_comment_count_idx ON post (comment_count)');\n        $this->addSql('DROP INDEX post_comment_body_ts_idx');\n        $this->addSql('ALTER TABLE post_comment DROP body_ts');\n    }\n\n    public function down(Schema $schema): void\n    {\n        // this down() migration is auto-generated, please modify it to your needs\n        $this->addSql('CREATE SCHEMA public');\n        $this->addSql('DROP INDEX entry_score_idx');\n        $this->addSql('DROP INDEX entry_comment_count_idx');\n        $this->addSql('ALTER TABLE entry ADD title_ts TEXT DEFAULT \\'english\\'');\n        $this->addSql('ALTER TABLE entry ADD body_ts TEXT DEFAULT \\'english\\'');\n        $this->addSql('CREATE INDEX entry_title_ts_idx ON entry (title_ts)');\n        $this->addSql('CREATE INDEX entry_body_ts_idx ON entry (body_ts)');\n        $this->addSql('DROP INDEX post_score_idx');\n        $this->addSql('DROP INDEX post_comment_count_idx');\n        $this->addSql('ALTER TABLE post ADD body_ts TEXT DEFAULT \\'english\\'');\n        $this->addSql('CREATE INDEX post_body_ts_idx ON post (body_ts)');\n        $this->addSql('ALTER TABLE post_comment ADD body_ts TEXT DEFAULT \\'english\\'');\n        $this->addSql('CREATE INDEX post_comment_body_ts_idx ON post_comment (body_ts)');\n        $this->addSql('ALTER TABLE entry_comment ADD body_ts TEXT DEFAULT \\'english\\'');\n        $this->addSql('CREATE INDEX entry_comment_body_ts_idx ON entry_comment (body_ts)');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20230615091124.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\n/**\n * Auto-generated Migration: Please modify to your needs!\n */\nfinal class Version20230615091124 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return '';\n    }\n\n    public function up(Schema $schema): void\n    {\n        // this up() migration is auto-generated, please modify it to your needs\n        $this->addSql('CREATE INDEX entry_last_active_at_idx ON entry (last_active)');\n        $this->addSql('CREATE INDEX entry_comment_up_votes_idx ON entry_comment (up_votes)');\n        $this->addSql('CREATE INDEX entry_comment_last_active_at_idx ON entry_comment (last_active)');\n        $this->addSql('CREATE INDEX entry_comment_created_at_idx ON entry_comment (created_at)');\n        $this->addSql('CREATE INDEX post_last_active_at_idx ON post (last_active)');\n        $this->addSql('CREATE INDEX post_comment_up_votes_idx ON post_comment (up_votes)');\n        $this->addSql('CREATE INDEX post_comment_last_active_at_idx ON post_comment (last_active)');\n        $this->addSql('CREATE INDEX post_comment_created_at_idx ON post_comment (created_at)');\n    }\n\n    public function down(Schema $schema): void\n    {\n        // this down() migration is auto-generated, please modify it to your needs\n        $this->addSql('CREATE SCHEMA public');\n        $this->addSql('DROP INDEX post_last_active_at_idx');\n        $this->addSql('DROP INDEX entry_last_active_at_idx');\n        $this->addSql('DROP INDEX post_comment_up_votes_idx');\n        $this->addSql('DROP INDEX post_comment_last_active_at_idx');\n        $this->addSql('DROP INDEX post_comment_created_at_idx');\n        $this->addSql('DROP INDEX entry_comment_up_votes_idx');\n        $this->addSql('DROP INDEX entry_comment_last_active_at_idx');\n        $this->addSql('DROP INDEX entry_comment_created_at_idx');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20230615203020.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\n/**\n * Auto-generated Migration: Please modify to your needs!\n */\nfinal class Version20230615203020 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return '';\n    }\n\n    public function up(Schema $schema): void\n    {\n        $this->addSql(\"ALTER TABLE entry ADD COLUMN title_ts tsvector GENERATED ALWAYS AS (to_tsvector('english', title)) STORED;\");\n        $this->addSql(\"ALTER TABLE entry ADD COLUMN body_ts tsvector GENERATED ALWAYS AS (to_tsvector('english', body)) STORED;\");\n        $this->addSql(\"ALTER TABLE post ADD COLUMN body_ts tsvector GENERATED ALWAYS AS (to_tsvector('english', body)) STORED;\");\n        $this->addSql(\"ALTER TABLE post_comment ADD COLUMN body_ts tsvector GENERATED ALWAYS AS (to_tsvector('english', body)) STORED;\");\n        $this->addSql(\"ALTER TABLE entry_comment ADD COLUMN body_ts tsvector GENERATED ALWAYS AS (to_tsvector('english', body)) STORED;\");\n\n        $this->addSql('CREATE INDEX entry_title_ts_idx ON entry USING GIN (title_ts);');\n        $this->addSql('CREATE INDEX entry_body_ts_idx ON entry USING GIN (body_ts);');\n        $this->addSql('CREATE INDEX post_body_ts_idx ON post USING GIN (body_ts);');\n        $this->addSql('CREATE INDEX post_comment_body_ts_idx ON post_comment USING GIN (body_ts);');\n        $this->addSql('CREATE INDEX entry_comment_body_ts_idx ON entry_comment USING GIN (body_ts);');\n    }\n\n    public function down(Schema $schema): void\n    {\n        $this->addSql('CREATE SCHEMA public');\n        $this->addSql('ALTER TABLE entry DROP title_ts');\n        $this->addSql('ALTER TABLE entry DROP body_ts');\n        $this->addSql('ALTER TABLE post DROP body_ts');\n        $this->addSql('ALTER TABLE post_comment DROP body_ts');\n        $this->addSql('ALTER TABLE entry_comment DROP body_ts');\n\n        $this->addSql('DROP INDEX entry_title_ts_idx');\n        $this->addSql('DROP INDEX entry_body_ts_idx');\n        $this->addSql('DROP INDEX post_body_ts_idx');\n        $this->addSql('DROP INDEX post_comment_body_ts_idx');\n        $this->addSql('DROP INDEX entry_comment_body_ts_idx');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20230701125418.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\n/**\n * Auto-generated Migration: Please modify to your needs!\n */\nfinal class Version20230701125418 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return '';\n    }\n\n    public function up(Schema $schema): void\n    {\n        $this->addSql('ALTER TABLE \"user\" ADD preferred_languages JSONB NOT NULL DEFAULT \\'[]\\'::jsonb');\n    }\n\n    public function down(Schema $schema): void\n    {\n        $this->addSql('ALTER TABLE \"user\" DROP preferred_languages');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20230712132025.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\n/**\n * Auto-generated Migration: Please modify to your needs!\n */\nfinal class Version20230712132025 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return '';\n    }\n\n    public function up(Schema $schema): void\n    {\n        $this->addSql('ALTER TABLE magazine DROP custom_js');\n    }\n\n    public function down(Schema $schema): void\n    {\n        $this->addSql('ALTER TABLE magazine ADD custom_js TEXT DEFAULT NULL');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20230715034515.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\n/**\n * Auto-generated Migration: Please modify to your needs!\n */\nfinal class Version20230715034515 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return '';\n    }\n\n    public function up(Schema $schema): void\n    {\n        $this->addSql('CREATE TABLE rememberme_token (series VARCHAR(88) NOT NULL, value VARCHAR(88) NOT NULL, lastUsed TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, class VARCHAR(100) NOT NULL, username VARCHAR(200) NOT NULL, PRIMARY KEY(series))');\n    }\n\n    public function down(Schema $schema): void\n    {\n        $this->addSql('DROP TABLE rememberme_token');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20230718160422.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\n/**\n * Auto-generated Migration: Please modify to your needs!\n */\nfinal class Version20230718160422 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return '';\n    }\n\n    public function up(Schema $schema): void\n    {\n        // this up() migration is auto-generated, please modify it to your needs\n        $this->addSql('ALTER TABLE \"user\" ADD oauth_keycloak_id VARCHAR(255) DEFAULT NULL');\n    }\n\n    public function down(Schema $schema): void\n    {\n        // this down() migration is auto-generated, please modify it to your needs\n        $this->addSql('ALTER TABLE \"user\" DROP oauth_keycloak_id');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20230719060447.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\n/**\n * Auto-generated Migration: Please modify to your needs!\n */\nfinal class Version20230710060447 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return '';\n    }\n\n    public function up(Schema $schema): void\n    {\n        // this up() migration is auto-generated, please modify it to your needs\n        $this->addSql('CREATE SEQUENCE oauth2_user_consent_id_seq INCREMENT BY 1 MINVALUE 1 START 1');\n        $this->addSql('CREATE TABLE oauth2_access_token (identifier CHAR(80) NOT NULL, client VARCHAR(32) NOT NULL, expiry TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, user_identifier VARCHAR(128) DEFAULT NULL, scopes TEXT DEFAULT NULL, revoked BOOLEAN NOT NULL, PRIMARY KEY(identifier))');\n        $this->addSql('CREATE INDEX IDX_454D9673C7440455 ON oauth2_access_token (client)');\n        $this->addSql('COMMENT ON COLUMN oauth2_access_token.expiry IS \\'(DC2Type:datetime_immutable)\\'');\n        $this->addSql('COMMENT ON COLUMN oauth2_access_token.scopes IS \\'(DC2Type:oauth2_scope)\\'');\n        $this->addSql('CREATE TABLE oauth2_authorization_code (identifier CHAR(80) NOT NULL, client VARCHAR(32) NOT NULL, expiry TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, user_identifier VARCHAR(128) DEFAULT NULL, scopes TEXT DEFAULT NULL, revoked BOOLEAN NOT NULL, PRIMARY KEY(identifier))');\n        $this->addSql('CREATE INDEX IDX_509FEF5FC7440455 ON oauth2_authorization_code (client)');\n        $this->addSql('COMMENT ON COLUMN oauth2_authorization_code.expiry IS \\'(DC2Type:datetime_immutable)\\'');\n        $this->addSql('COMMENT ON COLUMN oauth2_authorization_code.scopes IS \\'(DC2Type:oauth2_scope)\\'');\n        $this->addSql('CREATE TABLE \"oauth2_client\" (identifier VARCHAR(32) NOT NULL, name VARCHAR(128) NOT NULL, secret VARCHAR(128) DEFAULT NULL, redirect_uris TEXT DEFAULT NULL, grants TEXT DEFAULT NULL, scopes TEXT DEFAULT NULL, active BOOLEAN NOT NULL, allow_plain_text_pkce BOOLEAN DEFAULT false NOT NULL, description TEXT DEFAULT NULL, PRIMARY KEY(identifier))');\n        $this->addSql('COMMENT ON COLUMN \"oauth2_client\".redirect_uris IS \\'(DC2Type:oauth2_redirect_uri)\\'');\n        $this->addSql('COMMENT ON COLUMN \"oauth2_client\".grants IS \\'(DC2Type:oauth2_grant)\\'');\n        $this->addSql('COMMENT ON COLUMN \"oauth2_client\".scopes IS \\'(DC2Type:oauth2_scope)\\'');\n        $this->addSql('CREATE TABLE oauth2_refresh_token (identifier CHAR(80) NOT NULL, access_token CHAR(80) DEFAULT NULL, expiry TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, revoked BOOLEAN NOT NULL, PRIMARY KEY(identifier))');\n        $this->addSql('CREATE INDEX IDX_4DD90732B6A2DD68 ON oauth2_refresh_token (access_token)');\n        $this->addSql('COMMENT ON COLUMN oauth2_refresh_token.expiry IS \\'(DC2Type:datetime_immutable)\\'');\n        $this->addSql('CREATE TABLE oauth2_user_consent (id INT NOT NULL, user_id INT NOT NULL, client_identifier VARCHAR(32) NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, expires_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, scopes JSON NOT NULL, ip_address VARCHAR(255) NOT NULL, PRIMARY KEY(id))');\n        $this->addSql('CREATE INDEX IDX_C8F05D01A76ED395 ON oauth2_user_consent (user_id)');\n        $this->addSql('CREATE INDEX IDX_C8F05D01E77ABE2B ON oauth2_user_consent (client_identifier)');\n        $this->addSql('COMMENT ON COLUMN oauth2_user_consent.created_at IS \\'(DC2Type:datetime_immutable)\\'');\n        $this->addSql('COMMENT ON COLUMN oauth2_user_consent.expires_at IS \\'(DC2Type:datetime_immutable)\\'');\n        $this->addSql('ALTER TABLE oauth2_access_token ADD CONSTRAINT FK_454D9673C7440455 FOREIGN KEY (client) REFERENCES \"oauth2_client\" (identifier) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE oauth2_authorization_code ADD CONSTRAINT FK_509FEF5FC7440455 FOREIGN KEY (client) REFERENCES \"oauth2_client\" (identifier) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE oauth2_refresh_token ADD CONSTRAINT FK_4DD90732B6A2DD68 FOREIGN KEY (access_token) REFERENCES oauth2_access_token (identifier) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE oauth2_user_consent ADD CONSTRAINT FK_C8F05D01A76ED395 FOREIGN KEY (user_id) REFERENCES \"user\" (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE oauth2_user_consent ADD CONSTRAINT FK_C8F05D01E77ABE2B FOREIGN KEY (client_identifier) REFERENCES \"oauth2_client\" (identifier) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE report ALTER considered_at TYPE TIMESTAMP(0) WITH TIME ZONE');\n        $this->addSql('COMMENT ON COLUMN report.considered_at IS \\'(DC2Type:datetimetz_immutable)\\'');\n        $this->addSql('ALTER TABLE oauth2_client ADD user_id INT DEFAULT NULL');\n        $this->addSql('ALTER TABLE oauth2_client ADD contact_email VARCHAR(255) NOT NULL');\n        $this->addSql('ALTER TABLE oauth2_client ADD CONSTRAINT FK_669FF9C9A76ED395 FOREIGN KEY (user_id) REFERENCES \"user\" (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('CREATE UNIQUE INDEX UNIQ_669FF9C9A76ED395 ON oauth2_client (user_id)');\n        $this->addSql('ALTER TABLE \"user\" ADD is_bot BOOLEAN DEFAULT false NOT NULL');\n        $this->addSql('CREATE SEQUENCE oauth2_client_access_id_seq INCREMENT BY 1 MINVALUE 1 START 1');\n        $this->addSql('CREATE TABLE oauth2_client_access (id INT NOT NULL, client_id VARCHAR(32) NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, path VARCHAR(255) NOT NULL, PRIMARY KEY(id))');\n        $this->addSql('CREATE INDEX IDX_D959464019EB6921 ON oauth2_client_access (client_id)');\n        $this->addSql('COMMENT ON COLUMN oauth2_client_access.created_at IS \\'(DC2Type:datetime_immutable)\\'');\n        $this->addSql('ALTER TABLE oauth2_client_access ADD CONSTRAINT FK_D959464019EB6921 FOREIGN KEY (client_id) REFERENCES \"oauth2_client\" (identifier) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE oauth2_client ADD created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL DEFAULT now()');\n        $this->addSql('COMMENT ON COLUMN oauth2_client.created_at IS \\'(DC2Type:datetimetz_immutable)\\'');\n        $this->addSql('ALTER TABLE oauth2_client ALTER COLUMN created_at DROP DEFAULT');\n        $this->addSql('ALTER TABLE oauth2_client ADD image_id INT DEFAULT NULL');\n        $this->addSql('ALTER TABLE oauth2_client ADD CONSTRAINT FK_669FF9C93DA5256D FOREIGN KEY (image_id) REFERENCES image (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('CREATE UNIQUE INDEX UNIQ_669FF9C93DA5256D ON oauth2_client (image_id)');\n    }\n\n    public function down(Schema $schema): void\n    {\n        // this down() migration is auto-generated, please modify it to your needs\n        $this->addSql('ALTER TABLE \"oauth2_client\" DROP CONSTRAINT FK_669FF9C93DA5256D');\n        $this->addSql('DROP INDEX UNIQ_669FF9C93DA5256D');\n        $this->addSql('ALTER TABLE \"oauth2_client\" DROP image_id');\n        $this->addSql('ALTER TABLE \"oauth2_client\" DROP created_at');\n        $this->addSql('DROP SEQUENCE oauth2_client_access_id_seq CASCADE');\n        $this->addSql('ALTER TABLE oauth2_client_access DROP CONSTRAINT FK_D959464019EB6921');\n        $this->addSql('DROP TABLE oauth2_client_access');\n        $this->addSql('ALTER TABLE \"user\" DROP is_bot');\n        $this->addSql('ALTER TABLE \"oauth2_client\" DROP CONSTRAINT FK_669FF9C9A76ED395');\n        $this->addSql('DROP INDEX UNIQ_669FF9C9A76ED395');\n        $this->addSql('ALTER TABLE \"oauth2_client\" DROP user_id');\n        $this->addSql('ALTER TABLE \"oauth2_client\" DROP contact_email');\n        $this->addSql('DROP SEQUENCE oauth2_user_consent_id_seq CASCADE');\n        $this->addSql('ALTER TABLE oauth2_access_token DROP CONSTRAINT FK_454D9673C7440455');\n        $this->addSql('ALTER TABLE oauth2_authorization_code DROP CONSTRAINT FK_509FEF5FC7440455');\n        $this->addSql('ALTER TABLE oauth2_refresh_token DROP CONSTRAINT FK_4DD90732B6A2DD68');\n        $this->addSql('ALTER TABLE oauth2_user_consent DROP CONSTRAINT FK_C8F05D01A76ED395');\n        $this->addSql('ALTER TABLE oauth2_user_consent DROP CONSTRAINT FK_C8F05D01E77ABE2B');\n        $this->addSql('DROP TABLE oauth2_access_token');\n        $this->addSql('DROP TABLE oauth2_authorization_code');\n        $this->addSql('DROP TABLE \"oauth2_client\"');\n        $this->addSql('DROP TABLE oauth2_refresh_token');\n        $this->addSql('DROP TABLE oauth2_user_consent');\n        $this->addSql('ALTER TABLE report ALTER considered_at TYPE TIMESTAMP(0) WITH TIME ZONE');\n        $this->addSql('COMMENT ON COLUMN report.considered_at IS NULL');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20230729063543.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\nfinal class Version20230729063543 extends AbstractMigration\n{\n    public function up(Schema $schema): void\n    {\n        $this->addSql('ALTER TABLE post ADD sticky BOOLEAN NOT NULL DEFAULT false');\n    }\n\n    public function down(Schema $schema): void\n    {\n        $this->addSql('ALTER TABLE post DROP sticky');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20230812151754.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\n/**\n * Auto-generated Migration: Please modify to your needs!\n */\nfinal class Version20230812151754 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return '';\n    }\n\n    public function up(Schema $schema): void\n    {\n        // this up() migration is auto-generated, please modify it to your needs\n        $this->addSql('ALTER TABLE \"user\" ADD totp_secret VARCHAR(255) DEFAULT NULL');\n    }\n\n    public function down(Schema $schema): void\n    {\n        // this down() migration is auto-generated, please modify it to your needs\n        $this->addSql('ALTER TABLE \"user\" DROP totp_secret');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20230820234418.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\n/**\n * Auto-generated Migration: Please modify to your needs!\n */\nfinal class Version20230820234418 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return '';\n    }\n\n    public function up(Schema $schema): void\n    {\n        // this up() migration is auto-generated, please modify it to your needs\n        $this->addSql('ALTER TABLE \"user\" ADD custom_css TEXT DEFAULT NULL');\n    }\n\n    public function down(Schema $schema): void\n    {\n        // this down() migration is auto-generated, please modify it to your needs\n        $this->addSql('ALTER TABLE \"user\" DROP custom_css');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20230902082312.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\n/**\n * Auto-generated Migration: Please modify to your needs!\n */\nfinal class Version20230902082312 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return '';\n    }\n\n    public function up(Schema $schema): void\n    {\n        // this up() migration is auto-generated, please modify it to your needs\n        $this->addSql('ALTER TABLE \"user\" ADD ignore_magazines_custom_css BOOLEAN DEFAULT false NOT NULL');\n    }\n\n    public function down(Schema $schema): void\n    {\n        // this down() migration is auto-generated, please modify it to your needs\n        $this->addSql('ALTER TABLE \"user\" DROP ignore_magazines_custom_css');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20230906095436.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\n/**\n * Auto-generated Migration: Please modify to your needs!\n */\nfinal class Version20230906095436 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return '';\n    }\n\n    public function up(Schema $schema): void\n    {\n        $this->addSql('ALTER TABLE \"user\" ADD totp_backup_codes JSONB DEFAULT \\'[]\\' NOT NULL');\n    }\n\n    public function down(Schema $schema): void\n    {\n        $this->addSql('ALTER TABLE \"user\" DROP totp_backup_codes');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20231019023030.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\nfinal class Version20231019023030 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return 'Upgrade database to remove references to Cardano Wallet. The code is now deprecated and removed.';\n    }\n\n    public function up(Schema $schema): void\n    {\n        // this up() migration is auto-generated, please modify it to your needs\n        $this->addSql('DROP SEQUENCE cardano_tx_id_seq CASCADE');\n        $this->addSql('DROP SEQUENCE cardano_tx_init_id_seq CASCADE');\n        $this->addSql('ALTER TABLE cardano_tx DROP CONSTRAINT fk_f74c620e3eb84a1d');\n        $this->addSql('ALTER TABLE cardano_tx DROP CONSTRAINT fk_f74c620ecd53edb6');\n        $this->addSql('ALTER TABLE cardano_tx DROP CONSTRAINT fk_f74c620ef624b39d');\n        $this->addSql('ALTER TABLE cardano_tx DROP CONSTRAINT fk_f74c620eba364942');\n        $this->addSql('ALTER TABLE cardano_tx_init DROP CONSTRAINT fk_973316583eb84a1d');\n        $this->addSql('ALTER TABLE cardano_tx_init DROP CONSTRAINT fk_97331658a76ed395');\n        $this->addSql('ALTER TABLE cardano_tx_init DROP CONSTRAINT fk_97331658ba364942');\n        $this->addSql('DROP TABLE cardano_tx');\n        $this->addSql('DROP TABLE cardano_tx_init');\n        $this->addSql('ALTER TABLE \"user\" DROP cardano_wallet_id');\n        $this->addSql('ALTER TABLE \"user\" DROP cardano_wallet_address');\n    }\n\n    public function down(Schema $schema): void\n    {\n        // this down() migration is auto-generated, please modify it to your needs\n        $this->addSql('CREATE SEQUENCE cardano_tx_id_seq INCREMENT BY 1 MINVALUE 1 START 1');\n        $this->addSql('CREATE SEQUENCE cardano_tx_init_id_seq INCREMENT BY 1 MINVALUE 1 START 1');\n        $this->addSql('CREATE TABLE cardano_tx (id INT NOT NULL, magazine_id INT DEFAULT NULL, receiver_id INT DEFAULT NULL, sender_id INT DEFAULT NULL, entry_id INT DEFAULT NULL, amount INT NOT NULL, tx_hash VARCHAR(255) NOT NULL, created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, ctx_type TEXT NOT NULL, PRIMARY KEY(id))');\n        $this->addSql('CREATE INDEX idx_f74c620eba364942 ON cardano_tx (entry_id)');\n        $this->addSql('CREATE INDEX idx_f74c620ef624b39d ON cardano_tx (sender_id)');\n        $this->addSql('CREATE INDEX idx_f74c620ecd53edb6 ON cardano_tx (receiver_id)');\n        $this->addSql('CREATE INDEX idx_f74c620e3eb84a1d ON cardano_tx (magazine_id)');\n        $this->addSql('COMMENT ON COLUMN cardano_tx.created_at IS \\'(DC2Type:datetimetz_immutable)\\'');\n        $this->addSql('CREATE TABLE cardano_tx_init (id INT NOT NULL, magazine_id INT DEFAULT NULL, user_id INT DEFAULT NULL, entry_id INT DEFAULT NULL, session_id VARCHAR(255) NOT NULL, created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, cpi_type TEXT NOT NULL, PRIMARY KEY(id))');\n        $this->addSql('CREATE INDEX idx_97331658ba364942 ON cardano_tx_init (entry_id)');\n        $this->addSql('CREATE INDEX idx_97331658a76ed395 ON cardano_tx_init (user_id)');\n        $this->addSql('CREATE INDEX idx_973316583eb84a1d ON cardano_tx_init (magazine_id)');\n        $this->addSql('COMMENT ON COLUMN cardano_tx_init.created_at IS \\'(DC2Type:datetimetz_immutable)\\'');\n        $this->addSql('ALTER TABLE cardano_tx ADD CONSTRAINT fk_f74c620e3eb84a1d FOREIGN KEY (magazine_id) REFERENCES magazine (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE cardano_tx ADD CONSTRAINT fk_f74c620ecd53edb6 FOREIGN KEY (receiver_id) REFERENCES \"user\" (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE cardano_tx ADD CONSTRAINT fk_f74c620ef624b39d FOREIGN KEY (sender_id) REFERENCES \"user\" (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE cardano_tx ADD CONSTRAINT fk_f74c620eba364942 FOREIGN KEY (entry_id) REFERENCES entry (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE cardano_tx_init ADD CONSTRAINT fk_973316583eb84a1d FOREIGN KEY (magazine_id) REFERENCES magazine (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE cardano_tx_init ADD CONSTRAINT fk_97331658a76ed395 FOREIGN KEY (user_id) REFERENCES \"user\" (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE cardano_tx_init ADD CONSTRAINT fk_97331658ba364942 FOREIGN KEY (entry_id) REFERENCES entry (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE \"user\" ADD cardano_wallet_id VARCHAR(255) DEFAULT NULL');\n        $this->addSql('ALTER TABLE \"user\" ADD cardano_wallet_address VARCHAR(255) DEFAULT NULL');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20231019190634.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\nfinal class Version20231019190634 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return 'Upgrade User table to store user type (Person, Service, Org...). Default is Person.';\n    }\n\n    public function up(Schema $schema): void\n    {\n        $this->addSql('CREATE TYPE user_type AS ENUM (\\'Person\\', \\'Service\\', \\'Organization\\', \\'Application\\')');\n        $this->addSql('ALTER TABLE \"user\" ADD COLUMN \"type\" user_type NOT NULL DEFAULT \\'Person\\'');\n        $this->addSql('ALTER TABLE \"user\" DROP is_bot');\n    }\n\n    public function down(Schema $schema): void\n    {\n        $this->addSql('ALTER TABLE \"user\" DROP COLUMN \"type\"');\n        $this->addSql('DROP TYPE IF EXISTS user_type');\n        $this->addSql('ALTER TABLE \"user\" ADD is_bot BOOLEAN DEFAULT false NOT NULL');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20231103004800.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\nclass Version20231103004800 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return 'Change the scoring of entry and post tables';\n    }\n\n    public function up(Schema $schema): void\n    {\n        $this->addSql('UPDATE entry SET score=favourite_count + up_votes - down_votes');\n        $this->addSql('UPDATE post SET score=favourite_count + up_votes - down_votes');\n    }\n\n    public function down(Schema $schema): void\n    {\n        $this->addSql('UPDATE entry SET score=up_votes - down_votes');\n        $this->addSql('UPDATE post SET score=up_votes - down_votes');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20231103070928.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\n/**\n * Auto-generated Migration: Please modify to your needs!\n */\nfinal class Version20231103070928 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return 'Introducing visibility default value and marked for deletion timestamps columns';\n    }\n\n    public function up(Schema $schema): void\n    {\n        $this->addSql('ALTER TABLE magazine ADD marked_for_deletion_at TIMESTAMP(0) WITH TIME ZONE DEFAULT NULL');\n        $this->addSql('ALTER TABLE \"user\" ADD marked_for_deletion_at TIMESTAMP(0) WITH TIME ZONE DEFAULT NULL');\n        $this->addSql('ALTER TABLE \"user\" ADD visibility TEXT DEFAULT \\'visible\\' NOT NULL');\n        $this->addSql('CREATE INDEX user_visibility_idx ON \"user\" (visibility)');\n    }\n\n    public function down(Schema $schema): void\n    {\n        $this->addSql('DROP INDEX user_visibility_idx');\n        $this->addSql('ALTER TABLE \"user\" DROP marked_for_deletion_at');\n        $this->addSql('ALTER TABLE \"user\" DROP visibility');\n        $this->addSql('ALTER TABLE magazine DROP marked_for_deletion_at');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20231107204142.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\nclass Version20231107204142 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return 'Add column remote_followers_count';\n    }\n\n    public function up(Schema $schema): void\n    {\n        $this->addSql('ALTER TABLE \"user\" ADD ap_followers_count INT DEFAULT NULL');\n        $this->addSql('ALTER TABLE magazine ADD ap_followers_count INT DEFAULT NULL');\n        $this->addSql('ALTER TABLE \"user\" ADD ap_attributed_to_url VARCHAR(255) DEFAULT NULL');\n        $this->addSql('ALTER TABLE magazine ADD ap_attributed_to_url VARCHAR(255) DEFAULT NULL');\n    }\n\n    public function down(Schema $schema): void\n    {\n        $this->addSql('ALTER TABLE \"user\" DROP ap_followers_count');\n        $this->addSql('ALTER TABLE magazine DROP ap_followers_count');\n        $this->addSql('ALTER TABLE \"user\" DROP ap_attributed_to_url');\n        $this->addSql('ALTER TABLE magazine DROP ap_attributed_to_url');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20231108084451.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\nfinal class Version20231108084451 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return '';\n    }\n\n    public function up(Schema $schema): void\n    {\n        // this up() migration is auto-generated, please modify it to your needs\n        $this->addSql('CREATE SEQUENCE magazine_ownership_request_id_seq INCREMENT BY 1 MINVALUE 1 START 1');\n        $this->addSql('CREATE SEQUENCE moderator_request_id_seq INCREMENT BY 1 MINVALUE 1 START 1');\n        $this->addSql('CREATE TABLE magazine_ownership_request (id INT NOT NULL, user_id INT NOT NULL, magazine_id INT NOT NULL, created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, PRIMARY KEY(id))');\n        $this->addSql('CREATE INDEX IDX_A7160C65A76ED395 ON magazine_ownership_request (user_id)');\n        $this->addSql('CREATE INDEX IDX_A7160C653EB84A1D ON magazine_ownership_request (magazine_id)');\n        $this->addSql('CREATE UNIQUE INDEX magazine_ownership_magazine_user_idx ON magazine_ownership_request (magazine_id, user_id)');\n        $this->addSql('COMMENT ON COLUMN magazine_ownership_request.created_at IS \\'(DC2Type:datetimetz_immutable)\\'');\n        $this->addSql('CREATE TABLE moderator_request (id INT NOT NULL, user_id INT NOT NULL, magazine_id INT NOT NULL, created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, PRIMARY KEY(id))');\n        $this->addSql('CREATE INDEX IDX_2CC3E324A76ED395 ON moderator_request (user_id)');\n        $this->addSql('CREATE INDEX IDX_2CC3E3243EB84A1D ON moderator_request (magazine_id)');\n        $this->addSql('CREATE UNIQUE INDEX moderator_request_magazine_user_idx ON moderator_request (magazine_id, user_id)');\n        $this->addSql('COMMENT ON COLUMN moderator_request.created_at IS \\'(DC2Type:datetimetz_immutable)\\'');\n        $this->addSql('ALTER TABLE magazine_ownership_request ADD CONSTRAINT FK_A7160C65A76ED395 FOREIGN KEY (user_id) REFERENCES \"user\" (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE magazine_ownership_request ADD CONSTRAINT FK_A7160C653EB84A1D FOREIGN KEY (magazine_id) REFERENCES magazine (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE moderator_request ADD CONSTRAINT FK_2CC3E324A76ED395 FOREIGN KEY (user_id) REFERENCES \"user\" (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE moderator_request ADD CONSTRAINT FK_2CC3E3243EB84A1D FOREIGN KEY (magazine_id) REFERENCES magazine (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n    }\n\n    public function down(Schema $schema): void\n    {\n        // this down() migration is auto-generated, please modify it to your needs\n        $this->addSql('DROP SEQUENCE magazine_ownership_request_id_seq CASCADE');\n        $this->addSql('DROP SEQUENCE moderator_request_id_seq CASCADE');\n        $this->addSql('ALTER TABLE magazine_ownership_request DROP CONSTRAINT FK_A7160C65A76ED395');\n        $this->addSql('ALTER TABLE magazine_ownership_request DROP CONSTRAINT FK_A7160C653EB84A1D');\n        $this->addSql('ALTER TABLE moderator_request DROP CONSTRAINT FK_2CC3E324A76ED395');\n        $this->addSql('ALTER TABLE moderator_request DROP CONSTRAINT FK_2CC3E3243EB84A1D');\n        $this->addSql('DROP TABLE magazine_ownership_request');\n        $this->addSql('DROP TABLE moderator_request');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20231112133420.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\nfinal class Version20231112133420 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return 'add column \"added_by_user_id\" to moderator table';\n    }\n\n    public function up(Schema $schema): void\n    {\n        $this->addSql('ALTER TABLE moderator ADD added_by_user_id INT DEFAULT NULL');\n        $this->addSql('ALTER TABLE moderator ADD CONSTRAINT FK_6A30B268CA792C6B FOREIGN KEY (added_by_user_id) REFERENCES \"user\" (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('CREATE INDEX IDX_6A30B268CA792C6B ON moderator (added_by_user_id)');\n    }\n\n    public function down(Schema $schema): void\n    {\n        $this->addSql('ALTER TABLE moderator DROP CONSTRAINT FK_6A30B268CA792C6B');\n        $this->addSql('DROP INDEX IDX_6A30B268CA792C6B');\n        $this->addSql('ALTER TABLE moderator DROP added_by_user_id');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20231113165549.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\n/**\n * Auto-generated Migration: Please modify to your needs!\n */\nfinal class Version20231113165549 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return '';\n    }\n\n    public function up(Schema $schema): void\n    {\n        // this up() migration is auto-generated, please modify it to your needs\n        $this->addSql('ALTER TABLE site ADD announcement TEXT DEFAULT NULL');\n    }\n\n    public function down(Schema $schema): void\n    {\n        // this down() migration is auto-generated, please modify it to your needs\n        $this->addSql('ALTER TABLE site DROP announcement');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20231119012320.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\n/**\n * Auto-generated Migration: Please modify to your needs!\n */\nfinal class Version20231119012320 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return 'removal of awards';\n    }\n\n    public function up(Schema $schema): void\n    {\n        // this up() migration is auto-generated, please modify it to your needs\n        $this->addSql('DROP SEQUENCE award_id_seq CASCADE');\n        $this->addSql('DROP SEQUENCE award_type_id_seq CASCADE');\n        $this->addSql('ALTER TABLE award DROP CONSTRAINT fk_8a5b2ee7a76ed395');\n        $this->addSql('ALTER TABLE award DROP CONSTRAINT fk_8a5b2ee73eb84a1d');\n        $this->addSql('ALTER TABLE award DROP CONSTRAINT fk_8a5b2ee7c54c8c93');\n        $this->addSql('DROP TABLE award');\n        $this->addSql('DROP TABLE award_type');\n    }\n\n    public function down(Schema $schema): void\n    {\n        // this down() migration is auto-generated, please modify it to your needs\n        $this->addSql('CREATE SEQUENCE award_id_seq INCREMENT BY 1 MINVALUE 1 START 1');\n        $this->addSql('CREATE SEQUENCE award_type_id_seq INCREMENT BY 1 MINVALUE 1 START 1');\n        $this->addSql('CREATE TABLE award (id INT NOT NULL, user_id INT NOT NULL, magazine_id INT DEFAULT NULL, type_id INT DEFAULT NULL, created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, PRIMARY KEY(id))');\n        $this->addSql('CREATE INDEX idx_8a5b2ee7c54c8c93 ON award (type_id)');\n        $this->addSql('CREATE INDEX idx_8a5b2ee73eb84a1d ON award (magazine_id)');\n        $this->addSql('CREATE INDEX idx_8a5b2ee7a76ed395 ON award (user_id)');\n        $this->addSql('COMMENT ON COLUMN award.created_at IS \\'(DC2Type:datetimetz_immutable)\\'');\n        $this->addSql('CREATE TABLE award_type (id INT NOT NULL, name VARCHAR(255) NOT NULL, category VARCHAR(255) NOT NULL, count INT DEFAULT 0 NOT NULL, attributes TEXT DEFAULT NULL, PRIMARY KEY(id))');\n        $this->addSql('COMMENT ON COLUMN award_type.attributes IS \\'(DC2Type:array)\\'');\n        $this->addSql('ALTER TABLE award ADD CONSTRAINT fk_8a5b2ee7a76ed395 FOREIGN KEY (user_id) REFERENCES \"user\" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE award ADD CONSTRAINT fk_8a5b2ee73eb84a1d FOREIGN KEY (magazine_id) REFERENCES magazine (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE award ADD CONSTRAINT fk_8a5b2ee7c54c8c93 FOREIGN KEY (type_id) REFERENCES award_type (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20231120164429.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\n/**\n * Auto-generated Migration: Please modify to your needs!\n */\nfinal class Version20231120164429 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return 'remove leftover crypto junk';\n    }\n\n    public function up(Schema $schema): void\n    {\n        // this up() migration is auto-generated, please modify it to your needs\n        $this->addSql('ALTER TABLE entry DROP ada_amount');\n    }\n\n    public function down(Schema $schema): void\n    {\n        // this down() migration is auto-generated, please modify it to your needs\n        $this->addSql('ALTER TABLE entry ADD ada_amount INT DEFAULT 0 NOT NULL');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20231121010453.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\n/**\n * Auto-generated Migration: Please modify to your needs!\n */\nfinal class Version20231121010453 extends AbstractMigration\n{\n    private const INDEXES = [\n        'entry_ap_id_lower_idx' => ['table' => 'entry', 'column' => 'lower(ap_id)'],\n        'entry_comment_ap_id_lower_idx' => ['table' => 'entry_comment', 'column' => 'lower(ap_id)'],\n        'magazine_ap_id_lower_idx' => ['table' => 'magazine', 'column' => 'lower(ap_id)'],\n        'magazine_ap_profile_id_lower_idx' => ['table' => 'magazine', 'column' => 'lower(ap_profile_id)'],\n        'magazine_name_lower_idx' => ['table' => 'magazine', 'column' => 'lower(name)'],\n        'magazine_title_lower_idx' => ['table' => 'magazine', 'column' => 'lower(title)'],\n        'post_ap_id_lower_idx' => ['table' => 'post', 'column' => 'lower(ap_id)'],\n        'post_comment_ap_id_lower_idx' => ['table' => 'post_comment', 'column' => 'lower(ap_id)'],\n        'user_ap_id_lower_idx' => ['table' => 'user', 'column' => 'lower(ap_id)'],\n        'user_ap_profile_id_lower_idx' => ['table' => 'user', 'column' => 'lower(ap_profile_id)'],\n        'user_email_lower_idx' => ['table' => 'user', 'column' => 'lower(email)'],\n        'user_username_lower_idx' => ['table' => 'user', 'column' => 'lower(username)'],\n    ];\n\n    public function getDescription(): string\n    {\n        return 'Introduce db optimizations - sync with /kbin, Mbin specific changes';\n    }\n\n    public function up(Schema $schema): void\n    {\n        foreach (self::INDEXES as $index => $details) {\n            $this->addSql('CREATE INDEX '.$index.' ON \"'.$details['table'].'\" ('.$details['column'].')');\n        }\n    }\n\n    public function down(Schema $schema): void\n    {\n        foreach (self::INDEXES as $index => $details) {\n            $this->addSql('DROP INDEX '.$index);\n        }\n    }\n}\n"
  },
  {
    "path": "migrations/Version20231130203400.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\nclass Version20231130203400 extends AbstractMigration\n{\n    public function up(Schema $schema): void\n    {\n        $this->addSql('ALTER TABLE report ADD uuid VARCHAR(255) DEFAULT NULL');\n        $this->addSql('CREATE UNIQUE INDEX report_uuid_idx ON report (uuid)');\n    }\n\n    public function down(Schema $schema): void\n    {\n        $this->addSql('DROP INDEX report_uuid_idx');\n        $this->addSql('ALTER TABLE report DROP uuid');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20240113214751.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\n/**\n * Auto-generated Migration: Please modify to your needs!\n */\nfinal class Version20240113214751 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return '';\n    }\n\n    public function up(Schema $schema): void\n    {\n        // this up() migration is auto-generated, please modify it to your needs\n        $this->addSql('ALTER TABLE \"user\" ADD oauth_zitadel_id VARCHAR(255) DEFAULT NULL');\n    }\n\n    public function down(Schema $schema): void\n    {\n        // this down() migration is auto-generated, please modify it to your needs\n        $this->addSql('ALTER TABLE \"user\" DROP oauth_zitadel_id');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20240216110804.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\n/**\n * Auto-generated Migration: Please modify to your needs!\n */\nfinal class Version20240216110804 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return '';\n    }\n\n    public function up(Schema $schema): void\n    {\n        // this up() migration is auto-generated, please modify it to your needs\n        $this->addSql('ALTER TABLE entry DROP CONSTRAINT FK_2B219D70A76ED395');\n        $this->addSql('ALTER TABLE entry ADD CONSTRAINT FK_2B219D70A76ED395 FOREIGN KEY (user_id) REFERENCES \"user\" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE entry_comment DROP CONSTRAINT FK_B892FDFBA76ED395');\n        $this->addSql('ALTER TABLE entry_comment DROP CONSTRAINT FK_B892FDFB79066886');\n        $this->addSql('ALTER TABLE entry_comment ADD CONSTRAINT FK_B892FDFBA76ED395 FOREIGN KEY (user_id) REFERENCES \"user\" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE entry_comment ADD CONSTRAINT FK_B892FDFB79066886 FOREIGN KEY (root_id) REFERENCES entry_comment (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE entry_comment_vote DROP CONSTRAINT FK_9E561267A76ED395');\n        $this->addSql('ALTER TABLE entry_comment_vote DROP CONSTRAINT FK_9E561267F675F31B');\n        $this->addSql('ALTER TABLE entry_comment_vote ADD CONSTRAINT FK_9E561267A76ED395 FOREIGN KEY (user_id) REFERENCES \"user\" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE entry_comment_vote ADD CONSTRAINT FK_9E561267F675F31B FOREIGN KEY (author_id) REFERENCES \"user\" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE entry_vote DROP CONSTRAINT FK_FE32FD77A76ED395');\n        $this->addSql('ALTER TABLE entry_vote DROP CONSTRAINT FK_FE32FD77F675F31B');\n        $this->addSql('ALTER TABLE entry_vote ADD CONSTRAINT FK_FE32FD77A76ED395 FOREIGN KEY (user_id) REFERENCES \"user\" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE entry_vote ADD CONSTRAINT FK_FE32FD77F675F31B FOREIGN KEY (author_id) REFERENCES \"user\" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE favourite DROP CONSTRAINT FK_62A2CA193EB84A1D');\n        $this->addSql('ALTER TABLE favourite DROP CONSTRAINT FK_62A2CA19A76ED395');\n        $this->addSql('ALTER TABLE favourite DROP CONSTRAINT FK_62A2CA19BA364942');\n        $this->addSql('ALTER TABLE favourite DROP CONSTRAINT FK_62A2CA1960C33421');\n        $this->addSql('ALTER TABLE favourite DROP CONSTRAINT FK_62A2CA194B89032C');\n        $this->addSql('ALTER TABLE favourite DROP CONSTRAINT FK_62A2CA19DB1174D2');\n        $this->addSql('ALTER TABLE favourite ADD CONSTRAINT FK_62A2CA193EB84A1D FOREIGN KEY (magazine_id) REFERENCES magazine (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE favourite ADD CONSTRAINT FK_62A2CA19A76ED395 FOREIGN KEY (user_id) REFERENCES \"user\" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE favourite ADD CONSTRAINT FK_62A2CA19BA364942 FOREIGN KEY (entry_id) REFERENCES entry (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE favourite ADD CONSTRAINT FK_62A2CA1960C33421 FOREIGN KEY (entry_comment_id) REFERENCES entry_comment (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE favourite ADD CONSTRAINT FK_62A2CA194B89032C FOREIGN KEY (post_id) REFERENCES post (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE favourite ADD CONSTRAINT FK_62A2CA19DB1174D2 FOREIGN KEY (post_comment_id) REFERENCES post_comment (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE magazine_ban DROP CONSTRAINT FK_6A126CE53EB84A1D');\n        $this->addSql('ALTER TABLE magazine_ban DROP CONSTRAINT FK_6A126CE5A76ED395');\n        $this->addSql('ALTER TABLE magazine_ban DROP CONSTRAINT FK_6A126CE5386B8E7');\n        $this->addSql('ALTER TABLE magazine_ban ADD CONSTRAINT FK_6A126CE53EB84A1D FOREIGN KEY (magazine_id) REFERENCES magazine (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE magazine_ban ADD CONSTRAINT FK_6A126CE5A76ED395 FOREIGN KEY (user_id) REFERENCES \"user\" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE magazine_ban ADD CONSTRAINT FK_6A126CE5386B8E7 FOREIGN KEY (banned_by_id) REFERENCES \"user\" (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE magazine_log DROP CONSTRAINT FK_87D3D4C53EB84A1D');\n        $this->addSql('ALTER TABLE magazine_log DROP CONSTRAINT FK_87D3D4C5A76ED395');\n        $this->addSql('ALTER TABLE magazine_log DROP CONSTRAINT FK_87D3D4C51255CD1D');\n        $this->addSql('ALTER TABLE magazine_log ADD CONSTRAINT FK_87D3D4C53EB84A1D FOREIGN KEY (magazine_id) REFERENCES magazine (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE magazine_log ADD CONSTRAINT FK_87D3D4C5A76ED395 FOREIGN KEY (user_id) REFERENCES \"user\" (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE magazine_log ADD CONSTRAINT FK_87D3D4C51255CD1D FOREIGN KEY (ban_id) REFERENCES magazine_ban (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE magazine_ownership_request DROP CONSTRAINT FK_A7160C65A76ED395');\n        $this->addSql('ALTER TABLE magazine_ownership_request ADD CONSTRAINT FK_A7160C65A76ED395 FOREIGN KEY (user_id) REFERENCES \"user\" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE magazine_subscription DROP CONSTRAINT FK_ACCE935A76ED395');\n        $this->addSql('ALTER TABLE magazine_subscription DROP CONSTRAINT FK_ACCE9353EB84A1D');\n        $this->addSql('ALTER TABLE magazine_subscription ADD CONSTRAINT FK_ACCE935A76ED395 FOREIGN KEY (user_id) REFERENCES \"user\" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE magazine_subscription ADD CONSTRAINT FK_ACCE9353EB84A1D FOREIGN KEY (magazine_id) REFERENCES magazine (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE message DROP CONSTRAINT FK_B6BD307FF624B39D');\n        $this->addSql('ALTER TABLE message ADD CONSTRAINT FK_B6BD307FF624B39D FOREIGN KEY (sender_id) REFERENCES \"user\" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE moderator DROP CONSTRAINT FK_6A30B268A76ED395');\n        $this->addSql('ALTER TABLE moderator DROP CONSTRAINT FK_6A30B268CA792C6B');\n        $this->addSql('ALTER TABLE moderator ADD CONSTRAINT FK_6A30B268A76ED395 FOREIGN KEY (user_id) REFERENCES \"user\" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE moderator ADD CONSTRAINT FK_6A30B268CA792C6B FOREIGN KEY (added_by_user_id) REFERENCES \"user\" (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE moderator_request DROP CONSTRAINT FK_2CC3E324A76ED395');\n        $this->addSql('ALTER TABLE moderator_request ADD CONSTRAINT FK_2CC3E324A76ED395 FOREIGN KEY (user_id) REFERENCES \"user\" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE notification DROP CONSTRAINT FK_BF5476CA4B89032C');\n        $this->addSql('ALTER TABLE notification DROP CONSTRAINT FK_BF5476CA537A1329');\n        $this->addSql('ALTER TABLE notification DROP CONSTRAINT FK_BF5476CA1255CD1D');\n        $this->addSql('ALTER TABLE notification DROP CONSTRAINT FK_BF5476CADB1174D2');\n        $this->addSql('ALTER TABLE notification ADD CONSTRAINT FK_BF5476CA4B89032C FOREIGN KEY (post_id) REFERENCES post (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE notification ADD CONSTRAINT FK_BF5476CA537A1329 FOREIGN KEY (message_id) REFERENCES message (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE notification ADD CONSTRAINT FK_BF5476CA1255CD1D FOREIGN KEY (ban_id) REFERENCES magazine_ban (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE notification ADD CONSTRAINT FK_BF5476CADB1174D2 FOREIGN KEY (post_comment_id) REFERENCES post_comment (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE oauth2_client DROP CONSTRAINT FK_669FF9C9A76ED395');\n        $this->addSql('ALTER TABLE oauth2_client ADD CONSTRAINT FK_669FF9C9A76ED395 FOREIGN KEY (user_id) REFERENCES \"user\" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE oauth2_user_consent DROP CONSTRAINT FK_C8F05D01A76ED395');\n        $this->addSql('ALTER TABLE oauth2_user_consent ADD CONSTRAINT FK_C8F05D01A76ED395 FOREIGN KEY (user_id) REFERENCES \"user\" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE post DROP CONSTRAINT FK_5A8A6C8DA76ED395');\n        $this->addSql('ALTER TABLE post ADD CONSTRAINT FK_5A8A6C8DA76ED395 FOREIGN KEY (user_id) REFERENCES \"user\" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE post_comment DROP CONSTRAINT FK_A99CE55FA76ED395');\n        $this->addSql('ALTER TABLE post_comment ADD CONSTRAINT FK_A99CE55FA76ED395 FOREIGN KEY (user_id) REFERENCES \"user\" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE post_comment_vote DROP CONSTRAINT FK_D71B5A5BA76ED395');\n        $this->addSql('ALTER TABLE post_comment_vote DROP CONSTRAINT FK_D71B5A5BF675F31B');\n        $this->addSql('ALTER TABLE post_comment_vote ADD CONSTRAINT FK_D71B5A5BA76ED395 FOREIGN KEY (user_id) REFERENCES \"user\" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE post_comment_vote ADD CONSTRAINT FK_D71B5A5BF675F31B FOREIGN KEY (author_id) REFERENCES \"user\" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE post_vote DROP CONSTRAINT FK_9345E26FA76ED395');\n        $this->addSql('ALTER TABLE post_vote DROP CONSTRAINT FK_9345E26FF675F31B');\n        $this->addSql('ALTER TABLE post_vote ADD CONSTRAINT FK_9345E26FA76ED395 FOREIGN KEY (user_id) REFERENCES \"user\" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE post_vote ADD CONSTRAINT FK_9345E26FF675F31B FOREIGN KEY (author_id) REFERENCES \"user\" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE report DROP CONSTRAINT FK_C42F77843EB84A1D');\n        $this->addSql('ALTER TABLE report DROP CONSTRAINT FK_C42F778427EE0E60');\n        $this->addSql('ALTER TABLE report DROP CONSTRAINT FK_C42F778494BDEEB6');\n        $this->addSql('ALTER TABLE report DROP CONSTRAINT FK_C42F7784BA364942');\n        $this->addSql('ALTER TABLE report DROP CONSTRAINT FK_C42F778460C33421');\n        $this->addSql('ALTER TABLE report DROP CONSTRAINT FK_C42F77844B89032C');\n        $this->addSql('ALTER TABLE report DROP CONSTRAINT FK_C42F7784DB1174D2');\n        $this->addSql('ALTER TABLE report ADD CONSTRAINT FK_C42F77843EB84A1D FOREIGN KEY (magazine_id) REFERENCES magazine (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE report ADD CONSTRAINT FK_C42F778427EE0E60 FOREIGN KEY (reporting_id) REFERENCES \"user\" (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE report ADD CONSTRAINT FK_C42F778494BDEEB6 FOREIGN KEY (reported_id) REFERENCES \"user\" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE report ADD CONSTRAINT FK_C42F7784BA364942 FOREIGN KEY (entry_id) REFERENCES entry (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE report ADD CONSTRAINT FK_C42F778460C33421 FOREIGN KEY (entry_comment_id) REFERENCES entry_comment (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE report ADD CONSTRAINT FK_C42F77844B89032C FOREIGN KEY (post_id) REFERENCES post (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE report ADD CONSTRAINT FK_C42F7784DB1174D2 FOREIGN KEY (post_comment_id) REFERENCES post_comment (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE reset_password_request DROP CONSTRAINT FK_7CE748AA76ED395');\n        $this->addSql('ALTER TABLE reset_password_request ADD CONSTRAINT FK_7CE748AA76ED395 FOREIGN KEY (user_id) REFERENCES \"user\" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE view_counter DROP CONSTRAINT FK_E87F8182BA364942');\n        $this->addSql('ALTER TABLE view_counter ADD CONSTRAINT FK_E87F8182BA364942 FOREIGN KEY (entry_id) REFERENCES entry (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n    }\n\n    public function down(Schema $schema): void\n    {\n        // this down() migration is auto-generated, please modify it to your needs\n        $this->addSql('ALTER TABLE moderator DROP CONSTRAINT fk_6a30b268a76ed395');\n        $this->addSql('ALTER TABLE moderator DROP CONSTRAINT fk_6a30b268ca792c6b');\n        $this->addSql('ALTER TABLE moderator ADD CONSTRAINT fk_6a30b268a76ed395 FOREIGN KEY (user_id) REFERENCES \"user\" (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE moderator ADD CONSTRAINT fk_6a30b268ca792c6b FOREIGN KEY (added_by_user_id) REFERENCES \"user\" (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE post_comment_vote DROP CONSTRAINT fk_d71b5a5ba76ed395');\n        $this->addSql('ALTER TABLE post_comment_vote DROP CONSTRAINT fk_d71b5a5bf675f31b');\n        $this->addSql('ALTER TABLE post_comment_vote ADD CONSTRAINT fk_d71b5a5ba76ed395 FOREIGN KEY (user_id) REFERENCES \"user\" (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE post_comment_vote ADD CONSTRAINT fk_d71b5a5bf675f31b FOREIGN KEY (author_id) REFERENCES \"user\" (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE view_counter DROP CONSTRAINT fk_e87f8182ba364942');\n        $this->addSql('ALTER TABLE view_counter ADD CONSTRAINT fk_e87f8182ba364942 FOREIGN KEY (entry_id) REFERENCES entry (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE post_vote DROP CONSTRAINT fk_9345e26fa76ed395');\n        $this->addSql('ALTER TABLE post_vote DROP CONSTRAINT fk_9345e26ff675f31b');\n        $this->addSql('ALTER TABLE post_vote ADD CONSTRAINT fk_9345e26fa76ed395 FOREIGN KEY (user_id) REFERENCES \"user\" (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE post_vote ADD CONSTRAINT fk_9345e26ff675f31b FOREIGN KEY (author_id) REFERENCES \"user\" (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE post_comment DROP CONSTRAINT fk_a99ce55fa76ed395');\n        $this->addSql('ALTER TABLE post_comment ADD CONSTRAINT fk_a99ce55fa76ed395 FOREIGN KEY (user_id) REFERENCES \"user\" (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE entry_comment DROP CONSTRAINT fk_b892fdfba76ed395');\n        $this->addSql('ALTER TABLE entry_comment DROP CONSTRAINT fk_b892fdfb79066886');\n        $this->addSql('ALTER TABLE entry_comment ADD CONSTRAINT fk_b892fdfba76ed395 FOREIGN KEY (user_id) REFERENCES \"user\" (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE entry_comment ADD CONSTRAINT fk_b892fdfb79066886 FOREIGN KEY (root_id) REFERENCES entry_comment (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE post DROP CONSTRAINT fk_5a8a6c8da76ed395');\n        $this->addSql('ALTER TABLE post ADD CONSTRAINT fk_5a8a6c8da76ed395 FOREIGN KEY (user_id) REFERENCES \"user\" (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE report DROP CONSTRAINT fk_c42f77843eb84a1d');\n        $this->addSql('ALTER TABLE report DROP CONSTRAINT fk_c42f778427ee0e60');\n        $this->addSql('ALTER TABLE report DROP CONSTRAINT fk_c42f778494bdeeb6');\n        $this->addSql('ALTER TABLE report DROP CONSTRAINT fk_c42f7784ba364942');\n        $this->addSql('ALTER TABLE report DROP CONSTRAINT fk_c42f778460c33421');\n        $this->addSql('ALTER TABLE report DROP CONSTRAINT fk_c42f77844b89032c');\n        $this->addSql('ALTER TABLE report DROP CONSTRAINT fk_c42f7784db1174d2');\n        $this->addSql('ALTER TABLE report ADD CONSTRAINT fk_c42f77843eb84a1d FOREIGN KEY (magazine_id) REFERENCES magazine (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE report ADD CONSTRAINT fk_c42f778427ee0e60 FOREIGN KEY (reporting_id) REFERENCES \"user\" (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE report ADD CONSTRAINT fk_c42f778494bdeeb6 FOREIGN KEY (reported_id) REFERENCES \"user\" (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE report ADD CONSTRAINT fk_c42f7784ba364942 FOREIGN KEY (entry_id) REFERENCES entry (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE report ADD CONSTRAINT fk_c42f778460c33421 FOREIGN KEY (entry_comment_id) REFERENCES entry_comment (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE report ADD CONSTRAINT fk_c42f77844b89032c FOREIGN KEY (post_id) REFERENCES post (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE report ADD CONSTRAINT fk_c42f7784db1174d2 FOREIGN KEY (post_comment_id) REFERENCES post_comment (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE magazine_ownership_request DROP CONSTRAINT fk_a7160c65a76ed395');\n        $this->addSql('ALTER TABLE magazine_ownership_request ADD CONSTRAINT fk_a7160c65a76ed395 FOREIGN KEY (user_id) REFERENCES \"user\" (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE reset_password_request DROP CONSTRAINT fk_7ce748aa76ed395');\n        $this->addSql('ALTER TABLE reset_password_request ADD CONSTRAINT fk_7ce748aa76ed395 FOREIGN KEY (user_id) REFERENCES \"user\" (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE entry_comment_vote DROP CONSTRAINT fk_9e561267a76ed395');\n        $this->addSql('ALTER TABLE entry_comment_vote DROP CONSTRAINT fk_9e561267f675f31b');\n        $this->addSql('ALTER TABLE entry_comment_vote ADD CONSTRAINT fk_9e561267a76ed395 FOREIGN KEY (user_id) REFERENCES \"user\" (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE entry_comment_vote ADD CONSTRAINT fk_9e561267f675f31b FOREIGN KEY (author_id) REFERENCES \"user\" (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE favourite DROP CONSTRAINT fk_62a2ca193eb84a1d');\n        $this->addSql('ALTER TABLE favourite DROP CONSTRAINT fk_62a2ca19a76ed395');\n        $this->addSql('ALTER TABLE favourite DROP CONSTRAINT fk_62a2ca19ba364942');\n        $this->addSql('ALTER TABLE favourite DROP CONSTRAINT fk_62a2ca1960c33421');\n        $this->addSql('ALTER TABLE favourite DROP CONSTRAINT fk_62a2ca194b89032c');\n        $this->addSql('ALTER TABLE favourite DROP CONSTRAINT fk_62a2ca19db1174d2');\n        $this->addSql('ALTER TABLE favourite ADD CONSTRAINT fk_62a2ca193eb84a1d FOREIGN KEY (magazine_id) REFERENCES magazine (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE favourite ADD CONSTRAINT fk_62a2ca19a76ed395 FOREIGN KEY (user_id) REFERENCES \"user\" (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE favourite ADD CONSTRAINT fk_62a2ca19ba364942 FOREIGN KEY (entry_id) REFERENCES entry (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE favourite ADD CONSTRAINT fk_62a2ca1960c33421 FOREIGN KEY (entry_comment_id) REFERENCES entry_comment (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE favourite ADD CONSTRAINT fk_62a2ca194b89032c FOREIGN KEY (post_id) REFERENCES post (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE favourite ADD CONSTRAINT fk_62a2ca19db1174d2 FOREIGN KEY (post_comment_id) REFERENCES post_comment (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE entry_vote DROP CONSTRAINT fk_fe32fd77a76ed395');\n        $this->addSql('ALTER TABLE entry_vote DROP CONSTRAINT fk_fe32fd77f675f31b');\n        $this->addSql('ALTER TABLE entry_vote ADD CONSTRAINT fk_fe32fd77a76ed395 FOREIGN KEY (user_id) REFERENCES \"user\" (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE entry_vote ADD CONSTRAINT fk_fe32fd77f675f31b FOREIGN KEY (author_id) REFERENCES \"user\" (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE magazine_ban DROP CONSTRAINT fk_6a126ce53eb84a1d');\n        $this->addSql('ALTER TABLE magazine_ban DROP CONSTRAINT fk_6a126ce5a76ed395');\n        $this->addSql('ALTER TABLE magazine_ban DROP CONSTRAINT fk_6a126ce5386b8e7');\n        $this->addSql('ALTER TABLE magazine_ban ADD CONSTRAINT fk_6a126ce53eb84a1d FOREIGN KEY (magazine_id) REFERENCES magazine (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE magazine_ban ADD CONSTRAINT fk_6a126ce5a76ed395 FOREIGN KEY (user_id) REFERENCES \"user\" (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE magazine_ban ADD CONSTRAINT fk_6a126ce5386b8e7 FOREIGN KEY (banned_by_id) REFERENCES \"user\" (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE oauth2_user_consent DROP CONSTRAINT fk_c8f05d01a76ed395');\n        $this->addSql('ALTER TABLE oauth2_user_consent ADD CONSTRAINT fk_c8f05d01a76ed395 FOREIGN KEY (user_id) REFERENCES \"user\" (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE magazine_log DROP CONSTRAINT fk_87d3d4c53eb84a1d');\n        $this->addSql('ALTER TABLE magazine_log DROP CONSTRAINT fk_87d3d4c5a76ed395');\n        $this->addSql('ALTER TABLE magazine_log DROP CONSTRAINT fk_87d3d4c51255cd1d');\n        $this->addSql('ALTER TABLE magazine_log ADD CONSTRAINT fk_87d3d4c53eb84a1d FOREIGN KEY (magazine_id) REFERENCES magazine (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE magazine_log ADD CONSTRAINT fk_87d3d4c5a76ed395 FOREIGN KEY (user_id) REFERENCES \"user\" (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE magazine_log ADD CONSTRAINT fk_87d3d4c51255cd1d FOREIGN KEY (ban_id) REFERENCES magazine_ban (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE moderator_request DROP CONSTRAINT fk_2cc3e324a76ed395');\n        $this->addSql('ALTER TABLE moderator_request ADD CONSTRAINT fk_2cc3e324a76ed395 FOREIGN KEY (user_id) REFERENCES \"user\" (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE \"oauth2_client\" DROP CONSTRAINT fk_669ff9c9a76ed395');\n        $this->addSql('ALTER TABLE \"oauth2_client\" ADD CONSTRAINT fk_669ff9c9a76ed395 FOREIGN KEY (user_id) REFERENCES \"user\" (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE magazine_subscription DROP CONSTRAINT fk_acce935a76ed395');\n        $this->addSql('ALTER TABLE magazine_subscription DROP CONSTRAINT fk_acce9353eb84a1d');\n        $this->addSql('ALTER TABLE magazine_subscription ADD CONSTRAINT fk_acce935a76ed395 FOREIGN KEY (user_id) REFERENCES \"user\" (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE magazine_subscription ADD CONSTRAINT fk_acce9353eb84a1d FOREIGN KEY (magazine_id) REFERENCES magazine (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE message DROP CONSTRAINT fk_b6bd307ff624b39d');\n        $this->addSql('ALTER TABLE message ADD CONSTRAINT fk_b6bd307ff624b39d FOREIGN KEY (sender_id) REFERENCES \"user\" (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE notification DROP CONSTRAINT fk_bf5476ca4b89032c');\n        $this->addSql('ALTER TABLE notification DROP CONSTRAINT fk_bf5476cadb1174d2');\n        $this->addSql('ALTER TABLE notification DROP CONSTRAINT fk_bf5476ca537a1329');\n        $this->addSql('ALTER TABLE notification DROP CONSTRAINT fk_bf5476ca1255cd1d');\n        $this->addSql('ALTER TABLE notification ADD CONSTRAINT fk_bf5476ca4b89032c FOREIGN KEY (post_id) REFERENCES post (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE notification ADD CONSTRAINT fk_bf5476cadb1174d2 FOREIGN KEY (post_comment_id) REFERENCES post_comment (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE notification ADD CONSTRAINT fk_bf5476ca537a1329 FOREIGN KEY (message_id) REFERENCES message (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE notification ADD CONSTRAINT fk_bf5476ca1255cd1d FOREIGN KEY (ban_id) REFERENCES magazine_ban (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE entry DROP CONSTRAINT fk_2b219d70a76ed395');\n        $this->addSql('ALTER TABLE entry ADD CONSTRAINT fk_2b219d70a76ed395 FOREIGN KEY (user_id) REFERENCES \"user\" (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20240217103834.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\nfinal class Version20240217103834 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return 'This will make the file_path nullable, so we can store links to remote images, which could not be cached locally';\n    }\n\n    public function up(Schema $schema): void\n    {\n        $this->addSql('ALTER TABLE image ALTER file_path DROP NOT NULL');\n    }\n\n    public function down(Schema $schema): void\n    {\n        $this->addSql('ALTER TABLE image ALTER file_path SET NOT NULL');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20240217141231.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\nfinal class Version20240217141231 extends AbstractMigration\n{\n    public function up(Schema $schema): void\n    {\n        $this->addSql('ALTER TABLE magazine ADD last_origin_update TIMESTAMP(0) WITH TIME ZONE DEFAULT NULL');\n    }\n\n    public function down(Schema $schema): void\n    {\n        $this->addSql('ALTER TABLE magazine DROP last_origin_update');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20240313222328.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\nfinal class Version20240313222328 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return 'This migration removes all the duplicates from the favourite table and creates 4 unique indexes for each combination of user_id with [entry|entry_comment|post|post_comment]_id';\n    }\n\n    public function up(Schema $schema): void\n    {\n        $this->addSql('DELETE FROM favourite a USING favourite b WHERE a.id > b.id AND a.entry_id = b.entry_id AND a.user_id = b.user_id');\n        $this->addSql('DELETE FROM favourite a USING favourite b WHERE a.id > b.id AND a.entry_comment_id = b.entry_comment_id AND a.user_id = b.user_id');\n        $this->addSql('DELETE FROM favourite a USING favourite b WHERE a.id > b.id AND a.post_id = b.post_id AND a.user_id = b.user_id');\n        $this->addSql('DELETE FROM favourite a USING favourite b WHERE a.id > b.id AND a.post_comment_id = b.post_comment_id AND a.user_id = b.user_id');\n        $this->addSql('CREATE UNIQUE INDEX IF NOT EXISTS favourite_user_entry_unique_idx ON favourite (entry_id, user_id)');\n        $this->addSql('CREATE UNIQUE INDEX IF NOT EXISTS favourite_user_entry_comment_unique_idx ON favourite (entry_comment_id, user_id)');\n        $this->addSql('CREATE UNIQUE INDEX IF NOT EXISTS favourite_user_post_unique_idx ON favourite (post_id, user_id)');\n        $this->addSql('CREATE UNIQUE INDEX IF NOT EXISTS favourite_user_post_comment_unique_idx ON favourite (post_comment_id, user_id)');\n    }\n\n    public function down(Schema $schema): void\n    {\n        $this->addSql('DROP INDEX IF EXISTS favourite_user_entry_unique_idx');\n        $this->addSql('DROP INDEX IF EXISTS favourite_user_entry_comment_unique_idx');\n        $this->addSql('DROP INDEX IF EXISTS favourite_user_post_unique_idx');\n        $this->addSql('DROP INDEX IF EXISTS favourite_user_post_comment_unique_idx');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20240315124130.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\nclass Version20240315124130 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return 'This migration adds a unique index for the ap_profile_id and renames the other cryptically named indexes to understandable ones';\n    }\n\n    public function up(Schema $schema): void\n    {\n        $this->addSql('CREATE UNIQUE INDEX IF NOT EXISTS user_ap_profile_id_idx ON \"user\" (ap_profile_id)');\n        $this->addSql('ALTER INDEX IF EXISTS uniq_8d93d649e7927c74 RENAME TO user_email_idx');\n        $this->addSql('ALTER INDEX IF EXISTS uniq_8d93d649f85e0677 RENAME TO user_username_idx');\n        $this->addSql('ALTER INDEX IF EXISTS uniq_8d93d649904f155e RENAME TO user_ap_id_idx');\n    }\n\n    public function down(Schema $schema): void\n    {\n        $this->addSql('DROP INDEX IF EXISTS user_ap_profile_id_idx');\n        $this->addSql('ALTER INDEX IF EXISTS user_username_idx RENAME TO uniq_8d93d649f85e0677');\n        $this->addSql('ALTER INDEX IF EXISTS user_email_idx RENAME TO uniq_8d93d649e7927c74');\n        $this->addSql('ALTER INDEX IF EXISTS user_ap_id_idx RENAME TO uniq_8d93d649904f155e');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20240317163312.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\nfinal class Version20240317163312 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return 'This migration changes the report.reporting_id foreign key to cascade delete instead of cascading to null (which is not possible, because the column is not nullable)';\n    }\n\n    public function up(Schema $schema): void\n    {\n        $this->addSql('ALTER TABLE report DROP CONSTRAINT IF EXISTS FK_C42F778427EE0E60');\n        $this->addSql('ALTER TABLE report ADD CONSTRAINT FK_C42F778427EE0E60 FOREIGN KEY (reporting_id) REFERENCES \"user\" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n    }\n\n    public function down(Schema $schema): void\n    {\n        $this->addSql('ALTER TABLE report DROP CONSTRAINT IF EXISTS FK_C42F778427EE0E60');\n        $this->addSql('ALTER TABLE report ADD CONSTRAINT fk_c42f778427ee0e60 FOREIGN KEY (reporting_id) REFERENCES \"user\" (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20240330101300.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\nfinal class Version20240330101300 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return 'This migration moves hashtags from the entry, entry_comment, post and post_comment table to its own table, while keeping the hashtag links alive';\n    }\n\n    public function up(Schema $schema): void\n    {\n        $this->addSql('CREATE EXTENSION IF NOT EXISTS citext');\n        $this->addSql('CREATE SEQUENCE hashtag_id_seq INCREMENT BY 1 MINVALUE 1 START 1');\n        $this->addSql('CREATE SEQUENCE hashtag_link_id_seq INCREMENT BY 1 MINVALUE 1 START 1');\n        $this->addSql('CREATE TABLE hashtag (id INT NOT NULL, tag citext NOT NULL, banned BOOLEAN DEFAULT false NOT NULL, PRIMARY KEY(id))');\n        $this->addSql('CREATE UNIQUE INDEX UNIQ_5AB52A61389B783 ON hashtag (tag)');\n        $this->addSql('CREATE TABLE hashtag_link (id INT NOT NULL, hashtag_id INT NOT NULL, entry_id INT DEFAULT NULL, entry_comment_id INT DEFAULT NULL, post_id INT DEFAULT NULL, post_comment_id INT DEFAULT NULL, PRIMARY KEY(id))');\n        $this->addSql('CREATE INDEX IDX_83957168FB34EF56 ON hashtag_link (hashtag_id)');\n        $this->addSql('CREATE INDEX IDX_83957168BA364942 ON hashtag_link (entry_id)');\n        $this->addSql('CREATE INDEX IDX_8395716860C33421 ON hashtag_link (entry_comment_id)');\n        $this->addSql('CREATE INDEX IDX_839571684B89032C ON hashtag_link (post_id)');\n        $this->addSql('CREATE INDEX IDX_83957168DB1174D2 ON hashtag_link (post_comment_id)');\n        $this->addSql('ALTER TABLE hashtag_link ADD CONSTRAINT FK_83957168FB34EF56 FOREIGN KEY (hashtag_id) REFERENCES hashtag (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE hashtag_link ADD CONSTRAINT FK_83957168BA364942 FOREIGN KEY (entry_id) REFERENCES entry (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE hashtag_link ADD CONSTRAINT FK_8395716860C33421 FOREIGN KEY (entry_comment_id) REFERENCES entry_comment (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE hashtag_link ADD CONSTRAINT FK_839571684B89032C FOREIGN KEY (post_id) REFERENCES post (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE hashtag_link ADD CONSTRAINT FK_83957168DB1174D2 FOREIGN KEY (post_comment_id) REFERENCES post_comment (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n\n        // migrate entry tags\n        $select = \"SELECT e.id, e.tags, keys.value::CITEXT as hashtag, e.created_at FROM entry e\n                JOIN LATERAL (SELECT * FROM jsonb_array_elements_text(e.tags)) as keys ON TRUE\n                WHERE e.tags IS NOT NULL AND jsonb_typeof(e.tags) = 'array'\n            UNION ALL\n            SELECT e.id, e.tags, keys.value::CITEXT as hashtag, e.created_at FROM entry e\n                JOIN LATERAL (SELECT * FROM jsonb_each_text(e.tags)) as keys ON TRUE\n                WHERE e.tags IS NOT NULL AND jsonb_typeof(e.tags) = 'object'\n            ORDER BY created_at DESC\";\n        $foreachStatement = \"IF NOT EXISTS (SELECT id FROM hashtag WHERE hashtag.tag = temprow.hashtag) THEN\n                INSERT INTO hashtag(id, tag) VALUES(NEXTVAL('hashtag_id_seq'), temprow.hashtag);\n            END IF;\n            IF NOT EXISTS (SELECT l.id FROM hashtag_link l \n                INNER JOIN hashtag def ON def.id=l.hashtag_id \n                WHERE l.entry_id = temprow.id AND def.tag = temprow.hashtag) \n            THEN\n                INSERT INTO hashtag_link (id, entry_id, hashtag_id) VALUES (NEXTVAL('hashtag_link_id_seq'), temprow.id, (SELECT id FROM hashtag WHERE tag = temprow.hashtag));\n            END IF;\";\n\n        $this->addSql('DO\n            $do$\n                declare temprow record;\n            BEGIN \n                FOR temprow IN\n                    '.$select.'\n                LOOP\n                    '.$foreachStatement.'\n                END LOOP;\n            END\n            $do$;');\n\n        // migrate entry comments tags\n        $select = \"SELECT e.id, e.tags, keys.value::CITEXT as hashtag, e.created_at FROM entry_comment e\n                JOIN LATERAL (SELECT * FROM jsonb_array_elements_text(e.tags)) as keys ON TRUE\n                WHERE e.tags IS NOT NULL AND jsonb_typeof(e.tags) = 'array'\n            UNION ALL\n            SELECT e.id, e.tags, keys.value::CITEXT as hashtag, e.created_at FROM entry_comment e\n                JOIN LATERAL (SELECT * FROM jsonb_each_text(e.tags)) as keys ON TRUE\n                WHERE e.tags IS NOT NULL AND jsonb_typeof(e.tags) = 'object'\n            ORDER BY created_at DESC\";\n        $foreachStatement = \"IF NOT EXISTS (SELECT id FROM hashtag WHERE hashtag.tag = temprow.hashtag) THEN\n                INSERT INTO hashtag(id, tag) VALUES(NEXTVAL('hashtag_id_seq'), temprow.hashtag);\n            END IF;\n            IF NOT EXISTS (SELECT l.id FROM hashtag_link l \n                INNER JOIN hashtag def ON def.id=l.hashtag_id \n                WHERE l.entry_comment_id = temprow.id AND def.tag = temprow.hashtag) \n            THEN\n                INSERT INTO hashtag_link (id, entry_comment_id, hashtag_id) VALUES (NEXTVAL('hashtag_link_id_seq'), temprow.id, (SELECT id FROM hashtag WHERE tag=temprow.hashtag));\n            END IF;\";\n\n        $this->addSql('DO\n            $do$\n                declare temprow record;\n            BEGIN \n                FOR temprow IN\n                    '.$select.'\n                LOOP\n                    '.$foreachStatement.'\n                END LOOP;\n            END\n            $do$;');\n\n        // migrate post tags\n        $select = \"SELECT e.id, e.tags, keys.value::CITEXT as hashtag, e.created_at FROM post e\n                JOIN LATERAL (SELECT * FROM jsonb_array_elements_text(e.tags)) as keys ON TRUE\n                WHERE e.tags IS NOT NULL AND jsonb_typeof(e.tags) = 'array'\n            UNION ALL\n            SELECT e.id, e.tags, keys.value::CITEXT as hashtag, e.created_at FROM post e\n                JOIN LATERAL (SELECT * FROM jsonb_each_text(e.tags)) as keys ON TRUE\n                WHERE e.tags IS NOT NULL AND jsonb_typeof(e.tags) = 'object'\n            ORDER BY created_at DESC\";\n        $foreachStatement = \"IF NOT EXISTS (SELECT id FROM hashtag WHERE hashtag.tag = temprow.hashtag) THEN\n                INSERT INTO hashtag(id, tag) VALUES(NEXTVAL('hashtag_id_seq'), temprow.hashtag);\n            END IF;\n            IF NOT EXISTS (SELECT l.id FROM hashtag_link l \n                INNER JOIN hashtag def ON def.id=l.hashtag_id \n                WHERE l.post_id = temprow.id AND def.tag = temprow.hashtag) \n            THEN\n                INSERT INTO hashtag_link (id, post_id, hashtag_id) VALUES (NEXTVAL('hashtag_link_id_seq'), temprow.id, (SELECT id FROM hashtag WHERE tag=temprow.hashtag));\n            END IF;\";\n\n        $this->addSql('DO\n            $do$\n                declare temprow record;\n            BEGIN \n                FOR temprow IN\n                    '.$select.'\n                LOOP\n                    '.$foreachStatement.'\n                END LOOP;\n            END\n            $do$;');\n        // migrate post comment tags\n        $select = \"SELECT e.id, e.tags, keys.value::CITEXT as hashtag, e.created_at FROM post_comment e\n                JOIN LATERAL (SELECT * FROM jsonb_array_elements_text(e.tags)) as keys ON TRUE\n                WHERE e.tags IS NOT NULL AND jsonb_typeof(e.tags) = 'array'\n            UNION ALL\n            SELECT e.id, e.tags, keys.value::CITEXT as hashtag, e.created_at FROM post_comment e\n                JOIN LATERAL (SELECT * FROM jsonb_each_text(e.tags)) as keys ON TRUE\n                WHERE e.tags IS NOT NULL AND jsonb_typeof(e.tags) = 'object'\n            ORDER BY created_at DESC\";\n        $foreachStatement = \"IF NOT EXISTS (SELECT id FROM hashtag WHERE hashtag.tag = temprow.hashtag) THEN\n                INSERT INTO hashtag(id, tag) VALUES(NEXTVAL('hashtag_id_seq'), temprow.hashtag);\n            END IF;\n            IF NOT EXISTS (SELECT l.id FROM hashtag_link l \n                INNER JOIN hashtag def ON def.id=l.hashtag_id \n                WHERE l.post_comment_id = temprow.id AND def.tag = temprow.hashtag) \n            THEN\n                INSERT INTO hashtag_link (id, post_comment_id, hashtag_id) VALUES (NEXTVAL('hashtag_link_id_seq'), temprow.id, (SELECT id FROM hashtag WHERE tag=temprow.hashtag));\n            END IF;\";\n\n        $this->addSql('DO\n            $do$\n                declare temprow record;\n            BEGIN \n                FOR temprow IN\n                    '.$select.'\n                LOOP\n                    '.$foreachStatement.'\n                END LOOP;\n            END\n            $do$;');\n\n        $this->addSql('ALTER TABLE entry DROP COLUMN tags');\n        $this->addSql('ALTER TABLE entry_comment DROP COLUMN tags');\n        $this->addSql('ALTER TABLE post DROP COLUMN tags');\n        $this->addSql('ALTER TABLE post_comment DROP COLUMN tags');\n    }\n\n    public function down(Schema $schema): void\n    {\n        $this->addSql('ALTER TABLE entry_comment ADD tags JSONB DEFAULT NULL');\n        $this->addSql('ALTER TABLE post_comment ADD tags JSONB DEFAULT NULL');\n        $this->addSql('ALTER TABLE post ADD tags JSONB DEFAULT NULL');\n        $this->addSql('ALTER TABLE entry ADD tags JSONB DEFAULT NULL');\n\n        $this->addSql('DO\n$do$\n    declare temprow record;\nBEGIN \n    FOR temprow IN\n        SELECT hl.entry_id, hl.entry_comment_id, hl.post_id, hl.post_comment_id, h.tag FROM hashtag_link hl INNER JOIN hashtag h ON h.id = hl.hashtag_id\n    LOOP\n        IF temprow.entry_id IS NOT NULL THEN    \n            IF NOT EXISTS (SELECT id FROM entry e WHERE e.id = temprow.entry_id AND e.tags IS NOT NULL) THEN\n                UPDATE entry SET tags = \\'[]\\'::jsonb WHERE entry.id = temprow.entry_id;\n            END IF;\n            UPDATE entry SET tags = tags || to_jsonb(temprow.tag) WHERE entry.id = temprow.entry_id;\n        END IF;\n        IF temprow.entry_comment_id IS NOT NULL THEN\n            IF NOT EXISTS (SELECT id FROM entry_comment ec WHERE ec.id = temprow.entry_comment_id AND ec.tags IS NOT NULL) THEN\n                UPDATE entry_comment SET tags = \\'[]\\'::jsonb WHERE entry_comment.id = temprow.entry_comment_id;\n            END IF;\n            UPDATE entry_comment SET tags = tags || to_jsonb(temprow.tag) WHERE entry_comment.id = temprow.entry_comment_id;\n        END IF;\n        IF temprow.post_id IS NOT NULL THEN\n            IF NOT EXISTS (SELECT id FROM post p WHERE p.id = temprow.post_id AND p.tags IS NOT NULL) THEN\n                UPDATE post SET tags = \\'[]\\'::jsonb WHERE post.id = temprow.post_id;\n            END IF;\n            UPDATE post SET tags = tags || to_jsonb(temprow.tag) WHERE post.id = temprow.post_id;\n        END IF;\n        IF temprow.post_comment_id IS NOT NULL THEN\n            IF NOT EXISTS (SELECT id FROM post_comment pc WHERE pc.id = temprow.post_comment_id AND pc.tags IS NOT NULL) THEN\n                UPDATE post_comment SET tags = \\'[]\\'::jsonb WHERE post_comment.id = temprow.post_comment_id;\n            END IF;\n            UPDATE post_comment SET tags = tags || to_jsonb(temprow.tag) WHERE post_comment.id = temprow.post_comment_id;\n        END IF;\n    END LOOP;\nEND\n$do$;');\n\n        $this->addSql('DROP SEQUENCE hashtag_id_seq CASCADE');\n        $this->addSql('DROP SEQUENCE hashtag_link_id_seq CASCADE');\n        $this->addSql('ALTER TABLE hashtag_link DROP CONSTRAINT FK_83957168FB34EF56');\n        $this->addSql('ALTER TABLE hashtag_link DROP CONSTRAINT FK_83957168BA364942');\n        $this->addSql('ALTER TABLE hashtag_link DROP CONSTRAINT FK_8395716860C33421');\n        $this->addSql('ALTER TABLE hashtag_link DROP CONSTRAINT FK_839571684B89032C');\n        $this->addSql('ALTER TABLE hashtag_link DROP CONSTRAINT FK_83957168DB1174D2');\n        $this->addSql('DROP TABLE hashtag');\n        $this->addSql('DROP TABLE hashtag_link');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20240402190028.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\nfinal class Version20240402190028 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return 'Make the foreign keys in the message, message_thread and message_thread_participants tables cascade delete';\n    }\n\n    public function up(Schema $schema): void\n    {\n        $this->addSql('ALTER TABLE message_thread_participants DROP CONSTRAINT FK_F2DE92908829462F');\n        $this->addSql('ALTER TABLE message_thread_participants DROP CONSTRAINT FK_F2DE9290A76ED395');\n        $this->addSql('ALTER TABLE message DROP CONSTRAINT FK_B6BD307FE2904019');\n        $this->addSql('ALTER TABLE message_thread_participants ADD CONSTRAINT FK_F2DE92908829462F FOREIGN KEY (message_thread_id) REFERENCES message_thread (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE message_thread_participants ADD CONSTRAINT FK_F2DE9290A76ED395 FOREIGN KEY (user_id) REFERENCES \"user\" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE message ADD CONSTRAINT FK_B6BD307FE2904019 FOREIGN KEY (thread_id) REFERENCES message_thread (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n    }\n\n    public function down(Schema $schema): void\n    {\n        $this->addSql('ALTER TABLE message DROP CONSTRAINT fk_b6bd307fe2904019');\n        $this->addSql('ALTER TABLE message_thread_participants DROP CONSTRAINT fk_f2de92908829462f');\n        $this->addSql('ALTER TABLE message_thread_participants DROP CONSTRAINT fk_f2de9290a76ed395');\n        $this->addSql('ALTER TABLE message ADD CONSTRAINT fk_b6bd307fe2904019 FOREIGN KEY (thread_id) REFERENCES message_thread (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE message_thread_participants ADD CONSTRAINT fk_f2de92908829462f FOREIGN KEY (message_thread_id) REFERENCES message_thread (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE message_thread_participants ADD CONSTRAINT fk_f2de9290a76ed395 FOREIGN KEY (user_id) REFERENCES \"user\" (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20240405131611.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\n/**\n * Auto-generated Migration: Please modify to your needs!\n */\nfinal class Version20240405131611 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return 'Remove view counters and associated statistics.';\n    }\n\n    public function up(Schema $schema): void\n    {\n        // this up() migration is auto-generated, please modify it to your needs\n        $this->addSql('DROP SEQUENCE view_counter_id_seq CASCADE');\n        $this->addSql('ALTER TABLE view_counter DROP CONSTRAINT fk_e87f8182ba364942');\n        $this->addSql('DROP TABLE view_counter');\n        $this->addSql('ALTER TABLE entry DROP views');\n    }\n\n    public function down(Schema $schema): void\n    {\n        // this down() migration is auto-generated, please modify it to your needs\n        $this->addSql('CREATE SEQUENCE view_counter_id_seq INCREMENT BY 1 MINVALUE 1 START 1');\n        $this->addSql('CREATE TABLE view_counter (id INT NOT NULL, entry_id INT DEFAULT NULL, ip TEXT NOT NULL, view_date TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY(id))');\n        $this->addSql('CREATE INDEX idx_e87f8182ba364942 ON view_counter (entry_id)');\n        $this->addSql('ALTER TABLE view_counter ADD CONSTRAINT fk_e87f8182ba364942 FOREIGN KEY (entry_id) REFERENCES entry (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE entry ADD views INT DEFAULT NULL');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20240405134821.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\n/**\n * Auto-generated Migration: Please modify to your needs!\n */\nfinal class Version20240405134821 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return '';\n    }\n\n    public function up(Schema $schema): void\n    {\n        $this->addSql('ALTER TABLE \"user\" ADD oauth_azure_id VARCHAR(255) DEFAULT NULL');\n    }\n\n    public function down(Schema $schema): void\n    {\n        $this->addSql('ALTER TABLE \"user\" DROP oauth_azure_id');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20240409072525.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\n/**\n * Auto-generated Migration: Please modify to your needs!\n */\nfinal class Version20240409072525 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return 'upgrade Image::$sourceUrl to text to support longer links';\n    }\n\n    public function up(Schema $schema): void\n    {\n        // this up() migration is auto-generated, please modify it to your needs\n        $this->addSql('ALTER TABLE image ALTER source_url TYPE TEXT');\n    }\n\n    public function down(Schema $schema): void\n    {\n        // this down() migration is auto-generated, please modify it to your needs\n        $this->addSql('ALTER TABLE image ALTER source_url TYPE VARCHAR(255)');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20240412010024.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\n/**\n * Auto-generated Migration: Please modify to your needs!\n */\nfinal class Version20240412010024 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return 'Fix on delete to cascade for magazine_ban and magazine_log tables';\n    }\n\n    public function up(Schema $schema): void\n    {\n        // this up() migration is auto-generated, please modify it to your needs\n        $this->addSql('ALTER TABLE magazine_ban DROP CONSTRAINT FK_6A126CE5386B8E7');\n        $this->addSql('ALTER TABLE magazine_ban ADD CONSTRAINT FK_6A126CE5386B8E7 FOREIGN KEY (banned_by_id) REFERENCES \"user\" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE magazine_log DROP CONSTRAINT FK_87D3D4C5A76ED395');\n        $this->addSql('ALTER TABLE magazine_log ADD CONSTRAINT FK_87D3D4C5A76ED395 FOREIGN KEY (user_id) REFERENCES \"user\" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n    }\n\n    public function down(Schema $schema): void\n    {\n        // this down() migration is auto-generated, please modify it to your needs\n        $this->addSql('ALTER TABLE magazine_log DROP CONSTRAINT fk_87d3d4c5a76ed395');\n        $this->addSql('ALTER TABLE magazine_log ADD CONSTRAINT fk_87d3d4c5a76ed395 FOREIGN KEY (user_id) REFERENCES \"user\" (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE magazine_ban DROP CONSTRAINT fk_6a126ce5386b8e7');\n        $this->addSql('ALTER TABLE magazine_ban ADD CONSTRAINT fk_6a126ce5386b8e7 FOREIGN KEY (banned_by_id) REFERENCES \"user\" (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20240503224350.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\n/**\n * Auto-generated Migration: Please modify to your needs!\n */\nfinal class Version20240503224350 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return 'Add SimpleLogin SSO';\n    }\n\n    public function up(Schema $schema): void\n    {\n        $this->addSql('ALTER TABLE \"user\" ADD oauth_simple_login_id VARCHAR(255) DEFAULT NULL');\n    }\n\n    public function down(Schema $schema): void\n    {\n        $this->addSql('ALTER TABLE \"user\" DROP oauth_simple_login_id');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20240515122858.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\nfinal class Version20240515122858 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return 'Add the report field for notifications';\n    }\n\n    public function up(Schema $schema): void\n    {\n        $this->addSql('ALTER TABLE notification ADD report_id INT DEFAULT NULL');\n        $this->addSql('ALTER TABLE notification ADD CONSTRAINT FK_BF5476CA4BD2A4C0 FOREIGN KEY (report_id) REFERENCES report (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('CREATE INDEX IDX_BF5476CA4BD2A4C0 ON notification (report_id)');\n    }\n\n    public function down(Schema $schema): void\n    {\n        $this->addSql('ALTER TABLE notification DROP CONSTRAINT FK_BF5476CA4BD2A4C0');\n        $this->addSql('DROP INDEX IDX_BF5476CA4BD2A4C0');\n        $this->addSql('ALTER TABLE notification DROP report_id');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20240528172429.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\nfinal class Version20240528172429 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return 'Add a field to the magazine log table for adding and removing a moderator';\n    }\n\n    public function up(Schema $schema): void\n    {\n        $this->addSql('ALTER TABLE magazine_log ADD acting_user_id INT DEFAULT NULL');\n        $this->addSql('ALTER TABLE magazine_log ADD CONSTRAINT FK_87D3D4C53EAD8611 FOREIGN KEY (acting_user_id) REFERENCES \"user\" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('CREATE INDEX IDX_87D3D4C53EAD8611 ON magazine_log (acting_user_id)');\n    }\n\n    public function down(Schema $schema): void\n    {\n        $this->addSql('ALTER TABLE magazine_log DROP CONSTRAINT FK_87D3D4C53EAD8611');\n        $this->addSql('DROP INDEX IDX_87D3D4C53EAD8611');\n        $this->addSql('ALTER TABLE magazine_log DROP acting_user_id');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20240529115400.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\nclass Version20240529115400 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return 'Remove admin as the owner of remote magazines';\n    }\n\n    public function up(Schema $schema): void\n    {\n        $this->addSql('DELETE FROM moderator mod WHERE mod.is_owner = true AND EXISTS (SELECT * FROM magazine m WHERE mod.magazine_id = m.id AND m.ap_id IS NOT NULL);');\n    }\n\n    public function down(Schema $schema): void\n    {\n    }\n}\n"
  },
  {
    "path": "migrations/Version20240603190838.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\nfinal class Version20240603190838 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return 'add a uuid and an ap_id to the message table';\n    }\n\n    public function up(Schema $schema): void\n    {\n        $this->addSql('ALTER TABLE message ADD uuid UUID NOT NULL DEFAULT gen_random_uuid()');\n        $this->addSql('ALTER TABLE message ADD ap_id VARCHAR(255) DEFAULT NULL');\n        $this->addSql('ALTER TABLE message ADD edited_at TIMESTAMP(0) WITH TIME ZONE DEFAULT NULL');\n        $this->addSql('COMMENT ON COLUMN message.uuid IS \\'(DC2Type:uuid)\\'');\n        $this->addSql('COMMENT ON COLUMN message.edited_at IS \\'(DC2Type:datetimetz_immutable)\\'');\n        $this->addSql('CREATE UNIQUE INDEX UNIQ_B6BD307FD17F50A6 ON message (uuid)');\n        $this->addSql('CREATE UNIQUE INDEX UNIQ_B6BD307F904F155E ON message (ap_id)');\n    }\n\n    public function down(Schema $schema): void\n    {\n        $this->addSql('DROP INDEX UNIQ_B6BD307FD17F50A6');\n        $this->addSql('DROP INDEX UNIQ_B6BD307F904F155E');\n        $this->addSql('ALTER TABLE message DROP uuid');\n        $this->addSql('ALTER TABLE message DROP ap_id');\n        $this->addSql('ALTER TABLE message DROP edited_at');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20240603230734.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\n/**\n * Auto-generated Migration: Please modify to your needs!\n */\nfinal class Version20240603230734 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return 'Add Authentik SSO';\n    }\n\n    public function up(Schema $schema): void\n    {\n        $this->addSql('ALTER TABLE \"user\" ADD oauth_authentik_id VARCHAR(255) DEFAULT NULL');\n    }\n\n    public function down(Schema $schema): void\n    {\n        $this->addSql('ALTER TABLE \"user\" DROP oauth_authentik_id');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20240612234046.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\n/**\n * Auto-generated Migration: Please modify to your needs!\n */\nfinal class Version20240612234046 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return 'Add Privacy Portal SSO';\n    }\n\n    public function up(Schema $schema): void\n    {\n        $this->addSql('ALTER TABLE \"user\" ADD oauth_privacyportal_id VARCHAR(255) DEFAULT NULL');\n    }\n\n    public function down(Schema $schema): void\n    {\n        $this->addSql('ALTER TABLE \"user\" DROP oauth_privacyportal_id');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20240614120443.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\nfinal class Version20240614120443 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return 'Add columns for remote likes, dislikes and shares';\n    }\n\n    public function up(Schema $schema): void\n    {\n        $this->addSql('ALTER TABLE entry ADD ap_like_count INT DEFAULT NULL');\n        $this->addSql('ALTER TABLE entry ADD ap_dislike_count INT DEFAULT NULL');\n        $this->addSql('ALTER TABLE entry ADD ap_share_count INT DEFAULT NULL');\n        $this->addSql('ALTER TABLE entry_comment ADD ap_like_count INT DEFAULT NULL');\n        $this->addSql('ALTER TABLE entry_comment ADD ap_dislike_count INT DEFAULT NULL');\n        $this->addSql('ALTER TABLE entry_comment ADD ap_share_count INT DEFAULT NULL');\n        $this->addSql('ALTER TABLE post ADD ap_like_count INT DEFAULT NULL');\n        $this->addSql('ALTER TABLE post ADD ap_dislike_count INT DEFAULT NULL');\n        $this->addSql('ALTER TABLE post ADD ap_share_count INT DEFAULT NULL');\n        $this->addSql('ALTER TABLE post_comment ADD ap_like_count INT DEFAULT NULL');\n        $this->addSql('ALTER TABLE post_comment ADD ap_dislike_count INT DEFAULT NULL');\n        $this->addSql('ALTER TABLE post_comment ADD ap_share_count INT DEFAULT NULL');\n    }\n\n    public function down(Schema $schema): void\n    {\n        $this->addSql('ALTER TABLE entry_comment DROP ap_like_count');\n        $this->addSql('ALTER TABLE entry_comment DROP ap_dislike_count');\n        $this->addSql('ALTER TABLE entry_comment DROP ap_share_count');\n        $this->addSql('ALTER TABLE post_comment DROP ap_like_count');\n        $this->addSql('ALTER TABLE post_comment DROP ap_dislike_count');\n        $this->addSql('ALTER TABLE post_comment DROP ap_share_count');\n        $this->addSql('ALTER TABLE post DROP ap_like_count');\n        $this->addSql('ALTER TABLE post DROP ap_dislike_count');\n        $this->addSql('ALTER TABLE post DROP ap_share_count');\n        $this->addSql('ALTER TABLE entry DROP ap_like_count');\n        $this->addSql('ALTER TABLE entry DROP ap_dislike_count');\n        $this->addSql('ALTER TABLE entry DROP ap_share_count');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20240615225744.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\nfinal class Version20240615225744 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return 'Add a field to save the featured collection of magazines and users';\n    }\n\n    public function up(Schema $schema): void\n    {\n        $this->addSql('ALTER TABLE magazine ADD ap_featured_url VARCHAR(255) DEFAULT NULL');\n        $this->addSql('ALTER TABLE \"user\" ADD ap_featured_url VARCHAR(255) DEFAULT NULL');\n    }\n\n    public function down(Schema $schema): void\n    {\n        $this->addSql('ALTER TABLE \"user\" DROP ap_featured_url');\n        $this->addSql('ALTER TABLE magazine DROP ap_featured_url');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20240625162714.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\nfinal class Version20240625162714 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return 'add a unique index on ap_public_url';\n    }\n\n    public function up(Schema $schema): void\n    {\n        $this->addSql('CREATE UNIQUE INDEX user_ap_public_url_idx ON \"user\" (ap_public_url)');\n    }\n\n    public function down(Schema $schema): void\n    {\n        $this->addSql('DROP INDEX user_ap_public_url_idx');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20240628142700.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\nfinal class Version20240628142700 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return 'remove the unique index on ap_public_url';\n    }\n\n    public function up(Schema $schema): void\n    {\n        $this->addSql('DROP INDEX user_ap_public_url_idx');\n    }\n\n    public function down(Schema $schema): void\n    {\n        $this->addSql('CREATE UNIQUE INDEX user_ap_public_url_idx ON \"user\" (ap_public_url)');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20240628145441.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\nfinal class Version20240628145441 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return 'Add the instance table';\n    }\n\n    public function up(Schema $schema): void\n    {\n        $this->addSql('CREATE SEQUENCE instance_id_seq INCREMENT BY 1 MINVALUE 1 START 1');\n        $this->addSql('CREATE TABLE instance (id INT NOT NULL, software VARCHAR(255) DEFAULT NULL, version VARCHAR(255) DEFAULT NULL, domain VARCHAR(255) NOT NULL, last_successful_deliver TIMESTAMP(0) WITH TIME ZONE DEFAULT NULL, last_successful_receive TIMESTAMP(0) WITH TIME ZONE DEFAULT NULL, last_failed_deliver TIMESTAMP(0) WITH TIME ZONE DEFAULT NULL, failed_delivers INT NOT NULL, created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, updated_at TIMESTAMP(0) WITH TIME ZONE, PRIMARY KEY(id))');\n        $this->addSql('COMMENT ON COLUMN instance.last_successful_deliver IS \\'(DC2Type:datetime_immutable)\\'');\n        $this->addSql('COMMENT ON COLUMN instance.last_failed_deliver IS \\'(DC2Type:datetime_immutable)\\'');\n        $this->addSql('COMMENT ON COLUMN instance.last_successful_receive IS \\'(DC2Type:datetime_immutable)\\'');\n        $this->addSql('COMMENT ON COLUMN instance.created_at IS \\'(DC2Type:datetimetz_immutable)\\'');\n        $this->addSql('COMMENT ON COLUMN instance.updated_at IS \\'(DC2Type:datetimetz_immutable)\\'');\n        $this->addSql('CREATE UNIQUE INDEX UNIQ_4230B1DEA7A91E0B ON instance (domain)');\n    }\n\n    public function down(Schema $schema): void\n    {\n        $this->addSql('DROP INDEX UNIQ_4230B1DEA7A91E0B');\n        $this->addSql('DROP SEQUENCE instance_id_seq CASCADE');\n        $this->addSql('DROP TABLE instance');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20240701113000.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse App\\Entity\\Entry;\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\nclass Version20240701113000 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return 'fix the type of posts without a url, but with an image';\n    }\n\n    public function up(Schema $schema): void\n    {\n        $type = Entry::ENTRY_TYPE_IMAGE;\n        $this->addSql(\"UPDATE entry SET type = '$type', has_embed = true WHERE image_id IS NOT NULL AND url IS NULL\");\n    }\n\n    public function down(Schema $schema): void\n    {\n    }\n}\n"
  },
  {
    "path": "migrations/Version20240706005744.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\n/**\n * Auto-generated Migration: Please modify to your needs!\n */\nfinal class Version20240706005744 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return 'Add Discord SSO';\n    }\n\n    public function up(Schema $schema): void\n    {\n        // this up() migration is auto-generated, please modify it to your needs\n        $this->addSql('ALTER TABLE \"user\" ADD oauth_discord_id VARCHAR(255) DEFAULT NULL');\n    }\n\n    public function down(Schema $schema): void\n    {\n        // this down() migration is auto-generated, please modify it to your needs\n        $this->addSql('ALTER TABLE \"user\" DROP oauth_discord_id');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20240715181419.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\nfinal class Version20240715181419 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return 'Create a table user_push_subscription';\n    }\n\n    public function up(Schema $schema): void\n    {\n        $this->addSql('CREATE SEQUENCE user_push_subscription_id_seq INCREMENT BY 1 MINVALUE 1 START 1');\n        $this->addSql('CREATE TABLE user_push_subscription (id INT NOT NULL, user_id INT DEFAULT NULL, api_token CHAR(80) DEFAULT NULL, locale VARCHAR(255) DEFAULT NULL, endpoint TEXT NOT NULL, content_encryption_public_key TEXT NOT NULL, device_key UUID DEFAULT NULL, server_auth_key TEXT NOT NULL, notification_types JSON NOT NULL, PRIMARY KEY(id))');\n        $this->addSql('CREATE INDEX IDX_AE378BD8A76ED395 ON user_push_subscription (user_id)');\n        $this->addSql('COMMENT ON COLUMN user_push_subscription.device_key IS \\'(DC2Type:uuid)\\'');\n        $this->addSql('ALTER TABLE user_push_subscription ADD CONSTRAINT FK_AE378BD8A76ED395 FOREIGN KEY (user_id) REFERENCES \"user\" (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE user_push_subscription ADD CONSTRAINT FK_AE378BD87BA2F5EB FOREIGN KEY (api_token) REFERENCES oauth2_access_token (identifier) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('CREATE UNIQUE INDEX UNIQ_AE378BD87BA2F5EB ON user_push_subscription (api_token)');\n    }\n\n    public function down(Schema $schema): void\n    {\n        $this->addSql('ALTER TABLE user_push_subscription DROP CONSTRAINT FK_AE378BD8A76ED395');\n        $this->addSql('ALTER TABLE user_push_subscription DROP CONSTRAINT FK_AE378BD87BA2F5EB');\n        $this->addSql('DROP INDEX UNIQ_AE378BD87BA2F5EB');\n        $this->addSql('DROP INDEX IDX_AE378BD8A76ED395');\n        $this->addSql('DROP INDEX IDX_AE378BD8A76ED395');\n        $this->addSql('DROP TABLE user_push_subscription');\n        $this->addSql('DROP SEQUENCE user_push_subscription_id_seq');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20240718232800.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\nfinal class Version20240718232800 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return 'Add the push keys to the site table';\n    }\n\n    public function up(Schema $schema): void\n    {\n        $this->addSql('ALTER TABLE site ADD COLUMN push_private_key text DEFAULT NULL');\n        $this->addSql('ALTER TABLE site ADD COLUMN push_public_key text DEFAULT NULL');\n    }\n\n    public function down(Schema $schema): void\n    {\n        $this->addSql('ALTER TABLE site DROP COLUMN push_private_key');\n        $this->addSql('ALTER TABLE site DROP COLUMN push_public_key');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20240729174207.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\nfinal class Version20240729174207 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return 'Add the posting_restricted_to_mods column to the magazine table';\n    }\n\n    public function up(Schema $schema): void\n    {\n        $this->addSql('ALTER TABLE magazine ADD posting_restricted_to_mods BOOLEAN NOT NULL DEFAULT FALSE');\n    }\n\n    public function down(Schema $schema): void\n    {\n        $this->addSql('ALTER TABLE magazine DROP posting_restricted_to_mods');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20240815162107.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\nfinal class Version20240815162107 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return 'Add cascade delete to report.considered_by_id';\n    }\n\n    public function up(Schema $schema): void\n    {\n        $this->addSql('ALTER TABLE report DROP CONSTRAINT FK_C42F7784607E02EB');\n        $this->addSql('ALTER TABLE report ADD CONSTRAINT FK_C42F7784607E02EB FOREIGN KEY (considered_by_id) REFERENCES \"user\" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n    }\n\n    public function down(Schema $schema): void\n    {\n        $this->addSql('ALTER TABLE report DROP CONSTRAINT fk_c42f7784607e02eb');\n        $this->addSql('ALTER TABLE report ADD CONSTRAINT fk_c42f7784607e02eb FOREIGN KEY (considered_by_id) REFERENCES \"user\" (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20240820201944.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\nfinal class Version20240820201944 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return 'Add the activity table';\n    }\n\n    public function up(Schema $schema): void\n    {\n        $this->addSql('CREATE TABLE activity (uuid UUID NOT NULL, user_actor_id INT DEFAULT NULL, magazine_actor_id INT DEFAULT NULL, audience_id INT DEFAULT NULL, inner_activity_id UUID DEFAULT NULL, object_entry_id INT DEFAULT NULL, object_entry_comment_id INT DEFAULT NULL, object_post_id INT DEFAULT NULL, object_post_comment_id INT DEFAULT NULL, object_message_id INT DEFAULT NULL, object_user_id INT DEFAULT NULL, object_magazine_id INT DEFAULT NULL, type VARCHAR(255) NOT NULL, inner_activity_url TEXT DEFAULT NULL, object_generic TEXT DEFAULT NULL, target_string TEXT DEFAULT NULL, content_string TEXT DEFAULT NULL, activity_json TEXT DEFAULT NULL, is_remote BOOL NOT NULL DEFAULT FALSE, PRIMARY KEY(uuid))');\n        $this->addSql('CREATE INDEX IDX_AC74095AF057164A ON activity (user_actor_id)');\n        $this->addSql('CREATE INDEX IDX_AC74095A2F5FA0A4 ON activity (magazine_actor_id)');\n        $this->addSql('CREATE INDEX IDX_AC74095A848CC616 ON activity (audience_id)');\n        $this->addSql('CREATE INDEX IDX_AC74095A1B4C3858 ON activity (inner_activity_id)');\n        $this->addSql('CREATE INDEX IDX_AC74095A6CE0A42A ON activity (object_entry_id)');\n        $this->addSql('CREATE INDEX IDX_AC74095AC3683D33 ON activity (object_entry_comment_id)');\n        $this->addSql('CREATE INDEX IDX_AC74095A4BC7838C ON activity (object_post_id)');\n        $this->addSql('CREATE INDEX IDX_AC74095ACC1812B0 ON activity (object_post_comment_id)');\n        $this->addSql('CREATE INDEX IDX_AC74095A20E5BA95 ON activity (object_message_id)');\n        $this->addSql('CREATE INDEX IDX_AC74095AA7205335 ON activity (object_user_id)');\n        $this->addSql('CREATE INDEX IDX_AC74095AFC1C2A13 ON activity (object_magazine_id)');\n        $this->addSql('COMMENT ON COLUMN activity.uuid IS \\'(DC2Type:uuid)\\'');\n        $this->addSql('COMMENT ON COLUMN activity.inner_activity_id IS \\'(DC2Type:uuid)\\'');\n        $this->addSql('ALTER TABLE activity ADD CONSTRAINT FK_AC74095AF057164A FOREIGN KEY (user_actor_id) REFERENCES \"user\" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE activity ADD CONSTRAINT FK_AC74095A2F5FA0A4 FOREIGN KEY (magazine_actor_id) REFERENCES magazine (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE activity ADD CONSTRAINT FK_AC74095A848CC616 FOREIGN KEY (audience_id) REFERENCES magazine (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE activity ADD CONSTRAINT FK_AC74095A1B4C3858 FOREIGN KEY (inner_activity_id) REFERENCES activity (uuid) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE activity ADD CONSTRAINT FK_AC74095A6CE0A42A FOREIGN KEY (object_entry_id) REFERENCES entry (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE activity ADD CONSTRAINT FK_AC74095AC3683D33 FOREIGN KEY (object_entry_comment_id) REFERENCES entry_comment (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE activity ADD CONSTRAINT FK_AC74095A4BC7838C FOREIGN KEY (object_post_id) REFERENCES post (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE activity ADD CONSTRAINT FK_AC74095ACC1812B0 FOREIGN KEY (object_post_comment_id) REFERENCES post_comment (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE activity ADD CONSTRAINT FK_AC74095A20E5BA95 FOREIGN KEY (object_message_id) REFERENCES message (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE activity ADD CONSTRAINT FK_AC74095AA7205335 FOREIGN KEY (object_user_id) REFERENCES \"user\" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE activity ADD CONSTRAINT FK_AC74095AFC1C2A13 FOREIGN KEY (object_magazine_id) REFERENCES magazine (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n    }\n\n    public function down(Schema $schema): void\n    {\n        $this->addSql('ALTER TABLE activity DROP CONSTRAINT FK_AC74095AF057164A');\n        $this->addSql('ALTER TABLE activity DROP CONSTRAINT FK_AC74095A2F5FA0A4');\n        $this->addSql('ALTER TABLE activity DROP CONSTRAINT FK_AC74095A848CC616');\n        $this->addSql('ALTER TABLE activity DROP CONSTRAINT FK_AC74095A1B4C3858');\n        $this->addSql('ALTER TABLE activity DROP CONSTRAINT FK_AC74095A6CE0A42A');\n        $this->addSql('ALTER TABLE activity DROP CONSTRAINT FK_AC74095AC3683D33');\n        $this->addSql('ALTER TABLE activity DROP CONSTRAINT FK_AC74095A4BC7838C');\n        $this->addSql('ALTER TABLE activity DROP CONSTRAINT FK_AC74095ACC1812B0');\n        $this->addSql('ALTER TABLE activity DROP CONSTRAINT FK_AC74095A20E5BA95');\n        $this->addSql('ALTER TABLE activity DROP CONSTRAINT FK_AC74095AA7205335');\n        $this->addSql('ALTER TABLE activity DROP CONSTRAINT FK_AC74095AFC1C2A13');\n        $this->addSql('DROP TABLE activity');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20240831151328.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\nfinal class Version20240831151328 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return 'This adds the bookmark and bookmark list tables';\n    }\n\n    public function up(Schema $schema): void\n    {\n        $this->addSql('CREATE SEQUENCE bookmark_id_seq INCREMENT BY 1 MINVALUE 1 START 1');\n        $this->addSql('CREATE SEQUENCE bookmark_list_id_seq INCREMENT BY 1 MINVALUE 1 START 1');\n        $this->addSql('CREATE TABLE bookmark (id INT NOT NULL, list_id INT NOT NULL, user_id INT NOT NULL, entry_id INT DEFAULT NULL, entry_comment_id INT DEFAULT NULL, post_id INT DEFAULT NULL, post_comment_id INT DEFAULT NULL, created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, PRIMARY KEY(id))');\n        $this->addSql('CREATE INDEX IDX_DA62921D3DAE168B ON bookmark (list_id)');\n        $this->addSql('CREATE INDEX IDX_DA62921DA76ED395 ON bookmark (user_id)');\n        $this->addSql('CREATE INDEX IDX_DA62921DBA364942 ON bookmark (entry_id)');\n        $this->addSql('CREATE INDEX IDX_DA62921D60C33421 ON bookmark (entry_comment_id)');\n        $this->addSql('CREATE INDEX IDX_DA62921D4B89032C ON bookmark (post_id)');\n        $this->addSql('CREATE INDEX IDX_DA62921DDB1174D2 ON bookmark (post_comment_id)');\n        $this->addSql('CREATE UNIQUE INDEX bookmark_list_entry_entryComment_post_postComment_idx ON bookmark (list_id, entry_id, entry_comment_id, post_id, post_comment_id)');\n        $this->addSql('COMMENT ON COLUMN bookmark.created_at IS \\'(DC2Type:datetimetz_immutable)\\'');\n        $this->addSql('CREATE TABLE bookmark_list (id INT NOT NULL, user_id INT NOT NULL, name VARCHAR(255) NOT NULL, is_default BOOLEAN NOT NULL, PRIMARY KEY(id))');\n        $this->addSql('CREATE INDEX IDX_A650C0C4A76ED395 ON bookmark_list (user_id)');\n        $this->addSql('CREATE UNIQUE INDEX UNIQ_A650C0C4A76ED3955E237E06 ON bookmark_list (user_id, name)');\n        $this->addSql('ALTER TABLE bookmark ADD CONSTRAINT FK_DA62921D3DAE168B FOREIGN KEY (list_id) REFERENCES bookmark_list (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE bookmark ADD CONSTRAINT FK_DA62921DA76ED395 FOREIGN KEY (user_id) REFERENCES \"user\" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE bookmark ADD CONSTRAINT FK_DA62921DBA364942 FOREIGN KEY (entry_id) REFERENCES entry (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE bookmark ADD CONSTRAINT FK_DA62921D60C33421 FOREIGN KEY (entry_comment_id) REFERENCES entry_comment (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE bookmark ADD CONSTRAINT FK_DA62921D4B89032C FOREIGN KEY (post_id) REFERENCES post (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE bookmark ADD CONSTRAINT FK_DA62921DDB1174D2 FOREIGN KEY (post_comment_id) REFERENCES post_comment (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE bookmark_list ADD CONSTRAINT FK_A650C0C4A76ED395 FOREIGN KEY (user_id) REFERENCES \"user\" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n    }\n\n    public function down(Schema $schema): void\n    {\n        $this->addSql('DROP SEQUENCE bookmark_id_seq CASCADE');\n        $this->addSql('DROP SEQUENCE bookmark_list_id_seq CASCADE');\n        $this->addSql('ALTER TABLE bookmark DROP CONSTRAINT FK_DA62921D3DAE168B');\n        $this->addSql('ALTER TABLE bookmark DROP CONSTRAINT FK_DA62921DA76ED395');\n        $this->addSql('ALTER TABLE bookmark DROP CONSTRAINT FK_DA62921DBA364942');\n        $this->addSql('ALTER TABLE bookmark DROP CONSTRAINT FK_DA62921D60C33421');\n        $this->addSql('ALTER TABLE bookmark DROP CONSTRAINT FK_DA62921D4B89032C');\n        $this->addSql('ALTER TABLE bookmark DROP CONSTRAINT FK_DA62921DDB1174D2');\n        $this->addSql('ALTER TABLE bookmark_list DROP CONSTRAINT FK_A650C0C4A76ED395');\n        $this->addSql('DROP TABLE bookmark');\n        $this->addSql('DROP TABLE bookmark_list');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20240923164233.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\nfinal class Version20240923164233 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return 'Introducing the sessions table for session management.';\n    }\n\n    public function up(Schema $schema): void\n    {\n        $this->addSql('CREATE TABLE sessions (sess_id VARCHAR(128) NOT NULL, sess_data BYTEA NOT NULL, sess_lifetime INT NOT NULL, sess_time INT NOT NULL, PRIMARY KEY(sess_id))');\n        $this->addSql('CREATE INDEX sess_lifetime_idx ON sessions (sess_lifetime)');\n    }\n\n    public function down(Schema $schema): void\n    {\n        $this->addSql('DROP TABLE sessions');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20241104162329.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\nfinal class Version20241104162655 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return 'Add application_text and application_status to the user table';\n    }\n\n    public function up(Schema $schema): void\n    {\n        $this->addSql('CREATE TYPE enumApplicationStatus AS ENUM (\\'Approved\\', \\'Rejected\\', \\'Pending\\')');\n        $this->addSql('ALTER TABLE \"user\" ADD application_text TEXT DEFAULT NULL');\n        $this->addSql('ALTER TABLE \"user\" ADD application_status enumApplicationStatus DEFAULT \\'Approved\\' NOT NULL');\n    }\n\n    public function down(Schema $schema): void\n    {\n        $this->addSql('ALTER TABLE \"user\" DROP application_text');\n        $this->addSql('ALTER TABLE \"user\" DROP application_status');\n        $this->addSql('DROP TYPE enumApplicationStatus');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20241124155724.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\nfinal class Version20241124155724 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return 'Add new_user_id to notification table and notify_on_user_signup to \"user\" table for the `NewSignupNotification`';\n    }\n\n    public function up(Schema $schema): void\n    {\n        $this->addSql('ALTER TABLE notification ADD new_user_id INT DEFAULT NULL');\n        $this->addSql('ALTER TABLE notification ADD CONSTRAINT FK_BF5476CA7C2D807B FOREIGN KEY (new_user_id) REFERENCES \"user\" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('CREATE INDEX IDX_BF5476CA7C2D807B ON notification (new_user_id)');\n        $this->addSql('ALTER TABLE \"user\" ADD notify_on_user_signup BOOLEAN DEFAULT TRUE');\n    }\n\n    public function down(Schema $schema): void\n    {\n        $this->addSql('ALTER TABLE notification DROP CONSTRAINT FK_BF5476CA7C2D807B');\n        $this->addSql('DROP INDEX IDX_BF5476CA7C2D807B');\n        $this->addSql('ALTER TABLE notification DROP new_user_id');\n        $this->addSql('ALTER TABLE \"user\" DROP notify_on_user_signup');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20241125210454.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\nfinal class Version20241125210454 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return 'Create the notification_settings table for customized notification settings';\n    }\n\n    public function up(Schema $schema): void\n    {\n        $this->addSql('CREATE TYPE enumNotificationStatus AS ENUM(\\'Default\\', \\'Muted\\', \\'Loud\\')');\n        $this->addSql('CREATE SEQUENCE notification_settings_id_seq INCREMENT BY 1 MINVALUE 1 START 1');\n        $this->addSql('CREATE TABLE notification_settings (id INT NOT NULL, user_id INT NOT NULL, entry_id INT DEFAULT NULL, post_id INT DEFAULT NULL, magazine_id INT DEFAULT NULL, target_user_id INT DEFAULT NULL, notification_status enumNotificationStatus DEFAULT \\'Default\\' NOT NULL, PRIMARY KEY(id))');\n        $this->addSql('CREATE INDEX IDX_B0559860A76ED395 ON notification_settings (user_id)');\n        $this->addSql('CREATE INDEX IDX_B0559860BA364942 ON notification_settings (entry_id)');\n        $this->addSql('CREATE INDEX IDX_B05598604B89032C ON notification_settings (post_id)');\n        $this->addSql('CREATE INDEX IDX_B05598603EB84A1D ON notification_settings (magazine_id)');\n        $this->addSql('CREATE INDEX IDX_B05598606C066AFE ON notification_settings (target_user_id)');\n        $this->addSql('CREATE UNIQUE INDEX notification_settings_user_target ON notification_settings (user_id, entry_id, post_id, magazine_id, target_user_id)');\n        $this->addSql('COMMENT ON COLUMN notification_settings.notification_status IS \\'(DC2Type:EnumNotificationStatus)\\'');\n        $this->addSql('ALTER TABLE notification_settings ADD CONSTRAINT FK_B0559860A76ED395 FOREIGN KEY (user_id) REFERENCES \"user\" (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE notification_settings ADD CONSTRAINT FK_B0559860BA364942 FOREIGN KEY (entry_id) REFERENCES entry (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE notification_settings ADD CONSTRAINT FK_B05598604B89032C FOREIGN KEY (post_id) REFERENCES post (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE notification_settings ADD CONSTRAINT FK_B05598603EB84A1D FOREIGN KEY (magazine_id) REFERENCES magazine (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE notification_settings ADD CONSTRAINT FK_B05598606C066AFE FOREIGN KEY (target_user_id) REFERENCES \"user\" (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n    }\n\n    public function down(Schema $schema): void\n    {\n        $this->addSql('DROP SEQUENCE notification_settings_id_seq CASCADE');\n        $this->addSql('ALTER TABLE notification_settings DROP CONSTRAINT FK_B0559860A76ED395');\n        $this->addSql('ALTER TABLE notification_settings DROP CONSTRAINT FK_B0559860BA364942');\n        $this->addSql('ALTER TABLE notification_settings DROP CONSTRAINT FK_B05598604B89032C');\n        $this->addSql('ALTER TABLE notification_settings DROP CONSTRAINT FK_B05598603EB84A1D');\n        $this->addSql('ALTER TABLE notification_settings DROP CONSTRAINT FK_B05598606C066AFE');\n        $this->addSql('DROP TABLE notification_settings');\n        $this->addSql('DROP TYPE enumNotificationStatus');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20250128125727.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\nfinal class Version20250128125727 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return 'Add the default sort columns for front and comments';\n    }\n\n    public function up(Schema $schema): void\n    {\n        $this->addSql('CREATE TYPE enumSortOptions AS ENUM(\\'hot\\', \\'top\\', \\'newest\\', \\'active\\', \\'oldest\\', \\'commented\\')');\n        $this->addSql('ALTER TABLE \"user\" ADD front_default_sort enumSortOptions DEFAULT \\'hot\\' NOT NULL');\n        $this->addSql('ALTER TABLE \"user\" ADD comment_default_sort enumSortOptions DEFAULT \\'hot\\' NOT NULL');\n    }\n\n    public function down(Schema $schema): void\n    {\n        $this->addSql('ALTER TABLE \"user\" DROP front_default_sort');\n        $this->addSql('ALTER TABLE \"user\" DROP comment_default_sort');\n        $this->addSql('DROP TYPE enumSortOptions');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20250203232039.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\nfinal class Version20250203232039 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return 'Add missing cascade delete to the constraints of the notification_settings table';\n    }\n\n    public function up(Schema $schema): void\n    {\n        $this->addSql('ALTER TABLE notification_settings DROP CONSTRAINT IF EXISTS FK_B0559860A76ED395');\n        $this->addSql('ALTER TABLE notification_settings DROP CONSTRAINT IF EXISTS FK_B0559860BA364942');\n        $this->addSql('ALTER TABLE notification_settings DROP CONSTRAINT IF EXISTS FK_B05598604B89032C');\n        $this->addSql('ALTER TABLE notification_settings DROP CONSTRAINT IF EXISTS FK_B05598603EB84A1D');\n        $this->addSql('ALTER TABLE notification_settings DROP CONSTRAINT IF EXISTS FK_B05598606C066AFE');\n        $this->addSql('ALTER TABLE notification_settings ADD CONSTRAINT FK_B0559860A76ED395 FOREIGN KEY (user_id) REFERENCES \"user\" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE notification_settings ADD CONSTRAINT FK_B0559860BA364942 FOREIGN KEY (entry_id) REFERENCES entry (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE notification_settings ADD CONSTRAINT FK_B05598604B89032C FOREIGN KEY (post_id) REFERENCES post (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE notification_settings ADD CONSTRAINT FK_B05598603EB84A1D FOREIGN KEY (magazine_id) REFERENCES magazine (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE notification_settings ADD CONSTRAINT FK_B05598606C066AFE FOREIGN KEY (target_user_id) REFERENCES \"user\" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n    }\n\n    public function down(Schema $schema): void\n    {\n        $this->addSql('ALTER TABLE notification_settings DROP CONSTRAINT IF EXISTS FK_B0559860A76ED395');\n        $this->addSql('ALTER TABLE notification_settings DROP CONSTRAINT IF EXISTS FK_B0559860BA364942');\n        $this->addSql('ALTER TABLE notification_settings DROP CONSTRAINT IF EXISTS FK_B05598604B89032C');\n        $this->addSql('ALTER TABLE notification_settings DROP CONSTRAINT IF EXISTS FK_B05598603EB84A1D');\n        $this->addSql('ALTER TABLE notification_settings DROP CONSTRAINT IF EXISTS FK_B05598606C066AFE');\n        $this->addSql('ALTER TABLE notification_settings ADD CONSTRAINT FK_B0559860A76ED395 FOREIGN KEY (user_id) REFERENCES \"user\" (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE notification_settings ADD CONSTRAINT FK_B0559860BA364942 FOREIGN KEY (entry_id) REFERENCES entry (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE notification_settings ADD CONSTRAINT FK_B05598604B89032C FOREIGN KEY (post_id) REFERENCES post (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE notification_settings ADD CONSTRAINT FK_B05598603EB84A1D FOREIGN KEY (magazine_id) REFERENCES magazine (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE notification_settings ADD CONSTRAINT FK_B05598606C066AFE FOREIGN KEY (target_user_id) REFERENCES \"user\" (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20250204152300.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\nclass Version20250204152300 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return 'remove confusing comment on notification_settings.notification_status';\n    }\n\n    public function up(Schema $schema): void\n    {\n        $this->addSql('COMMENT ON COLUMN notification_settings.notification_status IS NULL');\n    }\n\n    public function down(Schema $schema): void\n    {\n        $this->addSql('COMMENT ON COLUMN notification_settings.notification_status IS \\'(DC2Type:EnumNotificationStatus)\\'');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20250706115844.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\nfinal class Version20250706115844 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return 'Add ts vectors for user and magazine columns';\n    }\n\n    public function up(Schema $schema): void\n    {\n        $this->addSql('ALTER TABLE magazine ADD name_ts tsvector GENERATED ALWAYS AS (to_tsvector(\\'english\\', name)) STORED');\n        $this->addSql('ALTER TABLE magazine ADD title_ts tsvector GENERATED ALWAYS AS (to_tsvector(\\'english\\', title)) STORED');\n        $this->addSql('ALTER TABLE magazine ADD description_ts tsvector GENERATED ALWAYS AS (to_tsvector(\\'english\\', description)) STORED');\n        $this->addSql('CREATE INDEX magazine_name_ts ON magazine USING GIN (name_ts)');\n        $this->addSql('CREATE INDEX magazine_title_ts ON magazine USING GIN (title_ts)');\n        $this->addSql('CREATE INDEX magazine_description_ts ON magazine USING GIN (description_ts)');\n\n        $this->addSql('ALTER TABLE \"user\" ADD username_ts tsvector GENERATED ALWAYS AS (to_tsvector(\\'english\\', username)) STORED');\n        $this->addSql('ALTER TABLE \"user\" ADD about_ts tsvector GENERATED ALWAYS AS (to_tsvector(\\'english\\', about)) STORED');\n        $this->addSql('CREATE INDEX user_username_ts ON \"user\" USING GIN (username_ts)');\n        $this->addSql('CREATE INDEX user_about_ts ON \"user\" USING GIN (about_ts)');\n    }\n\n    public function down(Schema $schema): void\n    {\n        $this->addSql('DROP INDEX user_username_ts');\n        $this->addSql('DROP INDEX user_about_ts');\n        $this->addSql('ALTER TABLE \"user\" DROP username_ts');\n        $this->addSql('ALTER TABLE \"user\" DROP about_ts');\n\n        $this->addSql('DROP INDEX magazine_name_ts');\n        $this->addSql('DROP INDEX magazine_title_ts');\n        $this->addSql('DROP INDEX magazine_description_ts');\n        $this->addSql('ALTER TABLE magazine DROP name_ts');\n        $this->addSql('ALTER TABLE magazine DROP title_ts');\n        $this->addSql('ALTER TABLE magazine DROP description_ts');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20250723183702.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\nfinal class Version20250723183702 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return 'Add magazine ban as an object to the activity table';\n    }\n\n    public function up(Schema $schema): void\n    {\n        $this->addSql('ALTER TABLE activity ADD object_magazine_ban_id INT DEFAULT NULL');\n        $this->addSql('ALTER TABLE activity ADD CONSTRAINT FK_AC74095AE490E490 FOREIGN KEY (object_magazine_ban_id) REFERENCES magazine_ban (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('CREATE INDEX IDX_AC74095AE490E490 ON activity (object_magazine_ban_id)');\n    }\n\n    public function down(Schema $schema): void\n    {\n        $this->addSql('ALTER TABLE activity DROP CONSTRAINT FK_AC74095AE490E490');\n        $this->addSql('DROP INDEX IDX_AC74095AE490E490');\n        $this->addSql('ALTER TABLE activity DROP object_magazine_ban_id');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20250802102904.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\nfinal class Version20250802102904 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return 'Add column ban_reason to the user table';\n    }\n\n    public function up(Schema $schema): void\n    {\n        $this->addSql('ALTER TABLE \"user\" ADD ban_reason VARCHAR(255) DEFAULT NULL');\n    }\n\n    public function down(Schema $schema): void\n    {\n        $this->addSql('ALTER TABLE \"user\" DROP ban_reason');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20250812194529.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\nfinal class Version20250812194529 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return 'Add created_at to the activity table';\n    }\n\n    public function up(Schema $schema): void\n    {\n        $this->addSql('ALTER TABLE activity ADD created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL default CURRENT_TIMESTAMP(0)');\n        $this->addSql('COMMENT ON COLUMN activity.created_at IS \\'(DC2Type:datetimetz_immutable)\\'');\n        $this->addSql('ALTER TABLE activity ALTER COLUMN created_at DROP DEFAULT');\n    }\n\n    public function down(Schema $schema): void\n    {\n        $this->addSql('ALTER TABLE activity DROP created_at');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20250813132233.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\nfinal class Version20250813132233 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return 'Add is_banned and is_explicitly_allowed to the instance table and move the banned instances out of the settings table';\n    }\n\n    public function up(Schema $schema): void\n    {\n        $this->addSql('ALTER TABLE instance ADD IF NOT EXISTS is_banned BOOLEAN DEFAULT false NOT NULL');\n        $this->addSql('ALTER TABLE instance ADD IF NOT EXISTS is_explicitly_allowed BOOLEAN DEFAULT false NOT NULL');\n\n        $this->addSql(\"DO\n\\$do\\$\n    declare tempRow record;\nBEGIN\n    FOR tempRow IN\n        SELECT keys.value FROM settings s\n            JOIN LATERAL (SELECT * FROM jsonb_array_elements_text(s.json)) as keys ON TRUE\n            WHERE s.name = 'KBIN_BANNED_INSTANCES'\n    LOOP\n        IF NOT EXISTS (SELECT * FROM instance i WHERE i.domain = tempRow.value) THEN\n            INSERT INTO instance(id, domain, created_at, failed_delivers, updated_at, is_banned)\n                VALUES (NEXTVAL('instance_id_seq'), tempRow.value, current_timestamp(0), 0, current_timestamp(0), true);\n        ELSE\n            UPDATE instance SET is_banned = true WHERE domain = tempRow.value;\n        END IF;\n    END LOOP;\nEND\n\\$do\\$;\");\n\n        $this->addSql('DELETE FROM settings WHERE name=\\'KBIN_BANNED_INSTANCES\\'');\n    }\n\n    public function down(Schema $schema): void\n    {\n        $this->addSql(\"DO\n\\$do\\$\n    declare tempRow record;\nBEGIN\n    FOR tempRow IN\n        SELECT i.domain FROM instance i WHERE i.is_banned = true\n    LOOP\n        IF NOT EXISTS (SELECT * FROM settings s WHERE s.name = 'KBIN_BANNED_INSTANCES') THEN\n            INSERT INTO settings (id, name, json) VALUES (NEXTVAL('settings_id_seq'), 'KBIN_BANNED_INSTANCES', '[]'::jsonb);\n        END IF;\n        UPDATE settings SET json = json || to_jsonb(tempRow.domain) WHERE name = 'KBIN_BANNED_INSTANCES';\n    END LOOP;\nEND\n\\$do\\$;\");\n        $this->addSql('ALTER TABLE instance DROP is_banned');\n        $this->addSql('ALTER TABLE instance DROP is_explicitly_allowed');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20250907112001.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\nfinal class Version20250907112001 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return 'Add old public and private key to the user and magazine tables';\n    }\n\n    public function up(Schema $schema): void\n    {\n        $this->addSql('ALTER TABLE magazine ADD old_private_key TEXT DEFAULT NULL');\n        $this->addSql('ALTER TABLE magazine ADD old_public_key TEXT DEFAULT NULL');\n        $this->addSql('ALTER TABLE \"user\" ADD old_private_key TEXT DEFAULT NULL');\n        $this->addSql('ALTER TABLE \"user\" ADD old_public_key TEXT DEFAULT NULL');\n    }\n\n    public function down(Schema $schema): void\n    {\n        $this->addSql('ALTER TABLE magazine DROP old_private_key');\n        $this->addSql('ALTER TABLE magazine DROP old_public_key');\n        $this->addSql('ALTER TABLE \"user\" DROP old_private_key');\n        $this->addSql('ALTER TABLE \"user\" DROP old_public_key');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20250924105525.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\nfinal class Version20250924105525 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return 'Add column direct_message_setting to \"user\"';\n    }\n\n    public function up(Schema $schema): void\n    {\n        $this->addSql('CREATE TYPE enumDirectMessageSettings AS ENUM(\\'everyone\\', \\'followers_only\\', \\'nobody\\')');\n        $this->addSql('ALTER TABLE \"user\" ADD direct_message_setting enumDirectMessageSettings DEFAULT \\'everyone\\' NOT NULL');\n    }\n\n    public function down(Schema $schema): void\n    {\n        $this->addSql('ALTER TABLE \"user\" DROP direct_message_setting');\n        $this->addSql('DROP TYPE enumDirectMessageSettings');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20251022104152.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\nfinal class Version20251022104152 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return 'Add last key rotation date to user and magazine tables';\n    }\n\n    public function up(Schema $schema): void\n    {\n        $this->addSql('ALTER TABLE magazine ADD last_key_rotation_date TIMESTAMP(0) WITH TIME ZONE DEFAULT NULL');\n        $this->addSql('ALTER TABLE \"user\" ADD last_key_rotation_date TIMESTAMP(0) WITH TIME ZONE DEFAULT NULL');\n    }\n\n    public function down(Schema $schema): void\n    {\n        $this->addSql('ALTER TABLE magazine DROP last_key_rotation_date');\n        $this->addSql('ALTER TABLE \"user\" DROP last_key_rotation_date');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20251022115254.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\nfinal class Version20251022115254 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return 'Add magazine banner';\n    }\n\n    public function up(Schema $schema): void\n    {\n        $this->addSql('ALTER TABLE magazine ADD banner_id INT DEFAULT NULL');\n        $this->addSql('ALTER TABLE magazine ADD CONSTRAINT FK_378C2FE4684EC833 FOREIGN KEY (banner_id) REFERENCES image (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('CREATE INDEX IDX_378C2FE4684EC833 ON magazine (banner_id)');\n    }\n\n    public function down(Schema $schema): void\n    {\n        $this->addSql('ALTER TABLE magazine DROP CONSTRAINT FK_378C2FE4684EC833');\n        $this->addSql('DROP INDEX IDX_378C2FE4684EC833');\n        $this->addSql('ALTER TABLE magazine DROP banner_id');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20251031174052.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\nfinal class Version20251031174052 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return 'Add is_locked column to post and entry';\n    }\n\n    public function up(Schema $schema): void\n    {\n        $this->addSql('ALTER TABLE entry ADD is_locked BOOLEAN DEFAULT false NOT NULL');\n        $this->addSql('ALTER TABLE post ADD is_locked BOOLEAN DEFAULT false NOT NULL');\n    }\n\n    public function down(Schema $schema): void\n    {\n        $this->addSql('ALTER TABLE post DROP is_locked');\n        $this->addSql('ALTER TABLE entry DROP is_locked');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20251118112235.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\nfinal class Version20251118112235 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return 'Add column front_default_content to \"user\"';\n    }\n\n    public function up(Schema $schema): void\n    {\n        $this->addSql('CREATE TYPE enumFrontContentOptions AS ENUM(\\'all\\', \\'threads\\', \\'microblog\\')');\n        $this->addSql('ALTER TABLE \"user\" ADD front_default_content enumFrontContentOptions DEFAULT NULL');\n    }\n\n    public function down(Schema $schema): void\n    {\n        $this->addSql('ALTER TABLE \"user\" DROP front_default_content');\n        $this->addSql('DROP TYPE enumFrontContentOptions');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20251129140919.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\nfinal class Version20251129140919 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return 'Rename value all to combined in front_default_content enum.';\n    }\n\n    public function up(Schema $schema): void\n    {\n        $this->addSql('ALTER TYPE enumFrontContentOptions RENAME VALUE \\'all\\' TO \\'combined\\'');\n    }\n\n    public function down(Schema $schema): void\n    {\n        $this->addSql('ALTER TYPE enumFrontContentOptions RENAME VALUE \\'combined\\' TO \\'all\\'');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20251206145724.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\nfinal class Version20251206145724 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return 'Remove old MAX_IMAGE_BYTES unused setting and also remove the renamed MBIN_MAX_IMAGE_BYTES.';\n    }\n\n    public function up(Schema $schema): void\n    {\n        $this->addSql('DELETE FROM settings WHERE name=\\'MAX_IMAGE_BYTES\\'');\n        $this->addSql('DELETE FROM settings WHERE name=\\'MBIN_MAX_IMAGE_BYTES\\'');\n    }\n\n    public function down(Schema $schema): void\n    {\n    }\n}\n"
  },
  {
    "path": "migrations/Version20251214111055.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\nfinal class Version20251214111055 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return 'Modify UserPushSubscription, so that the user is not nullable and it cascade deletes if the user is deleted';\n    }\n\n    public function up(Schema $schema): void\n    {\n        $this->addSql('ALTER TABLE user_push_subscription DROP CONSTRAINT FK_AE378BD8A76ED395');\n        $this->addSql('ALTER TABLE user_push_subscription ALTER user_id SET NOT NULL');\n        $this->addSql('ALTER TABLE user_push_subscription ADD CONSTRAINT FK_AE378BD8A76ED395 FOREIGN KEY (user_id) REFERENCES \"user\" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n    }\n\n    public function down(Schema $schema): void\n    {\n        $this->addSql('ALTER TABLE user_push_subscription DROP CONSTRAINT fk_ae378bd8a76ed395');\n        $this->addSql('ALTER TABLE user_push_subscription ALTER user_id DROP NOT NULL');\n        $this->addSql('ALTER TABLE user_push_subscription ADD CONSTRAINT fk_ae378bd8a76ed395 FOREIGN KEY (user_id) REFERENCES \"user\" (id) NOT DEFERRABLE INITIALLY IMMEDIATE');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20260113103210.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\nfinal class Version20260113103210 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return 'Add ap_indexable to ActivityPub actors';\n    }\n\n    public function up(Schema $schema): void\n    {\n        $this->addSql('ALTER TABLE magazine ADD ap_indexable BOOLEAN DEFAULT NULL');\n        $this->addSql('ALTER TABLE \"user\" ADD ap_indexable BOOLEAN DEFAULT NULL');\n        // The column should be nullable so that we know whether other software simply does not set this value,\n        // but for local users and magazines we should only have true and false as options\n        $this->addSql('UPDATE \"user\" SET ap_indexable = true WHERE ap_id IS NULL');\n        $this->addSql('UPDATE magazine SET ap_indexable = true WHERE ap_id IS NULL');\n    }\n\n    public function down(Schema $schema): void\n    {\n        $this->addSql('ALTER TABLE \"user\" DROP ap_indexable');\n        $this->addSql('ALTER TABLE magazine DROP ap_indexable');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20260113151625.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\nfinal class Version20260113151625 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return 'Set ap_discoverable to true for all local actors. In the past this was not used and only populated for remote actors.';\n    }\n\n    public function up(Schema $schema): void\n    {\n        $this->addSql('UPDATE \"user\" SET ap_discoverable = true WHERE ap_id IS NULL');\n        $this->addSql('UPDATE magazine SET ap_discoverable = true WHERE ap_id IS NULL');\n    }\n\n    public function down(Schema $schema): void\n    {\n    }\n}\n"
  },
  {
    "path": "migrations/Version20260118131639.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\nfinal class Version20260118131639 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return 'Create the unique index on \"user\".ap_public_url if it does not exist';\n    }\n\n    public function up(Schema $schema): void\n    {\n        $this->addSql('CREATE UNIQUE INDEX IF NOT EXISTS user_ap_public_url_idx ON \"user\" (ap_public_url)');\n    }\n\n    public function down(Schema $schema): void\n    {\n    }\n}\n"
  },
  {
    "path": "migrations/Version20260118142727.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\nfinal class Version20260118142727 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return 'Add indexes on the magazine table, rename an old one';\n    }\n\n    public function up(Schema $schema): void\n    {\n        $this->addSql('CREATE UNIQUE INDEX magazine_ap_profile_id_idx ON magazine (ap_profile_id)');\n        $this->addSql('CREATE UNIQUE INDEX magazine_ap_public_url_idx ON magazine (ap_public_url)');\n        $this->addSql('ALTER INDEX uniq_378c2fe4904f155e RENAME TO magazine_ap_id_idx');\n    }\n\n    public function down(Schema $schema): void\n    {\n        $this->addSql('DROP INDEX magazine_ap_profile_id_idx');\n        $this->addSql('DROP INDEX magazine_ap_public_url_idx');\n        $this->addSql('ALTER INDEX magazine_ap_id_idx RENAME TO uniq_378c2fe4904f155e');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20260120175744.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\nfinal class Version20260120175744 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return 'Add initial monitoring tables';\n    }\n\n    public function up(Schema $schema): void\n    {\n        $this->addSql('CREATE SEQUENCE monitoring_curl_request_id_seq INCREMENT BY 1 MINVALUE 1 START 1');\n        $this->addSql('CREATE SEQUENCE monitoring_query_id_seq INCREMENT BY 1 MINVALUE 1 START 1');\n        $this->addSql('CREATE SEQUENCE monitoring_twig_render_id_seq INCREMENT BY 1 MINVALUE 1 START 1');\n        $this->addSql('CREATE TABLE monitoring_curl_request (id INT NOT NULL, context_id UUID DEFAULT NULL, url VARCHAR(255) NOT NULL, method VARCHAR(255) NOT NULL, was_successful BOOLEAN NOT NULL, exception VARCHAR(255) DEFAULT NULL, created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, started_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, started_at_microseconds DOUBLE PRECISION NOT NULL, ended_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, ended_at_microseconds DOUBLE PRECISION NOT NULL, duration_milliseconds DOUBLE PRECISION NOT NULL, PRIMARY KEY(id))');\n        $this->addSql('CREATE INDEX IDX_19A4B8546B00C1CF ON monitoring_curl_request (context_id)');\n        $this->addSql('CREATE TABLE monitoring_execution_context (uuid UUID NOT NULL, execution_type VARCHAR(255) NOT NULL, path VARCHAR(255) NOT NULL, handler VARCHAR(255) NOT NULL, user_type VARCHAR(255) NOT NULL, status_code INT DEFAULT NULL, exception VARCHAR(255) DEFAULT NULL, stacktrace VARCHAR(255) DEFAULT NULL, response_sending_duration_milliseconds DOUBLE PRECISION DEFAULT NULL, query_duration_milliseconds DOUBLE PRECISION NOT NULL, twig_render_duration_milliseconds DOUBLE PRECISION NOT NULL, curl_request_duration_milliseconds DOUBLE PRECISION NOT NULL, created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, started_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, started_at_microseconds DOUBLE PRECISION NOT NULL, ended_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, ended_at_microseconds DOUBLE PRECISION NOT NULL, duration_milliseconds DOUBLE PRECISION NOT NULL, PRIMARY KEY(uuid))');\n        $this->addSql('CREATE TABLE monitoring_query (id INT NOT NULL, context_id UUID DEFAULT NULL, query_string_id VARCHAR(40) DEFAULT NULL, parameters JSONB DEFAULT NULL, created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, started_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, started_at_microseconds DOUBLE PRECISION NOT NULL, ended_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, ended_at_microseconds DOUBLE PRECISION NOT NULL, duration_milliseconds DOUBLE PRECISION NOT NULL, PRIMARY KEY(id))');\n        $this->addSql('CREATE INDEX IDX_760D8AF36B00C1CF ON monitoring_query (context_id)');\n        $this->addSql('CREATE INDEX IDX_760D8AF3BCAEFD40 ON monitoring_query (query_string_id)');\n        $this->addSql('CREATE TABLE monitoring_query_string (query_hash VARCHAR(40) NOT NULL, query TEXT NOT NULL, PRIMARY KEY(query_hash))');\n        $this->addSql('CREATE TABLE monitoring_twig_render (id INT NOT NULL, context_id UUID DEFAULT NULL, parent_id INT DEFAULT NULL, short_description TEXT NOT NULL, template_name VARCHAR(255) DEFAULT NULL, name VARCHAR(255) DEFAULT NULL, type VARCHAR(255) DEFAULT NULL, memory_usage INT DEFAULT NULL, peak_memory_usage INT DEFAULT NULL, profiler_duration DOUBLE PRECISION DEFAULT NULL, created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, started_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, started_at_microseconds DOUBLE PRECISION NOT NULL, ended_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, ended_at_microseconds DOUBLE PRECISION NOT NULL, duration_milliseconds DOUBLE PRECISION NOT NULL, PRIMARY KEY(id))');\n        $this->addSql('CREATE INDEX IDX_55BA2A536B00C1CF ON monitoring_twig_render (context_id)');\n        $this->addSql('CREATE INDEX IDX_55BA2A53727ACA70 ON monitoring_twig_render (parent_id)');\n        $this->addSql('ALTER TABLE monitoring_curl_request ADD CONSTRAINT FK_19A4B8546B00C1CF FOREIGN KEY (context_id) REFERENCES monitoring_execution_context (uuid) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE monitoring_query ADD CONSTRAINT FK_760D8AF36B00C1CF FOREIGN KEY (context_id) REFERENCES monitoring_execution_context (uuid) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE monitoring_query ADD CONSTRAINT FK_760D8AF3BCAEFD40 FOREIGN KEY (query_string_id) REFERENCES monitoring_query_string (query_hash) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE monitoring_twig_render ADD CONSTRAINT FK_55BA2A536B00C1CF FOREIGN KEY (context_id) REFERENCES monitoring_execution_context (uuid) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n        $this->addSql('ALTER TABLE monitoring_twig_render ADD CONSTRAINT FK_55BA2A53727ACA70 FOREIGN KEY (parent_id) REFERENCES monitoring_twig_render (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');\n    }\n\n    public function down(Schema $schema): void\n    {\n        $this->addSql('DROP SEQUENCE monitoring_curl_request_id_seq CASCADE');\n        $this->addSql('DROP SEQUENCE monitoring_query_id_seq CASCADE');\n        $this->addSql('DROP SEQUENCE monitoring_twig_render_id_seq CASCADE');\n        $this->addSql('ALTER TABLE monitoring_curl_request DROP CONSTRAINT FK_19A4B8546B00C1CF');\n        $this->addSql('ALTER TABLE monitoring_query DROP CONSTRAINT FK_760D8AF36B00C1CF');\n        $this->addSql('ALTER TABLE monitoring_query DROP CONSTRAINT FK_760D8AF3BCAEFD40');\n        $this->addSql('ALTER TABLE monitoring_twig_render DROP CONSTRAINT FK_55BA2A536B00C1CF');\n        $this->addSql('ALTER TABLE monitoring_twig_render DROP CONSTRAINT FK_55BA2A53727ACA70');\n        $this->addSql('DROP TABLE monitoring_curl_request');\n        $this->addSql('DROP TABLE monitoring_execution_context');\n        $this->addSql('DROP TABLE monitoring_query');\n        $this->addSql('DROP TABLE monitoring_query_string');\n        $this->addSql('DROP TABLE monitoring_twig_render');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20260127111110.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\nfinal class Version20260127111110 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return 'Remove duplicate indexes';\n    }\n\n    public function up(Schema $schema): void\n    {\n        $this->addSql('DROP INDEX IF EXISTS idx_da62921d3dae168b'); // bookmark (list_id) -> covered by bookmark_list_entry_entrycomment_post_postcomment_idx (list_id, entry_id, entry_comment_id, post_id, post_comment_id)\n        $this->addSql('DROP INDEX IF EXISTS idx_a650c0c4a76ed395'); // bookmark_list (user_id) -> covered by uniq_a650c0c4a76ed3955e237e06 (user_id, name)\n        $this->addSql('DROP INDEX IF EXISTS idx_5060bff4a76ed395'); // domain_block (user_id) -> covered by domain_block_idx (user_id, domain_id)\n        $this->addSql('DROP INDEX IF EXISTS idx_3ac9125ea76ed395'); // domain_subscription (user_id) -> covered by domain_subscription_idx (user_id, domain_id)\n        $this->addSql('DROP INDEX IF EXISTS entry_visibility_idx'); // entry (visibility) -> covered by entry_visibility_adult_idx (visibility, is_adult)\n        $this->addSql('DROP INDEX IF EXISTS idx_9e561267a76ed395'); // entry_comment_vote (user_id) -> covered by user_entry_comment_vote_idx (user_id, comment_id)\n        $this->addSql('DROP INDEX IF EXISTS idx_fe32fd77a76ed395'); // entry_vote (user_id) -> covered by user_entry_vote_idx (user_id, entry_id)\n        $this->addSql('DROP INDEX IF EXISTS idx_62a2ca1960c33421'); // favourite (entry_comment_id) -> covered by favourite_user_entry_comment_unique_idx (entry_comment_id, user_id)\n        $this->addSql('DROP INDEX IF EXISTS idx_62a2ca19ba364942'); // favourite (entry_id) -> covered by favourite_user_entry_unique_idx (entry_id, user_id)\n        $this->addSql('DROP INDEX IF EXISTS idx_62a2ca19db1174d2'); // favourite (post_comment_id) -> covered by favourite_user_post_comment_unique_idx (post_comment_id, user_id)\n        $this->addSql('DROP INDEX IF EXISTS idx_62a2ca194b89032c'); // favourite (post_id) -> covered by favourite_user_post_unique_idx (post_id, user_id)\n        $this->addSql('DROP INDEX IF EXISTS magazine_visibility_idx'); // magazine (visibility) -> covered by magazine_visibility_adult_idx (visibility, is_adult)\n        $this->addSql('DROP INDEX IF EXISTS idx_41cc6069a76ed395'); // magazine_block (user_id) -> covered by magazine_block_idx (user_id, magazine_id)\n        $this->addSql('DROP INDEX IF EXISTS idx_a7160c653eb84a1d'); // magazine_ownership_request (magazine_id) -> covered by magazine_ownership_magazine_user_idx (magazine_id, user_id)\n        $this->addSql('DROP INDEX IF EXISTS idx_acce935a76ed395'); // magazine_subscription (user_id) -> covered by magazine_subsription_idx (user_id, magazine_id)\n        $this->addSql('DROP INDEX IF EXISTS idx_38501651a76ed395'); // magazine_subscription_request (user_id) -> covered by magazine_subscription_requests_idx (user_id, magazine_id)\n        $this->addSql('DROP INDEX IF EXISTS idx_f2de92908829462f'); // message_thread_participants (message_thread_id) -> covered by message_thread_participants_pkey (message_thread_id, user_id)\n        $this->addSql('DROP INDEX IF EXISTS idx_6a30b2683eb84a1d'); // moderator (magazine_id) -> covered by moderator_magazine_user_idx (magazine_id, user_id)\n        $this->addSql('DROP INDEX IF EXISTS idx_2cc3e3243eb84a1d'); // moderator_request (magazine_id) -> covered by moderator_request_magazine_user_idx (magazine_id, user_id)\n        $this->addSql('DROP INDEX IF EXISTS idx_b0559860a76ed395'); // notification_settings (user_id) -> covered by notification_settings_user_target (user_id, entry_id, post_id, magazine_id, target_user_id)\n        $this->addSql('DROP INDEX IF EXISTS post_visibility_idx'); // post (visibility) -> covered by post_visibility_adult_idx (visibility, is_adult)\n        $this->addSql('DROP INDEX IF EXISTS idx_d71b5a5ba76ed395'); // post_comment_vote (user_id) -> covered by user_post_comment_vote_idx (user_id, comment_id)\n        $this->addSql('DROP INDEX IF EXISTS idx_9345e26fa76ed395'); // post_vote (user_id) -> covered by user_post_vote_idx (user_id, post_id)\n        $this->addSql('DROP INDEX IF EXISTS idx_61d96c7a548d5975'); // user_block (blocker_id) -> covered by user_block_idx (blocker_id, blocked_id)\n        $this->addSql('DROP INDEX IF EXISTS idx_d665f4dac24f853'); // user_follow (follower_id) -> covered by user_follows_idx (follower_id, following_id)\n        $this->addSql('DROP INDEX IF EXISTS idx_ee70876ac24f853'); // user_follow_request (follower_id) -> covered by user_follow_requests_idx (follower_id, following_id)\n        $this->addSql('DROP INDEX IF EXISTS idx_b53cb6dda76ed395'); // user_note (user_id) -> covered by user_noted_idx (user_id, target_id)\n    }\n\n    public function down(Schema $schema): void\n    {\n        $this->addSql('CREATE INDEX IF NOT EXISTS IDX_DA62921D3DAE168B ON bookmark (list_id)');\n        $this->addSql('CREATE INDEX IF NOT EXISTS IDX_A650C0C4A76ED395 ON bookmark_list (user_id)');\n        $this->addSql('CREATE INDEX IF NOT EXISTS IDX_5060BFF4A76ED395 ON domain_block (user_id)');\n        $this->addSql('CREATE INDEX IF NOT EXISTS IDX_3AC9125EA76ED395 ON domain_subscription (user_id)');\n        $this->addSql('CREATE INDEX IF NOT EXISTS entry_visibility_idx ON entry (visibility)');\n        $this->addSql('CREATE INDEX IF NOT EXISTS IDX_9E561267A76ED395 ON entry_comment_vote (user_id)');\n        $this->addSql('CREATE INDEX IF NOT EXISTS IDX_62A2CA1960C33421 ON favourite (entry_comment_id)');\n        $this->addSql('CREATE INDEX IF NOT EXISTS IDX_62A2CA19BA364942 ON favourite (entry_id)');\n        $this->addSql('CREATE INDEX IF NOT EXISTS IDX_62A2CA19DB1174D2 ON favourite (post_comment_id)');\n        $this->addSql('CREATE INDEX IF NOT EXISTS IDX_62A2CA194B89032C ON favourite (post_id)');\n        $this->addSql('CREATE INDEX IF NOT EXISTS magazine_visibility_idx ON magazine (visibility)');\n        $this->addSql('CREATE INDEX IF NOT EXISTS IDX_41CC6069A76ED395 ON magazine_block (user_id)');\n        $this->addSql('CREATE INDEX IF NOT EXISTS IDX_A7160C653EB84A1D ON magazine_ownership_request (magazine_id)');\n        $this->addSql('CREATE INDEX IF NOT EXISTS IDX_ACCE935A76ED395 ON magazine_subscription (user_id)');\n        $this->addSql('CREATE INDEX IF NOT EXISTS IDX_38501651A76ED395 ON magazine_subscription_request (user_id)');\n        $this->addSql('CREATE INDEX IF NOT EXISTS IDX_F2DE92908829462F ON message_thread_participants (message_thread_id)');\n        $this->addSql('CREATE INDEX IF NOT EXISTS IDX_6A30B2683EB84A1D ON moderator (magazine_id)');\n        $this->addSql('CREATE INDEX IF NOT EXISTS IDX_2CC3E3243EB84A1D ON moderator_request (magazine_id)');\n        $this->addSql('CREATE INDEX IF NOT EXISTS IDX_B0559860A76ED395 ON notification_settings (user_id)');\n        $this->addSql('CREATE INDEX IF NOT EXISTS post_visibility_idx ON post (visibility)');\n        $this->addSql('CREATE INDEX IF NOT EXISTS IDX_D71B5A5BA76ED395 ON post_comment_vote (user_id)');\n        $this->addSql('CREATE INDEX IF NOT EXISTS IDX_9345E26FA76ED395 ON post_vote (user_id)');\n        $this->addSql('CREATE INDEX IF NOT EXISTS IDX_61D96C7A548D5975 ON user_block (blocker_id)');\n        $this->addSql('CREATE INDEX IF NOT EXISTS IDX_D665F4DAC24F853 ON user_follow (follower_id)');\n        $this->addSql('CREATE INDEX IF NOT EXISTS IDX_EE70876AC24F853 ON user_follow_request (follower_id)');\n        $this->addSql('CREATE INDEX IF NOT EXISTS IDX_B53CB6DDA76ED395 ON user_note (user_id)');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20260201131000.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\nfinal class Version20260201131000 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return 'Add the name of local magazines to their tags array';\n    }\n\n    public function up(Schema $schema): void\n    {\n        // this will not match where tags IS NULL\n        $this->addSql('UPDATE magazine SET tags = tags || jsonb_build_array(name) WHERE ap_id IS NULL AND NOT (tags @> jsonb_build_array(name));');\n        // set it where tags IS NULL\n        $this->addSql('UPDATE magazine SET tags = jsonb_build_array(name) WHERE ap_id IS NULL AND tags IS NULL');\n    }\n\n    public function down(Schema $schema): void\n    {\n        $this->addSql('UPDATE magazine SET tags = tags - name WHERE ap_id IS NULL;');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20260224224633.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\n/**\n * Auto-generated Migration: Please modify to your needs!\n */\nfinal class Version20260224224633 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return 'Add title to user';\n    }\n\n    public function up(Schema $schema): void\n    {\n        $this->addSql('ALTER TABLE \"user\" ADD title VARCHAR(255) DEFAULT NULL');\n        $this->addSql('ALTER TABLE \"user\" ADD title_ts tsvector GENERATED ALWAYS AS (to_tsvector(\\'english\\', title)) STORED');\n        $this->addSql('CREATE INDEX user_title_ts ON \"user\" USING GIN (title_ts)');\n    }\n\n    public function down(Schema $schema): void\n    {\n        $this->addSql('DROP INDEX user_title_ts');\n        $this->addSql('ALTER TABLE \"user\" DROP title_ts');\n        $this->addSql('ALTER TABLE \"user\" DROP title');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20260303103217.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\nfinal class Version20260303103217 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return '';\n    }\n\n    public function up(Schema $schema): void\n    {\n        $this->addSql('ALTER TABLE rememberme_token ALTER class SET DEFAULT \\'\\'');\n    }\n\n    public function down(Schema $schema): void\n    {\n        $this->addSql('ALTER TABLE rememberme_token ALTER class DROP DEFAULT');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20260303142852.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\nfinal class Version20260303142852 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return 'Add local_size, original_size, is_compressed and source_too_big image columns';\n    }\n\n    public function up(Schema $schema): void\n    {\n        $this->addSql('ALTER TABLE image ADD is_compressed BOOLEAN DEFAULT false NOT NULL');\n        $this->addSql('ALTER TABLE image ADD source_too_big BOOLEAN DEFAULT false NOT NULL');\n        $this->addSql('ALTER TABLE image ADD downloaded_at TIMESTAMP(0) WITH TIME ZONE DEFAULT NULL');\n        // init the column for all existing images, it gets overwritten in the big loop underneath\n        $this->addSql('ALTER TABLE image ADD created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL DEFAULT current_timestamp');\n        $this->addSql('ALTER TABLE image ALTER created_at DROP DEFAULT;');\n        $this->addSql('ALTER TABLE image ADD original_size BIGINT DEFAULT 0 NOT NULL');\n        $this->addSql('ALTER TABLE image ADD local_size BIGINT DEFAULT 0 NOT NULL');\n\n        // set the downloaded at value to something realistically\n        $this->addSql('DO\n$do$\n    declare tempRow record;\nBEGIN\n    FOR tempRow IN\n        SELECT i.id, e.created_at as ec, ec.created_at as ecc, p.created_at as pc, pc.created_at as pcc, u.created_at as uc, u2.created_at as u2c, m.created_at as mc, m2.created_at as m2c FROM image i\n            LEFT JOIN entry e ON i.id = e.image_id\n            LEFT JOIN entry_comment ec ON i.id = ec.image_id\n            LEFT JOIN post p ON i.id = p.image_id\n            LEFT JOIN post_comment pc ON i.id = pc.image_id\n            LEFT JOIN \"user\" u ON i.id = u.avatar_id\n            LEFT JOIN \"user\" u2 ON i.id = u2.cover_id\n            LEFT JOIN magazine m ON i.id = m.icon_id\n            LEFT JOIN magazine m2 ON i.id = m2.banner_id\n    LOOP\n        IF tempRow.ec IS NOT NULL THEN\n            UPDATE image SET downloaded_at = tempRow.ec, created_at = tempRow.ec WHERE id = tempRow.id;\n        ELSIF tempRow.ecc IS NOT NULL THEN\n            UPDATE image SET downloaded_at = tempRow.ecc, created_at = tempRow.ecc WHERE id = tempRow.id;\n        ELSIF tempRow.pc IS NOT NULL THEN\n            UPDATE image SET downloaded_at = tempRow.pc, created_at = tempRow.pc WHERE id = tempRow.id;\n        ELSIF tempRow.pcc IS NOT NULL THEN\n            UPDATE image SET downloaded_at = tempRow.pcc, created_at = tempRow.pcc WHERE id = tempRow.id;\n        ELSIF tempRow.uc IS NOT NULL THEN\n            UPDATE image SET downloaded_at = tempRow.uc, created_at = tempRow.uc WHERE id = tempRow.id;\n        ELSIF tempRow.u2c IS NOT NULL THEN\n            UPDATE image SET downloaded_at = tempRow.u2c, created_at = tempRow.u2c WHERE id = tempRow.id;\n        ELSIF tempRow.mc IS NOT NULL THEN\n            UPDATE image SET downloaded_at = tempRow.mc, created_at = tempRow.mc WHERE id = tempRow.id;\n        ELSIF tempRow.m2c IS NOT NULL THEN\n            UPDATE image SET downloaded_at = tempRow.m2c, created_at = tempRow.m2c WHERE id = tempRow.id;\n        END IF;\n    END LOOP;\nEND\n$do$;');\n    }\n\n    public function down(Schema $schema): void\n    {\n        $this->addSql('ALTER TABLE image DROP is_compressed');\n        $this->addSql('ALTER TABLE image DROP source_too_big');\n        $this->addSql('ALTER TABLE image DROP downloaded_at');\n        $this->addSql('ALTER TABLE image DROP created_at');\n        $this->addSql('ALTER TABLE image DROP original_size');\n        $this->addSql('ALTER TABLE image DROP local_size');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20260315190023.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\nfinal class Version20260315190023 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return 'adds show_boosts_of_following';\n    }\n\n    public function up(Schema $schema): void\n    {\n        $this->addSql('ALTER TABLE \"user\" ADD show_boosts_of_following BOOLEAN DEFAULT false NOT NULL');\n    }\n\n    public function down(Schema $schema): void\n    {\n        $this->addSql('ALTER TABLE \"user\" DROP show_boosts_of_following');\n    }\n}\n"
  },
  {
    "path": "migrations/Version20260330132857.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace DoctrineMigrations;\n\nuse Doctrine\\DBAL\\Schema\\Schema;\nuse Doctrine\\Migrations\\AbstractMigration;\n\nfinal class Version20260330132857 extends AbstractMigration\n{\n    public function getDescription(): string\n    {\n        return 'Create initial user_filter_list table';\n    }\n\n    public function up(Schema $schema): void\n    {\n        $this->addSql('CREATE SEQUENCE user_filter_list_id_seq INCREMENT BY 1 MINVALUE 1 START 1');\n        $this->addSql('CREATE TABLE user_filter_list (id INT NOT NULL, name VARCHAR(255) NOT NULL, expiration_date TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, feeds BOOLEAN NOT NULL, profile BOOLEAN NOT NULL, comments BOOLEAN NOT NULL, words JSONB NOT NULL, created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, user_id INT NOT NULL, PRIMARY KEY (id))');\n        $this->addSql('CREATE INDEX IDX_85E956F4A76ED395 ON user_filter_list (user_id)');\n        $this->addSql('ALTER TABLE user_filter_list ADD CONSTRAINT FK_85E956F4A76ED395 FOREIGN KEY (user_id) REFERENCES \"user\" (id) ON DELETE CASCADE NOT DEFERRABLE');\n    }\n\n    public function down(Schema $schema): void\n    {\n        $this->addSql('DROP SEQUENCE user_filter_list_id_seq CASCADE');\n        $this->addSql('ALTER TABLE user_filter_list DROP CONSTRAINT FK_85E956F4A76ED395');\n        $this->addSql('DROP TABLE user_filter_list');\n    }\n}\n"
  },
  {
    "path": "package.json",
    "content": "{\n    \"devDependencies\": {\n        \"@babel/core\": \"^7.29.0\",\n        \"@babel/preset-env\": \"^7.29.2\",\n        \"@eslint/js\": \"^9.39.4\",\n        \"@floating-ui/dom\": \"^1.7.6\",\n        \"@fortawesome/fontawesome-free\": \"^6.7.2\",\n        \"@github/markdown-toolbar-element\": \"^2.2.3\",\n        \"@hotwired/stimulus\": \"^3.2.2\",\n        \"@stylistic/eslint-plugin\": \"^2.13.0\",\n        \"@symfony/stimulus-bridge\": \"^3.2.3\",\n        \"@symfony/stimulus-bundle\": \"file:vendor/symfony/stimulus-bundle/assets\",\n        \"@symfony/ux-autocomplete\": \"file:vendor/symfony/ux-autocomplete/assets\",\n        \"@symfony/ux-chartjs\": \"file:vendor/symfony/ux-chartjs/assets\",\n        \"@symfony/webpack-encore\": \"^5.3.1\",\n        \"chart.js\": \"^3.8.2\",\n        \"core-js\": \"^3.49.0\",\n        \"emoji-picker-element\": \"^1.29.1\",\n        \"eslint\": \"^9.39.4\",\n        \"file-loader\": \"^6.2.0\",\n        \"glightbox\": \"^3.3.1\",\n        \"globals\": \"^15.15.0\",\n        \"hotkeys-js\": \"^3.13.15\",\n        \"regenerator-runtime\": \"^0.14.1\",\n        \"sass\": \"^1.98.0\",\n        \"sass-loader\": \"^16.0.7\",\n        \"simple-icons-font\": \"^14.15.0\",\n        \"stimulus-textarea-autogrow\": \"^4.1.0\",\n        \"stimulus-use\": \"^0.52.3\",\n        \"timeago.js\": \"^4.0.2\",\n        \"tom-select\": \"^2.5.2\",\n        \"typescript\": \"^5.9.3\",\n        \"webpack\": \"^5.105.4\",\n        \"webpack-cli\": \"^5.1.4\",\n        \"webpack-notifier\": \"^1.15.0\"\n    },\n    \"name\": \"mbin\",\n    \"license\": \"AGPL-3.0\",\n    \"private\": true,\n    \"scripts\": {\n        \"dev-server\": \"encore dev-server\",\n        \"dev\": \"encore dev\",\n        \"watch\": \"encore dev --watch\",\n        \"build\": \"encore production --progress\",\n        \"lint\": \"eslint .\",\n        \"lint-fix\": \"eslint --fix .\"\n    }\n}\n"
  },
  {
    "path": "phpstan.dist.neon",
    "content": "parameters:\n    level: 6\n    paths:\n        - bin/\n        - config/\n        - public/\n        - src/\n        - tests/\n"
  },
  {
    "path": "phpunit.xml.dist",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!-- https://phpunit.readthedocs.io/en/latest/configuration.html -->\n<phpunit xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\"\n         xsi:noNamespaceSchemaLocation=\"vendor/phpunit/phpunit/phpunit.xsd\"\n         backupGlobals=\"false\"\n         colors=\"true\"\n         bootstrap=\"tests/bootstrap.php\"\n>\n    <php>\n        <ini name=\"display_errors\" value=\"1\" />\n        <ini name=\"error_reporting\" value=\"-1\" />\n        <server name=\"APP_ENV\" value=\"test\" force=\"true\" />\n        <server name=\"SHELL_VERBOSITY\" value=\"-1\" />\n        <server name=\"SYMFONY_PHPUNIT_REMOVE\" value=\"\" />\n        <server name=\"SYMFONY_PHPUNIT_VERSION\" value=\"11.4.3\" />\n        <server name=\"BOOTSTRAP_DB\" value=\"1\" />\n    </php>\n\n    <testsuites>\n        <testsuite name=\"Project Test Suite\">\n            <directory>tests</directory>\n        </testsuite>\n    </testsuites>\n\n    <coverage>\n    </coverage>\n\n    <source>\n        <include>\n            <directory suffix=\".php\">src</directory>\n        </include>\n        <exclude>\n            <directory suffix=\".php\">src/DataFixtures</directory>\n        </exclude>\n    </source>\n\n    <extensions>\n        <bootstrap class=\"Symfony\\Bridge\\PhpUnit\\SymfonyExtension\">\n            <parameter name=\"clock-mock-namespaces\" value=\"App\" />\n            <parameter name=\"dns-mock-namespaces\" value=\"App\" />\n        </bootstrap>\n        <bootstrap class=\"DAMA\\DoctrineTestBundle\\PHPUnit\\PHPUnitExtension\" />\n    </extensions>\n</phpunit>\n"
  },
  {
    "path": "public/index.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nuse App\\Kernel;\n\nrequire_once \\dirname(__DIR__).'/vendor/autoload_runtime.php';\n\nreturn function (array $context) {\n    return new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);\n};\n"
  },
  {
    "path": "public/js/fos_js_routes.json",
    "content": "{\"base_url\":\"\",\"routes\":{\"ajax_fetch_title\":{\"tokens\":[[\"text\",\"\\/ajax\\/fetch_title\"]],\"defaults\":[],\"requirements\":[],\"hosttokens\":[],\"methods\":[\"POST\"],\"schemes\":[]},\"ajax_fetch_duplicates\":{\"tokens\":[[\"text\",\"\\/ajax\\/fetch_duplicates\"]],\"defaults\":[],\"requirements\":[],\"hosttokens\":[],\"methods\":[\"POST\"],\"schemes\":[]},\"ajax_fetch_embed\":{\"tokens\":[[\"text\",\"\\/ajax\\/fetch_embed\"]],\"defaults\":[],\"requirements\":[],\"hosttokens\":[],\"methods\":[\"GET\"],\"schemes\":[]},\"ajax_fetch_post_comments\":{\"tokens\":[[\"variable\",\"\\/\",\"[^\\/]++\",\"id\",true],[\"text\",\"\\/ajax\\/fetch_post_comments\"]],\"defaults\":[],\"requirements\":[],\"hosttokens\":[],\"methods\":[\"GET\"],\"schemes\":[]},\"ajax_fetch_entry\":{\"tokens\":[[\"variable\",\"\\/\",\"[^\\/]++\",\"id\",true],[\"text\",\"\\/ajax\\/fetch_entry\"]],\"defaults\":[],\"requirements\":[],\"hosttokens\":[],\"methods\":[\"GET\"],\"schemes\":[]},\"ajax_fetch_entry_comment\":{\"tokens\":[[\"variable\",\"\\/\",\"[^\\/]++\",\"id\",true],[\"text\",\"\\/ajax\\/fetch_entry_comment\"]],\"defaults\":[],\"requirements\":[],\"hosttokens\":[],\"methods\":[\"GET\"],\"schemes\":[]},\"ajax_fetch_post\":{\"tokens\":[[\"variable\",\"\\/\",\"[^\\/]++\",\"id\",true],[\"text\",\"\\/ajax\\/fetch_post\"]],\"defaults\":[],\"requirements\":[],\"hosttokens\":[],\"methods\":[\"GET\"],\"schemes\":[]},\"ajax_fetch_post_comment\":{\"tokens\":[[\"variable\",\"\\/\",\"[^\\/]++\",\"id\",true],[\"text\",\"\\/ajax\\/fetch_post_comment\"]],\"defaults\":[],\"requirements\":[],\"hosttokens\":[],\"methods\":[\"GET\"],\"schemes\":[]},\"ajax_fetch_user_popup\":{\"tokens\":[[\"variable\",\"\\/\",\"[^\\/]++\",\"username\",true],[\"text\",\"\\/ajax\\/fetch_user_popup\"]],\"defaults\":[],\"requirements\":[],\"hosttokens\":[],\"methods\":[\"GET\"],\"schemes\":[]},\"theme_settings\":{\"tokens\":[[\"variable\",\"\\/\",\"[^\\/]++\",\"value\",true],[\"variable\",\"\\/\",\"[^\\/]++\",\"key\",true],[\"text\",\"\\/settings\\/theme\"]],\"defaults\":[],\"requirements\":[],\"hosttokens\":[],\"methods\":[\"GET\"],\"schemes\":[]}},\"prefix\":\"\",\"host\":\"app.localhost\",\"port\":\"\",\"scheme\":\"https\",\"locale\":\"\"}\n"
  },
  {
    "path": "public/manifest.json",
    "content": "{\n  \"id\": \"/\",\n  \"scope\": \"/\",\n  \"name\": \"Mbin\",\n  \"display\": \"standalone\",\n  \"start_url\": \"/\",\n  \"short_name\": \"Mbin\",\n  \"description\": \"Mbin Progressive Web App\",\n  \"related_applications\": [],\n  \"prefer_related_applications\": false,\n  \"display_override\": [\n    \"window-controls-overlay\"\n  ],\n  \"background_color\": \"#212121\",\n  \"theme_color\": \"#212121\",\n  \"icons\": [\n    {\n      \"src\": \"assets/icons/icon-1024x1024.png\",\n      \"sizes\": \"1024x1024\",\n      \"type\": \"image/png\"\n    },\n    {\n      \"src\": \"assets/icons/icon-512x512.png\",\n      \"sizes\": \"512x512\",\n      \"type\": \"image/png\"\n    },\n    {\n      \"src\": \"assets/icons/icon-256x256.png\",\n      \"sizes\": \"256x256\",\n      \"type\": \"image/png\"\n    },\n    {\n      \"src\": \"assets/icons/icon-192x192.png\",\n      \"sizes\": \"192x192\",\n      \"type\": \"image/png\"\n    },\n    {\n      \"src\": \"assets/icons/icon-144x144.png\",\n      \"sizes\": \"144x144\",\n      \"type\": \"image/png\"\n    },\n    {\n      \"src\": \"assets/icons/icon-96x96.png\",\n      \"sizes\": \"96x96\",\n      \"type\": \"image/png\"\n    },\n    {\n      \"src\": \"assets/icons/icon-72x72.png\",\n      \"sizes\": \"72x72\",\n      \"type\": \"image/png\"\n    },\n    {\n      \"src\": \"assets/icons/icon-48x48.png\",\n      \"sizes\": \"48x48\",\n      \"type\": \"image/png\"\n    },\n    {\n      \"src\": \"assets/icons/icon-192-maskable.png\",\n      \"type\": \"image/png\",\n      \"sizes\": \"192x192\",\n      \"purpose\": \"maskable\"\n    },\n    {\n      \"src\": \"assets/icons/icon-512-maskable.png\",\n      \"type\": \"image/png\",\n      \"sizes\": \"512x512\",\n      \"purpose\": \"maskable\"\n    }\n  ],\n  \"screenshots\": [\n    {\n      \"src\": \"assets/screenshots/screen.png\",\n      \"sizes\": \"1600x869\",\n      \"type\": \"image/png\"\n    }\n  ],\n  \"features\": [\n    \"fediverse\",\n    \"ActivityPub\",\n    \"simple\"\n  ],\n  \"categories\": [\n    \"social\",\n    \"microblogging\"\n  ],\n  \"shortcuts\": [\n    {\n      \"name\": \"Open Microblog\",\n      \"short_name\": \"Microblog\",\n      \"description\": \"Open the Microblog section\",\n      \"url\": \"/microblog\",\n      \"icons\": [\n        {\n          \"src\": \"assets/icons/mbin-shortcut-microblog.png\",\n          \"sizes\": \"192x192\",\n          \"type\": \"image/png\"\n        }\n      ]\n    },\n    {\n      \"name\": \"Open Magazines\",\n      \"short_name\": \"Magazines\",\n      \"description\": \"Open the Magazines list\",\n      \"url\": \"/magazines\",\n      \"icons\": [\n        {\n          \"src\": \"assets/icons/mbin-shortcut-magazine.png\",\n          \"sizes\": \"192x192\",\n          \"type\": \"image/png\"\n        }\n      ]\n    },\n    {\n      \"name\": \"Open People\",\n      \"short_name\": \"People\",\n      \"description\": \"Open the People list\",\n      \"url\": \"/people\",\n      \"icons\": [\n        {\n          \"src\": \"assets/icons/mbin-shortcut-people.png\",\n          \"sizes\": \"192x192\",\n          \"type\": \"image/png\"\n        }\n      ]\n    },\n    {\n      \"name\": \"Open Settings\",\n      \"short_name\": \"Settings\",\n      \"description\": \"Open the Settings page\",\n      \"url\": \"/settings\",\n      \"icons\": [\n        {\n          \"src\": \"assets/icons/mbin-shortcut-settings.png\",\n          \"sizes\": \"192x192\",\n          \"type\": \"image/png\"\n        }\n      ]\n    }\n  ]\n}"
  },
  {
    "path": "public/robots.txt",
    "content": "# Ban several AI bots from indexing Mbin instances at all, in order to prevent training their models on users' data.\n\n# Using: https://github.com/mitchellkrogza/nginx-ultimate-bad-bot-blocker/blob/master/robots.txt/robots.txt\n### Version Information for nginx-ultimate-bad-bot-blocker #\n### Version Information #\n###################################################\n### Version: V4.2025.11.5580\n### Updated: Sun Nov  9 10:09:12 UTC 2025\n### Bad Bot Count: 681\n###################################################\n### Version Information ##\nUser-agent: 01h4x.com\nDisallow:/\nUser-agent: 360Spider\nDisallow:/\nUser-agent: 404checker\nDisallow:/\nUser-agent: 404enemy\nDisallow:/\nUser-agent: 80legs\nDisallow:/\nUser-agent: ADmantX\nDisallow:/\nUser-agent: AIBOT\nDisallow:/\nUser-agent: ALittle Client\nDisallow:/\nUser-agent: ASPSeek\nDisallow:/\nUser-agent: Abonti\nDisallow:/\nUser-agent: Aboundex\nDisallow:/\nUser-agent: Aboundexbot\nDisallow:/\nUser-agent: Acunetix\nDisallow:/\nUser-agent: AdsTxtCrawlerTP\nDisallow:/\nUser-agent: AfD-Verbotsverfahren\nDisallow:/\nUser-agent: AhrefsBot\nDisallow:/\nUser-agent: Ai2Bot\nDisallow:/\nUser-agent: AiHitBot\nDisallow:/\nUser-agent: Aipbot\nDisallow:/\nUser-agent: Alexibot\nDisallow:/\nUser-agent: Aliyun\nDisallow:/\nUser-agent: AliyunSecBot\nDisallow:/\nUser-agent: AllSubmitter\nDisallow:/\nUser-agent: Alligator\nDisallow:/\nUser-agent: AlphaBot\nDisallow:/\nUser-agent: Anarchie\nDisallow:/\nUser-agent: Anarchy\nDisallow:/\nUser-agent: Anarchy99\nDisallow:/\nUser-agent: Ankit\nDisallow:/\nUser-agent: Anthill\nDisallow:/\nUser-agent: Apexoo\nDisallow:/\nUser-agent: Aspiegel\nDisallow:/\nUser-agent: Asterias\nDisallow:/\nUser-agent: Atomseobot\nDisallow:/\nUser-agent: Attach\nDisallow:/\nUser-agent: AwarioBot\nDisallow:/\nUser-agent: AwarioRssBot\nDisallow:/\nUser-agent: AwarioSmartBot\nDisallow:/\nUser-agent: BBBike\nDisallow:/\nUser-agent: BDCbot\nDisallow:/\nUser-agent: BDFetch\nDisallow:/\nUser-agent: BLEXBot\nDisallow:/\nUser-agent: BackDoorBot\nDisallow:/\nUser-agent: BackStreet\nDisallow:/\nUser-agent: BackWeb\nDisallow:/\nUser-agent: Backlink-Ceck\nDisallow:/\nUser-agent: BacklinkCrawler\nDisallow:/\nUser-agent: BacklinksExtendedBot\nDisallow:/\nUser-agent: Badass\nDisallow:/\nUser-agent: Bandit\nDisallow:/\nUser-agent: Barkrowler\nDisallow:/\nUser-agent: BatchFTP\nDisallow:/\nUser-agent: Battleztar Bazinga\nDisallow:/\nUser-agent: BetaBot\nDisallow:/\nUser-agent: Bigfoot\nDisallow:/\nUser-agent: Bitacle\nDisallow:/\nUser-agent: BlackWidow\nDisallow:/\nUser-agent: Black Hole\nDisallow:/\nUser-agent: Blackboard\nDisallow:/\nUser-agent: Blow\nDisallow:/\nUser-agent: BlowFish\nDisallow:/\nUser-agent: Boardreader\nDisallow:/\nUser-agent: Bolt\nDisallow:/\nUser-agent: BotALot\nDisallow:/\nUser-agent: Brandprotect\nDisallow:/\nUser-agent: Brandwatch\nDisallow:/\nUser-agent: Buck\nDisallow:/\nUser-agent: Buddy\nDisallow:/\nUser-agent: BuiltBotTough\nDisallow:/\nUser-agent: BuiltWith\nDisallow:/\nUser-agent: Bullseye\nDisallow:/\nUser-agent: BunnySlippers\nDisallow:/\nUser-agent: BuzzSumo\nDisallow:/\nUser-agent: Bytespider\nDisallow:/\nUser-agent: CATExplorador\nDisallow:/\nUser-agent: CCBot\nDisallow:/\nUser-agent: CODE87\nDisallow:/\nUser-agent: CSHttp\nDisallow:/\nUser-agent: Calculon\nDisallow:/\nUser-agent: CazoodleBot\nDisallow:/\nUser-agent: Cegbfeieh\nDisallow:/\nUser-agent: CensysInspect\nDisallow:/\nUser-agent: ChatGPT-User\nDisallow:/\nUser-agent: CheTeam\nDisallow:/\nUser-agent: CheeseBot\nDisallow:/\nUser-agent: CherryPicker\nDisallow:/\nUser-agent: ChinaClaw\nDisallow:/\nUser-agent: Chlooe\nDisallow:/\nUser-agent: Citoid\nDisallow:/\nUser-agent: Claritybot\nDisallow:/\nUser-agent: ClaudeBot\nDisallow:/\nUser-agent: Cliqzbot\nDisallow:/\nUser-agent: Cloud mapping\nDisallow:/\nUser-agent: Cocolyzebot\nDisallow:/\nUser-agent: Cogentbot\nDisallow:/\nUser-agent: Collector\nDisallow:/\nUser-agent: Copier\nDisallow:/\nUser-agent: CopyRightCheck\nDisallow:/\nUser-agent: Copyscape\nDisallow:/\nUser-agent: Cosmos\nDisallow:/\nUser-agent: Craftbot\nDisallow:/\nUser-agent: Crawling at Home Project\nDisallow:/\nUser-agent: CrazyWebCrawler\nDisallow:/\nUser-agent: Crescent\nDisallow:/\nUser-agent: CrunchBot\nDisallow:/\nUser-agent: Curious\nDisallow:/\nUser-agent: Custo\nDisallow:/\nUser-agent: CyotekWebCopy\nDisallow:/\nUser-agent: DBLBot\nDisallow:/\nUser-agent: DIIbot\nDisallow:/\nUser-agent: DSearch\nDisallow:/\nUser-agent: DTS Agent\nDisallow:/\nUser-agent: DataCha0s\nDisallow:/\nUser-agent: DatabaseDriverMysqli\nDisallow:/\nUser-agent: Demon\nDisallow:/\nUser-agent: Deusu\nDisallow:/\nUser-agent: Devil\nDisallow:/\nUser-agent: Digincore\nDisallow:/\nUser-agent: DigitalPebble\nDisallow:/\nUser-agent: Dirbuster\nDisallow:/\nUser-agent: Disco\nDisallow:/\nUser-agent: Discobot\nDisallow:/\nUser-agent: Discoverybot\nDisallow:/\nUser-agent: Dispatch\nDisallow:/\nUser-agent: DittoSpyder\nDisallow:/\nUser-agent: DnBCrawler-Analytics\nDisallow:/\nUser-agent: DnyzBot\nDisallow:/\nUser-agent: DomCopBot\nDisallow:/\nUser-agent: DomainAppender\nDisallow:/\nUser-agent: DomainCrawler\nDisallow:/\nUser-agent: DomainSigmaCrawler\nDisallow:/\nUser-agent: DomainStatsBot\nDisallow:/\nUser-agent: Domains Project\nDisallow:/\nUser-agent: Dotbot\nDisallow:/\nUser-agent: Download Wonder\nDisallow:/\nUser-agent: Dragonfly\nDisallow:/\nUser-agent: Drip\nDisallow:/\nUser-agent: ECCP/1.0\nDisallow:/\nUser-agent: EMail Siphon\nDisallow:/\nUser-agent: EMail Wolf\nDisallow:/\nUser-agent: EasyDL\nDisallow:/\nUser-agent: Ebingbong\nDisallow:/\nUser-agent: Ecxi\nDisallow:/\nUser-agent: EirGrabber\nDisallow:/\nUser-agent: EroCrawler\nDisallow:/\nUser-agent: Evil\nDisallow:/\nUser-agent: Exabot\nDisallow:/\nUser-agent: Express WebPictures\nDisallow:/\nUser-agent: ExtLinksBot\nDisallow:/\nUser-agent: Extractor\nDisallow:/\nUser-agent: ExtractorPro\nDisallow:/\nUser-agent: Extreme Picture Finder\nDisallow:/\nUser-agent: EyeNetIE\nDisallow:/\nUser-agent: Ezooms\nDisallow:/\nUser-agent: FDM\nDisallow:/\nUser-agent: FHscan\nDisallow:/\nUser-agent: FacebookBot\nDisallow:/\nUser-agent: FemtosearchBot\nDisallow:/\nUser-agent: Fimap\nDisallow:/\nUser-agent: Firefox/7.0\nDisallow:/\nUser-agent: FlashGet\nDisallow:/\nUser-agent: Flunky\nDisallow:/\nUser-agent: Foobot\nDisallow:/\nUser-agent: Freeuploader\nDisallow:/\nUser-agent: FrontPage\nDisallow:/\nUser-agent: Fuzz\nDisallow:/\nUser-agent: FyberSpider\nDisallow:/\nUser-agent: Fyrebot\nDisallow:/\nUser-agent: G-i-g-a-b-o-t\nDisallow:/\nUser-agent: GPTBot\nDisallow:/\nUser-agent: GT::WWW\nDisallow:/\nUser-agent: GalaxyBot\nDisallow:/\nUser-agent: GeedoProductSearch\nDisallow:/\nUser-agent: Genieo\nDisallow:/\nUser-agent: GermCrawler\nDisallow:/\nUser-agent: GetRight\nDisallow:/\nUser-agent: GetWeb\nDisallow:/\nUser-agent: Getintent\nDisallow:/\nUser-agent: Gigabot\nDisallow:/\nUser-agent: Go!Zilla\nDisallow:/\nUser-agent: Go-Ahead-Got-It\nDisallow:/\nUser-agent: GoZilla\nDisallow:/\nUser-agent: Gotit\nDisallow:/\nUser-agent: GrabNet\nDisallow:/\nUser-agent: Grabber\nDisallow:/\nUser-agent: Grafula\nDisallow:/\nUser-agent: GrapeFX\nDisallow:/\nUser-agent: GrapeshotCrawler\nDisallow:/\nUser-agent: GridBot\nDisallow:/\nUser-agent: HEADMasterSEO\nDisallow:/\nUser-agent: HMView\nDisallow:/\nUser-agent: HTMLparser\nDisallow:/\nUser-agent: HTTP::Lite\nDisallow:/\nUser-agent: HTTrack\nDisallow:/\nUser-agent: Haansoft\nDisallow:/\nUser-agent: HaosouSpider\nDisallow:/\nUser-agent: Harvest\nDisallow:/\nUser-agent: Havij\nDisallow:/\nUser-agent: Heritrix\nDisallow:/\nUser-agent: Hloader\nDisallow:/\nUser-agent: HonoluluBot\nDisallow:/\nUser-agent: Humanlinks\nDisallow:/\nUser-agent: HybridBot\nDisallow:/\nUser-agent: IDBTE4M\nDisallow:/\nUser-agent: IDBot\nDisallow:/\nUser-agent: IRLbot\nDisallow:/\nUser-agent: Iblog\nDisallow:/\nUser-agent: Id-search\nDisallow:/\nUser-agent: IlseBot\nDisallow:/\nUser-agent: Image Fetch\nDisallow:/\nUser-agent: Image Sucker\nDisallow:/\nUser-agent: ImagesiftBot\nDisallow:/\nUser-agent: IndeedBot\nDisallow:/\nUser-agent: Indy Library\nDisallow:/\nUser-agent: InfoNaviRobot\nDisallow:/\nUser-agent: InfoTekies\nDisallow:/\nUser-agent: Information Security Team InfraSec Scanner\nDisallow:/\nUser-agent: InfraSec Scanner\nDisallow:/\nUser-agent: Intelliseek\nDisallow:/\nUser-agent: InterGET\nDisallow:/\nUser-agent: InternetMeasurement\nDisallow:/\nUser-agent: InternetSeer\nDisallow:/\nUser-agent: Internet Ninja\nDisallow:/\nUser-agent: Iria\nDisallow:/\nUser-agent: Iskanie\nDisallow:/\nUser-agent: IstellaBot\nDisallow:/\nUser-agent: JOC Web Spider\nDisallow:/\nUser-agent: JamesBOT\nDisallow:/\nUser-agent: Jbrofuzz\nDisallow:/\nUser-agent: JennyBot\nDisallow:/\nUser-agent: JetCar\nDisallow:/\nUser-agent: Jetty\nDisallow:/\nUser-agent: JikeSpider\nDisallow:/\nUser-agent: Joomla\nDisallow:/\nUser-agent: Jorgee\nDisallow:/\nUser-agent: JustView\nDisallow:/\nUser-agent: Jyxobot\nDisallow:/\nUser-agent: Kenjin Spider\nDisallow:/\nUser-agent: Keybot Translation-Search-Machine\nDisallow:/\nUser-agent: Keyword Density\nDisallow:/\nUser-agent: Kinza\nDisallow:/\nUser-agent: Kozmosbot\nDisallow:/\nUser-agent: LNSpiderguy\nDisallow:/\nUser-agent: LWP::Simple\nDisallow:/\nUser-agent: Lanshanbot\nDisallow:/\nUser-agent: Larbin\nDisallow:/\nUser-agent: Leap\nDisallow:/\nUser-agent: LeechFTP\nDisallow:/\nUser-agent: LeechGet\nDisallow:/\nUser-agent: LexiBot\nDisallow:/\nUser-agent: Lftp\nDisallow:/\nUser-agent: LibWeb\nDisallow:/\nUser-agent: Libwhisker\nDisallow:/\nUser-agent: LieBaoFast\nDisallow:/\nUser-agent: Lightspeedsystems\nDisallow:/\nUser-agent: Likse\nDisallow:/\nUser-agent: LinkScan\nDisallow:/\nUser-agent: LinkWalker\nDisallow:/\nUser-agent: Linkbot\nDisallow:/\nUser-agent: LinkextractorPro\nDisallow:/\nUser-agent: LinkpadBot\nDisallow:/\nUser-agent: LinksManager\nDisallow:/\nUser-agent: LinqiaMetadataDownloaderBot\nDisallow:/\nUser-agent: LinqiaRSSBot\nDisallow:/\nUser-agent: LinqiaScrapeBot\nDisallow:/\nUser-agent: Lipperhey\nDisallow:/\nUser-agent: Lipperhey Spider\nDisallow:/\nUser-agent: Litemage_walker\nDisallow:/\nUser-agent: Lmspider\nDisallow:/\nUser-agent: Ltx71\nDisallow:/\nUser-agent: MFC_Tear_Sample\nDisallow:/\nUser-agent: MIDown tool\nDisallow:/\nUser-agent: MIIxpc\nDisallow:/\nUser-agent: MJ12bot\nDisallow:/\nUser-agent: MQQBrowser\nDisallow:/\nUser-agent: MSFrontPage\nDisallow:/\nUser-agent: MSIECrawler\nDisallow:/\nUser-agent: MTRobot\nDisallow:/\nUser-agent: Mag-Net\nDisallow:/\nUser-agent: Magnet\nDisallow:/\nUser-agent: Mail.RU_Bot\nDisallow:/\nUser-agent: Majestic-SEO\nDisallow:/\nUser-agent: Majestic12\nDisallow:/\nUser-agent: Majestic SEO\nDisallow:/\nUser-agent: MarkMonitor\nDisallow:/\nUser-agent: MarkWatch\nDisallow:/\nUser-agent: Mass Downloader\nDisallow:/\nUser-agent: Masscan\nDisallow:/\nUser-agent: Mata Hari\nDisallow:/\nUser-agent: MauiBot\nDisallow:/\nUser-agent: Mb2345Browser\nDisallow:/\nUser-agent: MeanPath Bot\nDisallow:/\nUser-agent: Meanpathbot\nDisallow:/\nUser-agent: Mediatoolkitbot\nDisallow:/\nUser-agent: MegaIndex.ru\nDisallow:/\nUser-agent: Metauri\nDisallow:/\nUser-agent: MicroMessenger\nDisallow:/\nUser-agent: Microsoft Data Access\nDisallow:/\nUser-agent: Microsoft URL Control\nDisallow:/\nUser-agent: Minefield\nDisallow:/\nUser-agent: Mister PiX\nDisallow:/\nUser-agent: Moblie Safari\nDisallow:/\nUser-agent: Mojeek\nDisallow:/\nUser-agent: Mojolicious\nDisallow:/\nUser-agent: MolokaiBot\nDisallow:/\nUser-agent: Morfeus Fucking Scanner\nDisallow:/\nUser-agent: Mozlila\nDisallow:/\nUser-agent: Mr.4x3\nDisallow:/\nUser-agent: Msrabot\nDisallow:/\nUser-agent: Musobot\nDisallow:/\nUser-agent: NICErsPRO\nDisallow:/\nUser-agent: NPbot\nDisallow:/\nUser-agent: Name Intelligence\nDisallow:/\nUser-agent: Nameprotect\nDisallow:/\nUser-agent: Navroad\nDisallow:/\nUser-agent: NearSite\nDisallow:/\nUser-agent: Needle\nDisallow:/\nUser-agent: Nessus\nDisallow:/\nUser-agent: NetAnts\nDisallow:/\nUser-agent: NetLyzer\nDisallow:/\nUser-agent: NetMechanic\nDisallow:/\nUser-agent: NetSpider\nDisallow:/\nUser-agent: NetZIP\nDisallow:/\nUser-agent: Net Vampire\nDisallow:/\nUser-agent: Netcraft\nDisallow:/\nUser-agent: Nettrack\nDisallow:/\nUser-agent: Netvibes\nDisallow:/\nUser-agent: NextGenSearchBot\nDisallow:/\nUser-agent: Nibbler\nDisallow:/\nUser-agent: Niki-bot\nDisallow:/\nUser-agent: Nikto\nDisallow:/\nUser-agent: NimbleCrawler\nDisallow:/\nUser-agent: Nimbostratus\nDisallow:/\nUser-agent: Ninja\nDisallow:/\nUser-agent: Nmap\nDisallow:/\nUser-agent: Nuclei\nDisallow:/\nUser-agent: Nutch\nDisallow:/\nUser-agent: Octopus\nDisallow:/\nUser-agent: Offline Explorer\nDisallow:/\nUser-agent: Offline Navigator\nDisallow:/\nUser-agent: OnCrawl\nDisallow:/\nUser-agent: OpenLinkProfiler\nDisallow:/\nUser-agent: OpenVAS\nDisallow:/\nUser-agent: Openfind\nDisallow:/\nUser-agent: Openvas\nDisallow:/\nUser-agent: OrangeBot\nDisallow:/\nUser-agent: OrangeSpider\nDisallow:/\nUser-agent: OutclicksBot\nDisallow:/\nUser-agent: OutfoxBot\nDisallow:/\nUser-agent: PECL::HTTP\nDisallow:/\nUser-agent: PHPCrawl\nDisallow:/\nUser-agent: POE-Component-Client-HTTP\nDisallow:/\nUser-agent: PageAnalyzer\nDisallow:/\nUser-agent: PageGrabber\nDisallow:/\nUser-agent: PageScorer\nDisallow:/\nUser-agent: PageThing.com\nDisallow:/\nUser-agent: Page Analyzer\nDisallow:/\nUser-agent: Pandalytics\nDisallow:/\nUser-agent: Panscient\nDisallow:/\nUser-agent: Papa Foto\nDisallow:/\nUser-agent: Pavuk\nDisallow:/\nUser-agent: PeoplePal\nDisallow:/\nUser-agent: Petalbot\nDisallow:/\nUser-agent: Pi-Monster\nDisallow:/\nUser-agent: Picscout\nDisallow:/\nUser-agent: Picsearch\nDisallow:/\nUser-agent: PictureFinder\nDisallow:/\nUser-agent: Piepmatz\nDisallow:/\nUser-agent: Pimonster\nDisallow:/\nUser-agent: Pixray\nDisallow:/\nUser-agent: PleaseCrawl\nDisallow:/\nUser-agent: Pockey\nDisallow:/\nUser-agent: ProPowerBot\nDisallow:/\nUser-agent: ProWebWalker\nDisallow:/\nUser-agent: Probethenet\nDisallow:/\nUser-agent: Proximic\nDisallow:/\nUser-agent: Psbot\nDisallow:/\nUser-agent: Pu_iN\nDisallow:/\nUser-agent: Pump\nDisallow:/\nUser-agent: PxBroker\nDisallow:/\nUser-agent: PyCurl\nDisallow:/\nUser-agent: QueryN Metasearch\nDisallow:/\nUser-agent: Quick-Crawler\nDisallow:/\nUser-agent: RSSingBot\nDisallow:/\nUser-agent: Rainbot\nDisallow:/\nUser-agent: RankActive\nDisallow:/\nUser-agent: RankActiveLinkBot\nDisallow:/\nUser-agent: RankFlex\nDisallow:/\nUser-agent: RankingBot\nDisallow:/\nUser-agent: RankingBot2\nDisallow:/\nUser-agent: Rankivabot\nDisallow:/\nUser-agent: RankurBot\nDisallow:/\nUser-agent: Re-re\nDisallow:/\nUser-agent: ReGet\nDisallow:/\nUser-agent: RealDownload\nDisallow:/\nUser-agent: Reaper\nDisallow:/\nUser-agent: RebelMouse\nDisallow:/\nUser-agent: Recorder\nDisallow:/\nUser-agent: RedesScrapy\nDisallow:/\nUser-agent: RepoMonkey\nDisallow:/\nUser-agent: Ripper\nDisallow:/\nUser-agent: RocketCrawler\nDisallow:/\nUser-agent: Rogerbot\nDisallow:/\nUser-agent: SBIder\nDisallow:/\nUser-agent: SEOkicks\nDisallow:/\nUser-agent: SEOkicks-Robot\nDisallow:/\nUser-agent: SEOlyt\nDisallow:/\nUser-agent: SEOlyticsCrawler\nDisallow:/\nUser-agent: SEOprofiler\nDisallow:/\nUser-agent: SEOstats\nDisallow:/\nUser-agent: SISTRIX\nDisallow:/\nUser-agent: SMTBot\nDisallow:/\nUser-agent: SalesIntelligent\nDisallow:/\nUser-agent: ScanAlert\nDisallow:/\nUser-agent: Scanbot\nDisallow:/\nUser-agent: ScoutJet\nDisallow:/\nUser-agent: Scrapy\nDisallow:/\nUser-agent: Screaming\nDisallow:/\nUser-agent: ScreenerBot\nDisallow:/\nUser-agent: ScrepyBot\nDisallow:/\nUser-agent: Searchestate\nDisallow:/\nUser-agent: SearchmetricsBot\nDisallow:/\nUser-agent: Seekport\nDisallow:/\nUser-agent: SeekportBot\nDisallow:/\nUser-agent: SemanticJuice\nDisallow:/\nUser-agent: Semrush\nDisallow:/\nUser-agent: SemrushBot\nDisallow:/\nUser-agent: SemrushBot-BA\nDisallow:/\nUser-agent: SemrushBot-FT\nDisallow:/\nUser-agent: SemrushBot-OCOB\nDisallow:/\nUser-agent: SemrushBot-SI\nDisallow:/\nUser-agent: SemrushBot-SWA\nDisallow:/\nUser-agent: SentiBot\nDisallow:/\nUser-agent: SenutoBot\nDisallow:/\nUser-agent: SeoCherryBot\nDisallow:/\nUser-agent: SeoSiteCheckup\nDisallow:/\nUser-agent: SeobilityBot\nDisallow:/\nUser-agent: Seomoz\nDisallow:/\nUser-agent: Shodan\nDisallow:/\nUser-agent: Siphon\nDisallow:/\nUser-agent: SiteAuditBot\nDisallow:/\nUser-agent: SiteCheckerBotCrawler\nDisallow:/\nUser-agent: SiteExplorer\nDisallow:/\nUser-agent: SiteLockSpider\nDisallow:/\nUser-agent: SiteSnagger\nDisallow:/\nUser-agent: SiteSucker\nDisallow:/\nUser-agent: Site Sucker\nDisallow:/\nUser-agent: Sitebeam\nDisallow:/\nUser-agent: Siteimprove\nDisallow:/\nUser-agent: Sitevigil\nDisallow:/\nUser-agent: SlySearch\nDisallow:/\nUser-agent: SmartDownload\nDisallow:/\nUser-agent: Snake\nDisallow:/\nUser-agent: Snapbot\nDisallow:/\nUser-agent: Snoopy\nDisallow:/\nUser-agent: SocialRankIOBot\nDisallow:/\nUser-agent: Sociscraper\nDisallow:/\nUser-agent: Sogou web spider\nDisallow:/\nUser-agent: Sosospider\nDisallow:/\nUser-agent: Sottopop\nDisallow:/\nUser-agent: SpaceBison\nDisallow:/\nUser-agent: Spammen\nDisallow:/\nUser-agent: SpankBot\nDisallow:/\nUser-agent: Spanner\nDisallow:/\nUser-agent: Spbot\nDisallow:/\nUser-agent: Spider_Bot\nDisallow:/\nUser-agent: Spider_Bot/3.0\nDisallow:/\nUser-agent: Spinn3r\nDisallow:/\nUser-agent: SplitSignalBot\nDisallow:/\nUser-agent: SputnikBot\nDisallow:/\nUser-agent: Sqlmap\nDisallow:/\nUser-agent: Sqlworm\nDisallow:/\nUser-agent: Sqworm\nDisallow:/\nUser-agent: Steeler\nDisallow:/\nUser-agent: Stripper\nDisallow:/\nUser-agent: Sucker\nDisallow:/\nUser-agent: Sucuri\nDisallow:/\nUser-agent: SuperBot\nDisallow:/\nUser-agent: SuperHTTP\nDisallow:/\nUser-agent: Surfbot\nDisallow:/\nUser-agent: SurveyBot\nDisallow:/\nUser-agent: Suzuran\nDisallow:/\nUser-agent: Swiftbot\nDisallow:/\nUser-agent: Szukacz\nDisallow:/\nUser-agent: T0PHackTeam\nDisallow:/\nUser-agent: T8Abot\nDisallow:/\nUser-agent: Teleport\nDisallow:/\nUser-agent: TeleportPro\nDisallow:/\nUser-agent: Telesoft\nDisallow:/\nUser-agent: Telesphoreo\nDisallow:/\nUser-agent: Telesphorep\nDisallow:/\nUser-agent: TheNomad\nDisallow:/\nUser-agent: The Intraformant\nDisallow:/\nUser-agent: Thumbor\nDisallow:/\nUser-agent: TightTwatBot\nDisallow:/\nUser-agent: TinyTestBot\nDisallow:/\nUser-agent: Titan\nDisallow:/\nUser-agent: Toata\nDisallow:/\nUser-agent: Toweyabot\nDisallow:/\nUser-agent: Tracemyfile\nDisallow:/\nUser-agent: Trendiction\nDisallow:/\nUser-agent: Trendictionbot\nDisallow:/\nUser-agent: True_Robot\nDisallow:/\nUser-agent: Turingos\nDisallow:/\nUser-agent: Turnitin\nDisallow:/\nUser-agent: TurnitinBot\nDisallow:/\nUser-agent: TwengaBot\nDisallow:/\nUser-agent: Twice\nDisallow:/\nUser-agent: Typhoeus\nDisallow:/\nUser-agent: URLy.Warning\nDisallow:/\nUser-agent: URLy Warning\nDisallow:/\nUser-agent: UnisterBot\nDisallow:/\nUser-agent: Upflow\nDisallow:/\nUser-agent: V-BOT\nDisallow:/\nUser-agent: VB Project\nDisallow:/\nUser-agent: VCI\nDisallow:/\nUser-agent: Vacuum\nDisallow:/\nUser-agent: Vagabondo\nDisallow:/\nUser-agent: VelenPublicWebCrawler\nDisallow:/\nUser-agent: VeriCiteCrawler\nDisallow:/\nUser-agent: VidibleScraper\nDisallow:/\nUser-agent: Virusdie\nDisallow:/\nUser-agent: VoidEYE\nDisallow:/\nUser-agent: Voil\nDisallow:/\nUser-agent: Voltron\nDisallow:/\nUser-agent: WASALive-Bot\nDisallow:/\nUser-agent: WBSearchBot\nDisallow:/\nUser-agent: WEBDAV\nDisallow:/\nUser-agent: WISENutbot\nDisallow:/\nUser-agent: WPScan\nDisallow:/\nUser-agent: WWW-Collector-E\nDisallow:/\nUser-agent: WWW-Mechanize\nDisallow:/\nUser-agent: WWW::Mechanize\nDisallow:/\nUser-agent: WWWOFFLE\nDisallow:/\nUser-agent: Wallpapers\nDisallow:/\nUser-agent: Wallpapers/3.0\nDisallow:/\nUser-agent: WallpapersHD\nDisallow:/\nUser-agent: WeSEE\nDisallow:/\nUser-agent: WebAuto\nDisallow:/\nUser-agent: WebBandit\nDisallow:/\nUser-agent: WebCollage\nDisallow:/\nUser-agent: WebCopier\nDisallow:/\nUser-agent: WebEnhancer\nDisallow:/\nUser-agent: WebFetch\nDisallow:/\nUser-agent: WebFuck\nDisallow:/\nUser-agent: WebGo IS\nDisallow:/\nUser-agent: WebImageCollector\nDisallow:/\nUser-agent: WebLeacher\nDisallow:/\nUser-agent: WebPix\nDisallow:/\nUser-agent: WebReaper\nDisallow:/\nUser-agent: WebSauger\nDisallow:/\nUser-agent: WebStripper\nDisallow:/\nUser-agent: WebSucker\nDisallow:/\nUser-agent: WebWhacker\nDisallow:/\nUser-agent: WebZIP\nDisallow:/\nUser-agent: Web Auto\nDisallow:/\nUser-agent: Web Collage\nDisallow:/\nUser-agent: Web Enhancer\nDisallow:/\nUser-agent: Web Fetch\nDisallow:/\nUser-agent: Web Fuck\nDisallow:/\nUser-agent: Web Pix\nDisallow:/\nUser-agent: Web Sauger\nDisallow:/\nUser-agent: Web Sucker\nDisallow:/\nUser-agent: Webalta\nDisallow:/\nUser-agent: WebmasterWorldForumBot\nDisallow:/\nUser-agent: Webshag\nDisallow:/\nUser-agent: WebsiteExtractor\nDisallow:/\nUser-agent: WebsiteQuester\nDisallow:/\nUser-agent: Website Quester\nDisallow:/\nUser-agent: Webster\nDisallow:/\nUser-agent: Whack\nDisallow:/\nUser-agent: Whacker\nDisallow:/\nUser-agent: Whatweb\nDisallow:/\nUser-agent: Who.is Bot\nDisallow:/\nUser-agent: Widow\nDisallow:/\nUser-agent: WinHTTrack\nDisallow:/\nUser-agent: WiseGuys Robot\nDisallow:/\nUser-agent: Wonderbot\nDisallow:/\nUser-agent: Woobot\nDisallow:/\nUser-agent: Wotbox\nDisallow:/\nUser-agent: Wprecon\nDisallow:/\nUser-agent: Xaldon WebSpider\nDisallow:/\nUser-agent: Xaldon_WebSpider\nDisallow:/\nUser-agent: Xenu\nDisallow:/\nUser-agent: YaK\nDisallow:/\nUser-agent: YoudaoBot\nDisallow:/\nUser-agent: Zade\nDisallow:/\nUser-agent: Zauba\nDisallow:/\nUser-agent: Zermelo\nDisallow:/\nUser-agent: Zeus\nDisallow:/\nUser-agent: Zitebot\nDisallow:/\nUser-agent: ZmEu\nDisallow:/\nUser-agent: ZoomBot\nDisallow:/\nUser-agent: ZoominfoBot\nDisallow:/\nUser-agent: ZumBot\nDisallow:/\nUser-agent: ZyBorg\nDisallow:/\nUser-agent: adscanner\nDisallow:/\nUser-agent: allenai.org\nDisallow:/\nUser-agent: anthropic-ai\nDisallow:/\nUser-agent: archive.org_bot\nDisallow:/\nUser-agent: arquivo-web-crawler\nDisallow:/\nUser-agent: arquivo.pt\nDisallow:/\nUser-agent: autoemailspider\nDisallow:/\nUser-agent: awario.com\nDisallow:/\nUser-agent: backlink-check\nDisallow:/\nUser-agent: cah.io.community\nDisallow:/\nUser-agent: check1.exe\nDisallow:/\nUser-agent: clark-crawler\nDisallow:/\nUser-agent: coccocbot\nDisallow:/\nUser-agent: cognitiveseo\nDisallow:/\nUser-agent: cohere-ai\nDisallow:/\nUser-agent: com.plumanalytics\nDisallow:/\nUser-agent: crawl.sogou.com\nDisallow:/\nUser-agent: crawler.feedback\nDisallow:/\nUser-agent: crawler4j\nDisallow:/\nUser-agent: dataforseo.com\nDisallow:/\nUser-agent: dataforseobot\nDisallow:/\nUser-agent: dataprovider\nDisallow:/\nUser-agent: demandbase-bot\nDisallow:/\nUser-agent: domainsproject.org\nDisallow:/\nUser-agent: eCatch\nDisallow:/\nUser-agent: evc-batch\nDisallow:/\nUser-agent: everyfeed-spider\nDisallow:/\nUser-agent: facebookscraper\nDisallow:/\nUser-agent: gopher\nDisallow:/\nUser-agent: heritrix\nDisallow:/\nUser-agent: imagesift.com\nDisallow:/\nUser-agent: instabid\nDisallow:/\nUser-agent: internetVista monitor\nDisallow:/\nUser-agent: ips-agent\nDisallow:/\nUser-agent: isitwp.com\nDisallow:/\nUser-agent: iubenda-radar\nDisallow:/\nUser-agent: linkdexbot\nDisallow:/\nUser-agent: linkfluence\nDisallow:/\nUser-agent: lwp-request\nDisallow:/\nUser-agent: lwp-trivial\nDisallow:/\nUser-agent: magpie-crawler\nDisallow:/\nUser-agent: meanpathbot\nDisallow:/\nUser-agent: mediawords\nDisallow:/\nUser-agent: muhstik-scan\nDisallow:/\nUser-agent: netEstate NE Crawler\nDisallow:/\nUser-agent: oBot\nDisallow:/\nUser-agent: omgili\nDisallow:/\nUser-agent: openai\nDisallow:/\nUser-agent: openai.com\nDisallow:/\nUser-agent: page scorer\nDisallow:/\nUser-agent: pcBrowser\nDisallow:/\nUser-agent: plumanalytics\nDisallow:/\nUser-agent: polaris version\nDisallow:/\nUser-agent: probe-image-size\nDisallow:/\nUser-agent: ripz\nDisallow:/\nUser-agent: s1z.ru\nDisallow:/\nUser-agent: satoristudio.net\nDisallow:/\nUser-agent: scalaj-http\nDisallow:/\nUser-agent: scan.lol\nDisallow:/\nUser-agent: seobility\nDisallow:/\nUser-agent: seocompany.store\nDisallow:/\nUser-agent: seoscanners\nDisallow:/\nUser-agent: seostar\nDisallow:/\nUser-agent: serpstatbot\nDisallow:/\nUser-agent: sexsearcher\nDisallow:/\nUser-agent: sitechecker.pro\nDisallow:/\nUser-agent: siteripz\nDisallow:/\nUser-agent: sogouspider\nDisallow:/\nUser-agent: sp_auditbot\nDisallow:/\nUser-agent: spyfu\nDisallow:/\nUser-agent: sysscan\nDisallow:/\nUser-agent: tAkeOut\nDisallow:/\nUser-agent: trendiction.com\nDisallow:/\nUser-agent: trendiction.de\nDisallow:/\nUser-agent: ubermetrics-technologies.com\nDisallow:/\nUser-agent: voyagerx.com\nDisallow:/\nUser-agent: webgains-bot\nDisallow:/\nUser-agent: webmeup-crawler\nDisallow:/\nUser-agent: webpros.com\nDisallow:/\nUser-agent: webprosbot\nDisallow:/\nUser-agent: x09Mozilla\nDisallow:/\nUser-agent: x22Mozilla\nDisallow:/\nUser-agent: xpymep1.exe\nDisallow:/\nUser-agent: zauba.io\nDisallow:/\nUser-agent: zgrab\nDisallow:/\n\n# Extra bad bots\nUser-agent: ChatGPT Agent\nDisallow: /\nUser-agent: Claude-SearchBot\nDisallow: /\nUser-agent: Claude-User\nDisallow: /\nUser-agent: Claude-Web\nDisallow: /\nUser-agent: Cloudflare-AutoRAG\nDisallow: /\nUser-agent: CloudVertexBot\nDisallow: /\nUser-agent: cohere-ai\nDisallow: /\nUser-agent: cohere-training-data-crawler\nDisallow: /\nUser-agent: Google-CloudVertexBot\nDisallow: /\nUser-agent: Google-Extended\nDisallow: /\nUser-agent: Google-Firebase\nDisallow: /\nUser-agent: Google-NotebookLM\nDisallow: /\nUser-agent: GoogleAgent-Mariner\nDisallow: /\nUser-agent: Perplexity-User\nDisallow: /\nUser-agent: PerplexityBot\nDisallow: /\nUser-agent: OmigiliBot\nDisallow: /\nUser-agent: Omigili\nDisallow: /\nUser-agent: Diffbot\nDisallow: /\nUser-agent: Amazonbot\nDisallow: /\nUser-agent: Timpibot\nDisallow: /\nUser-agent: meta-webindexer\nDisallow: /\nUser-agent: MistralAI-User\nDisallow: /\nUser-agent: MistralAI-User/1.0\nDisallow: /\n\n# Rest of indexing robots\nUser-agent: *\nRequest-rate: 1/1s\nDisallow: /login\nDisallow: /login*\nDisallow: /register\nDisallow: /register*\nDisallow: /reset-password\nDisallow: /reset-password*\nDisallow: /sub\nDisallow: /mod\nDisallow: /fav\nDisallow: /new\nDisallow: /new/*\nDisallow: /newMagazine\nDisallow: /terms\nDisallow: /privacy-policy\nDisallow: /search\n\n# Voting, Boosting, Reporting\nDisallow /er/*\nDisallow /ev/*\nDisallow /ef/*\nDisallow /eb/*\nDisallow /ecr/*\nDisallow /ecv/*\nDisallow /ecf/*\nDisallow /ecb/*\nDisallow /pr/*\nDisallow /pv/*\nDisallow /pf/*\nDisallow /pb/*\nDisallow /pcr/*\nDisallow /pcv/*\nDisallow /pcf/*\nDisallow /pcb/*\n\n# Crossposting\nDisallow /crosspost/*\n"
  },
  {
    "path": "public/sw.js",
    "content": "importScripts(\n    'https://storage.googleapis.com/workbox-cdn/releases/6.5.4/workbox-sw.js'\n);\n\nconsole.log(\"ServiceWorker is running\")\nself.addEventListener(\"push\", (e) => {\n    /** @var {PushMessageData} data */\n    const data = e.data\n    const json = data.json()\n    console.log(\"received push notification\", json)\n    const promiseChain = self.registration.showNotification(json.title, { body: json.message, data: json, icon: json.avatarUrl ?? json.iconUrl, badge: json.badgeUrl })\n\n    e.waitUntil(promiseChain);\n})\n\nself.addEventListener(\"notificationclick\", (/** @var {NotificationEvent} event */ event) => {\n    let n = event.notification\n    console.log(\"clicked on notification\", event)\n    if (!event.action || event.action === \"\") {\n        const url = n.data.actionUrl\n        if (url) {\n            const promiseChain = self.clients.matchAll({type: \"window\"})\n                .then((clientList) => {\n                    if (clientList.length > 0) {\n                        const client = clientList.at(0)\n                        console.log(\"got a windowclient\", client)\n                        return client.navigate(url)\n                            .then(client => {\n                                console.log(\"navigated to url\", url)\n                                if (client && client.focus) {\n                                    console.log(\"focusing to client\")\n                                    return client.focus();\n                                }\n                            })\n                    }\n                    if (self.clients.openWindow) {\n                        console.log(\"opening new window\")\n                        return self.clients.openWindow(url);\n                    }\n                })\n\n            event.waitUntil(promiseChain)\n        }\n    }\n    n.close()\n})\n\nself.addEventListener('install', (event) => {\n    console.log('Inside the install handler:', event);\n    event.waitUntil(self.skipWaiting())\n});\n\nself.addEventListener('activate', (event) => {\n    console.log('Inside the activate handler:', event);\n});\n\n\n// This is your Service Worker, you can put any of your custom Service Worker\n// code in this file, above the `precacheAndRoute` line.\n\nworkbox.precaching.precacheAndRoute(self.__WB_MANIFEST || []);\n"
  },
  {
    "path": "src/ActivityPub/ActorHandle.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\ActivityPub;\n\nclass ActorHandle\n{\n    public const HANDLE_PATTERN = '/^(?P<prefix>[@!])?(?P<name>[\\w\\-\\.]+)@(?P<host>[\\w\\.\\-]+)(?P<port>:[\\d]+)?$/';\n\n    public function __construct(\n        public ?string $prefix = null,\n        public ?string $name = null,\n        public ?string $host = null,\n        public ?int $port = null,\n    ) {\n    }\n\n    public function __toString(): string\n    {\n        return $this->formatWithPrefix($this->prefix);\n    }\n\n    public static function parse(string $handle): ?static\n    {\n        if (preg_match(static::HANDLE_PATTERN, $handle, $match)) {\n            $new = new static(\n                $match['prefix'] ?? null,\n                $match['name'],\n                $match['host']\n            );\n            $new->setPort($match['port'] ?? null);\n\n            return $new;\n        }\n\n        return null;\n    }\n\n    public static function isHandle(string $handle): bool\n    {\n        if (preg_match(static::HANDLE_PATTERN, $handle, $matches)) {\n            return !empty($matches['name']) && !empty($matches['host']);\n        }\n\n        return false;\n    }\n\n    public function isValid(): bool\n    {\n        return static::isHandle((string) $this);\n    }\n\n    /** @return string port as string in the format ':9000' or empty string if it's null */\n    public function getPortString(): string\n    {\n        return !empty($this->port) ? ':'.$this->port : '';\n    }\n\n    /** @param int|string|null $port port as either plain int or string formatted like ':9000' */\n    public function setPort(int|string|null $port): static\n    {\n        if (\\is_string($port)) {\n            $this->port = \\intval(ltrim($port, ':'));\n        } else {\n            $this->port = $port;\n        }\n\n        return $this;\n    }\n\n    /** @return string the handle's domain and optionally port in the format `host[:port]` */\n    public function getDomain(): string\n    {\n        return $this->host.$this->getPortString();\n    }\n\n    /** @param ?string $domain the domain in the format `host[:port]` to set both handle's host and port */\n    public function setDomain(?string $domain): static\n    {\n        $url = parse_url($domain ?? '');\n\n        $this->host = $url['host'] ?? null;\n        $this->port = $url['port'] ?? null;\n\n        return $this;\n    }\n\n    public function formatWithPrefix(?string $prefix): string\n    {\n        return \"{$prefix}{$this->name}@{$this->getDomain()}\";\n    }\n\n    /** @return string handle in the form `name@domain` */\n    public function plainHandle(): string\n    {\n        return $this->formatWithPrefix('');\n    }\n\n    /** @return string handle in the form `@name@domain` */\n    public function atHandle(): string\n    {\n        return $this->formatWithPrefix('@');\n    }\n\n    /** @return string handle in the form `!name@domain` */\n    public function bangHandle(): string\n    {\n        return $this->formatWithPrefix('!');\n    }\n}\n"
  },
  {
    "path": "src/ActivityPub/JsonRd.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\ActivityPub;\n\n/**\n * Class JsonRd.\n *\n * The JSON Resource Descriptor (JRD), originally introduced in RFC 6415\n * [https://tools.ietf.org/html/rfc7033#ref-16] and based on the Extensible\n * Resource Descriptor (XRD) format\n * [https://tools.ietf.org/html/rfc7033#ref-17], is a JSON object that\n * comprises the following name/value pairs:\n *\n * - subject\n * - aliases\n * - properties\n * - links\n *\n * The member \"subject\" is a name/value pair whose value is a string,\n * \"aliases\" is an array of strings, \"properties\" is an object\n * comprising name/value pairs whose values are strings, and \"links\" is\n * an array of objects that contain link relation information.\n *\n * When processing a JRD, the client MUST ignore any unknown member and\n * not treat the presence of an unknown member as an error.\n *\n * Forked from https://github.com/delirehberi/webfinger,\n *\n * @see https://github.com/delirehberi/webfinger/blob/master/src/JsonRD.php\n */\nclass JsonRd\n{\n    /**\n     * The value of the \"subject\" member is a URI that identifies the entity\n     * that the JRD describes.\n     *\n     * The \"subject\" value returned by a WebFinger resource MAY differ from\n     * the value of the \"resource\" parameter used in the client's request.\n     * This might happen, for example, when the subject's identity changes\n     * (e.g., a user moves his or her account to another service) or when\n     * the resource prefers to express URIs in canonical form.\n     *\n     * The \"subject\" member SHOULD be present in the JRD.\n     *\n     * @var string\n     */\n    protected $subject = '';\n\n    /**\n     * The \"aliases\" array is an array of zero or more URI strings that\n     * identify the same entity as the \"subject\" URI.\n     * The \"aliases\" array is OPTIONAL in the JRD.\n     *\n     * @var string[]\n     */\n    protected $aliases = [];\n\n    /**\n     * The \"properties\" object comprises zero or more name/value pairs whose\n     * names are URIs (referred to as \"property identifiers\") and whose\n     * values are strings or null.  Properties are used to convey additional\n     * information about the subject of the JRD.  As an example, consider\n     * this use of \"properties\":.\n     *\n     * \"properties\" : { \"http://webfinger.example/ns/name\" : \"Bob Smith\" }\n     *\n     * The \"properties\" member is OPTIONAL in the JRD.\n     *\n     * @var array<string, string>\n     */\n    protected $properties = [];\n\n    /**\n     * The \"links\" array has any number of member objects, each of which\n     * represents a link [4].\n     *\n     * @var JsonRdLink[]\n     */\n    protected $links = [];\n\n    public function addAlias(string $uri): static\n    {\n        array_push($this->aliases, $uri);\n\n        return $this;\n    }\n\n    public function removeAlias(string $uri): static\n    {\n        $key = array_search($uri, $this->aliases);\n        if (false !== $key) {\n            unset($this->aliases[$key]);\n        }\n\n        return $this;\n    }\n\n    public function addProperty(string $uri, ?string $value = null): static\n    {\n        $this->properties[$uri] = $value;\n\n        return $this;\n    }\n\n    public function removeProperty(string $uri): static\n    {\n        if (!\\array_key_exists($uri, $this->properties)) {\n            return $this;\n        }\n        unset($this->properties[$uri]);\n\n        return $this;\n    }\n\n    public function addLink(JsonRdLink $link): static\n    {\n        array_push($this->links, $link);\n\n        return $this;\n    }\n\n    public function removeLink(JsonRdLink $link): static\n    {\n        $serialized_link = serialize($link);\n        foreach ($this->links as $key => $_link) {\n            $_serialized_link = serialize($_link);\n            if ($_serialized_link === $serialized_link) {\n                unset($this->links[$key]);\n                break;\n            }\n        }\n\n        return $this;\n    }\n\n    public function toJSON(): string\n    {\n        return json_encode($this->toArray());\n    }\n\n    /**\n     * @return array<string, mixed>\n     */\n    public function toArray(): array\n    {\n        $data = [];\n        if (!empty($this->getSubject())) {\n            $data['subject'] = $this->getSubject();\n        }\n        !empty($this->getAliases()) && $data['aliases'] = $this->getAliases();\n        !empty($this->getLinks()) && $data['links'] = array_map(function (JsonRdLink $jsonRdLink) {\n            return $jsonRdLink->toArray();\n        }, $this->getLinks());\n        !empty($this->getProperties()) && $data['properties'] = $this->getProperties();\n\n        return $data;\n    }\n\n    /**\n     * @return string\n     */\n    public function getSubject()\n    {\n        return $this->subject;\n    }\n\n    public function setSubject(string $subject): static\n    {\n        $this->subject = $subject;\n\n        return $this;\n    }\n\n    /**\n     * @return string[]\n     */\n    public function getAliases(): array\n    {\n        return $this->aliases;\n    }\n\n    /**\n     * @param string[] $aliases\n     */\n    protected function setAliases(array $aliases): static\n    {\n        $this->aliases = $aliases;\n\n        return $this;\n    }\n\n    /**\n     * @return JsonRdLink[]\n     */\n    public function getLinks(): array\n    {\n        return $this->links;\n    }\n\n    /**\n     * @param JsonRdLink[] $links\n     */\n    protected function setLinks(array $links): static\n    {\n        $this->links = $links;\n\n        return $this;\n    }\n\n    /**\n     * @return array<string, string>\n     */\n    public function getProperties(): array\n    {\n        return $this->properties;\n    }\n\n    /**\n     * @param array<string, string> $properties\n     */\n    protected function setProperties(array $properties): static\n    {\n        $this->properties = $properties;\n\n        return $this;\n    }\n}\n"
  },
  {
    "path": "src/ActivityPub/JsonRdLink.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\ActivityPub;\n\n/**\n * Class JsonRDLinks.\n *\n * The \"links\" array has any number of member objects, each of which\n * represents a link [https://tools.ietf.org/html/rfc7033#ref-4].  Each of these link objects can have the\n * following members:\n *\n * o rel\n * o type\n * o href\n * o titles\n * o properties\n *\n * The \"rel\" and \"href\" members are strings representing the link's\n * relation type and the target URI, respectively.  The context of the\n * link is the \"subject\" (see Section 4.4.1).\n *\n * The \"type\" member is a string indicating what the media type of the\n * result of dereferencing the link ought to be.\n *\n * The order of elements in the \"links\" array MAY be interpreted as\n * indicating an order of preference.  Thus, if there are two or more\n * link relations having the same \"rel\" value, the first link relation\n * would indicate the user's preferred link.\n *\n * The \"links\" array is OPTIONAL in the JRD.\n *\n * Below, each of the members of the objects found in the \"links\" array\n * is described in more detail.  Each object in the \"links\" array,\n * referred to as a \"link relation object\", is completely independent\n * from any other object in the array; any requirement to include a\n * given member in the link relation object refers only to that\n * particular object.\n *\n * Forked from https://github.com/delirehberi/webfinger,\n *\n * @see https://github.com/delirehberi/webfinger/blob/master/src/JsonRDLink.php\n */\nclass JsonRdLink\n{\n    /**\n     * Link Relation Types\n     * Registration Procedure(s)\n     * Specification Required\n     * Expert(s)\n     * Mark Nottingham, Julian Reschke, Jan Algermissen\n     * Reference\n     * [http://www.iana.org/go/rfc8288]\n     * Note\n     * New link relations, along with changes to existing relations, can be requested\n     * using the [https://github.com/link-relations/registry] or the mailing list defined in [RFC8288].\n     */\n    public const REGISTERED_RELATION_TYPES = [\n        'about',\n        'alternate',\n        'appendix',\n        'archives',\n        'author',\n        'blocked-by',\n        'bookmark',\n        'canonical',\n        'chapter',\n        'cite-as',\n        'collection',\n        'contents',\n        'convertedFrom',\n        'copyright',\n        'create-form',\n        'current',\n        'describedby',\n        'describes',\n        'disclosure',\n        'dns-prefetch',\n        'duplicate',\n        'edit',\n        'edit-form',\n        'edit-media',\n        'enclosure',\n        'first',\n        'glossary',\n        'help',\n        'hosts',\n        'hub',\n        'icon',\n        'index',\n        'item',\n        'last',\n        'latest-version',\n        'license',\n        'lrdd',\n        'memento',\n        'monitor',\n        'monitor-group',\n        'next',\n        'next-archive',\n        'nofollow',\n        'noreferrer',\n        'original',\n        'payment',\n        'pingback',\n        'preconnect',\n        'predecessor-version',\n        'prefetch',\n        'preload',\n        'prerender',\n        'prev',\n        'preview',\n        'previous',\n        'prev-archive',\n        'privacy-policy',\n        'profile',\n        'related',\n        'restconf',\n        'replies',\n        'search',\n        'section',\n        'self',\n        'service',\n        'start',\n        'stylesheet',\n        'subsection',\n        'successor-version',\n        'tag',\n        'terms-of-service',\n        'timegate',\n        'timemap',\n        'type',\n        'up',\n        'version-history',\n        'via',\n        'webmention',\n        'working-copy',\n        'working-copy-of',\n    ];\n\n    /**\n     * The value of the \"rel\" member is a string that is either a URI or a\n     * registered relation type [https://tools.ietf.org/html/rfc7033#ref-8]\n     * (see RFC 5988 [https://tools.ietf.org/html/rfc7033#ref-4]).  The value\n     * of the \"rel\" member MUST contain exactly one URI or registered relation\n     * type. The URI or registered relation type identifies the type of the\n     * link relation.\n     *\n     * The other members of the object have meaning only once the type of\n     * link relation is understood.  In some instances, the link relation\n     * will have associated semantics enabling the client to query for other\n     * resources on the Internet.  In other instances, the link relation\n     * will have associated semantics enabling the client to utilize the\n     * other members of the link relation object without fetching additional\n     * external resources.\n     *\n     * URI link relation type values are compared using the \"Simple String\n     * Comparison\" algorithm of Section 6.2.1 of RFC 3986.\n     *\n     * The \"rel\" member MUST be present in the link relation object.\n     *\n     * @var string\n     */\n    protected $rel = '';\n    /**\n     * The value of the \"type\" member is a string that indicates the media\n     * type [https://tools.ietf.org/html/rfc7033#ref-9] of the target resource (see RFC 6838\n     * [https://tools.ietf.org/html/rfc7033#ref-10]).\n     * The \"type\" member is OPTIONAL in the link relation object.\n     *\n     * @var string\n     */\n    protected $type = '';\n\n    /**\n     * The value of the \"href\" member is a string that contains a URI\n     * pointing to the target resource.\n     * The \"href\" member is OPTIONAL in the link relation object.\n     *\n     * @var string\n     */\n    protected $href;\n\n    /**\n     * The \"titles\" object comprises zero or more name/value pairs whose\n     * names are a language tag [11] or the string \"und\".  The string is\n     * human-readable and describes the link relation.  More than one title\n     * for the link relation MAY be provided for the benefit of users who\n     * utilize the link relation, and, if used, a language identifier SHOULD\n     * be duly used as the name.  If the language is unknown or unspecified,\n     * then the name is \"und\".\n     *\n     * A JRD SHOULD NOT include more than one title identified with the same\n     * language tag (or \"und\") within the link relation object.  Meaning is\n     * undefined if a link relation object includes more than one title\n     * named with the same language tag (or \"und\"), though this MUST NOT be\n     * treated as an error.  A client MAY select whichever title or titles\n     * it wishes to utilize.\n     *\n     * Here is an example of the \"titles\" object:\n     *\n     * \"titles\" :\n     *   {\n     *   \"en-us\" : \"The Magical World of Steve\",\n     *   \"fr\" : \"Le Monde Magique de Steve\"\n     *   }\n     *\n     * The \"titles\" member is OPTIONAL in the link relation object.\n     *\n     * @var array<string, string>\n     */\n    protected $titles = [];\n\n    /**\n     * The \"properties\" object within the link relation object comprises\n     * zero or more name/value pairs whose names are URIs (referred to as\n     * \"property identifiers\") and whose values are strings or null.\n     * Properties are used to convey additional information about the link\n     * relation.  As an example, consider this use of \"properties\":.\n     *\n     * \"properties\" : { \"http://webfinger.example/mail/port\" : \"993\" }\n     *\n     * The \"properties\" member is OPTIONAL in the link relation object.\n     *\n     * @var array<string, string>\n     */\n    protected $properties = [];\n\n    public function addTitle(string $locale, string $value): static\n    {\n        if (!\\array_key_exists($locale, $this->titles)) {\n            $this->titles[$locale] = $value;\n        }\n\n        return $this;\n    }\n\n    public function removeTitle(string $locale): static\n    {\n        if (!\\array_key_exists($locale, $this->titles)) {\n            return $this;\n        }\n        unset($this->titles[$locale]);\n\n        return $this;\n    }\n\n    public function addProperty(string $url, string $value): static\n    {\n        $this->properties[$url] = $value;\n\n        return $this;\n    }\n\n    public function removeProperty(string $url): static\n    {\n        if (!\\array_key_exists($url, $this->properties)) {\n            return $this;\n        }\n        unset($this->properties[$url]);\n\n        return $this;\n    }\n\n    /**\n     * @return array<string, mixed>\n     */\n    public function toArray(): array\n    {\n        $data = [];\n        $data['rel'] = $this->getRel();\n        $data['href'] = $this->getHref();\n\n        !empty($this->getType()) && $data['type'] = $this->getType();\n        !empty($this->getTitles()) && $data['titles'] = $this->getTitles();\n        !empty($this->getProperties()) && $data['properties'] = $this->getProperties();\n\n        return $data;\n    }\n\n    public function getRel(): string\n    {\n        return $this->rel;\n    }\n\n    /**\n     * @throws \\Exception\n     */\n    public function setRel(string $relation): static\n    {\n        if (\\in_array($relation, self::REGISTERED_RELATION_TYPES)) {\n            $this->rel = $relation;\n\n            return $this;\n        }\n        preg_match(\"/^http(s)?\\:\\/\\/[a-z]+\\.[a-z]+/\", $relation, $match);\n        if (isset($match[0]) && !empty($match[0])) {\n            $this->rel = $relation;\n\n            return $this;\n        }\n        throw new \\Exception('The value of the `rel` member MUST contain exactly one URI or registered relation type.');\n    }\n\n    public function getHref(): ?string\n    {\n        return $this->href;\n    }\n\n    /**\n     * @todo we need to write for url validation for $href argument.\n     */\n    public function setHref(string $href): static\n    {\n        $this->href = $href;\n\n        return $this;\n    }\n\n    public function getType(): ?string\n    {\n        return $this->type;\n    }\n\n    public function setType(string $type): static\n    {\n        $this->type = $type;\n\n        return $this;\n    }\n\n    /**\n     * @return array<string, string>\n     */\n    public function getTitles(): array\n    {\n        return $this->titles;\n    }\n\n    /**\n     * @param array<string, string> $titles\n     */\n    protected function setTitles(array $titles): static\n    {\n        $this->titles = $titles;\n\n        return $this;\n    }\n\n    /**\n     * @return array<string, string>\n     */\n    public function getProperties(): array\n    {\n        return $this->properties;\n    }\n\n    /**\n     * @param array<string, string> $properties\n     */\n    protected function setProperties(array $properties): static\n    {\n        $this->properties = $properties;\n\n        return $this;\n    }\n}\n"
  },
  {
    "path": "src/ArgumentValueResolver/FavouriteResolver.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\ArgumentValueResolver;\n\nuse App\\Entity\\Contracts\\FavouriteInterface;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpKernel\\Controller\\ValueResolverInterface;\nuse Symfony\\Component\\HttpKernel\\ControllerMetadata\\ArgumentMetadata;\n\nclass FavouriteResolver implements ValueResolverInterface\n{\n    public function __construct(private readonly EntityManagerInterface $entityManager)\n    {\n    }\n\n    public function resolve(Request $request, ArgumentMetadata $argument): \\Generator\n    {\n        if (\n            FavouriteInterface::class === $argument->getType()\n            && !$argument->isVariadic()\n            && is_a($request->attributes->get('entityClass'), FavouriteInterface::class, true)\n            && $request->attributes->has('id')\n        ) {\n            ['id' => $id, 'entityClass' => $entityClass] = $request->attributes->all();\n\n            /** @var class-string<FavouriteInterface> $entityClass */\n            yield $this->entityManager->find($entityClass, $id);\n        }\n    }\n}\n"
  },
  {
    "path": "src/ArgumentValueResolver/MagazineResolver.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\ArgumentValueResolver;\n\nuse App\\Entity\\Magazine;\nuse App\\Repository\\MagazineRepository;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpKernel\\Controller\\ValueResolverInterface;\nuse Symfony\\Component\\HttpKernel\\ControllerMetadata\\ArgumentMetadata;\n\nclass MagazineResolver implements ValueResolverInterface\n{\n    public function __construct(private readonly MagazineRepository $repository)\n    {\n    }\n\n    public function resolve(Request $request, ArgumentMetadata $argument): \\Generator\n    {\n        if (Magazine::class !== $argument->getType()) {\n            return;\n        }\n\n        $magazineName = $request->attributes->get('magazine_name') ?? $request->attributes->get('name');\n\n        yield $this->repository->findOneByName($magazineName);\n    }\n}\n"
  },
  {
    "path": "src/ArgumentValueResolver/ReportResolver.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\ArgumentValueResolver;\n\nuse App\\Entity\\Contracts\\ReportInterface;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpKernel\\Controller\\ValueResolverInterface;\nuse Symfony\\Component\\HttpKernel\\ControllerMetadata\\ArgumentMetadata;\n\nclass ReportResolver implements ValueResolverInterface\n{\n    public function __construct(private readonly EntityManagerInterface $entityManager)\n    {\n    }\n\n    public function resolve(Request $request, ArgumentMetadata $argument): \\Generator\n    {\n        if (\n            ReportInterface::class === $argument->getType()\n            && !$argument->isVariadic()\n            && is_a($request->attributes->get('entityClass'), ReportInterface::class, true)\n            && $request->attributes->has('id')\n        ) {\n            ['id' => $id, 'entityClass' => $entityClass] = $request->attributes->all();\n\n            /** @var class-string<ReportInterface> $entityClass */\n            yield $this->entityManager->find($entityClass, $id);\n        }\n    }\n}\n"
  },
  {
    "path": "src/ArgumentValueResolver/UserResolver.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\ArgumentValueResolver;\n\nuse App\\Entity\\User;\nuse App\\Repository\\UserRepository;\nuse App\\Service\\ActivityPubManager;\nuse App\\Service\\SettingsManager;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpKernel\\Controller\\ValueResolverInterface;\nuse Symfony\\Component\\HttpKernel\\ControllerMetadata\\ArgumentMetadata;\nuse Symfony\\Component\\HttpKernel\\Exception\\NotFoundHttpException;\n\nclass UserResolver implements ValueResolverInterface\n{\n    public function __construct(\n        private readonly UserRepository $repository,\n        private readonly ActivityPubManager $activityPubManager,\n        private readonly SettingsManager $settingsManager,\n    ) {\n    }\n\n    public function resolve(Request $request, ArgumentMetadata $argument): \\Generator\n    {\n        if (User::class !== $argument->getType()) {\n            return;\n        }\n\n        if (!$username = $request->attributes->get('username') ?? $request->attributes->get('user')) {\n            return;\n        }\n\n        // @todo case-insensitive\n        if (!$user = $this->repository->findOneByUsername($username)) {\n            if (str_ends_with($username, '@'.$this->settingsManager->get('KBIN_DOMAIN'))) {\n                $username = ltrim($username, '@');\n                $username = str_replace('@'.$this->settingsManager->get('KBIN_DOMAIN'), '', $username);\n                $user = $this->repository->findOneByUsername($username);\n            }\n\n            if (!$user && substr_count($username, '@') > 1) {\n                try {\n                    $user = $this->activityPubManager->findActorOrCreate($username);\n                } catch (\\Exception $e) {\n                    $user = null;\n                }\n            }\n        }\n\n        if (!$user instanceof User) {\n            throw new NotFoundHttpException();\n        }\n\n        yield $user;\n    }\n}\n"
  },
  {
    "path": "src/ArgumentValueResolver/VotableResolver.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\ArgumentValueResolver;\n\nuse App\\Entity\\Contracts\\VotableInterface;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpKernel\\Controller\\ValueResolverInterface;\nuse Symfony\\Component\\HttpKernel\\ControllerMetadata\\ArgumentMetadata;\n\nclass VotableResolver implements ValueResolverInterface\n{\n    public function __construct(private readonly EntityManagerInterface $entityManager)\n    {\n    }\n\n    public function resolve(Request $request, ArgumentMetadata $argument): \\Generator\n    {\n        if (\n            VotableInterface::class === $argument->getType()\n            && !$argument->isVariadic()\n            && is_a($request->attributes->get('entityClass'), VotableInterface::class, true)\n            && $request->attributes->has('id')\n        ) {\n            ['id' => $id, 'entityClass' => $entityClass] = $request->attributes->all();\n\n            /** @var class-string<VotableInterface> $entityClass */\n            yield $this->entityManager->find($entityClass, $id);\n        }\n    }\n}\n"
  },
  {
    "path": "src/Command/ActorUpdateCommand.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Command;\n\nuse App\\Message\\ActivityPub\\UpdateActorMessage;\nuse App\\Repository\\MagazineRepository;\nuse App\\Repository\\UserRepository;\nuse Symfony\\Component\\Console\\Attribute\\AsCommand;\nuse Symfony\\Component\\Console\\Command\\Command;\nuse Symfony\\Component\\Console\\Input\\InputArgument;\nuse Symfony\\Component\\Console\\Input\\InputInterface;\nuse Symfony\\Component\\Console\\Input\\InputOption;\nuse Symfony\\Component\\Console\\Output\\OutputInterface;\nuse Symfony\\Component\\Console\\Style\\SymfonyStyle;\nuse Symfony\\Component\\Messenger\\MessageBusInterface;\nuse Symfony\\Component\\Messenger\\Stamp\\TransportNamesStamp;\n\n#[AsCommand(\n    name: 'mbin:actor:update',\n    description: 'This command will allow you to update remote actor (user/magazine) info.',\n)]\nclass ActorUpdateCommand extends Command\n{\n    public function __construct(\n        private readonly UserRepository $repository,\n        private readonly MagazineRepository $magazineRepository,\n        private readonly MessageBusInterface $bus,\n    ) {\n        parent::__construct();\n    }\n\n    protected function configure(): void\n    {\n        $this->addArgument('user', InputArgument::OPTIONAL, 'AP url of the actor to update')\n            ->addOption('users', null, InputOption::VALUE_NONE, 'update *all* known users that needs updating')\n            ->addOption('magazines', null, InputOption::VALUE_NONE, 'update *all* known magazines that needs updating')\n            ->addOption('force', null, InputOption::VALUE_NONE, 'force actor update even if they are recently updated');\n    }\n\n    protected function execute(InputInterface $input, OutputInterface $output): int\n    {\n        $io = new SymfonyStyle($input, $output);\n        $userArg = $input->getArgument('user');\n        $force = (bool) $input->getOption('force');\n\n        if ($userArg) {\n            $this->bus->dispatch(new UpdateActorMessage($userArg, $force), [new TransportNamesStamp('sync')]);\n        } elseif ($input->getOption('users')) {\n            foreach ($this->repository->findRemoteForUpdate() as $u) {\n                $this->bus->dispatch(new UpdateActorMessage($u->apProfileId, $force), [new TransportNamesStamp('sync')]);\n                $io->info($u->username);\n            }\n        } elseif ($input->getOption('magazines')) {\n            foreach ($this->magazineRepository->findRemoteForUpdate() as $u) {\n                $this->bus->dispatch(new UpdateActorMessage($u->apProfileId, $force), [new TransportNamesStamp('sync')]);\n                $io->info($u->name);\n            }\n        }\n\n        $io->success('Done.');\n\n        return Command::SUCCESS;\n    }\n}\n"
  },
  {
    "path": "src/Command/AdminCommand.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Command;\n\nuse App\\Repository\\UserRepository;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Symfony\\Component\\Console\\Attribute\\AsCommand;\nuse Symfony\\Component\\Console\\Command\\Command;\nuse Symfony\\Component\\Console\\Input\\InputArgument;\nuse Symfony\\Component\\Console\\Input\\InputInterface;\nuse Symfony\\Component\\Console\\Input\\InputOption;\nuse Symfony\\Component\\Console\\Output\\OutputInterface;\nuse Symfony\\Component\\Console\\Style\\SymfonyStyle;\n\n#[AsCommand(\n    name: 'mbin:user:admin',\n    description: 'This command allows you to grant administrator privileges to the user.',\n)]\nclass AdminCommand extends Command\n{\n    public function __construct(\n        private readonly EntityManagerInterface $entityManager,\n        private readonly UserRepository $repository,\n    ) {\n        parent::__construct();\n    }\n\n    protected function configure(): void\n    {\n        $this\n            ->addArgument('username', InputArgument::REQUIRED)\n            ->addOption('remove', 'r', InputOption::VALUE_NONE, 'Remove privileges');\n    }\n\n    protected function execute(InputInterface $input, OutputInterface $output): int\n    {\n        $io = new SymfonyStyle($input, $output);\n        $remove = $input->getOption('remove');\n        $user = $this->repository->findOneByUsername($input->getArgument('username'));\n\n        if (!$user) {\n            $io->error('User not found.');\n\n            return Command::FAILURE;\n        }\n\n        $user->setOrRemoveAdminRole($remove);\n        $this->entityManager->flush();\n\n        $remove ? $io->success('Administrator privileges have been revoked.')\n            : $io->success('Administrator privileges have been granted.');\n\n        return Command::SUCCESS;\n    }\n}\n"
  },
  {
    "path": "src/Command/ApImportObject.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Command;\n\nuse App\\Message\\ActivityPub\\Inbox\\ActivityMessage;\nuse App\\Service\\ActivityPub\\ApHttpClientInterface;\nuse Symfony\\Component\\Console\\Attribute\\AsCommand;\nuse Symfony\\Component\\Console\\Command\\Command;\nuse Symfony\\Component\\Console\\Input\\InputArgument;\nuse Symfony\\Component\\Console\\Input\\InputInterface;\nuse Symfony\\Component\\Console\\Output\\OutputInterface;\nuse Symfony\\Component\\Messenger\\MessageBusInterface;\n\n#[AsCommand(\n    name: 'mbin:ap:import',\n    description: 'This command allows you to import an AP resource.'\n)]\nclass ApImportObject extends Command\n{\n    public function __construct(\n        private readonly MessageBusInterface $bus,\n        private readonly ApHttpClientInterface $client,\n    ) {\n        parent::__construct();\n    }\n\n    protected function configure(): void\n    {\n        $this->addArgument('url', InputArgument::REQUIRED);\n    }\n\n    protected function execute(InputInterface $input, OutputInterface $output): int\n    {\n        $body = $this->client->getActivityObject($input->getArgument('url'), false);\n\n        $this->bus->dispatch(new ActivityMessage($body));\n\n        return Command::SUCCESS;\n    }\n}\n"
  },
  {
    "path": "src/Command/AwesomeBot/AwesomeBotEntries.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Command\\AwesomeBot;\n\nuse App\\DTO\\EntryDto;\nuse App\\Repository\\EntryRepository;\nuse App\\Repository\\MagazineRepository;\nuse App\\Repository\\UserRepository;\nuse App\\Service\\EntryManager;\nuse Doctrine\\Common\\Collections\\ArrayCollection;\nuse Symfony\\Component\\BrowserKit\\HttpBrowser;\nuse Symfony\\Component\\Console\\Attribute\\AsCommand;\nuse Symfony\\Component\\Console\\Command\\Command;\nuse Symfony\\Component\\Console\\Input\\InputArgument;\nuse Symfony\\Component\\Console\\Input\\InputInterface;\nuse Symfony\\Component\\Console\\Output\\OutputInterface;\nuse Symfony\\Component\\Console\\Style\\SymfonyStyle;\nuse Symfony\\Component\\HttpClient\\HttpClient;\n\n#[AsCommand(name: 'mbin:awesome-bot:entries:create')]\nclass AwesomeBotEntries extends Command\n{\n    // bin/console mbin:user:create awesome-vue-bot awesome-vue-bot@karab.in awesome-vue-bot\n    // bin/console mbin:awesome-bot:magazine:create ernest vue Vue https://github.com/vuejs/awesome-vue h3\n    // bin/console mbin:awesome-bot:entries:create awesome-vue-bot vue https://github.com/vuejs/awesome-vue h3\n\n    public function __construct(\n        private readonly EntryManager $entryManager,\n        private readonly UserRepository $userRepository,\n        private readonly MagazineRepository $magazineRepository,\n        private readonly EntryRepository $entryRepository,\n    ) {\n        parent::__construct();\n    }\n\n    protected function configure(): void\n    {\n        $this\n            ->setDescription('This command allows you to create awesome-bot entries.')\n            ->addArgument('username', InputArgument::REQUIRED)\n            ->addArgument('magazine_name', InputArgument::REQUIRED)\n            ->addArgument('url', InputArgument::REQUIRED)\n            ->addArgument('tags', InputArgument::REQUIRED);\n    }\n\n    protected function execute(InputInterface $input, OutputInterface $output): int\n    {\n        $io = new SymfonyStyle($input, $output);\n\n        $user = $this->userRepository->findOneByUsername($input->getArgument('username'));\n        $magazine = $this->magazineRepository->findOneByName($input->getArgument('magazine_name'));\n\n        $tags = $input->getArgument('tags') ? explode(',', $input->getArgument('tags')) : [];\n\n        if (!$user) {\n            $io->error('User not exist.');\n\n            return Command::FAILURE;\n        } elseif (!$magazine) {\n            $io->error('Magazine not exist.');\n\n            return Command::FAILURE;\n        }\n\n        $browser = new HttpBrowser(HttpClient::create());\n        $crawler = $browser->request('GET', $input->getArgument('url'));\n\n        $content = $crawler->filter('.markdown-body')->first()->children();\n\n        $tags = array_flip($tags);\n        $result = [];\n        foreach ($content as $elem) {\n            if (\\array_key_exists($elem->nodeName, $tags)) {\n                $tags[$elem->nodeName] = $elem->nodeValue;\n            }\n\n            if ('ul' === $elem->nodeName) {\n                foreach ($elem->childNodes as $li) {\n                    /**\n                     * @var \\DOMElement $li\n                     */\n                    if ('li' !== $li->nodeName) {\n                        continue;\n                    }\n\n                    if ('a' !== $li->firstChild->nodeName) {\n                        continue;\n                    }\n\n                    $result[] = [\n                        'title' => $li->nodeValue,\n                        'url' => $li->firstChild->getAttribute('href'),\n                        'badges' => new ArrayCollection(array_filter($tags, fn ($v) => \\is_string($v))),\n                    ];\n                }\n            }\n        }\n\n        foreach ($result as $item) {\n            if (false === filter_var($item['url'], FILTER_VALIDATE_URL)) {\n                continue;\n            }\n\n            if ($this->entryRepository->findOneByUrl($item['url'])) {\n                continue;\n            }\n\n            $dto = new EntryDto();\n            $dto->magazine = $magazine;\n            $dto->user = $user;\n            $dto->title = substr($item['title'], 0, 255);\n            $dto->url = $item['url'];\n            $dto->badges = $item['badges'];\n\n            $this->entryManager->create($dto, $user);\n        }\n\n        return Command::SUCCESS;\n    }\n}\n"
  },
  {
    "path": "src/Command/AwesomeBot/AwesomeBotFixtures.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Command\\AwesomeBot;\n\nuse App\\DTO\\EntryDto;\nuse App\\Repository\\EntryRepository;\nuse App\\Repository\\MagazineRepository;\nuse App\\Repository\\UserRepository;\nuse App\\Service\\EntryManager;\nuse Doctrine\\Common\\Collections\\ArrayCollection;\nuse Symfony\\Component\\BrowserKit\\HttpBrowser;\nuse Symfony\\Component\\Console\\Attribute\\AsCommand;\nuse Symfony\\Component\\Console\\Command\\Command;\nuse Symfony\\Component\\Console\\Input\\ArrayInput;\nuse Symfony\\Component\\Console\\Input\\InputInterface;\nuse Symfony\\Component\\Console\\Input\\InputOption;\nuse Symfony\\Component\\Console\\Output\\OutputInterface;\nuse Symfony\\Component\\Console\\Style\\SymfonyStyle;\nuse Symfony\\Component\\HttpClient\\HttpClient;\n\n#[AsCommand(name: 'mbin:awesome-bot:fixtures:create')]\nclass AwesomeBotFixtures extends Command\n{\n    public function __construct(\n        private readonly EntryManager $entryManager,\n        private readonly UserRepository $userRepository,\n        private readonly MagazineRepository $magazineRepository,\n        private readonly EntryRepository $entryRepository,\n    ) {\n        parent::__construct();\n    }\n\n    protected function configure(): void\n    {\n        $this\n            ->addOption('prepare', null, InputOption::VALUE_OPTIONAL)\n            ->setDescription('This command allows you to create awesome-bot entries.');\n    }\n\n    protected function execute(InputInterface $input, OutputInterface $output): int\n    {\n        $io = new SymfonyStyle($input, $output);\n\n        /** @var array<string, mixed>[] */\n        $result = [];\n\n        foreach ($this->getEntries() as $entry) {\n            if ($input->getOption('prepare')) {\n                $this->prepareMagazines($output, $entry);\n                continue;\n            }\n\n            $user = $this->userRepository->findOneByUsername($entry['username']);\n            $magazine = $this->magazineRepository->findOneByName($entry['magazine_name']);\n\n            $tags = $entry['tags'] ? explode(',', $entry['tags']) : [];\n\n            if (!$user) {\n                $io->error(\"User {$entry['username']} not exist.\");\n\n                return Command::FAILURE;\n            } elseif (!$magazine) {\n                $io->error(\"Magazine {$entry['magazine_name']} not exist.\");\n\n                return Command::FAILURE;\n            }\n\n            $browser = new HttpBrowser(HttpClient::create());\n            $crawler = $browser->request('GET', $entry['url']);\n\n            $content = $crawler->filter('.markdown-body')->first()->children();\n\n            $tags = array_flip($tags);\n            foreach ($content as $elem) {\n                if (\\array_key_exists($elem->nodeName, $tags)) {\n                    $tags[$elem->nodeName] = $elem->nodeValue;\n                }\n\n                if ('ul' === $elem->nodeName) {\n                    foreach ($elem->childNodes as $li) {\n                        /**\n                         * @var \\DOMElement $li\n                         */\n                        if ('li' !== $li->nodeName) {\n                            continue;\n                        }\n\n                        if (!$li->firstChild) {\n                            var_dump('a');\n                            continue;\n                        }\n                        if ('a' !== $li->firstChild->nodeName) {\n                            continue;\n                        }\n\n                        $result[] = [\n                            'magazine' => $magazine,\n                            'user' => $user,\n                            'title' => $li->nodeValue,\n                            'url' => $li->firstChild->getAttribute('href'),\n                            'badges' => new ArrayCollection(array_filter($tags, fn ($v) => \\is_string($v))),\n                        ];\n                    }\n                }\n            }\n        }\n\n        shuffle($result);\n        foreach ($result as $item) {\n            if (false === filter_var($item['url'], FILTER_VALIDATE_URL)) {\n                continue;\n            }\n\n            if ($this->entryRepository->findOneByUrl($item['url'])) {\n                continue;\n            }\n\n            $dto = new EntryDto();\n            $dto->magazine = $item['magazine'];\n            $dto->user = $item['user'];\n            $dto->title = substr($item['title'], 0, 255);\n            $dto->url = $item['url'];\n            $dto->badges = $item['badges'];\n\n            $entry = $this->entryManager->create($dto, $item['user']);\n\n            $io->info(\"(m/{$entry->magazine->name}) {$entry->title}\");\n\n            //            sleep(rand(2,30));\n        }\n\n        return Command::SUCCESS;\n    }\n\n    /**\n     * @return array<string, string>[]\n     */\n    private function getEntries(): array\n    {\n        return [\n            [\n                'username' => 'awesome-rust-bot',\n                'magazine_name' => 'rust',\n                'magazine_title' => 'Rust',\n                'url' => 'https://github.com/rust-unofficial/awesome-rust',\n                'tags' => 'h2,h3',\n            ],\n            [\n                'username' => 'awesome-vue-bot',\n                'magazine_name' => 'vue',\n                'magazine_title' => 'Vue',\n                'url' => 'https://github.com/vuejs/awesome-vue',\n                'tags' => 'h3',\n            ],\n            [\n                'username' => 'awesome-svelte-bot',\n                'magazine_name' => 'svelte',\n                'magazine_title' => 'Svelte',\n                'url' => 'https://github.com/TheComputerM/awesome-svelte',\n                'tags' => 'h2,h3',\n            ],\n            [\n                'username' => 'awesome-react-bot',\n                'magazine_name' => 'react',\n                'magazine_title' => 'React',\n                'url' => 'https://github.com/enaqx/awesome-react',\n                'tags' => 'h4,h5',\n            ],\n            [\n                'username' => 'awesome-ethereum-bot',\n                'magazine_name' => 'ethereum',\n                'magazine_title' => 'Ethereum',\n                'url' => 'https://github.com/bekatom/awesome-ethereum',\n                'tags' => '',\n            ],\n            [\n                'username' => 'awesome-golang-bot',\n                'magazine_name' => 'golang',\n                'magazine_title' => 'Golang',\n                'url' => 'https://github.com/avelino/awesome-go',\n                'tags' => 'h2',\n            ],\n            [\n                'username' => 'awesome-haskell-bot',\n                'magazine_name' => 'haskell',\n                'magazine_title' => 'Haskell',\n                'url' => 'https://github.com/krispo/awesome-haskell',\n                'tags' => 'h2',\n            ],\n            [\n                'username' => 'awesome-flutter-bot',\n                'magazine_name' => 'flutter',\n                'magazine_title' => 'Flutter',\n                'url' => 'https://github.com/Solido/awesome-flutter',\n                'tags' => 'h3, h4',\n            ],\n            [\n                'username' => 'awesome-erlang-bot',\n                'magazine_name' => 'erlang',\n                'magazine_title' => 'Erlang',\n                'url' => 'https://github.com/drobakowski/awesome-erlang',\n                'tags' => 'h2',\n            ],\n            [\n                'username' => 'awesome-php-bot',\n                'magazine_name' => 'php',\n                'magazine_title' => 'PHP',\n                'url' => 'https://github.com/ziadoz/awesome-php',\n                'tags' => 'h3',\n            ],\n            [\n                'username' => 'awesome-testing-bot',\n                'magazine_name' => 'testing',\n                'magazine_title' => 'Testing',\n                'url' => 'https://github.com/TheJambo/awesome-testing',\n                'tags' => 'h3',\n            ],\n            [\n                'username' => 'awesome-code-review-bot',\n                'magazine_name' => 'codeReview',\n                'magazine_title' => 'Code review',\n                'url' => 'https://github.com/joho/awesome-code-review',\n                'tags' => 'h2',\n            ],\n            [\n                'username' => 'awesome-bitcoin-bot',\n                'magazine_name' => 'bitcoin',\n                'magazine_title' => 'Bitcoin',\n                'url' => 'https://github.com/igorbarinov/awesome-bitcoin',\n                'tags' => 'h2',\n            ],\n            [\n                'username' => 'awesome-fediverse-bot',\n                'magazine_name' => 'fediverse',\n                'magazine_title' => 'Fediverse',\n                'url' => 'https://github.com/emilebosch/awesome-fediverse',\n                'tags' => '',\n            ],\n            [\n                'username' => 'awesome-eventstorming-bot',\n                'magazine_name' => 'eventstorming',\n                'magazine_title' => 'Eventstorming',\n                'url' => 'https://github.com/mariuszgil/awesome-eventstorming',\n                'tags' => 'h2',\n            ],\n            [\n                'username' => 'awesome-javascript-bot',\n                'magazine_name' => 'javascript',\n                'magazine_title' => 'Javascript',\n                'url' => 'https://github.com/sorrycc/awesome-javascript',\n                'tags' => 'h2,h3',\n            ],\n            [\n                'username' => 'awesome-unity-bot',\n                'magazine_name' => 'unity',\n                'magazine_title' => 'Unity 3D',\n                'url' => 'https://github.com/RyanNielson/awesome-unity',\n                'tags' => 'h2',\n            ],\n            [\n                'username' => 'awesome-selfhosted-bot',\n                'magazine_name' => 'selfhosted',\n                'magazine_title' => 'selfhosted',\n                'url' => 'https://github.com/awesome-selfhosted/awesome-selfhosted',\n                'tags' => 'h3',\n            ],\n            [\n                'username' => 'awesome-dotnet-bot',\n                'magazine_name' => 'dotnet',\n                'magazine_title' => 'dotnet',\n                'url' => 'https://github.com/quozd/awesome-dotnet',\n                'tags' => 'h2',\n            ],\n            [\n                'username' => 'awesome-java-bot',\n                'magazine_name' => 'java',\n                'magazine_title' => 'Java',\n                'url' => 'https://github.com/akullpp/awesome-java',\n                'tags' => 'h3',\n            ],\n            [\n                'username' => 'awesome-macos-bot',\n                'magazine_name' => 'macOS',\n                'magazine_title' => 'macOS',\n                'url' => 'https://github.com/iCHAIT/awesome-macOS',\n                'tags' => 'h3',\n            ],\n            [\n                'username' => 'awesome-laravel-bot',\n                'magazine_name' => 'laravel',\n                'magazine_title' => 'Laravel',\n                'url' => 'https://github.com/chiraggude/awesome-laravel',\n                'tags' => 'h2,h5',\n            ],\n            [\n                'username' => 'awesome-ux-bot',\n                'magazine_name' => 'ux',\n                'magazine_title' => 'UX',\n                'url' => 'https://github.com/netoguimaraes/awesome-ux',\n                'tags' => 'h2,h3',\n            ],\n            [\n                'username' => 'awesome-symfony-bot',\n                'magazine_name' => 'symfony',\n                'magazine_title' => 'Symfony',\n                'url' => 'https://github.com/sitepoint-editors/awesome-symfony',\n                'tags' => 'h2',\n            ],\n            [\n                'username' => 'awesome-design-bot',\n                'magazine_name' => 'design',\n                'magazine_title' => 'Design',\n                'url' => 'https://github.com/gztchan/awesome-design',\n                'tags' => 'h2',\n            ],\n            [\n                'username' => 'awesome-wordpress-bot',\n                'magazine_name' => 'wordpress',\n                'magazine_title' => 'wordpress',\n                'url' => 'https://github.com/miziomon/awesome-wordpress',\n                'tags' => 'h2,h4',\n            ],\n            [\n                'username' => 'awesome-drupal-bot',\n                'magazine_name' => 'drupal',\n                'magazine_title' => 'Drupal',\n                'url' => 'https://github.com/mrsinguyen/awesome-drupal',\n                'tags' => 'h2,h3',\n            ],\n        ];\n    }\n\n    /**\n     * @param array<string, string> $entry\n     */\n    private function prepareMagazines(OutputInterface $output, array $entry): void\n    {\n        try {\n            $command = $this->getApplication()->find('mbin:user:create');\n            $arguments = [\n                'username' => $entry['username'],\n                'email' => $entry['username'].'@karab.in',\n                'password' => md5((string) rand()),\n            ];\n            $input = new ArrayInput($arguments);\n            $command->run($input, $output);\n        } catch (\\Exception $e) {\n        }\n\n        try {\n            $command = $this->getApplication()->find('mbin:awesome-bot:magazine:create');\n            $arguments = [\n                'username' => 'demo',\n                'magazine_name' => $entry['magazine_name'],\n                'magazine_title' => $entry['magazine_title'],\n                'url' => $entry['url'],\n                'tags' => $entry['tags'],\n            ];\n            $input = new ArrayInput($arguments);\n            $command->run($input, $output);\n        } catch (\\Exception $e) {\n        }\n    }\n}\n"
  },
  {
    "path": "src/Command/AwesomeBot/AwesomeBotMagazine.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Command\\AwesomeBot;\n\nuse App\\DTO\\BadgeDto;\nuse App\\DTO\\MagazineDto;\nuse App\\Entity\\Magazine;\nuse App\\Repository\\UserRepository;\nuse App\\Service\\BadgeManager;\nuse App\\Service\\MagazineManager;\nuse Doctrine\\Common\\Collections\\ArrayCollection;\nuse Doctrine\\Common\\Collections\\Collection;\nuse JetBrains\\PhpStorm\\Pure;\nuse Symfony\\Component\\BrowserKit\\HttpBrowser;\nuse Symfony\\Component\\Console\\Attribute\\AsCommand;\nuse Symfony\\Component\\Console\\Command\\Command;\nuse Symfony\\Component\\Console\\Input\\InputArgument;\nuse Symfony\\Component\\Console\\Input\\InputInterface;\nuse Symfony\\Component\\Console\\Output\\OutputInterface;\nuse Symfony\\Component\\Console\\Style\\SymfonyStyle;\nuse Symfony\\Component\\HttpClient\\HttpClient;\n\n#[AsCommand(name: 'mbin:awesome-bot:magazine:create')]\nclass AwesomeBotMagazine extends Command\n{\n    public function __construct(\n        private readonly UserRepository $repository,\n        private readonly MagazineManager $magazineManager,\n        private readonly BadgeManager $badgeManager,\n    ) {\n        parent::__construct();\n    }\n\n    protected function configure(): void\n    {\n        $this\n            ->setDescription('This command allows you to create awesome-bot magazine.')\n            ->addArgument('username', InputArgument::REQUIRED)\n            ->addArgument('magazine_name', InputArgument::REQUIRED)\n            ->addArgument('magazine_title', InputArgument::REQUIRED)\n            ->addArgument('url', InputArgument::REQUIRED)\n            ->addArgument('tags', InputArgument::REQUIRED);\n    }\n\n    protected function execute(InputInterface $input, OutputInterface $output): int\n    {\n        $io = new SymfonyStyle($input, $output);\n\n        $user = $this->repository->findOneByUsername($input->getArgument('username'));\n\n        if (!$user) {\n            $io->error('User doesn\\'t exist.');\n\n            return Command::FAILURE;\n        }\n\n        try {\n            $dto = new MagazineDto();\n            $dto->name = $input->getArgument('magazine_name');\n            $dto->title = $input->getArgument('magazine_title');\n            $dto->description = 'Powered by '.$input->getArgument('url');\n            $dto->setOwner($user);\n\n            $magazine = $this->magazineManager->create($dto, $user);\n\n            $this->createBadges(\n                $magazine,\n                $input->getArgument('url'),\n                $input->getArgument('tags') ? explode(',', $input->getArgument('tags')) : []\n            );\n        } catch (\\Exception $e) {\n            $io->error('Can\\'t create magazine');\n\n            return Command::FAILURE;\n        }\n\n        return Command::SUCCESS;\n    }\n\n    /**\n     * @param string[] $tags\n     */\n    #[Pure]\n    private function createBadges(Magazine $magazine, string $url, array $tags): Collection\n    {\n        $browser = new HttpBrowser(HttpClient::create());\n        $crawler = $browser->request('GET', $url);\n\n        $content = $crawler->filter('.markdown-body')->first()->children();\n\n        $labels = [];\n        foreach ($content as $elem) {\n            if (\\in_array($elem->nodeName, $tags)) {\n                $labels[] = $elem->nodeValue;\n            }\n        }\n\n        $badges = [];\n        foreach ($labels as $label) {\n            $this->badgeManager->create(\n                BadgeDto::create($magazine, $label)\n            );\n        }\n\n        return new ArrayCollection($badges);\n    }\n}\n"
  },
  {
    "path": "src/Command/CheckDuplicatesUsersMagazines.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Command;\n\nuse App\\Entity\\Magazine;\nuse App\\Entity\\User;\nuse App\\Exception\\InvalidApPostException;\nuse App\\Repository\\InstanceRepository;\nuse App\\Repository\\UserRepository;\nuse App\\Service\\ActivityPub\\ApHttpClientInterface;\nuse App\\Service\\ActivityPubManager;\nuse App\\Service\\MagazineManager;\nuse App\\Service\\MentionManager;\nuse App\\Service\\UserManager;\nuse Doctrine\\DBAL\\ParameterType;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Psr\\Cache\\InvalidArgumentException;\nuse Symfony\\Component\\Console\\Attribute\\AsCommand;\nuse Symfony\\Component\\Console\\Command\\Command;\nuse Symfony\\Component\\Console\\Input\\InputInterface;\nuse Symfony\\Component\\Console\\Output\\OutputInterface;\nuse Symfony\\Component\\Console\\Style\\SymfonyStyle;\n\n#[AsCommand(\n    name: 'mbin:check:duplicates-users-magazines',\n    description: 'Check for duplicate users and magazines.',\n)]\nclass CheckDuplicatesUsersMagazines extends Command\n{\n    public function __construct(\n        private readonly EntityManagerInterface $entityManager,\n        private readonly UserManager $userManager,\n        private readonly MagazineManager $magazineManager,\n        private readonly ApHttpClientInterface $apHttpClient,\n        private readonly UserRepository $userRepository,\n        private readonly ActivityPubManager $activityPubManager,\n        private readonly InstanceRepository $instanceRepository,\n        private readonly MentionManager $mentionManager,\n    ) {\n        parent::__construct();\n    }\n\n    protected function configure(): void\n    {\n        $this\n            ->setDescription('Check for duplicate users and magazines with interactive deletion options.');\n    }\n\n    protected function execute(InputInterface $input, OutputInterface $output): int\n    {\n        $io = new SymfonyStyle($input, $output);\n\n        $io->title('Duplicate Users and Magazines Checker');\n        $dryRun = \\boolval($input->getOption('dry-run'));\n\n        // Let user choose entity type\n        $entity = $io->choice(\n            'What would you like to check for duplicates?',\n            ['users' => 'Users', 'magazines' => 'Magazines'],\n            'users'\n        );\n\n        if ('users' === $entity) {\n            $userType = $io->choice('Solve duplicates by handle or by profile URL?', ['handle', 'profileUrl']);\n            if ('handle' === $userType) {\n                $this->fixDuplicatesByHandle($io, $dryRun);\n\n                return Command::SUCCESS;\n            }\n        }\n\n        // Check for duplicates\n        $duplicates = $this->findDuplicates($io, $entity);\n\n        if (empty($duplicates)) {\n            $entityName = ucfirst(substr($entity, 0, -1));\n            $io->success(\"No duplicate {$entityName}s found.\");\n\n            return Command::SUCCESS;\n        }\n\n        // Display duplicates table\n        $entityName = ucfirst($entity);\n        $nameField = 'users' === $entity ? 'username' : 'name';\n        $this->displayDuplicatesTable($io, $duplicates, $entityName, $nameField);\n\n        // Ask if user wants to delete any duplicates\n        $deleteChoice = $io->confirm('Would you like to delete any of these duplicates?', false);\n\n        if (!$deleteChoice) {\n            $io->success('Operation completed. No deletions performed.');\n\n            return Command::SUCCESS;\n        }\n\n        // Get IDs to delete\n        $idsInput = $io->ask(\n            'Enter the IDs to delete (comma-separated, e.g., 1,2,3)',\n            null,\n            function ($input) {\n                if (empty($input)) {\n                    throw new \\InvalidArgumentException('Please provide at least one ID');\n                }\n\n                $ids = array_map('trim', explode(',', $input));\n                foreach ($ids as $id) {\n                    if (!is_numeric($id)) {\n                        throw new \\InvalidArgumentException(\"Invalid ID: $id\");\n                    }\n                }\n\n                return $ids;\n            }\n        );\n\n        return $this->deleteEntities($io, $entity, $idsInput);\n    }\n\n    private function findDuplicates(SymfonyStyle $io, string $entity): array\n    {\n        $conn = $this->entityManager->getConnection();\n\n        if ('users' === $entity) {\n            $sql = '\n                SELECT id, username, ap_public_url, created_at, last_active FROM\n                \"user\" WHERE ap_public_url IN\n                (SELECT ap_public_url FROM \"user\" WHERE ap_public_url IS NOT NULL GROUP BY ap_public_url HAVING COUNT(*) > 1)\n                ORDER BY ap_public_url;\n            ';\n        } else { // magazines\n            $sql = '\n                SELECT id, name, ap_public_url, created_at, last_active FROM\n                \"magazine\" WHERE ap_public_url IN\n                (SELECT ap_public_url FROM \"magazine\" WHERE ap_public_url IS NOT NULL GROUP BY ap_public_url HAVING COUNT(*) > 1)\n                ORDER BY ap_public_url;\n            ';\n        }\n\n        $stmt = $conn->prepare($sql);\n        $stmt = $stmt->executeQuery();\n        $results = $stmt->fetchAllAssociative();\n\n        return $results;\n    }\n\n    private function displayDuplicatesTable(SymfonyStyle $io, array $results, string $entityName, string $nameField): void\n    {\n        $io->section(\"Duplicate {$entityName}s Found\");\n\n        // Group by ap_public_url\n        $duplicates = [];\n        foreach ($results as $item) {\n            $url = $item['ap_public_url'];\n            if (!isset($duplicates[$url])) {\n                $duplicates[$url] = [];\n            }\n            $duplicates[$url][] = $item;\n        }\n\n        foreach ($duplicates as $url => $items) {\n            $io->text(\"\\n\".str_repeat('=', 30));\n            $io->text('Duplicate Group: '.$url);\n\n            // Prepare table data\n            $headers = ['ID', ucfirst($nameField), 'Created At', 'Last Active'];\n            $rows = [];\n\n            foreach ($items as $item) {\n                $rows[] = [\n                    $item['id'],\n                    $item[$nameField],\n                    $item['created_at'] ? substr($item['created_at'], 0, 19) : 'N/A',\n                    $item['last_active'] ? substr($item['last_active'], 0, 19) : 'N/A',\n                ];\n            }\n\n            $io->table($headers, $rows);\n        }\n\n        $io->text(\\sprintf(\"\\nTotal duplicate {$entityName}s: %d\", \\count($results)));\n    }\n\n    private function deleteEntities(SymfonyStyle $io, string $entity, array $ids): int\n    {\n        try {\n            foreach ($ids as $id) {\n                if ('users' === $entity) {\n                    // Check if user exists first\n                    $existingUser = $this->entityManager->getRepository(User::class)->find($id);\n                    if (!$existingUser) {\n                        $io->warning(\"User with ID $id not found, skipping...\");\n                        continue;\n                    }\n\n                    $this->userManager->delete($existingUser);\n                    $io->success(\"Deleted user: {$existingUser->getUsername()} (ID: $id)\");\n                } else { // magazines\n                    // Check if magazine exists first\n                    $magazine = $this->entityManager->getRepository(Magazine::class)->find($id);\n                    if (!$magazine) {\n                        $io->warning(\"Magazine with ID $id not found, skipping...\");\n                        continue;\n                    }\n\n                    $this->magazineManager->purge($magazine);\n                    $io->success(\"Deleted magazine: {$magazine->getApName()} (ID: $id)\");\n                }\n            }\n\n            $entityName = ucfirst(substr($entity, 0, -1));\n            $io->success(\"{$entityName} deletion completed successfully.\");\n        } catch (\\Exception $e) {\n            $io->error('Error during deletion: '.$e->getMessage());\n\n            return Command::FAILURE;\n        }\n\n        return Command::SUCCESS;\n    }\n\n    protected function fixDuplicatesByHandle(SymfonyStyle $io, bool $dryRun): int\n    {\n        $sql = 'SELECT\n    LOWER(username) AS normalized_username,\n    COUNT(*)        AS duplicate_count,\n    json_agg(id)   AS user_ids,\n    json_agg(username) AS usernames,\n    json_agg(ap_profile_id) as urls\nFROM \"user\"\nWHERE application_status = \\'Approved\\' AND ap_id IS NOT NULL\nGROUP BY LOWER(username)\nHAVING COUNT(*) > 1\nORDER BY duplicate_count DESC';\n\n        $io->writeln('Gathering duplicate users');\n        $result = $this->entityManager->getConnection()->prepare($sql)->executeQuery()->fetchAllAssociative();\n\n        $usersToUpdate = [];\n        $usersToMerge = [];\n        $io->writeln('Determining which users to update');\n        foreach ($result as $row) {\n            $username = $this->mentionManager->getUsername($row['normalized_username']);\n            $urls = json_decode($row['urls']);\n            // if the URL contains the string (after removing the host), the username is probably right\n            $urlsToUpdate = array_filter($urls, fn (string $url) => !str_contains(str_replace('https://'.parse_url($url, PHP_URL_HOST), '', strtolower($url)), $username));\n\n            foreach ($urlsToUpdate as $url) {\n                $usersToUpdate = array_merge($usersToUpdate, $this->getUsersToUpdateFromUrl($url, $io, $dryRun));\n            }\n\n            $referenceUrl = strtolower($urls[0]);\n            $pairShouldBeMerged = true;\n            foreach ($urls as $url) {\n                if ($referenceUrl !== strtolower($url)) {\n                    $pairShouldBeMerged = false;\n                    break;\n                }\n            }\n            if ($pairShouldBeMerged) {\n                $usersToMerge[] = [json_decode($row['user_ids'])];\n            }\n        }\n        $io->writeln('Updating users from URLs: '.implode(', ', $usersToUpdate));\n        foreach ($usersToUpdate as $url) {\n            if (!$dryRun) {\n                $io->writeln(\"Updating actor $url\");\n                $this->activityPubManager->updateActor($url);\n            } else {\n                $io->writeln(\"Would have updated actor $url\");\n            }\n        }\n\n        foreach ($usersToMerge as $userPairs) {\n            $users = $this->userRepository->findBy(['id' => $userPairs]);\n            $userString = implode(', ', array_map(fn (User $user) => \"'$user->username' ($user->apProfileId)\", $users));\n            $answer = $io->ask(\"Should these users get merged: $userString\", 'yes');\n            if ('yes' === $answer) {\n                $this->mergeRemoteUsers($users, $io, $dryRun);\n            }\n        }\n\n        return Command::SUCCESS;\n    }\n\n    private function getUsersToUpdateFromUrl(string $url, SymfonyStyle $io, bool $dryRun): array\n    {\n        $user = $this->userRepository->findOneBy(['apProfileId' => $url]);\n        if (!$user) {\n            return [];\n        }\n\n        if ($user->isDeleted || $user->isSoftDeleted() || $user->isTrashed() || $user->markedForDeletionAt) {\n            // deleted users do not get updated, thus they can cause non-unique violations if they also have a wrong username\n            if (!$dryRun) {\n                $answer = $io->ask(\"Do you want to purge user '$user->username' ($user->apProfileId)? They are already deleted.\", 'yes');\n                if ('yes' === strtolower($answer)) {\n                    $this->purgeUser($user);\n                }\n            } else {\n                $io->writeln(\"Would have asked whether '$user->username' ($user->apProfileId) should be purged\");\n            }\n\n            return [];\n        }\n\n        $instance = $this->instanceRepository->findOneBy(['domain' => $user->apDomain]);\n        if ($instance && ($instance->isBanned || $instance->isDead())) {\n            if (!$dryRun) {\n                $answer = $io->ask(\"The instance $instance->domain is either dead or banned, should the user '$user->username' ($user->apProfileId) be purged?\", 'yes');\n                if ('yes' === strtolower($answer)) {\n                    $this->purgeUser($user);\n                }\n            } else {\n                $io->writeln(\"Would have asked whether '$user->username' ($user->apProfileId) should be purged\");\n            }\n\n            return [];\n        }\n\n        $io->writeln(\"fetching remote object for '$user->username' ($user->apProfileId)\");\n        $actorObject = $this->apHttpClient->getActorObject($url);\n        if (!$actorObject) {\n            if (!$dryRun) {\n                $io->writeln(\\sprintf('Purging \"%s\", because it does not exist on the remote server', $user->username));\n                $this->purgeUser($user);\n            } else {\n                $io->writeln(\"Would have purged user '$user->username' ('$user->apProfileId'), because we didn't get a response from the server\");\n            }\n\n            return [];\n        } elseif ('Tombstone' === $actorObject['type']) {\n            if (!$dryRun) {\n                $io->writeln(\\sprintf('Purging \"%s\", because it was deleted on the remote server', $user->username));\n                $this->purgeUser($user);\n            } else {\n                $io->writeln(\"Would have purged user '$user->username' ('$user->apProfileId'), because it is deleted on the remote server\");\n            }\n\n            return [];\n        }\n\n        $domain = parse_url($url, PHP_URL_HOST);\n        $newUsername = '@'.$actorObject['preferredUsername'].'@'.$domain;\n        // if there already is a user with the username that is supposed to be on the current one,\n        // we have to update that user before the current one to avoid non-unique violations\n        $existingUsers = $this->userRepository->findBy(['username' => $newUsername]);\n        $result = [];\n        foreach ($existingUsers as $existingUser) {\n            if ($existingUser) {\n                if ($user->getId() === $existingUser->getId()) {\n                    continue;\n                }\n                $additionalUrls = $this->getUsersToUpdateFromUrl($existingUser->apProfileId, $io, $dryRun);\n\n                $result = array_merge($additionalUrls, $result);\n            }\n        }\n        $result[] = $url;\n\n        return $result;\n    }\n\n    /**\n     * @throws \\Doctrine\\DBAL\\Exception\n     */\n    private function purgeUser(User $user): void\n    {\n        $stmt = $this->entityManager->getConnection()\n            ->prepare('DELETE FROM \"user\" WHERE id = :id');\n        $stmt->bindValue('id', $user->getId(), ParameterType::INTEGER);\n        $stmt->executeStatement();\n    }\n\n    /**\n     * This replaces all the references of one user with all the others. The main user the others are merged into\n     * is determined by the exact match of the 'id' gathered from the URL in `$user->apProfileId`.\n     *\n     * @param User[] $users\n     *\n     * @throws InvalidApPostException\n     * @throws \\Doctrine\\DBAL\\Exception\n     * @throws InvalidArgumentException\n     */\n    private function mergeRemoteUsers(array $users, SymfonyStyle $io, bool $dryRun): void\n    {\n        if (0 === \\count($users)) {\n            return;\n        }\n\n        $actorObject = $this->apHttpClient->getActorObject($users[0]->apProfileId);\n        $mainUser = array_first(array_filter($users, fn (User $user) => $user->apProfileId === $actorObject['id']));\n\n        if (!$mainUser) {\n            $io->warning(\\sprintf('Could not find an exact match for %s in the users %s', $actorObject['id'], implode(', ', array_map(fn (User $user) => $user->apProfileId, $users))));\n\n            return;\n        }\n\n        foreach ($users as $user) {\n            if ($mainUser->getId() === $user->getId()) {\n                continue;\n            }\n\n            if ($dryRun) {\n                $io->writeln(\"Would have merged '$user->username' ('$user->apProfileId') into '$mainUser->username' ('$mainUser->apProfileId')\");\n                continue;\n            }\n\n            $io->writeln(\"Merging '$user->username' ('$user->apProfileId') into '$mainUser->username' ('$mainUser->apProfileId')\");\n            $conn = $this->entityManager->getConnection();\n            $conn->transactional(function () use ($conn, $mainUser, $user) {\n                $stmt = $conn->prepare('UPDATE activity SET user_actor_id = :main WHERE user_actor_id = :oldId');\n                $stmt->bindValue(':main', $mainUser->getId(), ParameterType::INTEGER);\n                $stmt->bindValue(':oldId', $user->getId(), ParameterType::INTEGER);\n                $stmt->executeStatement();\n\n                $stmt = $conn->prepare('UPDATE activity SET object_user_id = :main WHERE object_user_id = :oldId');\n                $stmt->bindValue(':main', $mainUser->getId(), ParameterType::INTEGER);\n                $stmt->bindValue(':oldId', $user->getId(), ParameterType::INTEGER);\n                $stmt->executeStatement();\n\n                $stmt = $conn->prepare('UPDATE entry SET user_id = :main WHERE user_id = :oldId');\n                $stmt->bindValue(':main', $mainUser->getId(), ParameterType::INTEGER);\n                $stmt->bindValue(':oldId', $user->getId(), ParameterType::INTEGER);\n                $stmt->executeStatement();\n\n                $stmt = $conn->prepare('UPDATE entry_comment SET user_id = :main WHERE user_id = :oldId');\n                $stmt->bindValue(':main', $mainUser->getId(), ParameterType::INTEGER);\n                $stmt->bindValue(':oldId', $user->getId(), ParameterType::INTEGER);\n                $stmt->executeStatement();\n\n                $stmt = $conn->prepare('UPDATE post SET user_id = :main WHERE user_id = :oldId');\n                $stmt->bindValue(':main', $mainUser->getId(), ParameterType::INTEGER);\n                $stmt->bindValue(':oldId', $user->getId(), ParameterType::INTEGER);\n                $stmt->executeStatement();\n\n                $stmt = $conn->prepare('UPDATE post_comment SET user_id = :main WHERE user_id = :oldId');\n                $stmt->bindValue(':main', $mainUser->getId(), ParameterType::INTEGER);\n                $stmt->bindValue(':oldId', $user->getId(), ParameterType::INTEGER);\n                $stmt->executeStatement();\n\n                $stmt = $conn->prepare('UPDATE entry_vote SET user_id = :main WHERE user_id = :oldId');\n                $stmt->bindValue(':main', $mainUser->getId(), ParameterType::INTEGER);\n                $stmt->bindValue(':oldId', $user->getId(), ParameterType::INTEGER);\n                $stmt->executeStatement();\n\n                $stmt = $conn->prepare('UPDATE entry_comment_vote SET user_id = :main WHERE user_id = :oldId');\n                $stmt->bindValue(':main', $mainUser->getId(), ParameterType::INTEGER);\n                $stmt->bindValue(':oldId', $user->getId(), ParameterType::INTEGER);\n                $stmt->executeStatement();\n\n                $stmt = $conn->prepare('UPDATE post_vote SET user_id = :main WHERE user_id = :oldId');\n                $stmt->bindValue(':main', $mainUser->getId(), ParameterType::INTEGER);\n                $stmt->bindValue(':oldId', $user->getId(), ParameterType::INTEGER);\n                $stmt->executeStatement();\n\n                $stmt = $conn->prepare('UPDATE post_comment_vote SET user_id = :main WHERE user_id = :oldId');\n                $stmt->bindValue(':main', $mainUser->getId(), ParameterType::INTEGER);\n                $stmt->bindValue(':oldId', $user->getId(), ParameterType::INTEGER);\n                $stmt->executeStatement();\n\n                $stmt = $conn->prepare('UPDATE favourite SET user_id = :main WHERE user_id = :oldId');\n                $stmt->bindValue(':main', $mainUser->getId(), ParameterType::INTEGER);\n                $stmt->bindValue(':oldId', $user->getId(), ParameterType::INTEGER);\n                $stmt->executeStatement();\n\n                $stmt = $conn->prepare('UPDATE message_thread_participants SET user_id = :main WHERE user_id = :oldId');\n                $stmt->bindValue(':main', $mainUser->getId(), ParameterType::INTEGER);\n                $stmt->bindValue(':oldId', $user->getId(), ParameterType::INTEGER);\n                $stmt->executeStatement();\n\n                $stmt = $conn->prepare('UPDATE message SET sender_id = :main WHERE sender_id = :oldId');\n                $stmt->bindValue(':main', $mainUser->getId(), ParameterType::INTEGER);\n                $stmt->bindValue(':oldId', $user->getId(), ParameterType::INTEGER);\n                $stmt->executeStatement();\n\n                $stmt = $conn->prepare('UPDATE magazine_ban SET user_id = :main WHERE user_id = :oldId');\n                $stmt->bindValue(':main', $mainUser->getId(), ParameterType::INTEGER);\n                $stmt->bindValue(':oldId', $user->getId(), ParameterType::INTEGER);\n                $stmt->executeStatement();\n\n                $stmt = $conn->prepare('UPDATE magazine_ban SET banned_by_id = :main WHERE banned_by_id = :oldId');\n                $stmt->bindValue(':main', $mainUser->getId(), ParameterType::INTEGER);\n                $stmt->bindValue(':oldId', $user->getId(), ParameterType::INTEGER);\n                $stmt->executeStatement();\n\n                $stmt = $conn->prepare('UPDATE magazine_block SET user_id = :main WHERE user_id = :oldId');\n                $stmt->bindValue(':main', $mainUser->getId(), ParameterType::INTEGER);\n                $stmt->bindValue(':oldId', $user->getId(), ParameterType::INTEGER);\n                $stmt->executeStatement();\n\n                $stmt = $conn->prepare('UPDATE magazine_log SET user_id = :main WHERE user_id = :oldId');\n                $stmt->bindValue(':main', $mainUser->getId(), ParameterType::INTEGER);\n                $stmt->bindValue(':oldId', $user->getId(), ParameterType::INTEGER);\n                $stmt->executeStatement();\n\n                $stmt = $conn->prepare('UPDATE magazine_log SET acting_user_id = :main WHERE acting_user_id = :oldId');\n                $stmt->bindValue(':main', $mainUser->getId(), ParameterType::INTEGER);\n                $stmt->bindValue(':oldId', $user->getId(), ParameterType::INTEGER);\n                $stmt->executeStatement();\n\n                $stmt = $conn->prepare('UPDATE magazine_subscription SET user_id = :main WHERE user_id = :oldId');\n                $stmt->bindValue(':main', $mainUser->getId(), ParameterType::INTEGER);\n                $stmt->bindValue(':oldId', $user->getId(), ParameterType::INTEGER);\n                $stmt->executeStatement();\n\n                $stmt = $conn->prepare('UPDATE moderator SET user_id = :main WHERE user_id = :oldId');\n                $stmt->bindValue(':main', $mainUser->getId(), ParameterType::INTEGER);\n                $stmt->bindValue(':oldId', $user->getId(), ParameterType::INTEGER);\n                $stmt->executeStatement();\n\n                $stmt = $conn->prepare('UPDATE moderator SET added_by_user_id = :main WHERE added_by_user_id = :oldId');\n                $stmt->bindValue(':main', $mainUser->getId(), ParameterType::INTEGER);\n                $stmt->bindValue(':oldId', $user->getId(), ParameterType::INTEGER);\n                $stmt->executeStatement();\n\n                $stmt = $conn->prepare('UPDATE notification_settings SET target_user_id = :main WHERE target_user_id = :oldId');\n                $stmt->bindValue(':main', $mainUser->getId(), ParameterType::INTEGER);\n                $stmt->bindValue(':oldId', $user->getId(), ParameterType::INTEGER);\n                $stmt->executeStatement();\n\n                $stmt = $conn->prepare('UPDATE report SET reported_id = :main WHERE reported_id = :oldId');\n                $stmt->bindValue(':main', $mainUser->getId(), ParameterType::INTEGER);\n                $stmt->bindValue(':oldId', $user->getId(), ParameterType::INTEGER);\n                $stmt->executeStatement();\n\n                $stmt = $conn->prepare('UPDATE report SET reporting_id = :main WHERE reporting_id = :oldId');\n                $stmt->bindValue(':main', $mainUser->getId(), ParameterType::INTEGER);\n                $stmt->bindValue(':oldId', $user->getId(), ParameterType::INTEGER);\n                $stmt->executeStatement();\n\n                $stmt = $conn->prepare('UPDATE user_block SET blocker_id = :main WHERE blocker_id = :oldId');\n                $stmt->bindValue(':main', $mainUser->getId(), ParameterType::INTEGER);\n                $stmt->bindValue(':oldId', $user->getId(), ParameterType::INTEGER);\n                $stmt->executeStatement();\n\n                $stmt = $conn->prepare('UPDATE user_block SET blocked_id = :main WHERE blocked_id = :oldId');\n                $stmt->bindValue(':main', $mainUser->getId(), ParameterType::INTEGER);\n                $stmt->bindValue(':oldId', $user->getId(), ParameterType::INTEGER);\n                $stmt->executeStatement();\n\n                $stmt = $conn->prepare('UPDATE user_follow SET follower_id = :main WHERE follower_id = :oldId');\n                $stmt->bindValue(':main', $mainUser->getId(), ParameterType::INTEGER);\n                $stmt->bindValue(':oldId', $user->getId(), ParameterType::INTEGER);\n                $stmt->executeStatement();\n\n                $stmt = $conn->prepare('UPDATE user_follow SET following_id = :main WHERE following_id = :oldId');\n                $stmt->bindValue(':main', $mainUser->getId(), ParameterType::INTEGER);\n                $stmt->bindValue(':oldId', $user->getId(), ParameterType::INTEGER);\n                $stmt->executeStatement();\n\n                $stmt = $conn->prepare('UPDATE user_follow_request SET follower_id = :main WHERE follower_id = :oldId');\n                $stmt->bindValue(':main', $mainUser->getId(), ParameterType::INTEGER);\n                $stmt->bindValue(':oldId', $user->getId(), ParameterType::INTEGER);\n                $stmt->executeStatement();\n\n                $stmt = $conn->prepare('UPDATE user_follow_request SET following_id = :main WHERE following_id = :oldId');\n                $stmt->bindValue(':main', $mainUser->getId(), ParameterType::INTEGER);\n                $stmt->bindValue(':oldId', $user->getId(), ParameterType::INTEGER);\n                $stmt->executeStatement();\n            });\n\n            $io->writeln(\"Purging user '$user->username' ('$user->apProfileId')\");\n            $this->purgeUser($user);\n        }\n    }\n}\n"
  },
  {
    "path": "src/Command/DeleteMonitoringDataCommand.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Command;\n\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Symfony\\Component\\Console\\Attribute\\AsCommand;\nuse Symfony\\Component\\Console\\Command\\Command;\nuse Symfony\\Component\\Console\\Input\\InputInterface;\nuse Symfony\\Component\\Console\\Input\\InputOption;\nuse Symfony\\Component\\Console\\Output\\OutputInterface;\nuse Symfony\\Component\\Console\\Style\\SymfonyStyle;\n\n#[AsCommand(\n    name: 'mbin:monitoring:delete-data',\n    description: 'Delete all Monitoring Data',\n)]\nclass DeleteMonitoringDataCommand extends Command\n{\n    public function __construct(\n        private readonly EntityManagerInterface $entityManager,\n    ) {\n        parent::__construct();\n    }\n\n    protected function configure(): void\n    {\n        $this->addOption('all', 'a', InputOption::VALUE_NONE, 'Delete all monitoring data');\n        $this->addOption('queries', null, InputOption::VALUE_NONE, 'Delete all query data');\n        $this->addOption('twig', null, InputOption::VALUE_NONE, 'Delete all twig data');\n        $this->addOption('requests', null, InputOption::VALUE_NONE, 'Delete all request data');\n        $this->addOption('before', null, InputOption::VALUE_OPTIONAL, 'Limit the deletion to contexts before the date');\n    }\n\n    protected function execute(InputInterface $input, OutputInterface $output): int\n    {\n        $io = new SymfonyStyle($input, $output);\n        $beforeString = $input->getOption('before');\n\n        try {\n            $before = $beforeString ? new \\DateTimeImmutable($beforeString) : new \\DateTimeImmutable();\n        } catch (\\Exception $e) {\n            $io->error(\\sprintf('%s is not in a valid form', $input->getOption('before')));\n\n            return Command::FAILURE;\n        }\n\n        if ($input->getOption('all')) {\n            $stmt = $this->entityManager->getConnection()->prepare('DELETE FROM monitoring_execution_context WHERE created_at < :before');\n            $stmt->bindValue('before', $before, 'datetime_immutable');\n            $stmt->executeStatement();\n\n            $io->success('Deleted monitoring data before '.$before->format(DATE_ATOM));\n        } else {\n            if ($input->getOption('queries')) {\n                $stmt = $this->entityManager->getConnection()->prepare('DELETE FROM monitoring_query WHERE created_at < :before');\n                $stmt->bindValue('before', $before, 'datetime_immutable');\n                $stmt->executeStatement();\n                $io->success('Deleted query data before '.$before->format(DATE_ATOM));\n            }\n\n            if ($input->getOption('twig')) {\n                $stmt = $this->entityManager->getConnection()->prepare('DELETE FROM monitoring_twig_render WHERE created_at < :before');\n                $stmt->bindValue('before', $before, 'datetime_immutable');\n                $stmt->executeStatement();\n                $io->success('Deleted twig data before '.$before->format(DATE_ATOM));\n            }\n\n            if ($input->getOption('requests')) {\n                $this->entityManager->getConnection()->prepare('DELETE FROM monitoring_curl_request WHERE created_at < :before');\n                $stmt->bindValue('before', $before, 'datetime_immutable');\n                $stmt->executeStatement();\n                $io->success('Deleted request data before '.$before->format(DATE_ATOM));\n            }\n        }\n\n        return Command::SUCCESS;\n    }\n}\n"
  },
  {
    "path": "src/Command/DeleteOrphanedImagesCommand.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Command;\n\nuse App\\Repository\\ImageRepository;\nuse App\\Service\\ImageManagerInterface;\nuse Symfony\\Component\\Console\\Attribute\\AsCommand;\nuse Symfony\\Component\\Console\\Command\\Command;\nuse Symfony\\Component\\Console\\Helper\\ProgressBar;\nuse Symfony\\Component\\Console\\Input\\InputArgument;\nuse Symfony\\Component\\Console\\Input\\InputInterface;\nuse Symfony\\Component\\Console\\Input\\InputOption;\nuse Symfony\\Component\\Console\\Output\\OutputInterface;\nuse Symfony\\Component\\Console\\Style\\SymfonyStyle;\n\n#[AsCommand(\n    name: 'mbin:images:remove-orphaned',\n    description: 'This command removes orphaned images from your configured filesystem.',\n)]\nclass DeleteOrphanedImagesCommand extends Command\n{\n    public function __construct(\n        private readonly ImageManagerInterface $imageManager,\n        private readonly ImageRepository $imageRepository,\n    ) {\n        parent::__construct();\n    }\n\n    protected function configure(): void\n    {\n        $this\n            ->addOption(\n                'ignored-paths',\n                null,\n                InputArgument::OPTIONAL,\n                'A comma seperated list of paths to be ignored in this process. If the path starts with one of the supplied string it will be skipped. e.g. \"/cache\"',\n                ''\n            )\n            ->addOption('dry-run', null, InputOption::VALUE_NONE, 'Dry run, don\\'t delete anything')\n        ;\n    }\n\n    protected function execute(InputInterface $input, OutputInterface $output): int\n    {\n        $io = new SymfonyStyle($input, $output);\n        $totalFiles = 0;\n        $totalDeletedSize = 0;\n        $totalDeletedFiles = 0;\n        $errors = 0;\n        $dryRun = $input->getOption('dry-run');\n        $ignoredPaths = array_filter(\n            array_map(fn (string $item) => trim($item), explode(',', $input->getOption('ignored-paths'))),\n            fn (string $item) => '' !== $item\n        );\n\n        if (\\sizeof($ignoredPaths)) {\n            $io->info(\\sprintf('Ignoring files in: %s', implode(', ', $ignoredPaths)));\n        }\n\n        ProgressBar::setFormatDefinition('custom_orphaned', '%deleted% deleted file(s) | %current% checked file(s) (in %elapsed%) - %message%');\n\n        $progress = $io->createProgressBar();\n        $progress->setFormat('custom_orphaned');\n        $progress->setMessage('');\n        $progress->start();\n\n        try {\n            foreach ($this->imageManager->deleteOrphanedFiles($this->imageRepository, $dryRun, $ignoredPaths) as $file) {\n                $progress->advance();\n                if ($file['deleted']) {\n                    if ($file['successful']) {\n                        if ($dryRun) {\n                            $progress->setMessage(\\sprintf('Would have deleted \"%s\"', $file['path']));\n                        } else {\n                            $progress->setMessage(\\sprintf('Deleted \"%s\"', $file['path']));\n                        }\n                        if ($file['fileSize']) {\n                            $totalDeletedSize += $file['fileSize'];\n                        }\n                        ++$totalDeletedFiles;\n                        $progress->setMessage($totalDeletedFiles.'', 'deleted');\n                        $progress->display();\n                    } else {\n                        if (null !== $file['exception']) {\n                            $io->warning(\\sprintf('Failed to delete \"%s\". Message: \"%s\"', $file['path'], $file['exception']->getMessage()));\n                        } else {\n                            $io->warning(\\sprintf('Failed to delete \"%s\".', $file['path']));\n                        }\n                        ++$errors;\n                    }\n                }\n            }\n        } catch (\\Exception $e) {\n            $progress->finish();\n            $io->error(\\sprintf('There was an error deleting the files: \"%s\" - %s', \\get_class($e), $e->getMessage()));\n\n            return Command::FAILURE;\n        }\n\n        $progress->finish();\n        $megaBytes = round($totalDeletedSize / pow(1000, 2), 2);\n        if ($dryRun) {\n            $io->info(\\sprintf('Would have deleted %s of %s images, and freed up %sMB', $totalDeletedFiles, $totalFiles, $megaBytes));\n        } else {\n            $io->info(\\sprintf('Deleted %s of %s images, and freed up %sMB', $totalDeletedFiles, $totalFiles, $megaBytes));\n        }\n        if ($errors) {\n            $io->warning(\\sprintf('There were %s errors', $errors));\n        }\n\n        return Command::SUCCESS;\n    }\n}\n"
  },
  {
    "path": "src/Command/DeleteUserCommand.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Command;\n\nuse App\\Message\\DeleteUserMessage;\nuse App\\Repository\\UserRepository;\nuse Symfony\\Component\\Console\\Attribute\\AsCommand;\nuse Symfony\\Component\\Console\\Command\\Command;\nuse Symfony\\Component\\Console\\Input\\InputArgument;\nuse Symfony\\Component\\Console\\Input\\InputInterface;\nuse Symfony\\Component\\Console\\Output\\OutputInterface;\nuse Symfony\\Component\\Console\\Style\\SymfonyStyle;\nuse Symfony\\Component\\Messenger\\MessageBusInterface;\n\n#[AsCommand(\n    name: 'mbin:user:delete',\n    description: 'This command will delete the supplied user',\n)]\nclass DeleteUserCommand extends Command\n{\n    public function __construct(\n        private readonly UserRepository $repository,\n        private readonly MessageBusInterface $bus,\n    ) {\n        parent::__construct();\n    }\n\n    protected function configure(): void\n    {\n        $this->addArgument('user', InputArgument::REQUIRED, 'The name of the user that should be deleted');\n    }\n\n    protected function execute(InputInterface $input, OutputInterface $output): int\n    {\n        $io = new SymfonyStyle($input, $output);\n        $userArg = $input->getArgument('user');\n        $user = $this->repository->findOneByUsername($userArg);\n\n        if (null !== $user) {\n            $this->bus->dispatch(new DeleteUserMessage($user->getId()));\n            $io->success('Dispatched a user delete message, the user will be deleted shortly');\n\n            return Command::SUCCESS;\n        } else {\n            $io->error(\"There is no user with the username '$userArg'\");\n\n            return Command::INVALID;\n        }\n    }\n}\n"
  },
  {
    "path": "src/Command/DocumentationGenerateFederationCommand.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Command;\n\nuse App\\DTO\\EntryCommentDto;\nuse App\\DTO\\EntryDto;\nuse App\\DTO\\MagazineDto;\nuse App\\DTO\\MessageDto;\nuse App\\DTO\\PostCommentDto;\nuse App\\DTO\\PostDto;\nuse App\\DTO\\ReportDto;\nuse App\\DTO\\UserDto;\nuse App\\Entity\\Image;\nuse App\\Entity\\MagazineBan;\nuse App\\Factory\\ActivityPub\\AddRemoveFactory;\nuse App\\Factory\\ActivityPub\\BlockFactory;\nuse App\\Factory\\ActivityPub\\CollectionFactory;\nuse App\\Factory\\ActivityPub\\EntryCommentNoteFactory;\nuse App\\Factory\\ActivityPub\\EntryPageFactory;\nuse App\\Factory\\ActivityPub\\FlagFactory;\nuse App\\Factory\\ActivityPub\\GroupFactory;\nuse App\\Factory\\ActivityPub\\InstanceFactory;\nuse App\\Factory\\ActivityPub\\LockFactory;\nuse App\\Factory\\ActivityPub\\MessageFactory;\nuse App\\Factory\\ActivityPub\\PersonFactory;\nuse App\\Factory\\ActivityPub\\PostCommentNoteFactory;\nuse App\\Factory\\ActivityPub\\PostNoteFactory;\nuse App\\Factory\\ImageFactory;\nuse App\\Repository\\UserRepository;\nuse App\\Service\\ActivityPub\\ActivityJsonBuilder;\nuse App\\Service\\ActivityPub\\ContextsProvider;\nuse App\\Service\\ActivityPub\\Wrapper\\AnnounceWrapper;\nuse App\\Service\\ActivityPub\\Wrapper\\CollectionInfoWrapper;\nuse App\\Service\\ActivityPub\\Wrapper\\CreateWrapper;\nuse App\\Service\\ActivityPub\\Wrapper\\DeleteWrapper;\nuse App\\Service\\ActivityPub\\Wrapper\\FollowResponseWrapper;\nuse App\\Service\\ActivityPub\\Wrapper\\FollowWrapper;\nuse App\\Service\\ActivityPub\\Wrapper\\LikeWrapper;\nuse App\\Service\\ActivityPub\\Wrapper\\UndoWrapper;\nuse App\\Service\\ActivityPub\\Wrapper\\UpdateWrapper;\nuse App\\Service\\EntryCommentManager;\nuse App\\Service\\EntryManager;\nuse App\\Service\\MagazineManager;\nuse App\\Service\\MessageManager;\nuse App\\Service\\PostCommentManager;\nuse App\\Service\\PostManager;\nuse App\\Service\\ReportManager;\nuse App\\Service\\SettingsManager;\nuse App\\Service\\UserManager;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Symfony\\Component\\Console\\Attribute\\AsCommand;\nuse Symfony\\Component\\Console\\Command\\Command;\nuse Symfony\\Component\\Console\\Input\\InputArgument;\nuse Symfony\\Component\\Console\\Input\\InputInterface;\nuse Symfony\\Component\\Console\\Input\\InputOption;\nuse Symfony\\Component\\Console\\Output\\OutputInterface;\nuse Symfony\\Component\\Console\\Style\\SymfonyStyle;\nuse Symfony\\Component\\Routing\\RouterInterface;\n\n#[AsCommand(\n    name: 'mbin:docs:gen:federation',\n    description: 'This command allows you to generate the federation JSON for the documentation.'\n)]\nclass DocumentationGenerateFederationCommand extends Command\n{\n    public function __construct(\n        private readonly RouterInterface $router,\n        private readonly SettingsManager $settingsManager,\n        private readonly UserManager $userManager,\n        private readonly MagazineManager $magazineManager,\n        private readonly MessageManager $messageManager,\n        private readonly EntryManager $entryManager,\n        private readonly EntryCommentManager $entryCommentManager,\n        private readonly PostManager $postManager,\n        private readonly PostCommentManager $postCommentManager,\n        private readonly ImageFactory $imageFactory,\n        private readonly PersonFactory $personFactory,\n        private readonly GroupFactory $groupFactory,\n        private readonly InstanceFactory $instanceFactory,\n        private readonly EntryPageFactory $entryPageFactory,\n        private readonly EntryCommentNoteFactory $entryCommentNoteFactory,\n        private readonly PostNoteFactory $postNoteFactory,\n        private readonly PostCommentNoteFactory $postCommentNoteFactory,\n        private readonly MessageFactory $messageFactory,\n        private readonly CollectionFactory $collectionFactory,\n        private readonly ContextsProvider $contextsProvider,\n        private readonly EntityManagerInterface $entityManager,\n        private readonly ReportManager $reportManager,\n        private readonly ActivityJsonBuilder $activityJsonBuilder,\n        private readonly CreateWrapper $createWrapper,\n        private readonly FollowWrapper $followWrapper,\n        private readonly FollowResponseWrapper $followResponseWrapper,\n        private readonly UndoWrapper $undoWrapper,\n        private readonly FlagFactory $flagFactory,\n        private readonly AddRemoveFactory $addRemoveFactory,\n        private readonly AnnounceWrapper $announceWrapper,\n        private readonly LikeWrapper $likeWrapper,\n        private readonly DeleteWrapper $deleteWrapper,\n        private readonly UpdateWrapper $updateWrapper,\n        private readonly UserRepository $userRepository,\n        private readonly CollectionInfoWrapper $collectionInfoWrapper,\n        private readonly BlockFactory $blockFactory,\n        private readonly LockFactory $lockFactory,\n    ) {\n        parent::__construct();\n    }\n\n    protected function configure()\n    {\n        $this->addArgument('target', InputArgument::REQUIRED, 'the target file the generated markdown should be saved to');\n        $this->addOption('overwrite', 'o', InputOption::VALUE_NONE, 'should the target file be overwritten in case it exists');\n    }\n\n    protected function execute(InputInterface $input, OutputInterface $output): int\n    {\n        // do everything in a transaction so we can roll that back afterward\n        $this->entityManager->beginTransaction();\n\n        $this->settingsManager->set('KBIN_FEDERATION_ENABLED', false);\n        $this->settingsManager->set('KBIN_DOMAIN', 'mbin.example');\n        $context = $this->router->getContext();\n        $context->setHost('mbin.example');\n\n        $io = new SymfonyStyle($input, $output);\n        $file = './docs/05-fediverse_developers/README.md';\n        if (!file_exists($file)) {\n            $io->error('File \"'.$file.'\" not found');\n\n            return Command::FAILURE;\n        }\n\n        $content = file_get_contents($file);\n        if (false === $content) {\n            $io->error('File \"'.$file.'\" could not be read');\n\n            return Command::FAILURE;\n        }\n\n        $target = $input->getArgument('target');\n        $overwrite = $input->getOption('overwrite');\n\n        if (file_exists($target) && !$overwrite) {\n            $io->error('File \"'.$target.'\" already exists');\n\n            return Command::FAILURE;\n        }\n\n        $content = $this->generateMarkdown($content);\n        $this->entityManager->rollback();\n\n        if (false === file_put_contents($target, $content)) {\n            $io->error('File \"'.$target.'\" could not be written');\n\n            return Command::FAILURE;\n        }\n        $io->success('Markdown has been generated and saved to \"'.$target.'\"');\n\n        return Command::SUCCESS;\n    }\n\n    private function generateMarkdown(string $content): string\n    {\n        $image = $this->createImage();\n        $imageDto = $this->imageFactory->createDto($image);\n        $dto = UserDto::create('BentiGorlich', 'a@b.test', avatar: $imageDto, cover: $imageDto);\n        $dto->plainPassword = 'secret';\n        $user = $this->userManager->create($dto, verifyUserEmail: false, preApprove: true);\n        $user = $this->userManager->edit($user, $dto);\n\n        $dto = UserDto::create('Melroy', 'a2@b.test', avatar: $imageDto, cover: $imageDto);\n        $dto->plainPassword = 'secret';\n        $user2 = $this->userManager->create($dto, verifyUserEmail: false, preApprove: true);\n        $user2 = $this->userManager->edit($user2, $dto);\n\n        $this->userManager->follow($user, $user2);\n        $this->userManager->follow($user2, $user);\n\n        $dto = new MagazineDto();\n        $dto->name = 'melroyMag';\n        $dto->title = 'Melroys Magazine';\n        $dto->description = 'Melroys wonderful magazine';\n        $dto->icon = $image;\n        $magazine = $this->magazineManager->create($dto, $user);\n\n        $dto = new EntryDto();\n        $dto->user = $user;\n        $dto->magazine = $magazine;\n        $dto->title = 'Bentis thread';\n        $dto->body = 'Bentis thread in melroys magazine';\n        $dto->lang = 'en';\n        $entry = $this->entryManager->create($dto, $user, rateLimit: false, stickyIt: true);\n        $entryCreate = $this->createWrapper->build($entry);\n\n        $dto = new EntryCommentDto();\n        $dto->user = $user;\n        $dto->magazine = $magazine;\n        $dto->entry = $entry;\n        $dto->body = 'melroys comment';\n        $dto->lang = 'en';\n        $entryComment = $this->entryCommentManager->create($dto, $user2, rateLimit: false);\n        $entryCommentCreate = $this->createWrapper->build($entryComment);\n\n        $dto = new PostDto();\n        $dto->user = $user;\n        $dto->magazine = $magazine;\n        $dto->lang = 'en';\n        $dto->body = 'Melroys post';\n        $post = $this->postManager->create($dto, $user, rateLimit: false);\n        $postCreate = $this->createWrapper->build($post);\n\n        $dto = new PostCommentDto();\n        $dto->user = $user;\n        $dto->magazine = $magazine;\n        $dto->lang = 'en';\n        $dto->body = 'Bentis post comment';\n        $dto->post = $post;\n        $postComment = $this->postCommentManager->create($dto, $user, rateLimit: false);\n        $postCommentCreate = $this->createWrapper->build($postComment);\n\n        $dto = new MessageDto();\n        $dto->body = 'Bentis message';\n        $thread = $this->messageManager->toThread($dto, $user, $user2);\n        $message = $thread->getLastMessage();\n\n        $userOutboxCollectionInfo = $this->collectionFactory->getUserOutboxCollection($user, false);\n        $userOutboxCollectionItems = $this->collectionFactory->getUserOutboxCollectionItems($user, 1, false);\n        $userFollowerCollection = $this->collectionInfoWrapper->build('ap_user_followers', ['username' => $user->username], $this->userRepository->findFollowers(1, $user)->getNbResults());\n        unset($userFollowerCollection['@context']);\n        $userFollowingCollection = $this->collectionInfoWrapper->build('ap_user_following', ['username' => $user->username], $this->userRepository->findFollowing(1, $user)->getNbResults());\n        unset($userFollowingCollection['@context']);\n        $moderatorCollection = $this->collectionFactory->getMagazineModeratorCollection($magazine, false);\n        $pinnedCollection = $this->collectionFactory->getMagazinePinnedCollection($magazine, false);\n        $magazineFollowersCollections = $this->collectionInfoWrapper->build('ap_magazine_followers', ['name' => $magazine->name], $magazine->subscriptionsCount);\n        $magazineFollowersCollectionItems = [];\n\n        $dto = ReportDto::create($entry, 'Spam');\n        $report = $this->reportManager->report($dto, $user2);\n\n        $activityUserFollow = $this->followWrapper->build($user, $user2);\n        $activityUserUndoFollow = $this->undoWrapper->build($activityUserFollow);\n        $activityUserAccept = $this->followResponseWrapper->build($user2, $activityUserFollow);\n        $activityUserCreate = $entryCreate;\n        $activityUserFlag = $this->flagFactory->build($report);\n        $activityUserLike = $this->likeWrapper->build($user2, $entry);\n        $activityUserUndoLike = $this->undoWrapper->build($activityUserLike);\n        $activityUserAnnounce = $this->announceWrapper->build($user2, $entry, true);\n        $activityUserUpdate = $this->updateWrapper->buildForActor($user2);\n        $activityUserEdit = $this->updateWrapper->buildForActivity($entry);\n        $activityUserDelete = $this->deleteWrapper->build($entry, includeContext: false);\n        $activityUserDeleteAccount = $this->deleteWrapper->buildForUser($user);\n        $activityUserLock = $this->lockFactory->build($user2, $entry);\n\n        $magazineBan = new MagazineBan($magazine, $user, $user2, 'A very specific reason', \\DateTimeImmutable::createFromFormat('Y-m-d', '2025-01-01'));\n        $this->entityManager->persist($magazineBan);\n\n        $activityModAddMod = $this->addRemoveFactory->buildAddModerator($user, $user2, $magazine);\n        $activityModRemoveMod = $this->addRemoveFactory->buildRemoveModerator($user, $user2, $magazine);\n        $activityModAddPin = $this->addRemoveFactory->buildAddPinnedPost($user, $entry);\n        $activityModRemovePin = $this->addRemoveFactory->buildRemovePinnedPost($user, $entry);\n        $activityModDelete = $this->deleteWrapper->adjustDeletePayload($user, $entryComment, false);\n        $activityModBan = $this->blockFactory->createActivityFromMagazineBan($magazineBan);\n        $activityModLock = $this->lockFactory->build($user, $entry);\n\n        $activityMagAnnounce = $this->announceWrapper->build($magazine, $entryCreate);\n        $activityAdminBan = $this->blockFactory->createActivityFromInstanceBan($user2, $user);\n        $activityAdminDeleteAccount = $this->deleteWrapper->buildForUser($user);\n\n        $jsonFlags = JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES;\n        $replaceVariables = [\n            '%@context%' => json_encode($this->contextsProvider->referencedContexts(), $jsonFlags),\n            '%@context_additional%' => json_encode(ContextsProvider::embeddedContexts(), $jsonFlags),\n            '%actor_instance%' => json_encode($this->instanceFactory->create(false), $jsonFlags),\n            '%actor_user%' => json_encode($this->personFactory->create($user, false), $jsonFlags),\n            '%actor_magazine%' => json_encode($this->groupFactory->create($magazine, false), $jsonFlags),\n            '%object_entry%' => json_encode($this->entryPageFactory->create($entry, []), $jsonFlags),\n            '%object_entry_comment%' => json_encode($this->entryCommentNoteFactory->create($entryComment, []), $jsonFlags),\n            '%object_post%' => json_encode($this->postNoteFactory->create($post, []), $jsonFlags),\n            '%object_post_comment%' => json_encode($this->postCommentNoteFactory->create($postComment, []), $jsonFlags),\n            '%object_message%' => json_encode($this->messageFactory->build($message, false), $jsonFlags),\n            '%collection_user_outbox%' => json_encode($userOutboxCollectionInfo, $jsonFlags),\n            '%collection_items_user_outbox%' => json_encode($userOutboxCollectionItems, $jsonFlags),\n            '%collection_user_followers%' => json_encode($userFollowerCollection, $jsonFlags),\n            '%collection_user_followings%' => json_encode($userFollowingCollection, $jsonFlags),\n            '%collection_magazine_outbox%' => json_encode(new \\stdClass(), $jsonFlags),\n            '%collection_magazine_followers%' => json_encode($magazineFollowersCollections, $jsonFlags),\n            '%collection_items_magazine_followers%' => json_encode($magazineFollowersCollectionItems, $jsonFlags),\n            '%collection_magazine_moderators%' => json_encode($moderatorCollection, $jsonFlags),\n            '%collection_magazine_featured%' => json_encode($pinnedCollection, $jsonFlags),\n            '%activity_user_follow%' => json_encode($this->activityJsonBuilder->buildActivityJson($activityUserFollow, false), $jsonFlags),\n            '%activity_user_undo_follow%' => json_encode($this->activityJsonBuilder->buildActivityJson($activityUserUndoFollow, false), $jsonFlags),\n            '%activity_user_accept%' => json_encode($this->activityJsonBuilder->buildActivityJson($activityUserAccept, false), $jsonFlags),\n            '%activity_user_create%' => json_encode($this->activityJsonBuilder->buildActivityJson($activityUserCreate, false), $jsonFlags),\n            '%activity_user_flag%' => json_encode($this->activityJsonBuilder->buildActivityJson($activityUserFlag, false), $jsonFlags),\n            '%activity_user_like%' => json_encode($this->activityJsonBuilder->buildActivityJson($activityUserLike, false), $jsonFlags),\n            '%activity_user_undo_like%' => json_encode($this->activityJsonBuilder->buildActivityJson($activityUserUndoLike, false), $jsonFlags),\n            '%activity_user_announce%' => json_encode($this->activityJsonBuilder->buildActivityJson($activityUserAnnounce, false), $jsonFlags),\n            '%activity_user_update_user%' => json_encode($this->activityJsonBuilder->buildActivityJson($activityUserUpdate, false), $jsonFlags),\n            '%activity_user_update_content%' => json_encode($this->activityJsonBuilder->buildActivityJson($activityUserEdit, false), $jsonFlags),\n            '%activity_user_delete%' => json_encode($this->activityJsonBuilder->buildActivityJson($activityUserDelete, false), $jsonFlags),\n            '%activity_user_delete_account%' => json_encode($this->activityJsonBuilder->buildActivityJson($activityUserDeleteAccount, false), $jsonFlags),\n            '%activity_user_lock%' => json_encode($this->activityJsonBuilder->buildActivityJson($activityUserLock, false), $jsonFlags),\n            '%activity_mod_add_mod%' => json_encode($this->activityJsonBuilder->buildActivityJson($activityModAddMod, false), $jsonFlags),\n            '%activity_mod_remove_mod%' => json_encode($this->activityJsonBuilder->buildActivityJson($activityModRemoveMod, false), $jsonFlags),\n            '%activity_mod_add_pin%' => json_encode($this->activityJsonBuilder->buildActivityJson($activityModAddPin, false), $jsonFlags),\n            '%activity_mod_remove_pin%' => json_encode($this->activityJsonBuilder->buildActivityJson($activityModRemovePin, false), $jsonFlags),\n            '%activity_mod_lock%' => json_encode($this->activityJsonBuilder->buildActivityJson($activityModLock, false), $jsonFlags),\n            '%activity_mod_delete%' => json_encode($this->activityJsonBuilder->buildActivityJson($activityModDelete, false), $jsonFlags),\n            '%activity_mod_ban%' => json_encode($this->activityJsonBuilder->buildActivityJson($activityModBan, false), $jsonFlags),\n            '%activity_mag_announce%' => json_encode($this->activityJsonBuilder->buildActivityJson($activityMagAnnounce, false), $jsonFlags),\n            '%activity_admin_ban%' => json_encode($this->activityJsonBuilder->buildActivityJson($activityAdminBan, false), $jsonFlags),\n            '%activity_admin_delete_account%' => json_encode($this->activityJsonBuilder->buildActivityJson($activityAdminDeleteAccount, false), $jsonFlags),\n        ];\n\n        foreach ($replaceVariables as $key => $value) {\n            $content = str_replace($key, $value, $content);\n        }\n\n        return $content;\n    }\n\n    protected function createImage(): Image\n    {\n        $fileName = hash('sha256', 'random');\n        $image = new Image($fileName, $fileName, $fileName, 100, 100, null);\n        $this->entityManager->persist($image);\n\n        return $image;\n    }\n}\n"
  },
  {
    "path": "src/Command/ImageCacheCommand.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Command;\n\nuse App\\Entity\\Entry;\nuse App\\Entity\\EntryComment;\nuse App\\Entity\\Magazine;\nuse App\\Entity\\Post;\nuse App\\Entity\\PostComment;\nuse App\\Entity\\User;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Symfony\\Component\\Console\\Attribute\\AsCommand;\nuse Symfony\\Component\\Console\\Command\\Command;\nuse Symfony\\Component\\Console\\Input\\ArrayInput;\nuse Symfony\\Component\\Console\\Input\\InputInterface;\nuse Symfony\\Component\\Console\\Output\\NullOutput;\nuse Symfony\\Component\\Console\\Output\\OutputInterface;\n\n#[AsCommand(\n    name: 'mbin:cache:build',\n    description: 'This command allows you to rebuild image thumbs cache.'\n)]\nclass ImageCacheCommand extends Command\n{\n    public function __construct(private readonly EntityManagerInterface $entityManager)\n    {\n        parent::__construct();\n    }\n\n    protected function execute(InputInterface $input, OutputInterface $output): int\n    {\n        $this->buildUsersCache();\n        $this->buildEntriesCache();\n        $this->buildEntryCommentsCache();\n        $this->buildPostsCache();\n        $this->buildPostCommentsCache();\n        $this->buildMagazinesCache();\n\n        return 1;\n    }\n\n    private function buildUsersCache(): void\n    {\n        $repo = $this->entityManager->getRepository(User::class);\n        $res = $repo->createQueryBuilder('u')->select('i.filePath')\n            ->join('u.avatar', 'i')\n            ->getQuery()\n            ->getArrayResult();\n\n        foreach ($res as $image) {\n            if (!$image['filePath']) {\n                continue;\n            }\n            $command = $this->getApplication()->find('liip:imagine:cache:resolve');\n\n            $arguments = [\n                'paths' => [$image['filePath']],\n                '--filter' => ['avatar_thumb'],\n            ];\n\n            $input = new ArrayInput($arguments);\n            $returnCode = $command->run($input, new NullOutput());\n        }\n    }\n\n    private function buildEntriesCache(): void\n    {\n        $repo = $this->entityManager->getRepository(Entry::class);\n        $res = $repo->createQueryBuilder('e')->select('i.filePath')\n            ->join('e.image', 'i')\n            ->getQuery()\n            ->getArrayResult();\n\n        foreach ($res as $image) {\n            if (!$image['filePath']) {\n                continue;\n            }\n            $command = $this->getApplication()->find('liip:imagine:cache:resolve');\n\n            $arguments = [\n                'paths' => [$image['filePath']],\n                '--filter' => ['entry_thumb'],\n            ];\n\n            $input = new ArrayInput($arguments);\n            $returnCode = $command->run($input, new NullOutput());\n        }\n    }\n\n    private function buildEntryCommentsCache(): void\n    {\n        $repo = $this->entityManager->getRepository(EntryComment::class);\n        $res = $repo->createQueryBuilder('c')->select('i.filePath')\n            ->join('c.image', 'i')\n            ->getQuery()\n            ->getArrayResult();\n\n        foreach ($res as $image) {\n            if (!$image['filePath']) {\n                continue;\n            }\n            $command = $this->getApplication()->find('liip:imagine:cache:resolve');\n\n            $arguments = [\n                'paths' => [$image['filePath']],\n                '--filter' => ['post_thumb'],\n            ];\n\n            $input = new ArrayInput($arguments);\n            $returnCode = $command->run($input, new NullOutput());\n        }\n    }\n\n    private function buildPostsCache(): void\n    {\n        $repo = $this->entityManager->getRepository(Post::class);\n        $res = $repo->createQueryBuilder('p')->select('i.filePath')\n            ->join('p.image', 'i')\n            ->getQuery()\n            ->getArrayResult();\n\n        foreach ($res as $image) {\n            if (!$image['filePath']) {\n                continue;\n            }\n            $command = $this->getApplication()->find('liip:imagine:cache:resolve');\n\n            $arguments = [\n                'paths' => [$image['filePath']],\n                '--filter' => ['post_thumb'],\n            ];\n\n            $input = new ArrayInput($arguments);\n            $returnCode = $command->run($input, new NullOutput());\n        }\n    }\n\n    private function buildPostCommentsCache(): void\n    {\n        $repo = $this->entityManager->getRepository(PostComment::class);\n        $res = $repo->createQueryBuilder('c')->select('i.filePath')\n            ->join('c.image', 'i')\n            ->getQuery()\n            ->getArrayResult();\n\n        foreach ($res as $image) {\n            if (!$image['filePath']) {\n                continue;\n            }\n            $command = $this->getApplication()->find('liip:imagine:cache:resolve');\n\n            $arguments = [\n                'paths' => [$image['filePath']],\n                '--filter' => ['post_thumb'],\n            ];\n\n            $input = new ArrayInput($arguments);\n            $returnCode = $command->run($input, new NullOutput());\n        }\n    }\n\n    private function buildMagazinesCache(): void\n    {\n        $repo = $this->entityManager->getRepository(Magazine::class);\n        $res = $repo->createQueryBuilder('m')->select('i.filePath')\n            ->join('m.icon', 'i')\n            ->getQuery()\n            ->getArrayResult();\n\n        foreach ($res as $image) {\n            if (!$image['filePath']) {\n                continue;\n            }\n            $command = $this->getApplication()->find('liip:imagine:cache:resolve');\n\n            $arguments = [\n                'paths' => [$image['filePath']],\n                '--filter' => ['post_thumb'],\n            ];\n\n            $input = new ArrayInput($arguments);\n            $returnCode = $command->run($input, new NullOutput());\n        }\n    }\n}\n"
  },
  {
    "path": "src/Command/MagazineCreateCommand.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Command;\n\nuse App\\DTO\\MagazineDto;\nuse App\\Repository\\MagazineRepository;\nuse App\\Repository\\UserRepository;\nuse App\\Service\\MagazineManager;\nuse Symfony\\Component\\Console\\Attribute\\AsCommand;\nuse Symfony\\Component\\Console\\Command\\Command;\nuse Symfony\\Component\\Console\\Input\\InputArgument;\nuse Symfony\\Component\\Console\\Input\\InputInterface;\nuse Symfony\\Component\\Console\\Input\\InputOption;\nuse Symfony\\Component\\Console\\Output\\OutputInterface;\nuse Symfony\\Component\\Console\\Style\\SymfonyStyle;\n\n#[AsCommand(\n    name: 'mbin:magazine:create',\n    description: 'This command allows you to create, delete and purge magazines.',\n)]\nclass MagazineCreateCommand extends Command\n{\n    public function __construct(\n        private readonly MagazineManager $magazineManager,\n        private readonly UserRepository $userRepository,\n        private readonly MagazineRepository $magazineRepository,\n    ) {\n        parent::__construct();\n    }\n\n    protected function configure(): void\n    {\n        $this\n            ->addArgument('name', InputArgument::REQUIRED)\n            ->addOption('owner', 'o', InputOption::VALUE_REQUIRED, 'the owner of the magazine')\n            ->addOption('remove', 'r', InputOption::VALUE_NONE, 'Remove the magazine')\n            ->addOption('purge', null, InputOption::VALUE_NONE, 'Purge the magazine')\n            ->addOption('restricted', null, InputOption::VALUE_NONE, 'Restrict the creation of threads to moderators')\n            ->addOption('title', 't', InputOption::VALUE_REQUIRED, 'the title of the magazine')\n            ->addOption('description', 'd', InputOption::VALUE_REQUIRED, 'the description of the magazine')\n        ;\n    }\n\n    protected function execute(InputInterface $input, OutputInterface $output): int\n    {\n        $io = new SymfonyStyle($input, $output);\n        $remove = $input->getOption('remove');\n        $purge = $input->getOption('purge');\n        $restricted = $input->getOption('restricted');\n        $ownerInput = $input->getOption('owner');\n        if ($ownerInput) {\n            $user = $this->userRepository->findOneByUsername($ownerInput);\n            if (null === $user) {\n                $io->error(\\sprintf('There is no user named: \"%s\"', $input->getArgument('owner')));\n\n                return Command::FAILURE;\n            }\n        } else {\n            $user = $this->userRepository->findAdmin();\n        }\n        $magazineName = $input->getArgument('name');\n        $existing = $this->magazineRepository->findOneBy(['name' => $magazineName, 'apId' => null]);\n        if ($remove || $purge) {\n            if (null !== $existing) {\n                if ($remove) {\n                    $this->magazineManager->delete($existing);\n                    $io->success(\\sprintf('The magazine \"%s\" has been removed.', $magazineName));\n\n                    return Command::SUCCESS;\n                } else {\n                    $this->magazineManager->purge($existing);\n                    $io->success(\\sprintf('The magazine \"%s\" has been purged.', $magazineName));\n\n                    return Command::SUCCESS;\n                }\n            } else {\n                $io->error(\\sprintf('There is no magazine named: \"%s\"', $magazineName));\n\n                return Command::FAILURE;\n            }\n        }\n\n        if (null !== $existing) {\n            $io->error(\\sprintf('There already is a magazine called \"%s\"', $magazineName));\n\n            return Command::FAILURE;\n        }\n\n        $dto = new MagazineDto();\n        $dto->name = $magazineName;\n        $dto->title = $input->getOption('title') ?? $magazineName;\n        $dto->description = $input->getOption('description');\n        $dto->isPostingRestrictedToMods = $restricted;\n\n        $magazine = $this->magazineManager->create($dto, $user, rateLimit: false);\n        $io->success(\\sprintf('The magazine \"%s\" was created successfully', $magazine->name));\n\n        return Command::SUCCESS;\n    }\n}\n"
  },
  {
    "path": "src/Command/MagazineUnsubCommand.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Command;\n\nuse App\\Repository\\MagazineRepository;\nuse App\\Service\\MagazineManager;\nuse Symfony\\Component\\Console\\Attribute\\AsCommand;\nuse Symfony\\Component\\Console\\Command\\Command;\nuse Symfony\\Component\\Console\\Input\\InputArgument;\nuse Symfony\\Component\\Console\\Input\\InputInterface;\nuse Symfony\\Component\\Console\\Output\\OutputInterface;\nuse Symfony\\Component\\Console\\Style\\SymfonyStyle;\n\n#[AsCommand(\n    name: 'mbin:magazine:unsub',\n    description: 'Remove all the subscribers from a magazine',\n)]\nclass MagazineUnsubCommand extends Command\n{\n    public function __construct(\n        private readonly MagazineRepository $repository,\n        private readonly MagazineManager $manager,\n    ) {\n        parent::__construct();\n    }\n\n    protected function configure(): void\n    {\n        $this->addArgument('magazine', InputArgument::REQUIRED);\n    }\n\n    protected function execute(InputInterface $input, OutputInterface $output): int\n    {\n        $io = new SymfonyStyle($input, $output);\n        $magazine = $this->repository->findOneByName($input->getArgument('magazine'));\n\n        if ($magazine) {\n            foreach ($magazine->subscriptions as $sub) {\n                $this->manager->unsubscribe($magazine, $sub->user);\n            }\n\n            $io->success('User unsubscribed');\n\n            return Command::SUCCESS;\n        }\n\n        return Command::FAILURE;\n    }\n}\n"
  },
  {
    "path": "src/Command/ModeratorCommand.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Command;\n\nuse App\\Repository\\UserRepository;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Symfony\\Component\\Console\\Attribute\\AsCommand;\nuse Symfony\\Component\\Console\\Command\\Command;\nuse Symfony\\Component\\Console\\Input\\InputArgument;\nuse Symfony\\Component\\Console\\Input\\InputInterface;\nuse Symfony\\Component\\Console\\Input\\InputOption;\nuse Symfony\\Component\\Console\\Output\\OutputInterface;\nuse Symfony\\Component\\Console\\Style\\SymfonyStyle;\n\n#[AsCommand(\n    name: 'mbin:user:moderator',\n    description: 'This command allows you to grant global moderator privileges to the user.',\n)]\nclass ModeratorCommand extends Command\n{\n    public function __construct(\n        private readonly EntityManagerInterface $entityManager,\n        private readonly UserRepository $repository,\n    ) {\n        parent::__construct();\n    }\n\n    protected function configure(): void\n    {\n        $this\n            ->addArgument('username', InputArgument::REQUIRED)\n            ->addOption('remove', 'r', InputOption::VALUE_NONE, 'Remove privileges');\n    }\n\n    protected function execute(InputInterface $input, OutputInterface $output): int\n    {\n        $io = new SymfonyStyle($input, $output);\n        $remove = $input->getOption('remove');\n        $user = $this->repository->findOneByUsername($input->getArgument('username'));\n\n        if (!$user) {\n            $io->error('User not found.');\n\n            return Command::FAILURE;\n        }\n\n        $user->setOrRemoveModeratorRole($remove);\n        $this->entityManager->flush();\n\n        $remove ? $io->success('Global moderator privileges have been revoked.')\n            : $io->success('Global moderator privileges have been granted.');\n\n        return Command::SUCCESS;\n    }\n}\n"
  },
  {
    "path": "src/Command/MoveEntriesByTagCommand.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Command;\n\nuse App\\Entity\\EntryComment;\nuse App\\Entity\\Favourite;\nuse App\\Entity\\Magazine;\nuse App\\Entity\\Report;\nuse App\\Repository\\EntryRepository;\nuse App\\Repository\\MagazineRepository;\nuse Doctrine\\Common\\Collections\\ArrayCollection;\nuse Doctrine\\Common\\Collections\\Collection;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Symfony\\Component\\Console\\Attribute\\AsCommand;\nuse Symfony\\Component\\Console\\Command\\Command;\nuse Symfony\\Component\\Console\\Input\\InputArgument;\nuse Symfony\\Component\\Console\\Input\\InputInterface;\nuse Symfony\\Component\\Console\\Output\\OutputInterface;\nuse Symfony\\Component\\Console\\Style\\SymfonyStyle;\n\n#[AsCommand(\n    name: 'mbin:entries:move',\n    description: 'This command allows you to move entries to a new magazine based on their tag.'\n)]\nclass MoveEntriesByTagCommand extends Command\n{\n    public function __construct(\n        private readonly EntityManagerInterface $entityManager,\n        private readonly MagazineRepository $magazineRepository,\n        private readonly EntryRepository $entryRepository,\n    ) {\n        parent::__construct();\n    }\n\n    protected function configure(): void\n    {\n        $this->addArgument('magazine', InputArgument::REQUIRED)\n            ->addArgument('tag', InputArgument::REQUIRED);\n    }\n\n    protected function execute(InputInterface $input, OutputInterface $output): int\n    {\n        $io = new SymfonyStyle($input, $output);\n\n        $magazine = $this->magazineRepository->findOneByName($input->getArgument('magazine'));\n        $tag = $input->getArgument('tag');\n\n        if (!$magazine) {\n            $io->error('The magazine does not exist.');\n\n            return Command::FAILURE;\n        }\n\n        $entries = $this->entryRepository->createQueryBuilder('e')\n            ->where('t.tag = :tag')\n            ->join('e.hashtags', 'h')\n            ->join('h.hashtag', 't')\n            ->setParameter('tag', $tag)\n            ->getQuery()\n            ->getResult();\n\n        foreach ($entries as $entry) {\n            /*\n             * @var Entry $entry\n             */\n            $entry->magazine = $magazine;\n\n            $this->moveComments($entry->comments, $magazine);\n            $this->moveReports($entry->reports, $magazine);\n            $this->moveFavourites($entry->favourites, $magazine);\n            $entry->badges->clear();\n\n            $tags = array_diff($entry->tags, [$tag]);\n            $entry->tags = \\count($tags) ? array_values($tags) : null;\n\n            $this->entityManager->persist($entry);\n        }\n\n        $this->entityManager->flush();\n\n        return Command::SUCCESS;\n    }\n\n    /**\n     * @param ArrayCollection<int, EntryComment>|Collection<int, EntryComment> $comments\n     */\n    private function moveComments(ArrayCollection|Collection $comments, Magazine $magazine): void\n    {\n        foreach ($comments as $comment) {\n            /*\n             * @var EntryComment $comment\n             */\n            $comment->magazine = $magazine;\n\n            $this->moveReports($comment->reports, $magazine);\n            $this->moveFavourites($comment->favourites, $magazine);\n\n            $this->entityManager->persist($comment);\n        }\n    }\n\n    /**\n     * @param ArrayCollection<int, Report>|Collection<int, Report> $reports\n     */\n    private function moveReports(ArrayCollection|Collection $reports, Magazine $magazine): void\n    {\n        foreach ($reports as $report) {\n            /*\n             * @var Report $report\n             */\n            $report->magazine = $magazine;\n\n            $this->entityManager->persist($report);\n        }\n    }\n\n    /**\n     * @param ArrayCollection<int, Favourite>|Collection<int, Favourite> $favourites\n     */\n    private function moveFavourites(ArrayCollection|Collection $favourites, Magazine $magazine): void\n    {\n        foreach ($favourites as $favourite) {\n            /*\n             * @var Favourite $favourite\n             */\n            $favourite->magazine = $magazine;\n\n            $this->entityManager->persist($favourite);\n        }\n    }\n}\n"
  },
  {
    "path": "src/Command/MovePostsByTagCommand.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Command;\n\nuse App\\Entity\\EntryComment;\nuse App\\Entity\\Favourite;\nuse App\\Entity\\Magazine;\nuse App\\Entity\\Report;\nuse App\\Repository\\MagazineRepository;\nuse App\\Repository\\PostRepository;\nuse App\\Service\\PostManager;\nuse Doctrine\\Common\\Collections\\ArrayCollection;\nuse Doctrine\\Common\\Collections\\Collection;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Symfony\\Component\\Console\\Attribute\\AsCommand;\nuse Symfony\\Component\\Console\\Command\\Command;\nuse Symfony\\Component\\Console\\Input\\InputArgument;\nuse Symfony\\Component\\Console\\Input\\InputInterface;\nuse Symfony\\Component\\Console\\Output\\OutputInterface;\nuse Symfony\\Component\\Console\\Style\\SymfonyStyle;\n\n#[AsCommand(\n    name: 'mbin:posts:move',\n    description: 'This command allows you to move posts to a new magazine based on their tag.'\n)]\nclass MovePostsByTagCommand extends Command\n{\n    public function __construct(\n        private readonly PostManager $postManager,\n        private readonly EntityManagerInterface $entityManager,\n        private readonly MagazineRepository $magazineRepository,\n        private readonly PostRepository $postRepository,\n    ) {\n        parent::__construct();\n    }\n\n    protected function configure(): void\n    {\n        $this->addArgument('magazine', InputArgument::REQUIRED)\n            ->addArgument('tag', InputArgument::REQUIRED);\n    }\n\n    protected function execute(InputInterface $input, OutputInterface $output): int\n    {\n        $io = new SymfonyStyle($input, $output);\n\n        $magazine = $this->magazineRepository->findOneByName($input->getArgument('magazine'));\n        $tag = $input->getArgument('tag');\n\n        if (!$magazine) {\n            $io->error('The magazine does not exist.');\n\n            return Command::FAILURE;\n        }\n\n        $qb = $this->postRepository->createQueryBuilder('p');\n\n        $qb->andWhere('t.tag = :tag')\n            ->join('p.hashtags', 'h')\n            ->join('h.hashtag', 't')\n            ->setParameter('tag', $tag);\n\n        $posts = $qb->getQuery()->getResult();\n\n        foreach ($posts as $post) {\n            $output->writeln((string) $post->getId());\n            $this->postManager->changeMagazine($post, $magazine);\n        }\n\n        return Command::SUCCESS;\n    }\n\n    /**\n     * @param ArrayCollection<int, EntryComment>|Collection<int, EntryComment> $comments\n     */\n    private function moveComments(ArrayCollection|Collection $comments, Magazine $magazine): void\n    {\n        foreach ($comments as $comment) {\n            /*\n             * @var EntryComment $comment\n             */\n            $comment->magazine = $magazine;\n\n            $this->moveReports($comment->reports, $magazine);\n            $this->moveFavourites($comment->favourites, $magazine);\n\n            $this->entityManager->persist($comment);\n        }\n    }\n\n    /**\n     * @param ArrayCollection<int, Report>|Collection<int, Report> $reports\n     */\n    private function moveReports(ArrayCollection|Collection $reports, Magazine $magazine): void\n    {\n        foreach ($reports as $report) {\n            /*\n             * @var Report $report\n             */\n            $report->magazine = $magazine;\n\n            $this->entityManager->persist($report);\n        }\n    }\n\n    /**\n     * @param ArrayCollection<int, Favourite>|Collection<int, Favourite> $favourites\n     */\n    private function moveFavourites(ArrayCollection|Collection $favourites, Magazine $magazine): void\n    {\n        foreach ($favourites as $favourite) {\n            /*\n             * @var Favourite $favourite\n             */\n            $favourite->magazine = $magazine;\n\n            $this->entityManager->persist($favourite);\n        }\n    }\n}\n"
  },
  {
    "path": "src/Command/PostMagazinesUpdateCommand.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Command;\n\nuse App\\Entity\\Post;\nuse App\\Repository\\MagazineRepository;\nuse App\\Repository\\PostRepository;\nuse App\\Repository\\TagLinkRepository;\nuse App\\Service\\PostManager;\nuse Symfony\\Component\\Console\\Attribute\\AsCommand;\nuse Symfony\\Component\\Console\\Command\\Command;\nuse Symfony\\Component\\Console\\Input\\InputInterface;\nuse Symfony\\Component\\Console\\Output\\OutputInterface;\n\n#[AsCommand(\n    name: 'mbin:posts:magazines',\n    description: 'This command allows to assign a magazine to a post.',\n)]\nclass PostMagazinesUpdateCommand extends Command\n{\n    public function __construct(\n        private readonly PostRepository $postRepository,\n        private readonly PostManager $postManager,\n        private readonly TagLinkRepository $tagLinkRepository,\n        private readonly MagazineRepository $magazineRepository,\n    ) {\n        parent::__construct();\n    }\n\n    protected function execute(InputInterface $input, OutputInterface $output): int\n    {\n        $posts = $this->postRepository->findTaggedFederatedInRandomMagazine();\n        foreach ($posts as $post) {\n            $this->handleMagazine($post, $output);\n        }\n\n        return Command::SUCCESS;\n    }\n\n    private function handleMagazine(Post $post, OutputInterface $output): void\n    {\n        $tags = $this->tagLinkRepository->getTagsOfContent($post);\n\n        $output->writeln((string) $post->getId());\n        foreach ($tags as $tag) {\n            if ($magazine = $this->magazineRepository->findOneByName($tag)) {\n                $output->writeln($magazine->name);\n                $this->postManager->changeMagazine($post, $magazine);\n                break;\n            }\n\n            if ($magazine = $this->magazineRepository->findByTag($tag)) {\n                $output->writeln($magazine->name);\n                $this->postManager->changeMagazine($post, $magazine);\n                break;\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "src/Command/RefreshImageMetaDataCommand.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Command;\n\nuse App\\Repository\\ImageRepository;\nuse App\\Utils\\GeneralUtil;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse League\\Flysystem\\FilesystemException;\nuse League\\Flysystem\\FilesystemOperator;\nuse Psr\\Log\\LoggerInterface;\nuse Symfony\\Component\\Console\\Attribute\\AsCommand;\nuse Symfony\\Component\\Console\\Command\\Command;\nuse Symfony\\Component\\Console\\Input\\InputInterface;\nuse Symfony\\Component\\Console\\Input\\InputOption;\nuse Symfony\\Component\\Console\\Output\\OutputInterface;\nuse Symfony\\Component\\Console\\Style\\SymfonyStyle;\n\n#[AsCommand(\n    name: 'mbin:images:refresh-meta',\n    description: 'Refresh meta information about your media',\n)]\nclass RefreshImageMetaDataCommand extends Command\n{\n    public function __construct(\n        private readonly ImageRepository $imageRepository,\n        private readonly FilesystemOperator $publicUploadsFilesystem,\n        private readonly LoggerInterface $logger,\n        private readonly EntityManagerInterface $entityManager,\n    ) {\n        parent::__construct();\n    }\n\n    protected function configure(): void\n    {\n        $this->addOption('batch-size', null, InputOption::VALUE_REQUIRED, 'The number of images to handle at once, the higher the number the faster the command, but it also takes more memory', '10000');\n        $this->addOption('dry-run', null, InputOption::VALUE_NONE, 'Do a trial without removing any media');\n    }\n\n    protected function execute(InputInterface $input, OutputInterface $output): int\n    {\n        $io = new SymfonyStyle($input, $output);\n        GeneralUtil::useProgressbarFormatsWithMessage();\n\n        $dryRun = \\boolval($input->getOption('dry-run'));\n        $batchSize = \\intval($input->getOption('batch-size'));\n        $images = $this->imageRepository->findSavedImagesPaginated($batchSize);\n        $count = $images->count();\n        $progressBar = $io->createProgressBar($count);\n        $progressBar->setMessage('');\n        $progressBar->start();\n        $totalCheckedFiles = 0;\n        $totalUpdateFiles = 0;\n\n        for ($i = 0; $i < $images->getNbPages(); ++$i) {\n            $progressBar->setMessage(\\sprintf('Fetching images %s - %s', ($i * $batchSize) + 1, ($i + 1) * $batchSize));\n            $progressBar->display();\n            foreach ($images->getCurrentPageResults() as $image) {\n                $progressBar->advance();\n                ++$totalCheckedFiles;\n\n                try {\n                    if ($this->publicUploadsFilesystem->has($image->filePath)) {\n                        ++$totalUpdateFiles;\n                        $fileSize = $this->publicUploadsFilesystem->fileSize($image->filePath);\n                        if (!$dryRun) {\n                            $image->localSize = $fileSize;\n                            $progressBar->setMessage(\\sprintf('Refreshed meta data of \"%s\" (%s)', $image->filePath, $image->getId()));\n                            $this->logger->debug('Refreshed meta data of \"{path}\" ({id})', ['path' => $image->filePath, 'id' => $image->getId()]);\n                        } else {\n                            $progressBar->setMessage(\\sprintf('Would have refreshed meta data of \"%s\" (%s)', $image->filePath, $image->getId()));\n                        }\n                        $progressBar->display();\n                    } else {\n                        $previousPath = $image->filePath;\n                        // mark it as not present on the media storage\n                        if (!$dryRun) {\n                            $image->filePath = null;\n                            $image->localSize = 0;\n                            $image->downloadedAt = null;\n                            $progressBar->setMessage(\\sprintf('Marked \"%s\" (%s) as not present on the media storage', $previousPath, $image->getId()));\n                        } else {\n                            $progressBar->setMessage(\\sprintf('Would have marked \"%s\" (%s) as not present on the media storage', $image->filePath, $image->getId()));\n                        }\n                        $progressBar->display();\n                    }\n                } catch (FilesystemException $e) {\n                    $this->logger->error('There was an exception refreshing the meta data of \"{path}\" ({id}): {exClass} - {message}', [\n                        'path' => $image->filePath,\n                        'id' => $image->getId(),\n                        'exClass' => \\get_class($image),\n                        'message' => $e->getMessage(),\n                        'exception' => $e,\n                    ]);\n                    $progressBar->setMessage(\\sprintf('Error checking meta data of \"%s\" (%s)', $image->filePath, $image->getId()));\n                    $progressBar->display();\n                }\n            }\n            if (!$dryRun) {\n                $this->entityManager->flush();\n            }\n            if ($images->hasNextPage()) {\n                $images->setCurrentPage($images->getNextPage());\n            }\n        }\n        $io->writeln('');\n        if (!$dryRun) {\n            $io->success(\\sprintf('Refreshed %s files', $totalUpdateFiles));\n        } else {\n            $io->success(\\sprintf('Would have refreshed %s files', $totalUpdateFiles));\n        }\n\n        return Command::SUCCESS;\n    }\n}\n"
  },
  {
    "path": "src/Command/RemoveAccountsMarkedForDeletion.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Command;\n\nuse App\\Message\\DeleteUserMessage;\nuse App\\Service\\UserManager;\nuse Symfony\\Component\\Console\\Attribute\\AsCommand;\nuse Symfony\\Component\\Console\\Command\\Command;\nuse Symfony\\Component\\Console\\Input\\InputInterface;\nuse Symfony\\Component\\Console\\Output\\OutputInterface;\nuse Symfony\\Component\\Messenger\\MessageBusInterface;\n\n#[AsCommand(\n    name: 'mbin:users:remove-marked-for-deletion',\n    description: 'removes all accounts that are marked for deletion today or in the past.',\n)]\nclass RemoveAccountsMarkedForDeletion extends Command\n{\n    public function __construct(\n        private readonly MessageBusInterface $bus,\n        private readonly UserManager $userManager,\n    ) {\n        parent::__construct();\n    }\n\n    protected function execute(InputInterface $input, OutputInterface $output): int\n    {\n        $users = $this->userManager->getUsersMarkedForDeletionBefore();\n        $deletedUsers = 0;\n        foreach ($users as $user) {\n            $output->writeln(\"deleting $user->username\");\n            try {\n                $this->bus->dispatch(new DeleteUserMessage($user->getId()));\n                ++$deletedUsers;\n            } catch (\\Exception|\\Error $e) {\n                $output->writeln('an error occurred during the deletion of '.$user->username.': '.\\get_class($e).' - '.$e->getMessage());\n            }\n        }\n        $output->writeln(\"deleted $deletedUsers user\");\n\n        return 0;\n    }\n}\n"
  },
  {
    "path": "src/Command/RemoveDMAndBanCommand.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Command;\n\nuse App\\Repository\\UserRepository;\nuse App\\Service\\UserManager;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Symfony\\Component\\Console\\Attribute\\AsCommand;\nuse Symfony\\Component\\Console\\Command\\Command;\nuse Symfony\\Component\\Console\\Helper\\Table;\nuse Symfony\\Component\\Console\\Input\\InputArgument;\nuse Symfony\\Component\\Console\\Input\\InputInterface;\nuse Symfony\\Component\\Console\\Output\\ConsoleOutput;\nuse Symfony\\Component\\Console\\Output\\OutputInterface;\nuse Symfony\\Component\\Console\\Style\\SymfonyStyle;\n\n#[AsCommand(\n    name: 'mbin:messages:remove_and_ban',\n    description: 'Removes found direct messages on body search and ban senders.',\n)]\nclass RemoveDMAndBanCommand extends Command\n{\n    private string $bodySearch;\n\n    public function __construct(\n        private readonly EntityManagerInterface $entityManager,\n        private readonly UserRepository $repository,\n        private readonly UserManager $manager,\n    ) {\n        parent::__construct();\n    }\n\n    protected function configure(): void\n    {\n        $this->addArgument('body', InputArgument::REQUIRED, 'Search query for direct message body.');\n    }\n\n    protected function execute(InputInterface $input, OutputInterface $output): int\n    {\n        $io = new SymfonyStyle($input, $output);\n        $this->bodySearch = (string) $input->getArgument('body');\n\n        try {\n            // Search and display messages\n            $this->searchMessages($io);\n\n            // Confirm?\n            if (!$io->confirm('Do you want to remove *all* found messages and ban sender users? This action is irreversible !!!', false)) {\n                // If not confirmed, exit\n                return Command::FAILURE;\n            }\n\n            // Ban sender users\n            $io->note('Banning sender users...');\n            $this->banSenders();\n\n            // Remove messages\n            $io->note('Removing direct messages...');\n            $this->removeMessages();\n\n            $io->success('Done!');\n            // Ban sender user\n        } catch (\\Exception $e) {\n            $io->error($e->getMessage());\n\n            return Command::FAILURE;\n        }\n\n        return Command::SUCCESS;\n    }\n\n    /**\n     * Search for direct messages matching the search query.\n     */\n    private function searchMessages(SymfonyStyle $io): void\n    {\n        $resultSet = $this->entityManager->getConnection()->executeQuery('\n            select m.id ,u.username, m.body\n            FROM message m\n            JOIN public.user u ON m.sender_id = u.id\n            WHERE body LIKE :body', ['body' => '%'.$this->bodySearch.'%']);\n        $results = $resultSet->fetchAllAssociative();\n\n        if (0 === \\count($results)) {\n            throw new \\Exception('No direct messages found.');\n        }\n\n        $io->text('Found '.\\count($results).' direct messages.');\n\n        // Display results\n        $table = new Table(new ConsoleOutput());\n        $table\n            ->setHeaders(['DM ID', 'Sender username', 'Body direct message'])\n            ->setRows(array_map(fn ($item) => [\n                $item['id'],\n                $item['username'],\n                wordwrap(str_replace([\"\\r\\n\", \"\\r\", \"\\n\"], ' ', $item['body']), 60, PHP_EOL, true),\n            ], $results));\n        $table->render();\n    }\n\n    /**\n     * Ban sender users based on the found messages.\n     */\n    private function banSenders(): void\n    {\n        $this->entityManager->getConnection()->executeQuery('\n            UPDATE public.user\n            SET is_banned = TRUE\n            WHERE id IN (\n                SELECT DISTINCT m.sender_id\n                FROM message m\n                JOIN public.user u ON m.sender_id = u.id\n                WHERE body LIKE :body\n            )', ['body' => '%'.$this->bodySearch.'%']);\n    }\n\n    /**\n     * Remove messages by removing message threads (message_thread table).\n     *\n     * Which will automatically do a cascade delete on the messages table and\n     * the message participants table.\n     */\n    private function removeMessages(): void\n    {\n        $this->entityManager->getConnection()->executeQuery('\n            DELETE FROM message_thread\n            WHERE id IN (\n                SELECT DISTINCT thread_id \n                FROM message \n                WHERE body LIKE :body\n            )', ['body' => '%'.$this->bodySearch.'%']);\n    }\n}\n"
  },
  {
    "path": "src/Command/RemoveDeadMessagesCommand.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Command;\n\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Symfony\\Component\\Console\\Attribute\\AsCommand;\nuse Symfony\\Component\\Console\\Command\\Command;\nuse Symfony\\Component\\Console\\Input\\InputInterface;\nuse Symfony\\Component\\Console\\Output\\OutputInterface;\nuse Symfony\\Component\\Console\\Style\\SymfonyStyle;\n\n#[AsCommand(\n    name: 'mbin:messenger:dead:remove_all',\n    description: 'This command removes all dead messages from the dead queue (database).',\n)]\nclass RemoveDeadMessagesCommand extends Command\n{\n    public function __construct(\n        private readonly EntityManagerInterface $entityManager,\n    ) {\n        parent::__construct();\n    }\n\n    protected function configure(): void\n    {\n    }\n\n    protected function execute(InputInterface $input, OutputInterface $output): int\n    {\n        $io = new SymfonyStyle($input, $output);\n\n        $this->removeDeadMessages();\n\n        return Command::SUCCESS;\n    }\n\n    /**\n     * Remove all dead messages from database.\n     */\n    private function removeDeadMessages(): void\n    {\n        $this->entityManager->getConnection()->executeQuery(\n            'DELETE FROM messenger_messages WHERE queue_name = ?',\n            ['dead']\n        );\n\n        // Followed by vacuuming the messenger_messages table.\n        $this->entityManager->getConnection()->executeQuery('VACUUM messenger_messages');\n    }\n}\n"
  },
  {
    "path": "src/Command/RemoveDuplicatesCommand.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Command;\n\nuse App\\Entity\\Post;\nuse App\\Entity\\User;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Symfony\\Component\\Console\\Attribute\\AsCommand;\nuse Symfony\\Component\\Console\\Command\\Command;\nuse Symfony\\Component\\Console\\Input\\InputInterface;\nuse Symfony\\Component\\Console\\Output\\OutputInterface;\nuse Symfony\\Component\\Console\\Style\\SymfonyStyle;\n\n#[AsCommand(\n    name: 'mbin:post:remove-duplicates',\n    description: 'This command removes post and user duplicates by their ActivityPub ID.',\n)]\nclass RemoveDuplicatesCommand extends Command\n{\n    public function __construct(\n        private readonly EntityManagerInterface $entityManager,\n    ) {\n        parent::__construct();\n    }\n\n    protected function configure(): void\n    {\n    }\n\n    protected function execute(InputInterface $input, OutputInterface $output): int\n    {\n        $io = new SymfonyStyle($input, $output);\n\n        $this->removePosts();\n        $this->removeActors();\n\n        return Command::SUCCESS;\n    }\n\n    private function removePosts(): void\n    {\n        $conn = $this->entityManager->getConnection();\n        $sql = '\n                SELECT *\n                FROM post\n                WHERE ap_id IN (\n                  SELECT ap_id\n                  FROM post\n                  GROUP BY ap_id\n                  HAVING COUNT(*) > 1\n                 )\n        ';\n        $stmt = $conn->prepare($sql);\n        $stmt = $stmt->executeQuery();\n\n        $results = $stmt->fetchAllAssociative();\n\n        foreach ($results as $item) {\n            try {\n                $post = $this->entityManager->getRepository(Post::class)->find($item['id']);\n                $this->entityManager->remove($post);\n                $this->entityManager->flush();\n            } catch (\\Exception $e) {\n            }\n        }\n    }\n\n    private function removeActors(): void\n    {\n        $conn = $this->entityManager->getConnection();\n        $sql = '\n                SELECT *\n                FROM \"user\"\n                WHERE ap_id IN (\n                  SELECT ap_id\n                  FROM \"user\"\n                  GROUP BY ap_id\n                  HAVING COUNT(*) > 1\n                 )\n        ';\n        $stmt = $conn->prepare($sql);\n        $stmt = $stmt->executeQuery();\n\n        $results = $stmt->fetchAllAssociative();\n\n        foreach ($results as $item) {\n            //            $this->entityManager->beginTransaction();\n\n            try {\n                $user = $this->entityManager->getRepository(User::class)->find($item['id']);\n                if ($user->posts->count() || $user->postComments->count() || $user->follows->count(\n                ) || $user->followers->count()) {\n                    continue;\n                }\n                $this->entityManager->remove($user);\n                $this->entityManager->flush();\n            } catch (\\Exception $e) {\n                //                $this->entityManager->rollback();\n\n                var_dump($e->getMessage());\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "src/Command/RemoveFailedMessagesCommand.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Command;\n\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Symfony\\Component\\Console\\Attribute\\AsCommand;\nuse Symfony\\Component\\Console\\Command\\Command;\nuse Symfony\\Component\\Console\\Input\\InputInterface;\nuse Symfony\\Component\\Console\\Output\\OutputInterface;\nuse Symfony\\Component\\Console\\Style\\SymfonyStyle;\n\n#[AsCommand(\n    name: 'mbin:messenger:failed:remove_all',\n    description: 'This command removes all failed messages from the failed queue (database).',\n)]\nclass RemoveFailedMessagesCommand extends Command\n{\n    public function __construct(\n        private readonly EntityManagerInterface $entityManager,\n    ) {\n        parent::__construct();\n    }\n\n    protected function configure(): void\n    {\n    }\n\n    protected function execute(InputInterface $input, OutputInterface $output): int\n    {\n        $io = new SymfonyStyle($input, $output);\n\n        $this->removeFailedMessages();\n\n        return Command::SUCCESS;\n    }\n\n    /**\n     * Remove all failed messages from database.\n     */\n    private function removeFailedMessages(): void\n    {\n        $this->entityManager->getConnection()->executeQuery(\n            'DELETE FROM messenger_messages WHERE queue_name = ?',\n            ['failed']\n        );\n\n        // Followed by vacuuming the messenger_messages table.\n        $this->entityManager->getConnection()->executeQuery('VACUUM messenger_messages');\n    }\n}\n"
  },
  {
    "path": "src/Command/RemoveOldImagesCommand.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Command;\n\nuse App\\Entity\\Entry;\nuse App\\Entity\\EntryComment;\nuse App\\Entity\\Post;\nuse App\\Entity\\PostComment;\nuse App\\Entity\\User;\nuse App\\Service\\EntryCommentManager;\nuse App\\Service\\EntryManager;\nuse App\\Service\\PostCommentManager;\nuse App\\Service\\PostManager;\nuse App\\Service\\UserManager;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Symfony\\Component\\Console\\Attribute\\AsCommand;\nuse Symfony\\Component\\Console\\Command\\Command;\nuse Symfony\\Component\\Console\\Input\\InputArgument;\nuse Symfony\\Component\\Console\\Input\\InputInterface;\nuse Symfony\\Component\\Console\\Input\\InputOption;\nuse Symfony\\Component\\Console\\Output\\OutputInterface;\nuse Symfony\\Component\\Console\\Style\\SymfonyStyle;\n\n#[AsCommand(\n    name: 'mbin:images:delete',\n    description: 'This command allows you to delete images from (old) federated content.'\n)]\nclass RemoveOldImagesCommand extends Command\n{\n    private int $batchSize = 800;\n    private int $monthsAgo = 12;\n    private bool $noActivity = false;\n\n    public function __construct(\n        private readonly EntityManagerInterface $entityManager,\n        private readonly EntryManager $entryManager,\n        private readonly EntryCommentManager $entryCommentManager,\n        private readonly PostManager $postManager,\n        private readonly PostCommentManager $postCommentManager,\n        private readonly UserManager $userManager,\n    ) {\n        parent::__construct();\n    }\n\n    public function configure()\n    {\n        $this\n            ->addArgument('type', InputArgument::OPTIONAL, 'Type of images to delete either: \"all\" (except for users), \"threads\", \"thread_comments\", \"posts\", \"post_comments\" or \"users\"', 'all')\n            ->addArgument('monthsAgo', InputArgument::OPTIONAL, 'Delete images older than x months', $this->monthsAgo)\n            ->addOption('noActivity', null, InputOption::VALUE_OPTIONAL, 'Delete image that doesn\\'t have recorded activity (comments, upvotes, boosts)', false)\n            ->addOption('batchSize', null, InputOption::VALUE_OPTIONAL, 'Number of images to delete at a time (for each type)', $this->batchSize);\n    }\n\n    /**\n     * Starting point, switch what image will get deleted based on the type input arg.\n     */\n    protected function execute(InputInterface $input, OutputInterface $output): int\n    {\n        $io = new SymfonyStyle($input, $output);\n        $type = $input->getArgument('type');\n        $this->monthsAgo = (int) $input->getArgument('monthsAgo');\n        if ($input->getOption('noActivity')) {\n            $this->noActivity = (bool) $input->getOption('noActivity');\n        }\n        $this->batchSize = (int) $input->getOption('batchSize');\n\n        if ('all' === $type) {\n            $nrDeletedImages = $this->deleteAllImages($output); // Except for user avatars and covers\n        } elseif ('threads' === $type) {\n            $nrDeletedImages = $this->deleteThreadsImages($output);\n        } elseif ('thread_comments' === $type) {\n            $nrDeletedImages = $this->deleteThreadCommentsImages($output);\n        } elseif ('posts' === $type) {\n            $nrDeletedImages = $this->deletePostsImages($output);\n        } elseif ('post_comments' === $type) {\n            $nrDeletedImages = $this->deletePostCommentsImages($output);\n        } elseif ('users' === $type) {\n            $nrDeletedImages = $this->deleteUsersImages($output);\n        } else {\n            $io->error('Invalid type of images to delete. Try \\'all\\', \\'threads\\', \\'thread_comments\\', \\'posts\\', \\'post_comments\\' or \\'users\\'.');\n\n            return Command::FAILURE;\n        }\n\n        $this->entityManager->clear();\n\n        $output->writeln(''); // New line\n        $output->writeln(\\sprintf('Total images deleted during this run: %d', $nrDeletedImages));\n\n        return Command::SUCCESS;\n    }\n\n    /**\n     * Call all delete methods below, _except_ for the delete users images.\n     * Since users on the instance can be several years old and not getting fetched,\n     * however we shouldn't remove their avatar/cover images just like that.\n     *\n     * @return number Total number of removed records from database\n     */\n    private function deleteAllImages($output): int\n    {\n        $threadsImagesRemoved = $this->deleteThreadsImages($output);\n        $threadCommentsImagesRemoved = $this->deleteThreadCommentsImages($output);\n        $postsImagesRemoved = $this->deletePostsImages($output);\n        $postCommentsImagesRemoved = $this->deletePostCommentsImages($output);\n\n        return $threadsImagesRemoved + $threadCommentsImagesRemoved + $postsImagesRemoved + $postCommentsImagesRemoved;\n    }\n\n    /**\n     * Delete thread images, check on created_at database column for the age.\n     * Limit by batch size.\n     *\n     * @return number Number of removed records from database\n     */\n    private function deleteThreadsImages(OutputInterface $output): int\n    {\n        $queryBuilder = $this->entityManager->createQueryBuilder();\n\n        $timeAgo = new \\DateTime(\"-{$this->monthsAgo} months\");\n\n        $query = $queryBuilder\n            ->select('e')\n            ->from(Entry::class, 'e')\n            ->where(\n                $queryBuilder->expr()->andX(\n                    $queryBuilder->expr()->lt('e.createdAt', ':timeAgo'),\n                    $queryBuilder->expr()->neq('i.id', 1),\n                    $queryBuilder->expr()->isNotNull('e.apId'),\n                    $this->noActivity ? $queryBuilder->expr()->eq('e.upVotes', 0) : null,\n                    $this->noActivity ? $queryBuilder->expr()->eq('e.commentCount', 0) : null,\n                    $this->noActivity ? $queryBuilder->expr()->eq('e.favouriteCount', 0) : null\n                )\n            )\n            ->innerJoin('e.image', 'i')\n            ->orderBy('e.id', 'ASC')\n            ->setParameter('timeAgo', $timeAgo)\n            ->setMaxResults($this->batchSize)\n            ->getQuery();\n\n        $entries = $query->getResult();\n\n        foreach ($entries as $entry) {\n            $output->writeln(\\sprintf('Deleting image from thread ID: %d, with ApId: %s', $entry->getId(), $entry->getApId()));\n            $this->entryManager->detachImage($entry);\n        }\n\n        // Return total number of elements deleted\n        return \\count($entries);\n    }\n\n    /**\n     * Delete thread comment images, check on created_at database column for the age.\n     * Limit by batch size.\n     *\n     * @return number Number of removed records from database\n     */\n    private function deleteThreadCommentsImages(OutputInterface $output): int\n    {\n        $queryBuilder = $this->entityManager->createQueryBuilder();\n\n        $timeAgo = new \\DateTime(\"-{$this->monthsAgo} months\");\n\n        $query = $queryBuilder\n            ->select('c')\n            ->from(EntryComment::class, 'c')\n            ->where(\n                $queryBuilder->expr()->andX(\n                    $queryBuilder->expr()->lt('c.createdAt', ':timeAgo'),\n                    $queryBuilder->expr()->neq('i.id', 1),\n                    $queryBuilder->expr()->isNotNull('c.apId'),\n                    $this->noActivity ? $queryBuilder->expr()->eq('c.upVotes', 0) : null,\n                    $this->noActivity ? $queryBuilder->expr()->eq('c.favouriteCount', 0) : null\n                )\n            )\n            ->innerJoin('c.image', 'i')\n            ->orderBy('c.id', 'ASC')\n            ->setParameter('timeAgo', $timeAgo)\n            ->setMaxResults($this->batchSize)\n            ->getQuery();\n\n        $comments = $query->getResult();\n\n        foreach ($comments as $comment) {\n            $output->writeln(\\sprintf('Deleting image from thread comment ID: %d, with ApId: %s', $comment->getId(), $comment->getApId()));\n            $this->entryCommentManager->detachImage($comment);\n        }\n\n        // Return total number of elements deleted\n        return \\count($comments);\n    }\n\n    /**\n     * Delete post images, check on created_at database column for the age.\n     * Limit by batch size.\n     *\n     * @return number Number of removed records from database\n     */\n    private function deletePostsImages(OutputInterface $output): int\n    {\n        $queryBuilder = $this->entityManager->createQueryBuilder();\n\n        $timeAgo = new \\DateTime(\"-{$this->monthsAgo} months\");\n\n        $query = $queryBuilder\n            ->select('p')\n            ->from(Post::class, 'p')\n            ->where(\n                $queryBuilder->expr()->andX(\n                    $queryBuilder->expr()->lt('p.createdAt', ':timeAgo'),\n                    $queryBuilder->expr()->neq('i.id', 1),\n                    $queryBuilder->expr()->isNotNull('p.apId'),\n                    $this->noActivity ? $queryBuilder->expr()->eq('p.upVotes', 0) : null,\n                    $this->noActivity ? $queryBuilder->expr()->eq('p.commentCount', 0) : null,\n                    $this->noActivity ? $queryBuilder->expr()->eq('p.favouriteCount', 0) : null\n                )\n            )\n            ->innerJoin('p.image', 'i')\n            ->orderBy('p.id', 'ASC')\n            ->setParameter('timeAgo', $timeAgo)\n            ->setMaxResults($this->batchSize)\n            ->getQuery();\n\n        $posts = $query->getResult();\n\n        foreach ($posts as $post) {\n            $output->writeln(\\sprintf('Deleting image from post ID: %d, with ApId: %s', $post->getId(), $post->getApId()));\n            $this->postManager->detachImage($post);\n        }\n\n        // Return total number of elements deleted\n        return \\count($posts);\n    }\n\n    /**\n     * Delete post comment images, check on created_at database column for the age.\n     * Limit by batch size.\n     *\n     * @return number Number of removed records from database\n     */\n    private function deletePostCommentsImages(OutputInterface $output): int\n    {\n        $queryBuilder = $this->entityManager->createQueryBuilder();\n\n        $timeAgo = new \\DateTime(\"-{$this->monthsAgo} months\");\n\n        $query = $queryBuilder\n            ->select('c')\n            ->from(PostComment::class, 'c')\n            ->where(\n                $queryBuilder->expr()->andX(\n                    $queryBuilder->expr()->lt('c.createdAt', ':timeAgo'),\n                    $queryBuilder->expr()->neq('i.id', 1),\n                    $queryBuilder->expr()->isNotNull('c.apId'),\n                    $this->noActivity ? $queryBuilder->expr()->eq('c.upVotes', 0) : null,\n                    $this->noActivity ? $queryBuilder->expr()->eq('c.favouriteCount', 0) : null\n                )\n            )\n            ->innerJoin('c.image', 'i')\n            ->orderBy('c.id', 'ASC')\n            ->setParameter('timeAgo', $timeAgo)\n            ->setMaxResults($this->batchSize)\n            ->getQuery();\n\n        $comments = $query->getResult();\n\n        foreach ($comments as $comment) {\n            $output->writeln(\\sprintf('Deleting image from post comment ID: %d, with ApId: %s', $comment->getId(), $comment->getApId()));\n            $this->postCommentManager->detachImage($comment);\n        }\n\n        // Return total number of elements deleted\n        return \\count($comments);\n    }\n\n    /**\n     * Delete user avatar and user cover images. Check ap_fetched_at column for the age.\n     * Limit by batch size.\n     *\n     * @return number Number of removed records from database\n     */\n    private function deleteUsersImages(OutputInterface $output): int\n    {\n        $queryBuilder = $this->entityManager->createQueryBuilder();\n\n        $timeAgo = new \\DateTime(\"-{$this->monthsAgo} months\");\n\n        $query = $queryBuilder\n            ->select('u')\n            ->from(User::class, 'u')\n            ->where(\n                $queryBuilder->expr()->andX(\n                    $queryBuilder->expr()->orX(\n                        $queryBuilder->expr()->isNotNull('u.avatar'),\n                        $queryBuilder->expr()->isNotNull('u.cover')\n                    ),\n                    $queryBuilder->expr()->lt('u.apFetchedAt', ':timeAgo'),\n                    $queryBuilder->expr()->isNotNull('u.apId')\n                )\n            )\n            ->orderBy('u.apFetchedAt', 'ASC')\n            ->setParameter('timeAgo', $timeAgo)\n            ->setMaxResults($this->batchSize)\n            ->getQuery();\n\n        $users = $query->getResult();\n\n        foreach ($users as $user) {\n            $output->writeln(\\sprintf('Deleting image from username: %s', $user->getUsername()));\n            if (null !== $user->cover) {\n                $this->userManager->detachCover($user);\n            }\n            if (null !== $user->avatar) {\n                $this->userManager->detachAvatar($user);\n            }\n        }\n\n        // Return total number of elements deleted\n        return \\count($users) * 2;\n    }\n}\n"
  },
  {
    "path": "src/Command/RemoveRemoteMediaCommand.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Command;\n\nuse App\\Repository\\ImageRepository;\nuse App\\Service\\ImageManager;\nuse App\\Twig\\Runtime\\FormattingExtensionRuntime;\nuse App\\Utils\\GeneralUtil;\nuse Psr\\Log\\LoggerInterface;\nuse Symfony\\Component\\Console\\Attribute\\AsCommand;\nuse Symfony\\Component\\Console\\Command\\Command;\nuse Symfony\\Component\\Console\\Input\\InputInterface;\nuse Symfony\\Component\\Console\\Input\\InputOption;\nuse Symfony\\Component\\Console\\Output\\OutputInterface;\nuse Symfony\\Component\\Console\\Style\\SymfonyStyle;\n\n#[AsCommand(\n    name: 'mbin:images:remove-remote',\n    description: 'Remove cached remote media',\n)]\nclass RemoveRemoteMediaCommand extends Command\n{\n    public function __construct(\n        private readonly ImageRepository $imageRepository,\n        private readonly ImageManager $imageManager,\n        private readonly LoggerInterface $logger,\n        private readonly FormattingExtensionRuntime $formatter,\n    ) {\n        parent::__construct();\n    }\n\n    protected function configure(): void\n    {\n        $this->addOption('days', 'd', InputOption::VALUE_REQUIRED, 'Delete media that is older than x days, if you omit this parameter or set it to 0 it will remove all cached remote media');\n        $this->addOption('batch-size', null, InputOption::VALUE_REQUIRED, 'The number of images to handle at once, the higher the number the faster the command, but it also takes more memory', '10000');\n        $this->addOption('dry-run', null, InputOption::VALUE_NONE, 'Do a trial without removing any media');\n    }\n\n    protected function execute(InputInterface $input, OutputInterface $output): int\n    {\n        $io = new SymfonyStyle($input, $output);\n        $days = \\intval($input->getOption('days'));\n        if ($days < 0) {\n            $io->error('Days must be at least 0');\n\n            return Command::FAILURE;\n        }\n\n        GeneralUtil::useProgressbarFormatsWithMessage();\n\n        $dryRun = \\boolval($input->getOption('dry-run'));\n        $batchSize = \\intval($input->getOption('batch-size'));\n        $images = $this->imageRepository->findOldRemoteMediaPaginated($days, $batchSize);\n        $count = $images->count();\n        $progressBar = $io->createProgressBar($count);\n        $progressBar->setMessage('');\n        $progressBar->start();\n        $totalDeletedFiles = 0;\n        $totalDeletedSize = 0;\n\n        for ($i = 0; $i < $images->getNbPages(); ++$i) {\n            $progressBar->setMessage(\\sprintf('Fetching images %s - %s', ($i * $batchSize) + 1, ($i + 1) * $batchSize));\n            $progressBar->display();\n            foreach ($images->getCurrentPageResults() as $image) {\n                $progressBar->advance();\n                ++$totalDeletedFiles;\n                $totalDeletedSize += $image->localSize;\n\n                if (!$dryRun) {\n                    $filePath = $image->filePath;\n                    if ($this->imageManager->removeCachedImage($image)) {\n                        $progressBar->setMessage(\\sprintf('Removed \"%s\" (%s)', $filePath, $image->getId()));\n                        $progressBar->display();\n                        $this->logger->debug('Removed \"{path}\" ({id})', ['path' => $filePath, 'id' => $image->getId()]);\n                    }\n                } else {\n                    $progressBar->setMessage(\\sprintf('Would have removed \"%s\" (%s)', $image->filePath, $image->getId()));\n                    $this->logger->debug('Would have removed \"{path}\" ({id})', ['path' => $image->filePath, 'id' => $image->getId()]);\n                }\n            }\n            if ($images->hasNextPage()) {\n                $images->setCurrentPage($images->getNextPage());\n            }\n        }\n        $io->writeln('');\n        if (!$dryRun) {\n            $io->success(\\sprintf('Removed %s files (~%sB)', $totalDeletedFiles, $this->formatter->abbreviateNumber($totalDeletedSize)));\n        } else {\n            $io->success(\\sprintf('Would have removed %s files (~%sB)', $totalDeletedFiles, $this->formatter->abbreviateNumber($totalDeletedSize)));\n        }\n\n        return Command::SUCCESS;\n    }\n}\n"
  },
  {
    "path": "src/Command/SubMagazineCommand.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Command;\n\nuse App\\Repository\\MagazineRepository;\nuse App\\Repository\\UserRepository;\nuse App\\Service\\MagazineManager;\nuse Symfony\\Component\\Console\\Attribute\\AsCommand;\nuse Symfony\\Component\\Console\\Command\\Command;\nuse Symfony\\Component\\Console\\Input\\InputArgument;\nuse Symfony\\Component\\Console\\Input\\InputInterface;\nuse Symfony\\Component\\Console\\Input\\InputOption;\nuse Symfony\\Component\\Console\\Output\\OutputInterface;\nuse Symfony\\Component\\Console\\Style\\SymfonyStyle;\n\n#[AsCommand(\n    name: 'mbin:magazine:sub',\n    description: 'This command allows to subscribe a user to a magazine.',\n)]\nclass SubMagazineCommand extends Command\n{\n    public function __construct(\n        private readonly MagazineManager $manager,\n        private readonly MagazineRepository $magazineRepository,\n        private readonly UserRepository $userRepository,\n    ) {\n        parent::__construct();\n    }\n\n    protected function configure(): void\n    {\n        $this\n            ->addArgument('magazine', InputArgument::REQUIRED)\n            ->addArgument('username', InputArgument::REQUIRED)\n            ->addOption('unsub', 'u', InputOption::VALUE_NONE, 'Unsubscribe magazine.');\n    }\n\n    protected function execute(InputInterface $input, OutputInterface $output): int\n    {\n        $io = new SymfonyStyle($input, $output);\n\n        $user = $this->userRepository->findOneByUsername($input->getArgument('username'));\n        $magazine = $this->magazineRepository->findOneByName($input->getArgument('magazine'));\n\n        if (!$user) {\n            $io->error('User not found.');\n\n            return Command::FAILURE;\n        }\n\n        if (!$magazine) {\n            $io->error('Magazine not found.');\n\n            return Command::FAILURE;\n        }\n\n        if (!$input->getOption('unsub')) {\n            $this->manager->subscribe($magazine, $user);\n        } else {\n            $this->manager->unsubscribe($magazine, $user);\n        }\n\n        return Command::SUCCESS;\n    }\n}\n"
  },
  {
    "path": "src/Command/Update/ApKeysUpdateCommand.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Command\\Update;\n\nuse App\\Entity\\Contracts\\ActivityPubActorInterface;\nuse App\\Entity\\Site;\nuse App\\Repository\\MagazineRepository;\nuse App\\Repository\\SiteRepository;\nuse App\\Repository\\UserRepository;\nuse App\\Service\\ActivityPub\\KeysGenerator;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse phpseclib3\\Crypt\\RSA;\nuse Symfony\\Component\\Console\\Attribute\\AsCommand;\nuse Symfony\\Component\\Console\\Command\\Command;\nuse Symfony\\Component\\Console\\Input\\InputInterface;\nuse Symfony\\Component\\Console\\Output\\OutputInterface;\n\n#[AsCommand(\n    name: 'mbin:ap:keys:update',\n    description: 'This command allows generate keys for AP Actors.',\n)]\nclass ApKeysUpdateCommand extends Command\n{\n    public function __construct(\n        private readonly UserRepository $userRepository,\n        private readonly MagazineRepository $magazineRepository,\n        private readonly SiteRepository $siteRepository,\n        private readonly EntityManagerInterface $entityManager,\n    ) {\n        parent::__construct();\n    }\n\n    protected function execute(InputInterface $input, OutputInterface $output): int\n    {\n        $this->generate($this->userRepository->findWithoutKeys());\n        $this->generate($this->magazineRepository->findWithoutKeys());\n\n        $site = $this->siteRepository->findAll();\n        if (empty($site)) {\n            $site = new Site();\n            $this->entityManager->persist($site);\n            $this->entityManager->flush();\n        }\n\n        $site = $this->siteRepository->findAll()[0];\n        $privateKey = RSA::createKey(4096);\n\n        $site->publicKey = (string) $privateKey->getPublicKey();\n        $site->privateKey = (string) $privateKey;\n\n        $this->entityManager->flush();\n\n        return Command::SUCCESS;\n    }\n\n    /**\n     * @param ActivityPubActorInterface[] $actors\n     */\n    private function generate(array $actors): void\n    {\n        foreach ($actors as $actor) {\n            $actor = KeysGenerator::generate($actor);\n            $this->entityManager->persist($actor);\n        }\n\n        $this->entityManager->flush();\n    }\n}\n"
  },
  {
    "path": "src/Command/Update/Async/ImageBlurhashHandler.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Command\\Update\\Async;\n\nuse App\\Entity\\Image;\nuse App\\Repository\\ImageRepository;\nuse App\\Service\\ImageManagerInterface;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Symfony\\Component\\Messenger\\Attribute\\AsMessageHandler;\n\n#[AsMessageHandler]\nclass ImageBlurhashHandler\n{\n    public function __construct(\n        private readonly EntityManagerInterface $entityManager,\n        private readonly ImageManagerInterface $manager,\n    ) {\n    }\n\n    /**\n     * @return true\n     */\n    public function __invoke(ImageBlurhashMessage $message): bool\n    {\n        /** @var ImageRepository $repo */\n        $repo = $this->entityManager->getRepository(Image::class);\n\n        $image = $repo->find($message->id);\n\n        $image->blurhash = $repo->blurhash($this->manager->getPath($image));\n\n        $this->entityManager->persist($image);\n        $this->entityManager->flush();\n\n        return true;\n    }\n}\n"
  },
  {
    "path": "src/Command/Update/Async/ImageBlurhashMessage.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Command\\Update\\Async;\n\nuse App\\Message\\Contracts\\AsyncMessageInterface;\n\nclass ImageBlurhashMessage implements AsyncMessageInterface\n{\n    public function __construct(public int $id)\n    {\n    }\n}\n"
  },
  {
    "path": "src/Command/Update/Async/NoteVisibilityHandler.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Command\\Update\\Async;\n\nuse App\\Entity\\Contracts\\VisibilityInterface;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\Messenger\\Attribute\\AsMessageHandler;\nuse Symfony\\Contracts\\HttpClient\\HttpClientInterface;\n\n#[AsMessageHandler]\nreadonly class NoteVisibilityHandler\n{\n    public function __construct(\n        private EntityManagerInterface $entityManager,\n        private HttpClientInterface $client,\n    ) {\n    }\n\n    public function __invoke(NoteVisibilityMessage $message): void\n    {\n        $repo = $this->entityManager->getRepository($message->class);\n\n        $entity = $repo->find($message->id);\n        $req = $this->client->request('GET', $entity->apId, [\n            'headers' => [\n                'Accept' => 'application/activity+json,application/ld+json,application/json',\n            ],\n        ]);\n\n        if (Response::HTTP_NOT_FOUND === $req->getStatusCode()) {\n            $entity->visibility = VisibilityInterface::VISIBILITY_PRIVATE;\n            $this->entityManager->flush();\n        }\n    }\n}\n"
  },
  {
    "path": "src/Command/Update/Async/NoteVisibilityMessage.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Command\\Update\\Async;\n\nuse App\\Entity\\Post;\nuse App\\Entity\\PostComment;\nuse App\\Message\\Contracts\\AsyncMessageInterface;\n\nclass NoteVisibilityMessage implements AsyncMessageInterface\n{\n    /**\n     * @param class-string<Post|PostComment> $class\n     */\n    public function __construct(public int $id, public string $class)\n    {\n    }\n}\n"
  },
  {
    "path": "src/Command/Update/ImageBlurhashUpdateCommand.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Command\\Update;\n\nuse App\\Command\\Update\\Async\\ImageBlurhashMessage;\nuse App\\Repository\\ImageRepository;\nuse Symfony\\Component\\Console\\Attribute\\AsCommand;\nuse Symfony\\Component\\Console\\Command\\Command;\nuse Symfony\\Component\\Console\\Input\\InputInterface;\nuse Symfony\\Component\\Console\\Output\\OutputInterface;\nuse Symfony\\Component\\Messenger\\MessageBusInterface;\n\n#[AsCommand(\n    name: 'mbin:blurhash:update',\n    description: 'This command allows generate blurhash for images.',\n)]\nclass ImageBlurhashUpdateCommand extends Command\n{\n    public function __construct(\n        private readonly ImageRepository $repository,\n        private readonly MessageBusInterface $bus,\n    ) {\n        parent::__construct();\n    }\n\n    protected function execute(InputInterface $input, OutputInterface $output): int\n    {\n        $images = $this->repository->findAll();\n\n        foreach ($images as $image) {\n            $this->bus->dispatch(new ImageBlurhashMessage($image->getId()));\n        }\n\n        return Command::SUCCESS;\n    }\n}\n"
  },
  {
    "path": "src/Command/Update/LocalMagazineApProfile.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Command\\Update;\n\nuse App\\Repository\\MagazineRepository;\nuse Symfony\\Component\\Console\\Attribute\\AsCommand;\nuse Symfony\\Component\\Console\\Command\\Command;\nuse Symfony\\Component\\Console\\Input\\InputInterface;\nuse Symfony\\Component\\Console\\Output\\OutputInterface;\nuse Symfony\\Component\\Console\\Style\\SymfonyStyle;\nuse Symfony\\Component\\Routing\\Generator\\UrlGeneratorInterface;\n\n#[AsCommand(\n    name: 'mbin:update:magazines:ap_profile',\n    description: 'This command allows generate Ap profile.',\n)]\nclass LocalMagazineApProfile extends Command\n{\n    public function __construct(\n        private readonly MagazineRepository $repository,\n        private readonly UrlGeneratorInterface $urlGenerator,\n    ) {\n        parent::__construct();\n    }\n\n    protected function execute(InputInterface $input, OutputInterface $output): int\n    {\n        $io = new SymfonyStyle($input, $output);\n\n        $magazines = $this->repository->createQueryBuilder('m')\n            ->where('m.apId IS NULL')\n            ->getQuery()\n            ->getResult();\n\n        foreach ($magazines as $magazine) {\n            $magazine->apProfileId = $this->urlGenerator->generate(\n                'ap_magazine',\n                ['name' => $magazine->name],\n                UrlGeneratorInterface::ABSOLUTE_URL\n            );\n            $io->info($magazine->name);\n            $this->repository->save($magazine, true);\n        }\n\n        return Command::SUCCESS;\n    }\n}\n"
  },
  {
    "path": "src/Command/Update/NoteVisibilityUpdateCommand.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Command\\Update;\n\nuse App\\Message\\ActivityPub\\UpdateActorMessage;\nuse App\\Repository\\UserRepository;\nuse Symfony\\Component\\Console\\Attribute\\AsCommand;\nuse Symfony\\Component\\Console\\Command\\Command;\nuse Symfony\\Component\\Console\\Input\\InputInterface;\nuse Symfony\\Component\\Console\\Output\\OutputInterface;\nuse Symfony\\Component\\Messenger\\MessageBusInterface;\n\n#[AsCommand(\n    name: 'mbin:ap:actor:update',\n    description: 'This command allows refresh remote users.'\n)]\nclass NoteVisibilityUpdateCommand extends Command\n{\n    public function __construct(\n        private readonly UserRepository $repository,\n        private readonly MessageBusInterface $bus,\n    ) {\n        parent::__construct();\n    }\n\n    protected function execute(InputInterface $input, OutputInterface $output): int\n    {\n        foreach ($this->repository->findAllRemote() as $user) {\n            $this->bus->dispatch(new UpdateActorMessage($user->apProfileId));\n        }\n\n        return Command::SUCCESS;\n    }\n}\n"
  },
  {
    "path": "src/Command/Update/PostCommentRootUpdateCommand.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Command\\Update;\n\nuse App\\Entity\\PostComment;\nuse App\\Repository\\PostCommentRepository;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Symfony\\Component\\Console\\Attribute\\AsCommand;\nuse Symfony\\Component\\Console\\Command\\Command;\nuse Symfony\\Component\\Console\\Input\\InputInterface;\nuse Symfony\\Component\\Console\\Output\\OutputInterface;\n\n#[AsCommand(\n    name: 'mbin:post:comments:root:update',\n    description: 'This command allows generate root id for comments.',\n)]\nclass PostCommentRootUpdateCommand extends Command\n{\n    public function __construct(\n        private readonly PostCommentRepository $repository,\n        private readonly EntityManagerInterface $entityManager,\n    ) {\n        parent::__construct();\n    }\n\n    protected function execute(InputInterface $input, OutputInterface $output): int\n    {\n        $queryBuilder = $this->repository->createQueryBuilder('c')\n            ->select('c.id')\n            ->where('c.parent IS NOT NULL')\n            ->andWhere('c.root IS NULL')\n            ->andWhere('c.updateMark = false')\n            ->orderBy('c.id', 'ASC')\n            ->getQuery()\n            ->getResult();\n\n        foreach ($queryBuilder as $comment) {\n            echo $comment['id'].PHP_EOL;\n            $this->update($this->repository->find($comment['id']));\n        }\n\n        return Command::SUCCESS;\n    }\n\n    private function update(PostComment $comment): void\n    {\n        if (null === $comment->parent->root) {\n            $this->entityManager->getConnection()->executeQuery(\n                'UPDATE post_comment SET root_id = :root_id, update_mark = true WHERE id = :id',\n                [\n                    'root_id' => $comment->parent->getId(),\n                    'id' => $comment->getId(),\n                ]\n            );\n\n            return;\n        }\n\n        $this->entityManager->getConnection()->executeQuery(\n            'UPDATE post_comment SET root_id = :root_id, update_mark = true WHERE id = :id',\n            [\n                'root_id' => $comment->parent->root->getId(),\n                'id' => $comment->getId(),\n            ]\n        );\n    }\n}\n"
  },
  {
    "path": "src/Command/Update/PushKeysUpdateCommand.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Command\\Update;\n\nuse App\\Entity\\Site;\nuse App\\Repository\\SiteRepository;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Minishlink\\WebPush\\VAPID;\nuse Symfony\\Component\\Console\\Attribute\\AsCommand;\nuse Symfony\\Component\\Console\\Command\\Command;\nuse Symfony\\Component\\Console\\Input\\InputInterface;\nuse Symfony\\Component\\Console\\Output\\OutputInterface;\n\n#[AsCommand(\n    name: 'mbin:push:keys:update',\n    description: 'This command allows generate keys for push subscriptions.',\n)]\nclass PushKeysUpdateCommand extends Command\n{\n    public function __construct(\n        private readonly SiteRepository $siteRepository,\n        private readonly EntityManagerInterface $entityManager,\n    ) {\n        parent::__construct();\n    }\n\n    protected function execute(InputInterface $input, OutputInterface $output): int\n    {\n        $site = $this->siteRepository->findAll();\n        if (empty($site)) {\n            $site = new Site();\n            $this->entityManager->persist($site);\n            $this->entityManager->flush();\n        }\n\n        $site = $this->siteRepository->findAll()[0];\n        if (null === $site->pushPrivateKey && null === $site->pushPublicKey) {\n            $keys = VAPID::createVapidKeys();\n            $site->pushPublicKey = (string) $keys['publicKey'];\n            $site->pushPrivateKey = (string) $keys['privateKey'];\n\n            $this->entityManager->flush();\n        }\n\n        return Command::SUCCESS;\n    }\n}\n"
  },
  {
    "path": "src/Command/Update/RemoveMagazineNameFromTagsCommand.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Command\\Update;\n\nuse App\\Repository\\MagazineRepository;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Symfony\\Component\\Console\\Attribute\\AsCommand;\nuse Symfony\\Component\\Console\\Command\\Command;\nuse Symfony\\Component\\Console\\Input\\InputInterface;\nuse Symfony\\Component\\Console\\Output\\OutputInterface;\n\n#[AsCommand(\n    name: 'mbin:magazine:tags',\n    description: 'This command allows remove magazine name from tags.'\n)]\nclass RemoveMagazineNameFromTagsCommand extends Command\n{\n    public function __construct(\n        private readonly MagazineRepository $magazineRepository,\n        private readonly EntityManagerInterface $entityManager,\n    ) {\n        parent::__construct();\n    }\n\n    protected function execute(InputInterface $input, OutputInterface $output): int\n    {\n        foreach ($this->magazineRepository->findAll() as $magazine) {\n            if ($tags = $magazine->tags) {\n                $magazine->tags = array_values(array_filter($tags, fn ($val) => $val !== $magazine->name));\n                if (empty($magazine->tags)) {\n                    $magazine->tags = null;\n                }\n            }\n        }\n\n        $this->entityManager->flush();\n\n        return Command::SUCCESS;\n    }\n}\n"
  },
  {
    "path": "src/Command/Update/RemoveRemoteEntriesFromLocalDomainCommand.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Command\\Update;\n\nuse App\\Repository\\DomainRepository;\nuse App\\Service\\SettingsManager;\nuse Doctrine\\DBAL\\ParameterType;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Symfony\\Component\\Console\\Attribute\\AsCommand;\nuse Symfony\\Component\\Console\\Command\\Command;\nuse Symfony\\Component\\Console\\Input\\InputInterface;\nuse Symfony\\Component\\Console\\Output\\OutputInterface;\nuse Symfony\\Component\\Console\\Style\\SymfonyStyle;\n\n#[AsCommand(\n    'mbin:update:local-domain',\n    'This command removes remote entries from the local domain',\n)]\nclass RemoveRemoteEntriesFromLocalDomainCommand extends Command\n{\n    public function __construct(\n        private readonly EntityManagerInterface $entityManager,\n        private readonly DomainRepository $repository,\n        private readonly SettingsManager $settingsManager,\n    ) {\n        parent::__construct();\n    }\n\n    protected function execute(InputInterface $input, OutputInterface $output): int\n    {\n        $io = new SymfonyStyle($input, $output);\n        $domainName = 'https://'.$this->settingsManager->get('KBIN_DOMAIN');\n        $domainName = preg_replace('/^www\\./i', '', parse_url($domainName)['host']);\n\n        $domain = $this->repository->findOneByName($domainName);\n        if (!$domain) {\n            $io->warning(\\sprintf('There is no local domain like %s', $domainName));\n\n            return Command::SUCCESS;\n        }\n\n        $countBeforeSql = 'SELECT COUNT(*) as ctn FROM entry WHERE domain_id = :dId';\n        $stmt1 = $this->entityManager->getConnection()->prepare($countBeforeSql);\n        $stmt1->bindValue('dId', $domain->getId(), ParameterType::INTEGER);\n        $countBefore = \\intval($stmt1->executeQuery()->fetchOne());\n\n        $sql = 'UPDATE entry SET domain_id = NULL WHERE domain_id = :dId AND ap_id IS NOT NULL';\n        $stmt2 = $this->entityManager->getConnection()->prepare($sql);\n        $stmt2->bindValue('dId', $domain->getId(), ParameterType::INTEGER);\n        $stmt2->executeStatement();\n\n        $countAfterSql = 'SELECT COUNT(*) as ctn FROM entry WHERE domain_id = :dId';\n        $stmt3 = $this->entityManager->getConnection()->prepare($countAfterSql);\n        $stmt3->bindValue('dId', $domain->getId(), ParameterType::INTEGER);\n        $countAfter = \\intval($stmt3->executeQuery()->fetchOne());\n\n        $sql = 'UPDATE domain SET entry_count = :c WHERE id = :dId';\n        $stmt4 = $this->entityManager->getConnection()->prepare($sql);\n        $stmt4->bindValue('dId', $domain->getId(), ParameterType::INTEGER);\n        $stmt4->bindValue('c', $countAfter, ParameterType::INTEGER);\n        $stmt4->executeStatement();\n\n        $io->success(\\sprintf('Removed %d entries from the domain %s, now only %d entries are left', $countBefore - $countAfter, $domainName, $countAfter));\n\n        return Command::SUCCESS;\n    }\n}\n"
  },
  {
    "path": "src/Command/Update/SlugUpdateCommand.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Command\\Update;\n\nuse App\\Entity\\Entry;\nuse App\\Entity\\Post;\nuse App\\Utils\\Slugger;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Symfony\\Component\\Console\\Attribute\\AsCommand;\nuse Symfony\\Component\\Console\\Command\\Command;\nuse Symfony\\Component\\Console\\Input\\InputInterface;\nuse Symfony\\Component\\Console\\Output\\OutputInterface;\n\n#[AsCommand(\n    name: 'mbin:slug:update',\n    description: 'This command allows refresh entries slugs.'\n)]\nclass SlugUpdateCommand extends Command\n{\n    public function __construct(\n        private readonly Slugger $slugger,\n        private readonly EntityManagerInterface $entityManager,\n    ) {\n        parent::__construct();\n    }\n\n    protected function execute(InputInterface $input, OutputInterface $output): int\n    {\n        $entries = $this->entityManager->getRepository(Entry::class)->findAll();\n        foreach ($entries as $entry) {\n            $entry->slug = $this->slugger->slug($entry->title);\n            $this->entityManager->persist($entry);\n        }\n\n        $posts = $this->entityManager->getRepository(Post::class)->findAll();\n        foreach ($posts as $post) {\n            $post->slug = $this->slugger->slug($post->body);\n            $this->entityManager->persist($post);\n        }\n\n        $this->entityManager->flush();\n\n        return Command::SUCCESS;\n    }\n}\n"
  },
  {
    "path": "src/Command/Update/TagsUpdateCommand.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Command\\Update;\n\nuse App\\Entity\\EntryComment;\nuse App\\Entity\\Post;\nuse App\\Entity\\PostComment;\nuse App\\Service\\TagExtractor;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Symfony\\Component\\Console\\Attribute\\AsCommand;\nuse Symfony\\Component\\Console\\Command\\Command;\nuse Symfony\\Component\\Console\\Input\\InputInterface;\nuse Symfony\\Component\\Console\\Output\\OutputInterface;\n\n#[AsCommand(\n    name: 'mbin:tag:update',\n    description: 'This command allows refresh entries tags.'\n)]\nclass TagsUpdateCommand extends Command\n{\n    public function __construct(\n        private readonly TagExtractor $tagExtractor,\n        private readonly EntityManagerInterface $entityManager,\n    ) {\n        parent::__construct();\n    }\n\n    protected function execute(InputInterface $input, OutputInterface $output): int\n    {\n        $comments = $this->entityManager->getRepository(EntryComment::class)->findAll();\n        foreach ($comments as $comment) {\n            // TODO: $comment->tags is undefined; should it be ->hashtags?\n            $comment->tags = $this->tagExtractor->extract($comment->body, $comment->magazine->name);\n            $this->entityManager->persist($comment);\n        }\n\n        $posts = $this->entityManager->getRepository(Post::class)->findAll();\n        foreach ($posts as $post) {\n            // TODO: $post->tags is undefined; should it be ->hashtags?\n            $post->tags = $this->tagExtractor->extract($post->body, $post->magazine->name);\n            $this->entityManager->persist($post);\n        }\n\n        $comments = $this->entityManager->getRepository(PostComment::class)->findAll();\n        foreach ($comments as $comment) {\n            // TODO: $comment->tags is undefined; should it be ->hashtags?\n            $comment->tags = $this->tagExtractor->extract($comment->body, $comment->magazine->name);\n            $this->entityManager->persist($comment);\n        }\n\n        $this->entityManager->flush();\n\n        return Command::SUCCESS;\n    }\n}\n"
  },
  {
    "path": "src/Command/Update/UserLastActiveUpdateCommand.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Command\\Update;\n\nuse App\\Entity\\User;\nuse App\\Repository\\SearchRepository;\nuse App\\Repository\\UserRepository;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Symfony\\Component\\Console\\Attribute\\AsCommand;\nuse Symfony\\Component\\Console\\Command\\Command;\nuse Symfony\\Component\\Console\\Input\\InputInterface;\nuse Symfony\\Component\\Console\\Output\\OutputInterface;\n\n#[AsCommand(\n    name: 'mbin:users:lastActive:update',\n    description: 'This command allows set user last active date.'\n)]\nclass UserLastActiveUpdateCommand extends Command\n{\n    public function __construct(\n        private readonly EntityManagerInterface $entityManager,\n        private readonly SearchRepository $searchRepository,\n    ) {\n        parent::__construct();\n    }\n\n    protected function execute(InputInterface $input, OutputInterface $output): int\n    {\n        /** @var UserRepository $repo */\n        $repo = $this->entityManager->getRepository(User::class);\n        $hideAdult = false;\n\n        foreach ($repo->findAll() as $user) {\n            $activity = $this->searchRepository->findUserPublicActivity(1, $user, $hideAdult);\n            if ($activity->count()) {\n                $user->lastActive = $activity->getCurrentPageResults()[0]->lastActive;\n            }\n        }\n\n        $this->entityManager->flush();\n\n        return Command::SUCCESS;\n    }\n}\n"
  },
  {
    "path": "src/Command/UserCommand.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Command;\n\nuse App\\DTO\\UserDto;\nuse App\\Repository\\UserRepository;\nuse App\\Service\\UserManager;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Symfony\\Component\\Console\\Attribute\\AsCommand;\nuse Symfony\\Component\\Console\\Command\\Command;\nuse Symfony\\Component\\Console\\Input\\InputArgument;\nuse Symfony\\Component\\Console\\Input\\InputInterface;\nuse Symfony\\Component\\Console\\Input\\InputOption;\nuse Symfony\\Component\\Console\\Output\\OutputInterface;\nuse Symfony\\Component\\Console\\Style\\SymfonyStyle;\n\n#[AsCommand(\n    name: 'mbin:user:create',\n    description: 'This command allows you to create user, optionally granting administrator or global moderator privileges.',\n)]\nclass UserCommand extends Command\n{\n    public function __construct(\n        private readonly EntityManagerInterface $entityManager,\n        private readonly UserRepository $repository,\n        private readonly UserManager $manager,\n    ) {\n        parent::__construct();\n    }\n\n    protected function configure(): void\n    {\n        $this->addArgument('username', InputArgument::REQUIRED)\n            ->addArgument('email', InputArgument::REQUIRED)\n            ->addArgument('password', InputArgument::REQUIRED)\n            ->addOption('applicationText', 'a', InputOption::VALUE_REQUIRED, 'The application text of the user, if set the user will not be pre-approved')\n            ->addOption('remove', 'r', InputOption::VALUE_NONE, 'Remove user')\n            ->addOption('admin', null, InputOption::VALUE_NONE, 'Grant administrator privileges')\n            ->addOption('moderator', null, InputOption::VALUE_NONE, 'Grant global moderator privileges');\n    }\n\n    protected function execute(InputInterface $input, OutputInterface $output): int\n    {\n        $io = new SymfonyStyle($input, $output);\n        $remove = $input->getOption('remove');\n        $user = $this->repository->findOneByUsername($input->getArgument('username'));\n\n        if ($user && !$remove) {\n            $io->error('User exists.');\n\n            return Command::FAILURE;\n        }\n\n        if ($user) {\n            // @todo publish delete user message\n            $this->entityManager->remove($user);\n            $this->entityManager->flush();\n\n            $io->success('The user deletion process has started.');\n\n            return Command::SUCCESS;\n        }\n\n        $this->createUser($input, $io);\n\n        return Command::SUCCESS;\n    }\n\n    private function createUser(InputInterface $input, SymfonyStyle $io): void\n    {\n        $applicationText = $input->getOption('applicationText');\n        if ('' === $applicationText) {\n            $applicationText = null;\n        }\n        $dto = (new UserDto())->create($input->getArgument('username'), $input->getArgument('email'), applicationText: $applicationText);\n        $dto->plainPassword = $input->getArgument('password');\n\n        $user = $this->manager->create($dto, false, false, preApprove: null === $applicationText);\n\n        if ($input->getOption('admin')) {\n            $user->setOrRemoveAdminRole();\n        }\n\n        if ($input->getOption('moderator')) {\n            $user->setOrRemoveModeratorRole();\n        }\n\n        $user->isVerified = true;\n        $this->entityManager->flush();\n\n        $io->success('A user has been created. It is recommended to change the password after the first login.');\n    }\n}\n"
  },
  {
    "path": "src/Command/UserPasswordCommand.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Command;\n\nuse App\\Repository\\UserRepository;\nuse App\\Service\\UserManager;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Symfony\\Component\\Console\\Attribute\\AsCommand;\nuse Symfony\\Component\\Console\\Command\\Command;\nuse Symfony\\Component\\Console\\Input\\InputArgument;\nuse Symfony\\Component\\Console\\Input\\InputInterface;\nuse Symfony\\Component\\Console\\Output\\OutputInterface;\nuse Symfony\\Component\\Console\\Style\\SymfonyStyle;\nuse Symfony\\Component\\PasswordHasher\\Hasher\\UserPasswordHasherInterface;\n\n#[AsCommand(\n    name: 'mbin:user:password',\n    description: 'This command allows you to manually set or reset a users password.',\n)]\nclass UserPasswordCommand extends Command\n{\n    public function __construct(\n        private readonly EntityManagerInterface $entityManager,\n        private readonly UserPasswordHasherInterface $userPasswordHasher,\n        private readonly UserRepository $repository,\n        private readonly UserManager $manager,\n    ) {\n        parent::__construct();\n    }\n\n    protected function configure(): void\n    {\n        $this->addArgument('username', InputArgument::REQUIRED)\n            ->addArgument('password', InputArgument::REQUIRED);\n    }\n\n    protected function execute(InputInterface $input, OutputInterface $output): int\n    {\n        $io = new SymfonyStyle($input, $output);\n        $password = $input->getArgument('password');\n        $user = $this->repository->findOneByUsername($input->getArgument('username'));\n\n        if (!$user) {\n            $io->error('User does not exist!');\n\n            return Command::FAILURE;\n        }\n\n        if ($user->apId) {\n            $io->error('The specified account is not a local user!');\n\n            return Command::FAILURE;\n        }\n\n        // Encode(hash) the plain password, and set it.\n        $encodedPassword = $this->userPasswordHasher->hashPassword(\n            $user,\n            $password\n        );\n\n        $user->setPassword($encodedPassword);\n        $this->entityManager->flush();\n\n        return Command::SUCCESS;\n    }\n}\n"
  },
  {
    "path": "src/Command/UserRotatePrivateKeys.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Command;\n\nuse App\\Repository\\UserRepository;\nuse App\\Service\\ActivityPub\\ActivityJsonBuilder;\nuse App\\Service\\ActivityPub\\Wrapper\\UpdateWrapper;\nuse App\\Service\\DeliverManager;\nuse App\\Service\\UserManager;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Symfony\\Component\\Console\\Attribute\\AsCommand;\nuse Symfony\\Component\\Console\\Command\\Command;\nuse Symfony\\Component\\Console\\Input\\InputArgument;\nuse Symfony\\Component\\Console\\Input\\InputInterface;\nuse Symfony\\Component\\Console\\Output\\OutputInterface;\nuse Symfony\\Component\\Console\\Style\\SymfonyStyle;\n\n#[AsCommand(\n    name: 'mbin:user:private-keys:rotate',\n    description: 'This command allows you to manually rotate the private keys of a user or all local users.',\n)]\nclass UserRotatePrivateKeys extends Command\n{\n    public function __construct(\n        private readonly EntityManagerInterface $entityManager,\n        private readonly UserRepository $userRepository,\n        private readonly DeliverManager $deliverManager,\n        private readonly UpdateWrapper $updateWrapper,\n        private readonly ActivityJsonBuilder $activityJsonBuilder,\n        private readonly UserManager $userManager,\n    ) {\n        parent::__construct();\n    }\n\n    protected function configure(): void\n    {\n        $this->addArgument('username', InputArgument::OPTIONAL)\n            ->addOption('all-local-users', 'a')\n            ->addOption('revert', 'r', description: 'revert a previous rotation of private keys');\n    }\n\n    protected function execute(InputInterface $input, OutputInterface $output): int\n    {\n        $io = new SymfonyStyle($input, $output);\n        $username = $input->getArgument('username');\n        $all = $input->getOption('all-local-users');\n        $revert = $input->getOption('revert');\n\n        if (!$username && !$all) {\n            $io->error('You must provide a username or execute the command for all local users!');\n\n            return Command::FAILURE;\n        }\n\n        if ($username) {\n            $user = $this->userRepository->findOneByUsername($username);\n            if (!$user) {\n                $io->error('The username \"'.$username.'\" does not exist!');\n\n                return Command::FAILURE;\n            } elseif ($user->apId) {\n                $io->error('The user \"'.$username.'\" is not a local user!');\n\n                return Command::FAILURE;\n            }\n            $users = [$user];\n        } elseif ($all) {\n            // all local users, including suspended, banned and marked for deletion, but excluding deleted ones\n            $users = $this->userRepository->findBy(['apId' => null, 'isDeleted' => false]);\n        } else {\n            // unreachable because of the first if\n            throw new \\LogicException('no username is set and it should not run for all local users!');\n        }\n\n        $userCount = \\count($users);\n        $action = $revert ? 'reverted' : 'rotated';\n\n        $io->confirm(\"This command will $action the private and public key of $userCount users. \"\n            .'After running this command it can take up to 24 hours for other instances to update their stored public keys. '\n            .'In this timeframe federation might be impacted by this, as those services cannot successfully verify the identity of your users. '\n            .'Please inform your users about this when you\\'re running this command. Do you want to continue?');\n\n        $ignoreCount = 0;\n        $progressBar = $io->createProgressBar($userCount);\n        foreach ($users as $user) {\n            $this->entityManager->beginTransaction();\n\n            if ($revert && (null === $user->oldPrivateKey || null === $user->oldPublicKey)) {\n                ++$ignoreCount;\n                $progressBar->advance();\n                continue;\n            }\n\n            $user->rotatePrivateKey(revert: $revert);\n            $update = $this->updateWrapper->buildForActor($user);\n\n            $this->entityManager->flush();\n            $this->entityManager->commit();\n\n            $updateJson = $this->activityJsonBuilder->buildActivityJson($update);\n            $inboxes = $this->userManager->getAllInboxesOfInteractions($user);\n\n            // send one signed with the old private key and one signed with the new\n            // some software will fetch the newest public key and some will have cached the old one\n            $this->deliverManager->deliver($inboxes, $updateJson, useOldPrivateKey: true);\n            $this->deliverManager->deliver($inboxes, $updateJson, useOldPrivateKey: false);\n            $progressBar->advance();\n        }\n        $progressBar->finish();\n\n        $ignoreText = $revert && $ignoreCount > 0 ? \" $ignoreCount users have been ignored, because their keys were never rotated.\" : '';\n        $io->info(\"Successfully $action the private key for $userCount users. $ignoreText\");\n\n        return Command::SUCCESS;\n    }\n}\n"
  },
  {
    "path": "src/Command/UserUnsubCommand.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Command;\n\nuse App\\Repository\\UserRepository;\nuse App\\Service\\UserManager;\nuse Symfony\\Component\\Console\\Attribute\\AsCommand;\nuse Symfony\\Component\\Console\\Command\\Command;\nuse Symfony\\Component\\Console\\Input\\InputArgument;\nuse Symfony\\Component\\Console\\Input\\InputInterface;\nuse Symfony\\Component\\Console\\Output\\OutputInterface;\nuse Symfony\\Component\\Console\\Style\\SymfonyStyle;\n\n#[AsCommand(\n    name: 'mbin:user:unsub',\n    description: 'Removes all followers from a user',\n)]\nclass UserUnsubCommand extends Command\n{\n    public function __construct(\n        private readonly UserRepository $repository,\n        private readonly UserManager $manager,\n    ) {\n        parent::__construct();\n    }\n\n    protected function configure(): void\n    {\n        $this->addArgument('username', InputArgument::REQUIRED);\n    }\n\n    protected function execute(InputInterface $input, OutputInterface $output): int\n    {\n        $io = new SymfonyStyle($input, $output);\n        $user = $this->repository->findOneByUsername($input->getArgument('username'));\n\n        if ($user) {\n            foreach ($user->followers as $follower) {\n                $this->manager->unfollow($follower->follower, $user);\n            }\n\n            $io->success('User unsubscribed');\n\n            return Command::SUCCESS;\n        }\n\n        return Command::FAILURE;\n    }\n}\n"
  },
  {
    "path": "src/Command/VerifyCommand.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Command;\n\nuse App\\Repository\\UserRepository;\nuse App\\Service\\UserManager;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Symfony\\Component\\Console\\Attribute\\AsCommand;\nuse Symfony\\Component\\Console\\Command\\Command;\nuse Symfony\\Component\\Console\\Input\\InputArgument;\nuse Symfony\\Component\\Console\\Input\\InputInterface;\nuse Symfony\\Component\\Console\\Input\\InputOption;\nuse Symfony\\Component\\Console\\Output\\OutputInterface;\nuse Symfony\\Component\\Console\\Style\\SymfonyStyle;\n\n#[AsCommand(\n    name: 'mbin:user:verify',\n    description: 'This command allows you to manually activate or deactivate a user, bypassing email verification requirement.',\n)]\nclass VerifyCommand extends Command\n{\n    public function __construct(\n        private readonly EntityManagerInterface $entityManager,\n        private readonly UserRepository $repository,\n        private readonly UserManager $manager,\n    ) {\n        parent::__construct();\n    }\n\n    protected function configure(): void\n    {\n        $this->addArgument('username', InputArgument::REQUIRED)\n            ->addOption('activate', 'a', InputOption::VALUE_NONE, 'Activate user, bypass email verification.')\n            ->addOption('deactivate', 'd', InputOption::VALUE_NONE, 'Deactivate user, require email (re)verification.');\n    }\n\n    protected function execute(InputInterface $input, OutputInterface $output): int\n    {\n        $io = new SymfonyStyle($input, $output);\n        $activate = $input->getOption('activate');\n        $deactivate = $input->getOption('deactivate');\n        $user = $this->repository->findOneByUsername($input->getArgument('username'));\n\n        if (!$user) {\n            $io->error('User does not exist!');\n\n            return Command::FAILURE;\n        }\n\n        if ($activate) {\n            $user->isVerified = true;\n            $this->entityManager->flush();\n\n            $io->success('The user has been activated and can login.');\n        } elseif ($deactivate) {\n            $user->isVerified = false;\n            $this->entityManager->flush();\n\n            $io->success('The user has been deactivated and cannot login.');\n        } else {\n            if ($user->isVerified) {\n                $io->success('The user is verified and can login.');\n            } else {\n                $io->success('The user is unverified and cannot login.');\n            }\n        }\n\n        return Command::SUCCESS;\n    }\n}\n"
  },
  {
    "path": "src/Controller/.gitignore",
    "content": ""
  },
  {
    "path": "src/Controller/AboutController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller;\n\nuse App\\Repository\\SiteRepository;\nuse App\\Service\\InstanceStatsManager;\nuse App\\Service\\SettingsManager;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\n\nclass AboutController extends AbstractController\n{\n    public function __invoke(SettingsManager $settings, SiteRepository $repository, InstanceStatsManager $counter, Request $request): Response\n    {\n        $site = $repository->findAll();\n\n        return $this->render(\n            'page/about.html.twig',\n            [\n                'body' => $site[0]->about ?? '',\n            ]\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/AbstractController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller;\n\nuse App\\Entity\\Entry;\nuse App\\Entity\\Magazine;\nuse App\\Entity\\Post;\nuse App\\Entity\\User;\nuse Symfony\\Bundle\\FrameworkBundle\\Controller\\AbstractController as BaseAbstractController;\nuse Symfony\\Component\\Form\\FormInterface;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\HttpKernel\\Exception\\BadRequestHttpException;\n\n/**\n * @method User getUserOrThrow()\n * @method validateCsrf(), throws an error on invalid token\n * @method User|null getUser()\n */\nabstract class AbstractController extends BaseAbstractController\n{\n    protected function getUserOrThrow(): User\n    {\n        $user = $this->getUser();\n\n        if (!$user) {\n            throw new \\BadMethodCallException('User is not logged in');\n        }\n\n        return $user;\n    }\n\n    protected function validateCsrf(string $id, $token): void\n    {\n        if (!\\is_string($token) || !$this->isCsrfTokenValid($id, $token)) {\n            throw new BadRequestHttpException(\"Invalid CSRF token, with ID: $id.\");\n        }\n    }\n\n    protected function redirectToRefererOrHome(Request $request, ?string $element = null): Response\n    {\n        if (!$request->headers->has('Referer')) {\n            return $this->redirectToRoute('front'.($element ? '#'.$element : ''));\n        }\n\n        return $this->redirect($request->headers->get('Referer').($element ? '#'.$element : ''));\n    }\n\n    protected function getJsonSuccessResponse(): JsonResponse\n    {\n        return new JsonResponse(\n            [\n                'success' => true,\n                'html' => '<div class=\"alert alert__info\">Reported</div>',\n            ]\n        );\n    }\n\n    protected function getJsonFormResponse(\n        FormInterface $form,\n        string $template,\n        ?array $variables = null,\n    ): JsonResponse {\n        return new JsonResponse(\n            [\n                'form' => $this->renderView(\n                    $template,\n                    [\n                        'form' => $form->createView(),\n                    ] + ($variables ?? [])\n                ),\n            ]\n        );\n    }\n\n    protected function getPageNb(Request $request): int\n    {\n        return (int) $request->get('p', 1);\n    }\n\n    protected function redirectToEntry(Entry $entry): Response\n    {\n        return $this->redirectToRoute(\n            'entry_single',\n            [\n                'magazine_name' => $entry->magazine->name,\n                'entry_id' => $entry->getId(),\n                'slug' => $entry->slug,\n            ]\n        );\n    }\n\n    protected function redirectToPost(Post $post): Response\n    {\n        return $this->redirectToRoute(\n            'post_single',\n            [\n                'magazine_name' => $post->magazine->name,\n                'post_id' => $post->getId(),\n                'slug' => $post->slug,\n            ]\n        );\n    }\n\n    protected function redirectToMagazine(Magazine $magazine, ?string $sortBy = null): Response\n    {\n        return $this->redirectToRoute(\n            'front_magazine',\n            [\n                'name' => $magazine->name,\n                'sortBy' => $sortBy,\n            ]\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/ActivityPub/ContextsController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\ActivityPub;\n\nuse App\\Service\\ActivityPub\\ContextsProvider;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpFoundation\\Request;\n\nclass ContextsController\n{\n    public function __invoke(Request $request, ContextsProvider $context): JsonResponse\n    {\n        return new JsonResponse(\n            ['@context' => $context->embeddedContexts()],\n            200,\n            [\n                'Content-Type' => 'application/ld+json',\n                'Access-Control-Allow-Origin' => '*',\n            ]\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/ActivityPub/EntryCommentController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\ActivityPub;\n\nuse App\\Controller\\AbstractController;\nuse App\\Controller\\Traits\\PrivateContentTrait;\nuse App\\Entity\\Entry;\nuse App\\Entity\\EntryComment;\nuse App\\Entity\\Magazine;\nuse App\\Factory\\ActivityPub\\EntryCommentNoteFactory;\nuse App\\Repository\\TagLinkRepository;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\n\nclass EntryCommentController extends AbstractController\n{\n    use PrivateContentTrait;\n\n    public function __construct(\n        private readonly EntryCommentNoteFactory $commentNoteFactory,\n        private readonly TagLinkRepository $tagLinkRepository,\n    ) {\n    }\n\n    public function __invoke(\n        #[MapEntity(mapping: ['magazine_name' => 'name'])]\n        Magazine $magazine,\n        #[MapEntity(id: 'entry_id')]\n        Entry $entry,\n        #[MapEntity(id: 'comment_id')]\n        EntryComment $comment,\n        Request $request,\n    ): Response {\n        if ($comment->apId) {\n            return $this->redirect($comment->apId);\n        }\n\n        $this->handlePrivateContent($comment);\n\n        $response = new JsonResponse($this->commentNoteFactory->create($comment, $this->tagLinkRepository->getTagsOfContent($comment), true));\n\n        $response->headers->set('Content-Type', 'application/activity+json');\n\n        return $response;\n    }\n}\n"
  },
  {
    "path": "src/Controller/ActivityPub/EntryController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\ActivityPub;\n\nuse App\\Controller\\AbstractController;\nuse App\\Entity\\Entry;\nuse App\\Entity\\Magazine;\nuse App\\Factory\\ActivityPub\\EntryPageFactory;\nuse App\\Repository\\TagLinkRepository;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\n\nclass EntryController extends AbstractController\n{\n    public function __construct(\n        private readonly EntryPageFactory $pageFactory,\n        private readonly TagLinkRepository $tagLinkRepository,\n    ) {\n    }\n\n    public function __invoke(\n        #[MapEntity(mapping: ['magazine_name' => 'name'])]\n        Magazine $magazine,\n        #[MapEntity(id: 'entry_id')]\n        Entry $entry,\n        Request $request,\n    ): Response {\n        if ($entry->apId) {\n            return $this->redirect($entry->apId);\n        }\n\n        $response = new JsonResponse($this->pageFactory->create($entry, $this->tagLinkRepository->getTagsOfContent($entry), true));\n\n        $response->headers->set('Content-Type', 'application/activity+json');\n\n        return $response;\n    }\n}\n"
  },
  {
    "path": "src/Controller/ActivityPub/HostMetaController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\ActivityPub;\n\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\Routing\\Generator\\UrlGeneratorInterface;\n\n/**\n * Implementation of RFC 6415 host-meta file.\n *\n * @see https://datatracker.ietf.org/doc/html/rfc6415\n */\nclass HostMetaController\n{\n    public function __construct(\n        private readonly UrlGeneratorInterface $urlGenerator,\n    ) {\n    }\n\n    public function __invoke(): Response\n    {\n        $document = new \\XMLWriter();\n        $document->openMemory();\n        $document->startDocument('1.0', 'UTF-8');\n\n        $document->startElement('XRD');\n        $document->writeAttribute('xmlns', 'http://docs.oasis-open.org/ns/xri/xrd-1.0');\n\n        $document->startElement('Link');\n        $document->writeAttribute('rel', 'lrdd');\n        $document->writeAttribute('type', 'application/jrd+json');\n        $document->writeAttribute(\n            'template',\n            $this->urlGenerator->generate(\n                'ap_webfinger',\n                [],\n                $this->urlGenerator::ABSOLUTE_URL,\n            ).'?resource={uri}'\n        );\n\n        $document->endElement(); // Link\n        $document->endElement(); // XRD\n        $document->endDocument();\n\n        return new Response(\n            $document->outputMemory(),\n            Response::HTTP_OK,\n            [\n                'Content-Type' => 'application/xrd+xml',\n            ],\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/ActivityPub/InstanceController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\ActivityPub;\n\nuse App\\Factory\\ActivityPub\\InstanceFactory;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Contracts\\Cache\\CacheInterface;\nuse Symfony\\Contracts\\Cache\\ItemInterface;\n\nclass InstanceController\n{\n    public function __invoke(Request $request, InstanceFactory $instanceFactory, CacheInterface $cache): JsonResponse\n    {\n        $instance = $cache->get('instance_actor', function (ItemInterface $item) use ($instanceFactory) {\n            $item->expiresAfter(7200);\n\n            return $instanceFactory->create();\n        });\n\n        return new JsonResponse($instance, 200, [\n            'Content-Type' => 'application/activity+json',\n        ]);\n    }\n}\n"
  },
  {
    "path": "src/Controller/ActivityPub/InstanceOutboxController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\ActivityPub;\n\nuse App\\Factory\\ActivityPub\\InstanceFactory;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpFoundation\\Request;\n\nclass InstanceOutboxController\n{\n    public function __invoke(string $kbinDomain, Request $request, InstanceFactory $instanceFactory): JsonResponse\n    {\n        return new JsonResponse([]);\n    }\n}\n"
  },
  {
    "path": "src/Controller/ActivityPub/Magazine/MagazineController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\ActivityPub\\Magazine;\n\nuse App\\Controller\\AbstractController;\nuse App\\Entity\\Magazine;\nuse App\\Factory\\ActivityPub\\GroupFactory;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\n\nclass MagazineController extends AbstractController\n{\n    public function __construct(private readonly GroupFactory $groupFactory)\n    {\n    }\n\n    public function __invoke(Magazine $magazine): JsonResponse\n    {\n        if ($magazine->apId) {\n            throw $this->createNotFoundException();\n        }\n\n        $response = new JsonResponse($this->groupFactory->create($magazine));\n\n        $response->headers->set('Content-Type', 'application/activity+json');\n\n        return $response;\n    }\n}\n"
  },
  {
    "path": "src/Controller/ActivityPub/Magazine/MagazineFollowersController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\ActivityPub\\Magazine;\n\nuse App\\Controller\\AbstractController;\nuse App\\Entity\\Magazine;\nuse App\\Repository\\MagazineSubscriptionRepository;\nuse App\\Service\\ActivityPub\\Wrapper\\CollectionInfoWrapper;\nuse App\\Service\\ActivityPub\\Wrapper\\CollectionItemsWrapper;\nuse App\\Service\\ActivityPubManager;\nuse JetBrains\\PhpStorm\\ArrayShape;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpFoundation\\Request;\n\nclass MagazineFollowersController extends AbstractController\n{\n    public function __construct(\n        private readonly ActivityPubManager $manager,\n        private readonly CollectionInfoWrapper $collectionInfoWrapper,\n        private readonly CollectionItemsWrapper $collectionItemsWrapper,\n        private readonly MagazineSubscriptionRepository $magazineSubscriptionRepository,\n    ) {\n    }\n\n    public function __invoke(Magazine $magazine, Request $request): JsonResponse\n    {\n        if ($magazine->apId) {\n            throw $this->createNotFoundException();\n        }\n\n        if (!$request->get('page')) {\n            $data = $this->collectionInfoWrapper->build('ap_magazine_followers', ['name' => $magazine->name], $magazine->subscriptionsCount);\n        } else {\n            $data = $this->getCollectionItems($magazine, (int) $request->get('page'));\n        }\n\n        $response = new JsonResponse($data);\n        $response->headers->set('Content-Type', 'application/activity+json');\n\n        return $response;\n    }\n\n    #[ArrayShape([\n        '@context' => 'string',\n        'type' => 'string',\n        'partOf' => 'string',\n        'id' => 'string',\n        'totalItems' => 'int',\n        'orderedItems' => 'array',\n        'next' => 'string',\n    ])]\n    private function getCollectionItems(Magazine $magazine, int $page): array\n    {\n        $subscriptions = $this->magazineSubscriptionRepository->findMagazineSubscribers(1, $magazine);\n        $actors = array_map(fn ($sub) => $sub->user, iterator_to_array($subscriptions->getCurrentPageResults()));\n\n        $items = [];\n        foreach ($actors as $actor) {\n            $items[] = $this->manager->getActorProfileId($actor);\n        }\n\n        return $this->collectionItemsWrapper->build(\n            'ap_magazine_followers',\n            ['name' => $magazine->name],\n            $subscriptions,\n            $items,\n            $page\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/ActivityPub/Magazine/MagazineInboxController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\ActivityPub\\Magazine;\n\nuse App\\Message\\ActivityPub\\Inbox\\ActivityMessage;\nuse Psr\\Log\\LoggerInterface;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\Messenger\\MessageBusInterface;\n\nclass MagazineInboxController\n{\n    public function __construct(private readonly MessageBusInterface $bus, private readonly LoggerInterface $logger)\n    {\n    }\n\n    public function __invoke(Request $request): JsonResponse\n    {\n        $requestInfo = array_filter(\n            [\n                'host' => $request->getHost(),\n                'method' => $request->getMethod(),\n                'uri' => $request->getRequestUri(),\n                'client_ip' => $request->getClientIp(),\n            ]\n        );\n\n        $this->logger->debug('MagazineInboxController:request: '.$requestInfo['method'].' '.$requestInfo['uri']);\n        $this->logger->debug('MagazineInboxController:headers: '.$request->headers);\n        $this->logger->debug('MagazineInboxController:content: '.$request->getContent());\n\n        $this->bus->dispatch(\n            new ActivityMessage(\n                $request->getContent(),\n                $requestInfo,\n                $request->headers->all(),\n            )\n        );\n\n        $response = new JsonResponse();\n        $response->headers->set('Content-Type', 'application/activity+json');\n\n        return $response;\n    }\n}\n"
  },
  {
    "path": "src/Controller/ActivityPub/Magazine/MagazineModeratorsController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\ActivityPub\\Magazine;\n\nuse App\\Entity\\Magazine;\nuse App\\Factory\\ActivityPub\\CollectionFactory;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpFoundation\\Request;\n\nclass MagazineModeratorsController\n{\n    public function __construct(\n        private readonly CollectionFactory $collectionFactory,\n    ) {\n    }\n\n    public function __invoke(Magazine $magazine, Request $request): JsonResponse\n    {\n        $data = $this->collectionFactory->getMagazineModeratorCollection($magazine);\n        $response = new JsonResponse($data);\n        $response->headers->set('Content-Type', 'application/activity+json');\n\n        return $response;\n    }\n}\n"
  },
  {
    "path": "src/Controller/ActivityPub/Magazine/MagazineOutboxController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\ActivityPub\\Magazine;\n\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\n\nclass MagazineOutboxController\n{\n    public function __construct()\n    {\n    }\n\n    public function __invoke(): JsonResponse\n    {\n        return new JsonResponse();\n    }\n}\n"
  },
  {
    "path": "src/Controller/ActivityPub/Magazine/MagazinePinnedController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\ActivityPub\\Magazine;\n\nuse App\\Entity\\Magazine;\nuse App\\Factory\\ActivityPub\\CollectionFactory;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpFoundation\\Request;\n\nclass MagazinePinnedController\n{\n    public function __construct(\n        private readonly CollectionFactory $collectionFactory,\n    ) {\n    }\n\n    public function __invoke(Magazine $magazine, Request $request): JsonResponse\n    {\n        $data = $this->collectionFactory->getMagazinePinnedCollection($magazine);\n        $response = new JsonResponse($data);\n        $response->headers->set('Content-Type', 'application/activity+json');\n\n        return $response;\n    }\n}\n"
  },
  {
    "path": "src/Controller/ActivityPub/MessageController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\ActivityPub;\n\nuse App\\Controller\\AbstractController;\nuse App\\Entity\\Message;\nuse App\\Factory\\ActivityPub\\MessageFactory;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\n\nclass MessageController extends AbstractController\n{\n    public function __construct(\n        private readonly MessageFactory $factory,\n    ) {\n    }\n\n    public function __invoke(\n        #[MapEntity(mapping: ['uuid' => 'uuid'])]\n        Message $message,\n        Request $request,\n    ): Response {\n        $json = $this->factory->build($message);\n\n        $response = new JsonResponse($json);\n        $response->headers->set('Content-Type', 'application/activity+json');\n\n        return $response;\n    }\n}\n"
  },
  {
    "path": "src/Controller/ActivityPub/NodeInfoController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\ActivityPub;\n\nuse App\\Factory\\ActivityPub\\NodeInfoFactory;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\Routing\\Generator\\UrlGeneratorInterface;\n\nclass NodeInfoController\n{\n    public const NODE_REL_v20 = 'http://nodeinfo.diaspora.software/ns/schema/2.0';\n    public const NODE_REL_v21 = 'http://nodeinfo.diaspora.software/ns/schema/2.1';\n\n    public function __construct(\n        private readonly NodeInfoFactory $nodeInfoFactory,\n        private readonly UrlGeneratorInterface $urlGenerator,\n    ) {\n    }\n\n    /**\n     * Returning NodeInfo JSON response for path: .well-known/nodeinfo.\n     */\n    public function nodeInfo(): JsonResponse\n    {\n        return new JsonResponse($this->getLinks());\n    }\n\n    /**\n     * Returning NodeInfo JSON response for path: nodeinfo/2.x.\n     *\n     * @param string $version version number of NodeInfo\n     */\n    public function nodeInfoV2(string $version): JsonResponse\n    {\n        return new JsonResponse($this->nodeInfoFactory->create($version));\n    }\n\n    /**\n     * Get list of links for well-known nodeinfo.\n     *\n     * @return array<string, array<string, string>[]>\n     */\n    private function getLinks(): array\n    {\n        return [\n            'links' => [\n                [\n                    'rel' => self::NODE_REL_v21,\n                    'href' => $this->urlGenerator->generate('ap_node_info_v2', ['version' => '2.1'], UrlGeneratorInterface::ABSOLUTE_URL),\n                ],\n                [\n                    'rel' => self::NODE_REL_v20,\n                    'href' => $this->urlGenerator->generate('ap_node_info_v2', ['version' => '2.0'], UrlGeneratorInterface::ABSOLUTE_URL),\n                ],\n            ],\n        ];\n    }\n}\n"
  },
  {
    "path": "src/Controller/ActivityPub/ObjectController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\ActivityPub;\n\nuse App\\Repository\\ActivityRepository;\nuse App\\Service\\ActivityPub\\ActivityJsonBuilder;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\Uid\\Uuid;\n\nclass ObjectController\n{\n    public function __construct(\n        private readonly ActivityRepository $activityRepository,\n        private readonly ActivityJsonBuilder $activityJsonBuilder,\n    ) {\n    }\n\n    public function __invoke(string $id, Request $request): JsonResponse\n    {\n        $uuid = Uuid::fromString($id);\n        $activity = $this->activityRepository->findOneBy(['uuid' => $uuid, 'isRemote' => false]);\n        if (null === $activity) {\n            return new JsonResponse(status: 404);\n        }\n\n        $response = new JsonResponse($this->activityJsonBuilder->buildActivityJson($activity));\n        $response->headers->set('Content-Type', 'application/activity+json');\n\n        return $response;\n    }\n}\n"
  },
  {
    "path": "src/Controller/ActivityPub/PostCommentController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\ActivityPub;\n\nuse App\\Controller\\AbstractController;\nuse App\\Controller\\Traits\\PrivateContentTrait;\nuse App\\Entity\\Magazine;\nuse App\\Entity\\Post;\nuse App\\Entity\\PostComment;\nuse App\\Factory\\ActivityPub\\PostCommentNoteFactory;\nuse App\\Repository\\TagLinkRepository;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\n\nclass PostCommentController extends AbstractController\n{\n    use PrivateContentTrait;\n\n    public function __construct(\n        private readonly PostCommentNoteFactory $commentNoteFactory,\n        private readonly TagLinkRepository $tagLinkRepository,\n    ) {\n    }\n\n    public function __invoke(\n        #[MapEntity(mapping: ['magazine_name' => 'name'])]\n        Magazine $magazine,\n        #[MapEntity(id: 'post_id')]\n        Post $post,\n        #[MapEntity(id: 'comment_id')]\n        PostComment $comment,\n        Request $request,\n    ): Response {\n        if ($comment->apId) {\n            return $this->redirect($comment->apId);\n        }\n\n        $this->handlePrivateContent($post);\n\n        $response = new JsonResponse($this->commentNoteFactory->create($comment, $this->tagLinkRepository->getTagsOfContent($comment), true));\n\n        $response->headers->set('Content-Type', 'application/activity+json');\n\n        return $response;\n    }\n}\n"
  },
  {
    "path": "src/Controller/ActivityPub/PostController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\ActivityPub;\n\nuse App\\Controller\\AbstractController;\nuse App\\Entity\\Magazine;\nuse App\\Entity\\Post;\nuse App\\Factory\\ActivityPub\\PostNoteFactory;\nuse App\\Repository\\TagLinkRepository;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\n\nclass PostController extends AbstractController\n{\n    public function __construct(\n        private readonly PostNoteFactory $postNoteFactory,\n        private readonly TagLinkRepository $tagLinkRepository,\n    ) {\n    }\n\n    public function __invoke(\n        #[MapEntity(mapping: ['magazine_name' => 'name'])]\n        Magazine $magazine,\n        #[MapEntity(id: 'post_id')]\n        Post $post,\n        Request $request,\n    ): Response {\n        if ($post->apId) {\n            return $this->redirect($post->apId);\n        }\n\n        $response = new JsonResponse($this->postNoteFactory->create($post, $this->tagLinkRepository->getTagsOfContent($post), true));\n\n        $response->headers->set('Content-Type', 'application/activity+json');\n\n        return $response;\n    }\n}\n"
  },
  {
    "path": "src/Controller/ActivityPub/ReportController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\ActivityPub;\n\nuse App\\Controller\\AbstractController;\nuse App\\Entity\\Report;\nuse App\\Factory\\ActivityPub\\FlagFactory;\nuse GraphQL\\Exception\\ArgumentException;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\n\nclass ReportController extends AbstractController\n{\n    public function __construct(\n        private readonly FlagFactory $factory,\n    ) {\n    }\n\n    public function __invoke(\n        #[MapEntity(mapping: ['report_id' => 'uuid'])]\n        Report $report,\n        Request $request,\n    ): Response {\n        if (!$report) {\n            throw new ArgumentException('there is no such report');\n        }\n\n        $json = $this->factory->build($report, $this->factory->getPublicUrl($report->getSubject()));\n\n        $response = new JsonResponse($json);\n        $response->headers->set('Content-Type', 'application/activity+json');\n\n        return $response;\n    }\n}\n"
  },
  {
    "path": "src/Controller/ActivityPub/SharedInboxController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\ActivityPub;\n\nuse App\\Message\\ActivityPub\\Inbox\\ActivityMessage;\nuse Psr\\Log\\LoggerInterface;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\Messenger\\MessageBusInterface;\n\nclass SharedInboxController\n{\n    public function __construct(\n        private readonly MessageBusInterface $bus,\n        private readonly LoggerInterface $logger,\n    ) {\n    }\n\n    public function __invoke(Request $request): JsonResponse\n    {\n        $requestInfo = array_filter(\n            [\n                'host' => $request->getHost(),\n                'method' => $request->getMethod(),\n                'uri' => $request->getRequestUri(),\n                'client_ip' => $request->getClientIp(),\n            ]\n        );\n\n        $this->logger->debug('SharedInboxController:request: '.$requestInfo['method'].' '.$requestInfo['uri']);\n        $this->logger->debug('SharedInboxController:headers: '.$request->headers);\n        $this->logger->debug('SharedInboxController:body: '.$request->getContent());\n\n        $this->bus->dispatch(\n            new ActivityMessage(\n                $request->getContent(),\n                $requestInfo,\n                $request->headers->all(),\n            )\n        );\n\n        $response = new JsonResponse();\n        $response->headers->set('Content-Type', 'application/activity+json');\n\n        return $response;\n    }\n}\n"
  },
  {
    "path": "src/Controller/ActivityPub/User/UserController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\ActivityPub\\User;\n\nuse App\\Controller\\AbstractController;\nuse App\\Entity\\User;\nuse App\\Enums\\EApplicationStatus;\nuse App\\Factory\\ActivityPub\\PersonFactory;\nuse App\\Factory\\ActivityPub\\TombstoneFactory;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpFoundation\\Request;\n\nclass UserController extends AbstractController\n{\n    public function __construct(\n        private readonly TombstoneFactory $tombstoneFactory,\n        private readonly PersonFactory $personFactory,\n    ) {\n    }\n\n    public function __invoke(User $user, Request $request): JsonResponse\n    {\n        if ($user->apId) {\n            throw $this->createNotFoundException();\n        }\n\n        if (EApplicationStatus::Approved !== $user->getApplicationStatus()) {\n            throw $this->createNotFoundException();\n        }\n\n        if (!$user->isDeleted || null !== $user->markedForDeletionAt) {\n            $response = new JsonResponse($this->personFactory->create($user, true));\n        } else {\n            $response = new JsonResponse($this->tombstoneFactory->createForUser($user));\n            $response->setStatusCode(410);\n        }\n\n        $response->headers->set('Content-Type', 'application/activity+json');\n\n        return $response;\n    }\n}\n"
  },
  {
    "path": "src/Controller/ActivityPub/User/UserFollowersController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\ActivityPub\\User;\n\nuse App\\Entity\\Contracts\\ActivityPubActivityInterface;\nuse App\\Entity\\User;\nuse App\\Repository\\UserRepository;\nuse App\\Service\\ActivityPub\\Wrapper\\CollectionInfoWrapper;\nuse App\\Service\\ActivityPub\\Wrapper\\CollectionItemsWrapper;\nuse App\\Service\\ActivityPubManager;\nuse JetBrains\\PhpStorm\\ArrayShape;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpFoundation\\Request;\n\nclass UserFollowersController\n{\n    public function __construct(\n        private readonly UserRepository $userRepository,\n        private readonly ActivityPubManager $manager,\n        private readonly CollectionInfoWrapper $collectionInfoWrapper,\n        private readonly CollectionItemsWrapper $collectionItemsWrapper,\n    ) {\n    }\n\n    public function followers(User $user, Request $request): JsonResponse\n    {\n        return $this->get($user, $request, ActivityPubActivityInterface::FOLLOWERS);\n    }\n\n    public function get(User $user, Request $request, string $type): JsonResponse\n    {\n        if (!$request->get('page')) {\n            $data = $this->getCollectionInfo($user, $type);\n        } else {\n            $data = $this->getCollectionItems($user, (int) $request->get('page'), $type);\n        }\n\n        $response = new JsonResponse($data);\n\n        $response->headers->set('Content-Type', 'application/activity+json');\n\n        return $response;\n    }\n\n    #[ArrayShape([\n        '@context' => 'string',\n        'type' => 'string',\n        'id' => 'string',\n        'first' => 'string',\n        'totalItems' => 'int',\n    ])]\n    private function getCollectionInfo(User $user, string $type): array\n    {\n        $routeName = \"ap_user_{$type}\";\n\n        if (ActivityPubActivityInterface::FOLLOWING === $type) {\n            $count = $this->userRepository->findFollowing(1, $user)->getNbResults();\n        } else {\n            $count = $this->userRepository->findFollowers(1, $user)->getNbResults();\n        }\n\n        return $this->collectionInfoWrapper->build($routeName, ['username' => $user->username], $count);\n    }\n\n    #[ArrayShape([\n        '@context' => 'string',\n        'type' => 'string',\n        'partOf' => 'string',\n        'id' => 'string',\n        'totalItems' => 'int',\n        'orderedItems' => 'array',\n    ])]\n    private function getCollectionItems(User $user, int $page, string $type): array\n    {\n        $routeName = \"ap_user_{$type}\";\n        $items = [];\n\n        if (ActivityPubActivityInterface::FOLLOWING === $type) {\n            $actors = $this->userRepository->findFollowing($page, $user);\n            foreach ($actors as $actor) {\n                $items[] = $this->manager->getActorProfileId($actor->following);\n            }\n        } else {\n            $actors = $this->userRepository->findFollowers($page, $user);\n            foreach ($actors as $actor) {\n                $items[] = $this->manager->getActorProfileId($actor->follower);\n            }\n        }\n\n        return $this->collectionItemsWrapper->build(\n            $routeName,\n            ['username' => $user->username],\n            $actors,\n            $items,\n            $page\n        );\n    }\n\n    public function following(User $user, Request $request): JsonResponse\n    {\n        return $this->get($user, $request, ActivityPubActivityInterface::FOLLOWING);\n    }\n}\n"
  },
  {
    "path": "src/Controller/ActivityPub/User/UserInboxController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\ActivityPub\\User;\n\nuse App\\Message\\ActivityPub\\Inbox\\ActivityMessage;\nuse Psr\\Log\\LoggerInterface;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\Messenger\\MessageBusInterface;\n\nclass UserInboxController\n{\n    public function __construct(private readonly MessageBusInterface $bus, private readonly LoggerInterface $logger)\n    {\n    }\n\n    public function __invoke(Request $request): JsonResponse\n    {\n        $requestInfo = array_filter(\n            [\n                'host' => $request->getHost(),\n                'method' => $request->getMethod(),\n                'uri' => $request->getRequestUri(),\n                'client_ip' => $request->getClientIp(),\n            ]\n        );\n\n        $this->logger->debug('UserInboxController:request: '.$requestInfo['method'].' '.$requestInfo['uri']);\n        $this->logger->debug('UserInboxController:headers: '.$request->headers);\n        $this->logger->debug('UserInboxController:content: '.$request->getContent());\n\n        $this->bus->dispatch(\n            new ActivityMessage(\n                $request->getContent(),\n                $requestInfo,\n                $request->headers->all(),\n            )\n        );\n\n        $response = new JsonResponse();\n        $response->headers->set('Content-Type', 'application/activity+json');\n\n        return $response;\n    }\n}\n"
  },
  {
    "path": "src/Controller/ActivityPub/User/UserOutboxController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\ActivityPub\\User;\n\nuse App\\Controller\\AbstractController;\nuse App\\Entity\\User;\nuse App\\Factory\\ActivityPub\\CollectionFactory;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpFoundation\\Request;\n\nclass UserOutboxController extends AbstractController\n{\n    public function __construct(\n        private readonly CollectionFactory $collectionFactory,\n    ) {\n    }\n\n    public function __invoke(User $user, Request $request): JsonResponse\n    {\n        if ($user->apId) {\n            throw $this->createNotFoundException();\n        }\n\n        if (!$request->get('page')) {\n            $data = $this->collectionFactory->getUserOutboxCollection($user);\n        } else {\n            $data = $this->collectionFactory->getUserOutboxCollectionItems($user, (int) $request->get('page'));\n        }\n\n        $response = new JsonResponse($data);\n\n        $response->headers->set('Content-Type', 'application/activity+json');\n\n        return $response;\n    }\n}\n"
  },
  {
    "path": "src/Controller/ActivityPub/WebFingerController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\ActivityPub;\n\nuse App\\ActivityPub\\JsonRd;\nuse App\\Event\\ActivityPub\\WebfingerResponseEvent;\nuse App\\Service\\ActivityPub\\Webfinger\\WebFingerParameters;\nuse Psr\\EventDispatcher\\EventDispatcherInterface;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpFoundation\\Request;\n\nclass WebFingerController\n{\n    public function __construct(\n        private readonly EventDispatcherInterface $eventDispatcher,\n        private readonly WebFingerParameters $webFingerParameters,\n    ) {\n    }\n\n    public function __invoke(Request $request): JsonResponse\n    {\n        $event = new WebfingerResponseEvent(\n            new JsonRd(),\n            $request->query->get('resource') ?: '',\n            $this->webFingerParameters->getParams($request),\n        );\n        $this->eventDispatcher->dispatch($event);\n\n        if (!empty($event->jsonRd->getLinks())) {\n            $response = new JsonResponse($event->jsonRd->toArray());\n        } else {\n            $response = new JsonResponse();\n            $response->setStatusCode(404);\n            $response->headers->set('Status', '404 Not Found');\n        }\n\n        $response->headers->set('Content-Type', 'application/jrd+json; charset=utf-8');\n        $response->headers->set('Access-Control-Allow-Origin', '*');\n\n        return $response;\n    }\n}\n"
  },
  {
    "path": "src/Controller/Admin/AdminClearCacheController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Admin;\n\nuse App\\Controller\\AbstractController;\nuse Symfony\\Bundle\\FrameworkBundle\\Console\\Application;\nuse Symfony\\Component\\Console\\Input\\ArrayInput;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\HttpKernel\\KernelInterface;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass AdminClearCacheController extends AbstractController\n{\n    #[IsGranted('ROLE_ADMIN')]\n    public function __invoke(Request $request, KernelInterface $kernel): Response\n    {\n        $application = new Application($kernel);\n        $application->setAutoExit(false);\n\n        $input = new ArrayInput([\n            'command' => 'cache:clear',\n        ]);\n\n        $application->run($input);\n\n        return $this->redirectToRefererOrHome($request);\n    }\n}\n"
  },
  {
    "path": "src/Controller/Admin/AdminDashboardController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Admin;\n\nuse App\\Controller\\AbstractController;\nuse App\\Service\\InstanceStatsManager;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass AdminDashboardController extends AbstractController\n{\n    public function __construct(private readonly InstanceStatsManager $counter)\n    {\n    }\n\n    #[IsGranted('ROLE_ADMIN')]\n    public function __invoke(?int $statsPeriod, ?bool $withFederated): Response\n    {\n        if (!$statsPeriod or -1 === $statsPeriod) {\n            $statsPeriod = null;\n        }\n\n        if ($statsPeriod) {\n            $statsPeriod = min($statsPeriod, 365);\n        }\n\n        if (null === $withFederated) {\n            $withFederated = false;\n        }\n\n        return $this->render('admin/dashboard.html.twig', [\n            'period' => $statsPeriod,\n            'withFederated' => $withFederated,\n        ] + $this->counter->count($statsPeriod ? \"-$statsPeriod days\" : null, $withFederated));\n    }\n}\n"
  },
  {
    "path": "src/Controller/Admin/AdminDeletionController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Admin;\n\nuse App\\Controller\\AbstractController;\nuse App\\Repository\\MagazineRepository;\nuse App\\Repository\\UserRepository;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass AdminDeletionController extends AbstractController\n{\n    public function __construct(\n        private readonly UserRepository $userRepository,\n        private readonly MagazineRepository $magazineRepository,\n    ) {\n    }\n\n    #[IsGranted('ROLE_ADMIN')]\n    public function users(Request $request): Response\n    {\n        return $this->render('admin/deletion_users.html.twig', [\n            'users' => $this->userRepository->findForDeletionPaginated($request->get('page', 1)),\n        ]);\n    }\n\n    #[IsGranted('ROLE_ADMIN')]\n    public function magazines(Request $request): Response\n    {\n        return $this->render('admin/deletion_magazines.html.twig', [\n            'magazines' => $this->magazineRepository->findForDeletionPaginated($request->get('page', 1)),\n        ]);\n    }\n}\n"
  },
  {
    "path": "src/Controller/Admin/AdminFederationController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Admin;\n\nuse App\\Controller\\AbstractController;\nuse App\\DTO\\ConfirmDefederationDto;\nuse App\\DTO\\FederationSettingsDto;\nuse App\\Form\\ConfirmDefederationType;\nuse App\\Form\\FederationSettingsType;\nuse App\\Repository\\InstanceRepository;\nuse App\\Service\\InstanceManager;\nuse App\\Service\\SettingsManager;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\HttpKernel\\Attribute\\MapQueryParameter;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass AdminFederationController extends AbstractController\n{\n    public function __construct(\n        private readonly SettingsManager $settingsManager,\n        private readonly InstanceRepository $instanceRepository,\n        private readonly EntityManagerInterface $entityManager,\n        private readonly InstanceManager $instanceManager,\n    ) {\n    }\n\n    #[IsGranted('ROLE_ADMIN')]\n    public function __invoke(Request $request): Response\n    {\n        $settings = $this->settingsManager->getDto();\n        $dto = new FederationSettingsDto(\n            $settings->KBIN_FEDERATION_ENABLED,\n            $settings->MBIN_USE_FEDERATION_ALLOW_LIST,\n            $settings->KBIN_FEDERATION_PAGE_ENABLED,\n        );\n\n        $form = $this->createForm(FederationSettingsType::class, $dto);\n        $form->handleRequest($request);\n\n        if ($form->isSubmitted() && $form->isValid()) {\n            /** @var FederationSettingsDto $dto */\n            $dto = $form->getData();\n            $settings->KBIN_FEDERATION_ENABLED = $dto->federationEnabled;\n            $settings->MBIN_USE_FEDERATION_ALLOW_LIST = $dto->federationUsesAllowList;\n            $settings->KBIN_FEDERATION_PAGE_ENABLED = $dto->federationPageEnabled;\n            $this->settingsManager->save($settings);\n\n            return $this->redirectToRoute('admin_federation');\n        }\n\n        $useAllowList = $this->settingsManager->getUseAllowList();\n\n        return $this->render(\n            'admin/federation.html.twig',\n            [\n                'form' => $form->createView(),\n                'useAllowList' => $useAllowList,\n                'instances' => $useAllowList ? $this->settingsManager->getAllowedInstances() : $this->settingsManager->getBannedInstances(),\n                'allInstances' => $this->instanceRepository->findAllOrdered(),\n            ]\n        );\n    }\n\n    #[IsGranted('ROLE_ADMIN')]\n    public function banInstance(#[MapQueryParameter] string $instanceDomain, Request $request): Response\n    {\n        $instance = $this->instanceRepository->getOrCreateInstance($instanceDomain);\n\n        $form = $this->createForm(ConfirmDefederationType::class, new ConfirmDefederationDto());\n        $form->handleRequest($request);\n        if ($form->isSubmitted() && $form->isValid()) {\n            /** @var ConfirmDefederationDto $dto */\n            $dto = $form->getData();\n            if ($dto->confirm) {\n                $this->instanceManager->banInstance($instance);\n\n                return $this->redirectToRoute('admin_federation');\n            } else {\n                $this->addFlash('error', 'flash_error_defederation_must_confirm');\n            }\n        }\n\n        return $this->render('admin/federation_defederate_instance.html.twig', [\n            'form' => $form->createView(),\n            'instance' => $instance,\n            'counts' => $this->instanceRepository->getInstanceCounts($instance),\n            'useAllowList' => $this->settingsManager->getUseAllowList(),\n        ], new Response(status: $form->isSubmitted() && !$form->isValid() ? 422 : 200));\n    }\n\n    #[IsGranted('ROLE_ADMIN')]\n    public function unbanInstance(#[MapQueryParameter] string $instanceDomain): Response\n    {\n        $instance = $this->instanceRepository->getOrCreateInstance($instanceDomain);\n        $this->instanceManager->unbanInstance($instance);\n\n        return $this->redirectToRoute('admin_federation');\n    }\n\n    #[IsGranted('ROLE_ADMIN')]\n    public function allowInstance(#[MapQueryParameter] string $instanceDomain): Response\n    {\n        $instance = $this->instanceRepository->getOrCreateInstance($instanceDomain);\n        $this->instanceManager->allowInstanceFederation($instance);\n\n        return $this->redirectToRoute('admin_federation');\n    }\n\n    #[IsGranted('ROLE_ADMIN')]\n    public function denyInstance(#[MapQueryParameter] string $instanceDomain, Request $request): Response\n    {\n        $instance = $this->instanceRepository->getOrCreateInstance($instanceDomain);\n\n        $form = $this->createForm(ConfirmDefederationType::class, new ConfirmDefederationDto());\n        $form->handleRequest($request);\n        if ($form->isSubmitted() && $form->isValid()) {\n            /** @var ConfirmDefederationDto $dto */\n            $dto = $form->getData();\n            if ($dto->confirm) {\n                $this->instanceManager->denyInstanceFederation($instance);\n\n                return $this->redirectToRoute('admin_federation');\n            } else {\n                $this->addFlash('error', 'flash_error_defederation_must_confirm');\n            }\n        }\n\n        return $this->render('admin/federation_defederate_instance.html.twig', [\n            'form' => $form->createView(),\n            'instance' => $instance,\n            'counts' => $this->instanceRepository->getInstanceCounts($instance),\n            'useAllowList' => $this->settingsManager->getUseAllowList(),\n        ], new Response(status: $form->isSubmitted() && !$form->isValid() ? 422 : 200));\n    }\n}\n"
  },
  {
    "path": "src/Controller/Admin/AdminMagazineOwnershipRequestController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Admin;\n\nuse App\\Controller\\AbstractController;\nuse App\\Entity\\Magazine;\nuse App\\Entity\\User;\nuse App\\Repository\\MagazineOwnershipRequestRepository;\nuse App\\Service\\MagazineManager;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass AdminMagazineOwnershipRequestController extends AbstractController\n{\n    public function __construct(\n        private readonly MagazineOwnershipRequestRepository $repository,\n        private readonly MagazineManager $manager,\n    ) {\n    }\n\n    #[IsGranted('ROLE_ADMIN')]\n    public function requests(Request $request): Response\n    {\n        return $this->render('admin/magazine_ownership.html.twig', [\n            'requests' => $this->repository->findAllPaginated($request->get('page', 1)),\n        ]);\n    }\n\n    #[IsGranted('ROLE_ADMIN')]\n    public function accept(Magazine $magazine, User $user, Request $request): Response\n    {\n        $this->validateCsrf('admin_magazine_ownership_requests_accept', $request->getPayload()->get('token'));\n\n        $this->manager->acceptOwnershipRequest($magazine, $user, $this->getUserOrThrow());\n\n        return $this->redirectToRefererOrHome($request);\n    }\n\n    #[IsGranted('ROLE_ADMIN')]\n    public function reject(Magazine $magazine, User $user, Request $request): Response\n    {\n        $this->validateCsrf('admin_magazine_ownership_requests_reject', $request->getPayload()->get('token'));\n\n        $this->manager->toggleOwnershipRequest($magazine, $user);\n\n        return $this->redirectToRefererOrHome($request);\n    }\n}\n"
  },
  {
    "path": "src/Controller/Admin/AdminModeratorController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Admin;\n\nuse App\\Controller\\AbstractController;\nuse App\\DTO\\ModeratorDto;\nuse App\\Entity\\User;\nuse App\\Form\\ModeratorType;\nuse App\\Repository\\UserRepository;\nuse App\\Service\\InstanceManager;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass AdminModeratorController extends AbstractController\n{\n    public function __construct(\n        private readonly InstanceManager $manager,\n        private readonly UserRepository $repository,\n    ) {\n    }\n\n    #[IsGranted('ROLE_ADMIN')]\n    public function moderators(Request $request): Response\n    {\n        $dto = new ModeratorDto(null);\n\n        $form = $this->createForm(ModeratorType::class, $dto);\n        $form->handleRequest($request);\n\n        if ($form->isSubmitted() && $form->isValid()) {\n            $dto->addedBy = $this->getUserOrThrow();\n            $this->manager->addModerator($dto);\n        }\n\n        $moderators = $this->repository->findModerators($this->getPageNb($request));\n\n        return $this->render(\n            'admin/moderators.html.twig',\n            [\n                'moderators' => $moderators,\n                'form' => $form->createView(),\n            ]\n        );\n    }\n\n    #[IsGranted('ROLE_ADMIN')]\n    public function removeModerator(User $user, Request $request): Response\n    {\n        $this->validateCsrf('remove_moderator', $request->getPayload()->get('token'));\n\n        $this->manager->removeModerator($user);\n\n        return $this->redirectToRefererOrHome($request);\n    }\n}\n"
  },
  {
    "path": "src/Controller/Admin/AdminMonitoringController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Admin;\n\nuse App\\Controller\\AbstractController;\nuse App\\DTO\\MonitoringExecutionContextFilterDto;\nuse App\\Form\\MonitoringExecutionContextFilterType;\nuse App\\Repository\\MonitoringRepository;\nuse Symfony\\Component\\Form\\FormFactoryInterface;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\HttpKernel\\Attribute\\MapQueryParameter;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\nuse Symfony\\Contracts\\Translation\\TranslatorInterface;\nuse Symfony\\UX\\Chartjs\\Builder\\ChartBuilderInterface;\nuse Symfony\\UX\\Chartjs\\Model\\Chart;\n\nclass AdminMonitoringController extends AbstractController\n{\n    public function __construct(\n        private readonly MonitoringRepository $monitoringRepository,\n        private readonly ChartBuilderInterface $chartBuilder,\n        private readonly FormFactoryInterface $formFactory,\n        private readonly TranslatorInterface $translator,\n    ) {\n    }\n\n    #[IsGranted('ROLE_ADMIN')]\n    public function overview(Request $request, #[MapQueryParameter] int $p = 1): Response\n    {\n        $dto = new MonitoringExecutionContextFilterDto();\n        $form = $this->formFactory->createNamed('filter', MonitoringExecutionContextFilterType::class, $dto, ['method' => 'GET']);\n\n        try {\n            $form->handleRequest($request);\n            if ($form->isSubmitted() && $form->isValid()) {\n                $dto = $form->getData();\n            }\n        } catch (\\Exception) {\n        }\n\n        $contexts = $this->monitoringRepository->getFilteredContextsPaginated($dto);\n        $contexts->setCurrentPage($p);\n        $contexts->setMaxPerPage(50);\n\n        if (1 === $p) {\n            $chart = $this->getOverViewChart($dto);\n        }\n\n        return $this->render('admin/monitoring/monitoring.html.twig', [\n            'page' => $p,\n            'executionContexts' => $contexts,\n            'chart' => $chart ?? null,\n            'form' => $form,\n            'configuration' => $this->monitoringRepository->getConfiguration(),\n        ]);\n    }\n\n    private function getOverViewChart(MonitoringExecutionContextFilterDto $dto): Chart\n    {\n        $chart = $this->chartBuilder->createChart(Chart::TYPE_BAR);\n        $chart->setData($this->getOverviewChartData($dto));\n        $chart->setOptions([\n            'scales' => [\n                'y' => [\n                    'label' => '<%=value%>ms',\n                ],\n            ],\n            'interaction' => [\n                'mode' => 'index',\n                'axis' => 'xy',\n            ],\n            'plugins' => [\n                'tooltip' => [\n                    'enabled' => true,\n                ],\n            ],\n        ]);\n\n        return $chart;\n    }\n\n    public function getOverviewChartData(MonitoringExecutionContextFilterDto $dto): array\n    {\n        $rawData = $this->monitoringRepository->getOverviewRouteCalls($dto);\n        $labels = [];\n        $overallDurationRemaining = [];\n        $queryDurations = [];\n        $twigRenderDuration = [];\n        $curlRequestDuration = [];\n        $sendingDuration = [];\n\n        foreach ($rawData as $data) {\n            $labels[] = $data['path'];\n            $total = round(\\floatval($data['total_duration']), 2);\n            $query = round(\\floatval($data['query_duration']), 2);\n            $twig = round(\\floatval($data['twig_render_duration']), 2);\n            $curl = round(\\floatval($data['curl_request_duration']), 2);\n            $sending = round(\\floatval($data['response_duration']), 2);\n            $overallDurationRemaining[] = max(0, round($total - $query - $twig - $curl - $sending, 2));\n            $queryDurations[] = $query;\n            $twigRenderDuration[] = $twig;\n            $curlRequestDuration[] = $curl;\n            $sendingDuration[] = $sending;\n        }\n\n        return [\n            'labels' => $labels,\n            'datasets' => [\n                [\n                    'label' => $this->translator->trans('monitoring_duration_overall'),\n                    'data' => $overallDurationRemaining,\n                    'stack' => '1',\n                    'backgroundColor' => 'gray',\n                    'borderRadius' => 5,\n                ],\n                [\n                    'label' => $this->translator->trans('monitoring_duration_query'),\n                    'data' => $queryDurations,\n                    'stack' => '1',\n                    'backgroundColor' => '#a3067c',\n                    'borderRadius' => 5,\n                ],\n                [\n                    'label' => $this->translator->trans('monitoring_duration_twig_render'),\n                    'data' => $twigRenderDuration,\n                    'stack' => '1',\n                    'backgroundColor' => 'green',\n                    'borderRadius' => 5,\n                ],\n                [\n                    'label' => $this->translator->trans('monitoring_duration_curl_request'),\n                    'data' => $curlRequestDuration,\n                    'stack' => '1',\n                    'backgroundColor' => '#07abaf',\n                    'borderRadius' => 5,\n                ],\n                [\n                    'label' => $this->translator->trans('monitoring_duration_sending_response'),\n                    'data' => $sendingDuration,\n                    'stack' => '1',\n                    'backgroundColor' => 'lightgray',\n                    'borderRadius' => 5,\n                ],\n            ],\n        ];\n    }\n\n    #[IsGranted('ROLE_ADMIN')]\n    public function single(string $id, string $page, #[MapQueryParameter] bool $groupSimilar = true, #[MapQueryParameter] bool $formatQuery = false, #[MapQueryParameter] bool $showParameters = false, #[MapQueryParameter] bool $compareToParent = true): Response\n    {\n        $context = $this->monitoringRepository->findOneBy(['uuid' => $id]);\n        if (!$context) {\n            throw $this->createNotFoundException();\n        }\n\n        return $this->render('admin/monitoring/monitoring_single.html.twig', [\n            'context' => $context,\n            'page' => $page,\n            'groupSimilar' => $groupSimilar,\n            'formatQuery' => $formatQuery,\n            'showParameters' => $showParameters,\n            'compareToParent' => $compareToParent,\n        ]);\n    }\n}\n"
  },
  {
    "path": "src/Controller/Admin/AdminPagesController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Admin;\n\nuse App\\Controller\\AbstractController;\nuse App\\DTO\\PageDto;\nuse App\\Entity\\Site;\nuse App\\Form\\PageType;\nuse App\\Repository\\SiteRepository;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass AdminPagesController extends AbstractController\n{\n    public function __construct(\n        private readonly EntityManagerInterface $entityManager,\n        private readonly SiteRepository $repository,\n    ) {\n    }\n\n    #[IsGranted('ROLE_ADMIN')]\n    public function __invoke(Request $request, ?string $page = 'announcement'): Response\n    {\n        $entity = $this->repository->findAll();\n        if (!\\count($entity)) {\n            $entity = new Site();\n        } else {\n            $entity = $entity[0];\n        }\n\n        $form = $this->createForm(PageType::class, (new PageDto())->create($entity->{$page} ?? ''));\n\n        try {\n            $form->handleRequest($request);\n        } catch (\\Exception $e) {\n            $entity->{$page} = '';\n            $this->entityManager->persist($entity);\n            $this->entityManager->flush();\n\n            return $this->redirectToRefererOrHome($request);\n        }\n\n        if ($form->isSubmitted() && $form->isValid()) {\n            $entity->{$page} = $form->getData()->body;\n            $this->entityManager->persist($entity);\n            $this->entityManager->flush();\n\n            return $this->redirectToRefererOrHome($request);\n        }\n\n        return $this->render('admin/pages.html.twig', [\n            'form' => $form->createView(),\n        ]);\n    }\n}\n"
  },
  {
    "path": "src/Controller/Admin/AdminReportController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Admin;\n\nuse App\\Controller\\AbstractController;\nuse App\\Repository\\NotificationRepository;\nuse App\\Repository\\ReportRepository;\nuse Symfony\\Component\\ExpressionLanguage\\Expression;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass AdminReportController extends AbstractController\n{\n    public function __construct(\n        private readonly ReportRepository $repository,\n        private readonly NotificationRepository $notificationRepository,\n    ) {\n    }\n\n    #[IsGranted(new Expression('is_granted(\"ROLE_ADMIN\") or is_granted(\"ROLE_MODERATOR\")'))]\n    public function __invoke(Request $request, string $status): Response\n    {\n        $page = (int) $request->get('p', 1);\n\n        $reports = $this->repository->findAllPaginated($page, $status);\n        $this->notificationRepository->markReportNotificationsAsRead($this->getUserOrThrow());\n\n        return $this->render(\n            'admin/reports.html.twig',\n            [\n                'reports' => $reports,\n            ]\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Admin/AdminSettingsController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Admin;\n\nuse App\\Controller\\AbstractController;\nuse App\\Form\\SettingsType;\nuse App\\Service\\SettingsManager;\nuse Symfony\\Component\\Form\\FormError;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\nuse Symfony\\Contracts\\Translation\\TranslatorInterface;\n\nclass AdminSettingsController extends AbstractController\n{\n    public function __construct(\n        private readonly SettingsManager $settings,\n        private readonly TranslatorInterface $translator)\n    {\n    }\n\n    #[IsGranted('ROLE_ADMIN')]\n    public function __invoke(Request $request): Response\n    {\n        $dto = $this->settings->getDto();\n\n        $form = $this->createForm(SettingsType::class, $dto);\n\n        if ($dto->MBIN_SIDEBAR_SECTIONS_RANDOM_LOCAL_ONLY) {\n            // See: https://github.com/MbinOrg/mbin/issues/1868\n            $form->get('MBIN_SIDEBAR_SECTIONS_RANDOM_LOCAL_ONLY')->addError(new FormError($this->translator->trans('random_local_only_performance_warning')));\n        }\n\n        $form->handleRequest($request);\n\n        if ($form->isSubmitted() && $form->isValid()) {\n            $this->settings->save($dto);\n\n            return $this->redirectToRefererOrHome($request);\n        }\n\n        return $this->render('admin/settings.html.twig', [\n            'form' => $form->createView(),\n        ]);\n    }\n}\n"
  },
  {
    "path": "src/Controller/Admin/AdminSignupRequestsController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Admin;\n\nuse App\\Controller\\AbstractController;\nuse App\\Entity\\User;\nuse App\\Repository\\NotificationRepository;\nuse App\\Repository\\UserRepository;\nuse App\\Service\\UserManager;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\ExpressionLanguage\\Expression;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\HttpKernel\\Attribute\\MapQueryParameter;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass AdminSignupRequestsController extends AbstractController\n{\n    public function __construct(\n        private readonly UserRepository $repository,\n        private readonly UserManager $userManager,\n        private readonly NotificationRepository $notificationRepository,\n    ) {\n    }\n\n    #[IsGranted(new Expression('is_granted(\"ROLE_ADMIN\") or is_granted(\"ROLE_MODERATOR\")'))]\n    public function requests(#[MapQueryParameter] ?int $page = 1, #[MapQueryParameter] ?string $username = null): Response\n    {\n        if (null === $username) {\n            $requests = $this->repository->findAllSignupRequestsPaginated($page);\n        } else {\n            $requests = [];\n            if ($signupRequest = $this->repository->findSignupRequest($username)) {\n                $requests[] = $signupRequest;\n            }\n            $user = $this->repository->findOneBy(['username' => $username]);\n            // Always mark the notifications as read, even if the user does not have any signup requests anymore\n            $this->notificationRepository->markUserSignupNotificationsAsRead($this->getUserOrThrow(), $user);\n        }\n\n        return $this->render('admin/signup_requests.html.twig', [\n            'requests' => $requests,\n            'page' => $page,\n            'username' => $username,\n        ]);\n    }\n\n    #[IsGranted(new Expression('is_granted(\"ROLE_ADMIN\") or is_granted(\"ROLE_MODERATOR\")'))]\n    public function approve(#[MapQueryParameter] int $page, #[MapEntity(id: 'id')] User $user): Response\n    {\n        $this->userManager->approveUserApplication($user);\n\n        return $this->redirectToRoute('admin_signup_requests', ['page' => $page]);\n    }\n\n    #[IsGranted(new Expression('is_granted(\"ROLE_ADMIN\") or is_granted(\"ROLE_MODERATOR\")'))]\n    public function reject(#[MapQueryParameter] int $page, #[MapEntity(id: 'id')] User $user): Response\n    {\n        $this->userManager->rejectUserApplication($user);\n\n        return $this->redirectToRoute('admin_signup_requests', ['page' => $page]);\n    }\n}\n"
  },
  {
    "path": "src/Controller/Admin/AdminUsersController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Admin;\n\nuse App\\Controller\\AbstractController;\nuse App\\Entity\\User;\nuse App\\Repository\\ReputationRepository;\nuse App\\Repository\\UserRepository;\nuse Doctrine\\ORM\\Query\\Expr\\OrderBy;\nuse Symfony\\Component\\HttpFoundation\\RequestStack;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\HttpKernel\\Attribute\\MapQueryParameter;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass AdminUsersController extends AbstractController\n{\n    public function __construct(\n        private readonly UserRepository $repository,\n        private readonly RequestStack $request,\n        private readonly ReputationRepository $reputationRepository,\n    ) {\n    }\n\n    #[IsGranted('ROLE_ADMIN')]\n    public function active(\n        ?bool $withFederated = null,\n        #[MapQueryParameter] int $p = 1,\n        #[MapQueryParameter] string $sort = 'ASC',\n        #[MapQueryParameter] string $field = 'createdAt',\n        #[MapQueryParameter] ?string $search = null,\n    ): Response {\n        $users = $this->repository\n            ->findAllActivePaginated(\n                page: $p,\n                onlyLocal: !($withFederated ?? false),\n                searchTerm: $search,\n                orderBy: new OrderBy(\"u.$field\", $sort),\n            );\n        $userIds = array_map(fn (User $user) => $user->getId(), [...$users]);\n        $attitudes = $this->reputationRepository->getUserAttitudes(...$userIds);\n\n        return $this->render(\n            'admin/users.html.twig',\n            [\n                'users' => $users,\n                'withFederated' => $withFederated,\n                'sortField' => $field,\n                'order' => $sort,\n                'searchTerm' => $search,\n                'attitudes' => $attitudes,\n            ]\n        );\n    }\n\n    #[IsGranted('ROLE_ADMIN')]\n    public function inactive(\n        #[MapQueryParameter] int $p = 1,\n        #[MapQueryParameter] string $sort = 'ASC',\n        #[MapQueryParameter] string $field = 'createdAt',\n        #[MapQueryParameter] ?string $search = null,\n    ): Response {\n        return $this->render(\n            'admin/users.html.twig',\n            [\n                'users' => $this->repository->findAllInactivePaginated(\n                    $p,\n                    searchTerm: $search,\n                    orderBy: new OrderBy(\"u.$field\", $sort)\n                ),\n                'sortField' => $field,\n                'order' => $sort,\n                'searchTerm' => $search,\n            ]\n        );\n    }\n\n    #[IsGranted('ROLE_ADMIN')]\n    public function suspended(\n        ?bool $withFederated = null,\n        #[MapQueryParameter] int $p = 1,\n        #[MapQueryParameter] string $sort = 'ASC',\n        #[MapQueryParameter] string $field = 'createdAt',\n        #[MapQueryParameter] ?string $search = null,\n    ): Response {\n        return $this->render(\n            'admin/users.html.twig',\n            [\n                'users' => $this->repository->findAllSuspendedPaginated(\n                    $p,\n                    onlyLocal: !($withFederated ?? false),\n                    searchTerm: $search,\n                    orderBy: new OrderBy(\"u.$field\", $sort)\n                ),\n                'withFederated' => $withFederated,\n                'sortField' => $field,\n                'order' => $sort,\n                'searchTerm' => $search,\n            ]\n        );\n    }\n\n    #[IsGranted('ROLE_ADMIN')]\n    public function banned(\n        ?bool $withFederated = null,\n        #[MapQueryParameter] int $p = 1,\n        #[MapQueryParameter] string $sort = 'ASC',\n        #[MapQueryParameter] string $field = 'createdAt',\n        #[MapQueryParameter] ?string $search = null,\n    ): Response {\n        return $this->render(\n            'admin/users.html.twig',\n            [\n                'users' => $this->repository->findAllBannedPaginated(\n                    $p,\n                    onlyLocal: !($withFederated ?? false),\n                    searchTerm: $search,\n                    orderBy: new OrderBy(\"u.$field\", $sort),\n                ),\n                'withFederated' => $withFederated,\n                'sortField' => $field,\n                'order' => $sort,\n                'searchTerm' => $search,\n            ]\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/AgentController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller;\n\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\n\nclass AgentController extends AbstractController\n{\n    public function __invoke(Request $request): Response\n    {\n        return $this->render('page/agent.html.twig');\n    }\n}\n"
  },
  {
    "path": "src/Controller/AjaxController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller;\n\nuse App\\DTO\\UserNoteDto;\nuse App\\Entity\\Entry;\nuse App\\Entity\\EntryComment;\nuse App\\Entity\\Post;\nuse App\\Entity\\PostComment;\nuse App\\Entity\\User;\nuse App\\Entity\\UserPushSubscription;\nuse App\\Form\\UserNoteType;\nuse App\\PageView\\PostCommentPageView;\nuse App\\Payloads\\NotificationsCountResponsePayload;\nuse App\\Payloads\\PushNotification;\nuse App\\Payloads\\RegisterPushRequestPayload;\nuse App\\Payloads\\TestPushRequestPayload;\nuse App\\Payloads\\UnRegisterPushRequestPayload;\nuse App\\Repository\\Criteria;\nuse App\\Repository\\EntryRepository;\nuse App\\Repository\\PostCommentRepository;\nuse App\\Repository\\UserPushSubscriptionRepository;\nuse App\\Repository\\UserRepository;\nuse App\\Service\\Notification\\UserPushSubscriptionManager;\nuse App\\Service\\SettingsManager;\nuse App\\Service\\UserNoteManager;\nuse App\\Utils\\Embed;\nuse Doctrine\\DBAL\\ParameterType;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Psr\\Log\\LoggerInterface;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Bundle\\SecurityBundle\\Security;\nuse Symfony\\Component\\Emoji\\EmojiTransliterator;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpKernel\\Attribute\\MapQueryParameter;\nuse Symfony\\Component\\HttpKernel\\Attribute\\MapRequestPayload;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\nuse Symfony\\Contracts\\Cache\\CacheInterface;\nuse Symfony\\Contracts\\Cache\\ItemInterface;\nuse Symfony\\Contracts\\HttpClient\\HttpClientInterface;\nuse Symfony\\Contracts\\Translation\\TranslatorInterface;\n\nclass AjaxController extends AbstractController\n{\n    public function __construct(\n        private readonly UserPushSubscriptionRepository $repository,\n        private readonly EntityManagerInterface $entityManager,\n        private readonly LoggerInterface $logger,\n        private readonly UserPushSubscriptionManager $pushSubscriptionManager,\n        private readonly TranslatorInterface $translator,\n        private readonly SettingsManager $settingsManager,\n        private readonly Security $security,\n    ) {\n    }\n\n    public function fetchTitle(Embed $embed, Request $request): JsonResponse\n    {\n        $url = json_decode($request->getContent())->url;\n        $embed = $embed->fetch($url);\n\n        return new JsonResponse(\n            [\n                'title' => $embed->title,\n                'description' => $embed->description,\n                'image' => $embed->image,\n            ]\n        );\n    }\n\n    public function fetchDuplicates(EntryRepository $repository, Request $request): JsonResponse\n    {\n        $url = json_decode($request->getContent())->url;\n        $entries = $repository->findBy(['url' => $url]);\n\n        return new JsonResponse(\n            [\n                'total' => \\count($entries),\n                'html' => $this->renderView('entry/_list.html.twig', ['entries' => $entries]),\n            ]\n        );\n    }\n\n    /**\n     * Returns an embeded objects html value, to be used for front-end insertion.\n     */\n    public function fetchEmbed(Embed $embed, Request $request): JsonResponse\n    {\n        $data = $embed->fetch($request->get('url'));\n        // only wrap embed link for image embed as it doesn't make much sense for any other type for embed\n        if ($data->isImageUrl()) {\n            $html = \\sprintf(\n                '<a href=\"%s\" class=\"embed-link\">%s</a>',\n                $data->url,\n                $data->html\n            );\n        } else {\n            $html = $data->html;\n        }\n\n        return new JsonResponse(\n            [\n                'html' => \\sprintf('<div class=\"preview\">%s</div>', $html),\n            ]\n        );\n    }\n\n    public function fetchEntry(#[MapEntity(id: 'id')] Entry $entry, Request $request): JsonResponse\n    {\n        return new JsonResponse(\n            [\n                'html' => $this->renderView(\n                    'components/_ajax.html.twig',\n                    [\n                        'component' => 'entry',\n                        'attributes' => [\n                            'entry' => $entry,\n                        ],\n                    ]\n                ),\n            ]\n        );\n    }\n\n    public function fetchEntryComment(#[MapEntity(id: 'id')] EntryComment $comment): JsonResponse\n    {\n        return new JsonResponse(\n            [\n                'html' => $this->renderView(\n                    'components/_ajax.html.twig',\n                    [\n                        'component' => 'entry_comment',\n                        'attributes' => [\n                            'comment' => $comment,\n                            'showEntryTitle' => false,\n                            'showMagazineName' => false,\n                        ],\n                    ]\n                ),\n            ]\n        );\n    }\n\n    public function fetchPost(#[MapEntity(id: 'id')] Post $post): JsonResponse\n    {\n        return new JsonResponse(\n            [\n                'html' => $this->renderView(\n                    'components/_ajax.html.twig',\n                    [\n                        'component' => 'post',\n                        'attributes' => [\n                            'post' => $post,\n                        ],\n                    ]\n                ),\n            ]\n        );\n    }\n\n    public function fetchPostComment(#[MapEntity(id: 'id')] PostComment $comment): JsonResponse\n    {\n        return new JsonResponse(\n            [\n                'html' => $this->renderView(\n                    'components/_ajax.html.twig',\n                    [\n                        'component' => 'post_comment',\n                        'attributes' => [\n                            'comment' => $comment,\n                        ],\n                    ]\n                ),\n            ]\n        );\n    }\n\n    public function fetchPostComments(#[MapEntity(id: 'id')] Post $post, PostCommentRepository $repository): JsonResponse\n    {\n        $criteria = new PostCommentPageView(1, $this->security);\n        $criteria->post = $post;\n        $criteria->sortOption = Criteria::SORT_OLD;\n        $criteria->perPage = 500;\n\n        $comments = $repository->findByCriteria($criteria);\n\n        return new JsonResponse(\n            [\n                'html' => $this->renderView(\n                    'post/comment/_preview.html.twig',\n                    ['comments' => $comments, 'post' => $post, 'criteria' => $criteria]\n                ),\n            ]\n        );\n    }\n\n    public function fetchOnline(\n        string $topic,\n        string $mercurePublicUrl,\n        string $mercureSubscriptionsToken,\n        HttpClientInterface $httpClient,\n        CacheInterface $cache,\n    ): JsonResponse {\n        $resp = $httpClient->request('GET', $mercurePublicUrl.'/subscriptions/'.$topic, [\n            'auth_bearer' => $mercureSubscriptionsToken,\n        ]);\n\n        // @todo cloudflare bug\n        $online = $cache->get($topic, function (ItemInterface $item) use ($resp) {\n            $item->expiresAfter(45);\n\n            return \\count($resp->toArray()['subscriptions']) + 1;\n        });\n\n        return new JsonResponse([\n            'online' => $online,\n        ]);\n    }\n\n    public function fetchUserPopup(#[MapEntity(mapping: ['username' => 'username'])] User $user, UserNoteManager $manager): JsonResponse\n    {\n        if ($this->getUser()) {\n            $dto = $manager->createDto($this->getUserOrThrow(), $user);\n        } else {\n            $dto = new UserNoteDto();\n            $dto->target = $user;\n        }\n\n        $form = $this->createForm(UserNoteType::class, $dto, [\n            'action' => $this->generateUrl('user_note', ['username' => $dto->target->username]),\n        ]);\n\n        return new JsonResponse([\n            'html' => $this->renderView('user/_user_popover.html.twig', ['user' => $user, 'form' => $form->createView()]\n            ),\n        ]);\n    }\n\n    public function fetchUsersSuggestions(string $username, Request $request, UserRepository $repository): JsonResponse\n    {\n        return new JsonResponse(\n            [\n                'html' => $this->renderView(\n                    'search/_user_suggestion.html.twig',\n                    [\n                        'users' => $repository->findUsersSuggestions(ltrim($username, '@')),\n                    ]\n                ),\n            ]\n        );\n    }\n\n    public function fetchEmojiSuggestions(#[MapQueryParameter] string $query): JsonResponse\n    {\n        $trans = EmojiTransliterator::create('text-emoji');\n        $class = new \\ReflectionClass($trans);\n        $emojis = $class->getProperty('map')->getValue($trans);\n        $codes = array_keys($emojis);\n        $matches = array_filter($codes, fn ($emoji) => str_contains($emoji, $query));\n        $results = array_map(function ($code) use ($emojis) {\n            $std = new \\stdClass();\n            $std->shortCode = $code;\n            $std->emoji = $emojis[$code];\n\n            return $std;\n        }, $matches);\n\n        return new JsonResponse(\n            [\n                'html' => $this->renderView(\n                    'search/_emoji_suggestion.html.twig',\n                    [\n                        'emojis' => \\array_slice($results, 0, 5),\n                    ]\n                ),\n            ]\n        );\n    }\n\n    #[IsGranted('ROLE_USER')]\n    public function fetchNotificationsCount(): JsonResponse\n    {\n        $user = $this->getUserOrThrow();\n\n        return new JsonResponse(new NotificationsCountResponsePayload($user->countNewNotifications(), $user->countNewMessages()));\n    }\n\n    public function registerPushNotifications(#[MapRequestPayload] RegisterPushRequestPayload $payload): JsonResponse\n    {\n        $user = $this->getUserOrThrow();\n        $pushSubscription = $this->repository->findOneBy(['apiToken' => null, 'deviceKey' => $payload->deviceKey, 'user' => $user]);\n        if (!$pushSubscription) {\n            $pushSubscription = new UserPushSubscription($user, $payload->endpoint, $payload->contentPublicKey, $payload->serverKey, []);\n            $pushSubscription->deviceKey = $payload->deviceKey;\n            $pushSubscription->locale = $this->settingsManager->getLocale();\n        } else {\n            $pushSubscription->endpoint = $payload->endpoint;\n            $pushSubscription->serverAuthKey = $payload->serverKey;\n            $pushSubscription->contentEncryptionPublicKey = $payload->contentPublicKey;\n        }\n        $this->entityManager->persist($pushSubscription);\n        $this->entityManager->flush();\n\n        try {\n            $testNotification = new PushNotification(null, '', $this->translator->trans('test_push_message', locale: $pushSubscription->locale));\n            $this->pushSubscriptionManager->sendTextToUser($user, $testNotification, specificDeviceKey: $payload->deviceKey);\n\n            return new JsonResponse();\n        } catch (\\ErrorException $e) {\n            $this->logger->error('[AjaxController::handle] There was an exception while deleting a UserPushSubscription: {e} - {m}. {o}', [\n                'e' => \\get_class($e),\n                'm' => $e->getMessage(),\n                'o' => json_encode($e),\n            ]);\n\n            return new JsonResponse(status: 500);\n        }\n    }\n\n    public function unregisterPushNotifications(#[MapRequestPayload] UnRegisterPushRequestPayload $payload): JsonResponse\n    {\n        try {\n            $conn = $this->entityManager->getConnection();\n            $stmt = $conn->prepare('DELETE FROM user_push_subscription WHERE user_id = :user AND device_key = :device');\n            $stmt->bindValue('user', $this->getUserOrThrow()->getId(), ParameterType::INTEGER);\n            $stmt->bindValue('device', $payload->deviceKey, ParameterType::STRING);\n            $stmt->executeQuery();\n\n            return new JsonResponse();\n        } catch (\\Exception $e) {\n            $this->logger->error('[AjaxController::unregisterPushNotifications] There was an exception while deleting a UserPushSubscription: {e} - {m}. {o}', [\n                'e' => \\get_class($e),\n                'm' => $e->getMessage(),\n                'o' => json_encode($e),\n            ]);\n\n            return new JsonResponse(status: 500);\n        }\n    }\n\n    public function testPushNotification(#[MapRequestPayload] TestPushRequestPayload $payload): JsonResponse\n    {\n        $user = $this->getUserOrThrow();\n        try {\n            $this->pushSubscriptionManager->sendTextToUser($user, new PushNotification(null, '', $this->translator->trans('test_push_message')), specificDeviceKey: $payload->deviceKey);\n\n            return new JsonResponse();\n        } catch (\\ErrorException $e) {\n            $this->logger->error('[AjaxController::testPushNotification] There was an exception while deleting a UserPushSubscription: {e} - {m}. {o}', [\n                'e' => \\get_class($e),\n                'm' => $e->getMessage(),\n                'o' => json_encode($e),\n            ]);\n\n            return new JsonResponse(status: 500);\n        }\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/BaseApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api;\n\nuse App\\Controller\\AbstractController;\nuse App\\DTO\\EntryCommentDto;\nuse App\\DTO\\EntryCommentResponseDto;\nuse App\\DTO\\EntryDto;\nuse App\\DTO\\EntryResponseDto;\nuse App\\DTO\\MagazineDto;\nuse App\\DTO\\MagazineResponseDto;\nuse App\\DTO\\PostCommentDto;\nuse App\\DTO\\PostCommentResponseDto;\nuse App\\DTO\\PostDto;\nuse App\\DTO\\PostResponseDto;\nuse App\\DTO\\ReportDto;\nuse App\\DTO\\ReportRequestDto;\nuse App\\DTO\\UserDto;\nuse App\\DTO\\UserFilterListResponseDto;\nuse App\\DTO\\UserResponseDto;\nuse App\\Entity\\Client;\nuse App\\Entity\\Contracts\\ContentInterface;\nuse App\\Entity\\Contracts\\ContentVisibilityInterface;\nuse App\\Entity\\Contracts\\ReportInterface;\nuse App\\Entity\\Contracts\\VisibilityInterface;\nuse App\\Entity\\Entry;\nuse App\\Entity\\EntryComment;\nuse App\\Entity\\Image;\nuse App\\Entity\\MagazineLog;\nuse App\\Entity\\OAuth2ClientAccess;\nuse App\\Entity\\Post;\nuse App\\Entity\\PostComment;\nuse App\\Entity\\UserFilterList;\nuse App\\Enums\\ENotificationStatus;\nuse App\\Exception\\SubjectHasBeenReportedException;\nuse App\\Factory\\EntryCommentFactory;\nuse App\\Factory\\EntryFactory;\nuse App\\Factory\\ImageFactory;\nuse App\\Factory\\MagazineFactory;\nuse App\\Factory\\PostCommentFactory;\nuse App\\Factory\\PostFactory;\nuse App\\Factory\\UserFactory;\nuse App\\Form\\Constraint\\ImageConstraint;\nuse App\\Pagination\\Cursor\\CursorPaginationInterface;\nuse App\\Repository\\BookmarkListRepository;\nuse App\\Repository\\BookmarkRepository;\nuse App\\Repository\\Criteria;\nuse App\\Repository\\EntryCommentRepository;\nuse App\\Repository\\EntryRepository;\nuse App\\Repository\\ImageRepository;\nuse App\\Repository\\InstanceRepository;\nuse App\\Repository\\NotificationSettingsRepository;\nuse App\\Repository\\OAuth2ClientAccessRepository;\nuse App\\Repository\\PostCommentRepository;\nuse App\\Repository\\PostRepository;\nuse App\\Repository\\ReputationRepository;\nuse App\\Repository\\TagLinkRepository;\nuse App\\Repository\\UserRepository;\nuse App\\Schema\\CursorPaginationSchema;\nuse App\\Schema\\PaginationSchema;\nuse App\\Service\\BookmarkManager;\nuse App\\Service\\InstanceManager;\nuse App\\Service\\IpResolver;\nuse App\\Service\\ReportManager;\nuse App\\Service\\SettingsManager;\nuse App\\Service\\UserManager;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse League\\Bundle\\OAuth2ServerBundle\\Model\\AccessToken;\nuse League\\Bundle\\OAuth2ServerBundle\\Security\\Authentication\\Token\\OAuth2Token;\nuse Pagerfanta\\PagerfantaInterface;\nuse Psr\\Container\\ContainerExceptionInterface;\nuse Psr\\Container\\NotFoundExceptionInterface;\nuse Psr\\Log\\LoggerInterface;\nuse Symfony\\Component\\HttpFoundation\\File\\UploadedFile;\nuse Symfony\\Component\\HttpFoundation\\RequestStack;\nuse Symfony\\Component\\HttpKernel\\Exception\\AccessDeniedHttpException;\nuse Symfony\\Component\\HttpKernel\\Exception\\BadRequestHttpException;\nuse Symfony\\Component\\HttpKernel\\Exception\\TooManyRequestsHttpException;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Serializer\\SerializerInterface;\nuse Symfony\\Component\\Validator\\Constraints\\Image as BaseImageConstraint;\nuse Symfony\\Component\\Validator\\Validator\\ValidatorInterface;\nuse Symfony\\Contracts\\Translation\\TranslatorInterface;\n\nclass BaseApi extends AbstractController\n{\n    public const MIN_PER_PAGE = 1;\n    public const MAX_PER_PAGE = 100;\n    public const DEPTH = 10;\n    public const MIN_DEPTH = 0;\n    public const MAX_DEPTH = 25;\n\n    /** @var BaseImageConstraint */\n    private static $constraint;\n\n    public function __construct(\n        protected readonly IpResolver $ipResolver,\n        protected readonly LoggerInterface $logger,\n        protected readonly SerializerInterface $serializer,\n        protected readonly ValidatorInterface $validator,\n        protected readonly EntityManagerInterface $entityManager,\n        protected readonly ImageFactory $imageFactory,\n        protected readonly PostFactory $postFactory,\n        protected readonly PostCommentFactory $postCommentFactory,\n        protected readonly EntryFactory $entryFactory,\n        protected readonly EntryCommentFactory $entryCommentFactory,\n        protected readonly MagazineFactory $magazineFactory,\n        protected readonly RequestStack $request,\n        protected readonly TagLinkRepository $tagLinkRepository,\n        protected readonly EntryRepository $entryRepository,\n        protected readonly EntryCommentRepository $entryCommentRepository,\n        protected readonly PostRepository $postRepository,\n        protected readonly PostCommentRepository $postCommentRepository,\n        protected readonly BookmarkListRepository $bookmarkListRepository,\n        protected readonly BookmarkRepository $bookmarkRepository,\n        protected readonly BookmarkManager $bookmarkManager,\n        protected readonly UserManager $userManager,\n        protected readonly UserRepository $userRepository,\n        private readonly ImageRepository $imageRepository,\n        private readonly ReportManager $reportManager,\n        private readonly OAuth2ClientAccessRepository $clientAccessRepository,\n        protected readonly NotificationSettingsRepository $notificationSettingsRepository,\n        protected readonly SettingsManager $settingsManager,\n        protected readonly UserFactory $userFactory,\n        protected readonly ReputationRepository $reputationRepository,\n        protected readonly InstanceRepository $instanceRepository,\n        protected readonly InstanceManager $instanceManager,\n        protected readonly TranslatorInterface $translator,\n    ) {\n    }\n\n    /**\n     * Rate limit an API request and return rate limit status headers.\n     *\n     * @param ?RateLimiterFactoryInterface $limiterFactory     A limiter factory to use when the user is authenticated\n     * @param ?RateLimiterFactoryInterface $anonLimiterFactory A limiter factory to use when the user is anonymous\n     *\n     * @return array<string, int> An array of headers describing the current rate limit status to the client\n     *\n     * @throws AccessDeniedHttpException    if the user is not authenticated and no anonymous rate limiter factory is provided, access to the resource will be denied\n     * @throws TooManyRequestsHttpException If the limit is hit, rate limit the connection\n     */\n    protected function rateLimit(\n        ?RateLimiterFactoryInterface $limiterFactory = null,\n        ?RateLimiterFactoryInterface $anonLimiterFactory = null,\n    ): array {\n        $this->logAccess();\n        if (null === $limiterFactory && null === $anonLimiterFactory) {\n            throw new \\LogicException('No rate limiter factory provided!');\n        }\n        $limiter = null;\n        if (\n            $limiterFactory && $this->isGranted('ROLE_USER')\n        ) {\n            $limiter = $limiterFactory->create($this->getUserOrThrow()->getUserIdentifier());\n        } elseif ($anonLimiterFactory) {\n            $limiter = $anonLimiterFactory->create($this->ipResolver->resolve());\n        } else {\n            // non-API_USER without an anonymous rate limiter? Not allowed.\n            throw new AccessDeniedHttpException();\n        }\n        $limit = $limiter->consume();\n\n        $headers = [\n            'X-RateLimit-Remaining' => $limit->getRemainingTokens(),\n            'X-RateLimit-Retry-After' => $limit->getRetryAfter()->getTimestamp(),\n            'X-RateLimit-Limit' => $limit->getLimit(),\n        ];\n\n        if (false === $limit->isAccepted()) {\n            throw new TooManyRequestsHttpException(headers: $headers);\n        }\n\n        return $headers;\n    }\n\n    /**\n     * Logs timestamp, client, and route name of authenticated API access for admin\n     * to track how API clients are being (ab)used and for stat creation.\n     *\n     * This might be better to have as a cache entry, with an aggregate in the database\n     * created periodically\n     */\n    private function logAccess(): void\n    {\n        /** @var ?OAuth2Token $token */\n        $token = $this->container->get('security.token_storage')->getToken();\n        if (null !== $token && $token instanceof OAuth2Token) {\n            $clientId = $token->getOAuthClientId();\n            /** @var Client $client */\n            $client = $this->entityManager->getReference(Client::class, $clientId);\n            $access = new OAuth2ClientAccess();\n            $access->setClient($client);\n            $access->setCreatedAt(new \\DateTimeImmutable());\n            $access->setPath($this->request->getCurrentRequest()->get('_route'));\n            $this->clientAccessRepository->save($access, flush: true);\n        }\n    }\n\n    public function getOAuthToken(): ?OAuth2Token\n    {\n        try {\n            /** @var ?OAuth2Token $token */\n            $token = $this->container->get('security.token_storage')->getToken();\n            if ($token instanceof OAuth2Token) {\n                return $token;\n            }\n        } catch (NotFoundExceptionInterface|ContainerExceptionInterface $e) {\n            $this->logger->warning('there was an error getting the access token: {e} - {m}, {stack}', [\n                'e' => \\get_class($e),\n                'm' => $e->getMessage(),\n                'stack' => $e->getTraceAsString(),\n            ]);\n        }\n\n        return null;\n    }\n\n    public function getAccessToken(?OAuth2Token $oAuth2Token): ?AccessToken\n    {\n        if (!$oAuth2Token) {\n            return null;\n        }\n\n        return $this->entityManager\n            ->getRepository(AccessToken::class)\n            ->findOneBy(['identifier' => $oAuth2Token->getAttribute('access_token_id')]);\n    }\n\n    public function serializePaginated(array $serializedItems, PagerfantaInterface $pagerfanta): array\n    {\n        return [\n            'items' => $serializedItems,\n            'pagination' => new PaginationSchema($pagerfanta),\n        ];\n    }\n\n    public function serializeCursorPaginated(array $serializedItems, CursorPaginationInterface $pagerfanta): array\n    {\n        return [\n            'items' => $serializedItems,\n            'pagination' => new CursorPaginationSchema($pagerfanta),\n        ];\n    }\n\n    public function serializeContentInterface(ContentInterface $content, bool $forceVisible = false): mixed\n    {\n        $toReturn = null;\n        if ($content instanceof Entry) {\n            $cross = $this->entryRepository->findCross($content);\n            $crossDtos = array_map(fn ($entry) => $this->entryFactory->createResponseDto($entry, []), $cross);\n            $dto = $this->entryFactory->createResponseDto($content, $this->tagLinkRepository->getTagsOfContent($content), $crossDtos);\n            $dto->visibility = $forceVisible ? VisibilityInterface::VISIBILITY_VISIBLE : $dto->visibility;\n            $toReturn = $dto->jsonSerialize();\n            $toReturn['itemType'] = 'entry';\n        } elseif ($content instanceof EntryComment) {\n            $dto = $this->entryCommentFactory->createResponseDto($content, $this->tagLinkRepository->getTagsOfContent($content));\n            $dto->visibility = $forceVisible ? VisibilityInterface::VISIBILITY_VISIBLE : $dto->visibility;\n            $toReturn = $dto->jsonSerialize();\n            $toReturn['itemType'] = 'entry_comment';\n        } elseif ($content instanceof Post) {\n            $dto = $this->postFactory->createResponseDto($content, $this->tagLinkRepository->getTagsOfContent($content));\n            $dto->visibility = $forceVisible ? VisibilityInterface::VISIBILITY_VISIBLE : $dto->visibility;\n            $toReturn = $dto->jsonSerialize();\n            $toReturn['itemType'] = 'post';\n        } elseif ($content instanceof PostComment) {\n            $dto = $this->postCommentFactory->createResponseDto($content, $this->tagLinkRepository->getTagsOfContent($content));\n            $dto->visibility = $forceVisible ? VisibilityInterface::VISIBILITY_VISIBLE : $dto->visibility;\n            $toReturn = $dto->jsonSerialize();\n            $toReturn['itemType'] = 'post_comment';\n        } else {\n            throw new \\LogicException('Invalid contentInterface classname \"'.$this->entityManager->getClassMetadata(\\get_class($content))->rootEntityName.'\"');\n        }\n\n        if ($forceVisible) {\n            $toReturn['visibility'] = $content->visibility;\n        }\n\n        return $toReturn;\n    }\n\n    /**\n     * Serialize a single log item to JSON.\n     */\n    protected function serializeLogItem(MagazineLog $log): array\n    {\n        /** @var ?ContentVisibilityInterface $subject */\n        $subject = $log->getSubject();\n        $response = $this->magazineFactory->createLogDto($log);\n        $response->setSubject(\n            $subject,\n            $this->entryFactory,\n            $this->entryCommentFactory,\n            $this->postFactory,\n            $this->postCommentFactory,\n            $this->tagLinkRepository,\n        );\n\n        return $response->jsonSerialize();\n    }\n\n    /**\n     * Serialize a single magazine to JSON.\n     *\n     * @param MagazineDto $dto The MagazineDto to serialize\n     *\n     * @return MagazineResponseDto An associative array representation of the entry's safe fields, to be used as JSON\n     */\n    protected function serializeMagazine(MagazineDto $dto): MagazineResponseDto\n    {\n        $response = $this->magazineFactory->createResponseDto($dto);\n\n        if ($user = $this->getUser()) {\n            $response->notificationStatus = $this->notificationSettingsRepository->findOneByTarget($user, $dto)?->getStatus() ?? ENotificationStatus::Default;\n        }\n\n        return $response;\n    }\n\n    /**\n     * Serialize a single user to JSON.\n     *\n     * @param UserDto $dto The UserDto to serialize\n     *\n     * @return UserResponseDto A JsonSerializable representation of the user\n     */\n    protected function serializeUser(UserDto $dto): UserResponseDto\n    {\n        $response = new UserResponseDto($dto);\n\n        if ($user = $this->getUser()) {\n            $response->notificationStatus = $this->notificationSettingsRepository->findOneByTarget($user, $dto)?->getStatus() ?? ENotificationStatus::Default;\n        }\n\n        return $response;\n    }\n\n    protected function serializeFilterList(UserFilterList $list): UserFilterListResponseDto\n    {\n        return UserFilterListResponseDto::fromList($list);\n    }\n\n    public static function constrainPerPage(mixed $value, int $min = self::MIN_PER_PAGE, int $max = self::MAX_PER_PAGE): int\n    {\n        return min(max(\\intval($value), $min), $max);\n    }\n\n    /**\n     * Alias for constrainPerPage with different defaults.\n     */\n    public static function constrainDepth(mixed $value, int $min = self::MIN_DEPTH, int $max = self::MAX_DEPTH): int\n    {\n        return self::constrainPerPage($value, $min, $max);\n    }\n\n    public function handleLanguageCriteria(Criteria $criteria): void\n    {\n        $usePreferred = filter_var($this->request->getCurrentRequest()->get('usePreferredLangs', false), FILTER_VALIDATE_BOOL);\n\n        if ($usePreferred && null === $this->getUser()) {\n            // Debating between AccessDenied and BadRequest exceptions for this\n            throw new AccessDeniedHttpException('You must be logged in to use your preferred languages');\n        }\n\n        $languages = $usePreferred ? $this->getUserOrThrow()->preferredLanguages : $this->request->getCurrentRequest()->get('lang');\n        if (null !== $languages) {\n            if (\\is_string($languages)) {\n                $languages = explode(',', $languages);\n            }\n\n            $criteria->languages = $languages;\n        }\n    }\n\n    /**\n     * @throws BadRequestHttpException|\\Exception\n     */\n    public function handleUploadedImage(): Image\n    {\n        $img = $this->handleUploadedImageOptional();\n        if (null === $img) {\n            throw new BadRequestHttpException('Uploaded file not found!');\n        }\n\n        return $img;\n    }\n\n    /**\n     * @throws BadRequestHttpException|\\Exception\n     */\n    public function handleUploadedImageOptional(): ?Image\n    {\n        try {\n            /**\n             * @var UploadedFile $uploaded\n             */\n            $uploaded = $this->request->getCurrentRequest()->files->get('uploadImage');\n\n            if (null === $uploaded) {\n                return null;\n            }\n\n            if (null === self::$constraint) {\n                self::$constraint = ImageConstraint::default();\n            }\n\n            if (self::$constraint->maxSize < $uploaded->getSize()) {\n                throw new BadRequestHttpException('File cannot exceed '.(string) self::$constraint->maxSize.' bytes');\n            }\n\n            if (false === array_search($uploaded->getMimeType(), self::$constraint->mimeTypes)) {\n                throw new BadRequestHttpException('Mimetype of \"'.$uploaded->getMimeType().'\" not allowed!');\n            }\n\n            $image = $this->imageRepository->findOrCreateFromUpload($uploaded);\n\n            if (null === $image) {\n                throw new BadRequestHttpException('Failed to create file');\n            }\n\n            $image->altText = $this->request->getCurrentRequest()->get('alt', null);\n        } catch (\\Exception $e) {\n            if (null !== $uploaded && file_exists($uploaded->getPathname())) {\n                unlink($uploaded->getPathname());\n            }\n            throw $e;\n        }\n\n        return $image;\n    }\n\n    protected function reportContent(ReportInterface $reportable): void\n    {\n        /** @var ReportRequestDto $dto */\n        $dto = $this->serializer->deserialize($this->request->getCurrentRequest()->getContent(), ReportRequestDto::class, 'json');\n\n        $errors = $this->validator->validate($dto);\n        if (0 < \\count($errors)) {\n            throw new BadRequestHttpException((string) $errors);\n        }\n\n        $reportDto = ReportDto::create($reportable, $dto->reason);\n\n        try {\n            $this->reportManager->report($reportDto, $this->getUserOrThrow());\n        } catch (SubjectHasBeenReportedException $e) {\n            // Do nothing\n        }\n    }\n\n    /**\n     * Serialize a single entry to JSON.\n     *\n     * @param Entry[]|null $crosspostedEntries\n     */\n    protected function serializeEntry(EntryDto|Entry $dto, array $tags, ?array $crosspostedEntries = null): EntryResponseDto\n    {\n        $crosspostedEntryDtos = null;\n        if (null !== $crosspostedEntries) {\n            $crosspostedEntryDtos = array_map(fn (Entry $item) => $this->entryFactory->createResponseDto($item, []), $crosspostedEntries);\n        }\n        $response = $this->entryFactory->createResponseDto($dto, $tags, $crosspostedEntryDtos);\n\n        if ($this->isGranted('ROLE_OAUTH2_ENTRY:VOTE')) {\n            $response->isFavourited = $dto instanceof EntryDto ? $dto->isFavourited : $dto->isFavored($this->getUserOrThrow());\n            $response->userVote = $dto instanceof EntryDto ? $dto->userVote : $dto->getUserChoice($this->getUserOrThrow());\n        }\n\n        if ($user = $this->getUser()) {\n            $response->canAuthUserModerate = $dto->getMagazine()->userIsModerator($user) || $user->isModerator() || $user->isAdmin();\n            $response->notificationStatus = $this->notificationSettingsRepository->findOneByTarget($user, $dto)?->getStatus() ?? ENotificationStatus::Default;\n        }\n\n        return $response;\n    }\n\n    /**\n     * Serialize a single entry comment to JSON.\n     */\n    protected function serializeEntryComment(EntryCommentDto $comment, array $tags): EntryCommentResponseDto\n    {\n        $response = $this->entryCommentFactory->createResponseDto($comment, $tags);\n\n        if ($this->isGranted('ROLE_OAUTH2_ENTRY_COMMENT:VOTE')) {\n            $response->isFavourited = $comment->isFavourited;\n            $response->userVote = $comment->userVote;\n        }\n\n        if ($user = $this->getUser()) {\n            $response->canAuthUserModerate = $comment->magazine->userIsModerator($user) || $user->isModerator() || $user->isAdmin();\n        }\n\n        return $response;\n    }\n\n    /**\n     * Serialize a single post to JSON.\n     */\n    protected function serializePost(Post|PostDto $dto, array $tags): PostResponseDto\n    {\n        if (null === $dto) {\n            return [];\n        }\n        $response = $this->postFactory->createResponseDto($dto, $tags);\n\n        if ($this->isGranted('ROLE_OAUTH2_POST:VOTE')) {\n            $response->isFavourited = $dto instanceof PostDto ? $dto->isFavourited : $dto->isFavored($this->getUserOrThrow());\n            $response->userVote = $dto instanceof PostDto ? $dto->userVote : $dto->getUserChoice($this->getUserOrThrow());\n        }\n\n        if ($user = $this->getUser()) {\n            $response->canAuthUserModerate = $dto->getMagazine()->userIsModerator($user) || $user->isModerator() || $user->isAdmin();\n            $response->notificationStatus = $this->notificationSettingsRepository->findOneByTarget($user, $dto)?->getStatus() ?? ENotificationStatus::Default;\n        }\n\n        return $response;\n    }\n\n    /**\n     * Serialize a single comment to JSON.\n     */\n    protected function serializePostComment(PostCommentDto $comment, array $tags): PostCommentResponseDto\n    {\n        $response = $this->postCommentFactory->createResponseDto($comment, $tags);\n\n        if ($this->isGranted('ROLE_OAUTH2_POST_COMMENT:VOTE')) {\n            $response->isFavourited = $comment instanceof PostCommentDto ? $comment->isFavourited : $comment->isFavored($this->getUserOrThrow());\n            $response->userVote = $comment instanceof PostCommentDto ? $comment->userVote : $comment->getUserChoice($this->getUserOrThrow());\n        }\n\n        if ($user = $this->getUser()) {\n            $response->canAuthUserModerate = $comment->getMagazine()->userIsModerator($user) || $user->isModerator() || $user->isAdmin();\n        }\n\n        return $response;\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Bookmark/BookmarkApiController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Bookmark;\n\nuse App\\Controller\\Api\\BaseApi;\nuse App\\DTO\\BookmarksDto;\nuse App\\Schema\\Errors\\NotFoundErrorSchema;\nuse App\\Schema\\Errors\\TooManyRequestsErrorSchema;\nuse App\\Schema\\Errors\\UnauthorizedErrorSchema;\nuse App\\Service\\BookmarkManager;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse Nelmio\\ApiDocBundle\\Attribute\\Security;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpKernel\\Exception\\NotFoundHttpException;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass BookmarkApiController extends BaseApi\n{\n    #[OA\\Response(\n        response: 200,\n        description: 'Add a bookmark for the subject in the default list',\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\\Schema(type: 'integer')),\n        ],\n        content: new Model(type: BookmarksDto::class)\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'The specified subject does not exist',\n        content: new OA\\JsonContent(ref: new Model(type: NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\\Schema(type: 'integer')),\n        ],\n        content: new OA\\JsonContent(ref: new Model(type: TooManyRequestsErrorSchema::class))\n    )]\n    #[OA\\Parameter(\n        name: 'subject_id',\n        description: 'The id of the subject to be added to the default list',\n        in: 'path',\n        schema: new OA\\Schema(type: 'integer')\n    )]\n    #[OA\\Parameter(\n        name: 'subject_type',\n        description: 'the type of the subject',\n        in: 'path',\n        schema: new OA\\Schema(type: 'string', enum: ['entry', 'entry_comment', 'post', 'post_comment'])\n    )]\n    #[OA\\Tag(name: 'bookmark')]\n    #[Security(name: 'oauth2', scopes: ['bookmark:add'])]\n    #[IsGranted('ROLE_OAUTH2_BOOKMARK:ADD')]\n    public function subjectBookmarkStandard(int $subject_id, string $subject_type, RateLimiterFactoryInterface $apiUpdateLimiter): JsonResponse\n    {\n        $user = $this->getUserOrThrow();\n        $headers = $this->rateLimit($apiUpdateLimiter);\n        $subjectClass = BookmarkManager::GetClassFromSubjectType($subject_type);\n        $subject = $this->entityManager->getRepository($subjectClass)->find($subject_id);\n        if (null === $subject) {\n            throw new NotFoundHttpException(code: 404, headers: $headers);\n        }\n        $this->bookmarkManager->addBookmarkToDefaultList($user, $subject);\n\n        $dto = new BookmarksDto();\n        $dto->bookmarks = $this->bookmarkListRepository->getBookmarksOfContentInterface($subject);\n\n        return new JsonResponse($dto, status: 200, headers: $headers);\n    }\n\n    #[OA\\Response(\n        response: 200,\n        description: 'Add a bookmark for the subject in the specified list',\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\\Schema(type: 'integer')),\n        ],\n        content: new Model(type: BookmarksDto::class)\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'The specified subject or list does not exist',\n        content: new OA\\JsonContent(ref: new Model(type: NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\\Schema(type: 'integer')),\n        ],\n        content: new OA\\JsonContent(ref: new Model(type: TooManyRequestsErrorSchema::class))\n    )]\n    #[OA\\Parameter(\n        name: 'subject_id',\n        description: 'The id of the subject to be added to the specified list',\n        in: 'path',\n        schema: new OA\\Schema(type: 'integer')\n    )]\n    #[OA\\Parameter(\n        name: 'subject_type',\n        description: 'the type of the subject',\n        in: 'path',\n        schema: new OA\\Schema(type: 'string', enum: ['entry', 'entry_comment', 'post', 'post_comment'])\n    )]\n    #[OA\\Tag(name: 'bookmark')]\n    #[Security(name: 'oauth2', scopes: ['bookmark:add'])]\n    #[IsGranted('ROLE_OAUTH2_BOOKMARK:ADD')]\n    public function subjectBookmarkToList(string $list_name, int $subject_id, string $subject_type, RateLimiterFactoryInterface $apiUpdateLimiter): JsonResponse\n    {\n        $user = $this->getUserOrThrow();\n        $headers = $this->rateLimit($apiUpdateLimiter);\n        $subjectClass = BookmarkManager::GetClassFromSubjectType($subject_type);\n        $subject = $this->entityManager->getRepository($subjectClass)->find($subject_id);\n        if (null === $subject) {\n            throw new NotFoundHttpException(code: 404, headers: $headers);\n        }\n        $list = $this->bookmarkListRepository->findOneByUserAndName($user, $list_name);\n        if (null === $list) {\n            throw new NotFoundHttpException(code: 404, headers: $headers);\n        }\n        $this->bookmarkManager->addBookmark($user, $list, $subject);\n\n        $dto = new BookmarksDto();\n        $dto->bookmarks = $this->bookmarkListRepository->getBookmarksOfContentInterface($subject);\n\n        return new JsonResponse($dto, status: 200, headers: $headers);\n    }\n\n    #[OA\\Response(\n        response: 200,\n        description: 'Remove bookmark for the subject from the specified list',\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\\Schema(type: 'integer')),\n        ],\n        content: new Model(type: BookmarksDto::class)\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'The specified subject or list does not exist',\n        content: new OA\\JsonContent(ref: new Model(type: NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\\Schema(type: 'integer')),\n        ],\n        content: new OA\\JsonContent(ref: new Model(type: TooManyRequestsErrorSchema::class))\n    )]\n    #[OA\\Parameter(\n        name: 'subject_id',\n        description: 'The id of the subject to be removed',\n        in: 'path',\n        schema: new OA\\Schema(type: 'integer')\n    )]\n    #[OA\\Parameter(\n        name: 'subject_type',\n        description: 'the type of the subject',\n        in: 'path',\n        schema: new OA\\Schema(type: 'string', enum: ['entry', 'entry_comment', 'post', 'post_comment'])\n    )]\n    #[OA\\Tag(name: 'bookmark')]\n    #[Security(name: 'oauth2', scopes: ['bookmark:remove'])]\n    #[IsGranted('ROLE_OAUTH2_BOOKMARK:REMOVE')]\n    public function subjectRemoveBookmarkFromList(string $list_name, int $subject_id, string $subject_type, RateLimiterFactoryInterface $apiUpdateLimiter): JsonResponse\n    {\n        $user = $this->getUserOrThrow();\n        $headers = $this->rateLimit($apiUpdateLimiter);\n        $subjectClass = BookmarkManager::GetClassFromSubjectType($subject_type);\n        $subject = $this->entityManager->getRepository($subjectClass)->find($subject_id);\n        if (null === $subject) {\n            throw new NotFoundHttpException(code: 404, headers: $headers);\n        }\n        $list = $this->bookmarkListRepository->findOneByUserAndName($user, $list_name);\n        if (null === $list) {\n            throw new NotFoundHttpException(code: 404, headers: $headers);\n        }\n        $this->bookmarkRepository->removeBookmarkFromList($user, $list, $subject);\n\n        $dto = new BookmarksDto();\n        $dto->bookmarks = $this->bookmarkListRepository->getBookmarksOfContentInterface($subject);\n\n        return new JsonResponse($dto, status: 200, headers: $headers);\n    }\n\n    #[OA\\Response(\n        response: 200,\n        description: 'Remove all bookmarks for the subject',\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\\Schema(type: 'integer')),\n        ],\n        content: new Model(type: BookmarksDto::class)\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'The specified subject does not exist',\n        content: new OA\\JsonContent(ref: new Model(type: NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\\Schema(type: 'integer')),\n        ],\n        content: new OA\\JsonContent(ref: new Model(type: TooManyRequestsErrorSchema::class))\n    )]\n    #[OA\\Parameter(\n        name: 'subject_id',\n        description: 'The id of the subject to be removed',\n        in: 'path',\n        schema: new OA\\Schema(type: 'integer')\n    )]\n    #[OA\\Parameter(\n        name: 'subject_type',\n        description: 'the type of the subject',\n        in: 'path',\n        schema: new OA\\Schema(type: 'string', enum: ['entry', 'entry_comment', 'post', 'post_comment'])\n    )]\n    #[OA\\Tag(name: 'bookmark')]\n    #[Security(name: 'oauth2', scopes: ['bookmark:remove'])]\n    #[IsGranted('ROLE_OAUTH2_BOOKMARK:REMOVE')]\n    public function subjectRemoveBookmarks(int $subject_id, string $subject_type, RateLimiterFactoryInterface $apiUpdateLimiter): JsonResponse\n    {\n        $user = $this->getUserOrThrow();\n        $headers = $this->rateLimit($apiUpdateLimiter);\n        $subjectClass = BookmarkManager::GetClassFromSubjectType($subject_type);\n        $subject = $this->entityManager->getRepository($subjectClass)->find($subject_id);\n        if (null === $subject) {\n            throw new NotFoundHttpException(code: 404, headers: $headers);\n        }\n        $this->bookmarkRepository->removeAllBookmarksForContent($user, $subject);\n\n        $dto = new BookmarksDto();\n        $dto->bookmarks = $this->bookmarkListRepository->getBookmarksOfContentInterface($subject);\n\n        return new JsonResponse($dto, status: 200, headers: $headers);\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Bookmark/BookmarkListApiController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Bookmark;\n\nuse App\\Controller\\Api\\BaseApi;\nuse App\\DTO\\BookmarkListDto;\nuse App\\Entity\\BookmarkList;\nuse App\\Entity\\Contracts\\ContentInterface;\nuse App\\Entity\\Entry;\nuse App\\PageView\\EntryPageView;\nuse App\\Repository\\Criteria;\nuse App\\Repository\\EntryRepository;\nuse App\\Schema\\ContentSchema;\nuse App\\Schema\\Errors\\BadRequestErrorSchema;\nuse App\\Schema\\Errors\\NotFoundErrorSchema;\nuse App\\Schema\\Errors\\TooManyRequestsErrorSchema;\nuse App\\Schema\\Errors\\UnauthorizedErrorSchema;\nuse App\\Schema\\PaginationSchema;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse Nelmio\\ApiDocBundle\\Attribute\\Security;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Bundle\\SecurityBundle\\Security as SymfonySecurity;\nuse Symfony\\Component\\HttpFoundation\\Exception\\BadRequestException;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpKernel\\Attribute\\MapQueryParameter;\nuse Symfony\\Component\\HttpKernel\\Attribute\\MapRequestPayload;\nuse Symfony\\Component\\HttpKernel\\Exception\\NotFoundHttpException;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass BookmarkListApiController extends BaseApi\n{\n    #[OA\\Response(\n        response: 200,\n        description: 'Returns the content of a bookmark list',\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\\Schema(type: 'integer')),\n        ],\n        content: new OA\\JsonContent(\n            properties: [\n                new OA\\Property(\n                    property: 'items',\n                    type: 'array',\n                    items: new OA\\Items(ref: new Model(type: ContentSchema::class))\n                ),\n                new OA\\Property(\n                    property: 'pagination',\n                    ref: new Model(type: PaginationSchema::class)\n                ),\n            ],\n            type: 'object'\n        )\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'The requested list does not exist',\n        content: new OA\\JsonContent(ref: new Model(type: NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\\Schema(type: 'integer')),\n        ],\n        content: new OA\\JsonContent(ref: new Model(type: TooManyRequestsErrorSchema::class))\n    )]\n    #[OA\\Parameter(\n        name: 'list',\n        description: 'The list from which to retrieve the bookmarks. If not set the default list will be used',\n        in: 'query',\n        schema: new OA\\Schema(\n            type: 'string',\n            default: null\n        )\n    )]\n    #[OA\\Parameter(\n        name: 'sort',\n        description: 'The sorting method to use during entry fetch',\n        in: 'query',\n        schema: new OA\\Schema(\n            default: Criteria::SORT_DEFAULT,\n            enum: Criteria::SORT_OPTIONS\n        )\n    )]\n    #[OA\\Parameter(\n        name: 'time',\n        description: 'The maximum age of retrieved entries',\n        in: 'query',\n        schema: new OA\\Schema(\n            default: Criteria::TIME_ALL,\n            enum: Criteria::TIME_ROUTES_EN\n        )\n    )]\n    #[OA\\Parameter(\n        name: 'federation',\n        description: 'Whether to include federated posts',\n        in: 'query',\n        schema: new OA\\Schema(\n            default: Criteria::AP_ALL,\n            enum: [Criteria::AP_ALL, Criteria::AP_LOCAL]\n        )\n    )]\n    #[OA\\Parameter(\n        name: 'type',\n        description: 'The type of entries to fetch. If set only entries will be returned',\n        in: 'query',\n        schema: new OA\\Schema(\n            default: 'all',\n            enum: [...Entry::ENTRY_TYPE_OPTIONS, 'all']\n        )\n    )]\n    #[OA\\Parameter(\n        name: 'p',\n        description: 'Page of entries to retrieve',\n        in: 'query',\n        schema: new OA\\Schema(type: 'integer', default: 1, minimum: 1)\n    )]\n    #[OA\\Parameter(\n        name: 'perPage',\n        description: 'Number of entries to retrieve per page',\n        in: 'query',\n        schema: new OA\\Schema(type: 'integer', default: EntryRepository::PER_PAGE, maximum: self::MAX_PER_PAGE, minimum: self::MIN_PER_PAGE)\n    )]\n    #[OA\\Tag(name: 'bookmark_list')]\n    #[Security(name: 'oauth2', scopes: ['bookmark_list:read'])]\n    #[IsGranted('ROLE_OAUTH2_BOOKMARK_LIST:READ')]\n    public function front(\n        #[MapQueryParameter] ?string $list,\n        #[MapQueryParameter] ?string $sort,\n        #[MapQueryParameter] ?string $time,\n        #[MapQueryParameter] ?string $federation,\n        #[MapQueryParameter] ?string $type,\n        #[MapQueryParameter] ?int $p,\n        #[MapQueryParameter] ?int $perPage,\n        RateLimiterFactoryInterface $apiReadLimiter,\n        SymfonySecurity $security,\n    ): JsonResponse {\n        $user = $this->getUserOrThrow();\n        $headers = $this->rateLimit($apiReadLimiter);\n        $criteria = new EntryPageView($p ?? 1, $security);\n        $criteria->setTime($criteria->resolveTime($time ?? Criteria::TIME_ALL));\n        $criteria->setType($criteria->resolveType($type ?? 'all'));\n        $criteria->showSortOption($criteria->resolveSort($sort ?? Criteria::SORT_NEW));\n        $criteria->setFederation($federation ?? Criteria::AP_ALL);\n\n        if (null !== $list) {\n            $bookmarkList = $this->bookmarkListRepository->findOneBy(['name' => $list, 'user' => $user]);\n            if (null === $bookmarkList) {\n                return new JsonResponse(status: 404, headers: $headers);\n            }\n        } else {\n            $bookmarkList = $this->bookmarkListRepository->findOneByUserDefault($user);\n        }\n        $pagerfanta = $this->bookmarkRepository->findPopulatedByList($bookmarkList, $criteria, $perPage);\n        $objects = $pagerfanta->getCurrentPageResults();\n        $items = array_map(fn (ContentInterface $item) => $this->serializeContentInterface($item), $objects);\n        $result = $this->serializePaginated($items, $pagerfanta);\n\n        return new JsonResponse($result, status: 200, headers: $headers);\n    }\n\n    #[OA\\Response(\n        response: 200,\n        description: 'Returns all bookmark lists from the user',\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\\Schema(type: 'integer')),\n        ],\n        content: new OA\\JsonContent(\n            properties: [\n                new OA\\Property(\n                    property: 'items',\n                    type: 'array',\n                    items: new OA\\Items(ref: new Model(type: BookmarkListDto::class))\n                ),\n            ],\n            type: 'object'\n        )\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\\Schema(type: 'integer')),\n        ],\n        content: new OA\\JsonContent(ref: new Model(type: TooManyRequestsErrorSchema::class))\n    )]\n    #[OA\\Tag(name: 'bookmark_list')]\n    #[Security(name: 'oauth2', scopes: ['bookmark_list:read'])]\n    #[IsGranted('ROLE_OAUTH2_BOOKMARK_LIST:READ')]\n    public function list(RateLimiterFactoryInterface $apiReadLimiter): JsonResponse\n    {\n        $user = $this->getUserOrThrow();\n        $headers = $this->rateLimit($apiReadLimiter);\n        $items = array_map(fn (BookmarkList $list) => BookmarkListDto::fromList($list), $this->bookmarkListRepository->findByUser($user));\n        $response = [\n            'items' => $items,\n        ];\n\n        return new JsonResponse($response, status: 200, headers: $headers);\n    }\n\n    #[OA\\Response(\n        response: 200,\n        description: 'Sets the provided list as the default',\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\\Schema(type: 'integer')),\n        ],\n        content: new Model(type: BookmarkListDto::class),\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'The requested list does not exist',\n        content: new OA\\JsonContent(ref: new Model(type: NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\\Schema(type: 'integer')),\n        ],\n        content: new OA\\JsonContent(ref: new Model(type: TooManyRequestsErrorSchema::class))\n    )]\n    #[OA\\Parameter(\n        name: 'list_name',\n        description: 'The name of the list to be made the default',\n        in: 'path',\n        schema: new OA\\Schema(type: 'string')\n    )]\n    #[OA\\Tag(name: 'bookmark_list')]\n    #[Security(name: 'oauth2', scopes: ['bookmark_list:edit'])]\n    #[IsGranted('ROLE_OAUTH2_BOOKMARK_LIST:EDIT')]\n    public function makeDefault(string $list_name, RateLimiterFactoryInterface $apiUpdateLimiter): JsonResponse\n    {\n        $user = $this->getUserOrThrow();\n        $headers = $this->rateLimit($apiUpdateLimiter);\n        $list = $this->bookmarkListRepository->findOneByUserAndName($user, $list_name);\n        if (null === $list) {\n            throw new NotFoundHttpException(headers: $headers);\n        }\n        $this->bookmarkListRepository->makeListDefault($user, $list);\n\n        return new JsonResponse(BookmarkListDto::fromList($list), status: 200, headers: $headers);\n    }\n\n    #[OA\\Response(\n        response: 200,\n        description: 'Edits the supplied list',\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\\Schema(type: 'integer')),\n        ],\n        content: new Model(type: BookmarkListDto::class),\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'The requested list does not exist',\n        content: new OA\\JsonContent(ref: new Model(type: NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\\Schema(type: 'integer')),\n        ],\n        content: new OA\\JsonContent(ref: new Model(type: TooManyRequestsErrorSchema::class))\n    )]\n    #[OA\\Parameter(\n        name: 'list_name',\n        description: 'The name of the list to be edited',\n        in: 'path',\n        schema: new OA\\Schema(type: 'string')\n    )]\n    #[OA\\RequestBody(content: new Model(type: BookmarkListDto::class))]\n    #[OA\\Tag(name: 'bookmark_list')]\n    #[Security(name: 'oauth2', scopes: ['bookmark_list:edit'])]\n    #[IsGranted('ROLE_OAUTH2_BOOKMARK_LIST:EDIT')]\n    public function editList(string $list_name, #[MapRequestPayload] BookmarkListDto $dto, RateLimiterFactoryInterface $apiUpdateLimiter): JsonResponse\n    {\n        $user = $this->getUserOrThrow();\n        $headers = $this->rateLimit($apiUpdateLimiter);\n        $list = $this->bookmarkListRepository->findOneByUserAndName($user, $list_name);\n        if (null === $list) {\n            throw new NotFoundHttpException(headers: $headers);\n        }\n        $this->bookmarkListRepository->editList($user, $list, $dto);\n\n        return new JsonResponse(BookmarkListDto::fromList($list), status: 200, headers: $headers);\n    }\n\n    #[OA\\Response(\n        response: 200,\n        description: 'Creates a list with the supplied name',\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\\Schema(type: 'integer')),\n        ],\n        content: new Model(type: BookmarkListDto::class),\n    )]\n    #[OA\\Response(\n        response: 400,\n        description: 'The requested list already exists',\n        content: new OA\\JsonContent(ref: new Model(type: BadRequestErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\\Schema(type: 'integer')),\n        ],\n        content: new OA\\JsonContent(ref: new Model(type: TooManyRequestsErrorSchema::class))\n    )]\n    #[OA\\Parameter(\n        name: 'list_name',\n        description: 'The name of the list to be created',\n        in: 'path',\n        schema: new OA\\Schema(type: 'string')\n    )]\n    #[OA\\Tag(name: 'bookmark_list')]\n    #[Security(name: 'oauth2', scopes: ['bookmark_list:edit'])]\n    #[IsGranted('ROLE_OAUTH2_BOOKMARK_LIST:EDIT')]\n    public function createList(string $list_name, RateLimiterFactoryInterface $apiUpdateLimiter): JsonResponse\n    {\n        $user = $this->getUserOrThrow();\n        $headers = $this->rateLimit($apiUpdateLimiter);\n        $list = $this->bookmarkListRepository->findOneByUserAndName($user, $list_name);\n        if (null !== $list) {\n            throw new BadRequestException();\n        }\n        $list = $this->bookmarkManager->createList($user, $list_name);\n\n        return new JsonResponse(BookmarkListDto::fromList($list), status: 200, headers: $headers);\n    }\n\n    #[OA\\Response(\n        response: 200,\n        description: 'Deletes the provided list',\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\\Schema(type: 'integer')),\n        ],\n        content: null\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'The requested list does not exist',\n        content: new OA\\JsonContent(ref: new Model(type: NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\\Schema(type: 'integer')),\n        ],\n        content: new OA\\JsonContent(ref: new Model(type: TooManyRequestsErrorSchema::class))\n    )]\n    #[OA\\Parameter(\n        name: 'list_name',\n        description: 'The name of the list to be deleted',\n        in: 'path',\n        schema: new OA\\Schema(type: 'string')\n    )]\n    #[OA\\Tag(name: 'bookmark_list')]\n    #[Security(name: 'oauth2', scopes: ['bookmark_list:delete'])]\n    #[IsGranted('ROLE_OAUTH2_BOOKMARK_LIST:DELETE')]\n    public function deleteList(string $list_name, RateLimiterFactoryInterface $apiDeleteLimiter): JsonResponse\n    {\n        $user = $this->getUserOrThrow();\n        $headers = $this->rateLimit($apiDeleteLimiter);\n        $list = $this->bookmarkListRepository->findOneByUserAndName($user, $list_name);\n        if (null === $list) {\n            throw new NotFoundHttpException(headers: $headers);\n        }\n        $this->bookmarkListRepository->deleteList($list);\n\n        return new JsonResponse(status: 200, headers: $headers);\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Combined/CombinedRetrieveApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Combined;\n\nuse App\\Controller\\Api\\BaseApi;\nuse App\\Controller\\Traits\\PrivateContentTrait;\nuse App\\DTO\\ContentResponseDto;\nuse App\\Entity\\Entry;\nuse App\\Entity\\EntryComment;\nuse App\\Entity\\Post;\nuse App\\Entity\\PostComment;\nuse App\\Entity\\User;\nuse App\\PageView\\ContentPageView;\nuse App\\Pagination\\Cursor\\CursorPaginationInterface;\nuse App\\Repository\\ContentRepository;\nuse App\\Repository\\Criteria;\nuse App\\Schema\\CursorPaginationSchema;\nuse App\\Schema\\Errors\\TooManyRequestsErrorSchema;\nuse App\\Schema\\Errors\\UnauthorizedErrorSchema;\nuse App\\Schema\\PaginationSchema;\nuse App\\Utils\\SqlHelpers;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse OpenApi\\Attributes as OA;\nuse Pagerfanta\\PagerfantaInterface;\nuse Symfony\\Bundle\\SecurityBundle\\Security;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpKernel\\Attribute\\MapQueryParameter;\nuse Symfony\\Component\\HttpKernel\\Exception\\BadRequestHttpException;\nuse Symfony\\Component\\HttpKernel\\Exception\\HttpException;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass CombinedRetrieveApi extends BaseApi\n{\n    use PrivateContentTrait;\n\n    #[OA\\Response(\n        response: 200,\n        description: 'A paginated list of combined entries and posts filtered by the query parameters',\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\\Schema(type: 'integer')),\n        ],\n        content: new OA\\JsonContent(\n            properties: [\n                new OA\\Property(\n                    property: 'items',\n                    type: 'array',\n                    items: new OA\\Items(ref: new Model(type: ContentResponseDto::class))\n                ),\n                new OA\\Property(\n                    property: 'pagination',\n                    ref: new Model(type: PaginationSchema::class)\n                ),\n            ],\n            type: 'object'\n        )\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\\Schema(type: 'integer')),\n        ],\n        content: new OA\\JsonContent(ref: new Model(type: TooManyRequestsErrorSchema::class))\n    )]\n    #[OA\\Parameter(\n        name: 'p',\n        description: 'Page of content to retrieve',\n        in: 'query',\n        schema: new OA\\Schema(type: 'integer', default: 1, minimum: 1)\n    )]\n    #[OA\\Parameter(\n        name: 'perPage',\n        description: 'Number of content items to retrieve per page',\n        in: 'query',\n        schema: new OA\\Schema(type: 'integer', default: ContentRepository::PER_PAGE, maximum: self::MAX_PER_PAGE, minimum: self::MIN_PER_PAGE)\n    )]\n    #[OA\\Parameter(\n        name: 'sort',\n        description: 'Sort method to use when retrieving content',\n        in: 'query',\n        schema: new OA\\Schema(type: 'string', default: Criteria::SORT_HOT, enum: Criteria::SORT_OPTIONS)\n    )]\n    #[OA\\Parameter(\n        name: 'time',\n        description: 'Max age of retrieved content',\n        in: 'query',\n        schema: new OA\\Schema(type: 'string', default: Criteria::TIME_ALL, enum: Criteria::TIME_ROUTES_EN)\n    )]\n    #[OA\\Parameter(\n        name: 'lang[]',\n        description: 'Language(s) of content to return',\n        in: 'query',\n        schema: new OA\\Schema(\n            type: 'array',\n            items: new OA\\Items(type: 'string', default: null, maxLength: 3, minLength: 2)\n        ),\n        explode: true,\n        allowReserved: true\n    )]\n    #[OA\\Parameter(\n        name: 'usePreferredLangs',\n        description: 'Filter by a user\\'s preferred languages? (Requires authentication and takes precedence over lang[])',\n        in: 'query',\n        schema: new OA\\Schema(type: 'boolean', default: false),\n    )]\n    #[OA\\Parameter(\n        name: 'federation',\n        description: 'What type of federated entries to retrieve',\n        in: 'query',\n        schema: new OA\\Schema(type: 'string', default: Criteria::AP_ALL, enum: Criteria::AP_OPTIONS)\n    )]\n    #[OA\\Parameter(\n        name: 'includeBoosts',\n        description: 'if true then boosted content from followed users are included',\n        in: 'query',\n        schema: new OA\\Schema(type: 'boolean', default: false)\n    )]\n    #[OA\\Tag(name: 'combined')]\n    public function collection(\n        RateLimiterFactoryInterface $apiReadLimiter,\n        RateLimiterFactoryInterface $anonymousApiReadLimiter,\n        Security $security,\n        ContentRepository $contentRepository,\n        SqlHelpers $sqlHelpers,\n        #[MapQueryParameter] ?int $p,\n        #[MapQueryParameter] ?int $perPage,\n        #[MapQueryParameter] ?string $sort,\n        #[MapQueryParameter] ?string $time,\n        #[MapQueryParameter] ?string $federation,\n        #[MapQueryParameter] ?bool $includeBoosts,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter);\n        $criteria = $this->getCriteria($p, $security, $sort, $time, $federation, $includeBoosts, $perPage, $sqlHelpers, null);\n\n        $content = $contentRepository->findByCriteria($criteria);\n\n        return $this->serializeContent($content, $headers);\n    }\n\n    #[OA\\Response(\n        response: 200,\n        description: 'A paginated list of combined entries and posts from subscribed magazines and users filtered by the query parameters',\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\\Schema(type: 'integer')),\n        ],\n        content: new OA\\JsonContent(\n            properties: [\n                new OA\\Property(\n                    property: 'items',\n                    type: 'array',\n                    items: new OA\\Items(ref: new Model(type: ContentResponseDto::class))\n                ),\n                new OA\\Property(\n                    property: 'pagination',\n                    ref: new Model(type: PaginationSchema::class)\n                ),\n            ],\n            type: 'object'\n        )\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\\Schema(type: 'integer')),\n        ],\n        content: new OA\\JsonContent(ref: new Model(type: TooManyRequestsErrorSchema::class))\n    )]\n    #[OA\\Parameter(\n        name: 'collectionType',\n        description: 'the type of collection to get',\n        in: 'path',\n        schema: new OA\\Schema(type: 'string', enum: ['subscribed', 'moderated', 'favourited'])\n    )]\n    #[OA\\Parameter(\n        name: 'p',\n        description: 'Page of content to retrieve',\n        in: 'query',\n        schema: new OA\\Schema(type: 'integer', default: 1, minimum: 1)\n    )]\n    #[OA\\Parameter(\n        name: 'perPage',\n        description: 'Number of content items to retrieve per page',\n        in: 'query',\n        schema: new OA\\Schema(type: 'integer', default: ContentRepository::PER_PAGE, maximum: self::MAX_PER_PAGE, minimum: self::MIN_PER_PAGE)\n    )]\n    #[OA\\Parameter(\n        name: 'sort',\n        description: 'Sort method to use when retrieving content',\n        in: 'query',\n        schema: new OA\\Schema(type: 'string', default: Criteria::SORT_HOT, enum: Criteria::SORT_OPTIONS)\n    )]\n    #[OA\\Parameter(\n        name: 'time',\n        description: 'Max age of retrieved content',\n        in: 'query',\n        schema: new OA\\Schema(type: 'string', default: Criteria::TIME_ALL, enum: Criteria::TIME_ROUTES_EN)\n    )]\n    #[OA\\Parameter(\n        name: 'lang[]',\n        description: 'Language(s) of content to return',\n        in: 'query',\n        schema: new OA\\Schema(\n            type: 'array',\n            items: new OA\\Items(type: 'string', default: null, maxLength: 3, minLength: 2)\n        ),\n        explode: true,\n        allowReserved: true\n    )]\n    #[OA\\Parameter(\n        name: 'usePreferredLangs',\n        description: 'Filter by a user\\'s preferred languages? (Requires authentication and takes precedence over lang[])',\n        in: 'query',\n        schema: new OA\\Schema(type: 'boolean', default: false),\n    )]\n    #[OA\\Parameter(\n        name: 'federation',\n        description: 'What type of federated entries to retrieve',\n        in: 'query',\n        schema: new OA\\Schema(type: 'string', default: Criteria::AP_ALL, enum: Criteria::AP_OPTIONS)\n    )]\n    #[OA\\Parameter(\n        name: 'includeBoosts',\n        description: 'if true then boosted content from followed users are included',\n        in: 'query',\n        schema: new OA\\Schema(type: 'boolean', default: false)\n    )]\n    #[OA\\Tag(name: 'combined')]\n    #[\\Nelmio\\ApiDocBundle\\Attribute\\Security(name: 'oauth2', scopes: ['read'])]\n    #[IsGranted('ROLE_OAUTH2_READ')]\n    public function userCollection(\n        RateLimiterFactoryInterface $apiReadLimiter,\n        RateLimiterFactoryInterface $anonymousApiReadLimiter,\n        Security $security,\n        ContentRepository $contentRepository,\n        SqlHelpers $sqlHelpers,\n        #[MapQueryParameter] ?int $p,\n        #[MapQueryParameter] ?int $perPage,\n        #[MapQueryParameter] ?string $sort,\n        #[MapQueryParameter] ?string $time,\n        #[MapQueryParameter] ?string $federation,\n        #[MapQueryParameter] ?bool $includeBoosts,\n        string $collectionType,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter);\n        $criteria = $this->getCriteria($p, $security, $sort, $time, $federation, $includeBoosts, $perPage, $sqlHelpers, $collectionType);\n\n        $content = $contentRepository->findByCriteria($criteria);\n\n        return $this->serializeContent($content, $headers);\n    }\n\n    #[OA\\Response(\n        response: 200,\n        description: 'A cursor paginated list of combined entries and posts filtered by the query parameters',\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\\Schema(type: 'integer')),\n        ],\n        content: new OA\\JsonContent(\n            properties: [\n                new OA\\Property(\n                    property: 'items',\n                    type: 'array',\n                    items: new OA\\Items(ref: new Model(type: ContentResponseDto::class))\n                ),\n                new OA\\Property(\n                    property: 'pagination',\n                    ref: new Model(type: CursorPaginationSchema::class)\n                ),\n            ],\n            type: 'object'\n        )\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\\Schema(type: 'integer')),\n        ],\n        content: new OA\\JsonContent(ref: new Model(type: TooManyRequestsErrorSchema::class))\n    )]\n    #[OA\\Parameter(\n        name: 'cursor',\n        description: 'The cursor',\n        in: 'query',\n        schema: new OA\\Schema(type: 'string', default: null)\n    )]\n    #[OA\\Parameter(\n        name: 'cursor2',\n        description: 'The secondary cursor, always a datetime',\n        in: 'query',\n        schema: new OA\\Schema(type: 'string', default: null)\n    )]\n    #[OA\\Parameter(\n        name: 'perPage',\n        description: 'Number of content items to retrieve per page',\n        in: 'query',\n        schema: new OA\\Schema(type: 'integer', default: ContentRepository::PER_PAGE, maximum: self::MAX_PER_PAGE, minimum: self::MIN_PER_PAGE)\n    )]\n    #[OA\\Parameter(\n        name: 'sort',\n        description: 'Sort method to use when retrieving content',\n        in: 'query',\n        schema: new OA\\Schema(type: 'string', default: Criteria::SORT_HOT, enum: Criteria::SORT_OPTIONS)\n    )]\n    #[OA\\Parameter(\n        name: 'time',\n        description: 'Max age of retrieved content',\n        in: 'query',\n        schema: new OA\\Schema(type: 'string', default: Criteria::TIME_ALL, enum: Criteria::TIME_ROUTES_EN)\n    )]\n    #[OA\\Parameter(\n        name: 'lang[]',\n        description: 'Language(s) of content to return',\n        in: 'query',\n        schema: new OA\\Schema(\n            type: 'array',\n            items: new OA\\Items(type: 'string', default: null, maxLength: 3, minLength: 2)\n        ),\n        explode: true,\n        allowReserved: true\n    )]\n    #[OA\\Parameter(\n        name: 'usePreferredLangs',\n        description: 'Filter by a user\\'s preferred languages? (Requires authentication and takes precedence over lang[])',\n        in: 'query',\n        schema: new OA\\Schema(type: 'boolean', default: false),\n    )]\n    #[OA\\Parameter(\n        name: 'federation',\n        description: 'What type of federated entries to retrieve',\n        in: 'query',\n        schema: new OA\\Schema(type: 'string', default: Criteria::AP_ALL, enum: Criteria::AP_OPTIONS)\n    )]\n    #[OA\\Parameter(\n        name: 'includeBoosts',\n        description: 'if true then boosted content from followed users are included',\n        in: 'query',\n        schema: new OA\\Schema(type: 'boolean', default: false)\n    )]\n    #[OA\\Tag(name: 'combined')]\n    public function cursorCollection(\n        RateLimiterFactoryInterface $apiReadLimiter,\n        RateLimiterFactoryInterface $anonymousApiReadLimiter,\n        Security $security,\n        ContentRepository $contentRepository,\n        #[MapQueryParameter] ?string $cursor,\n        #[MapQueryParameter] ?string $cursor2,\n        #[MapQueryParameter] ?int $perPage,\n        #[MapQueryParameter] ?string $sort,\n        #[MapQueryParameter] ?string $time,\n        #[MapQueryParameter] ?string $federation,\n        #[MapQueryParameter] ?bool $includeBoosts,\n        SqlHelpers $sqlHelpers,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter);\n        $criteria = $this->getCriteria(1, $security, $sort, $time, $federation, $includeBoosts, $perPage, $sqlHelpers, null);\n        $currentCursor = $this->getCursor($contentRepository, $criteria->sortOption, $cursor);\n        $currentCursor2 = $cursor2 ? $this->getCursor($contentRepository, Criteria::SORT_NEW, $cursor2) : null;\n\n        $content = $contentRepository->findByCriteriaCursored($criteria, $currentCursor, $currentCursor2);\n\n        return $this->serializeContentCursored($content, $headers);\n    }\n\n    #[OA\\Response(\n        response: 200,\n        description: 'A cursor paginated list of combined entries and posts from subscribed magazines and users filtered by the query parameters',\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\\Schema(type: 'integer')),\n        ],\n        content: new OA\\JsonContent(\n            properties: [\n                new OA\\Property(\n                    property: 'items',\n                    type: 'array',\n                    items: new OA\\Items(ref: new Model(type: ContentResponseDto::class))\n                ),\n                new OA\\Property(\n                    property: 'pagination',\n                    ref: new Model(type: CursorPaginationSchema::class)\n                ),\n            ],\n            type: 'object'\n        )\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\\Schema(type: 'integer')),\n        ],\n        content: new OA\\JsonContent(ref: new Model(type: TooManyRequestsErrorSchema::class))\n    )]\n    #[OA\\Parameter(\n        name: 'cursor',\n        description: 'The cursor',\n        in: 'query',\n        schema: new OA\\Schema(type: 'string', default: null)\n    )]\n    #[OA\\Parameter(\n        name: 'cursor2',\n        description: 'The secondary cursor, always a datetime',\n        in: 'query',\n        schema: new OA\\Schema(type: 'string', default: null)\n    )]\n    #[OA\\Parameter(\n        name: 'perPage',\n        description: 'Number of content items to retrieve per page',\n        in: 'query',\n        schema: new OA\\Schema(type: 'integer', default: ContentRepository::PER_PAGE, maximum: self::MAX_PER_PAGE, minimum: self::MIN_PER_PAGE)\n    )]\n    #[OA\\Parameter(\n        name: 'sort',\n        description: 'Sort method to use when retrieving content',\n        in: 'query',\n        schema: new OA\\Schema(type: 'string', default: Criteria::SORT_HOT, enum: Criteria::SORT_OPTIONS)\n    )]\n    #[OA\\Parameter(\n        name: 'time',\n        description: 'Max age of retrieved content',\n        in: 'query',\n        schema: new OA\\Schema(type: 'string', default: Criteria::TIME_ALL, enum: Criteria::TIME_ROUTES_EN)\n    )]\n    #[OA\\Parameter(\n        name: 'lang[]',\n        description: 'Language(s) of content to return',\n        in: 'query',\n        schema: new OA\\Schema(\n            type: 'array',\n            items: new OA\\Items(type: 'string', default: null, maxLength: 3, minLength: 2)\n        ),\n        explode: true,\n        allowReserved: true\n    )]\n    #[OA\\Parameter(\n        name: 'usePreferredLangs',\n        description: 'Filter by a user\\'s preferred languages? (Requires authentication and takes precedence over lang[])',\n        in: 'query',\n        schema: new OA\\Schema(type: 'boolean', default: false),\n    )]\n    #[OA\\Parameter(\n        name: 'federation',\n        description: 'What type of federated entries to retrieve',\n        in: 'query',\n        schema: new OA\\Schema(type: 'string', default: Criteria::AP_ALL, enum: Criteria::AP_OPTIONS)\n    )]\n    #[OA\\Parameter(\n        name: 'includeBoosts',\n        description: 'if true then boosted content from followed users are included',\n        in: 'query',\n        schema: new OA\\Schema(type: 'boolean', default: false)\n    )]\n    #[OA\\Tag(name: 'combined')]\n    #[\\Nelmio\\ApiDocBundle\\Attribute\\Security(name: 'oauth2', scopes: ['read'])]\n    #[IsGranted('ROLE_OAUTH2_READ')]\n    public function cursorUserCollection(\n        RateLimiterFactoryInterface $apiReadLimiter,\n        RateLimiterFactoryInterface $anonymousApiReadLimiter,\n        Security $security,\n        ContentRepository $contentRepository,\n        #[MapQueryParameter] ?string $cursor,\n        #[MapQueryParameter] ?string $cursor2,\n        #[MapQueryParameter] ?int $perPage,\n        #[MapQueryParameter] ?string $sort,\n        #[MapQueryParameter] ?string $time,\n        #[MapQueryParameter] ?string $federation,\n        #[MapQueryParameter] ?bool $includeBoosts,\n        string $collectionType,\n        SqlHelpers $sqlHelpers,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter);\n        $criteria = $this->getCriteria(1, $security, $sort, $time, $federation, $includeBoosts, $perPage, $sqlHelpers, $collectionType);\n        $currentCursor = $this->getCursor($contentRepository, $criteria->sortOption, $cursor);\n        $currentCursor2 = $cursor2 ? $this->getCursor($contentRepository, Criteria::SORT_NEW, $cursor2) : null;\n\n        $content = $contentRepository->findByCriteriaCursored($criteria, $currentCursor, $currentCursor2);\n\n        return $this->serializeContentCursored($content, $headers);\n    }\n\n    private function getCriteria(?int $p, Security $security, ?string $sort, ?string $time, ?string $federation, ?bool $includeBoosts, ?int $perPage, SqlHelpers $sqlHelpers, ?string $collectionType): ContentPageView\n    {\n        $criteria = new ContentPageView($p ?? 1, $security);\n        $criteria->sortOption = $sort ?? Criteria::SORT_HOT;\n        $criteria->time = $criteria->resolveTime($time ?? Criteria::TIME_ALL);\n        $criteria->setFederation($federation ?? Criteria::AP_ALL);\n        $this->handleLanguageCriteria($criteria);\n        $criteria->content = Criteria::CONTENT_COMBINED;\n        $criteria->perPage = $perPage;\n        $user = $security->getUser();\n        if ($user instanceof User) {\n            $criteria->includeBoosts = $includeBoosts ?? $user->showBoostsOfFollowing;\n            $criteria->fetchCachedItems($sqlHelpers, $user);\n        }\n\n        switch ($collectionType) {\n            case 'subscribed':\n                $criteria->subscribed = true;\n                break;\n            case 'moderated':\n                $criteria->moderated = true;\n                break;\n            case 'favourited':\n                $criteria->favourite = true;\n                break;\n        }\n\n        return $criteria;\n    }\n\n    private function serializeContent(PagerfantaInterface $content, array $headers): JsonResponse\n    {\n        $result = [];\n        foreach ($content as $item) {\n            if ($item instanceof Entry) {\n                $this->handlePrivateContent($item);\n                $result[] = new ContentResponseDto(entry: $this->serializeEntry($this->entryFactory->createDto($item), $this->tagLinkRepository->getTagsOfContent($item)));\n            } elseif ($item instanceof Post) {\n                $this->handlePrivateContent($item);\n                $result[] = new ContentResponseDto(post: $this->serializePost($this->postFactory->createDto($item), $this->tagLinkRepository->getTagsOfContent($item)));\n            } elseif ($item instanceof EntryComment) {\n                $this->handlePrivateContent($item);\n                $result[] = new ContentResponseDto(entryComment: $this->serializeEntryComment($this->entryCommentFactory->createDto($item), $this->tagLinkRepository->getTagsOfContent($item)));\n            } elseif ($item instanceof PostComment) {\n                $this->handlePrivateContent($item);\n                $result[] = new ContentResponseDto(postComment: $this->serializePostComment($this->postCommentFactory->createDto($item), $this->tagLinkRepository->getTagsOfContent($item)));\n            }\n        }\n\n        return new JsonResponse($this->serializePaginated($result, $content), headers: $headers);\n    }\n\n    private function serializeContentCursored(CursorPaginationInterface $content, array $headers): JsonResponse\n    {\n        $result = [];\n        foreach ($content as $item) {\n            if ($item instanceof Entry) {\n                $this->handlePrivateContent($item);\n                $result[] = new ContentResponseDto(entry: $this->serializeEntry($this->entryFactory->createDto($item), $this->tagLinkRepository->getTagsOfContent($item)));\n            } elseif ($item instanceof Post) {\n                $this->handlePrivateContent($item);\n                $result[] = new ContentResponseDto(post: $this->serializePost($this->postFactory->createDto($item), $this->tagLinkRepository->getTagsOfContent($item)));\n            }\n        }\n\n        return new JsonResponse($this->serializeCursorPaginated($result, $content), headers: $headers);\n    }\n\n    private function getCursor(ContentRepository $contentRepository, string $sortOption, ?string $cursor): int|\\DateTime|\\DateTimeImmutable\n    {\n        $initialCursor = $contentRepository->guessInitialCursor($sortOption);\n        if ($initialCursor instanceof \\DateTime || $initialCursor instanceof \\DateTimeImmutable) {\n            try {\n                $currentCursor = null !== $cursor ? new \\DateTimeImmutable($cursor) : $initialCursor;\n            } catch (\\DateException) {\n                throw new BadRequestHttpException('The cursor is not a parsable datetime.');\n            }\n        } elseif (\\is_int($initialCursor)) {\n            $currentCursor = null !== $cursor ? \\intval($cursor) : $initialCursor;\n        } else {\n            $this->logger->critical('Could not get a cursor from class \"{c}\"', ['c' => \\get_class($initialCursor)]);\n            throw new HttpException(500, 'Could not determine the cursor.');\n        }\n\n        return $currentCursor;\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Domain/DomainBaseApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Domain;\n\nuse App\\Controller\\Api\\BaseApi;\nuse App\\DTO\\DomainDto;\nuse App\\Entity\\Domain;\nuse App\\Factory\\DomainFactory;\nuse Symfony\\Contracts\\Service\\Attribute\\Required;\n\nclass DomainBaseApi extends BaseApi\n{\n    private readonly DomainFactory $factory;\n\n    #[Required]\n    public function setFactory(DomainFactory $factory): void\n    {\n        $this->factory = $factory;\n    }\n\n    /**\n     * Serialize a domain to JSON.\n     */\n    protected function serializeDomain(DomainDto|Domain $dto): DomainDto\n    {\n        $response = $dto instanceof Domain ? $this->factory->createDto($dto) : $dto;\n\n        return $response;\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Domain/DomainBlockApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Domain;\n\nuse App\\DTO\\DomainDto;\nuse App\\Entity\\Domain;\nuse App\\Factory\\DomainFactory;\nuse App\\Service\\DomainManager;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse Nelmio\\ApiDocBundle\\Attribute\\Security;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass DomainBlockApi extends DomainBaseApi\n{\n    #[OA\\Response(\n        response: 200,\n        description: 'Domain blocked',\n        content: new Model(type: DomainDto::class),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Domain not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'domain_id',\n        in: 'path',\n        description: 'The domain to block',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Tag(name: 'domain')]\n    #[Security(name: 'oauth2', scopes: ['domain:block'])]\n    #[IsGranted('ROLE_OAUTH2_DOMAIN:BLOCK')]\n    public function block(\n        #[MapEntity(id: 'domain_id')]\n        Domain $domain,\n        DomainManager $manager,\n        DomainFactory $factory,\n        RateLimiterFactoryInterface $apiUpdateLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiUpdateLimiter);\n\n        $manager->block($domain, $this->getUserOrThrow());\n\n        return new JsonResponse(\n            $this->serializeDomain($factory->createDto($domain)),\n            headers: $headers\n        );\n    }\n\n    #[OA\\Response(\n        response: 200,\n        description: 'Domain unblocked',\n        content: new Model(type: DomainDto::class),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Domain not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'domain_id',\n        in: 'path',\n        description: 'The domain to unblock',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Tag(name: 'domain')]\n    #[Security(name: 'oauth2', scopes: ['domain:block'])]\n    #[IsGranted('ROLE_OAUTH2_DOMAIN:BLOCK')]\n    public function unblock(\n        #[MapEntity(id: 'domain_id')]\n        Domain $domain,\n        DomainManager $manager,\n        DomainFactory $factory,\n        RateLimiterFactoryInterface $apiUpdateLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiUpdateLimiter);\n\n        $manager->unblock($domain, $this->getUserOrThrow());\n\n        return new JsonResponse(\n            $this->serializeDomain($factory->createDto($domain)),\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Domain/DomainRetrieveApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Domain;\n\nuse App\\Controller\\Traits\\PrivateContentTrait;\nuse App\\DTO\\DomainDto;\nuse App\\Entity\\Domain;\nuse App\\Entity\\DomainBlock;\nuse App\\Entity\\DomainSubscription;\nuse App\\Entity\\User;\nuse App\\Factory\\DomainFactory;\nuse App\\Repository\\DomainRepository;\nuse App\\Schema\\PaginationSchema;\nuse App\\Service\\SearchManager;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse Nelmio\\ApiDocBundle\\Attribute\\Security;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpKernel\\Exception\\AccessDeniedHttpException;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass DomainRetrieveApi extends DomainBaseApi\n{\n    use PrivateContentTrait;\n\n    #[OA\\Response(\n        response: 200,\n        description: 'Returns the Domain',\n        content: new Model(type: DomainDto::class),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Domain not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'domain_id',\n        in: 'path',\n        description: 'The domain to retrieve',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Tag(name: 'domain')]\n    public function __invoke(\n        #[MapEntity(id: 'domain_id')]\n        Domain $domain,\n        DomainFactory $factory,\n        RateLimiterFactoryInterface $apiReadLimiter,\n        RateLimiterFactoryInterface $anonymousApiReadLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter);\n\n        $dto = $factory->createDto($domain);\n\n        return new JsonResponse(\n            $this->serializeDomain($dto),\n            headers: $headers\n        );\n    }\n\n    #[OA\\Response(\n        response: 200,\n        description: 'Returns a paginated list of domains',\n        content: new OA\\JsonContent(\n            type: 'object',\n            properties: [\n                new OA\\Property(\n                    property: 'items',\n                    type: 'array',\n                    items: new OA\\Items(ref: new Model(type: DomainDto::class))\n                ),\n                new OA\\Property(\n                    property: 'pagination',\n                    ref: new Model(type: PaginationSchema::class)\n                ),\n            ]\n        ),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'p',\n        description: 'Page of domains to retrieve',\n        in: 'query',\n        schema: new OA\\Schema(type: 'integer', default: 1, minimum: 1)\n    )]\n    #[OA\\Parameter(\n        name: 'perPage',\n        description: 'Number of domains per page',\n        in: 'query',\n        schema: new OA\\Schema(type: 'integer', default: DomainRepository::PER_PAGE, minimum: self::MIN_PER_PAGE, maximum: self::MAX_PER_PAGE)\n    )]\n    #[OA\\Parameter(\n        name: 'q',\n        description: 'Domain search term',\n        in: 'query',\n        schema: new OA\\Schema(type: 'string')\n    )]\n    #[OA\\Tag(name: 'domain')]\n    public function collection(\n        DomainRepository $repository,\n        SearchManager $searchManager,\n        RateLimiterFactoryInterface $apiReadLimiter,\n        RateLimiterFactoryInterface $anonymousApiReadLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter);\n\n        $request = $this->request->getCurrentRequest();\n        $perPage = self::constrainPerPage($request->get('perPage', DomainRepository::PER_PAGE));\n\n        if ($q = $request->get('q')) {\n            $domains = $searchManager->findDomainsPaginated($q, $this->getPageNb($request), $perPage);\n        } else {\n            $domains = $repository->findAllPaginated(\n                $this->getPageNb($request),\n                $perPage\n            );\n        }\n\n        $dtos = [];\n        foreach ($domains->getCurrentPageResults() as $value) {\n            \\assert($value instanceof Domain);\n            array_push($dtos, $this->serializeDomain($value));\n        }\n\n        return new JsonResponse(\n            $this->serializePaginated($dtos, $domains),\n            headers: $headers\n        );\n    }\n\n    #[OA\\Response(\n        response: 200,\n        description: 'Returns a paginated list of subscribed domains',\n        content: new OA\\JsonContent(\n            type: 'object',\n            properties: [\n                new OA\\Property(\n                    property: 'items',\n                    type: 'array',\n                    items: new OA\\Items(ref: new Model(type: DomainDto::class))\n                ),\n                new OA\\Property(\n                    property: 'pagination',\n                    ref: new Model(type: PaginationSchema::class)\n                ),\n            ]\n        ),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'p',\n        description: 'Page of domains to retrieve',\n        in: 'query',\n        schema: new OA\\Schema(type: 'integer', default: 1, minimum: 1)\n    )]\n    #[OA\\Parameter(\n        name: 'perPage',\n        description: 'Number of domains per page',\n        in: 'query',\n        schema: new OA\\Schema(type: 'integer', default: DomainRepository::PER_PAGE, minimum: self::MIN_PER_PAGE, maximum: self::MAX_PER_PAGE)\n    )]\n    #[OA\\Tag(name: 'domain')]\n    #[Security(name: 'oauth2', scopes: ['domain:subscribe'])]\n    #[IsGranted('ROLE_OAUTH2_DOMAIN:SUBSCRIBE')]\n    public function subscribed(\n        DomainRepository $repository,\n        RateLimiterFactoryInterface $apiReadLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiReadLimiter);\n\n        $request = $this->request->getCurrentRequest();\n        $domains = $repository->findSubscribedDomains(\n            $this->getPageNb($request),\n            $this->getUserOrThrow(),\n            self::constrainPerPage($request->get('perPage', DomainRepository::PER_PAGE))\n        );\n\n        $dtos = [];\n        foreach ($domains->getCurrentPageResults() as $value) {\n            \\assert($value instanceof DomainSubscription);\n            array_push($dtos, $this->serializeDomain($value->domain));\n        }\n\n        return new JsonResponse(\n            $this->serializePaginated($dtos, $domains),\n            headers: $headers\n        );\n    }\n\n    #[OA\\Response(\n        response: 200,\n        description: 'Returns a paginated list of user\\'s subscribed domains',\n        content: new OA\\JsonContent(\n            type: 'object',\n            properties: [\n                new OA\\Property(\n                    property: 'items',\n                    type: 'array',\n                    items: new OA\\Items(ref: new Model(type: DomainDto::class))\n                ),\n                new OA\\Property(\n                    property: 'pagination',\n                    ref: new Model(type: PaginationSchema::class)\n                ),\n            ]\n        ),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'This user does not allow others to view their subscribed domains',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'user_id',\n        description: 'User from which to retrieve subscribed domains',\n        in: 'path',\n        schema: new OA\\Schema(type: 'integer')\n    )]\n    #[OA\\Parameter(\n        name: 'p',\n        description: 'Page of domains to retrieve',\n        in: 'query',\n        schema: new OA\\Schema(type: 'integer', default: 1, minimum: 1)\n    )]\n    #[OA\\Parameter(\n        name: 'perPage',\n        description: 'Number of domains per page',\n        in: 'query',\n        schema: new OA\\Schema(\n            type: 'integer',\n            default: DomainRepository::PER_PAGE,\n            minimum: self::MIN_PER_PAGE,\n            maximum: self::MAX_PER_PAGE\n        )\n    )]\n    #[OA\\Tag(name: 'user')]\n    #[Security(name: 'oauth2', scopes: ['read'])]\n    #[IsGranted('ROLE_OAUTH2_READ')]\n    public function subscriptions(\n        #[MapEntity(id: 'user_id')]\n        User $user,\n        DomainRepository $repository,\n        RateLimiterFactoryInterface $apiReadLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiReadLimiter);\n\n        if ($user->getId() !== $this->getUserOrThrow()->getId() && !$user->getShowProfileFollowings()) {\n            throw new AccessDeniedHttpException('You are not permitted to view the domains followed by this user');\n        }\n\n        $request = $this->request->getCurrentRequest();\n        $domains = $repository->findSubscribedDomains(\n            $this->getPageNb($request),\n            $user,\n            self::constrainPerPage($request->get('perPage', DomainRepository::PER_PAGE))\n        );\n\n        $dtos = [];\n        foreach ($domains->getCurrentPageResults() as $value) {\n            array_push($dtos, $this->serializeDomain($value->domain));\n        }\n\n        return new JsonResponse(\n            $this->serializePaginated($dtos, $domains),\n            headers: $headers\n        );\n    }\n\n    #[OA\\Response(\n        response: 200,\n        description: 'Returns a paginated list of blocked domains',\n        content: new OA\\JsonContent(\n            type: 'object',\n            properties: [\n                new OA\\Property(\n                    property: 'items',\n                    type: 'array',\n                    items: new OA\\Items(ref: new Model(type: DomainDto::class))\n                ),\n                new OA\\Property(\n                    property: 'pagination',\n                    ref: new Model(type: PaginationSchema::class)\n                ),\n            ]\n        ),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'p',\n        description: 'Page of domains to retrieve',\n        in: 'query',\n        schema: new OA\\Schema(type: 'integer', default: 1, minimum: 1)\n    )]\n    #[OA\\Parameter(\n        name: 'perPage',\n        description: 'Number of domains per page',\n        in: 'query',\n        schema: new OA\\Schema(type: 'integer', default: DomainRepository::PER_PAGE, minimum: self::MIN_PER_PAGE, maximum: self::MAX_PER_PAGE)\n    )]\n    #[OA\\Tag(name: 'domain')]\n    #[Security(name: 'oauth2', scopes: ['domain:block'])]\n    #[IsGranted('ROLE_OAUTH2_DOMAIN:BLOCK')]\n    public function blocked(\n        DomainRepository $repository,\n        RateLimiterFactoryInterface $apiReadLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiReadLimiter);\n\n        $request = $this->request->getCurrentRequest();\n        $domains = $repository->findBlockedDomains(\n            $this->getPageNb($request),\n            $this->getUserOrThrow(),\n            self::constrainPerPage($request->get('perPage', DomainRepository::PER_PAGE))\n        );\n\n        $dtos = [];\n        foreach ($domains->getCurrentPageResults() as $value) {\n            \\assert($value instanceof DomainBlock);\n            array_push($dtos, $this->serializeDomain($value->domain));\n        }\n\n        return new JsonResponse(\n            $this->serializePaginated($dtos, $domains),\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Domain/DomainSubscribeApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Domain;\n\nuse App\\DTO\\DomainDto;\nuse App\\Entity\\Domain;\nuse App\\Factory\\DomainFactory;\nuse App\\Service\\DomainManager;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse Nelmio\\ApiDocBundle\\Attribute\\Security;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass DomainSubscribeApi extends DomainBaseApi\n{\n    #[OA\\Response(\n        response: 200,\n        description: 'Domain subscription status updated',\n        content: new Model(type: DomainDto::class),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Domain not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'domain_id',\n        in: 'path',\n        description: 'The domain to subscribe to',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Tag(name: 'domain')]\n    #[Security(name: 'oauth2', scopes: ['domain:subscribe'])]\n    #[IsGranted('ROLE_OAUTH2_DOMAIN:SUBSCRIBE')]\n    public function subscribe(\n        #[MapEntity(id: 'domain_id')]\n        Domain $domain,\n        DomainManager $manager,\n        DomainFactory $factory,\n        RateLimiterFactoryInterface $apiUpdateLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiUpdateLimiter);\n\n        $manager->subscribe($domain, $this->getUserOrThrow());\n\n        return new JsonResponse(\n            $this->serializeDomain($factory->createDto($domain)),\n            headers: $headers\n        );\n    }\n\n    #[OA\\Response(\n        response: 200,\n        description: 'Domain subscription status updated',\n        content: new Model(type: DomainDto::class),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Domain not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'domain_id',\n        in: 'path',\n        description: 'The domain to unsubscribe from',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Tag(name: 'domain')]\n    #[Security(name: 'oauth2', scopes: ['domain:subscribe'])]\n    #[IsGranted('ROLE_OAUTH2_DOMAIN:SUBSCRIBE')]\n    public function unsubscribe(\n        #[MapEntity(id: 'domain_id')]\n        Domain $domain,\n        DomainManager $manager,\n        DomainFactory $factory,\n        RateLimiterFactoryInterface $apiUpdateLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiUpdateLimiter);\n\n        $manager->unsubscribe($domain, $this->getUserOrThrow());\n\n        return new JsonResponse(\n            $this->serializeDomain($factory->createDto($domain)),\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Entry/Admin/EntriesChangeMagazineApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Entry\\Admin;\n\nuse App\\Controller\\Api\\Entry\\EntriesBaseApi;\nuse App\\DTO\\EntryResponseDto;\nuse App\\Entity\\Entry;\nuse App\\Entity\\Magazine;\nuse App\\Factory\\EntryFactory;\nuse App\\Service\\EntryManager;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse Nelmio\\ApiDocBundle\\Attribute\\Security;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass EntriesChangeMagazineApi extends EntriesBaseApi\n{\n    #[OA\\Response(\n        response: 200,\n        description: 'Entry\\'s magazine changed',\n        content: new Model(type: EntryResponseDto::class),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'You are not authorized to change this entry\\'s magazine',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Entry not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'entry_id',\n        in: 'path',\n        description: 'The entry to move',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Parameter(\n        name: 'target_id',\n        in: 'path',\n        description: 'The magazine to move the entry to',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Tag(name: 'admin/entry')]\n    #[IsGranted('ROLE_ADMIN')]\n    #[Security(name: 'oauth2', scopes: ['admin:magazine:move_entry'])]\n    #[IsGranted('ROLE_OAUTH2_ADMIN:MAGAZINE:MOVE_ENTRY')]\n    /** Changes the magazine of the entry to target */\n    public function __invoke(\n        #[MapEntity(id: 'entry_id')]\n        Entry $entry,\n        #[MapEntity(id: 'target_id')]\n        Magazine $target,\n        EntryManager $manager,\n        EntryFactory $factory,\n        RateLimiterFactoryInterface $apiModerateLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiModerateLimiter);\n\n        $manager->changeMagazine($entry, $target);\n\n        return new JsonResponse(\n            $this->serializeEntry($factory->createDto($entry), $this->tagLinkRepository->getTagsOfContent($entry), $this->entryRepository->findCross($entry)),\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Entry/Admin/EntriesPurgeApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Entry\\Admin;\n\nuse App\\Controller\\Api\\Entry\\EntriesBaseApi;\nuse App\\Entity\\Entry;\nuse App\\Service\\EntryManager;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse Nelmio\\ApiDocBundle\\Attribute\\Security;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass EntriesPurgeApi extends EntriesBaseApi\n{\n    #[OA\\Response(\n        response: 204,\n        description: 'Entry purged',\n        content: null,\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'You are not authorized to purge this entry',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Entry not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'entry_id',\n        in: 'path',\n        description: 'The entry to purge',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Tag(name: 'admin/entry')]\n    #[IsGranted('ROLE_ADMIN')]\n    #[Security(name: 'oauth2', scopes: ['admin:entry:purge'])]\n    #[IsGranted('ROLE_OAUTH2_ADMIN:ENTRY:PURGE')]\n    #[IsGranted('purge', subject: 'entry')]\n    /**\n     * Purges an entry from the instance, deleting it completely. This action is irreversible.\n     */\n    public function __invoke(\n        #[MapEntity(id: 'entry_id')]\n        Entry $entry,\n        EntryManager $manager,\n        RateLimiterFactoryInterface $apiModerateLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiModerateLimiter);\n\n        $manager->purge($this->getUserOrThrow(), $entry);\n\n        return new JsonResponse(\n            status: 204,\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Entry/Comments/Admin/EntryCommentsPurgeApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Entry\\Comments\\Admin;\n\nuse App\\Controller\\Api\\Entry\\EntriesBaseApi;\nuse App\\Entity\\EntryComment;\nuse App\\Service\\EntryCommentManager;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse Nelmio\\ApiDocBundle\\Attribute\\Security;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass EntryCommentsPurgeApi extends EntriesBaseApi\n{\n    #[OA\\Response(\n        response: 204,\n        description: 'Comment purged',\n        content: null,\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'You are not authorized to purge this comment',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Comment not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'comment_id',\n        in: 'path',\n        description: 'The comment to purge',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Tag(name: 'admin/entry_comment')]\n    #[IsGranted('ROLE_ADMIN')]\n    #[Security(name: 'oauth2', scopes: ['admin:entry_comment:purge'])]\n    #[IsGranted('ROLE_OAUTH2_ADMIN:ENTRY_COMMENT:PURGE')]\n    #[IsGranted('purge', subject: 'comment')]\n    /**\n     * Purges a comment from the instance, deleting it completely. This action is irreversible.\n     */\n    public function __invoke(\n        #[MapEntity(id: 'comment_id')]\n        EntryComment $comment,\n        EntryCommentManager $manager,\n        RateLimiterFactoryInterface $apiModerateLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiModerateLimiter);\n\n        $manager->purge($this->getUserOrThrow(), $comment);\n\n        return new JsonResponse(\n            status: 204,\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Entry/Comments/DomainEntryCommentsRetrieveApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Entry\\Comments;\n\nuse App\\Controller\\Api\\Entry\\EntriesBaseApi;\nuse App\\Controller\\Traits\\PrivateContentTrait;\nuse App\\DTO\\EntryCommentResponseDto;\nuse App\\Entity\\Domain;\nuse App\\PageView\\EntryCommentPageView;\nuse App\\Repository\\Criteria;\nuse App\\Repository\\EntryCommentRepository;\nuse App\\Repository\\EntryRepository;\nuse App\\Schema\\PaginationSchema;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Bundle\\SecurityBundle\\Security;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpFoundation\\RequestStack;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\n\nclass DomainEntryCommentsRetrieveApi extends EntriesBaseApi\n{\n    use PrivateContentTrait;\n\n    #[OA\\Response(\n        response: 200,\n        description: 'Returns a paginated list of comments from a specific domain',\n        content: new OA\\JsonContent(\n            type: 'object',\n            properties: [\n                new OA\\Property(\n                    property: 'items',\n                    type: 'array',\n                    items: new OA\\Items(ref: new Model(type: EntryCommentResponseDto::class))\n                ),\n                new OA\\Property(\n                    property: 'pagination',\n                    ref: new Model(type: PaginationSchema::class)\n                ),\n            ]\n        ),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Domain not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'domain_id',\n        in: 'path',\n        description: 'The domain to retrieve comments from',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Parameter(\n        name: 'sort',\n        in: 'query',\n        description: 'The sorting method to use during comment fetch',\n        schema: new OA\\Schema(\n            type: 'string',\n            default: EntryCommentPageView::SORT_DEFAULT,\n            enum: EntryCommentPageView::SORT_OPTIONS\n        )\n    )]\n    #[OA\\Parameter(\n        name: 'time',\n        in: 'query',\n        description: 'The maximum age of retrieved entries',\n        schema: new OA\\Schema(\n            type: 'string',\n            default: Criteria::TIME_ALL,\n            enum: Criteria::TIME_ROUTES_EN\n        )\n    )]\n    #[OA\\Parameter(\n        name: 'p',\n        description: 'Page of comments to retrieve',\n        in: 'query',\n        schema: new OA\\Schema(type: 'integer', default: 1, minimum: 1)\n    )]\n    #[OA\\Parameter(\n        name: 'perPage',\n        description: 'Number of comments to retrieve per page',\n        in: 'query',\n        schema: new OA\\Schema(type: 'integer', default: EntryRepository::PER_PAGE, minimum: self::MIN_PER_PAGE, maximum: self::MAX_PER_PAGE)\n    )]\n    #[OA\\Parameter(\n        name: 'd',\n        description: 'Depth of comment children to retrieve',\n        in: 'query',\n        schema: new OA\\Schema(type: 'integer', default: self::DEPTH, minimum: self::MIN_DEPTH, maximum: self::MAX_DEPTH)\n    )]\n    #[OA\\Parameter(\n        name: 'lang[]',\n        description: 'Language(s) of entries to return',\n        in: 'query',\n        explode: true,\n        allowReserved: true,\n        schema: new OA\\Schema(\n            type: 'array',\n            items: new OA\\Items(type: 'string', default: null, minLength: 2, maxLength: 3)\n        )\n    )]\n    #[OA\\Parameter(\n        name: 'usePreferredLangs',\n        description: 'Filter by a user\\'s preferred languages? (Requires authentication and takes precedence over lang[])',\n        in: 'query',\n        schema: new OA\\Schema(type: 'boolean', default: false),\n    )]\n    #[OA\\Tag(name: 'domain')]\n    public function __invoke(\n        #[MapEntity(id: 'domain_id')]\n        Domain $domain,\n        EntryCommentRepository $repository,\n        RequestStack $request,\n        RateLimiterFactoryInterface $apiReadLimiter,\n        RateLimiterFactoryInterface $anonymousApiReadLimiter,\n        Security $security,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter);\n\n        $criteria = new EntryCommentPageView((int) $request->getCurrentRequest()->get('p', 1), $security);\n        $criteria->sortOption = $request->getCurrentRequest()->get('sort', Criteria::SORT_HOT);\n        $criteria->time = $criteria->resolveTime(\n            $request->getCurrentRequest()->get('time', Criteria::TIME_ALL)\n        );\n        $this->handleLanguageCriteria($criteria);\n\n        $criteria->perPage = self::constrainPerPage($request->getCurrentRequest()->get('perPage', EntryRepository::PER_PAGE));\n\n        $criteria->domain = $domain->name;\n\n        $comments = $repository->findByCriteria($criteria);\n\n        $dtos = [];\n        foreach ($comments->getCurrentPageResults() as $value) {\n            try {\n                $this->handlePrivateContent($value);\n                $dtos[] = $this->serializeCommentTree($value, $criteria);\n            } catch (\\Exception $e) {\n                continue;\n            }\n        }\n\n        return new JsonResponse(\n            $this->serializePaginated($dtos, $comments),\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Entry/Comments/EntryCommentsActivityApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Entry\\Comments;\n\nuse App\\Controller\\Api\\Entry\\EntriesBaseApi;\nuse App\\Controller\\Traits\\PrivateContentTrait;\nuse App\\DTO\\ActivitiesResponseDto;\nuse App\\Entity\\EntryComment;\nuse App\\Factory\\ContentActivityDtoFactory;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\n\nclass EntryCommentsActivityApi extends EntriesBaseApi\n{\n    use PrivateContentTrait;\n\n    #[OA\\Response(\n        response: 200,\n        description: 'Vote activity of the comment',\n        content: new Model(type: ActivitiesResponseDto::class),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'You are not authorized to access this comment',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Comment not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'comment_id',\n        in: 'path',\n        description: 'The comment to query',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Tag(name: 'entry_comment')]\n    public function __invoke(\n        #[MapEntity(id: 'comment_id')]\n        EntryComment $comment,\n        ContentActivityDtoFactory $dtoFactory,\n        RateLimiterFactoryInterface $apiReadLimiter,\n        RateLimiterFactoryInterface $anonymousApiReadLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter);\n\n        $this->handlePrivateContent($comment);\n\n        $dto = $dtoFactory->createActivitiesDto($comment);\n\n        return new JsonResponse(\n            $dto,\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Entry/Comments/EntryCommentsCreateApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Entry\\Comments;\n\nuse App\\Controller\\Api\\Entry\\EntriesBaseApi;\nuse App\\Controller\\Traits\\PrivateContentTrait;\nuse App\\DTO\\EntryCommentRequestDto;\nuse App\\DTO\\EntryCommentResponseDto;\nuse App\\DTO\\ImageUploadDto;\nuse App\\Entity\\Entry;\nuse App\\Entity\\EntryComment;\nuse App\\Factory\\EntryCommentFactory;\nuse App\\Service\\EntryCommentManager;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse Nelmio\\ApiDocBundle\\Attribute\\Security;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpKernel\\Exception\\AccessDeniedHttpException;\nuse Symfony\\Component\\HttpKernel\\Exception\\BadRequestHttpException;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\nuse Symfony\\Component\\Validator\\Validator\\ValidatorInterface;\n\nclass EntryCommentsCreateApi extends EntriesBaseApi\n{\n    use PrivateContentTrait;\n\n    #[OA\\Response(\n        response: 201,\n        description: 'Comment created',\n        content: new Model(type: EntryCommentResponseDto::class),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 400,\n        description: 'The request body was invalid or the comment you are replying to does not belong to the entry you are trying to add the new comment to.',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\BadRequestErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'You are not permitted to add comments to this entry',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Entry or parent comment not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'entry_id',\n        in: 'path',\n        description: 'Entry to which the new comment will belong',\n        schema: new OA\\Schema(type: 'integer')\n    )]\n    #[OA\\RequestBody(content: new Model(\n        type: EntryCommentRequestDto::class,\n        groups: [\n            'common',\n            'comment',\n            'no-upload',\n        ]\n    ))]\n    #[OA\\Tag(name: 'entry_comment')]\n    #[Security(name: 'oauth2', scopes: ['entry_comment:create'])]\n    #[IsGranted('ROLE_OAUTH2_ENTRY_COMMENT:CREATE')]\n    #[IsGranted('comment', subject: 'entry')]\n    public function __invoke(\n        #[MapEntity(id: 'entry_id')]\n        Entry $entry,\n        #[MapEntity(id: 'comment_id')]\n        ?EntryComment $parent,\n        EntryCommentManager $manager,\n        EntryCommentFactory $factory,\n        ValidatorInterface $validator,\n        RateLimiterFactoryInterface $apiCommentLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiCommentLimiter);\n\n        if (!$this->isGranted('create_content', $entry->magazine)) {\n            throw new AccessDeniedHttpException();\n        }\n\n        if ($parent && $parent->entry->getId() !== $entry->getId()) {\n            throw new BadRequestHttpException('The parent comment does not belong to that entry!');\n        }\n        $dto = $this->deserializeComment();\n\n        $dto->entry = $entry;\n        $dto->magazine = $entry->magazine;\n        $dto->parent = $parent;\n\n        $errors = $validator->validate($dto);\n        if (\\count($errors) > 0) {\n            throw new BadRequestHttpException((string) $errors);\n        }\n\n        // Rate limiting already taken care of\n        $comment = $manager->create($dto, $this->getUserOrThrow(), rateLimit: false);\n        $dto = $factory->createDto($comment);\n        $dto->parent = $parent;\n\n        return new JsonResponse(\n            $this->serializeEntryComment($dto, $this->tagLinkRepository->getTagsOfContent($comment)),\n            status: 201,\n            headers: $headers\n        );\n    }\n\n    #[OA\\Response(\n        response: 201,\n        description: 'Comment created',\n        content: new Model(type: EntryCommentResponseDto::class),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 400,\n        description: 'The request body was invalid or the comment you are replying to does not belong to the entry you are trying to add the new comment to.',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\BadRequestErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'You are not permitted to add comments to this entry',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Entry or parent comment not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'entry_id',\n        in: 'path',\n        description: 'Entry to which the new comment will belong',\n        schema: new OA\\Schema(type: 'integer')\n    )]\n    #[OA\\RequestBody(content: new OA\\MediaType(\n        'multipart/form-data',\n        schema: new OA\\Schema(\n            ref: new Model(\n                type: EntryCommentRequestDto::class,\n                groups: [\n                    'common',\n                    'comment',\n                    ImageUploadDto::IMAGE_UPLOAD,\n                ]\n            )\n        )\n    ))]\n    #[OA\\Tag(name: 'entry_comment')]\n    #[Security(name: 'oauth2', scopes: ['entry_comment:create'])]\n    #[IsGranted('ROLE_OAUTH2_ENTRY_COMMENT:CREATE')]\n    #[IsGranted('comment', subject: 'entry')]\n    public function uploadImage(\n        #[MapEntity(id: 'entry_id')]\n        Entry $entry,\n        #[MapEntity(id: 'comment_id')]\n        ?EntryComment $parent,\n        EntryCommentManager $manager,\n        EntryCommentFactory $factory,\n        ValidatorInterface $validator,\n        RateLimiterFactoryInterface $apiImageLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiImageLimiter);\n\n        $image = $this->handleUploadedImage();\n\n        if (!$this->isGranted('create_content', $entry->magazine)) {\n            throw new AccessDeniedHttpException();\n        }\n\n        if ($parent && $parent->entry->getId() !== $entry->getId()) {\n            throw new BadRequestHttpException('The parent comment does not belong to that entry!');\n        }\n\n        $dto = $this->deserializeCommentFromForm();\n\n        $dto->entry = $entry;\n        $dto->magazine = $entry->magazine;\n        $dto->parent = $parent;\n        $dto->image = $this->imageFactory->createDto($image);\n\n        $errors = $validator->validate($dto);\n        if (\\count($errors) > 0) {\n            throw new BadRequestHttpException((string) $errors);\n        }\n\n        // Rate limiting already taken care of\n        $comment = $manager->create($dto, $this->getUserOrThrow(), rateLimit: false);\n        $dto = $factory->createDto($comment);\n        $dto->parent = $parent;\n\n        return new JsonResponse(\n            $this->serializeEntryComment($dto, $this->tagLinkRepository->getTagsOfContent($comment)),\n            status: 201,\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Entry/Comments/EntryCommentsDeleteApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Entry\\Comments;\n\nuse App\\Controller\\Api\\Entry\\EntriesBaseApi;\nuse App\\Controller\\Traits\\PrivateContentTrait;\nuse App\\Entity\\EntryComment;\nuse App\\Service\\EntryCommentManager;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse Nelmio\\ApiDocBundle\\Attribute\\Security;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass EntryCommentsDeleteApi extends EntriesBaseApi\n{\n    use PrivateContentTrait;\n\n    #[OA\\Response(\n        response: 204,\n        description: 'Comment deleted successfully',\n        content: null,\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'You are not authorized to delete this comment',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Comment not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'comment_id',\n        in: 'path',\n        description: 'The comment to delete',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Tag(name: 'entry_comment')]\n    #[Security(name: 'oauth2', scopes: ['entry_comment:delete'])]\n    #[IsGranted('ROLE_OAUTH2_ENTRY_COMMENT:DELETE')]\n    #[IsGranted('delete', subject: 'comment')]\n    public function __invoke(\n        #[MapEntity(id: 'comment_id')]\n        EntryComment $comment,\n        EntryCommentManager $manager,\n        RateLimiterFactoryInterface $apiDeleteLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiDeleteLimiter);\n\n        $manager->delete($this->getUserOrThrow(), $comment);\n\n        return new JsonResponse(status: 204, headers: $headers);\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Entry/Comments/EntryCommentsFavouriteApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Entry\\Comments;\n\nuse App\\Controller\\Api\\Entry\\EntriesBaseApi;\nuse App\\DTO\\EntryCommentResponseDto;\nuse App\\Entity\\EntryComment;\nuse App\\Factory\\EntryCommentFactory;\nuse App\\Service\\FavouriteManager;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse Nelmio\\ApiDocBundle\\Attribute\\Security;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass EntryCommentsFavouriteApi extends EntriesBaseApi\n{\n    #[OA\\Response(\n        response: 200,\n        description: 'Comment favourite status toggled',\n        content: new Model(type: EntryCommentResponseDto::class),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Comment not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'comment_id',\n        in: 'path',\n        description: 'The comment to favourite',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Parameter(\n        name: 'd',\n        in: 'query',\n        description: 'Comment tree depth to retrieve (-1 for unlimited depth)',\n        schema: new OA\\Schema(type: 'integer', default: -1),\n    )]\n    #[OA\\Tag(name: 'entry_comment')]\n    // TODO: Bots should not be able to vote\n    //       *sad beep boop*\n    #[Security(name: 'oauth2', scopes: ['entry_comment:vote'])]\n    #[IsGranted('ROLE_OAUTH2_ENTRY_COMMENT:VOTE')]\n    public function __invoke(\n        #[MapEntity(id: 'comment_id')]\n        EntryComment $comment,\n        FavouriteManager $manager,\n        EntryCommentFactory $factory,\n        RateLimiterFactoryInterface $apiVoteLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiVoteLimiter);\n\n        $manager->toggle($this->getUserOrThrow(), $comment);\n\n        return new JsonResponse(\n            $this->serializeEntryComment($factory->createDto($comment), $this->tagLinkRepository->getTagsOfContent($comment)),\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Entry/Comments/EntryCommentsReportApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Entry\\Comments;\n\nuse App\\Controller\\Api\\Entry\\EntriesBaseApi;\nuse App\\Controller\\Traits\\PrivateContentTrait;\nuse App\\DTO\\ReportRequestDto;\nuse App\\Entity\\EntryComment;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse Nelmio\\ApiDocBundle\\Attribute\\Security;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass EntryCommentsReportApi extends EntriesBaseApi\n{\n    use PrivateContentTrait;\n\n    #[OA\\Response(\n        response: 204,\n        description: 'Report created',\n        content: null,\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'You have not been authorized to report this comment',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Comment not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'comment_id',\n        in: 'path',\n        description: 'The comment to report',\n        schema: new OA\\Schema(type: 'integer')\n    )]\n    #[OA\\RequestBody(content: new Model(type: ReportRequestDto::class))]\n    #[OA\\Tag(name: 'entry_comment')]\n    #[Security(name: 'oauth2', scopes: ['entry_comment:report'])]\n    #[IsGranted('ROLE_OAUTH2_ENTRY_COMMENT:REPORT')]\n    public function __invoke(\n        #[MapEntity(id: 'comment_id')]\n        EntryComment $comment,\n        RateLimiterFactoryInterface $apiReportLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiReportLimiter);\n\n        $this->reportContent($comment);\n\n        return new JsonResponse(\n            status: 204,\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Entry/Comments/EntryCommentsRetrieveApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Entry\\Comments;\n\nuse App\\Controller\\Api\\Entry\\EntriesBaseApi;\nuse App\\Controller\\Traits\\PrivateContentTrait;\nuse App\\DTO\\EntryCommentResponseDto;\nuse App\\Entity\\Entry;\nuse App\\Entity\\EntryComment;\nuse App\\PageView\\EntryCommentPageView;\nuse App\\Repository\\Criteria;\nuse App\\Repository\\EntryCommentRepository;\nuse App\\Schema\\PaginationSchema;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Bundle\\SecurityBundle\\Security;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\n\nclass EntryCommentsRetrieveApi extends EntriesBaseApi\n{\n    use PrivateContentTrait;\n\n    #[OA\\Response(\n        response: 200,\n        description: 'Returns comments from the entry',\n        content: new OA\\JsonContent(\n            type: 'object',\n            properties: [\n                new OA\\Property(\n                    property: 'items',\n                    type: 'array',\n                    items: new OA\\Items(ref: new Model(type: EntryCommentResponseDto::class))\n                ),\n                new OA\\Property(\n                    property: 'pagination',\n                    ref: new Model(type: PaginationSchema::class)\n                ),\n            ]\n        ),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Entry not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'entry_id',\n        in: 'path',\n        description: 'The entry to retrieve comments from',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Parameter(\n        name: 'sortBy',\n        in: 'query',\n        description: 'The order to retrieve comments by',\n        schema: new OA\\Schema(\n            type: 'string',\n            enum: EntryCommentPageView::SORT_OPTIONS,\n            default: EntryCommentPageView::SORT_DEFAULT\n        )\n    )]\n    #[OA\\Parameter(\n        name: 'time',\n        in: 'query',\n        description: 'The maximum age of retrieved comments',\n        schema: new OA\\Schema(\n            type: 'string',\n            default: Criteria::TIME_ALL,\n            enum: Criteria::TIME_ROUTES_EN\n        )\n    )]\n    #[OA\\Parameter(\n        name: 'p',\n        in: 'query',\n        description: 'The page of comments to retrieve',\n        schema: new OA\\Schema(type: 'integer', default: 1),\n    )]\n    #[OA\\Parameter(\n        name: 'perPage',\n        in: 'query',\n        description: 'The number of top level comments per page',\n        schema: new OA\\Schema(type: 'integer', default: EntryCommentRepository::PER_PAGE, minimum: self::MIN_PER_PAGE, maximum: self::MAX_PER_PAGE),\n    )]\n    #[OA\\Parameter(\n        name: 'd',\n        in: 'query',\n        description: 'The depth of comment trees retrieved',\n        schema: new OA\\Schema(type: 'integer', default: self::DEPTH, minimum: self::MIN_DEPTH, maximum: self::MAX_DEPTH),\n    )]\n    #[OA\\Parameter(\n        name: 'lang[]',\n        description: 'Language(s) of comments to return',\n        in: 'query',\n        explode: true,\n        allowReserved: true,\n        schema: new OA\\Schema(\n            type: 'array',\n            items: new OA\\Items(type: 'string')\n        )\n    )]\n    #[OA\\Parameter(\n        name: 'usePreferredLangs',\n        description: 'Filter by a user\\'s preferred languages? (Requires authentication and takes precedence over lang[])',\n        in: 'query',\n        schema: new OA\\Schema(type: 'boolean', default: false),\n    )]\n    #[OA\\Tag(name: 'entry')]\n    public function __invoke(\n        #[MapEntity(id: 'entry_id')]\n        Entry $entry,\n        EntryCommentRepository $commentsRepository,\n        RateLimiterFactoryInterface $apiReadLimiter,\n        RateLimiterFactoryInterface $anonymousApiReadLimiter,\n        Security $security,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter);\n\n        $this->handlePrivateContent($entry);\n\n        $request = $this->request->getCurrentRequest();\n        $criteria = new EntryCommentPageView($this->getPageNb($request), $security);\n        $sort = $criteria->resolveSort($request->get('sortBy', Criteria::SORT_HOT));\n        $criteria->showSortOption($sort);\n        $criteria->entry = $entry;\n        $criteria->perPage = self::constrainPerPage($request->get('perPage', EntryCommentRepository::PER_PAGE));\n        $criteria->setTime($criteria->resolveTime($request->get('time', Criteria::TIME_ALL)));\n\n        $this->handleLanguageCriteria($criteria);\n\n        $comments = $commentsRepository->findByCriteria($criteria);\n\n        $commentsRepository->hydrate(...$comments);\n        $commentsRepository->hydrateChildren(...$comments);\n\n        $dtos = [];\n        foreach ($comments->getCurrentPageResults() as $value) {\n            try {\n                \\assert($value instanceof EntryComment);\n                $this->handlePrivateContent($value);\n                $dtos[] = $this->serializeCommentTree($value, $criteria);\n            } catch (\\Exception $e) {\n                continue;\n            }\n        }\n\n        return new JsonResponse(\n            $this->serializePaginated($dtos, $comments),\n            headers: $headers\n        );\n    }\n\n    #[OA\\Response(\n        response: 200,\n        description: 'Returns the comment',\n        content: new Model(type: EntryCommentResponseDto::class)\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Comment not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Parameter(\n        name: 'comment_id',\n        in: 'path',\n        description: 'The comment to retrieve',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Parameter(\n        name: 'd',\n        in: 'query',\n        description: 'Comment tree depth to retrieve',\n        schema: new OA\\Schema(type: 'integer', default: self::DEPTH, minimum: self::MIN_DEPTH, maximum: self::MAX_DEPTH),\n    )]\n    #[OA\\Tag(name: 'entry_comment')]\n    public function single(\n        #[MapEntity(id: 'comment_id')]\n        EntryComment $comment,\n        EntryCommentRepository $commentsRepository,\n        RateLimiterFactoryInterface $apiReadLimiter,\n        RateLimiterFactoryInterface $anonymousApiReadLimiter,\n        Security $security,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter);\n        $this->handlePrivateContent($comment);\n\n        return new JsonResponse(\n            $this->serializeCommentTree($comment, new EntryCommentPageView(0, $security)),\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Entry/Comments/EntryCommentsUpdateApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Entry\\Comments;\n\nuse App\\Controller\\Api\\Entry\\EntriesBaseApi;\nuse App\\Controller\\Traits\\PrivateContentTrait;\nuse App\\DTO\\EntryCommentRequestDto;\nuse App\\DTO\\EntryCommentResponseDto;\nuse App\\Entity\\EntryComment;\nuse App\\Factory\\EntryCommentFactory;\nuse App\\PageView\\EntryCommentPageView;\nuse App\\Service\\EntryCommentManager;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse Nelmio\\ApiDocBundle\\Attribute\\Security;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Bundle\\SecurityBundle\\Security as SymfonySecurity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpKernel\\Exception\\AccessDeniedHttpException;\nuse Symfony\\Component\\HttpKernel\\Exception\\BadRequestHttpException;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\nuse Symfony\\Component\\Validator\\Validator\\ValidatorInterface;\n\nclass EntryCommentsUpdateApi extends EntriesBaseApi\n{\n    use PrivateContentTrait;\n\n    #[OA\\Response(\n        response: 200,\n        description: 'Comment updated',\n        content: new Model(type: EntryCommentResponseDto::class),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'You do not have permission to update this comment',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Comment not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'comment_id',\n        in: 'path',\n        description: 'The id of the comment to update',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Parameter(\n        name: 'd',\n        in: 'query',\n        description: 'Comment tree depth to retrieve (-1 for unlimited depth)',\n        schema: new OA\\Schema(type: 'integer', default: -1),\n    )]\n    #[OA\\RequestBody(content: new Model(\n        type: EntryCommentRequestDto::class,\n        groups: [\n            'common',\n            'comment',\n            'no-upload',\n        ]\n    ))]\n    #[OA\\Tag(name: 'entry_comment')]\n    #[Security(name: 'oauth2', scopes: ['entry_comment:edit'])]\n    #[IsGranted('ROLE_OAUTH2_ENTRY_COMMENT:EDIT')]\n    #[IsGranted('edit', subject: 'comment')]\n    public function __invoke(\n        #[MapEntity(id: 'comment_id')]\n        EntryComment $comment,\n        EntryCommentManager $manager,\n        EntryCommentFactory $factory,\n        ValidatorInterface $validator,\n        RateLimiterFactoryInterface $apiUpdateLimiter,\n        SymfonySecurity $security,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiUpdateLimiter);\n\n        if (!$this->isGranted('create_content', $comment->magazine)) {\n            throw new AccessDeniedHttpException();\n        }\n        $dto = $this->deserializeComment($factory->createDto($comment));\n\n        $errors = $validator->validate($dto);\n        if (\\count($errors) > 0) {\n            throw new BadRequestHttpException((string) $errors);\n        }\n\n        $comment = $manager->edit($comment, $dto, $this->getUserOrThrow());\n\n        return new JsonResponse(\n            $this->serializeCommentTree($comment, new EntryCommentPageView(0, $security)),\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Entry/Comments/EntryCommentsVoteApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Entry\\Comments;\n\nuse App\\Controller\\Api\\Entry\\EntriesBaseApi;\nuse App\\DTO\\EntryCommentResponseDto;\nuse App\\Entity\\Contracts\\VotableInterface;\nuse App\\Entity\\EntryComment;\nuse App\\Factory\\EntryCommentFactory;\nuse App\\Service\\SettingsManager;\nuse App\\Service\\VoteManager;\nuse App\\Utils\\DownvotesMode;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse Nelmio\\ApiDocBundle\\Attribute\\Security;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpKernel\\Exception\\BadRequestHttpException;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass EntryCommentsVoteApi extends EntriesBaseApi\n{\n    // TODO: Bots should not be able to vote\n    //       *sad beep boop*\n    #[OA\\Response(\n        response: 200,\n        description: 'Comment vote changed',\n        content: new Model(type: EntryCommentResponseDto::class),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 400,\n        description: 'Vote choice was not valid',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\BadRequestErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Comment not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'comment_id',\n        in: 'path',\n        description: 'The comment to vote upon',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Parameter(\n        name: 'choice',\n        in: 'path',\n        description: 'The user\\'s voting choice. 0 clears the user\\'s vote.',\n        schema: new OA\\Schema(type: 'integer', enum: VotableInterface::VOTE_CHOICES),\n    )]\n    #[OA\\Tag(name: 'entry_comment')]\n    #[Security(name: 'oauth2', scopes: ['entry_comment:vote'])]\n    #[IsGranted('ROLE_OAUTH2_ENTRY_COMMENT:VOTE')]\n    public function __invoke(\n        #[MapEntity(id: 'comment_id')]\n        EntryComment $comment,\n        int $choice,\n        VoteManager $manager,\n        EntryCommentFactory $factory,\n        RateLimiterFactoryInterface $apiVoteLimiter,\n        SettingsManager $settingsManager,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiVoteLimiter);\n\n        if (!\\in_array($choice, VotableInterface::VOTE_CHOICES)) {\n            throw new BadRequestHttpException('Vote must be either -1, 0, or 1');\n        }\n\n        if (DownvotesMode::Disabled === $settingsManager->getDownvotesMode() && VotableInterface::VOTE_DOWN === $choice) {\n            throw new BadRequestHttpException('Downvotes are disabled!');\n        }\n\n        // Rate limiting handled above\n        $manager->vote($choice, $comment, $this->getUserOrThrow(), rateLimit: false);\n\n        return new JsonResponse(\n            $this->serializeEntryComment($factory->createDto($comment), $this->tagLinkRepository->getTagsOfContent($comment)),\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Entry/Comments/Moderate/EntryCommentsSetAdultApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Entry\\Comments\\Moderate;\n\nuse App\\Controller\\Api\\Entry\\EntriesBaseApi;\nuse App\\DTO\\EntryCommentResponseDto;\nuse App\\Entity\\EntryComment;\nuse App\\Factory\\EntryCommentFactory;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse Nelmio\\ApiDocBundle\\Attribute\\Security;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass EntryCommentsSetAdultApi extends EntriesBaseApi\n{\n    #[OA\\Response(\n        response: 200,\n        description: 'Comment isAdult status set',\n        content: new Model(type: EntryCommentResponseDto::class),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'You are not authorized to moderate this comment',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Comment not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'comment_id',\n        in: 'path',\n        description: 'The comment to set adult status on',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Parameter(\n        name: 'adult',\n        in: 'path',\n        description: 'new isAdult status',\n        schema: new OA\\Schema(type: 'boolean', default: true),\n    )]\n    #[OA\\Tag(name: 'moderation/entry_comment')]\n    #[Security(name: 'oauth2', scopes: ['moderate:entry_comment:set_adult'])]\n    #[IsGranted('ROLE_OAUTH2_MODERATE:ENTRY_COMMENT:SET_ADULT')]\n    #[IsGranted('moderate', subject: 'comment')]\n    public function __invoke(\n        #[MapEntity(id: 'comment_id')]\n        EntryComment $comment,\n        EntryCommentFactory $factory,\n        EntityManagerInterface $manager,\n        RateLimiterFactoryInterface $apiModerateLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiModerateLimiter);\n\n        $request = $this->request->getCurrentRequest();\n        // Returns true for \"1\", \"true\", \"on\" and \"yes\". Returns false otherwise.\n        $comment->isAdult = filter_var($request->get('adult', 'true'), FILTER_VALIDATE_BOOL);\n\n        $manager->flush();\n\n        return new JsonResponse(\n            $this->serializeEntryComment($factory->createDto($comment), $this->tagLinkRepository->getTagsOfContent($comment)),\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Entry/Comments/Moderate/EntryCommentsSetLanguageApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Entry\\Comments\\Moderate;\n\nuse App\\Controller\\Api\\Entry\\EntriesBaseApi;\nuse App\\DTO\\EntryCommentResponseDto;\nuse App\\Entity\\EntryComment;\nuse App\\Factory\\EntryCommentFactory;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse Nelmio\\ApiDocBundle\\Attribute\\Security;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpKernel\\Exception\\BadRequestHttpException;\nuse Symfony\\Component\\Intl\\Languages;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass EntryCommentsSetLanguageApi extends EntriesBaseApi\n{\n    #[OA\\Response(\n        response: 200,\n        description: 'Comment language changed',\n        content: new Model(type: EntryCommentResponseDto::class),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 400,\n        description: 'Given language is not valid',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\BadRequestErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'You are not authorized to moderate this comment',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Comment not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'comment_id',\n        in: 'path',\n        description: 'The comment to change language of',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Parameter(\n        name: 'lang',\n        in: 'path',\n        description: 'new language',\n        schema: new OA\\Schema(type: 'string', minLength: 2, maxLength: 3),\n    )]\n    #[OA\\Tag(name: 'moderation/entry_comment')]\n    #[Security(name: 'oauth2', scopes: ['moderate:entry_comment:language'])]\n    #[IsGranted('ROLE_OAUTH2_MODERATE:ENTRY_COMMENT:LANGUAGE')]\n    #[IsGranted('moderate', subject: 'comment')]\n    public function __invoke(\n        #[MapEntity(id: 'comment_id')]\n        EntryComment $comment,\n        EntryCommentFactory $factory,\n        EntityManagerInterface $manager,\n        RateLimiterFactoryInterface $apiModerateLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiModerateLimiter);\n\n        $request = $this->request->getCurrentRequest();\n        $newLang = $request->get('lang', '');\n\n        $valid = false !== array_search($newLang, Languages::getLanguageCodes());\n\n        if (!$valid) {\n            throw new BadRequestHttpException('The given language is not valid!');\n        }\n\n        $comment->lang = $newLang;\n\n        $manager->flush();\n\n        return new JsonResponse(\n            $this->serializeEntryComment($factory->createDto($comment), $this->tagLinkRepository->getTagsOfContent($comment)),\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Entry/Comments/Moderate/EntryCommentsTrashApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Entry\\Comments\\Moderate;\n\nuse App\\Controller\\Api\\Entry\\EntriesBaseApi;\nuse App\\DTO\\EntryCommentResponseDto;\nuse App\\Entity\\Contracts\\VisibilityInterface;\nuse App\\Entity\\EntryComment;\nuse App\\Factory\\EntryCommentFactory;\nuse App\\Service\\EntryCommentManager;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse Nelmio\\ApiDocBundle\\Attribute\\Security;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpKernel\\Exception\\BadRequestHttpException;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass EntryCommentsTrashApi extends EntriesBaseApi\n{\n    #[OA\\Response(\n        response: 200,\n        description: 'Comment trashed',\n        content: new Model(type: EntryCommentResponseDto::class),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'You are not authorized to trash this comment',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Comment not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'comment_id',\n        in: 'path',\n        description: 'The comment to trash',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Tag(name: 'moderation/entry_comment')]\n    #[Security(name: 'oauth2', scopes: ['moderate:entry_comment:trash'])]\n    #[IsGranted('ROLE_OAUTH2_MODERATE:ENTRY_COMMENT:TRASH')]\n    #[IsGranted('moderate', subject: 'comment')]\n    public function trash(\n        #[MapEntity(id: 'comment_id')]\n        EntryComment $comment,\n        EntryCommentManager $manager,\n        EntryCommentFactory $factory,\n        RateLimiterFactoryInterface $apiModerateLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiModerateLimiter);\n\n        $moderator = $this->getUserOrThrow();\n\n        $manager->trash($moderator, $comment);\n\n        // Force response to have all fields visible\n        $visibility = $comment->visibility;\n        $comment->visibility = VisibilityInterface::VISIBILITY_VISIBLE;\n        $response = $this->serializeEntryComment($factory->createDto($comment), $this->tagLinkRepository->getTagsOfContent($comment))->jsonSerialize();\n        $response['visibility'] = $visibility;\n\n        return new JsonResponse(\n            $response,\n            headers: $headers\n        );\n    }\n\n    #[OA\\Response(\n        response: 200,\n        description: 'Comment restored',\n        content: new Model(type: EntryCommentResponseDto::class),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 400,\n        description: 'The comment was not in the trashed state',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\BadRequestErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'You are not authorized to restore this comment',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Comment not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'comment_id',\n        in: 'path',\n        description: 'The comment to restore',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Tag(name: 'moderation/entry_comment')]\n    #[Security(name: 'oauth2', scopes: ['moderate:entry_comment:trash'])]\n    #[IsGranted('ROLE_OAUTH2_MODERATE:ENTRY_COMMENT:TRASH')]\n    #[IsGranted('moderate', subject: 'comment')]\n    public function restore(\n        #[MapEntity(id: 'comment_id')]\n        EntryComment $comment,\n        EntryCommentManager $manager,\n        EntryCommentFactory $factory,\n        RateLimiterFactoryInterface $apiModerateLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiModerateLimiter);\n\n        $moderator = $this->getUserOrThrow();\n\n        try {\n            $manager->restore($moderator, $comment);\n        } catch (\\Exception $e) {\n            throw new BadRequestHttpException('The comment cannot be restored because it was not trashed!');\n        }\n\n        return new JsonResponse(\n            $this->serializeEntryComment($factory->createDto($comment), $this->tagLinkRepository->getTagsOfContent($comment)),\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Entry/Comments/UserEntryCommentsRetrieveApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Entry\\Comments;\n\nuse App\\Controller\\Api\\Entry\\EntriesBaseApi;\nuse App\\Controller\\Traits\\PrivateContentTrait;\nuse App\\DTO\\EntryCommentResponseDto;\nuse App\\Entity\\User;\nuse App\\PageView\\EntryCommentPageView;\nuse App\\Repository\\Criteria;\nuse App\\Repository\\EntryCommentRepository;\nuse App\\Repository\\EntryRepository;\nuse App\\Schema\\PaginationSchema;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Bundle\\SecurityBundle\\Security;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpFoundation\\RequestStack;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\n\nclass UserEntryCommentsRetrieveApi extends EntriesBaseApi\n{\n    use PrivateContentTrait;\n\n    #[OA\\Response(\n        response: 200,\n        description: 'Returns a paginated list of comments from a specific user',\n        content: new OA\\JsonContent(\n            type: 'object',\n            properties: [\n                new OA\\Property(\n                    property: 'items',\n                    type: 'array',\n                    items: new OA\\Items(ref: new Model(type: EntryCommentResponseDto::class))\n                ),\n                new OA\\Property(\n                    property: 'pagination',\n                    ref: new Model(type: PaginationSchema::class)\n                ),\n            ]\n        ),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'user not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'user_id',\n        in: 'path',\n        description: 'The user whose comments should be retrieved',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Parameter(\n        name: 'sort',\n        in: 'query',\n        description: 'The sorting method to use during comment fetch',\n        schema: new OA\\Schema(\n            type: 'string',\n            default: EntryCommentPageView::SORT_DEFAULT,\n            enum: EntryCommentPageView::SORT_OPTIONS\n        )\n    )]\n    #[OA\\Parameter(\n        name: 'time',\n        in: 'query',\n        description: 'The maximum age of retrieved entries',\n        schema: new OA\\Schema(\n            type: 'string',\n            default: Criteria::TIME_ALL,\n            enum: Criteria::TIME_ROUTES_EN\n        )\n    )]\n    #[OA\\Parameter(\n        name: 'p',\n        description: 'Page of comments to retrieve',\n        in: 'query',\n        schema: new OA\\Schema(type: 'integer', default: 1, minimum: 1)\n    )]\n    #[OA\\Parameter(\n        name: 'perPage',\n        description: 'Number of comments to retrieve per page',\n        in: 'query',\n        schema: new OA\\Schema(type: 'integer', default: EntryRepository::PER_PAGE, minimum: self::MIN_PER_PAGE, maximum: self::MAX_PER_PAGE)\n    )]\n    #[OA\\Parameter(\n        name: 'd',\n        description: 'Depth of comment children to retrieve',\n        in: 'query',\n        schema: new OA\\Schema(type: 'integer', default: self::DEPTH, minimum: self::MIN_DEPTH, maximum: self::MAX_DEPTH)\n    )]\n    #[OA\\Parameter(\n        name: 'lang[]',\n        description: 'Language(s) of entries to return',\n        in: 'query',\n        explode: true,\n        allowReserved: true,\n        schema: new OA\\Schema(\n            type: 'array',\n            items: new OA\\Items(type: 'string', default: null, minLength: 2, maxLength: 3)\n        )\n    )]\n    #[OA\\Parameter(\n        name: 'usePreferredLangs',\n        description: 'Filter by a user\\'s preferred languages? (Requires authentication and takes precedence over lang[])',\n        in: 'query',\n        schema: new OA\\Schema(type: 'boolean', default: false),\n    )]\n    #[OA\\Tag(name: 'user')]\n    public function __invoke(\n        #[MapEntity(id: 'user_id')]\n        User $user,\n        EntryCommentRepository $repository,\n        RequestStack $request,\n        RateLimiterFactoryInterface $apiReadLimiter,\n        RateLimiterFactoryInterface $anonymousApiReadLimiter,\n        Security $security,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter);\n\n        $criteria = new EntryCommentPageView((int) $request->getCurrentRequest()->get('p', 1), $security);\n        $criteria->sortOption = $request->getCurrentRequest()->get('sort', Criteria::SORT_HOT);\n        $criteria->time = $criteria->resolveTime(\n            $request->getCurrentRequest()->get('time', Criteria::TIME_ALL)\n        );\n        $this->handleLanguageCriteria($criteria);\n        $criteria->perPage = self::constrainPerPage($request->getCurrentRequest()->get('perPage', EntryRepository::PER_PAGE));\n        $criteria->user = $user;\n        $criteria->onlyParents = false;\n\n        $comments = $repository->findByCriteria($criteria);\n\n        $dtos = [];\n        foreach ($comments->getCurrentPageResults() as $value) {\n            try {\n                $this->handlePrivateContent($value);\n                $dtos[] = $this->serializeCommentTree($value, $criteria);\n            } catch (\\Exception $e) {\n                continue;\n            }\n        }\n\n        return new JsonResponse(\n            $this->serializePaginated($dtos, $comments),\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Entry/DomainEntriesRetrieveApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Entry;\n\nuse App\\Controller\\Traits\\PrivateContentTrait;\nuse App\\DTO\\EntryResponseDto;\nuse App\\Entity\\Domain;\nuse App\\Entity\\Entry;\nuse App\\Entity\\User;\nuse App\\Factory\\EntryFactory;\nuse App\\PageView\\EntryPageView;\nuse App\\Repository\\ContentRepository;\nuse App\\Repository\\Criteria;\nuse App\\Repository\\EntryRepository;\nuse App\\Schema\\PaginationSchema;\nuse App\\Utils\\SqlHelpers;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Bundle\\SecurityBundle\\Security;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpFoundation\\RequestStack;\nuse Symfony\\Component\\HttpKernel\\Attribute\\MapQueryParameter;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\n\nclass DomainEntriesRetrieveApi extends EntriesBaseApi\n{\n    use PrivateContentTrait;\n\n    #[OA\\Response(\n        response: 200,\n        description: 'Returns a paginated list of entries from a specific domain',\n        content: new OA\\JsonContent(\n            type: 'object',\n            properties: [\n                new OA\\Property(\n                    property: 'items',\n                    type: 'array',\n                    items: new OA\\Items(ref: new Model(type: EntryResponseDto::class))\n                ),\n                new OA\\Property(\n                    property: 'pagination',\n                    ref: new Model(type: PaginationSchema::class)\n                ),\n            ]\n        ),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Domain not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'domain_id',\n        in: 'path',\n        description: 'The domain to retrieve entries from',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Parameter(\n        name: 'sort',\n        in: 'query',\n        description: 'The sorting method to use during entry fetch',\n        schema: new OA\\Schema(\n            default: Criteria::SORT_DEFAULT,\n            enum: Criteria::SORT_OPTIONS\n        )\n    )]\n    #[OA\\Parameter(\n        name: 'time',\n        in: 'query',\n        description: 'The maximum age of retrieved entries',\n        schema: new OA\\Schema(\n            default: Criteria::TIME_ALL,\n            enum: Criteria::TIME_ROUTES_EN\n        )\n    )]\n    #[OA\\Parameter(\n        name: 'p',\n        description: 'Page of entries to retrieve',\n        in: 'query',\n        schema: new OA\\Schema(type: 'integer', default: 1, minimum: 1)\n    )]\n    #[OA\\Parameter(\n        name: 'perPage',\n        description: 'Number of entries to retrieve per page',\n        in: 'query',\n        schema: new OA\\Schema(type: 'integer', default: EntryRepository::PER_PAGE, minimum: self::MIN_PER_PAGE, maximum: self::MAX_PER_PAGE)\n    )]\n    #[OA\\Parameter(\n        name: 'lang[]',\n        description: 'Language(s) of entries to return',\n        in: 'query',\n        explode: true,\n        allowReserved: true,\n        schema: new OA\\Schema(\n            type: 'array',\n            items: new OA\\Items(type: 'string', default: null, minLength: 2, maxLength: 3)\n        )\n    )]\n    #[OA\\Parameter(\n        name: 'usePreferredLangs',\n        description: 'Filter by a user\\'s preferred languages? (Requires authentication and takes precedence over lang[])',\n        in: 'query',\n        schema: new OA\\Schema(type: 'boolean', default: false),\n    )]\n    #[OA\\Tag(name: 'domain')]\n    public function __invoke(\n        #[MapEntity(id: 'domain_id')]\n        Domain $domain,\n        ContentRepository $repository,\n        EntryFactory $factory,\n        RequestStack $request,\n        RateLimiterFactoryInterface $apiReadLimiter,\n        RateLimiterFactoryInterface $anonymousApiReadLimiter,\n        Security $security,\n        SqlHelpers $sqlHelpers,\n        #[MapQueryParameter] ?int $p,\n        #[MapQueryParameter] ?int $perPage,\n        #[MapQueryParameter] ?string $sort,\n        #[MapQueryParameter] ?string $time,\n        #[MapQueryParameter] ?string $federation,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter);\n\n        $criteria = new EntryPageView($p ?? 1, $security);\n        $criteria->sortOption = $sort ?? Criteria::SORT_HOT;\n        $criteria->time = $criteria->resolveTime($time ?? Criteria::TIME_ALL);\n        $this->handleLanguageCriteria($criteria);\n        $user = $security->getUser();\n        if ($user instanceof User) {\n            $criteria->fetchCachedItems($sqlHelpers, $user);\n        }\n\n        $criteria->perPage = self::constrainPerPage($perPage ?? ContentRepository::PER_PAGE);\n\n        $criteria->domain = $domain->name;\n\n        $entries = $repository->findByCriteria($criteria);\n\n        $dtos = [];\n        foreach ($entries->getCurrentPageResults() as $value) {\n            try {\n                \\assert($value instanceof Entry);\n                $this->handlePrivateContent($value);\n                $dtos[] = $this->serializeEntry($factory->createDto($value), $this->tagLinkRepository->getTagsOfContent($value));\n            } catch (\\Exception $e) {\n                continue;\n            }\n        }\n\n        return new JsonResponse(\n            $this->serializePaginated($dtos, $entries),\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Entry/EntriesActivityApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Entry;\n\nuse App\\Controller\\Traits\\PrivateContentTrait;\nuse App\\DTO\\ActivitiesResponseDto;\nuse App\\Entity\\Entry;\nuse App\\Factory\\ContentActivityDtoFactory;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\n\nclass EntriesActivityApi extends EntriesBaseApi\n{\n    use PrivateContentTrait;\n\n    #[OA\\Response(\n        response: 200,\n        description: 'Vote activity of the entry',\n        content: new Model(type: ActivitiesResponseDto::class),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'You are not authorized to access this entry',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Entry not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'entry_id',\n        in: 'path',\n        description: 'The entry to query',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Tag(name: 'entry')]\n    public function __invoke(\n        #[MapEntity(id: 'entry_id')]\n        Entry $entry,\n        ContentActivityDtoFactory $dtoFactory,\n        RateLimiterFactoryInterface $apiReadLimiter,\n        RateLimiterFactoryInterface $anonymousApiReadLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter);\n\n        $this->handlePrivateContent($entry);\n\n        $dto = $dtoFactory->createActivitiesDto($entry);\n\n        return new JsonResponse(\n            $dto,\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Entry/EntriesBaseApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Entry;\n\nuse App\\Controller\\Api\\BaseApi;\nuse App\\DTO\\EntryCommentDto;\nuse App\\DTO\\EntryCommentRequestDto;\nuse App\\DTO\\EntryDto;\nuse App\\DTO\\EntryRequestDto;\nuse App\\DTO\\ImageDto;\nuse App\\Entity\\Entry;\nuse App\\Entity\\EntryComment;\nuse App\\Entity\\Magazine;\nuse App\\Factory\\EntryCommentFactory;\nuse App\\PageView\\EntryCommentPageView;\nuse App\\Service\\EntryManager;\nuse Symfony\\Component\\HttpKernel\\Exception\\AccessDeniedHttpException;\nuse Symfony\\Component\\HttpKernel\\Exception\\BadRequestHttpException;\nuse Symfony\\Component\\HttpKernel\\Exception\\NotFoundHttpException;\nuse Symfony\\Contracts\\Service\\Attribute\\Required;\n\nclass EntriesBaseApi extends BaseApi\n{\n    private EntryCommentFactory $commentsFactory;\n\n    #[Required]\n    public function setCommentsFactory(EntryCommentFactory $commentsFactory): void\n    {\n        $this->commentsFactory = $commentsFactory;\n    }\n\n    /**\n     * Deserialize an entry from JSON.\n     *\n     * @param ?EntryDto $dto The EntryDto to modify with new values (default: null to create a new EntryDto)\n     *\n     * @return EntryDto An entry with only certain fields allowed to be modified by the user\n     *\n     * Modifies:\n     *  * title\n     *  * body\n     *  * tags\n     *  * isAdult\n     *  * isOc\n     *  * lang\n     *  * imageAlt\n     *  * imageUrl\n     */\n    protected function deserializeEntry(?EntryDto $dto = null, array $context = []): EntryDto\n    {\n        $dto = $dto ? $dto : new EntryDto();\n        $deserialized = $this->serializer->deserialize($this->request->getCurrentRequest()->getContent(), EntryRequestDto::class, 'json', $context);\n        \\assert($deserialized instanceof EntryRequestDto);\n\n        $dto = $deserialized->mergeIntoDto($dto, $this->settingsManager);\n\n        $dto->ip = $this->ipResolver->resolve();\n\n        return $dto;\n    }\n\n    protected function deserializeEntryFromForm(): EntryRequestDto\n    {\n        $request = $this->request->getCurrentRequest();\n        $deserialized = new EntryRequestDto();\n        $deserialized->title = $request->get('title');\n        $deserialized->url = $request->get('url');\n        $deserialized->body = $request->get('body');\n        $deserialized->tags = $request->get('tags');\n        $deserialized->body = $request->get('body');\n        // TODO: Support badges whenever/however they're implemented\n        // $deserialized->badges = $request->get('badges');\n        $deserialized->isOc = filter_var($request->get('isOc'), FILTER_VALIDATE_BOOL);\n        $deserialized->lang = $request->get('lang');\n        $deserialized->isAdult = filter_var($request->get('isAdult'), FILTER_VALIDATE_BOOL);\n\n        return $deserialized;\n    }\n\n    /**\n     * Deserialize a comment from JSON.\n     *\n     * @param ?EntryCommentDto $dto The EntryCommentDto to modify with new values (default: null to create a new EntryCommentDto)\n     *\n     * @return EntryCommentDto A comment with only certain fields allowed to be modified by the user\n     *\n     * Modifies:\n     *  * body\n     *  * isAdult\n     *  * lang\n     *  * imageAlt (currently not working to modify the image)\n     *  * imageUrl (currently not working to modify the image)\n     */\n    protected function deserializeComment(?EntryCommentDto $dto = null): EntryCommentDto\n    {\n        $dto = $dto ? $dto : new EntryCommentDto();\n\n        /**\n         * @var EntryCommentRequestDto $deserialized\n         */\n        $deserialized = $this->serializer->deserialize($this->request->getCurrentRequest()->getContent(), EntryCommentRequestDto::class, 'json', [\n            'groups' => [\n                'common',\n                'comment',\n                'no-upload',\n            ],\n        ]);\n\n        $dto->ip = $this->ipResolver->resolve();\n\n        return $deserialized->mergeIntoDto($dto, $this->settingsManager);\n    }\n\n    protected function deserializeCommentFromForm(?EntryCommentDto $dto = null): EntryCommentDto\n    {\n        $request = $this->request->getCurrentRequest();\n        $dto = $dto ? $dto : new EntryCommentDto();\n        $deserialized = new EntryCommentRequestDto();\n\n        $deserialized->body = $request->get('body');\n        $deserialized->lang = $request->get('lang');\n\n        $dto->ip = $this->ipResolver->resolve();\n\n        return $deserialized->mergeIntoDto($dto, $this->settingsManager);\n    }\n\n    /**\n     * Serialize a comment tree to JSON.\n     *\n     * @param ?EntryComment $comment The root comment to base the tree on\n     * @param ?int          $depth   how many levels of children to include. If null (default), retrieves depth from query parameter 'd'.\n     *\n     * @return array An associative array representation of the comment's hierarchy, to be used as JSON\n     */\n    protected function serializeCommentTree(?EntryComment $comment, EntryCommentPageView $commentPageView, ?int $depth = null): array\n    {\n        if (null === $comment) {\n            return [];\n        }\n\n        if (null === $depth) {\n            $depth = self::constrainDepth($this->request->getCurrentRequest()->get('d', self::DEPTH));\n        }\n        $canModerate = null;\n        if ($user = $this->getUser()) {\n            $canModerate = $comment->getMagazine()->userIsModerator($user) || $user->isModerator() || $user->isAdmin();\n        }\n\n        $commentTree = $this->commentsFactory->createResponseTree($comment, $commentPageView, $depth, $canModerate);\n        $commentTree->canAuthUserModerate = $canModerate;\n\n        return $commentTree->jsonSerialize();\n    }\n\n    public function createEntry(Magazine $magazine, EntryManager $manager, array $context, ?ImageDto $image = null): Entry\n    {\n        $dto = new EntryDto();\n        $dto->magazine = $magazine;\n        if (null !== $image) {\n            $dto->image = $image;\n        }\n\n        if (null === $dto->magazine) {\n            throw new NotFoundHttpException('Magazine not found');\n        }\n\n        $dto = $this->deserializeEntry($dto, $context);\n\n        if (!$this->isGranted('create_content', $dto->magazine)) {\n            throw new AccessDeniedHttpException();\n        }\n\n        $errors = $this->validator->validate($dto);\n        if (\\count($errors) > 0) {\n            throw new BadRequestHttpException((string) $errors);\n        }\n\n        return $manager->create($dto, $this->getUserOrThrow());\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Entry/EntriesDeleteApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Entry;\n\nuse App\\Entity\\Entry;\nuse App\\Service\\EntryManager;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse Nelmio\\ApiDocBundle\\Attribute\\Security;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpKernel\\Exception\\AccessDeniedHttpException;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass EntriesDeleteApi extends EntriesBaseApi\n{\n    #[OA\\Response(\n        response: 204,\n        description: 'Entry deleted',\n        content: null,\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'You are not authorized to delete this entry',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Entry not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'entry_id',\n        in: 'path',\n        description: 'The entry to delete',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Tag(name: 'entry')]\n    #[Security(name: 'oauth2', scopes: ['entry:delete'])]\n    #[IsGranted('ROLE_OAUTH2_ENTRY:DELETE')]\n    #[IsGranted('delete', subject: 'entry')]\n    public function __invoke(\n        #[MapEntity(id: 'entry_id')]\n        Entry $entry,\n        EntryManager $manager,\n        RateLimiterFactoryInterface $apiDeleteLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiDeleteLimiter);\n\n        if ($entry->user->getId() !== $this->getUserOrThrow()->getId()) {\n            throw new AccessDeniedHttpException();\n        }\n\n        $manager->delete($this->getUserOrThrow(), $entry);\n\n        return new JsonResponse(status: 204, headers: $headers);\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Entry/EntriesFavouriteApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Entry;\n\nuse App\\DTO\\EntryResponseDto;\nuse App\\Entity\\Entry;\nuse App\\Factory\\EntryFactory;\nuse App\\Service\\FavouriteManager;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse Nelmio\\ApiDocBundle\\Attribute\\Security;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass EntriesFavouriteApi extends EntriesBaseApi\n{\n    #[OA\\Response(\n        response: 200,\n        description: 'Entry favourite status toggled',\n        content: new Model(type: EntryResponseDto::class),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Entry not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'entry_id',\n        in: 'path',\n        description: 'The entry to favourite',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Tag(name: 'entry')]\n    #[Security(name: 'oauth2', scopes: ['entry:vote'])]\n    #[IsGranted('ROLE_OAUTH2_ENTRY:VOTE')]\n    public function __invoke(\n        #[MapEntity(id: 'entry_id')]\n        Entry $entry,\n        FavouriteManager $manager,\n        EntryFactory $factory,\n        RateLimiterFactoryInterface $apiVoteLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiVoteLimiter);\n\n        $manager->toggle($this->getUserOrThrow(), $entry);\n\n        return new JsonResponse(\n            $this->serializeEntry($factory->createDto($entry), $this->tagLinkRepository->getTagsOfContent($entry), $this->entryRepository->findCross($entry)),\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Entry/EntriesReportApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Entry;\n\nuse App\\Controller\\Traits\\PrivateContentTrait;\nuse App\\DTO\\ReportRequestDto;\nuse App\\Entity\\Entry;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse Nelmio\\ApiDocBundle\\Attribute\\Security;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass EntriesReportApi extends EntriesBaseApi\n{\n    use PrivateContentTrait;\n\n    #[OA\\Response(\n        response: 204,\n        description: 'Report created',\n        content: null,\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'You have not been authorized to report this entry',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Entry not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'entry_id',\n        in: 'path',\n        description: 'The entry to report',\n        schema: new OA\\Schema(type: 'integer')\n    )]\n    #[OA\\RequestBody(content: new Model(type: ReportRequestDto::class))]\n    #[OA\\Tag(name: 'entry')]\n    #[Security(name: 'oauth2', scopes: ['entry:report'])]\n    #[IsGranted('ROLE_OAUTH2_ENTRY:REPORT')]\n    public function __invoke(\n        #[MapEntity(id: 'entry_id')]\n        Entry $entry,\n        RateLimiterFactoryInterface $apiReportLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiReportLimiter);\n\n        $this->reportContent($entry);\n\n        return new JsonResponse(\n            status: 204,\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Entry/EntriesRetrieveApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Entry;\n\nuse App\\Controller\\Traits\\PrivateContentTrait;\nuse App\\DTO\\EntryResponseDto;\nuse App\\Entity\\Entry;\nuse App\\Entity\\User;\nuse App\\Event\\Entry\\EntryHasBeenSeenEvent;\nuse App\\Factory\\EntryFactory;\nuse App\\PageView\\EntryPageView;\nuse App\\Repository\\ContentRepository;\nuse App\\Repository\\Criteria;\nuse App\\Repository\\EntryRepository;\nuse App\\Schema\\PaginationSchema;\nuse App\\Utils\\SqlHelpers;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse Nelmio\\ApiDocBundle\\Attribute\\Security;\nuse OpenApi\\Attributes as OA;\nuse Psr\\EventDispatcher\\EventDispatcherInterface;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Bundle\\SecurityBundle\\Security as SymfonySecurity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpKernel\\Attribute\\MapQueryParameter;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Security\\Core\\Exception\\AccessDeniedException;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass EntriesRetrieveApi extends EntriesBaseApi\n{\n    use PrivateContentTrait;\n\n    #[OA\\Response(\n        response: 200,\n        description: 'Returns the Entry',\n        content: new Model(type: EntryResponseDto::class),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Entry not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'entry_id',\n        in: 'path',\n        description: 'The entry to retrieve',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Tag(name: 'entry')]\n    public function __invoke(\n        #[MapEntity(id: 'entry_id')]\n        Entry $entry,\n        EntryFactory $factory,\n        EventDispatcherInterface $dispatcher,\n        RateLimiterFactoryInterface $apiReadLimiter,\n        RateLimiterFactoryInterface $anonymousApiReadLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter);\n\n        $this->handlePrivateContent($entry);\n\n        $dispatcher->dispatch(new EntryHasBeenSeenEvent($entry));\n\n        $dto = $factory->createDto($entry);\n\n        return new JsonResponse(\n            $this->serializeEntry($dto, $this->tagLinkRepository->getTagsOfContent($entry), $this->entryRepository->findCross($entry)),\n            headers: $headers\n        );\n    }\n\n    #[OA\\Response(\n        response: 200,\n        description: 'Returns a list of Entries',\n        content: new OA\\JsonContent(\n            type: 'object',\n            properties: [\n                new OA\\Property(\n                    property: 'items',\n                    type: 'array',\n                    items: new OA\\Items(ref: new Model(type: EntryResponseDto::class))\n                ),\n                new OA\\Property(\n                    property: 'pagination',\n                    ref: new Model(type: PaginationSchema::class)\n                ),\n            ]\n        ),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'sort',\n        in: 'query',\n        description: 'The sorting method to use during entry fetch',\n        schema: new OA\\Schema(\n            default: Criteria::SORT_DEFAULT,\n            enum: Criteria::SORT_OPTIONS\n        )\n    )]\n    #[OA\\Parameter(\n        name: 'time',\n        in: 'query',\n        description: 'The maximum age of retrieved entries',\n        schema: new OA\\Schema(\n            default: Criteria::TIME_ALL,\n            enum: Criteria::TIME_ROUTES_EN\n        )\n    )]\n    #[OA\\Parameter(\n        name: 'p',\n        description: 'Page of entries to retrieve',\n        in: 'query',\n        schema: new OA\\Schema(type: 'integer', default: 1, minimum: 1)\n    )]\n    #[OA\\Parameter(\n        name: 'perPage',\n        description: 'Number of entries to retrieve per page',\n        in: 'query',\n        schema: new OA\\Schema(type: 'integer', default: EntryRepository::PER_PAGE, minimum: self::MIN_PER_PAGE, maximum: self::MAX_PER_PAGE)\n    )]\n    #[OA\\Parameter(\n        name: 'lang[]',\n        description: 'Language(s) of entries to return',\n        in: 'query',\n        explode: true,\n        allowReserved: true,\n        schema: new OA\\Schema(\n            type: 'array',\n            items: new OA\\Items(type: 'string')\n        )\n    )]\n    #[OA\\Parameter(\n        name: 'usePreferredLangs',\n        description: 'Filter by a user\\'s preferred languages? (Requires authentication and takes precedence over lang[])',\n        in: 'query',\n        schema: new OA\\Schema(type: 'boolean', default: false),\n    )]\n    #[OA\\Parameter(\n        name: 'federation',\n        description: 'What type of federated entries to retrieve',\n        in: 'query',\n        schema: new OA\\Schema(type: 'string', default: Criteria::AP_ALL, enum: Criteria::AP_OPTIONS)\n    )]\n    #[OA\\Tag(name: 'entry')]\n    public function collection(\n        ContentRepository $repository,\n        EntryFactory $factory,\n        RateLimiterFactoryInterface $apiReadLimiter,\n        RateLimiterFactoryInterface $anonymousApiReadLimiter,\n        SymfonySecurity $security,\n        SqlHelpers $sqlHelpers,\n        #[MapQueryParameter] ?int $p,\n        #[MapQueryParameter] ?string $sort,\n        #[MapQueryParameter] ?string $time,\n        #[MapQueryParameter] ?string $federation,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter);\n\n        $criteria = new EntryPageView($p ?? 1, $security);\n        $criteria->sortOption = $sort ?? Criteria::SORT_HOT;\n        $criteria->time = $criteria->resolveTime($time ?? Criteria::TIME_ALL);\n        $criteria->setFederation($federation ?? Criteria::AP_ALL);\n        $this->handleLanguageCriteria($criteria);\n        $criteria->content = Criteria::CONTENT_THREADS;\n\n        $user = $security->getUser();\n        if ($user instanceof User) {\n            $criteria->fetchCachedItems($sqlHelpers, $user);\n        }\n\n        $entries = $repository->findByCriteria($criteria);\n\n        $dtos = [];\n        foreach ($entries->getCurrentPageResults() as $value) {\n            try {\n                \\assert($value instanceof Entry);\n                $this->handlePrivateContent($value);\n                $dtos[] = $this->serializeEntry($factory->createDto($value), $this->tagLinkRepository->getTagsOfContent($value));\n            } catch (AccessDeniedException $e) {\n                continue;\n            }\n        }\n\n        return new JsonResponse(\n            $this->serializePaginated($dtos, $entries),\n            headers: $headers\n        );\n    }\n\n    #[OA\\Response(\n        response: 200,\n        description: 'Returns a list of entries from subscribed magazines',\n        content: new OA\\JsonContent(\n            type: 'object',\n            properties: [\n                new OA\\Property(\n                    property: 'items',\n                    type: 'array',\n                    items: new OA\\Items(ref: new Model(type: EntryResponseDto::class))\n                ),\n                new OA\\Property(\n                    property: 'pagination',\n                    ref: new Model(type: PaginationSchema::class)\n                ),\n            ]\n        ),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'sort',\n        in: 'query',\n        description: 'The sorting method to use during entry fetch',\n        schema: new OA\\Schema(\n            default: Criteria::SORT_DEFAULT,\n            enum: Criteria::SORT_OPTIONS\n        )\n    )]\n    #[OA\\Parameter(\n        name: 'time',\n        in: 'query',\n        description: 'The maximum age of retrieved entries',\n        schema: new OA\\Schema(\n            default: Criteria::TIME_ALL,\n            enum: Criteria::TIME_ROUTES_EN\n        )\n    )]\n    #[OA\\Parameter(\n        name: 'p',\n        description: 'Page of entries to retrieve',\n        in: 'query',\n        schema: new OA\\Schema(type: 'integer', default: 1, minimum: 1)\n    )]\n    #[OA\\Parameter(\n        name: 'perPage',\n        description: 'Number of entries to retrieve per page',\n        in: 'query',\n        schema: new OA\\Schema(type: 'integer', default: EntryRepository::PER_PAGE, minimum: self::MIN_PER_PAGE, maximum: self::MAX_PER_PAGE)\n    )]\n    #[OA\\Parameter(\n        name: 'federation',\n        description: 'What type of federated entries to retrieve',\n        in: 'query',\n        schema: new OA\\Schema(type: 'string', default: Criteria::AP_ALL, enum: Criteria::AP_OPTIONS)\n    )]\n    #[OA\\Tag(name: 'entry')]\n    #[Security(name: 'oauth2', scopes: ['read'])]\n    #[IsGranted('ROLE_OAUTH2_READ')]\n    public function subscribed(\n        ContentRepository $repository,\n        EntryFactory $factory,\n        RateLimiterFactoryInterface $apiReadLimiter,\n        SymfonySecurity $security,\n        SqlHelpers $sqlHelpers,\n        #[MapQueryParameter] ?int $p,\n        #[MapQueryParameter] ?string $sort,\n        #[MapQueryParameter] ?string $time,\n        #[MapQueryParameter] ?string $federation,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiReadLimiter);\n\n        $criteria = new EntryPageView($p ?? 1, $security);\n        $criteria->sortOption = $sort ?? Criteria::SORT_HOT;\n        $criteria->time = $criteria->resolveTime($time ?? Criteria::TIME_ALL);\n        $criteria->setFederation($federation ?? Criteria::AP_ALL);\n\n        $criteria->subscribed = true;\n        $criteria->content = Criteria::CONTENT_THREADS;\n\n        $user = $security->getUser();\n        if ($user instanceof User) {\n            $criteria->fetchCachedItems($sqlHelpers, $user);\n        }\n\n        $entries = $repository->findByCriteria($criteria);\n\n        $dtos = [];\n        foreach ($entries->getCurrentPageResults() as $value) {\n            try {\n                \\assert($value instanceof Entry);\n                $this->handlePrivateContent($value);\n                $dtos[] = $this->serializeEntry($factory->createDto($value), $this->tagLinkRepository->getTagsOfContent($value));\n            } catch (\\Exception $e) {\n                continue;\n            }\n        }\n\n        return new JsonResponse(\n            $this->serializePaginated($dtos, $entries),\n            headers: $headers\n        );\n    }\n\n    #[OA\\Response(\n        response: 200,\n        description: 'Returns a list of entries from moderated magazines',\n        content: new OA\\JsonContent(\n            type: 'object',\n            properties: [\n                new OA\\Property(\n                    property: 'items',\n                    type: 'array',\n                    items: new OA\\Items(ref: new Model(type: EntryResponseDto::class))\n                ),\n                new OA\\Property(\n                    property: 'pagination',\n                    ref: new Model(type: PaginationSchema::class)\n                ),\n            ]\n        ),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'sort',\n        in: 'query',\n        description: 'The sorting method to use during entry fetch',\n        schema: new OA\\Schema(\n            default: Criteria::SORT_NEW,\n            enum: Criteria::SORT_OPTIONS\n        )\n    )]\n    #[OA\\Parameter(\n        name: 'time',\n        in: 'query',\n        description: 'The maximum age of retrieved entries',\n        schema: new OA\\Schema(\n            default: Criteria::TIME_ALL,\n            enum: Criteria::TIME_ROUTES_EN\n        )\n    )]\n    #[OA\\Parameter(\n        name: 'p',\n        description: 'Page of entries to retrieve',\n        in: 'query',\n        schema: new OA\\Schema(type: 'integer', default: 1, minimum: 1)\n    )]\n    #[OA\\Parameter(\n        name: 'perPage',\n        description: 'Number of entries to retrieve per page',\n        in: 'query',\n        schema: new OA\\Schema(type: 'integer', default: EntryRepository::PER_PAGE, minimum: self::MIN_PER_PAGE, maximum: self::MAX_PER_PAGE)\n    )]\n    #[OA\\Parameter(\n        name: 'federation',\n        description: 'What type of federated entries to retrieve',\n        in: 'query',\n        schema: new OA\\Schema(type: 'string', default: Criteria::AP_ALL, enum: Criteria::AP_OPTIONS)\n    )]\n    #[OA\\Tag(name: 'entry')]\n    #[Security(name: 'oauth2', scopes: ['moderate:entry'])]\n    #[IsGranted('ROLE_OAUTH2_MODERATE:ENTRY')]\n    public function moderated(\n        ContentRepository $repository,\n        EntryFactory $factory,\n        RateLimiterFactoryInterface $apiReadLimiter,\n        SymfonySecurity $security,\n        SqlHelpers $sqlHelpers,\n        #[MapQueryParameter] ?int $p,\n        #[MapQueryParameter] ?string $sort,\n        #[MapQueryParameter] ?string $time,\n        #[MapQueryParameter] ?string $federation,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiReadLimiter);\n\n        $criteria = new EntryPageView($p ?? 1, $security);\n        $criteria->sortOption = $sort ?? Criteria::SORT_HOT;\n        $criteria->time = $criteria->resolveTime($time ?? Criteria::TIME_ALL);\n        $criteria->setFederation($federation ?? Criteria::AP_ALL);\n\n        $criteria->moderated = true;\n        $criteria->content = Criteria::CONTENT_THREADS;\n\n        $user = $security->getUser();\n        if ($user instanceof User) {\n            $criteria->fetchCachedItems($sqlHelpers, $user);\n        }\n\n        $entries = $repository->findByCriteria($criteria);\n\n        $dtos = [];\n        foreach ($entries->getCurrentPageResults() as $value) {\n            try {\n                \\assert($value instanceof Entry);\n                $this->handlePrivateContent($value);\n                $dtos[] = $this->serializeEntry($factory->createDto($value), $this->tagLinkRepository->getTagsOfContent($value));\n            } catch (\\Exception $e) {\n                continue;\n            }\n        }\n\n        return new JsonResponse(\n            $this->serializePaginated($dtos, $entries),\n            headers: $headers\n        );\n    }\n\n    #[OA\\Response(\n        response: 200,\n        description: 'Returns a list of favourited entries',\n        content: new OA\\JsonContent(\n            type: 'object',\n            properties: [\n                new OA\\Property(\n                    property: 'items',\n                    type: 'array',\n                    items: new OA\\Items(ref: new Model(type: EntryResponseDto::class))\n                ),\n                new OA\\Property(\n                    property: 'pagination',\n                    ref: new Model(type: PaginationSchema::class)\n                ),\n            ]\n        ),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'sort',\n        in: 'query',\n        description: 'The sorting method to use during entry fetch',\n        schema: new OA\\Schema(\n            default: Criteria::SORT_TOP,\n            enum: Criteria::SORT_OPTIONS\n        )\n    )]\n    #[OA\\Parameter(\n        name: 'time',\n        in: 'query',\n        description: 'The maximum age of retrieved entries',\n        schema: new OA\\Schema(\n            default: Criteria::TIME_ALL,\n            enum: Criteria::TIME_ROUTES_EN\n        )\n    )]\n    #[OA\\Parameter(\n        name: 'p',\n        description: 'Page of entries to retrieve',\n        in: 'query',\n        schema: new OA\\Schema(type: 'integer', default: 1, minimum: 1)\n    )]\n    #[OA\\Parameter(\n        name: 'perPage',\n        description: 'Number of entries to retrieve per page',\n        in: 'query',\n        schema: new OA\\Schema(type: 'integer', default: EntryRepository::PER_PAGE, minimum: self::MIN_PER_PAGE, maximum: self::MAX_PER_PAGE)\n    )]\n    #[OA\\Parameter(\n        name: 'federation',\n        description: 'What type of federated entries to retrieve',\n        in: 'query',\n        schema: new OA\\Schema(type: 'string', default: Criteria::AP_ALL, enum: Criteria::AP_OPTIONS)\n    )]\n    #[OA\\Tag(name: 'entry')]\n    #[Security(name: 'oauth2', scopes: ['entry:vote'])]\n    #[IsGranted('ROLE_OAUTH2_ENTRY:VOTE')]\n    public function favourited(\n        ContentRepository $repository,\n        EntryFactory $factory,\n        RateLimiterFactoryInterface $apiReadLimiter,\n        SymfonySecurity $security,\n        SqlHelpers $sqlHelpers,\n        #[MapQueryParameter] ?int $p,\n        #[MapQueryParameter] ?string $sort,\n        #[MapQueryParameter] ?string $time,\n        #[MapQueryParameter] ?string $federation,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiReadLimiter);\n\n        $criteria = new EntryPageView($p ?? 1, $security);\n        $criteria->sortOption = $sort ?? Criteria::SORT_HOT;\n        $criteria->time = $criteria->resolveTime($time ?? Criteria::TIME_ALL);\n        $criteria->setFederation($federation ?? Criteria::AP_ALL);\n\n        $criteria->favourite = true;\n\n        $user = $security->getUser();\n        if ($user instanceof User) {\n            $criteria->fetchCachedItems($sqlHelpers, $user);\n        }\n\n        $entries = $repository->findByCriteria($criteria);\n        $criteria->content = Criteria::CONTENT_THREADS;\n\n        $dtos = [];\n        foreach ($entries->getCurrentPageResults() as $value) {\n            try {\n                \\assert($value instanceof Entry);\n                $this->handlePrivateContent($value);\n                $dtos[] = $this->serializeEntry($factory->createDto($value), $this->tagLinkRepository->getTagsOfContent($value));\n            } catch (\\Exception $e) {\n                continue;\n            }\n        }\n\n        return new JsonResponse(\n            $this->serializePaginated($dtos, $entries),\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Entry/EntriesUpdateApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Entry;\n\nuse App\\DTO\\EntryRequestDto;\nuse App\\DTO\\EntryResponseDto;\nuse App\\Entity\\Entry;\nuse App\\Service\\EntryManager;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse Nelmio\\ApiDocBundle\\Attribute\\Security;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpKernel\\Exception\\BadRequestHttpException;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\nuse Symfony\\Component\\Validator\\Validator\\ValidatorInterface;\n\nclass EntriesUpdateApi extends EntriesBaseApi\n{\n    #[OA\\Response(\n        response: 200,\n        description: 'Entry updated',\n        content: new Model(type: EntryResponseDto::class),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'You do not have permission to update this entry',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Entry not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'entry_id',\n        in: 'path',\n        description: 'The id of the entry to update',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\RequestBody(content: new Model(\n        type: EntryRequestDto::class,\n        groups: [\n            'common',\n            Entry::ENTRY_TYPE_ARTICLE,\n        ]\n    ))]\n    #[OA\\Tag(name: 'entry')]\n    #[Security(name: 'oauth2', scopes: ['entry:edit'])]\n    #[IsGranted('ROLE_OAUTH2_ENTRY:EDIT')]\n    #[IsGranted('edit', subject: 'entry')]\n    public function __invoke(\n        #[MapEntity(id: 'entry_id')]\n        Entry $entry,\n        EntryManager $manager,\n        ValidatorInterface $validator,\n        RateLimiterFactoryInterface $apiUpdateLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiUpdateLimiter);\n\n        $dto = $this->deserializeEntry($manager->createDto($entry), context: [\n            'groups' => [\n                'common',\n                Entry::ENTRY_TYPE_ARTICLE,\n            ],\n        ]);\n\n        $errors = $validator->validate($dto);\n        if (\\count($errors) > 0) {\n            throw new BadRequestHttpException((string) $errors);\n        }\n\n        $entry = $manager->edit($entry, $dto, $this->getUserOrThrow());\n\n        return new JsonResponse(\n            $this->serializeEntry($entry, $this->tagLinkRepository->getTagsOfContent($entry), $this->entryRepository->findCross($entry)),\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Entry/EntriesVoteApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Entry;\n\nuse App\\DTO\\EntryResponseDto;\nuse App\\Entity\\Contracts\\VotableInterface;\nuse App\\Entity\\Entry;\nuse App\\Factory\\EntryFactory;\nuse App\\Service\\SettingsManager;\nuse App\\Service\\VoteManager;\nuse App\\Utils\\DownvotesMode;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse Nelmio\\ApiDocBundle\\Attribute\\Security;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpKernel\\Exception\\BadRequestHttpException;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass EntriesVoteApi extends EntriesBaseApi\n{\n    // TODO: Bots should not be able to vote\n    //       *sad beep boop*\n    #[OA\\Response(\n        response: 200,\n        description: 'Entry vote changed',\n        content: new Model(type: EntryResponseDto::class),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 400,\n        description: 'Vote choice was not valid',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\BadRequestErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Entry not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'entry_id',\n        in: 'path',\n        description: 'The entry to vote upon',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Parameter(\n        name: 'choice',\n        in: 'path',\n        description: 'The user\\'s voting choice. 0 clears the user\\'s vote.',\n        schema: new OA\\Schema(type: 'integer', enum: VotableInterface::VOTE_CHOICES),\n    )]\n    #[OA\\Tag(name: 'entry')]\n    #[Security(name: 'oauth2', scopes: ['entry:vote'])]\n    #[IsGranted('ROLE_OAUTH2_ENTRY:VOTE')]\n    public function __invoke(\n        #[MapEntity(id: 'entry_id')]\n        Entry $entry,\n        int $choice,\n        VoteManager $manager,\n        EntryFactory $factory,\n        RateLimiterFactoryInterface $apiVoteLimiter,\n        SettingsManager $settingsManager,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiVoteLimiter);\n\n        if (!\\in_array($choice, VotableInterface::VOTE_CHOICES)) {\n            throw new BadRequestHttpException('Vote must be either -1, 0, or 1');\n        }\n\n        if (DownvotesMode::Disabled === $settingsManager->getDownvotesMode() && VotableInterface::VOTE_DOWN === $choice) {\n            throw new BadRequestHttpException('Downvotes are disabled!');\n        }\n\n        // Rate limiting handled above\n        $manager->vote($choice, $entry, $this->getUserOrThrow(), rateLimit: false);\n\n        return new JsonResponse(\n            $this->serializeEntry($factory->createDto($entry), $this->tagLinkRepository->getTagsOfContent($entry), $this->entryRepository->findCross($entry)),\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Entry/MagazineEntriesRetrieveApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Entry;\n\nuse App\\Controller\\Traits\\PrivateContentTrait;\nuse App\\DTO\\EntryResponseDto;\nuse App\\Entity\\Entry;\nuse App\\Entity\\Magazine;\nuse App\\Entity\\User;\nuse App\\Factory\\EntryFactory;\nuse App\\PageView\\EntryPageView;\nuse App\\Repository\\ContentRepository;\nuse App\\Repository\\Criteria;\nuse App\\Repository\\EntryRepository;\nuse App\\Schema\\PaginationSchema;\nuse App\\Utils\\SqlHelpers;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Bundle\\SecurityBundle\\Security;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpFoundation\\RequestStack;\nuse Symfony\\Component\\HttpKernel\\Attribute\\MapQueryParameter;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\n\nclass MagazineEntriesRetrieveApi extends EntriesBaseApi\n{\n    use PrivateContentTrait;\n\n    #[OA\\Response(\n        response: 200,\n        description: 'Returns a paginated list of Entries',\n        content: new OA\\JsonContent(\n            type: 'object',\n            properties: [\n                new OA\\Property(\n                    property: 'items',\n                    type: 'array',\n                    items: new OA\\Items(ref: new Model(type: EntryResponseDto::class))\n                ),\n                new OA\\Property(\n                    property: 'pagination',\n                    ref: new Model(type: PaginationSchema::class)\n                ),\n            ]\n        ),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Magazine not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'magazine_id',\n        in: 'path',\n        description: 'The magazine to retrieve entries from',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Parameter(\n        name: 'sort',\n        in: 'query',\n        description: 'The sorting method to use during entry fetch',\n        schema: new OA\\Schema(\n            default: Criteria::SORT_DEFAULT,\n            enum: Criteria::SORT_OPTIONS\n        )\n    )]\n    #[OA\\Parameter(\n        name: 'time',\n        in: 'query',\n        description: 'The maximum age of retrieved entries',\n        schema: new OA\\Schema(\n            default: Criteria::TIME_ALL,\n            enum: Criteria::TIME_ROUTES_EN\n        )\n    )]\n    #[OA\\Parameter(\n        name: 'p',\n        description: 'Page of entries to retrieve',\n        in: 'query',\n        schema: new OA\\Schema(type: 'integer', default: 1, minimum: 1)\n    )]\n    #[OA\\Parameter(\n        name: 'perPage',\n        description: 'Number of entries to retrieve per page',\n        in: 'query',\n        schema: new OA\\Schema(type: 'integer', default: EntryRepository::PER_PAGE, minimum: self::MIN_PER_PAGE, maximum: self::MAX_PER_PAGE)\n    )]\n    #[OA\\Parameter(\n        name: 'lang[]',\n        description: 'Language(s) of entries to return',\n        in: 'query',\n        explode: true,\n        allowReserved: true,\n        schema: new OA\\Schema(\n            type: 'array',\n            items: new OA\\Items(type: 'string', default: null, minLength: 2, maxLength: 3)\n        )\n    )]\n    #[OA\\Parameter(\n        name: 'usePreferredLangs',\n        description: 'Filter by a user\\'s preferred languages? (Requires authentication and takes precedence over lang[])',\n        in: 'query',\n        schema: new OA\\Schema(type: 'boolean', default: false),\n    )]\n    #[OA\\Tag(name: 'magazine')]\n    public function __invoke(\n        #[MapEntity(id: 'magazine_id')]\n        Magazine $magazine,\n        ContentRepository $repository,\n        EntryFactory $factory,\n        RequestStack $request,\n        RateLimiterFactoryInterface $apiReadLimiter,\n        RateLimiterFactoryInterface $anonymousApiReadLimiter,\n        Security $security,\n        SqlHelpers $sqlHelpers,\n        #[MapQueryParameter] ?int $p,\n        #[MapQueryParameter] ?int $perPage,\n        #[MapQueryParameter] ?string $sort,\n        #[MapQueryParameter] ?string $time,\n        #[MapQueryParameter] ?string $federation,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter);\n\n        $criteria = new EntryPageView($p ?? 1, $security);\n        $criteria->sortOption = $sort ?? Criteria::SORT_HOT;\n        $criteria->time = $criteria->resolveTime($time ?? Criteria::TIME_ALL);\n        $this->handleLanguageCriteria($criteria);\n\n        $criteria->stickiesFirst = true;\n\n        $criteria->perPage = self::constrainPerPage($perPage ?? ContentRepository::PER_PAGE);\n\n        $criteria->magazine = $magazine;\n\n        $user = $security->getUser();\n        if ($user instanceof User) {\n            $criteria->fetchCachedItems($sqlHelpers, $user);\n        }\n\n        $entries = $repository->findByCriteria($criteria);\n\n        $dtos = [];\n        foreach ($entries->getCurrentPageResults() as $value) {\n            try {\n                \\assert($value instanceof Entry);\n                $dtos[] = $this->serializeEntry($factory->createDto($value), $this->tagLinkRepository->getTagsOfContent($value));\n            } catch (\\Exception $e) {\n                continue;\n            }\n        }\n\n        return new JsonResponse(\n            $this->serializePaginated($dtos, $entries),\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Entry/MagazineEntryCreateApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Entry;\n\nuse App\\Controller\\Traits\\PrivateContentTrait;\nuse App\\DTO\\EntryDto;\nuse App\\DTO\\EntryRequestDto;\nuse App\\DTO\\EntryResponseDto;\nuse App\\DTO\\ImageUploadDto;\nuse App\\Entity\\Entry;\nuse App\\Entity\\Magazine;\nuse App\\Service\\EntryManager;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse Nelmio\\ApiDocBundle\\Attribute\\Security;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpKernel\\Exception\\AccessDeniedHttpException;\nuse Symfony\\Component\\HttpKernel\\Exception\\BadRequestHttpException;\nuse Symfony\\Component\\HttpKernel\\Exception\\NotFoundHttpException;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\nuse Symfony\\Component\\Validator\\Validator\\ValidatorInterface;\n\nclass MagazineEntryCreateApi extends EntriesBaseApi\n{\n    use PrivateContentTrait;\n\n    #[OA\\Post(deprecated: true)]\n    #[OA\\Response(\n        response: 201,\n        description: 'Returns the created Entry',\n        content: new Model(type: EntryResponseDto::class),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 400,\n        description: 'An entry must have at least one of URL, body, or image',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\BadRequestErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'Either the entry:create scope has not been granted, or the user is banned from the magazine',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Magazine not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'magazine_id',\n        in: 'path',\n        description: 'The magazine to create the entry in',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\RequestBody(content: new Model(\n        type: EntryRequestDto::class,\n        groups: [\n            Entry::ENTRY_TYPE_ARTICLE,\n            'common',\n        ]\n    ))]\n    #[OA\\Tag(name: 'magazine')]\n    #[Security(name: 'oauth2', scopes: ['entry:create'])]\n    #[IsGranted('ROLE_OAUTH2_ENTRY:CREATE')]\n    public function article(\n        #[MapEntity(id: 'magazine_id')]\n        Magazine $magazine,\n        EntryManager $manager,\n        RateLimiterFactoryInterface $apiEntryLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiEntryLimiter);\n\n        $entry = $this->createEntry($magazine, $manager, context: [\n            'groups' => [\n                Entry::ENTRY_TYPE_ARTICLE,\n                'common',\n            ],\n        ]);\n\n        return new JsonResponse(\n            $this->serializeEntry($manager->createDto($entry), $this->tagLinkRepository->getTagsOfContent($entry), $this->entryRepository->findCross($entry)),\n            status: 201,\n            headers: $headers\n        );\n    }\n\n    #[OA\\Post(deprecated: true)]\n    #[OA\\Response(\n        response: 201,\n        description: 'Returns the created Entry',\n        content: new Model(type: EntryResponseDto::class),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 400,\n        description: 'An entry must have at least one of URL, body, or image',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\BadRequestErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'Either the entry:create scope has not been granted, or the user is banned from the magazine',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Magazine not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'magazine_id',\n        in: 'path',\n        description: 'The magazine to create the entry in',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\RequestBody(content: new Model(\n        type: EntryRequestDto::class,\n        groups: [\n            Entry::ENTRY_TYPE_LINK,\n            'common',\n        ]\n    ))]\n    #[OA\\Tag(name: 'magazine')]\n    #[Security(name: 'oauth2', scopes: ['entry:create'])]\n    #[IsGranted('ROLE_OAUTH2_ENTRY:CREATE')]\n    public function link(\n        #[MapEntity(id: 'magazine_id')]\n        Magazine $magazine,\n        EntryManager $manager,\n        RateLimiterFactoryInterface $apiEntryLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiEntryLimiter);\n\n        $entry = $this->createEntry($magazine, $manager, context: [\n            'groups' => [\n                Entry::ENTRY_TYPE_LINK,\n                'common',\n            ],\n        ]);\n\n        return new JsonResponse(\n            $this->serializeEntry($manager->createDto($entry), $this->tagLinkRepository->getTagsOfContent($entry), $this->entryRepository->findCross($entry)),\n            status: 201,\n            headers: $headers\n        );\n    }\n\n    #[OA\\Post(deprecated: true)]\n    #[OA\\Response(\n        response: 201,\n        description: 'Returns the created Entry',\n        content: new Model(type: EntryResponseDto::class),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'Either the entry:create scope has not been granted, or the user is banned from the magazine',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Magazine not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'magazine_id',\n        in: 'path',\n        description: 'The magazine to create the entry in',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\RequestBody(content: new Model(\n        type: EntryRequestDto::class,\n        groups: [\n            Entry::ENTRY_TYPE_VIDEO,\n            'common',\n        ]\n    ))]\n    #[OA\\Tag(name: 'magazine')]\n    #[Security(name: 'oauth2', scopes: ['entry:create'])]\n    #[IsGranted('ROLE_OAUTH2_ENTRY:CREATE')]\n    public function video(\n        #[MapEntity(id: 'magazine_id')]\n        Magazine $magazine,\n        EntryManager $manager,\n        RateLimiterFactoryInterface $apiEntryLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiEntryLimiter);\n\n        $entry = $this->createEntry($magazine, $manager, [\n            'groups' => [\n                Entry::ENTRY_TYPE_VIDEO,\n                'common',\n            ],\n        ]);\n\n        return new JsonResponse(\n            $this->serializeEntry($manager->createDto($entry), $this->tagLinkRepository->getTagsOfContent($entry), $this->entryRepository->findCross($entry)),\n            status: 201,\n            headers: $headers\n        );\n    }\n\n    #[OA\\Post(deprecated: true)]\n    #[OA\\Response(\n        response: 201,\n        description: 'Returns the created image entry',\n        content: new Model(type: EntryResponseDto::class),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 400,\n        description: 'Image was too large, not provided, or is not an acceptable file type',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\BadRequestErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'Either the entry:create scope has not been granted, or the user is banned from the magazine',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Magazine not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'magazine_id',\n        in: 'path',\n        description: 'The magazine to create the entry in',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\RequestBody(content: new OA\\MediaType(\n        'multipart/form-data',\n        schema: new OA\\Schema(\n            ref: new Model(\n                type: EntryRequestDto::class,\n                groups: [\n                    ImageUploadDto::IMAGE_UPLOAD,\n                    Entry::ENTRY_TYPE_IMAGE,\n                    'common',\n                ]\n            )\n        )\n    ))]\n    #[OA\\Tag(name: 'magazine')]\n    #[Security(name: 'oauth2', scopes: ['entry:create'])]\n    #[IsGranted('ROLE_OAUTH2_ENTRY:CREATE')]\n    public function uploadImage(\n        #[MapEntity(id: 'magazine_id')]\n        Magazine $magazine,\n        ValidatorInterface $validator,\n        EntryManager $manager,\n        RateLimiterFactoryInterface $apiImageLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiImageLimiter);\n\n        $dto = new EntryDto();\n        $dto->magazine = $magazine;\n\n        if (null === $dto->magazine) {\n            throw new NotFoundHttpException('Magazine not found');\n        }\n\n        $deserialized = $this->deserializeEntryFromForm();\n\n        $dto = $deserialized->mergeIntoDto($dto, $this->settingsManager);\n\n        if (!$this->isGranted('create_content', $dto->magazine)) {\n            throw new AccessDeniedHttpException();\n        }\n\n        $image = $this->handleUploadedImage();\n\n        if (null !== $image) {\n            $dto->image = $this->imageFactory->createDto($image);\n        }\n\n        $errors = $validator->validate($dto);\n        if (0 < \\count($errors)) {\n            throw new BadRequestHttpException((string) $errors);\n        }\n\n        $entry = $manager->create($dto, $this->getUserOrThrow());\n\n        return new JsonResponse(\n            $this->serializeEntry($manager->createDto($entry), $this->tagLinkRepository->getTagsOfContent($entry), $this->entryRepository->findCross($entry)),\n            status: 201,\n            headers: $headers\n        );\n    }\n\n    #[OA\\Response(\n        response: 201,\n        description: 'Returns the created entry',\n        content: new Model(type: EntryResponseDto::class),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 400,\n        description: 'An entry must have at least one of URL, body, or image; Image was too large or is not an acceptable file type',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\BadRequestErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'Either the entry:create scope has not been granted, or the user is banned from the magazine',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Magazine not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'magazine_id',\n        in: 'path',\n        description: 'The magazine to create the entry in',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\RequestBody(\n        content: new OA\\MediaType(\n            mediaType: 'multipart/form-data',\n            schema: new OA\\Schema(\n                ref: new Model(\n                    type: EntryRequestDto::class,\n                    groups: [\n                        ImageUploadDto::IMAGE_UPLOAD,\n                        Entry::ENTRY_TYPE_ARTICLE,\n                        Entry::ENTRY_TYPE_LINK,\n                        Entry::ENTRY_TYPE_VIDEO,\n                        Entry::ENTRY_TYPE_IMAGE,\n                        'common',\n                    ],\n                )\n            )\n        )\n    )]\n    #[OA\\Tag(name: 'magazine')]\n    #[Security(name: 'oauth2', scopes: ['entry:create'])]\n    #[IsGranted('ROLE_OAUTH2_ENTRY:CREATE')]\n    public function entry(\n        #[MapEntity(id: 'magazine_id')]\n        ?Magazine $magazine,\n        ValidatorInterface $validator,\n        EntryManager $manager,\n        RateLimiterFactoryInterface $apiImageLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiImageLimiter);\n\n        if (null === $magazine) {\n            throw new NotFoundHttpException('Magazine not found');\n        }\n\n        if (!$this->isGranted('create_content', $magazine)) {\n            throw new AccessDeniedHttpException();\n        }\n\n        $dto = new EntryDto();\n        $dto->magazine = $magazine;\n\n        $deserialized = $this->deserializeEntryFromForm();\n        $dto = $deserialized->mergeIntoDto($dto, $this->settingsManager);\n\n        $image = $this->handleUploadedImageOptional();\n\n        if (null !== $image) {\n            $dto->image = $this->imageFactory->createDto($image);\n        }\n\n        $errors = $validator->validate($dto);\n        if (0 < \\count($errors)) {\n            throw new BadRequestHttpException((string) $errors);\n        }\n\n        $entry = $manager->create($dto, $this->getUserOrThrow());\n\n        $retDto = $manager->createDto($entry);\n        $tags = $this->tagLinkRepository->getTagsOfContent($entry);\n        $crossposts = $this->entryRepository->findCross($entry);\n\n        return new JsonResponse(\n            $this->serializeEntry($retDto, $tags, $crossposts),\n            status: 201,\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Entry/Moderate/EntriesLockApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Entry\\Moderate;\n\nuse App\\Controller\\Api\\Entry\\EntriesBaseApi;\nuse App\\DTO\\EntryResponseDto;\nuse App\\Entity\\Entry;\nuse App\\Factory\\EntryFactory;\nuse App\\Schema\\Errors\\ForbiddenErrorSchema;\nuse App\\Schema\\Errors\\NotFoundErrorSchema;\nuse App\\Schema\\Errors\\TooManyRequestsErrorSchema;\nuse App\\Schema\\Errors\\UnauthorizedErrorSchema;\nuse App\\Service\\EntryManager;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse Nelmio\\ApiDocBundle\\Attribute\\Security;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass EntriesLockApi extends EntriesBaseApi\n{\n    #[OA\\Response(\n        response: 200,\n        description: 'Entry lock status toggled',\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\\Schema(type: 'integer')),\n        ],\n        content: new Model(type: EntryResponseDto::class)\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'You are not authorized to lock this entry',\n        content: new OA\\JsonContent(ref: new Model(type: ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Entry not found',\n        content: new OA\\JsonContent(ref: new Model(type: NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\\Schema(type: 'integer')),\n        ],\n        content: new OA\\JsonContent(ref: new Model(type: TooManyRequestsErrorSchema::class))\n    )]\n    #[OA\\Parameter(\n        name: 'entry_id',\n        description: 'The entry to lock or unlock',\n        in: 'path',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Tag(name: 'moderation/entry')]\n    #[Security(name: 'oauth2', scopes: ['moderate:entry:lock'])]\n    #[IsGranted('ROLE_OAUTH2_MODERATE:ENTRY:LOCK')]\n    #[IsGranted('lock', subject: 'entry')]\n    public function __invoke(\n        #[MapEntity(id: 'entry_id')]\n        Entry $entry,\n        EntryManager $manager,\n        EntryFactory $factory,\n        RateLimiterFactoryInterface $apiModerateLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiModerateLimiter);\n\n        $manager->toggleLock($entry, $this->getUserOrThrow());\n\n        return new JsonResponse(\n            $this->serializeEntry($factory->createDto($entry), $this->tagLinkRepository->getTagsOfContent($entry), $this->entryRepository->findCross($entry)),\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Entry/Moderate/EntriesPinApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Entry\\Moderate;\n\nuse App\\Controller\\Api\\Entry\\EntriesBaseApi;\nuse App\\DTO\\EntryResponseDto;\nuse App\\Entity\\Entry;\nuse App\\Factory\\EntryFactory;\nuse App\\Service\\EntryManager;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse Nelmio\\ApiDocBundle\\Attribute\\Security;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass EntriesPinApi extends EntriesBaseApi\n{\n    #[OA\\Response(\n        response: 200,\n        description: 'Entry pin status toggled',\n        content: new Model(type: EntryResponseDto::class),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'You are not authorized to pin this entry',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Entry not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'entry_id',\n        in: 'path',\n        description: 'The entry to pin or unpin',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Tag(name: 'moderation/entry')]\n    #[Security(name: 'oauth2', scopes: ['moderate:entry:pin'])]\n    #[IsGranted('ROLE_OAUTH2_MODERATE:ENTRY:PIN')]\n    #[IsGranted('moderate', subject: 'entry')]\n    public function __invoke(\n        #[MapEntity(id: 'entry_id')]\n        Entry $entry,\n        EntryManager $manager,\n        EntryFactory $factory,\n        RateLimiterFactoryInterface $apiModerateLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiModerateLimiter);\n\n        $manager->pin($entry, $this->getUserOrThrow());\n\n        return new JsonResponse(\n            $this->serializeEntry($factory->createDto($entry), $this->tagLinkRepository->getTagsOfContent($entry), $this->entryRepository->findCross($entry)),\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Entry/Moderate/EntriesSetAdultApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Entry\\Moderate;\n\nuse App\\Controller\\Api\\Entry\\EntriesBaseApi;\nuse App\\DTO\\EntryResponseDto;\nuse App\\Entity\\Entry;\nuse App\\Factory\\EntryFactory;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse Nelmio\\ApiDocBundle\\Attribute\\Security;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass EntriesSetAdultApi extends EntriesBaseApi\n{\n    #[OA\\Response(\n        response: 200,\n        description: 'Entry isAdult status set',\n        content: new Model(type: EntryResponseDto::class),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'You are not authorized to moderate this entry',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Entry not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'entry_id',\n        in: 'path',\n        description: 'The entry to set adult status on',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Parameter(\n        name: 'adult',\n        in: 'path',\n        description: 'new isAdult status',\n        schema: new OA\\Schema(type: 'boolean', default: true),\n    )]\n    #[OA\\Tag(name: 'moderation/entry')]\n    #[Security(name: 'oauth2', scopes: ['moderate:entry:set_adult'])]\n    #[IsGranted('ROLE_OAUTH2_MODERATE:ENTRY:SET_ADULT')]\n    #[IsGranted('moderate', subject: 'entry')]\n    public function __invoke(\n        #[MapEntity(id: 'entry_id')]\n        Entry $entry,\n        EntityManagerInterface $manager,\n        EntryFactory $factory,\n        RateLimiterFactoryInterface $apiModerateLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiModerateLimiter);\n\n        $request = $this->request->getCurrentRequest();\n        // Returns true for \"1\", \"true\", \"on\" and \"yes\". Returns false otherwise.\n        $entry->isAdult = filter_var($request->get('adult', 'true'), FILTER_VALIDATE_BOOL);\n\n        $manager->flush();\n\n        return new JsonResponse(\n            $this->serializeEntry($factory->createDto($entry), $this->tagLinkRepository->getTagsOfContent($entry), $this->entryRepository->findCross($entry)),\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Entry/Moderate/EntriesSetLanguageApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Entry\\Moderate;\n\nuse App\\Controller\\Api\\Entry\\EntriesBaseApi;\nuse App\\DTO\\EntryResponseDto;\nuse App\\Entity\\Entry;\nuse App\\Factory\\EntryFactory;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse Nelmio\\ApiDocBundle\\Attribute\\Security;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpKernel\\Exception\\BadRequestHttpException;\nuse Symfony\\Component\\Intl\\Languages;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass EntriesSetLanguageApi extends EntriesBaseApi\n{\n    #[OA\\Response(\n        response: 200,\n        description: 'Entry language changed',\n        content: new Model(type: EntryResponseDto::class),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 400,\n        description: 'Given language is not valid',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\BadRequestErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'You are not authorized to moderate this entry',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Entry not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'entry_id',\n        in: 'path',\n        description: 'The entry to change language of',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Parameter(\n        name: 'lang',\n        in: 'path',\n        description: 'new language',\n        schema: new OA\\Schema(type: 'string', minLength: 2, maxLength: 3),\n    )]\n    #[OA\\Tag(name: 'moderation/entry')]\n    #[Security(name: 'oauth2', scopes: ['moderate:entry:language'])]\n    #[IsGranted('ROLE_OAUTH2_MODERATE:ENTRY:LANGUAGE')]\n    #[IsGranted('moderate', subject: 'entry')]\n    public function __invoke(\n        #[MapEntity(id: 'entry_id')]\n        Entry $entry,\n        EntityManagerInterface $manager,\n        EntryFactory $factory,\n        RateLimiterFactoryInterface $apiModerateLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiModerateLimiter);\n\n        $request = $this->request->getCurrentRequest();\n        $newLang = $request->get('lang', '');\n\n        $valid = false !== array_search($newLang, Languages::getLanguageCodes());\n\n        if (!$valid) {\n            throw new BadRequestHttpException('The given language is not valid!');\n        }\n\n        $entry->lang = $newLang;\n\n        $manager->flush();\n\n        return new JsonResponse(\n            $this->serializeEntry($factory->createDto($entry), $this->tagLinkRepository->getTagsOfContent($entry), $this->entryRepository->findCross($entry)),\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Entry/Moderate/EntriesTrashApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Entry\\Moderate;\n\nuse App\\Controller\\Api\\Entry\\EntriesBaseApi;\nuse App\\DTO\\EntryResponseDto;\nuse App\\Entity\\Contracts\\VisibilityInterface;\nuse App\\Entity\\Entry;\nuse App\\Factory\\EntryFactory;\nuse App\\Service\\EntryManager;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse Nelmio\\ApiDocBundle\\Attribute\\Security;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpKernel\\Exception\\BadRequestHttpException;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass EntriesTrashApi extends EntriesBaseApi\n{\n    #[OA\\Response(\n        response: 200,\n        description: 'Entry trashed',\n        content: new Model(type: EntryResponseDto::class),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'You are not authorized to trash this entry',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Entry not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'entry_id',\n        in: 'path',\n        description: 'The entry to trash',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Tag(name: 'moderation/entry')]\n    #[Security(name: 'oauth2', scopes: ['moderate:entry:trash'])]\n    #[IsGranted('ROLE_OAUTH2_MODERATE:ENTRY:TRASH')]\n    #[IsGranted('moderate', subject: 'entry')]\n    public function trash(\n        #[MapEntity(id: 'entry_id')]\n        Entry $entry,\n        EntryManager $manager,\n        EntryFactory $factory,\n        RateLimiterFactoryInterface $apiModerateLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiModerateLimiter);\n\n        $moderator = $this->getUserOrThrow();\n\n        $manager->trash($moderator, $entry);\n\n        $response = $this->serializeEntry($factory->createDto($entry), $this->tagLinkRepository->getTagsOfContent($entry), $this->entryRepository->findCross($entry));\n\n        // Force response to have all fields visible\n        $visibility = $response->visibility;\n        $response->visibility = VisibilityInterface::VISIBILITY_VISIBLE;\n        $response = $response->jsonSerialize();\n        $response['visibility'] = $visibility;\n\n        return new JsonResponse(\n            $response,\n            headers: $headers\n        );\n    }\n\n    #[OA\\Response(\n        response: 200,\n        description: 'Entry restored',\n        content: new Model(type: EntryResponseDto::class),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 400,\n        description: 'The entry was not in the trashed state',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\BadRequestErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'You are not authorized to restore this entry',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Entry not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'entry_id',\n        in: 'path',\n        description: 'The entry to restore',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Tag(name: 'moderation/entry')]\n    #[Security(name: 'oauth2', scopes: ['moderate:entry:trash'])]\n    #[IsGranted('ROLE_OAUTH2_MODERATE:ENTRY:TRASH')]\n    #[IsGranted('moderate', subject: 'entry')]\n    public function restore(\n        #[MapEntity(id: 'entry_id')]\n        Entry $entry,\n        EntryManager $manager,\n        EntryFactory $factory,\n        RateLimiterFactoryInterface $apiModerateLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiModerateLimiter);\n\n        $moderator = $this->getUserOrThrow();\n\n        try {\n            $manager->restore($moderator, $entry);\n        } catch (\\Exception $e) {\n            throw new BadRequestHttpException('The entry cannot be restored because it was not trashed!');\n        }\n\n        return new JsonResponse(\n            $this->serializeEntry($factory->createDto($entry), $this->tagLinkRepository->getTagsOfContent($entry), $this->entryRepository->findCross($entry)),\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Entry/UserEntriesRetrieveApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Entry;\n\nuse App\\Controller\\Traits\\PrivateContentTrait;\nuse App\\DTO\\EntryResponseDto;\nuse App\\Entity\\Entry;\nuse App\\Entity\\User;\nuse App\\Factory\\EntryFactory;\nuse App\\PageView\\EntryPageView;\nuse App\\Repository\\Criteria;\nuse App\\Repository\\EntryRepository;\nuse App\\Schema\\PaginationSchema;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Bundle\\SecurityBundle\\Security;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpFoundation\\RequestStack;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\n\nclass UserEntriesRetrieveApi extends EntriesBaseApi\n{\n    use PrivateContentTrait;\n\n    #[OA\\Response(\n        response: 200,\n        description: 'Returns a paginated list of entries from a specific user',\n        content: new OA\\JsonContent(\n            type: 'object',\n            properties: [\n                new OA\\Property(\n                    property: 'items',\n                    type: 'array',\n                    items: new OA\\Items(ref: new Model(type: EntryResponseDto::class))\n                ),\n                new OA\\Property(\n                    property: 'pagination',\n                    ref: new Model(type: PaginationSchema::class)\n                ),\n            ]\n        ),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'User not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'user_id',\n        in: 'path',\n        description: 'The user whose entries to retrieve',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Parameter(\n        name: 'sort',\n        in: 'query',\n        description: 'The sorting method to use during entry fetch',\n        schema: new OA\\Schema(\n            default: Criteria::SORT_DEFAULT,\n            enum: Criteria::SORT_OPTIONS\n        )\n    )]\n    #[OA\\Parameter(\n        name: 'time',\n        in: 'query',\n        description: 'The maximum age of retrieved entries',\n        schema: new OA\\Schema(\n            default: Criteria::TIME_ALL,\n            enum: Criteria::TIME_ROUTES_EN\n        )\n    )]\n    #[OA\\Parameter(\n        name: 'p',\n        description: 'Page of entries to retrieve',\n        in: 'query',\n        schema: new OA\\Schema(type: 'integer', default: 1, minimum: 1)\n    )]\n    #[OA\\Parameter(\n        name: 'perPage',\n        description: 'Number of entries to retrieve per page',\n        in: 'query',\n        schema: new OA\\Schema(type: 'integer', default: EntryRepository::PER_PAGE, minimum: self::MIN_PER_PAGE, maximum: self::MAX_PER_PAGE)\n    )]\n    #[OA\\Parameter(\n        name: 'lang[]',\n        description: 'Language(s) of entries to return',\n        in: 'query',\n        explode: true,\n        allowReserved: true,\n        schema: new OA\\Schema(\n            type: 'array',\n            items: new OA\\Items(type: 'string', default: null, minLength: 2, maxLength: 3)\n        )\n    )]\n    #[OA\\Parameter(\n        name: 'usePreferredLangs',\n        description: 'Filter by a user\\'s preferred languages? (Requires authentication and takes precedence over lang[])',\n        in: 'query',\n        schema: new OA\\Schema(type: 'boolean', default: false),\n    )]\n    #[OA\\Tag(name: 'user')]\n    public function __invoke(\n        #[MapEntity(id: 'user_id')]\n        User $user,\n        EntryRepository $repository,\n        EntryFactory $factory,\n        RequestStack $request,\n        RateLimiterFactoryInterface $apiReadLimiter,\n        RateLimiterFactoryInterface $anonymousApiReadLimiter,\n        Security $security,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter);\n\n        $criteria = new EntryPageView((int) $request->getCurrentRequest()->get('p', 1), $security);\n        $criteria->sortOption = $request->getCurrentRequest()->get('sort', Criteria::SORT_HOT);\n        $criteria->time = $criteria->resolveTime(\n            $request->getCurrentRequest()->get('time', Criteria::TIME_ALL)\n        );\n        $this->handleLanguageCriteria($criteria);\n\n        $criteria->stickiesFirst = true;\n\n        $criteria->perPage = self::constrainPerPage($request->getCurrentRequest()->get('perPage', EntryRepository::PER_PAGE));\n\n        $criteria->user = $user;\n\n        $entries = $repository->findByCriteria($criteria);\n\n        $dtos = [];\n        foreach ($entries->getCurrentPageResults() as $value) {\n            try {\n                \\assert($value instanceof Entry);\n                $dtos[] = $this->serializeEntry($factory->createDto($value), $this->tagLinkRepository->getTagsOfContent($value));\n            } catch (\\Exception $e) {\n                continue;\n            }\n        }\n\n        return new JsonResponse(\n            $this->serializePaginated($dtos, $entries),\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/EntryComments.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api;\n\nuse App\\ApiDataProvider\\DtoPaginator;\nuse App\\Controller\\AbstractController;\nuse App\\Entity\\Entry;\nuse App\\Factory\\EntryCommentFactory;\nuse App\\PageView\\EntryCommentPageView;\nuse App\\Repository\\EntryCommentRepository;\nuse Symfony\\Bundle\\SecurityBundle\\Security;\nuse Symfony\\Component\\HttpFoundation\\RequestStack;\n\nclass EntryComments extends AbstractController\n{\n    public function __construct(\n        private readonly EntryCommentRepository $repository,\n        private readonly EntryCommentFactory $factory,\n        private readonly RequestStack $request,\n        private readonly Security $security,\n    ) {\n    }\n\n    public function __invoke(Entry $entry)\n    {\n        try {\n            $criteria = new EntryCommentPageView((int) $this->request->getCurrentRequest()->get('p', 1), $this->security);\n            $criteria->entry = $entry;\n            $criteria->onlyParents = false;\n\n            $comments = $this->repository->findByCriteria($criteria);\n        } catch (\\Exception $e) {\n            return [];\n        }\n\n        $dtos = array_map(fn ($comment) => $this->factory->createDto($comment),\n            (array) $comments->getCurrentPageResults());\n\n        return new DtoPaginator($dtos, 0, EntryCommentRepository::PER_PAGE, $comments->getNbResults());\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Instance/Admin/InstanceRetrieveSettingsApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Instance\\Admin;\n\nuse App\\Controller\\Api\\Instance\\InstanceBaseApi;\nuse App\\DTO\\SettingsDto;\nuse App\\Service\\SettingsManager;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse Nelmio\\ApiDocBundle\\Attribute\\Security;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass InstanceRetrieveSettingsApi extends InstanceBaseApi\n{\n    #[OA\\Response(\n        response: 200,\n        description: 'Settings retrieved',\n        content: new Model(type: SettingsDto::class),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'You do not have permission to view the instance settings',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Tag(name: 'admin/instance')]\n    #[IsGranted('ROLE_ADMIN')]\n    #[Security(name: 'oauth2', scopes: ['admin:instance:settings:read'])]\n    #[IsGranted('ROLE_OAUTH2_ADMIN:INSTANCE:SETTINGS:READ')]\n    public function __invoke(\n        SettingsManager $settings,\n        RateLimiterFactoryInterface $apiModerateLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiModerateLimiter);\n\n        return new JsonResponse(\n            $settings->getDto(),\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Instance/Admin/InstanceUpdateFederationApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Instance\\Admin;\n\nuse App\\Controller\\Api\\Instance\\InstanceBaseApi;\nuse App\\DTO\\InstancesDto;\nuse App\\DTO\\SettingsDto;\nuse App\\Entity\\Instance;\nuse App\\Repository\\InstanceRepository;\nuse App\\Schema\\Errors\\BadRequestErrorSchema;\nuse App\\Schema\\Errors\\ForbiddenErrorSchema;\nuse App\\Schema\\Errors\\NotFoundErrorSchema;\nuse App\\Schema\\Errors\\TooManyRequestsErrorSchema;\nuse App\\Schema\\Errors\\UnauthorizedErrorSchema;\nuse App\\Service\\InstanceManager;\nuse App\\Service\\SettingsManager;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse Nelmio\\ApiDocBundle\\Attribute\\Security;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpKernel\\Exception\\BadRequestHttpException;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\nuse Symfony\\Component\\Serializer\\SerializerInterface;\nuse Symfony\\Component\\Validator\\Validator\\ValidatorInterface;\n\nclass InstanceUpdateFederationApi extends InstanceBaseApi\n{\n    #[OA\\Response(\n        response: 200,\n        description: 'Defederated instances updated',\n        content: new Model(type: InstancesDto::class),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 400,\n        description: 'One of the URLs entered was invalid',\n        content: new OA\\JsonContent(ref: new Model(type: BadRequestErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'You do not have permission to edit the list of defederated instances',\n        content: new OA\\JsonContent(ref: new Model(type: ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\RequestBody(content: new Model(type: InstancesDto::class))]\n    #[OA\\Tag(name: 'admin/federation')]\n    #[IsGranted('ROLE_ADMIN')]\n    #[Security(name: 'oauth2', scopes: ['admin:federation:update'])]\n    #[IsGranted('ROLE_OAUTH2_ADMIN:FEDERATION:UPDATE')]\n    #[OA\\Put(description: 'This is the old version of banning and unbanning instances, use /api/instance/ban and /api/instance/unban instead', deprecated: true)]\n    public function __invoke(\n        SettingsManager $settings,\n        InstanceRepository $instanceRepository,\n        InstanceManager $instanceManager,\n        SerializerInterface $serializer,\n        ValidatorInterface $validator,\n        RateLimiterFactoryInterface $apiModerateLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiModerateLimiter);\n\n        $request = $this->request->getCurrentRequest();\n        /** @var InstancesDto $dto */\n        $dto = $serializer->deserialize($request->getContent(), InstancesDto::class, 'json');\n\n        $dto->instances = array_map(\n            fn (string $instance) => trim(str_replace('www.', '', $instance)),\n            $dto->instances\n        );\n\n        $errors = $validator->validate($dto);\n        if (0 < \\count($errors)) {\n            throw new BadRequestHttpException((string) $errors);\n        }\n\n        $instanceManager->setBannedInstances($dto->instances);\n\n        $dto = new InstancesDto($instanceRepository->getBannedInstanceUrls());\n\n        return new JsonResponse(\n            $dto,\n            headers: $headers\n        );\n    }\n\n    #[OA\\Response(\n        response: 200,\n        description: 'Instance added to ban list',\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\\Schema(type: 'integer')),\n        ],\n        content: new Model(type: SettingsDto::class)\n    )]\n    #[OA\\Response(\n        response: 400,\n        description: 'Instance cannot be banned when an allow list is used',\n        content: new OA\\JsonContent(ref: new Model(type: BadRequestErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'You do not have permission to edit the instance settings',\n        content: new OA\\JsonContent(ref: new Model(type: ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\\Schema(type: 'integer')),\n        ],\n        content: new OA\\JsonContent(ref: new Model(type: TooManyRequestsErrorSchema::class))\n    )]\n    #[OA\\Tag(name: 'admin/federation')]\n    #[IsGranted('ROLE_ADMIN')]\n    #[Security(name: 'oauth2', scopes: ['admin:federation:update'])]\n    #[IsGranted('ROLE_OAUTH2_ADMIN:FEDERATION:UPDATE')]\n    public function banInstance(\n        RateLimiterFactoryInterface $apiModerateLimiter,\n        string $domain,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiModerateLimiter);\n        $instance = $this->instanceRepository->getOrCreateInstance($domain);\n        try {\n            $this->instanceManager->banInstance($instance);\n        } catch (\\LogicException $exception) {\n            throw new BadRequestHttpException($exception->getMessage());\n        }\n\n        return new JsonResponse(headers: $headers);\n    }\n\n    #[OA\\Response(\n        response: 200,\n        description: 'Instance removed from ban list',\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\\Schema(type: 'integer')),\n        ],\n        content: new Model(type: SettingsDto::class)\n    )]\n    #[OA\\Response(\n        response: 400,\n        description: 'Instance cannot be unbanned when an allow list is used',\n        content: new OA\\JsonContent(ref: new Model(type: BadRequestErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'You do not have permission to edit the instance settings',\n        content: new OA\\JsonContent(ref: new Model(type: ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Instance not found',\n        content: new OA\\JsonContent(ref: new Model(type: NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\\Schema(type: 'integer')),\n        ],\n        content: new OA\\JsonContent(ref: new Model(type: TooManyRequestsErrorSchema::class))\n    )]\n    #[OA\\Tag(name: 'admin/federation')]\n    #[IsGranted('ROLE_ADMIN')]\n    #[Security(name: 'oauth2', scopes: ['admin:federation:update'])]\n    #[IsGranted('ROLE_OAUTH2_ADMIN:FEDERATION:UPDATE')]\n    public function unbanInstance(\n        RateLimiterFactoryInterface $apiModerateLimiter,\n        #[MapEntity(mapping: ['domain' => 'domain'])] Instance $instance,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiModerateLimiter);\n        try {\n            $this->instanceManager->unbanInstance($instance);\n        } catch (\\LogicException $exception) {\n            throw new BadRequestHttpException($exception->getMessage());\n        }\n\n        return new JsonResponse(headers: $headers);\n    }\n\n    #[OA\\Response(\n        response: 200,\n        description: 'Instance added to allowlist',\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\\Schema(type: 'integer')),\n        ],\n        content: new Model(type: SettingsDto::class)\n    )]\n    #[OA\\Response(\n        response: 400,\n        description: 'Instance cannot be removed from the allow list when the allow list is not used',\n        content: new OA\\JsonContent(ref: new Model(type: BadRequestErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'You do not have permission to edit the instance settings',\n        content: new OA\\JsonContent(ref: new Model(type: ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\\Schema(type: 'integer')),\n        ],\n        content: new OA\\JsonContent(ref: new Model(type: TooManyRequestsErrorSchema::class))\n    )]\n    #[OA\\Tag(name: 'admin/federation')]\n    #[IsGranted('ROLE_ADMIN')]\n    #[Security(name: 'oauth2', scopes: ['admin:federation:update'])]\n    #[IsGranted('ROLE_OAUTH2_ADMIN:FEDERATION:UPDATE')]\n    public function allowInstance(\n        RateLimiterFactoryInterface $apiModerateLimiter,\n        string $domain,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiModerateLimiter);\n        $instance = $this->instanceRepository->getOrCreateInstance($domain);\n        try {\n            $this->instanceManager->allowInstanceFederation($instance);\n        } catch (\\LogicException $exception) {\n            throw new BadRequestHttpException($exception->getMessage());\n        }\n\n        return new JsonResponse(headers: $headers);\n    }\n\n    #[OA\\Response(\n        response: 200,\n        description: 'Instance removed from allow list',\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\\Schema(type: 'integer')),\n        ],\n        content: new Model(type: SettingsDto::class)\n    )]\n    #[OA\\Response(\n        response: 400,\n        description: 'Instance cannot be put on the allow list when the allow list is not used',\n        content: new OA\\JsonContent(ref: new Model(type: BadRequestErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'You do not have permission to edit the instance settings',\n        content: new OA\\JsonContent(ref: new Model(type: ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Instance not found',\n        content: new OA\\JsonContent(ref: new Model(type: NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\\Schema(type: 'integer')),\n        ],\n        content: new OA\\JsonContent(ref: new Model(type: TooManyRequestsErrorSchema::class))\n    )]\n    #[OA\\Tag(name: 'admin/federation')]\n    #[IsGranted('ROLE_ADMIN')]\n    #[Security(name: 'oauth2', scopes: ['admin:federation:update'])]\n    #[IsGranted('ROLE_OAUTH2_ADMIN:FEDERATION:UPDATE')]\n    public function denyInstance(\n        RateLimiterFactoryInterface $apiModerateLimiter,\n        #[MapEntity(mapping: ['domain' => 'domain'])] Instance $instance,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiModerateLimiter);\n        try {\n            $this->instanceManager->denyInstanceFederation($instance);\n        } catch (\\LogicException $exception) {\n            throw new BadRequestHttpException($exception->getMessage());\n        }\n\n        return new JsonResponse(headers: $headers);\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Instance/Admin/InstanceUpdatePagesApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Instance\\Admin;\n\nuse App\\Controller\\Api\\Instance\\InstanceBaseApi;\nuse App\\DTO\\PageDto;\nuse App\\DTO\\SiteResponseDto;\nuse App\\Entity\\Site;\nuse App\\Repository\\SiteRepository;\nuse App\\Service\\SettingsManager;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse Nelmio\\ApiDocBundle\\Attribute\\Security;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpKernel\\Exception\\BadRequestHttpException;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\nuse Symfony\\Component\\Serializer\\SerializerInterface;\nuse Symfony\\Component\\Validator\\Validator\\ValidatorInterface;\n\nclass InstanceUpdatePagesApi extends InstanceBaseApi\n{\n    #[OA\\Response(\n        response: 200,\n        description: 'Page updated',\n        content: new Model(type: SiteResponseDto::class),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 400,\n        description: 'Invalid settings provided',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\BadRequestErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'You do not have permission to edit the instance pages',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\RequestBody(content: new OA\\JsonContent(ref: new Model(type: PageDto::class)))]\n    #[OA\\Parameter(\n        name: 'page',\n        in: 'path',\n        schema: new OA\\Schema(type: 'string', enum: SiteResponseDto::PAGES)\n    )]\n    #[OA\\Tag(name: 'admin/instance')]\n    #[IsGranted('ROLE_ADMIN')]\n    #[Security(name: 'oauth2', scopes: ['admin:instance:information:edit'])]\n    #[IsGranted('ROLE_OAUTH2_ADMIN:INSTANCE:INFORMATION:EDIT')]\n    public function __invoke(\n        SiteRepository $repository,\n        EntityManagerInterface $entityManager,\n        SerializerInterface $serializer,\n        ValidatorInterface $validator,\n        RateLimiterFactoryInterface $apiModerateLimiter,\n        SettingsManager $settingsManager,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiModerateLimiter);\n\n        $request = $this->request->getCurrentRequest();\n        $page = $request->get('page');\n\n        if (null === $page || false === array_search($page, SiteResponseDto::PAGES)) {\n            throw new BadRequestHttpException('Page parameter is invalid!');\n        }\n\n        /** @var PageDto $dto */\n        $dto = $serializer->deserialize($request->getContent(), PageDto::class, 'json');\n\n        $errors = $validator->validate($dto);\n        if (0 < \\count($errors)) {\n            throw new BadRequestHttpException((string) $errors);\n        }\n\n        $entity = $repository->findAll();\n        if (!\\count($entity)) {\n            $entity = new Site();\n        } else {\n            $entity = $entity[0];\n        }\n\n        $entity->{$page} = $dto->body;\n        $entityManager->persist($entity);\n        $entityManager->flush();\n\n        return new JsonResponse(\n            new SiteResponseDto($entity, $settingsManager->getDownvotesMode()),\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Instance/Admin/InstanceUpdateSettingsApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Instance\\Admin;\n\nuse App\\Controller\\Api\\Instance\\InstanceBaseApi;\nuse App\\DTO\\SettingsDto;\nuse App\\Schema\\Errors\\BadRequestErrorSchema;\nuse App\\Schema\\Errors\\ForbiddenErrorSchema;\nuse App\\Schema\\Errors\\TooManyRequestsErrorSchema;\nuse App\\Schema\\Errors\\UnauthorizedErrorSchema;\nuse App\\Service\\SettingsManager;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse Nelmio\\ApiDocBundle\\Attribute\\Security;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpKernel\\Exception\\BadRequestHttpException;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\nuse Symfony\\Component\\Serializer\\SerializerInterface;\nuse Symfony\\Component\\Validator\\Validator\\ValidatorInterface;\n\nclass InstanceUpdateSettingsApi extends InstanceBaseApi\n{\n    #[OA\\Response(\n        response: 200,\n        description: 'Settings updated',\n        content: new Model(type: SettingsDto::class),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 400,\n        description: 'Invalid settings provided',\n        content: new OA\\JsonContent(ref: new Model(type: BadRequestErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'You do not have permission to edit the instance settings',\n        content: new OA\\JsonContent(ref: new Model(type: ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\RequestBody(content: new OA\\JsonContent(ref: new Model(type: SettingsDto::class)))]\n    #[OA\\Tag(name: 'admin/instance')]\n    #[IsGranted('ROLE_ADMIN')]\n    #[Security(name: 'oauth2', scopes: ['admin:instance:settings:edit'])]\n    #[IsGranted('ROLE_OAUTH2_ADMIN:INSTANCE:SETTINGS:EDIT')]\n    public function __invoke(\n        SettingsManager $settings,\n        SerializerInterface $serializer,\n        ValidatorInterface $validator,\n        RateLimiterFactoryInterface $apiModerateLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiModerateLimiter);\n\n        $request = $this->request->getCurrentRequest();\n        /** @var SettingsDto $dto */\n        $dto = $serializer->deserialize($request->getContent(), SettingsDto::class, 'json');\n\n        $errors = $validator->validate($dto);\n        if (0 < \\count($errors)) {\n            throw new BadRequestHttpException((string) $errors);\n        }\n\n        $settingDto = $dto->mergeIntoDto($settings->getDto());\n\n        $settings->save($settingDto);\n\n        return new JsonResponse(\n            $settingDto,\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Instance/InstanceBaseApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Instance;\n\nuse App\\Controller\\Api\\BaseApi;\n\nclass InstanceBaseApi extends BaseApi\n{\n}\n"
  },
  {
    "path": "src/Controller/Api/Instance/InstanceDetailsApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Instance;\n\nuse App\\DTO\\RemoteInstanceDto;\nuse App\\DTO\\SiteResponseDto;\nuse App\\Entity\\Instance;\nuse App\\Repository\\SiteRepository;\nuse App\\Schema\\Errors\\ForbiddenErrorSchema;\nuse App\\Schema\\Errors\\NotFoundErrorSchema;\nuse App\\Schema\\Errors\\TooManyRequestsErrorSchema;\nuse App\\Schema\\Errors\\UnauthorizedErrorSchema;\nuse App\\Service\\SettingsManager;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse Nelmio\\ApiDocBundle\\Attribute\\Security;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass InstanceDetailsApi extends InstanceBaseApi\n{\n    #[OA\\Response(\n        response: 200,\n        description: 'Returns the site\\'s details',\n        content: new OA\\JsonContent(ref: new Model(type: SiteResponseDto::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to expired token',\n        content: new OA\\JsonContent(ref: new Model(type: UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Tag('instance')]\n    /**\n     * Retrieve information about the instance written by the admin.\n     */\n    public function __invoke(\n        SiteRepository $repository,\n        RateLimiterFactoryInterface $apiReadLimiter,\n        RateLimiterFactoryInterface $anonymousApiReadLimiter,\n        SettingsManager $settingsManager,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter);\n\n        $results = $repository->findAll();\n        $dto = new SiteResponseDto(null, $settingsManager->getDownvotesMode());\n        if (0 < \\count($results)) {\n            $dto = new SiteResponseDto($results[0], $settingsManager->getDownvotesMode());\n        }\n\n        return new JsonResponse(\n            $dto,\n            headers: $headers\n        );\n    }\n\n    #[OA\\Response(\n        response: 200,\n        description: 'Returns the details of a remote instance',\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\\Schema(type: 'integer')),\n        ],\n        content: new OA\\JsonContent(ref: new Model(type: RemoteInstanceDto::class))\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to expired token',\n        content: new OA\\JsonContent(ref: new Model(type: UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'You do not have permission to view the details for remote instances',\n        content: new OA\\JsonContent(ref: new Model(type: ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Remote instance not found',\n        content: new OA\\JsonContent(ref: new Model(type: NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\\Schema(type: 'integer')),\n        ],\n        content: new OA\\JsonContent(ref: new Model(type: TooManyRequestsErrorSchema::class))\n    )]\n    #[IsGranted('ROLE_ADMIN')]\n    #[Security(name: 'oauth2', scopes: ['admin:federation:read'])]\n    #[IsGranted('ROLE_OAUTH2_ADMIN:FEDERATION:READ')]\n    #[OA\\Tag('admin/instance')]\n    public function retrieveRemoteInstanceDetails(\n        RateLimiterFactoryInterface $apiReadLimiter,\n        #[MapEntity(mapping: ['domain' => 'domain'])] Instance $instance,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiReadLimiter);\n\n        $dto = RemoteInstanceDto::create($instance, $this->instanceRepository->getInstanceCounts($instance));\n\n        return new JsonResponse($dto, headers: $headers);\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Instance/InstanceModLogApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Instance;\n\nuse App\\DTO\\MagazineLogResponseDto;\nuse App\\Entity\\MagazineLog;\nuse App\\Repository\\MagazineLogRepository;\nuse App\\Schema\\PaginationSchema;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpKernel\\Attribute\\MapQueryParameter;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\n\nclass InstanceModLogApi extends InstanceBaseApi\n{\n    #[OA\\Response(\n        response: 200,\n        description: 'Returns the site\\'s global moderation log',\n        content: new OA\\JsonContent(\n            type: 'object',\n            properties: [\n                new OA\\Property(\n                    property: 'items',\n                    type: 'array',\n                    items: new OA\\Items(ref: new Model(type: MagazineLogResponseDto::class))\n                ),\n                new OA\\Property(\n                    property: 'pagination',\n                    ref: new Model(type: PaginationSchema::class)\n                ),\n            ]\n        ),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Page not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'p',\n        description: 'Page of moderation log to retrieve',\n        in: 'query',\n        schema: new OA\\Schema(type: 'integer', default: 1, minimum: 1)\n    )]\n    #[OA\\Parameter(\n        name: 'perPage',\n        description: 'Number of moderation log items to retrieve per page',\n        in: 'query',\n        schema: new OA\\Schema(type: 'integer', default: MagazineLogRepository::PER_PAGE, minimum: self::MIN_PER_PAGE, maximum: self::MAX_PER_PAGE)\n    )]\n    #[OA\\Parameter(\n        name: 'types[]',\n        description: 'The types of magazine logs to retrieve',\n        in: 'query',\n        schema: new OA\\Schema(type: 'array', items: new OA\\Items(type: 'string', enum: MagazineLog::CHOICES))\n    )]\n    #[OA\\Tag('instance')]\n    /**\n     * Retrieve information about moderation actions taken across the instance.\n     */\n    public function collection(\n        MagazineLogRepository $repository,\n        RateLimiterFactoryInterface $apiReadLimiter,\n        RateLimiterFactoryInterface $anonymousApiReadLimiter,\n        #[MapQueryParameter] ?array $types = null,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter);\n\n        $request = $this->request->getCurrentRequest();\n        $logs = $repository->findByCustom(\n            $this->getPageNb($request),\n            self::constrainPerPage($request->get('perPage', MagazineLogRepository::PER_PAGE)),\n            types: $types,\n        );\n\n        $dtos = [];\n        foreach ($logs->getCurrentPageResults() as $value) {\n            $dtos[] = $this->serializeLogItem($value);\n        }\n\n        return new JsonResponse(\n            $this->serializePaginated($dtos, $logs),\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Instance/InstanceRetrieveFederationApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Instance;\n\nuse App\\DTO\\InstanceDto;\nuse App\\DTO\\InstancesDto;\nuse App\\DTO\\InstancesDtoV2;\nuse App\\Entity\\Instance;\nuse App\\Repository\\InstanceRepository;\nuse App\\Schema\\Errors\\TooManyRequestsErrorSchema;\nuse App\\Service\\SettingsManager;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\n\nclass InstanceRetrieveFederationApi extends InstanceBaseApi\n{\n    #[OA\\Response(\n        response: 200,\n        description: 'Returns a list of de-federated instances',\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\\Schema(type: 'integer')),\n        ],\n        content: new Model(type: InstancesDto::class)\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\\Schema(type: 'integer')),\n        ],\n        content: new OA\\JsonContent(ref: new Model(type: TooManyRequestsErrorSchema::class))\n    )]\n    #[OA\\Tag(name: 'instance')]\n    /**\n     * Get de-federated instances.\n     */\n    public function getDeFederated(\n        InstanceRepository $instanceRepository,\n        RateLimiterFactoryInterface $apiReadLimiter,\n        RateLimiterFactoryInterface $anonymousApiReadLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter);\n        $dto = new InstancesDto($instanceRepository->getBannedInstanceUrls());\n\n        return new JsonResponse(\n            $dto,\n            headers: $headers\n        );\n    }\n\n    #[OA\\Response(\n        response: 200,\n        description: 'Returns a list of de-federated instances and info about their server software',\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\\Schema(type: 'integer')),\n        ],\n        content: new Model(type: InstancesDtoV2::class)\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\\Schema(type: 'integer')),\n        ],\n        content: new OA\\JsonContent(ref: new Model(type: TooManyRequestsErrorSchema::class))\n    )]\n    #[OA\\Tag(name: 'instance')]\n    /**\n     * Get de-federated instances.\n     */\n    public function getDeFederatedV2(\n        RateLimiterFactoryInterface $apiReadLimiter,\n        RateLimiterFactoryInterface $anonymousApiReadLimiter,\n        InstanceRepository $instanceRepository,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter);\n        $instances = array_map(fn (Instance $i) => new InstanceDto($i->domain, $i->software, $i->version), $instanceRepository->getBannedInstances());\n        $dto = new InstancesDtoV2($instances);\n\n        return new JsonResponse(\n            $dto,\n            headers: $headers\n        );\n    }\n\n    #[OA\\Response(\n        response: 200,\n        description: 'Returns a list of federated instances',\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\\Schema(type: 'integer')),\n        ],\n        content: new Model(type: InstancesDtoV2::class)\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\\Schema(type: 'integer')),\n        ],\n        content: new OA\\JsonContent(ref: new Model(type: TooManyRequestsErrorSchema::class))\n    )]\n    #[OA\\Tag(name: 'instance')]\n    /**\n     * Get federated instances.\n     */\n    public function getFederated(\n        RateLimiterFactoryInterface $apiReadLimiter,\n        RateLimiterFactoryInterface $anonymousApiReadLimiter,\n        InstanceRepository $instanceRepository,\n        SettingsManager $settingsManager,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter);\n        $instances = array_map(fn (Instance $i) => new InstanceDto($i->domain, $i->software, $i->version), $instanceRepository->getAllowedInstances($settingsManager->getUseAllowList()));\n        $dto = new InstancesDtoV2($instances);\n\n        return new JsonResponse(\n            $dto,\n            headers: $headers\n        );\n    }\n\n    #[OA\\Response(\n        response: 200,\n        description: 'Returns a list of dead instances',\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\\Schema(type: 'integer')),\n        ],\n        content: new Model(type: InstancesDtoV2::class)\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\\Schema(type: 'integer')),\n        ],\n        content: new OA\\JsonContent(ref: new Model(type: TooManyRequestsErrorSchema::class))\n    )]\n    #[OA\\Tag(name: 'instance')]\n    /**\n     * Get dead instances.\n     */\n    public function getDead(\n        RateLimiterFactoryInterface $apiReadLimiter,\n        RateLimiterFactoryInterface $anonymousApiReadLimiter,\n        InstanceRepository $instanceRepository,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter);\n        $instances = array_map(fn (Instance $i) => new InstanceDto($i->domain, $i->software, $i->version), $instanceRepository->getDeadInstances());\n        $dto = new InstancesDtoV2($instances);\n\n        return new JsonResponse(\n            $dto,\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Instance/InstanceRetrieveInfoApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Instance;\n\nuse App\\Entity\\User;\nuse App\\Factory\\ActivityPub\\PersonFactory;\nuse App\\Service\\ProjectInfoService;\nuse App\\Service\\SettingsManager;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\n\nclass InstanceRetrieveInfoApi extends InstanceBaseApi\n{\n    #[OA\\Response(\n        response: 200,\n        description: 'Get general instance information (eg. software name and version)',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\InfoSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Tag(name: 'instance')]\n    /**\n     * Retrieve instance information (like the software name and version plus general website info).\n     */\n    public function __invoke(\n        SettingsManager $settings,\n        ProjectInfoService $projectInfo,\n        RateLimiterFactoryInterface $apiReadLimiter,\n        RateLimiterFactoryInterface $anonymousApiReadLimiter,\n        PersonFactory $userFactory,\n    ): JsonResponse {\n        $userToJson = function (User $admin) use ($userFactory) {\n            $json = $userFactory->create($admin);\n            unset($json['@context']);\n\n            return $json;\n        };\n        $adminUsers = $this->userRepository->findAllAdmins();\n        $admins = array_map($userToJson, $adminUsers);\n        $moderatorUsers = $this->userRepository->findAllModerators();\n        $moderators = array_map($userToJson, $moderatorUsers);\n\n        $headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter);\n        $body = [\n            'softwareName' => $projectInfo->getName(),\n            'softwareVersion' => $projectInfo->getVersion(),\n            'softwareRepository' => $projectInfo->getRepositoryURL(),\n            'websiteDomain' => $settings->get('KBIN_DOMAIN'),\n            'websiteContactEmail' => $settings->get('KBIN_CONTACT_EMAIL'),\n            'websiteTitle' => $settings->get('KBIN_TITLE'),\n            'websiteOpenRegistrations' => $settings->get('KBIN_REGISTRATIONS_ENABLED'),\n            'websiteFederationEnabled' => $settings->get('KBIN_FEDERATION_ENABLED'),\n            'websiteDefaultLang' => $settings->get('KBIN_DEFAULT_LANG'),\n            'instanceModerators' => $moderators,\n            'instanceAdmins' => $admins,\n        ];\n\n        return new JsonResponse(\n            $body,\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Instance/InstanceRetrieveStatsApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Instance;\n\nuse App\\DTO\\ContentStatsResponseDto;\nuse App\\DTO\\VoteStatsResponseDto;\nuse App\\Repository\\StatsContentRepository;\nuse App\\Repository\\StatsVotesRepository;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpKernel\\Exception\\BadRequestHttpException;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\n\nclass InstanceRetrieveStatsApi extends InstanceBaseApi\n{\n    #[OA\\Response(\n        response: 200,\n        description: 'Votes by interval retrieved. These are not guaranteed to be continuous.',\n        content: new OA\\JsonContent(\n            properties: [\n                new OA\\Property(\n                    'entry',\n                    type: 'array',\n                    items: new OA\\Items(ref: new Model(type: VoteStatsResponseDto::class))\n                ),\n                new OA\\Property(\n                    'entry_comment',\n                    type: 'array',\n                    items: new OA\\Items(ref: new Model(type: VoteStatsResponseDto::class))\n                ),\n                new OA\\Property(\n                    'post',\n                    type: 'array',\n                    items: new OA\\Items(ref: new Model(type: VoteStatsResponseDto::class))\n                ),\n                new OA\\Property(\n                    'post_comment',\n                    type: 'array',\n                    items: new OA\\Items(ref: new Model(type: VoteStatsResponseDto::class))\n                ),\n            ]\n        ),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 400,\n        description: 'Invalid parameters',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\BadRequestErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'start',\n        in: 'query',\n        description: 'The start date of the window to retrieve votes in. If not provided defaults to 1 (resolution) ago',\n        schema: new OA\\Schema(type: 'string', format: 'date'),\n    )]\n    #[OA\\Parameter(\n        name: 'end',\n        in: 'query',\n        description: 'The end date of the window to retrieve votes in. If not provided defaults to today',\n        schema: new OA\\Schema(type: 'string', format: 'date'),\n    )]\n    #[OA\\Parameter(\n        name: 'resolution',\n        required: true,\n        in: 'query',\n        description: 'The size of chunks to aggregate votes in',\n        schema: new OA\\Schema(type: 'string', enum: ['all', 'year', 'month', 'day', 'hour']),\n    )]\n    #[OA\\Parameter(\n        name: 'local',\n        in: 'query',\n        description: 'Exclude federated votes?',\n        schema: new OA\\Schema(type: 'boolean', default: false),\n    )]\n    #[OA\\Tag(name: 'instance/stats')]\n    /**\n     * Retrieve the votes of the instance over time.\n     */\n    public function votes(\n        StatsVotesRepository $repository,\n        RateLimiterFactoryInterface $apiReadLimiter,\n        RateLimiterFactoryInterface $anonymousApiReadLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter);\n        $request = $this->request->getCurrentRequest();\n        $resolution = $request->get('resolution');\n        $local = filter_var($request->get('local', false), FILTER_VALIDATE_BOOL);\n\n        try {\n            $startString = $request->get('start');\n            if (null === $startString) {\n                $start = null;\n            } else {\n                $start = new \\DateTime($startString);\n            }\n\n            $endString = $request->get('end');\n            if (null === $endString) {\n                $end = null;\n            } else {\n                $end = new \\DateTime($endString);\n            }\n        } catch (\\Exception $e) {\n            throw new BadRequestHttpException('Failed to parse start or end time');\n        }\n\n        if (null === $resolution) {\n            throw new BadRequestHttpException('Resolution must be provided!');\n        }\n\n        try {\n            $stats = $repository->getStats(null, $resolution, $start, $end, $local);\n        } catch (\\LogicException $e) {\n            throw new BadRequestHttpException($e->getMessage());\n        }\n\n        return new JsonResponse(\n            $stats,\n            headers: $headers\n        );\n    }\n\n    #[OA\\Response(\n        response: 200,\n        description: 'Submissions by interval retrieved. These are not guaranteed to be continuous.',\n        content: new OA\\JsonContent(\n            properties: [\n                new OA\\Property(\n                    'entry',\n                    type: 'array',\n                    items: new OA\\Items(ref: new Model(type: ContentStatsResponseDto::class))\n                ),\n                new OA\\Property(\n                    'entry_comment',\n                    type: 'array',\n                    items: new OA\\Items(ref: new Model(type: ContentStatsResponseDto::class))\n                ),\n                new OA\\Property(\n                    'post',\n                    type: 'array',\n                    items: new OA\\Items(ref: new Model(type: ContentStatsResponseDto::class))\n                ),\n                new OA\\Property(\n                    'post_comment',\n                    type: 'array',\n                    items: new OA\\Items(ref: new Model(type: ContentStatsResponseDto::class))\n                ),\n            ]\n        ),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 400,\n        description: 'Invalid parameters',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\BadRequestErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'start',\n        in: 'query',\n        description: 'The start date of the window to retrieve submissions in. If not provided defaults to 1 (resolution) ago',\n        schema: new OA\\Schema(type: 'string', format: 'date'),\n    )]\n    #[OA\\Parameter(\n        name: 'end',\n        in: 'query',\n        description: 'The end date of the window to retrieve submissions in. If not provided defaults to today',\n        schema: new OA\\Schema(type: 'string', format: 'date'),\n    )]\n    #[OA\\Parameter(\n        name: 'resolution',\n        required: true,\n        in: 'query',\n        description: 'The size of chunks to aggregate content submissions in',\n        schema: new OA\\Schema(type: 'string', enum: ['all', 'year', 'month', 'day', 'hour']),\n    )]\n    #[OA\\Parameter(\n        name: 'local',\n        in: 'query',\n        description: 'Exclude federated content?',\n        schema: new OA\\Schema(type: 'boolean', default: false),\n    )]\n    #[OA\\Tag(name: 'instance/stats')]\n    /**\n     * Retrieve the content stats of the instance over time.\n     */\n    public function content(\n        StatsContentRepository $repository,\n        RateLimiterFactoryInterface $apiReadLimiter,\n        RateLimiterFactoryInterface $anonymousApiReadLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter);\n        $request = $this->request->getCurrentRequest();\n        $resolution = $request->get('resolution');\n        $local = filter_var($request->get('local', false), FILTER_VALIDATE_BOOL);\n\n        try {\n            $startString = $request->get('start');\n            if (null === $startString) {\n                $start = null;\n            } else {\n                $start = new \\DateTimeImmutable($startString);\n            }\n\n            $endString = $request->get('end');\n            if (null === $endString) {\n                $end = null;\n            } else {\n                $end = new \\DateTimeImmutable($endString);\n            }\n        } catch (\\Exception $e) {\n            throw new BadRequestHttpException('Failed to parse start or end time');\n        }\n\n        if (null === $resolution) {\n            throw new BadRequestHttpException('Resolution must be provided!');\n        }\n\n        try {\n            $stats = $repository->getStats(null, $resolution, $start, $end, $local);\n        } catch (\\LogicException $e) {\n            throw new BadRequestHttpException($e->getMessage());\n        }\n\n        return new JsonResponse(\n            $stats,\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Magazine/Admin/MagazineAddBadgesApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Magazine\\Admin;\n\nuse App\\Controller\\Api\\Magazine\\MagazineBaseApi;\nuse App\\DTO\\BadgeDto;\nuse App\\DTO\\MagazineResponseDto;\nuse App\\Entity\\Magazine;\nuse App\\Factory\\MagazineFactory;\nuse App\\Service\\BadgeManager;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse Nelmio\\ApiDocBundle\\Attribute\\Security;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpKernel\\Exception\\BadRequestHttpException;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\nuse Symfony\\Component\\Serializer\\SerializerInterface;\nuse Symfony\\Component\\Validator\\Validator\\ValidatorInterface;\n\nclass MagazineAddBadgesApi extends MagazineBaseApi\n{\n    #[OA\\Response(\n        response: 201,\n        description: 'Badge created',\n        content: new Model(type: MagazineResponseDto::class),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 400,\n        description: 'Badge name invalid',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\BadRequestErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'You do not have permission to update this magazine',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Magazine not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'magazine_id',\n        in: 'path',\n        description: 'The id of the magazine to update',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\RequestBody(content: new Model(type: BadgeDto::class, groups: ['create-badge']))]\n    #[OA\\Tag(name: 'moderation/magazine/owner')]\n    #[Security(name: 'oauth2', scopes: ['moderate:magazine_admin:badges'])]\n    #[IsGranted('ROLE_OAUTH2_MODERATE:MAGAZINE_ADMIN:BADGES')]\n    #[IsGranted('edit', subject: 'magazine')]\n    /**\n     * Add a badge to the magazine.\n     */\n    public function __invoke(\n        #[MapEntity(id: 'magazine_id')]\n        Magazine $magazine,\n        BadgeManager $manager,\n        MagazineFactory $factory,\n        SerializerInterface $serializer,\n        ValidatorInterface $validator,\n        RateLimiterFactoryInterface $apiModerateLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiModerateLimiter);\n\n        $request = $this->request->getCurrentRequest();\n        /**\n         * @var BadgeDto $dto\n         */\n        $dto = $serializer->deserialize($request->getContent(), BadgeDto::class, 'json', ['groups' => ['create-badge']]);\n\n        $dto->magazine = $magazine;\n\n        $errors = $validator->validate($dto);\n        if (0 < \\count($errors)) {\n            throw new BadRequestHttpException((string) $errors);\n        }\n\n        $manager->create($dto);\n\n        return new JsonResponse(\n            $this->serializeMagazine($factory->createDto($magazine)),\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Magazine/Admin/MagazineAddModeratorsApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Magazine\\Admin;\n\nuse App\\Controller\\Api\\Magazine\\MagazineBaseApi;\nuse App\\DTO\\MagazineResponseDto;\nuse App\\DTO\\ModeratorDto;\nuse App\\Entity\\Magazine;\nuse App\\Entity\\Moderator;\nuse App\\Entity\\User;\nuse App\\Factory\\MagazineFactory;\nuse App\\Service\\MagazineManager;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse Nelmio\\ApiDocBundle\\Attribute\\Security;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpKernel\\Exception\\BadRequestHttpException;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass MagazineAddModeratorsApi extends MagazineBaseApi\n{\n    #[OA\\Response(\n        response: 200,\n        description: 'Moderator added',\n        content: new Model(type: MagazineResponseDto::class),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 400,\n        description: 'User is already a moderator of this magazine',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\BadRequestErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'You do not have permission to update this magazine',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Magazine not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'magazine_id',\n        in: 'path',\n        description: 'The id of the magazine to update',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Parameter(\n        name: 'user_id',\n        in: 'path',\n        description: 'The id of the user to add as moderator',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Tag(name: 'moderation/magazine/owner')]\n    #[Security(name: 'oauth2', scopes: ['moderate:magazine_admin:moderators'])]\n    #[IsGranted('ROLE_OAUTH2_MODERATE:MAGAZINE_ADMIN:MODERATORS')]\n    #[IsGranted('edit', subject: 'magazine')]\n    /**\n     * Add a user as a moderator of the magazine.\n     */\n    public function __invoke(\n        #[MapEntity(id: 'magazine_id')]\n        Magazine $magazine,\n        #[MapEntity(id: 'user_id')]\n        User $user,\n        MagazineManager $manager,\n        MagazineFactory $factory,\n        RateLimiterFactoryInterface $apiModerateLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiModerateLimiter);\n\n        $moderator = $magazine->moderators->findFirst(fn (int $index, Moderator $moderator) => $moderator->user->getId() === $user->getId());\n\n        if (null !== $moderator) {\n            throw new BadRequestHttpException('The user is already a moderator of this magazine');\n        }\n\n        $dto = new ModeratorDto($magazine);\n\n        $dto->user = $user;\n        $dto->addedBy = $this->getUserOrThrow();\n\n        $manager->addModerator($dto);\n\n        return new JsonResponse(\n            $this->serializeMagazine($factory->createDto($magazine)),\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Magazine/Admin/MagazineAddTagsApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Magazine\\Admin;\n\nuse App\\Controller\\Api\\Magazine\\MagazineBaseApi;\nuse App\\DTO\\MagazineResponseDto;\nuse App\\Entity\\Magazine;\nuse App\\Factory\\MagazineFactory;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse Nelmio\\ApiDocBundle\\Attribute\\Security;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpKernel\\Exception\\BadRequestHttpException;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass MagazineAddTagsApi extends MagazineBaseApi\n{\n    #[OA\\Response(\n        response: 201,\n        description: 'Tag created',\n        content: new Model(type: MagazineResponseDto::class),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 400,\n        description: 'Tag not present, does not match /^[a-z]{2,32}$/, or already exists on magazine',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\BadRequestErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'You do not have permission to update this magazine',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Magazine not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'magazine_id',\n        in: 'path',\n        description: 'The id of the magazine to update',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Parameter(\n        name: 'tag',\n        in: 'path',\n        description: 'The tag to add',\n        schema: new OA\\Schema(type: 'string'),\n    )]\n    #[OA\\Tag(name: 'moderation/magazine/owner')]\n    #[Security(name: 'oauth2', scopes: ['moderate:magazine_admin:tags'])]\n    #[IsGranted('ROLE_OAUTH2_MODERATE:MAGAZINE_ADMIN:TAGS')]\n    #[IsGranted('edit', subject: 'magazine')]\n    /**\n     * Add a tag to the magazine.\n     */\n    public function __invoke(\n        #[MapEntity(id: 'magazine_id')]\n        Magazine $magazine,\n        string $tag,\n        EntityManagerInterface $entityManager,\n        MagazineFactory $factory,\n        RateLimiterFactoryInterface $apiModerateLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiModerateLimiter);\n\n        if (null === $tag) {\n            throw new BadRequestHttpException('Tag is required');\n        }\n\n        if (null !== $magazine->tags && false !== array_search($tag, $magazine->tags)) {\n            throw new BadRequestHttpException('Tag exists on magazine already');\n        }\n\n        if (1 !== preg_match('/^[a-z]{2,32}$/', $tag)) {\n            throw new BadRequestHttpException('Invalid tag');\n        }\n\n        if (null === $magazine->tags) {\n            $magazine->tags = [];\n        }\n\n        array_push($magazine->tags, $tag);\n\n        $entityManager->flush();\n\n        return new JsonResponse(\n            $this->serializeMagazine($factory->createDto($magazine)),\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Magazine/Admin/MagazineCreateApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Magazine\\Admin;\n\nuse App\\Controller\\Api\\Magazine\\MagazineBaseApi;\nuse App\\Controller\\Traits\\PrivateContentTrait;\nuse App\\DTO\\MagazineRequestDto;\nuse App\\DTO\\MagazineResponseDto;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse Nelmio\\ApiDocBundle\\Attribute\\Security;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass MagazineCreateApi extends MagazineBaseApi\n{\n    use PrivateContentTrait;\n\n    #[OA\\Response(\n        response: 201,\n        description: 'Magazine created',\n        content: new Model(type: MagazineResponseDto::class),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Magazine not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\RequestBody(content: new Model(type: MagazineRequestDto::class))]\n    #[OA\\Tag(name: 'moderation/magazine/owner')]\n    #[Security(name: 'oauth2', scopes: ['moderate:magazine_admin:create'])]\n    #[IsGranted('ROLE_OAUTH2_MODERATE:MAGAZINE_ADMIN:CREATE')]\n    public function __invoke(\n        RateLimiterFactoryInterface $apiMagazineLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiMagazineLimiter);\n\n        $magazine = $this->createMagazine();\n\n        return new JsonResponse(\n            $this->serializeMagazine($this->manager->createDto($magazine)),\n            status: 201,\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Magazine/Admin/MagazineDeleteApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Magazine\\Admin;\n\nuse App\\Controller\\Api\\Magazine\\MagazineBaseApi;\nuse App\\Entity\\Magazine;\nuse App\\Service\\MagazineManager;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse Nelmio\\ApiDocBundle\\Attribute\\Security;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass MagazineDeleteApi extends MagazineBaseApi\n{\n    #[OA\\Response(\n        response: 204,\n        description: 'Magazine deleted',\n        content: null,\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'You are not authorized to delete this magazine',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Magazine not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'magazine_id',\n        in: 'path',\n        description: 'The magazine to delete',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Tag(name: 'moderation/magazine/owner')]\n    #[Security(name: 'oauth2', scopes: ['moderate:magazine_admin:delete'])]\n    #[IsGranted('ROLE_OAUTH2_MODERATE:MAGAZINE_ADMIN:DELETE')]\n    #[IsGranted('delete', subject: 'magazine')]\n    public function __invoke(\n        #[MapEntity(id: 'magazine_id')]\n        Magazine $magazine,\n        MagazineManager $manager,\n        RateLimiterFactoryInterface $apiDeleteLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiDeleteLimiter);\n\n        $manager->delete($magazine);\n\n        return new JsonResponse(status: 204, headers: $headers);\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Magazine/Admin/MagazineDeleteBannerApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Magazine\\Admin;\n\nuse App\\Controller\\Api\\Magazine\\MagazineBaseApi;\nuse App\\DTO\\MagazineThemeResponseDto;\nuse App\\Entity\\Magazine;\nuse App\\Service\\MagazineManager;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse Nelmio\\ApiDocBundle\\Attribute\\Security;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass MagazineDeleteBannerApi extends MagazineBaseApi\n{\n    #[OA\\Response(\n        response: 200,\n        description: 'Banner removed',\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\\Schema(type: 'integer')),\n        ],\n        content: new Model(type: MagazineThemeResponseDto::class)\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'You do not have permission to delete this magazine\\'s banner',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Magazine not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\\Schema(type: 'integer')),\n        ],\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class))\n    )]\n    #[OA\\Parameter(\n        name: 'magazine_id',\n        description: 'The id of the magazine to remove the banner from',\n        in: 'path',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Tag(name: 'moderation/magazine/owner')]\n    #[Security(name: 'oauth2', scopes: ['moderate:magazine_admin:theme'])]\n    #[IsGranted('ROLE_OAUTH2_MODERATE:MAGAZINE_ADMIN:THEME')]\n    #[IsGranted('edit', subject: 'magazine')]\n    /**\n     * Update the magazine's theme.\n     */\n    public function __invoke(\n        #[MapEntity(id: 'magazine_id')]\n        Magazine $magazine,\n        MagazineManager $manager,\n        RateLimiterFactoryInterface $apiModerateLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiModerateLimiter);\n\n        $manager->detachBanner($magazine);\n\n        $iconDto = $magazine->icon ? $this->imageFactory->createDto($magazine->icon) : null;\n        $dto = MagazineThemeResponseDto::create($manager->createDto($magazine), $magazine->customCss, $iconDto, banner: null);\n\n        return new JsonResponse(\n            $dto,\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Magazine/Admin/MagazineDeleteIconApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Magazine\\Admin;\n\nuse App\\Controller\\Api\\Magazine\\MagazineBaseApi;\nuse App\\DTO\\MagazineThemeResponseDto;\nuse App\\Entity\\Magazine;\nuse App\\Service\\MagazineManager;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse Nelmio\\ApiDocBundle\\Attribute\\Security;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass MagazineDeleteIconApi extends MagazineBaseApi\n{\n    #[OA\\Response(\n        response: 200,\n        description: 'Icon removed',\n        content: new Model(type: MagazineThemeResponseDto::class),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'You do not have permission to delete this magazine\\'s icon',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Magazine not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'magazine_id',\n        in: 'path',\n        description: 'The id of the magazine to remove the icon from',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Tag(name: 'moderation/magazine/owner')]\n    #[Security(name: 'oauth2', scopes: ['moderate:magazine_admin:theme'])]\n    #[IsGranted('ROLE_OAUTH2_MODERATE:MAGAZINE_ADMIN:THEME')]\n    #[IsGranted('edit', subject: 'magazine')]\n    /**\n     * Update the magazine's theme.\n     */\n    public function __invoke(\n        #[MapEntity(id: 'magazine_id')]\n        Magazine $magazine,\n        MagazineManager $manager,\n        RateLimiterFactoryInterface $apiModerateLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiModerateLimiter);\n\n        $manager->detachIcon($magazine);\n\n        $bannerDto = $magazine->banner ? $this->imageFactory->createDto($magazine->banner) : null;\n        $dto = MagazineThemeResponseDto::create($manager->createDto($magazine), $magazine->customCss, icon: null, banner: $bannerDto);\n\n        return new JsonResponse(\n            $dto,\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Magazine/Admin/MagazinePurgeApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Magazine\\Admin;\n\nuse App\\Controller\\Api\\Magazine\\MagazineBaseApi;\nuse App\\Entity\\Magazine;\nuse App\\Service\\MagazineManager;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse Nelmio\\ApiDocBundle\\Attribute\\Security;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass MagazinePurgeApi extends MagazineBaseApi\n{\n    #[OA\\Response(\n        response: 204,\n        description: 'Magazine purged',\n        content: null,\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'You are not authorized to purge this magazine',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Magazine not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'magazine_id',\n        in: 'path',\n        description: 'The magazine to purge',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Tag(name: 'admin/magazine')]\n    #[Security(name: 'oauth2', scopes: ['admin:magazine:purge'])]\n    #[IsGranted('ROLE_OAUTH2_ADMIN:MAGAZINE:PURGE')]\n    #[IsGranted('purge', subject: 'magazine')]\n    public function __invoke(\n        #[MapEntity(id: 'magazine_id')]\n        Magazine $magazine,\n        MagazineManager $manager,\n        RateLimiterFactoryInterface $apiDeleteLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiDeleteLimiter);\n\n        $manager->purge($magazine);\n\n        return new JsonResponse(status: 204, headers: $headers);\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Magazine/Admin/MagazineRemoveBadgesApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Magazine\\Admin;\n\nuse App\\Controller\\Api\\Magazine\\MagazineBaseApi;\nuse App\\DTO\\MagazineResponseDto;\nuse App\\Entity\\Badge;\nuse App\\Entity\\Magazine;\nuse App\\Factory\\MagazineFactory;\nuse App\\Service\\BadgeManager;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse Nelmio\\ApiDocBundle\\Attribute\\Security;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpKernel\\Exception\\NotFoundHttpException;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass MagazineRemoveBadgesApi extends MagazineBaseApi\n{\n    #[OA\\Response(\n        response: 200,\n        description: 'Badge deleted',\n        content: new Model(type: MagazineResponseDto::class),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'You do not have permission to update this magazine',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Magazine or badge not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'magazine_id',\n        in: 'path',\n        description: 'The id of the magazine to update',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Parameter(\n        name: 'badge_id',\n        in: 'path',\n        description: 'The id of the badge to delete',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Tag(name: 'moderation/magazine/owner')]\n    #[Security(name: 'oauth2', scopes: ['moderate:magazine_admin:badges'])]\n    #[IsGranted('ROLE_OAUTH2_MODERATE:MAGAZINE_ADMIN:BADGES')]\n    #[IsGranted('edit', subject: 'magazine')]\n    /**\n     * Remove a badge from the magazine.\n     */\n    public function __invoke(\n        #[MapEntity(id: 'magazine_id')]\n        Magazine $magazine,\n        #[MapEntity(id: 'badge_id')]\n        Badge $badge,\n        BadgeManager $manager,\n        MagazineFactory $factory,\n        RateLimiterFactoryInterface $apiModerateLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiModerateLimiter);\n\n        if ($badge->magazine->getId() !== $magazine->getId()) {\n            throw new NotFoundHttpException('Badge not found on magazine');\n        }\n\n        $manager->delete($badge);\n\n        return new JsonResponse(\n            $this->serializeMagazine($factory->createDto($magazine)),\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Magazine/Admin/MagazineRemoveModeratorsApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Magazine\\Admin;\n\nuse App\\Controller\\Api\\Magazine\\MagazineBaseApi;\nuse App\\DTO\\MagazineResponseDto;\nuse App\\Entity\\Magazine;\nuse App\\Entity\\Moderator;\nuse App\\Entity\\User;\nuse App\\Factory\\MagazineFactory;\nuse App\\Service\\MagazineManager;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse Nelmio\\ApiDocBundle\\Attribute\\Security;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpKernel\\Exception\\BadRequestHttpException;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass MagazineRemoveModeratorsApi extends MagazineBaseApi\n{\n    #[OA\\Response(\n        response: 200,\n        description: 'Moderator removed',\n        content: new Model(type: MagazineResponseDto::class),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 400,\n        description: 'User is not a moderator of this magazine',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\BadRequestErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'You do not have permission to update this magazine',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Magazine not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'magazine_id',\n        in: 'path',\n        description: 'The id of the magazine to update',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Parameter(\n        name: 'user_id',\n        in: 'path',\n        description: 'The id of the user to remove as moderator',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Tag(name: 'moderation/magazine/owner')]\n    #[Security(name: 'oauth2', scopes: ['moderate:magazine_admin:moderators'])]\n    #[IsGranted('ROLE_OAUTH2_MODERATE:MAGAZINE_ADMIN:MODERATORS')]\n    #[IsGranted('edit', subject: 'magazine')]\n    /**\n     * Remove a moderator from the magazine.\n     */\n    public function __invoke(\n        #[MapEntity(id: 'magazine_id')]\n        Magazine $magazine,\n        #[MapEntity(id: 'user_id')]\n        User $user,\n        MagazineManager $manager,\n        MagazineFactory $factory,\n        RateLimiterFactoryInterface $apiModerateLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiModerateLimiter);\n\n        $moderator = $magazine->moderators->findFirst(fn (int $index, Moderator $moderator) => $moderator->user->getId() === $user->getId());\n\n        if (null === $moderator) {\n            throw new BadRequestHttpException('Given user is not a moderator of this magazine');\n        }\n\n        $manager->removeModerator($moderator, $this->getUserOrThrow());\n\n        return new JsonResponse(\n            $this->serializeMagazine($factory->createDto($magazine)),\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Magazine/Admin/MagazineRemoveTagsApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Magazine\\Admin;\n\nuse App\\Controller\\Api\\Magazine\\MagazineBaseApi;\nuse App\\DTO\\MagazineResponseDto;\nuse App\\Entity\\Magazine;\nuse App\\Factory\\MagazineFactory;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse Nelmio\\ApiDocBundle\\Attribute\\Security;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpKernel\\Exception\\AccessDeniedHttpException;\nuse Symfony\\Component\\HttpKernel\\Exception\\BadRequestHttpException;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass MagazineRemoveTagsApi extends MagazineBaseApi\n{\n    #[OA\\Response(\n        response: 200,\n        description: 'Tag deleted',\n        content: new Model(type: MagazineResponseDto::class),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'You do not have permission to update this magazine',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Magazine or tag not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'magazine_id',\n        in: 'path',\n        description: 'The id of the magazine to update',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Parameter(\n        name: 'tag',\n        in: 'path',\n        description: 'The tag to remove',\n        schema: new OA\\Schema(type: 'string'),\n    )]\n    #[OA\\Tag(name: 'moderation/magazine/owner')]\n    #[Security(name: 'oauth2', scopes: ['moderate:magazine_admin:tags'])]\n    #[IsGranted('ROLE_OAUTH2_MODERATE:MAGAZINE_ADMIN:TAGS')]\n    #[IsGranted('edit', subject: 'magazine')]\n    /**\n     * Remove a tag from the magazine.\n     */\n    public function __invoke(\n        #[MapEntity(id: 'magazine_id')]\n        Magazine $magazine,\n        string $tag,\n        EntityManagerInterface $entityManager,\n        MagazineFactory $factory,\n        RateLimiterFactoryInterface $apiModerateLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiModerateLimiter);\n\n        if (!$magazine->userIsOwner($this->getUserOrThrow())) {\n            throw new AccessDeniedHttpException();\n        }\n\n        if (null === $tag) {\n            throw new BadRequestHttpException('Tag must be present');\n        }\n\n        if (null === $magazine->tags) {\n            $index = false;\n        } else {\n            $index = array_search($tag, $magazine->tags);\n        }\n\n        if (false === $index) {\n            throw new BadRequestHttpException('Tag is not present on magazine');\n        }\n\n        array_splice($magazine->tags, $index, 1);\n\n        if (0 === \\count($magazine->tags)) {\n            $magazine->tags = null;\n        }\n\n        $entityManager->flush();\n\n        return new JsonResponse(\n            $this->serializeMagazine($factory->createDto($magazine)),\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Magazine/Admin/MagazineRetrieveStatsApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Magazine\\Admin;\n\nuse App\\Controller\\Api\\Magazine\\MagazineBaseApi;\nuse App\\DTO\\ContentStatsResponseDto;\nuse App\\DTO\\VoteStatsResponseDto;\nuse App\\Entity\\Magazine;\nuse App\\Repository\\StatsContentRepository;\nuse App\\Repository\\StatsVotesRepository;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse Nelmio\\ApiDocBundle\\Attribute\\Security;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpKernel\\Exception\\BadRequestHttpException;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass MagazineRetrieveStatsApi extends MagazineBaseApi\n{\n    #[OA\\Response(\n        response: 200,\n        description: 'Votes by interval retrieved. These are not guaranteed to be continuous.',\n        content: new OA\\JsonContent(\n            properties: [\n                new OA\\Property(\n                    'entry',\n                    type: 'array',\n                    items: new OA\\Items(ref: new Model(type: VoteStatsResponseDto::class))\n                ),\n                new OA\\Property(\n                    'entry_comment',\n                    type: 'array',\n                    items: new OA\\Items(ref: new Model(type: VoteStatsResponseDto::class))\n                ),\n                new OA\\Property(\n                    'post',\n                    type: 'array',\n                    items: new OA\\Items(ref: new Model(type: VoteStatsResponseDto::class))\n                ),\n                new OA\\Property(\n                    'post_comment',\n                    type: 'array',\n                    items: new OA\\Items(ref: new Model(type: VoteStatsResponseDto::class))\n                ),\n            ]\n        ),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 400,\n        description: 'Invalid parameters',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\BadRequestErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'You do not have permission to view the stats of this magazine',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Magazine not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'magazine_id',\n        in: 'path',\n        description: 'The id of the magazine to retrieve stats from',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Parameter(\n        name: 'start',\n        in: 'query',\n        description: 'The start date of the window to retrieve votes in. If not provided defaults to 1 (resolution) ago',\n        schema: new OA\\Schema(type: 'string', format: 'date'),\n    )]\n    #[OA\\Parameter(\n        name: 'end',\n        in: 'query',\n        description: 'The end date of the window to retrieve votes in. If not provided defaults to today',\n        schema: new OA\\Schema(type: 'string', format: 'date'),\n    )]\n    #[OA\\Parameter(\n        name: 'resolution',\n        required: true,\n        in: 'query',\n        description: 'The size of chunks to aggregate votes in',\n        schema: new OA\\Schema(type: 'string', enum: ['all', 'year', 'month', 'day', 'hour']),\n    )]\n    #[OA\\Parameter(\n        name: 'local',\n        in: 'query',\n        description: 'Exclude federated votes?',\n        schema: new OA\\Schema(type: 'boolean', default: false),\n    )]\n    #[OA\\Tag(name: 'moderation/magazine/owner')]\n    #[Security(name: 'oauth2', scopes: ['moderate:magazine_admin:stats'])]\n    #[IsGranted('ROLE_OAUTH2_MODERATE:MAGAZINE_ADMIN:STATS')]\n    #[IsGranted('edit', subject: 'magazine')]\n    /**\n     * Retrieve the votes of a magazine over time.\n     */\n    public function votes(\n        #[MapEntity(id: 'magazine_id')]\n        Magazine $magazine,\n        StatsVotesRepository $repository,\n        RateLimiterFactoryInterface $apiModerateLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiModerateLimiter);\n        $request = $this->request->getCurrentRequest();\n        $resolution = $request->get('resolution');\n        $local = filter_var($request->get('local', false), FILTER_VALIDATE_BOOL);\n\n        try {\n            $startString = $request->get('start');\n            if (null === $startString) {\n                $start = null;\n            } else {\n                $start = new \\DateTime($startString);\n            }\n\n            $endString = $request->get('end');\n            if (null === $endString) {\n                $end = null;\n            } else {\n                $end = new \\DateTime($endString);\n            }\n        } catch (\\Exception $e) {\n            throw new BadRequestHttpException('Failed to parse start or end time');\n        }\n\n        if (null === $resolution) {\n            throw new BadRequestHttpException('Resolution must be provided!');\n        }\n\n        try {\n            $stats = $repository->getStats($magazine, $resolution, $start, $end, $local);\n        } catch (\\LogicException $e) {\n            throw new BadRequestHttpException($e->getMessage());\n        }\n\n        return new JsonResponse(\n            $stats,\n            headers: $headers\n        );\n    }\n\n    #[OA\\Response(\n        response: 200,\n        description: 'Submissions by interval retrieved. These are not guaranteed to be continuous.',\n        content: new OA\\JsonContent(\n            properties: [\n                new OA\\Property(\n                    'entry',\n                    type: 'array',\n                    items: new OA\\Items(ref: new Model(type: ContentStatsResponseDto::class))\n                ),\n                new OA\\Property(\n                    'entry_comment',\n                    type: 'array',\n                    items: new OA\\Items(ref: new Model(type: ContentStatsResponseDto::class))\n                ),\n                new OA\\Property(\n                    'post',\n                    type: 'array',\n                    items: new OA\\Items(ref: new Model(type: ContentStatsResponseDto::class))\n                ),\n                new OA\\Property(\n                    'post_comment',\n                    type: 'array',\n                    items: new OA\\Items(ref: new Model(type: ContentStatsResponseDto::class))\n                ),\n            ]\n        ),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 400,\n        description: 'Invalid parameters',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\BadRequestErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'You do not have permission to view the stats of this magazine',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Magazine not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'magazine_id',\n        in: 'path',\n        description: 'The id of the magazine to retrieve stats from',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Parameter(\n        name: 'start',\n        in: 'query',\n        description: 'The start date of the window to retrieve submissions in. If not provided defaults to 1 (resolution) ago',\n        schema: new OA\\Schema(type: 'string', format: 'date'),\n    )]\n    #[OA\\Parameter(\n        name: 'end',\n        in: 'query',\n        description: 'The end date of the window to retrieve submissions in. If not provided defaults to today',\n        schema: new OA\\Schema(type: 'string', format: 'date'),\n    )]\n    #[OA\\Parameter(\n        name: 'resolution',\n        required: true,\n        in: 'query',\n        description: 'The size of chunks to aggregate content submissions in',\n        schema: new OA\\Schema(type: 'string', enum: ['all', 'year', 'month', 'day', 'hour']),\n    )]\n    #[OA\\Parameter(\n        name: 'local',\n        in: 'query',\n        description: 'Exclude federated content?',\n        schema: new OA\\Schema(type: 'boolean', default: false),\n    )]\n    #[OA\\Tag(name: 'moderation/magazine/owner')]\n    #[Security(name: 'oauth2', scopes: ['moderate:magazine_admin:stats'])]\n    #[IsGranted('ROLE_OAUTH2_MODERATE:MAGAZINE_ADMIN:STATS')]\n    #[IsGranted('edit', subject: 'magazine')]\n    /**\n     * Retrieve the content stats of a magazine over time.\n     */\n    public function content(\n        #[MapEntity(id: 'magazine_id')]\n        Magazine $magazine,\n        StatsContentRepository $repository,\n        RateLimiterFactoryInterface $apiModerateLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiModerateLimiter);\n        $request = $this->request->getCurrentRequest();\n        $resolution = $request->get('resolution');\n        $local = filter_var($request->get('local', false), FILTER_VALIDATE_BOOL);\n\n        try {\n            $startString = $request->get('start');\n            if (null === $startString) {\n                $start = null;\n            } else {\n                $start = new \\DateTimeImmutable($startString);\n            }\n\n            $endString = $request->get('end');\n            if (null === $endString) {\n                $end = null;\n            } else {\n                $end = new \\DateTimeImmutable($endString);\n            }\n        } catch (\\Exception $e) {\n            throw new BadRequestHttpException('Failed to parse start or end time');\n        }\n\n        if (null === $resolution) {\n            throw new BadRequestHttpException('Resolution must be provided!');\n        }\n\n        try {\n            $stats = $repository->getStats($magazine, $resolution, $start, $end, $local);\n        } catch (\\LogicException $e) {\n            throw new BadRequestHttpException($e->getMessage());\n        }\n\n        return new JsonResponse(\n            $stats,\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Magazine/Admin/MagazineUpdateApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Magazine\\Admin;\n\nuse App\\Controller\\Api\\Magazine\\MagazineBaseApi;\nuse App\\DTO\\MagazineResponseDto;\nuse App\\DTO\\MagazineUpdateRequestDto;\nuse App\\Entity\\Magazine;\nuse App\\Factory\\MagazineFactory;\nuse App\\Service\\MagazineManager;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse Nelmio\\ApiDocBundle\\Attribute\\Security;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpKernel\\Exception\\AccessDeniedHttpException;\nuse Symfony\\Component\\HttpKernel\\Exception\\BadRequestHttpException;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\nuse Symfony\\Component\\Validator\\Validator\\ValidatorInterface;\nuse Symfony\\Contracts\\Translation\\TranslatorInterface;\n\nclass MagazineUpdateApi extends MagazineBaseApi\n{\n    #[OA\\Response(\n        response: 200,\n        description: 'Magazine updated',\n        content: new Model(type: MagazineResponseDto::class),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 400,\n        description: 'Update parameters were invalid',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\BadRequestErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'You do not have permission to update this magazine',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Magazine not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'magazine_id',\n        in: 'path',\n        description: 'The id of the magazine to update',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\RequestBody(content: new Model(type: MagazineUpdateRequestDto::class))]\n    #[OA\\Tag(name: 'moderation/magazine/owner')]\n    #[Security(name: 'oauth2', scopes: ['moderate:magazine_admin:update'])]\n    #[IsGranted('ROLE_OAUTH2_MODERATE:MAGAZINE_ADMIN:UPDATE')]\n    #[IsGranted('edit', subject: 'magazine')]\n    public function __invoke(\n        #[MapEntity(id: 'magazine_id')]\n        Magazine $magazine,\n        MagazineManager $manager,\n        ValidatorInterface $validator,\n        MagazineFactory $factory,\n        RateLimiterFactoryInterface $apiUpdateLimiter,\n        TranslatorInterface $translator,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiUpdateLimiter);\n\n        if (!$magazine->userIsOwner($this->getUserOrThrow())) {\n            throw new AccessDeniedHttpException();\n        }\n\n        $dto = $this->deserializeMagazine($manager->createDto($magazine));\n\n        $errors = $validator->validate($dto);\n        if (\\count($errors) > 0) {\n            throw new BadRequestHttpException((string) $errors);\n        }\n\n        if (!$magazine->rules && $dto->rules) {\n            throw new BadRequestHttpException($translator->trans('magazine_rules_deprecated'));\n        }\n\n        if ($magazine->name !== $dto->name) {\n            throw new BadRequestHttpException('Magazine name cannot be edited');\n        }\n\n        $magazine = $manager->edit($magazine, $dto, $this->getUserOrThrow());\n\n        return new JsonResponse(\n            $this->serializeMagazine($factory->createDto($magazine)),\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Magazine/Admin/MagazineUpdateThemeApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Magazine\\Admin;\n\nuse App\\Controller\\Api\\Magazine\\MagazineBaseApi;\nuse App\\DTO\\ImageUploadDto;\nuse App\\DTO\\MagazineThemeDto;\nuse App\\DTO\\MagazineThemeRequestDto;\nuse App\\DTO\\MagazineThemeResponseDto;\nuse App\\Entity\\Magazine;\nuse App\\Factory\\ImageFactory;\nuse App\\Schema\\Errors\\BadRequestErrorSchema;\nuse App\\Schema\\Errors\\ForbiddenErrorSchema;\nuse App\\Schema\\Errors\\TooManyRequestsErrorSchema;\nuse App\\Schema\\Errors\\UnauthorizedErrorSchema;\nuse App\\Service\\MagazineManager;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse Nelmio\\ApiDocBundle\\Attribute\\Security;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpKernel\\Exception\\BadRequestHttpException;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\nuse Symfony\\Component\\Validator\\Validator\\ValidatorInterface;\n\nclass MagazineUpdateThemeApi extends MagazineBaseApi\n{\n    #[OA\\Response(\n        response: 201,\n        description: 'Theme updated',\n        content: new Model(type: MagazineThemeResponseDto::class),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 400,\n        description: 'Invalid parameters',\n        content: new OA\\JsonContent(ref: new Model(type: BadRequestErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'You do not have permission to update this magazine',\n        content: new OA\\JsonContent(ref: new Model(type: ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Magazine not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'magazine_id',\n        in: 'path',\n        description: 'The id of the magazine to update',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\RequestBody(content: new OA\\MediaType(\n        'multipart/form-data',\n        schema: new OA\\Schema(\n            ref: new Model(\n                type: MagazineThemeRequestDto::class,\n                groups: [\n                    ImageUploadDto::IMAGE_UPLOAD_NO_ALT,\n                    'common',\n                ]\n            )\n        )\n    ))]\n    #[OA\\Tag(name: 'moderation/magazine/owner')]\n    #[Security(name: 'oauth2', scopes: ['moderate:magazine_admin:theme'])]\n    #[IsGranted('ROLE_OAUTH2_MODERATE:MAGAZINE_ADMIN:THEME')]\n    #[IsGranted('edit', subject: 'magazine')]\n    /**\n     * Update the magazine's theme.\n     */\n    public function __invoke(\n        #[MapEntity(id: 'magazine_id')]\n        Magazine $magazine,\n        MagazineManager $manager,\n        ImageFactory $imageFactory,\n        ValidatorInterface $validator,\n        RateLimiterFactoryInterface $apiModerateLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiModerateLimiter);\n\n        $dto = new MagazineThemeDto($magazine);\n        $dto = $this->deserializeThemeFromForm($dto);\n\n        try {\n            $icon = $this->handleUploadedImage();\n            $dto->icon = $imageFactory->createDto($icon);\n        } catch (BadRequestHttpException $e) {\n            $dto->icon = null; // Todo: add an API to remove the icon rather than only replace\n        }\n\n        $errors = $validator->validate($dto);\n        if (0 < \\count($errors)) {\n            throw new BadRequestHttpException((string) $errors);\n        }\n\n        $manager->changeTheme($dto);\n\n        $imageDto = $magazine->icon ? $this->imageFactory->createDto($magazine->icon) : null;\n        $bannerDto = $magazine->banner ? $this->imageFactory->createDto($magazine->banner) : null;\n        $dto = MagazineThemeResponseDto::create($manager->createDto($magazine), $magazine->customCss, $imageDto, $bannerDto);\n\n        return new JsonResponse(\n            $dto,\n            headers: $headers\n        );\n    }\n\n    #[OA\\Response(\n        response: 200,\n        description: 'Magazine banner updated',\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\\Schema(type: 'integer')),\n        ],\n        content: new Model(type: MagazineThemeResponseDto::class)\n    )]\n    #[OA\\Response(\n        response: 400,\n        description: 'The uploaded image was missing or invalid',\n        content: new OA\\JsonContent(ref: new Model(type: BadRequestErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'You are not authorized to update the magazine\\'s banner',\n        content: new OA\\JsonContent(ref: new Model(type: ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\\Schema(type: 'integer')),\n        ],\n        content: new OA\\JsonContent(ref: new Model(type: TooManyRequestsErrorSchema::class))\n    )]\n    #[OA\\RequestBody(content: new OA\\MediaType(\n        'multipart/form-data',\n        schema: new OA\\Schema(\n            ref: new Model(\n                type: ImageUploadDto::class,\n                groups: [\n                    ImageUploadDto::IMAGE_UPLOAD_NO_ALT,\n                ]\n            )\n        )\n    ))]\n    #[OA\\Tag(name: 'moderation/magazine/owner')]\n    #[Security(name: 'oauth2', scopes: ['moderate:magazine_admin:theme'])]\n    #[IsGranted('ROLE_OAUTH2_MODERATE:MAGAZINE_ADMIN:THEME')]\n    #[IsGranted('edit', subject: 'magazine')]\n    public function banner(\n        #[MapEntity(id: 'magazine_id')]\n        Magazine $magazine,\n        MagazineManager $manager,\n        RateLimiterFactoryInterface $apiModerateLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiModerateLimiter);\n\n        $image = $this->handleUploadedImage();\n\n        if (null !== $magazine->banner && $image->getId() !== $magazine->banner->getId()) {\n            $manager->detachBanner($magazine);\n        }\n\n        $dto = new MagazineThemeDto($magazine);\n\n        $dto->banner = $image ? $this->imageFactory->createDto($image) : $dto->banner;\n\n        $magazine = $manager->changeTheme($dto);\n        $iconDto = $magazine->icon ? $this->imageFactory->createDto($magazine->icon) : null;\n        $bannerDto = $magazine->banner ? $this->imageFactory->createDto($magazine->banner) : null;\n\n        return new JsonResponse(\n            MagazineThemeResponseDto::create($manager->createDto($magazine), $magazine->customCss, $iconDto, $bannerDto),\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Magazine/MagazineBaseApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Magazine;\n\nuse App\\Controller\\Api\\BaseApi;\nuse App\\DTO\\ImageDto;\nuse App\\DTO\\MagazineDto;\nuse App\\DTO\\MagazineRequestDto;\nuse App\\DTO\\MagazineThemeDto;\nuse App\\DTO\\MagazineThemeRequestDto;\nuse App\\Entity\\Magazine;\nuse App\\Entity\\Report;\nuse App\\Factory\\ReportFactory;\nuse App\\Service\\MagazineManager;\nuse Symfony\\Component\\HttpKernel\\Exception\\BadRequestHttpException;\nuse Symfony\\Contracts\\Service\\Attribute\\Required;\n\nclass MagazineBaseApi extends BaseApi\n{\n    private readonly ReportFactory $reportFactory;\n    protected readonly MagazineManager $manager;\n\n    #[Required]\n    public function setReportFactory(ReportFactory $reportFactory)\n    {\n        $this->reportFactory = $reportFactory;\n    }\n\n    #[Required]\n    public function setManager(MagazineManager $manager)\n    {\n        $this->manager = $manager;\n    }\n\n    protected function serializeReport(Report $report)\n    {\n        $response = $this->reportFactory->createResponseDto($report);\n\n        return $response;\n    }\n\n    /**\n     * Deserialize a magazine from JSON.\n     *\n     * @param ?MagazineDto $dto The MagazineDto to modify with new values (default: null to create a new MagazineDto)\n     *\n     * @return MagazineDto A magazine with only certain fields allowed to be modified by the user\n     */\n    protected function deserializeMagazine(?MagazineDto $dto = null): MagazineDto\n    {\n        $dto = $dto ?? new MagazineDto();\n        $deserialized = $this->serializer->deserialize($this->request->getCurrentRequest()->getContent(), MagazineRequestDto::class, 'json');\n        \\assert($deserialized instanceof MagazineRequestDto);\n\n        return $deserialized->mergeIntoDto($dto);\n    }\n\n    protected function deserializeThemeFromForm(MagazineThemeDto $dto): MagazineThemeDto\n    {\n        $deserialized = new MagazineThemeRequestDto();\n        $deserialized->customCss = $this->request->getCurrentRequest()->get('customCss');\n        $deserialized->backgroundImage = $this->request->getCurrentRequest()->get('backgroundImage');\n\n        $dto = $deserialized->mergeIntoDto($dto);\n\n        return $dto;\n    }\n\n    protected function createMagazine(?ImageDto $image = null): Magazine\n    {\n        $dto = $this->deserializeMagazine();\n\n        if ($image) {\n            $dto->icon = $image;\n        }\n\n        $errors = $this->validator->validate($dto);\n        if (\\count($errors) > 0) {\n            throw new BadRequestHttpException((string) $errors);\n        }\n\n        if (!empty($dto->rules)) {\n            throw new BadRequestHttpException($this->translator->trans('magazine_rules_deprecated'));\n        }\n\n        // Rate limit handled elsewhere\n        $magazine = $this->manager->create($dto, $this->getUserOrThrow(), rateLimit: false);\n\n        return $magazine;\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Magazine/MagazineBlockApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Magazine;\n\nuse App\\DTO\\MagazineResponseDto;\nuse App\\Entity\\Magazine;\nuse App\\Factory\\MagazineFactory;\nuse App\\Service\\MagazineManager;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse Nelmio\\ApiDocBundle\\Attribute\\Security;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass MagazineBlockApi extends MagazineBaseApi\n{\n    #[OA\\Response(\n        response: 200,\n        description: 'Magazine blocked',\n        content: new Model(type: MagazineResponseDto::class),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Magazine not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'magazine_id',\n        in: 'path',\n        description: 'The magazine to block',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Tag(name: 'magazine')]\n    #[Security(name: 'oauth2', scopes: ['magazine:block'])]\n    #[IsGranted('ROLE_OAUTH2_MAGAZINE:BLOCK')]\n    #[IsGranted('block', subject: 'magazine')]\n    public function block(\n        #[MapEntity(id: 'magazine_id')]\n        Magazine $magazine,\n        MagazineManager $manager,\n        MagazineFactory $factory,\n        RateLimiterFactoryInterface $apiUpdateLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiUpdateLimiter);\n\n        $manager->block($magazine, $this->getUserOrThrow());\n\n        return new JsonResponse(\n            $this->serializeMagazine($factory->createDto($magazine)),\n            headers: $headers\n        );\n    }\n\n    #[OA\\Response(\n        response: 200,\n        description: 'Magazine unblocked',\n        content: new Model(type: MagazineResponseDto::class),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Magazine not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'magazine_id',\n        in: 'path',\n        description: 'The magazine to unblock',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Tag(name: 'magazine')]\n    #[Security(name: 'oauth2', scopes: ['magazine:block'])]\n    #[IsGranted('ROLE_OAUTH2_MAGAZINE:BLOCK')]\n    #[IsGranted('block', subject: 'magazine')]\n    public function unblock(\n        #[MapEntity(id: 'magazine_id')]\n        Magazine $magazine,\n        MagazineManager $manager,\n        MagazineFactory $factory,\n        RateLimiterFactoryInterface $apiUpdateLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiUpdateLimiter);\n\n        $manager->unblock($magazine, $this->getUserOrThrow());\n\n        return new JsonResponse(\n            $this->serializeMagazine($factory->createDto($magazine)),\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Magazine/MagazineModLogApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Magazine;\n\nuse App\\DTO\\MagazineLogResponseDto;\nuse App\\Entity\\Magazine;\nuse App\\Entity\\MagazineLog;\nuse App\\Repository\\MagazineLogRepository;\nuse App\\Schema\\PaginationSchema;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpKernel\\Attribute\\MapQueryParameter;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\n\nclass MagazineModLogApi extends MagazineBaseApi\n{\n    #[OA\\Response(\n        response: 200,\n        description: 'Returns the magazine\\'s moderation log',\n        content: new OA\\JsonContent(\n            type: 'object',\n            properties: [\n                new OA\\Property(\n                    property: 'items',\n                    type: 'array',\n                    items: new OA\\Items(ref: new Model(type: MagazineLogResponseDto::class))\n                ),\n                new OA\\Property(\n                    property: 'pagination',\n                    ref: new Model(type: PaginationSchema::class)\n                ),\n            ]\n        ),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Page not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'magazine_id',\n        description: 'Magazine to get mod log from',\n        in: 'path',\n        schema: new OA\\Schema(type: 'integer')\n    )]\n    #[OA\\Parameter(\n        name: 'p',\n        description: 'Page of moderation log to retrieve',\n        in: 'query',\n        schema: new OA\\Schema(type: 'integer', default: 1, minimum: 1)\n    )]\n    #[OA\\Parameter(\n        name: 'perPage',\n        description: 'Number of moderation log items to retrieve per page',\n        in: 'query',\n        schema: new OA\\Schema(type: 'integer', default: MagazineLogRepository::PER_PAGE, minimum: self::MIN_PER_PAGE, maximum: self::MAX_PER_PAGE)\n    )]\n    #[OA\\Parameter(\n        name: 'types[]',\n        description: 'The types of magazine logs to retrieve',\n        in: 'query',\n        schema: new OA\\Schema(type: 'array', items: new OA\\Items(type: 'string', enum: MagazineLog::CHOICES))\n    )]\n    #[OA\\Tag('magazine')]\n    /**\n     * Retrieve information about moderation actions taken in the magazine.\n     */\n    public function collection(\n        #[MapEntity(id: 'magazine_id')]\n        Magazine $magazine,\n        MagazineLogRepository $repository,\n        RateLimiterFactoryInterface $apiReadLimiter,\n        RateLimiterFactoryInterface $anonymousApiReadLimiter,\n        #[MapQueryParameter] ?array $types = null,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter);\n\n        $request = $this->request->getCurrentRequest();\n        $logs = $repository->findByCustom(\n            $this->getPageNb($request),\n            self::constrainPerPage($request->get('perPage', MagazineLogRepository::PER_PAGE)),\n            types: $types,\n            magazine: $magazine,\n        );\n\n        $dtos = [];\n        foreach ($logs->getCurrentPageResults() as $value) {\n            $dtos[] = $this->serializeLogItem($value);\n        }\n\n        return new JsonResponse(\n            $this->serializePaginated($dtos, $logs),\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Magazine/MagazineRetrieveApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Magazine;\n\nuse App\\Controller\\Traits\\PrivateContentTrait;\nuse App\\DTO\\MagazineResponseDto;\nuse App\\Entity\\Magazine;\nuse App\\Entity\\MagazineBlock;\nuse App\\Entity\\MagazineSubscription;\nuse App\\Entity\\User;\nuse App\\Factory\\MagazineFactory;\nuse App\\PageView\\MagazinePageView;\nuse App\\Repository\\Criteria;\nuse App\\Repository\\MagazineRepository;\nuse App\\Schema\\PaginationSchema;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse Nelmio\\ApiDocBundle\\Attribute\\Security;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpKernel\\Exception\\AccessDeniedHttpException;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass MagazineRetrieveApi extends MagazineBaseApi\n{\n    use PrivateContentTrait;\n\n    #[OA\\Response(\n        response: 200,\n        description: 'Returns the Magazine',\n        content: new Model(type: MagazineResponseDto::class),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Magazine not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'magazine_id',\n        in: 'path',\n        description: 'The magazine to retrieve',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Tag(name: 'magazine')]\n    public function __invoke(\n        #[MapEntity(id: 'magazine_id')]\n        Magazine $magazine,\n        MagazineFactory $factory,\n        RateLimiterFactoryInterface $apiReadLimiter,\n        RateLimiterFactoryInterface $anonymousApiReadLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter);\n\n        $dto = $factory->createDto($magazine);\n\n        return new JsonResponse(\n            $this->serializeMagazine($dto),\n            headers: $headers\n        );\n    }\n\n    #[OA\\Response(\n        response: 200,\n        description: 'Returns the magazine for the given name',\n        content: new Model(type: MagazineResponseDto::class),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Magazine not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'magazine_name',\n        in: 'path',\n        description: 'The magazine to retrieve',\n        schema: new OA\\Schema(type: 'string'),\n    )]\n    #[OA\\Tag(name: 'magazine')]\n    public function byName(\n        #[MapEntity(mapping: ['magazine_name' => 'name'])]\n        Magazine $magazine,\n        MagazineFactory $factory,\n        RateLimiterFactoryInterface $apiReadLimiter,\n        RateLimiterFactoryInterface $anonymousApiReadLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter);\n\n        $dto = $factory->createDto($magazine);\n\n        return new JsonResponse(\n            $this->serializeMagazine($dto),\n            headers: $headers\n        );\n    }\n\n    #[OA\\Response(\n        response: 200,\n        description: 'Returns a paginated list of magazines',\n        content: new OA\\JsonContent(\n            type: 'object',\n            properties: [\n                new OA\\Property(\n                    property: 'items',\n                    type: 'array',\n                    items: new OA\\Items(ref: new Model(type: MagazineResponseDto::class))\n                ),\n                new OA\\Property(\n                    property: 'pagination',\n                    ref: new Model(type: PaginationSchema::class)\n                ),\n            ]\n        ),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'p',\n        description: 'Page of magazines to retrieve',\n        in: 'query',\n        schema: new OA\\Schema(type: 'integer', default: 1, minimum: 1)\n    )]\n    #[OA\\Parameter(\n        name: 'perPage',\n        description: 'Number of magazines per page',\n        in: 'query',\n        schema: new OA\\Schema(type: 'integer', default: MagazineRepository::PER_PAGE, minimum: self::MIN_PER_PAGE, maximum: self::MAX_PER_PAGE)\n    )]\n    #[OA\\Parameter(\n        name: 'q',\n        description: 'Magazine search term',\n        in: 'query',\n        schema: new OA\\Schema(type: 'string')\n    )]\n    #[OA\\Parameter(\n        name: 'sort',\n        description: 'Sort method to use when retrieving magazines',\n        in: 'query',\n        schema: new OA\\Schema(type: 'string', default: MagazinePageView::SORT_HOT, enum: [...MagazineRepository::SORT_OPTIONS, MagazinePageView::SORT_OWNER_LAST_ACTIVE])\n    )]\n    #[OA\\Parameter(\n        name: 'federation',\n        description: 'What type of federated magazines to retrieve',\n        in: 'query',\n        schema: new OA\\Schema(type: 'string', default: Criteria::AP_ALL, enum: Criteria::AP_OPTIONS)\n    )]\n    #[OA\\Parameter(\n        name: 'hide_adult',\n        description: 'Options for retrieving adult magazines',\n        in: 'query',\n        schema: new OA\\Schema(type: 'string', default: MagazinePageView::ADULT_HIDE, enum: MagazinePageView::ADULT_OPTIONS)\n    )]\n    #[OA\\Parameter(\n        name: 'abandoned',\n        description: 'Options for retrieving abandoned magazines (federation must be \\''.Criteria::AP_LOCAL.'\\')',\n        in: 'query',\n        schema: new OA\\Schema(type: 'boolean', default: false)\n    )]\n    #[OA\\Tag(name: 'magazine')]\n    public function collection(\n        MagazineRepository $repository,\n        MagazineFactory $factory,\n        RateLimiterFactoryInterface $apiReadLimiter,\n        RateLimiterFactoryInterface $anonymousApiReadLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter);\n\n        $request = $this->request->getCurrentRequest();\n        $criteria = new MagazinePageView(\n            $this->getPageNb($request),\n            $request->get('sort', MagazinePageView::SORT_HOT),\n            $request->get('federation', Criteria::AP_ALL),\n            $request->get('hide_adult', MagazinePageView::ADULT_HIDE),\n            filter_var($request->get('abandoned', 'false'), FILTER_VALIDATE_BOOL),\n        );\n        $criteria->perPage = self::constrainPerPage($request->get('perPage', MagazineRepository::PER_PAGE));\n\n        if ($q = $request->get('q')) {\n            $criteria->query = $q;\n        }\n\n        $magazines = $repository->findPaginated($criteria);\n        $dtos = [];\n        foreach ($magazines->getCurrentPageResults() as $value) {\n            \\assert($value instanceof Magazine);\n            array_push($dtos, $this->serializeMagazine($factory->createDto($value)));\n        }\n\n        return new JsonResponse(\n            $this->serializePaginated($dtos, $magazines),\n            headers: $headers\n        );\n    }\n\n    #[OA\\Response(\n        response: 200,\n        description: 'Returns a paginated list of subscribed magazines',\n        content: new OA\\JsonContent(\n            type: 'object',\n            properties: [\n                new OA\\Property(\n                    property: 'items',\n                    type: 'array',\n                    items: new OA\\Items(ref: new Model(type: MagazineResponseDto::class))\n                ),\n                new OA\\Property(\n                    property: 'pagination',\n                    ref: new Model(type: PaginationSchema::class)\n                ),\n            ]\n        ),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'p',\n        description: 'Page of magazines to retrieve',\n        in: 'query',\n        schema: new OA\\Schema(type: 'integer', default: 1, minimum: 1)\n    )]\n    #[OA\\Parameter(\n        name: 'perPage',\n        description: 'Number of magazines per page',\n        in: 'query',\n        schema: new OA\\Schema(type: 'integer', default: MagazineRepository::PER_PAGE, minimum: self::MIN_PER_PAGE, maximum: self::MAX_PER_PAGE)\n    )]\n    #[OA\\Tag(name: 'magazine')]\n    #[Security(name: 'oauth2', scopes: ['magazine:subscribe'])]\n    #[IsGranted('ROLE_OAUTH2_MAGAZINE:SUBSCRIBE')]\n    public function subscribed(\n        MagazineRepository $repository,\n        MagazineFactory $factory,\n        RateLimiterFactoryInterface $apiReadLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiReadLimiter);\n\n        $request = $this->request->getCurrentRequest();\n        $magazines = $repository->findSubscribedMagazines(\n            $this->getPageNb($request),\n            $this->getUserOrThrow(),\n            self::constrainPerPage($request->get('perPage', MagazineRepository::PER_PAGE))\n        );\n\n        $dtos = [];\n        foreach ($magazines->getCurrentPageResults() as $value) {\n            \\assert($value instanceof MagazineSubscription);\n            array_push($dtos, $this->serializeMagazine($factory->createDto($value->magazine)));\n        }\n\n        return new JsonResponse(\n            $this->serializePaginated($dtos, $magazines),\n            headers: $headers\n        );\n    }\n\n    #[OA\\Response(\n        response: 200,\n        description: 'Returns a paginated list of user\\'s subscribed magazines',\n        content: new OA\\JsonContent(\n            type: 'object',\n            properties: [\n                new OA\\Property(\n                    property: 'items',\n                    type: 'array',\n                    items: new OA\\Items(ref: new Model(type: MagazineResponseDto::class))\n                ),\n                new OA\\Property(\n                    property: 'pagination',\n                    ref: new Model(type: PaginationSchema::class)\n                ),\n            ]\n        ),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'This user does not allow others to view their subscribed magazines',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'user_id',\n        description: 'User from which to retrieve subscribed magazines',\n        in: 'path',\n        schema: new OA\\Schema(type: 'integer')\n    )]\n    #[OA\\Parameter(\n        name: 'p',\n        description: 'Page of magazines to retrieve',\n        in: 'query',\n        schema: new OA\\Schema(type: 'integer', default: 1, minimum: 1)\n    )]\n    #[OA\\Parameter(\n        name: 'perPage',\n        description: 'Number of magazines per page',\n        in: 'query',\n        schema: new OA\\Schema(\n            type: 'integer',\n            default: MagazineRepository::PER_PAGE,\n            minimum: self::MIN_PER_PAGE,\n            maximum: self::MAX_PER_PAGE\n        )\n    )]\n    #[OA\\Tag(name: 'user')]\n    #[Security(name: 'oauth2', scopes: ['magazine:subscribe'])]\n    #[IsGranted('ROLE_OAUTH2_MAGAZINE:SUBSCRIBE')]\n    public function subscriptions(\n        #[MapEntity(id: 'user_id')]\n        User $user,\n        MagazineRepository $repository,\n        MagazineFactory $factory,\n        RateLimiterFactoryInterface $apiReadLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiReadLimiter);\n\n        if ($user->getId() !== $this->getUserOrThrow()->getId() && !$user->getShowProfileSubscriptions()) {\n            throw new AccessDeniedHttpException('You are not permitted to view the magazines this user subscribes to');\n        }\n\n        $request = $this->request->getCurrentRequest();\n        $magazines = $repository->findSubscribedMagazines(\n            $this->getPageNb($request),\n            $user,\n            self::constrainPerPage($request->get('perPage', MagazineRepository::PER_PAGE))\n        );\n\n        $dtos = [];\n        foreach ($magazines->getCurrentPageResults() as $value) {\n            \\assert($value instanceof MagazineSubscription);\n            array_push($dtos, $this->serializeMagazine($factory->createDto($value->magazine)));\n        }\n\n        return new JsonResponse(\n            $this->serializePaginated($dtos, $magazines),\n            headers: $headers\n        );\n    }\n\n    #[OA\\Response(\n        response: 200,\n        description: 'Returns a paginated list of moderated magazines',\n        content: new OA\\JsonContent(\n            type: 'object',\n            properties: [\n                new OA\\Property(\n                    property: 'items',\n                    type: 'array',\n                    items: new OA\\Items(ref: new Model(type: MagazineResponseDto::class))\n                ),\n                new OA\\Property(\n                    property: 'pagination',\n                    ref: new Model(type: PaginationSchema::class)\n                ),\n            ]\n        ),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'p',\n        description: 'Page of magazines to retrieve',\n        in: 'query',\n        schema: new OA\\Schema(type: 'integer', default: 1, minimum: 1)\n    )]\n    #[OA\\Parameter(\n        name: 'perPage',\n        description: 'Number of magazines per page',\n        in: 'query',\n        schema: new OA\\Schema(type: 'integer', default: MagazineRepository::PER_PAGE, minimum: self::MIN_PER_PAGE, maximum: self::MAX_PER_PAGE)\n    )]\n    #[OA\\Tag(name: 'magazine')]\n    #[Security(name: 'oauth2', scopes: ['moderate:magazine:list'])]\n    #[IsGranted('ROLE_OAUTH2_MODERATE:MAGAZINE:LIST')]\n    public function moderated(\n        MagazineRepository $repository,\n        MagazineFactory $factory,\n        RateLimiterFactoryInterface $apiReadLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiReadLimiter);\n\n        $request = $this->request->getCurrentRequest();\n        $magazines = $repository->findModeratedMagazines(\n            $this->getUserOrThrow(),\n            $this->getPageNb($request),\n            self::constrainPerPage($request->get('perPage', MagazineRepository::PER_PAGE))\n        );\n\n        $dtos = [];\n        foreach ($magazines->getCurrentPageResults() as $value) {\n            \\assert($value instanceof Magazine);\n            array_push($dtos, $this->serializeMagazine($factory->createDto($value)));\n        }\n\n        return new JsonResponse(\n            $this->serializePaginated($dtos, $magazines),\n            headers: $headers\n        );\n    }\n\n    #[OA\\Response(\n        response: 200,\n        description: 'Returns a paginated list of blocked magazines',\n        content: new OA\\JsonContent(\n            type: 'object',\n            properties: [\n                new OA\\Property(\n                    property: 'items',\n                    type: 'array',\n                    items: new OA\\Items(ref: new Model(type: MagazineResponseDto::class))\n                ),\n                new OA\\Property(\n                    property: 'pagination',\n                    ref: new Model(type: PaginationSchema::class)\n                ),\n            ]\n        ),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'p',\n        description: 'Page of magazines to retrieve',\n        in: 'query',\n        schema: new OA\\Schema(type: 'integer', default: 1, minimum: 1)\n    )]\n    #[OA\\Parameter(\n        name: 'perPage',\n        description: 'Number of magazines per page',\n        in: 'query',\n        schema: new OA\\Schema(type: 'integer', default: MagazineRepository::PER_PAGE, minimum: self::MIN_PER_PAGE, maximum: self::MAX_PER_PAGE)\n    )]\n    #[OA\\Tag(name: 'magazine')]\n    #[Security(name: 'oauth2', scopes: ['magazine:block'])]\n    #[IsGranted('ROLE_OAUTH2_MAGAZINE:BLOCK')]\n    public function blocked(\n        MagazineRepository $repository,\n        MagazineFactory $factory,\n        RateLimiterFactoryInterface $apiReadLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiReadLimiter);\n\n        $request = $this->request->getCurrentRequest();\n        $magazines = $repository->findBlockedMagazines(\n            $this->getPageNb($request),\n            $this->getUserOrThrow(),\n            self::constrainPerPage($request->get('perPage', MagazineRepository::PER_PAGE))\n        );\n\n        $dtos = [];\n        foreach ($magazines->getCurrentPageResults() as $value) {\n            \\assert($value instanceof MagazineBlock);\n            array_push($dtos, $this->serializeMagazine($factory->createDto($value->magazine)));\n        }\n\n        return new JsonResponse(\n            $this->serializePaginated($dtos, $magazines),\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Magazine/MagazineRetrieveThemeApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Magazine;\n\nuse App\\DTO\\MagazineThemeResponseDto;\nuse App\\Entity\\Magazine;\nuse App\\Factory\\MagazineFactory;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\n\nclass MagazineRetrieveThemeApi extends MagazineBaseApi\n{\n    #[OA\\Response(\n        response: 200,\n        description: 'Theme retrieved',\n        content: new Model(type: MagazineThemeResponseDto::class),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Magazine not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'magazine_id',\n        in: 'path',\n        description: 'The id of the magazine to retrieve the theme from',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Tag(name: 'magazine')]\n    /**\n     * Retrieve the magazine's theme.\n     */\n    public function __invoke(\n        #[MapEntity(id: 'magazine_id')]\n        Magazine $magazine,\n        MagazineFactory $magazineFactory,\n        RateLimiterFactoryInterface $apiReadLimiter,\n        RateLimiterFactoryInterface $anonymousApiReadLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter);\n\n        $imageDto = $magazine->icon ? $this->imageFactory->createDto($magazine->icon) : null;\n        $bannerDto = $magazine->banner ? $this->imageFactory->createDto($magazine->banner) : null;\n        $dto = MagazineThemeResponseDto::create($magazineFactory->createDto($magazine), $magazine->customCss, $imageDto, $bannerDto);\n\n        return new JsonResponse(\n            $dto,\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Magazine/MagazineSubscribeApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Magazine;\n\nuse App\\DTO\\MagazineResponseDto;\nuse App\\Entity\\Magazine;\nuse App\\Factory\\MagazineFactory;\nuse App\\Service\\MagazineManager;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse Nelmio\\ApiDocBundle\\Attribute\\Security;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass MagazineSubscribeApi extends MagazineBaseApi\n{\n    #[OA\\Response(\n        response: 200,\n        description: 'Magazine subscription status updated',\n        content: new Model(type: MagazineResponseDto::class),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Magazine not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'magazine_id',\n        in: 'path',\n        description: 'The magazine to subscribe to',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Tag(name: 'magazine')]\n    #[Security(name: 'oauth2', scopes: ['magazine:subscribe'])]\n    #[IsGranted('ROLE_OAUTH2_MAGAZINE:SUBSCRIBE')]\n    #[IsGranted('subscribe', subject: 'magazine')]\n    public function subscribe(\n        #[MapEntity(id: 'magazine_id')]\n        Magazine $magazine,\n        MagazineManager $manager,\n        MagazineFactory $factory,\n        RateLimiterFactoryInterface $apiUpdateLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiUpdateLimiter);\n\n        $manager->subscribe($magazine, $this->getUserOrThrow());\n\n        return new JsonResponse(\n            $this->serializeMagazine($factory->createDto($magazine)),\n            headers: $headers\n        );\n    }\n\n    #[OA\\Response(\n        response: 200,\n        description: 'Magazine subscription status updated',\n        content: new Model(type: MagazineResponseDto::class),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Magazine not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'magazine_id',\n        in: 'path',\n        description: 'The magazine to unsubscribe from',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Tag(name: 'magazine')]\n    #[Security(name: 'oauth2', scopes: ['magazine:subscribe'])]\n    #[IsGranted('ROLE_OAUTH2_MAGAZINE:SUBSCRIBE')]\n    #[IsGranted('subscribe', subject: 'magazine')]\n    public function unsubscribe(\n        #[MapEntity(id: 'magazine_id')]\n        Magazine $magazine,\n        MagazineManager $manager,\n        MagazineFactory $factory,\n        RateLimiterFactoryInterface $apiUpdateLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiUpdateLimiter);\n\n        $manager->unsubscribe($magazine, $this->getUserOrThrow());\n\n        return new JsonResponse(\n            $this->serializeMagazine($factory->createDto($magazine)),\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Magazine/Moderate/MagazineBansRetrieveApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Magazine\\Moderate;\n\nuse App\\Controller\\Api\\Magazine\\MagazineBaseApi;\nuse App\\Controller\\Traits\\PrivateContentTrait;\nuse App\\DTO\\MagazineBanResponseDto;\nuse App\\Entity\\Magazine;\nuse App\\Entity\\MagazineBan;\nuse App\\Factory\\MagazineFactory;\nuse App\\Repository\\MagazineRepository;\nuse App\\Schema\\PaginationSchema;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse Nelmio\\ApiDocBundle\\Attribute\\Security;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass MagazineBansRetrieveApi extends MagazineBaseApi\n{\n    use PrivateContentTrait;\n\n    #[OA\\Response(\n        response: 200,\n        description: 'Returns a paginated list of bans',\n        content: new OA\\JsonContent(\n            type: 'object',\n            properties: [\n                new OA\\Property(\n                    property: 'items',\n                    type: 'array',\n                    items: new OA\\Items(ref: new Model(type: MagazineBanResponseDto::class))\n                ),\n                new OA\\Property(\n                    property: 'pagination',\n                    ref: new Model(type: PaginationSchema::class)\n                ),\n            ]\n        ),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'You are not allowed to view this magazine\\'s ban list',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Page number not valid',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'magazine_id',\n        description: 'Magazine to retrieve bans from',\n        in: 'path',\n        schema: new OA\\Schema(type: 'integer')\n    )]\n    #[OA\\Parameter(\n        name: 'p',\n        description: 'Page of bans to retrieve',\n        in: 'query',\n        schema: new OA\\Schema(type: 'integer', default: 1, minimum: 1)\n    )]\n    #[OA\\Parameter(\n        name: 'perPage',\n        description: 'Number of bans per page',\n        in: 'query',\n        schema: new OA\\Schema(type: 'integer', default: MagazineRepository::PER_PAGE, minimum: self::MIN_PER_PAGE, maximum: self::MAX_PER_PAGE)\n    )]\n    #[OA\\Tag(name: 'moderation/magazine')]\n    #[Security(name: 'oauth2', scopes: ['moderate:magazine:ban:read'])]\n    #[IsGranted('ROLE_OAUTH2_MODERATE:MAGAZINE:BAN:READ')]\n    #[IsGranted('moderate', subject: 'magazine')]\n    public function collection(\n        #[MapEntity(id: 'magazine_id')]\n        Magazine $magazine,\n        MagazineRepository $repository,\n        MagazineFactory $factory,\n        RateLimiterFactoryInterface $apiModerateLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiModerateLimiter);\n\n        $request = $this->request->getCurrentRequest();\n        $bans = $repository->findBans(\n            $magazine,\n            $this->getPageNb($request),\n            self::constrainPerPage($request->get('perPage', MagazineRepository::PER_PAGE))\n        );\n\n        $dtos = [];\n        foreach ($bans->getCurrentPageResults() as $value) {\n            \\assert($value instanceof MagazineBan);\n            array_push($dtos, $factory->createBanDto($value));\n        }\n\n        return new JsonResponse(\n            $this->serializePaginated($dtos, $bans),\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Magazine/Moderate/MagazineModOwnerRequestApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Magazine\\Moderate;\n\nuse App\\Controller\\Api\\Magazine\\MagazineBaseApi;\nuse App\\DTO\\ToggleCreatedDto;\nuse App\\Entity\\Magazine;\nuse App\\Entity\\User;\nuse App\\Security\\Voter\\MagazineVoter;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse Nelmio\\ApiDocBundle\\Attribute\\Security;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\HttpKernel\\Attribute\\MapQueryParameter;\nuse Symfony\\Component\\HttpKernel\\Exception\\NotFoundHttpException;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Security\\Core\\Exception\\AccessDeniedException;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass MagazineModOwnerRequestApi extends MagazineBaseApi\n{\n    #[OA\\Response(\n        response: 200,\n        description: 'Moderator request created or deleted',\n        content: new Model(type: ToggleCreatedDto::class),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token or the magazine is not local',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Magazine not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'magazine_id',\n        in: 'path',\n        description: 'The magazine to apply for mod to',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Tag(name: 'moderation/magazine/owner')]\n    #[Security(name: 'oauth2', scopes: ['magazine:subscribe'])]\n    #[IsGranted('ROLE_OAUTH2_MAGAZINE:SUBSCRIBE')]\n    #[IsGranted(MagazineVoter::SUBSCRIBE, subject: 'magazine')]\n    public function toggleModRequest(\n        #[MapEntity(id: 'magazine_id')]\n        Magazine $magazine,\n        RateLimiterFactoryInterface $apiModerateLimiter,\n    ): Response {\n        $headers = $this->rateLimit($apiModerateLimiter);\n\n        // applying to be a moderator is only supported for local magazines\n        if ($magazine->apId) {\n            throw new AccessDeniedException();\n        }\n\n        $this->manager->toggleModeratorRequest($magazine, $this->getUserOrThrow());\n\n        return new JsonResponse(\n            new ToggleCreatedDto($this->manager->userRequestedModerator($magazine, $this->getUserOrThrow())),\n            headers: $headers,\n        );\n    }\n\n    #[OA\\Response(\n        response: 204,\n        description: 'Moderator request was accepted',\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token or you are no admin',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'mod request not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'magazine_id',\n        in: 'path',\n        description: 'The magazine to manage',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Parameter(\n        name: 'user_id',\n        in: 'path',\n        description: 'The user to accept',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Tag(name: 'moderation/magazine/owner')]\n    #[Security(name: 'oauth2', scopes: ['admin:magazine:moderate'])]\n    #[IsGranted('ROLE_OAUTH2_ADMIN:MAGAZINE:MODERATE')]\n    #[IsGranted('ROLE_ADMIN')]\n    public function acceptModRequest(\n        #[MapEntity(id: 'magazine_id')]\n        Magazine $magazine,\n        #[MapEntity(id: 'user_id')]\n        User $user,\n        RateLimiterFactoryInterface $apiModerateLimiter,\n    ): Response {\n        $headers = $this->rateLimit($apiModerateLimiter);\n\n        if (!$this->manager->userRequestedModerator($magazine, $user)) {\n            throw new NotFoundHttpException('moderator request does not exist');\n        }\n\n        $this->manager->acceptModeratorRequest($magazine, $user, $this->getUserOrThrow());\n\n        return new Response(\n            status: Response::HTTP_NO_CONTENT,\n            headers: $headers,\n        );\n    }\n\n    #[OA\\Response(\n        response: 204,\n        description: 'Moderator request was rejected',\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token or you are no admin',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'mod request not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'magazine_id',\n        in: 'path',\n        description: 'The magazine to manage',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Parameter(\n        name: 'user_id',\n        in: 'path',\n        description: 'The user to reject',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Tag(name: 'moderation/magazine/owner')]\n    #[Security(name: 'oauth2', scopes: ['admin:magazine:moderate'])]\n    #[IsGranted('ROLE_OAUTH2_ADMIN:MAGAZINE:MODERATE')]\n    #[IsGranted('ROLE_ADMIN')]\n    public function rejectModRequest(\n        #[MapEntity(id: 'magazine_id')]\n        Magazine $magazine,\n        #[MapEntity(id: 'user_id')]\n        User $user,\n        RateLimiterFactoryInterface $apiModerateLimiter,\n    ): Response {\n        $headers = $this->rateLimit($apiModerateLimiter);\n\n        if (!$this->manager->userRequestedModerator($magazine, $user)) {\n            throw new NotFoundHttpException('moderator request does not exist');\n        }\n\n        $this->manager->toggleModeratorRequest($magazine, $user);\n\n        return new Response(\n            status: Response::HTTP_NO_CONTENT,\n            headers: $headers,\n        );\n    }\n\n    #[OA\\Response(\n        response: 200,\n        description: 'returns a list of moderator requests with user and magazine',\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token or you are no admin',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Magazine not found or id was invalid',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'magazine',\n        in: 'query',\n        description: 'The magazine to filter for',\n        required: false,\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Tag(name: 'moderation/magazine/owner')]\n    #[Security(name: 'oauth2', scopes: ['admin:magazine:moderate'])]\n    #[IsGranted('ROLE_OAUTH2_ADMIN:MAGAZINE:MODERATE')]\n    #[IsGranted('ROLE_ADMIN')]\n    public function getModRequests(\n        #[MapQueryParameter(name: 'magazine')]\n        ?int $magazineId,\n        RateLimiterFactoryInterface $apiModerateLimiter,\n    ): Response {\n        $headers = $this->rateLimit($apiModerateLimiter);\n\n        $magazine = null;\n        if (null !== $magazineId) {\n            $magazine = $this->entityManager->getRepository(Magazine::class)->find($magazineId);\n\n            if (null === $magazine) {\n                throw new NotFoundHttpException(\"magazine with id $magazineId does not exist\");\n            }\n        }\n\n        $requests = $this->manager->listModeratorRequests($magazine);\n        $requestsDto = array_map(function ($item) {\n            return [\n                'user' => $this->userFactory->createSmallDto($item->user),\n                'magazine' => $this->magazineFactory->createSmallDto($item->magazine),\n            ];\n        }, $requests);\n\n        return new JsonResponse(\n            $requestsDto,\n            headers: $headers,\n        );\n    }\n\n    #[OA\\Response(\n        response: 200,\n        description: 'Owner request created or deleted',\n        content: new Model(type: ToggleCreatedDto::class),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token or the magazine is not local',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Magazine not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'magazine_id',\n        in: 'path',\n        description: 'The magazine to apply for owner to',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Tag(name: 'moderation/magazine/owner')]\n    #[Security(name: 'oauth2', scopes: ['magazine:subscribe'])]\n    #[IsGranted('ROLE_OAUTH2_MAGAZINE:SUBSCRIBE')]\n    #[IsGranted(MagazineVoter::SUBSCRIBE, subject: 'magazine')]\n    public function toggleOwnerRequest(\n        #[MapEntity(id: 'magazine_id')]\n        Magazine $magazine,\n        RateLimiterFactoryInterface $apiModerateLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiModerateLimiter);\n\n        // applying to be a owner is only supported for local magazines\n        if ($magazine->apId) {\n            throw new AccessDeniedException();\n        }\n\n        $this->manager->toggleOwnershipRequest($magazine, $this->getUserOrThrow());\n\n        return new JsonResponse(\n            new ToggleCreatedDto($this->manager->userRequestedOwnership($magazine, $this->getUserOrThrow())),\n            headers: $headers,\n        );\n    }\n\n    #[OA\\Response(\n        response: 204,\n        description: 'Ownership request was accepted',\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token or you are no admin',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'owner request not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'magazine_id',\n        in: 'path',\n        description: 'The magazine to manage',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Parameter(\n        name: 'user_id',\n        in: 'path',\n        description: 'The user to reject',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Tag(name: 'moderation/magazine/owner')]\n    #[Security(name: 'oauth2', scopes: ['admin:magazine:moderate'])]\n    #[IsGranted('ROLE_OAUTH2_ADMIN:MAGAZINE:MODERATE')]\n    #[IsGranted('ROLE_ADMIN')]\n    public function acceptOwnerRequest(\n        #[MapEntity(id: 'magazine_id')]\n        Magazine $magazine,\n        #[MapEntity(id: 'user_id')]\n        User $user,\n        RateLimiterFactoryInterface $apiModerateLimiter,\n    ): Response {\n        $headers = $this->rateLimit($apiModerateLimiter);\n\n        if (!$this->manager->userRequestedOwnership($magazine, $user)) {\n            throw new NotFoundHttpException('ownership request does not exist');\n        }\n\n        $this->manager->acceptOwnershipRequest($magazine, $user, $this->getUserOrThrow());\n\n        return new JsonResponse(\n            status: Response::HTTP_NO_CONTENT,\n            headers: $headers,\n        );\n    }\n\n    #[OA\\Response(\n        response: 204,\n        description: 'Moderator request was rejected',\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token or you are no admin',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'owner request not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'magazine_id',\n        in: 'path',\n        description: 'The magazine to manage',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Parameter(\n        name: 'user_id',\n        in: 'path',\n        description: 'The user to reject',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Tag(name: 'moderation/magazine/owner')]\n    #[Security(name: 'oauth2', scopes: ['admin:magazine:moderate'])]\n    #[IsGranted('ROLE_OAUTH2_ADMIN:MAGAZINE:MODERATE')]\n    #[IsGranted('ROLE_ADMIN')]\n    public function rejectOwnerRequest(\n        #[MapEntity(id: 'magazine_id')]\n        Magazine $magazine,\n        #[MapEntity(id: 'user_id')]\n        User $user,\n        RateLimiterFactoryInterface $apiModerateLimiter,\n    ): Response {\n        $headers = $this->rateLimit($apiModerateLimiter);\n\n        if (!$this->manager->userRequestedOwnership($magazine, $user)) {\n            throw new NotFoundHttpException('ownership request does not exist');\n        }\n\n        $this->manager->toggleOwnershipRequest($magazine, $user);\n\n        return new Response(\n            status: Response::HTTP_NO_CONTENT,\n            headers: $headers,\n        );\n    }\n\n    #[OA\\Response(\n        response: 200,\n        description: 'returns a list of ownership requests with user and magazine',\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token or you are no admin',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Magazine not found or id was invalid',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'magazine',\n        in: 'query',\n        description: 'The magazine filter for',\n        required: false,\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Tag(name: 'moderation/magazine/owner')]\n    #[Security(name: 'oauth2', scopes: ['admin:magazine:moderate'])]\n    #[IsGranted('ROLE_OAUTH2_ADMIN:MAGAZINE:MODERATE')]\n    #[IsGranted('ROLE_ADMIN')]\n    public function getOwnerRequests(\n        #[MapQueryParameter(name: 'magazine')]\n        ?int $magazineId,\n        RateLimiterFactoryInterface $apiModerateLimiter,\n    ): Response {\n        $headers = $this->rateLimit($apiModerateLimiter);\n\n        $magazine = null;\n        if (null !== $magazineId) {\n            $magazine = $this->entityManager->getRepository(Magazine::class)->find($magazineId);\n\n            if (null === $magazine) {\n                throw new NotFoundHttpException(\"magazine with id $magazineId does not exist\");\n            }\n        }\n\n        $requests = $this->manager->listOwnershipRequests($magazine);\n        $requestsDto = array_map(function ($item) {\n            return [\n                'user' => $this->userFactory->createSmallDto($item->user),\n                'magazine' => $this->magazineFactory->createSmallDto($item->magazine),\n            ];\n        }, $requests);\n\n        return new JsonResponse(\n            $requestsDto,\n            headers: $headers,\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Magazine/Moderate/MagazineReportsAcceptApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Magazine\\Moderate;\n\nuse App\\Controller\\Api\\Magazine\\MagazineBaseApi;\nuse App\\Controller\\Traits\\PrivateContentTrait;\nuse App\\DTO\\ReportResponseDto;\nuse App\\Entity\\Magazine;\nuse App\\Entity\\Report;\nuse App\\Factory\\ContentManagerFactory;\nuse App\\Service\\ReportManager;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse Nelmio\\ApiDocBundle\\Attribute\\Security;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpKernel\\Exception\\NotFoundHttpException;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass MagazineReportsAcceptApi extends MagazineBaseApi\n{\n    use PrivateContentTrait;\n\n    #[OA\\Response(\n        response: 200,\n        description: 'Accept a report',\n        content: new Model(type: ReportResponseDto::class),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'You are not allowed to accept this report',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Report not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'magazine_id',\n        in: 'path',\n        description: 'The magazine the report is in',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Parameter(\n        name: 'report_id',\n        in: 'path',\n        description: 'The report to accept',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Tag(name: 'moderation/magazine')]\n    #[Security(name: 'oauth2', scopes: ['moderate:magazine:reports:action'])]\n    #[IsGranted('ROLE_OAUTH2_MODERATE:MAGAZINE:REPORTS:ACTION')]\n    #[IsGranted('moderate', subject: 'magazine')]\n    /**\n     * Accepting a report will delete the reported item.\n     */\n    public function __invoke(\n        #[MapEntity(id: 'magazine_id')]\n        Magazine $magazine,\n        #[MapEntity(id: 'report_id')]\n        Report $report,\n        ReportManager $reportManager,\n        ContentManagerFactory $managerFactory,\n        RateLimiterFactoryInterface $apiModerateLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiModerateLimiter);\n\n        if ($magazine->getId() !== $report->magazine->getId()) {\n            throw new NotFoundHttpException('Report not found in magazine');\n        }\n\n        $manager = $managerFactory->createManager($report->getSubject());\n\n        $manager->delete($this->getUserOrThrow(), $report->getSubject());\n\n        return new JsonResponse(\n            $this->serializeReport($report),\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Magazine/Moderate/MagazineReportsRejectApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Magazine\\Moderate;\n\nuse App\\Controller\\Api\\Magazine\\MagazineBaseApi;\nuse App\\Controller\\Traits\\PrivateContentTrait;\nuse App\\DTO\\ReportResponseDto;\nuse App\\Entity\\Magazine;\nuse App\\Entity\\Report;\nuse App\\Service\\ReportManager;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse Nelmio\\ApiDocBundle\\Attribute\\Security;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpKernel\\Exception\\NotFoundHttpException;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass MagazineReportsRejectApi extends MagazineBaseApi\n{\n    use PrivateContentTrait;\n\n    #[OA\\Response(\n        response: 200,\n        description: 'Reject a report',\n        content: new Model(type: ReportResponseDto::class),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'You are not allowed to accept this report',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Report not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'magazine_id',\n        in: 'path',\n        description: 'The magazine the report is in',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Parameter(\n        name: 'report_id',\n        in: 'path',\n        description: 'The report to reject',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Tag(name: 'moderation/magazine')]\n    #[Security(name: 'oauth2', scopes: ['moderate:magazine:reports:action'])]\n    #[IsGranted('ROLE_OAUTH2_MODERATE:MAGAZINE:REPORTS:ACTION')]\n    #[IsGranted('moderate', subject: 'magazine')]\n    /**\n     * Rejecting a report will preserve the reported item.\n     */\n    public function __invoke(\n        #[MapEntity(id: 'magazine_id')]\n        Magazine $magazine,\n        #[MapEntity(id: 'report_id')]\n        Report $report,\n        ReportManager $manager,\n        RateLimiterFactoryInterface $apiModerateLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiModerateLimiter);\n\n        if ($magazine->getId() !== $report->magazine->getId()) {\n            throw new NotFoundHttpException('Report not found in magazine');\n        }\n\n        $manager->reject($report, $this->getUserOrThrow());\n\n        return new JsonResponse(\n            $this->serializeReport($report),\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Magazine/Moderate/MagazineReportsRetrieveApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Magazine\\Moderate;\n\nuse App\\Controller\\Api\\Magazine\\MagazineBaseApi;\nuse App\\Controller\\Traits\\PrivateContentTrait;\nuse App\\DTO\\ReportResponseDto;\nuse App\\Entity\\Magazine;\nuse App\\Entity\\Report;\nuse App\\Repository\\MagazineRepository;\nuse App\\Schema\\PaginationSchema;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse Nelmio\\ApiDocBundle\\Attribute\\Security;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpKernel\\Exception\\BadRequestHttpException;\nuse Symfony\\Component\\HttpKernel\\Exception\\NotFoundHttpException;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass MagazineReportsRetrieveApi extends MagazineBaseApi\n{\n    use PrivateContentTrait;\n\n    #[OA\\Response(\n        response: 200,\n        description: 'Returns a report',\n        content: new Model(type: ReportResponseDto::class),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'You are not allowed to retrieve reports for this magazine',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Report not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'magazine_id',\n        in: 'path',\n        description: 'The magazine of the report',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Parameter(\n        name: 'report_id',\n        in: 'path',\n        description: 'The report to retrieve',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Tag(name: 'moderation/magazine')]\n    #[Security(name: 'oauth2', scopes: ['moderate:magazine:reports:read'])]\n    #[IsGranted('ROLE_OAUTH2_MODERATE:MAGAZINE:REPORTS:READ')]\n    #[IsGranted('moderate', subject: 'magazine')]\n    public function __invoke(\n        #[MapEntity(id: 'magazine_id')]\n        Magazine $magazine,\n        #[MapEntity(id: 'report_id')]\n        Report $report,\n        RateLimiterFactoryInterface $apiModerateLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiModerateLimiter);\n\n        if ($magazine->getId() !== $report->magazine->getId()) {\n            throw new NotFoundHttpException('The report was not found in the magazine');\n        }\n\n        return new JsonResponse(\n            $this->serializeReport($report),\n            headers: $headers\n        );\n    }\n\n    #[OA\\Response(\n        response: 200,\n        description: 'Returns a paginated list of reports',\n        content: new OA\\JsonContent(\n            type: 'object',\n            properties: [\n                new OA\\Property(\n                    property: 'items',\n                    type: 'array',\n                    items: new OA\\Items(ref: new Model(type: ReportResponseDto::class))\n                ),\n                new OA\\Property(\n                    property: 'pagination',\n                    ref: new Model(type: PaginationSchema::class)\n                ),\n            ]\n        ),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'magazine_id',\n        description: 'Magazine to retrieve reports from',\n        in: 'path',\n        schema: new OA\\Schema(type: 'integer')\n    )]\n    #[OA\\Parameter(\n        name: 'p',\n        description: 'Page of reports to retrieve',\n        in: 'query',\n        schema: new OA\\Schema(type: 'integer', default: 1, minimum: 1)\n    )]\n    #[OA\\Parameter(\n        name: 'perPage',\n        description: 'Number of reports per page',\n        in: 'query',\n        schema: new OA\\Schema(type: 'integer', default: MagazineRepository::PER_PAGE, minimum: self::MIN_PER_PAGE, maximum: self::MAX_PER_PAGE)\n    )]\n    #[OA\\Parameter(\n        name: 'status',\n        description: 'Filter by report status',\n        in: 'query',\n        schema: new OA\\Schema(type: 'string', default: Report::STATUS_PENDING, enum: Report::STATUS_OPTIONS)\n    )]\n    #[OA\\Tag(name: 'moderation/magazine')]\n    #[Security(name: 'oauth2', scopes: ['moderate:magazine:reports:read'])]\n    #[IsGranted('ROLE_OAUTH2_MODERATE:MAGAZINE:REPORTS:READ')]\n    #[IsGranted('moderate', subject: 'magazine')]\n    public function collection(\n        #[MapEntity(id: 'magazine_id')]\n        Magazine $magazine,\n        MagazineRepository $repository,\n        RateLimiterFactoryInterface $apiModerateLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiModerateLimiter);\n\n        $request = $this->request->getCurrentRequest();\n        $status = $request->get('status', Report::STATUS_PENDING);\n        if (false === array_search($status, Report::STATUS_OPTIONS)) {\n            throw new BadRequestHttpException('Invalid status');\n        }\n\n        $reports = $repository->findReports(\n            $magazine,\n            $this->getPageNb($request),\n            self::constrainPerPage($request->get('perPage', MagazineRepository::PER_PAGE)),\n            $status\n        );\n\n        $dtos = [];\n        foreach ($reports->getCurrentPageResults() as $value) {\n            \\assert($value instanceof Report);\n            array_push($dtos, $this->serializeReport($value));\n        }\n\n        return new JsonResponse(\n            $this->serializePaginated($dtos, $reports),\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Magazine/Moderate/MagazineTrashedRetrieveApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Magazine\\Moderate;\n\nuse App\\Controller\\Api\\Magazine\\MagazineBaseApi;\nuse App\\Controller\\Traits\\PrivateContentTrait;\nuse App\\Entity\\Magazine;\nuse App\\Repository\\MagazineRepository;\nuse App\\Schema\\ContentSchema;\nuse App\\Schema\\PaginationSchema;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse Nelmio\\ApiDocBundle\\Attribute\\Security;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass MagazineTrashedRetrieveApi extends MagazineBaseApi\n{\n    use PrivateContentTrait;\n\n    #[OA\\Response(\n        response: 200,\n        description: 'Returns a paginated list of trashed entries, posts, and comments',\n        content: new OA\\JsonContent(\n            type: 'object',\n            properties: [\n                new OA\\Property(\n                    property: 'items',\n                    type: 'array',\n                    items: new OA\\Items(\n                        ref: new Model(type: ContentSchema::class)\n                    )\n                ),\n                new OA\\Property(\n                    property: 'pagination',\n                    ref: new Model(type: PaginationSchema::class)\n                ),\n            ]\n        ),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'You are not allowed to view this magazine\\'s trashed items',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Page number not valid',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'magazine_id',\n        description: 'Magazine to retrieve trash from',\n        in: 'path',\n        schema: new OA\\Schema(type: 'integer')\n    )]\n    #[OA\\Parameter(\n        name: 'p',\n        description: 'Page of trash to retrieve',\n        in: 'query',\n        schema: new OA\\Schema(type: 'integer', default: 1, minimum: 1)\n    )]\n    #[OA\\Parameter(\n        name: 'perPage',\n        description: 'Number of trash per page',\n        in: 'query',\n        schema: new OA\\Schema(type: 'integer', default: MagazineRepository::PER_PAGE, minimum: self::MIN_PER_PAGE, maximum: self::MAX_PER_PAGE)\n    )]\n    #[OA\\Tag(name: 'moderation/magazine')]\n    #[Security(name: 'oauth2', scopes: ['moderate:magazine:trash:read'])]\n    #[IsGranted('ROLE_OAUTH2_MODERATE:MAGAZINE:TRASH:READ')]\n    #[IsGranted('moderate', subject: 'magazine')]\n    public function collection(\n        #[MapEntity(id: 'magazine_id')]\n        Magazine $magazine,\n        MagazineRepository $repository,\n        RateLimiterFactoryInterface $apiModerateLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiModerateLimiter);\n\n        $request = $this->request->getCurrentRequest();\n        $trash = $repository->findTrashed(\n            $magazine,\n            $this->getPageNb($request),\n            self::constrainPerPage($request->get('perPage', MagazineRepository::PER_PAGE))\n        );\n\n        $dtos = [];\n        foreach ($trash->getCurrentPageResults() as $value) {\n            array_push($dtos, $this->serializeContentInterface($value, forceVisible: true));\n        }\n\n        return new JsonResponse(\n            $this->serializePaginated($dtos, $trash),\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Magazine/Moderate/MagazineUserBanApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Magazine\\Moderate;\n\nuse App\\Controller\\Api\\Magazine\\MagazineBaseApi;\nuse App\\DTO\\MagazineBanDto;\nuse App\\DTO\\MagazineBanResponseDto;\nuse App\\Entity\\Magazine;\nuse App\\Entity\\User;\nuse App\\Factory\\MagazineFactory;\nuse App\\Service\\MagazineManager;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse Nelmio\\ApiDocBundle\\Attribute\\Security;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpKernel\\Exception\\BadRequestHttpException;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\nuse Symfony\\Component\\Serializer\\SerializerInterface;\nuse Symfony\\Component\\Validator\\Validator\\ValidatorInterface;\n\nclass MagazineUserBanApi extends MagazineBaseApi\n{\n    #[OA\\Response(\n        response: 200,\n        description: 'User banned',\n        content: new Model(type: MagazineBanResponseDto::class),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 400,\n        description: 'The ban\\'s body was not formatted correctly',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\BadRequestErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'You are not authorized to ban users from this magazine',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'User or magazine not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'magazine_id',\n        in: 'path',\n        description: 'The magazine to ban the user in',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Parameter(\n        name: 'user_id',\n        in: 'path',\n        description: 'The user to ban',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\RequestBody(content: new Model(type: MagazineBanDto::class))]\n    #[OA\\Tag(name: 'moderation/magazine')]\n    #[Security(name: 'oauth2', scopes: ['moderate:magazine:ban:create'])]\n    #[IsGranted('ROLE_OAUTH2_MODERATE:MAGAZINE:BAN:CREATE')]\n    #[IsGranted('moderate', subject: 'magazine')]\n    /**\n     * Create a new magazine ban for a user.\n     */\n    public function ban(\n        #[MapEntity(id: 'magazine_id')]\n        Magazine $magazine,\n        #[MapEntity(id: 'user_id')]\n        User $user,\n        MagazineManager $manager,\n        MagazineFactory $factory,\n        SerializerInterface $deserializer,\n        ValidatorInterface $validator,\n        RateLimiterFactoryInterface $apiModerateLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiModerateLimiter);\n\n        $request = $this->request->getCurrentRequest();\n        $moderator = $this->getUserOrThrow();\n        /** @var MagazineBanDto $ban */\n        $ban = $deserializer->deserialize($request->getContent(), MagazineBanDto::class, 'json');\n\n        $errors = $validator->validate($ban);\n        if (0 < \\count($errors)) {\n            throw new BadRequestHttpException((string) $errors);\n        }\n\n        $ban = $manager->ban($magazine, $user, $moderator, $ban);\n\n        if (!$ban) {\n            throw new BadRequestHttpException('Failed to ban user');\n        }\n\n        $response = $factory->createBanDto($ban);\n\n        return new JsonResponse(\n            $response,\n            headers: $headers\n        );\n    }\n\n    #[OA\\Response(\n        response: 200,\n        description: 'User unbanned',\n        content: new Model(type: MagazineBanResponseDto::class),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'You are not authorized to unban this user',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'User or magazine not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'magazine_id',\n        in: 'path',\n        description: 'The magazine the user is banned in',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Parameter(\n        name: 'user_id',\n        in: 'path',\n        description: 'The user to unban',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Tag(name: 'moderation/magazine')]\n    #[Security(name: 'oauth2', scopes: ['moderate:magazine:ban:delete'])]\n    #[IsGranted('ROLE_OAUTH2_MODERATE:MAGAZINE:BAN:DELETE')]\n    #[IsGranted('moderate', subject: 'magazine')]\n    /**\n     * Remove magazine ban from a user.\n     */\n    public function unban(\n        #[MapEntity(id: 'magazine_id')]\n        Magazine $magazine,\n        #[MapEntity(id: 'user_id')]\n        User $user,\n        MagazineManager $manager,\n        MagazineFactory $factory,\n        RateLimiterFactoryInterface $apiModerateLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiModerateLimiter);\n\n        $ban = $manager->unban($magazine, $user);\n\n        if (!$ban) {\n            throw new BadRequestHttpException('Failed to ban user');\n        }\n\n        $response = $factory->createBanDto($ban);\n\n        return new JsonResponse(\n            $response,\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/MagazineBadges.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api;\n\nuse App\\ApiDataProvider\\DtoPaginator;\nuse App\\Controller\\AbstractController;\nuse App\\Entity\\Magazine;\nuse App\\Factory\\BadgeFactory;\n\nclass MagazineBadges extends AbstractController\n{\n    public function __construct(private readonly BadgeFactory $factory)\n    {\n    }\n\n    public function __invoke(Magazine $magazine)\n    {\n        $dtos = array_map(fn ($badge) => $this->factory->createDto($badge), $magazine->badges->toArray());\n\n        return new DtoPaginator($dtos, 0, 10, \\count($dtos));\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Message/MessageBaseApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Message;\n\nuse App\\Controller\\Api\\BaseApi;\nuse App\\DTO\\MessageDto;\nuse App\\Entity\\Message;\nuse App\\Entity\\MessageThread;\nuse App\\Factory\\MessageFactory;\nuse Symfony\\Contracts\\Service\\Attribute\\Required;\n\nclass MessageBaseApi extends BaseApi\n{\n    public const REPLY_DEPTH = 25;\n    public const MIN_REPLY_DEPTH = 0;\n    public const MAX_REPLY_DEPTH = 100;\n\n    private MessageFactory $messageFactory;\n\n    #[Required]\n    public function setMessageFactory(MessageFactory $messageFactory): void\n    {\n        $this->messageFactory = $messageFactory;\n    }\n\n    /**\n     * Serialize a single message to JSON.\n     *\n     * @param Message $message The Message to serialize\n     *\n     * @return array An associative array representation of the message's safe fields, to be used as JSON\n     */\n    protected function serializeMessage(Message $message)\n    {\n        $response = $this->messageFactory->createResponseDto($message);\n\n        return $response;\n    }\n\n    /**\n     * Serialize a message thread to JSON.\n     *\n     * @param MessageThread $thread The thread to serialize\n     *\n     * @return array An associative array representation of the message's safe fields, to be used as JSON\n     */\n    protected function serializeMessageThread(MessageThread $thread)\n    {\n        $depth = $this->constrainPerPage($this->request->getCurrentRequest()->get('d', self::REPLY_DEPTH), self::MIN_REPLY_DEPTH, self::MAX_REPLY_DEPTH);\n        $response = $this->messageFactory->createThreadResponseDto($thread, $depth);\n\n        return $response;\n    }\n\n    /**\n     * Deserialize a message from JSON.\n     *\n     * @return MessageDto A message DTO\n     */\n    protected function deserializeMessage(): MessageDto\n    {\n        $request = $this->request->getCurrentRequest();\n        $dto = $this->serializer->deserialize($request->getContent(), MessageDto::class, 'json');\n\n        return $dto;\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Message/MessageReadApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Message;\n\nuse App\\DTO\\MessageResponseDto;\nuse App\\Entity\\Message;\nuse App\\Service\\MessageManager;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse Nelmio\\ApiDocBundle\\Attribute\\Security;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpKernel\\Exception\\AccessDeniedHttpException;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass MessageReadApi extends MessageBaseApi\n{\n    #[OA\\Response(\n        response: 200,\n        description: 'Marks the message as read',\n        content: new Model(type: MessageResponseDto::class),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Message not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'message_id',\n        in: 'path',\n        description: 'The message to read',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Tag(name: 'message')]\n    #[Security(name: 'oauth2', scopes: ['user:message:read'])]\n    #[IsGranted('ROLE_OAUTH2_USER:MESSAGE:READ')]\n    public function read(\n        #[MapEntity(id: 'message_id')]\n        Message $message,\n        MessageManager $manager,\n        RateLimiterFactoryInterface $apiUpdateLimiter,\n    ): JsonResponse {\n        if (!$this->isGranted('show', $message->thread)) {\n            throw new AccessDeniedHttpException();\n        }\n\n        $headers = $this->rateLimit($apiUpdateLimiter);\n\n        $manager->readMessage($message, $this->getUserOrThrow(), flush: true);\n\n        return new JsonResponse(\n            $this->serializeMessage($message),\n            headers: $headers\n        );\n    }\n\n    #[OA\\Response(\n        response: 200,\n        description: 'Marks the message as new',\n        content: new Model(type: MessageResponseDto::class),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Message not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'message_id',\n        in: 'path',\n        description: 'The message to mark as new',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Tag(name: 'message')]\n    #[Security(name: 'oauth2', scopes: ['user:message:read'])]\n    #[IsGranted('ROLE_OAUTH2_USER:MESSAGE:READ')]\n    public function unread(\n        #[MapEntity(id: 'message_id')]\n        Message $message,\n        MessageManager $manager,\n        RateLimiterFactoryInterface $apiUpdateLimiter,\n    ): JsonResponse {\n        if (!$this->isGranted('show', $message->thread)) {\n            throw new AccessDeniedHttpException();\n        }\n\n        $headers = $this->rateLimit($apiUpdateLimiter);\n\n        $manager->unreadMessage($message, $this->getUserOrThrow(), flush: true);\n\n        return new JsonResponse(\n            $this->serializeMessage($message),\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Message/MessageRetrieveApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Message;\n\nuse App\\Controller\\Traits\\PrivateContentTrait;\nuse App\\DTO\\MessageResponseDto;\nuse App\\DTO\\MessageThreadResponseDto;\nuse App\\DTO\\UserResponseDto;\nuse App\\Entity\\MessageThread;\nuse App\\Factory\\UserFactory;\nuse App\\PageView\\MessageThreadPageView;\nuse App\\Repository\\Criteria;\nuse App\\Repository\\MessageRepository;\nuse App\\Repository\\MessageThreadRepository;\nuse App\\Schema\\PaginationSchema;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse Nelmio\\ApiDocBundle\\Attribute\\Security;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpKernel\\Exception\\AccessDeniedHttpException;\nuse Symfony\\Component\\HttpKernel\\Exception\\NotFoundHttpException;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass MessageRetrieveApi extends MessageBaseApi\n{\n    use PrivateContentTrait;\n\n    public const MESSAGES_PER_PAGE = 25;\n\n    #[OA\\Response(\n        response: 200,\n        description: 'Returns the Message',\n        content: new Model(type: MessageResponseDto::class),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Message not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'message_id',\n        in: 'path',\n        description: 'The message to retrieve',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Tag(name: 'message')]\n    #[Security(name: 'oauth2', scopes: ['user:message:read'])]\n    #[IsGranted('ROLE_OAUTH2_USER:MESSAGE:READ')]\n    public function __invoke(\n        MessageRepository $repository,\n        RateLimiterFactoryInterface $apiReadLimiter,\n        RateLimiterFactoryInterface $anonymousApiReadLimiter,\n    ): JsonResponse {\n        $message = $repository->find((int) $this->request->getCurrentRequest()->get('message_id'));\n        if (null === $message) {\n            throw new NotFoundHttpException();\n        }\n        if (!$this->isGranted('show', $message->thread)) {\n            throw new AccessDeniedHttpException();\n        }\n\n        $headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter);\n\n        return new JsonResponse(\n            $this->serializeMessage($message),\n            headers: $headers\n        );\n    }\n\n    #[OA\\Response(\n        response: 200,\n        description: 'Returns a paginated list of message threads for the current user',\n        content: new OA\\JsonContent(\n            type: 'object',\n            properties: [\n                new OA\\Property(\n                    property: 'items',\n                    type: 'array',\n                    items: new OA\\Items(ref: new Model(type: MessageThreadResponseDto::class))\n                ),\n                new OA\\Property(\n                    property: 'pagination',\n                    ref: new Model(type: PaginationSchema::class)\n                ),\n            ]\n        ),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'p',\n        description: 'Page of messages to retrieve',\n        in: 'query',\n        schema: new OA\\Schema(type: 'integer', default: 1, minimum: 1)\n    )]\n    #[OA\\Parameter(\n        name: 'perPage',\n        description: 'Number of messages per page',\n        in: 'query',\n        schema: new OA\\Schema(type: 'integer', default: MessageThreadRepository::PER_PAGE, minimum: self::MIN_PER_PAGE, maximum: self::MAX_PER_PAGE)\n    )]\n    #[OA\\Parameter(\n        name: 'd',\n        description: 'Number of replies per thread',\n        in: 'query',\n        schema: new OA\\Schema(type: 'integer', default: self::REPLY_DEPTH, minimum: self::MIN_REPLY_DEPTH, maximum: self::MAX_REPLY_DEPTH)\n    )]\n    #[OA\\Tag(name: 'message')]\n    #[Security(name: 'oauth2', scopes: ['user:message:read'])]\n    #[IsGranted('ROLE_OAUTH2_USER:MESSAGE:READ')]\n    public function collection(\n        MessageThreadRepository $repository,\n        RateLimiterFactoryInterface $apiReadLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiReadLimiter);\n\n        $request = $this->request->getCurrentRequest();\n        $messages = $repository->findUserMessages(\n            $this->getUserOrThrow(),\n            $this->getPageNb($request),\n            $this->constrainPerPage($request->get('perPage', MessageThreadRepository::PER_PAGE))\n        );\n\n        $dtos = [];\n        foreach ($messages->getCurrentPageResults() as $value) {\n            array_push($dtos, $this->serializeMessageThread($value));\n        }\n\n        return new JsonResponse(\n            $this->serializePaginated($dtos, $messages),\n            headers: $headers\n        );\n    }\n\n    #[OA\\Response(\n        response: 200,\n        description: 'Returns a paginated list of messages in a thread',\n        content: new OA\\JsonContent(\n            type: 'object',\n            properties: [\n                new OA\\Property(\n                    property: 'items',\n                    type: 'array',\n                    items: new OA\\Items(ref: new Model(type: MessageResponseDto::class))\n                ),\n                new OA\\Property(\n                    property: 'pagination',\n                    ref: new Model(type: PaginationSchema::class)\n                ),\n                new OA\\Property(\n                    property: 'participants',\n                    type: 'array',\n                    items: new OA\\Items(ref: new Model(type: UserResponseDto::class))\n                ),\n            ]\n        ),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'You are not allowed to view the messages in this thread',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Page not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'thread_id',\n        description: 'Thread from which to retrieve messages',\n        in: 'path',\n        schema: new OA\\Schema(type: 'integer')\n    )]\n    #[OA\\Parameter(\n        name: 'p',\n        description: 'Page of messages to retrieve',\n        in: 'query',\n        schema: new OA\\Schema(type: 'integer', default: 1, minimum: 1)\n    )]\n    #[OA\\Parameter(\n        name: 'perPage',\n        description: 'Number of messages per page',\n        in: 'query',\n        schema: new OA\\Schema(\n            type: 'integer',\n            default: MessageRepository::PER_PAGE,\n            minimum: self::MIN_PER_PAGE,\n            maximum: self::MAX_PER_PAGE\n        )\n    )]\n    #[OA\\Parameter(\n        name: 'sort',\n        description: 'Order to retrieve messages by',\n        in: 'path',\n        schema: new OA\\Schema(type: 'string', default: Criteria::SORT_NEW, enum: MessageThreadPageView::SORT_OPTIONS)\n    )]\n    #[OA\\Tag(name: 'message')]\n    #[Security(name: 'oauth2', scopes: ['user:message:read'])]\n    #[IsGranted('ROLE_OAUTH2_USER:MESSAGE:READ')]\n    #[IsGranted('show', subject: 'thread', statusCode: 403)]\n    public function thread(\n        #[MapEntity(id: 'thread_id')]\n        MessageThread $thread,\n        MessageRepository $repository,\n        UserFactory $userFactory,\n        RateLimiterFactoryInterface $apiReadLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiReadLimiter);\n\n        $request = $this->request->getCurrentRequest();\n        $criteria = new MessageThreadPageView($this->getPageNb($request));\n        $criteria->perPage = $this->constrainPerPage($request->get('perPage', self::MESSAGES_PER_PAGE));\n        $criteria->thread = $thread;\n        $criteria->sortOption = $request->get('sort', Criteria::SORT_NEW);\n\n        $messages = $repository->findByCriteria($criteria);\n\n        $dtos = [];\n        foreach ($messages->getCurrentPageResults() as $value) {\n            array_push($dtos, $this->serializeMessage($value));\n        }\n\n        $paginated = $this->serializePaginated($dtos, $messages);\n        $paginated['participants'] = array_map(\n            fn ($participant) => new UserResponseDto($userFactory->createDto($participant)),\n            $thread->participants->toArray()\n        );\n\n        return new JsonResponse(\n            $paginated,\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Message/MessageThreadCreateApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Message;\n\nuse App\\Controller\\Traits\\PrivateContentTrait;\nuse App\\DTO\\MessageDto;\nuse App\\DTO\\MessageThreadResponseDto;\nuse App\\Entity\\User;\nuse App\\Service\\MessageManager;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse Nelmio\\ApiDocBundle\\Attribute\\Security;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpKernel\\Exception\\AccessDeniedHttpException;\nuse Symfony\\Component\\HttpKernel\\Exception\\BadRequestHttpException;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\nuse Symfony\\Component\\Validator\\Validator\\ValidatorInterface;\n\nclass MessageThreadCreateApi extends MessageBaseApi\n{\n    use PrivateContentTrait;\n\n    #[OA\\Response(\n        response: 201,\n        description: 'Message thread created',\n        content: new Model(type: MessageThreadResponseDto::class),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 400,\n        description: 'The request body was invalid',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\BadRequestErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'You are not permitted to message this user',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'User not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'user_id',\n        in: 'path',\n        description: 'User being messaged',\n        schema: new OA\\Schema(type: 'integer')\n    )]\n    #[OA\\Parameter(\n        name: 'd',\n        in: 'query',\n        description: 'Number of replies returned',\n        schema: new OA\\Schema(type: 'integer', default: self::REPLY_DEPTH, minimum: self::MIN_REPLY_DEPTH, maximum: self::MAX_REPLY_DEPTH)\n    )]\n    #[OA\\RequestBody(content: new Model(type: MessageDto::class))]\n    #[OA\\Tag(name: 'message')]\n    #[Security(name: 'oauth2', scopes: ['user:message:create'])]\n    #[IsGranted('ROLE_OAUTH2_USER:MESSAGE:CREATE')]\n    #[IsGranted('message', subject: 'receiver', statusCode: 403)]\n    public function __invoke(\n        #[MapEntity(id: 'user_id')]\n        User $receiver,\n        MessageManager $manager,\n        ValidatorInterface $validator,\n        RateLimiterFactoryInterface $apiMessageLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiMessageLimiter);\n\n        if ($receiver->apId) {\n            throw new AccessDeniedHttpException();\n        }\n\n        $dto = $this->deserializeMessage();\n\n        $errors = $validator->validate($dto);\n        if (\\count($errors) > 0) {\n            throw new BadRequestHttpException((string) $errors);\n        }\n\n        $thread = $manager->toThread($dto, $this->getUserOrThrow(), $receiver);\n\n        return new JsonResponse(\n            $this->serializeMessageThread($thread),\n            status: 201,\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Message/MessageThreadReplyApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Message;\n\nuse App\\Controller\\Traits\\PrivateContentTrait;\nuse App\\DTO\\MessageDto;\nuse App\\DTO\\MessageThreadResponseDto;\nuse App\\Entity\\MessageThread;\nuse App\\Service\\MessageManager;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse Nelmio\\ApiDocBundle\\Attribute\\Security;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpKernel\\Exception\\BadRequestHttpException;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\nuse Symfony\\Component\\Validator\\Validator\\ValidatorInterface;\n\nclass MessageThreadReplyApi extends MessageBaseApi\n{\n    use PrivateContentTrait;\n\n    #[OA\\Response(\n        response: 201,\n        description: 'Message reply added',\n        content: new Model(type: MessageThreadResponseDto::class),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 400,\n        description: 'The request body was invalid',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\BadRequestErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'You are not permitted to message in thread',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'User not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'thread_id',\n        in: 'path',\n        description: 'Thread being replied to',\n        schema: new OA\\Schema(type: 'integer')\n    )]\n    #[OA\\Parameter(\n        name: 'd',\n        in: 'query',\n        description: 'Number of replies returned',\n        schema: new OA\\Schema(type: 'integer', default: self::REPLY_DEPTH, minimum: self::MIN_REPLY_DEPTH, maximum: self::MAX_REPLY_DEPTH)\n    )]\n    #[OA\\RequestBody(content: new Model(type: MessageDto::class))]\n    #[OA\\Tag(name: 'message')]\n    #[Security(name: 'oauth2', scopes: ['user:message:create'])]\n    #[IsGranted('ROLE_OAUTH2_USER:MESSAGE:CREATE')]\n    #[IsGranted('show', subject: 'thread', statusCode: 403)]\n    public function __invoke(\n        #[MapEntity(id: 'thread_id')]\n        MessageThread $thread,\n        MessageManager $manager,\n        ValidatorInterface $validator,\n        RateLimiterFactoryInterface $apiMessageLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiMessageLimiter);\n\n        $dto = $this->deserializeMessage();\n\n        $errors = $validator->validate($dto);\n        if (\\count($errors) > 0) {\n            throw new BadRequestHttpException((string) $errors);\n        }\n\n        $manager->toMessage($dto, $thread, $this->getUserOrThrow());\n\n        return new JsonResponse(\n            $this->serializeMessageThread($thread),\n            status: 201,\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Notification/NotificationBaseApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Notification;\n\nuse App\\Controller\\Api\\BaseApi;\nuse App\\DTO\\EntryCommentResponseDto;\nuse App\\DTO\\EntryResponseDto;\nuse App\\DTO\\PostCommentResponseDto;\nuse App\\DTO\\PostResponseDto;\nuse App\\Entity\\Contracts\\ReportInterface;\nuse App\\Entity\\Entry;\nuse App\\Entity\\EntryComment;\nuse App\\Entity\\NewSignupNotification;\nuse App\\Entity\\Notification;\nuse App\\Entity\\Post;\nuse App\\Entity\\PostComment;\nuse App\\Entity\\ReportApprovedNotification;\nuse App\\Entity\\ReportCreatedNotification;\nuse App\\Entity\\ReportRejectedNotification;\nuse App\\Factory\\MessageFactory;\nuse Symfony\\Contracts\\Service\\Attribute\\Required;\n\nclass NotificationBaseApi extends BaseApi\n{\n    private MessageFactory $messageFactory;\n\n    #[Required]\n    public function setMessageFactory(MessageFactory $messageFactory)\n    {\n        $this->messageFactory = $messageFactory;\n    }\n\n    /**\n     * Serialize a single message to JSON.\n     *\n     * @param Notification $dto The Notification to serialize\n     *\n     * @return array An associative array representation of the message's safe fields, to be used as JSON\n     */\n    protected function serializeNotification(Notification $dto)\n    {\n        $toReturn = [\n            'notificationId' => $dto->getId(),\n            'status' => $dto->status,\n            'type' => $dto->getType(),\n        ];\n\n        switch ($dto->getType()) {\n            case 'entry_created_notification':\n            case 'entry_edited_notification':\n            case 'entry_deleted_notification':\n            case 'entry_mentioned_notification':\n                /**\n                 * @var \\App\\Entity\\EntryMentionedNotification $dto\n                 */\n                $entry = $dto->getSubject();\n                $toReturn['subject'] = $this->entryFactory->createResponseDto($entry, $this->tagLinkRepository->getTagsOfContent($entry));\n                break;\n            case 'entry_comment_created_notification':\n            case 'entry_comment_edited_notification':\n            case 'entry_comment_reply_notification':\n            case 'entry_comment_deleted_notification':\n            case 'entry_comment_mentioned_notification':\n                /**\n                 * @var \\App\\Entity\\EntryCommentMentionedNotification $dto\n                 */\n                $comment = $dto->getSubject();\n                $toReturn['subject'] = $this->entryCommentFactory->createResponseDto($comment, $this->tagLinkRepository->getTagsOfContent($comment));\n                break;\n            case 'post_created_notification':\n            case 'post_edited_notification':\n            case 'post_deleted_notification':\n            case 'post_mentioned_notification':\n                /**\n                 * @var \\App\\Entity\\PostMentionedNotification $dto\n                 */\n                $post = $dto->getSubject();\n                $toReturn['subject'] = $this->postFactory->createResponseDto($post, $this->tagLinkRepository->getTagsOfContent($post));\n                break;\n            case 'post_comment_created_notification':\n            case 'post_comment_edited_notification':\n            case 'post_comment_reply_notification':\n            case 'post_comment_deleted_notification':\n            case 'post_comment_mentioned_notification':\n                /**\n                 * @var \\App\\Entity\\PostCommentMentionedNotification $dto\n                 */\n                $comment = $dto->getSubject();\n                $toReturn['subject'] = $this->postCommentFactory->createResponseDto($comment, $this->tagLinkRepository->getTagsOfContent($comment));\n                break;\n            case 'message_notification':\n                if (!$this->isGranted('ROLE_OAUTH2_USER:MESSAGE:READ')) {\n                    $toReturn['subject'] = [\n                        'messageId' => null,\n                        'threadId' => null,\n                        'sender' => null,\n                        'body' => $this->translator->trans('oauth.client_not_granted_message_read_permission'),\n                        'status' => null,\n                        'createdAt' => null,\n                    ];\n                    break;\n                }\n                /**\n                 * @var \\App\\Entity\\MessageNotification $dto\n                 */\n                $message = $dto->getSubject();\n                $toReturn['subject'] = $this->messageFactory->createResponseDto($message);\n                break;\n            case 'ban':\n                /**\n                 * @var \\App\\Entity\\MagazineBanNotification $dto\n                 */\n                $ban = $dto->getSubject();\n                $toReturn['subject'] = $this->magazineFactory->createBanDto($ban);\n                break;\n            case 'report_created_notification':\n                /** @var ReportCreatedNotification $n */\n                $n = $dto;\n                $toReturn['reason'] = $n->report->reason;\n                // no break\n            case 'report_rejected_notification':\n            case 'report_approved_notification':\n                /** @var ReportCreatedNotification|ReportRejectedNotification|ReportApprovedNotification $n */\n                $n = $dto;\n                $toReturn['subject'] = $this->createResponseDtoForReport($n->report->getSubject());\n                $toReturn['reportId'] = $n->report->getId();\n                break;\n            case 'new_signup':\n                /** @var NewSignupNotification $n */\n                $n = $dto;\n                $toReturn['subject'] = $this->userFactory->createSignupResponseDto($n->getSubject());\n                break;\n        }\n\n        return $toReturn;\n    }\n\n    private function createResponseDtoForReport(ReportInterface $subject): EntryCommentResponseDto|EntryResponseDto|PostCommentResponseDto|PostResponseDto\n    {\n        if ($subject instanceof Entry) {\n            return $this->entryFactory->createResponseDto($subject, $this->tagLinkRepository->getTagsOfContent($subject));\n        } elseif ($subject instanceof EntryComment) {\n            return $this->entryCommentFactory->createResponseDto($subject, $this->tagLinkRepository->getTagsOfContent($subject));\n        } elseif ($subject instanceof Post) {\n            return $this->postFactory->createResponseDto($subject, $this->tagLinkRepository->getTagsOfContent($subject));\n        } elseif ($subject instanceof PostComment) {\n            return $this->postCommentFactory->createResponseDto($subject, $this->tagLinkRepository->getTagsOfContent($subject));\n        }\n        throw new \\InvalidArgumentException(\"cannot work with: '\".\\get_class($subject).\"'\");\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Notification/NotificationPurgeApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Notification;\n\nuse App\\Controller\\Traits\\PrivateContentTrait;\nuse App\\Entity\\Notification;\nuse App\\Service\\NotificationManager;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse Nelmio\\ApiDocBundle\\Attribute\\Security;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass NotificationPurgeApi extends NotificationBaseApi\n{\n    use PrivateContentTrait;\n\n    #[OA\\Response(\n        response: 204,\n        description: 'Cleared the notification',\n        content: null,\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'You are not allowed to delete this notification',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Notification not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'notification_id',\n        in: 'path',\n        description: 'The notification to delete',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Tag(name: 'notification')]\n    #[Security(name: 'oauth2', scopes: ['user:notification:delete'])]\n    #[IsGranted('ROLE_OAUTH2_USER:NOTIFICATION:DELETE')]\n    #[IsGranted('delete', subject: 'notification')]\n    public function purge(\n        #[MapEntity(id: 'notification_id')]\n        Notification $notification,\n        RateLimiterFactoryInterface $apiNotificationLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiNotificationLimiter);\n\n        $this->entityManager->remove($notification);\n        $this->entityManager->flush();\n\n        return new JsonResponse(\n            status: 204,\n            headers: $headers\n        );\n    }\n\n    #[OA\\Response(\n        response: 204,\n        description: 'Cleared all notifications',\n        content: null,\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'You do not have permission to clear notifications',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Tag(name: 'notification')]\n    #[Security(name: 'oauth2', scopes: ['user:notification:delete'])]\n    #[IsGranted('ROLE_OAUTH2_USER:NOTIFICATION:DELETE')]\n    public function purgeAll(\n        NotificationManager $manager,\n        RateLimiterFactoryInterface $apiNotificationLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiNotificationLimiter);\n\n        $manager->clear($this->getUserOrThrow());\n\n        return new JsonResponse(\n            status: 204,\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Notification/NotificationPushApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Notification;\n\nuse App\\Controller\\Traits\\PrivateContentTrait;\nuse App\\DTO\\NotificationPushSubscriptionRequestDto;\nuse App\\Entity\\UserPushSubscription;\nuse App\\Payloads\\PushNotification;\nuse App\\Repository\\UserPushSubscriptionRepository;\nuse App\\Schema\\Errors\\ForbiddenErrorSchema;\nuse App\\Schema\\Errors\\NotFoundErrorSchema;\nuse App\\Schema\\Errors\\TooManyRequestsErrorSchema;\nuse App\\Schema\\Errors\\UnauthorizedErrorSchema;\nuse App\\Service\\Notification\\UserPushSubscriptionManager;\nuse App\\Service\\SettingsManager;\nuse Doctrine\\DBAL\\ParameterType;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse Nelmio\\ApiDocBundle\\Attribute\\Security;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Component\\HttpFoundation\\Exception\\BadRequestException;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpKernel\\Attribute\\MapRequestPayload;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\nuse Symfony\\Contracts\\Translation\\TranslatorInterface;\n\nclass NotificationPushApi extends NotificationBaseApi\n{\n    use PrivateContentTrait;\n\n    #[OA\\Response(\n        response: 200,\n        description: 'Created a new push subscription. If there already is a push subscription for this client it will be overwritten. a test notification will be sent right away',\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\\Schema(type: 'integer')),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'You are not allowed to create push notifications',\n        content: new OA\\JsonContent(ref: new Model(type: ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\\Schema(type: 'integer')),\n        ],\n        content: new OA\\JsonContent(ref: new Model(type: TooManyRequestsErrorSchema::class))\n    )]\n    #[OA\\RequestBody(content: new Model(type: NotificationPushSubscriptionRequestDto::class))]\n    #[OA\\Tag(name: 'notification')]\n    #[Security(name: 'oauth2', scopes: ['user:notification:read'])]\n    #[IsGranted('ROLE_OAUTH2_USER:NOTIFICATION:READ')]\n    /**\n     * Register a new push subscription.\n     */\n    public function createSubscription(\n        RateLimiterFactoryInterface $apiNotificationLimiter,\n        UserPushSubscriptionRepository $repository,\n        SettingsManager $settingsManager,\n        UserPushSubscriptionManager $pushSubscriptionManager,\n        TranslatorInterface $translator,\n        #[MapRequestPayload] NotificationPushSubscriptionRequestDto $payload,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiNotificationLimiter);\n        $user = $this->getUserOrThrow();\n        $token = $this->getOAuthToken();\n        $apiToken = $this->getAccessToken($token);\n\n        $pushSubscription = $repository->findOneBy(['user' => $user, 'apiToken' => $apiToken]);\n        if (!$pushSubscription) {\n            $pushSubscription = new UserPushSubscription($user, $payload->endpoint, $payload->contentPublicKey, $payload->serverKey, [], $apiToken);\n            $pushSubscription->locale = $settingsManager->getLocale();\n        } else {\n            $pushSubscription->endpoint = $payload->endpoint;\n            $pushSubscription->serverAuthKey = $payload->serverKey;\n            $pushSubscription->contentEncryptionPublicKey = $payload->contentPublicKey;\n        }\n\n        $this->entityManager->persist($pushSubscription);\n        $this->entityManager->flush();\n\n        try {\n            $testNotification = new PushNotification(null, '', $translator->trans('test_push_message', locale: $pushSubscription->locale));\n            $pushSubscriptionManager->sendTextToUser($user, $testNotification, specificToken: $apiToken);\n\n            return new JsonResponse(headers: $headers);\n        } catch (\\ErrorException $e) {\n            $this->logger->error('There was an exception while deleting a UserPushSubscription: {e} - {m}. {o}', [\n                'e' => \\get_class($e),\n                'm' => $e->getMessage(),\n                'o' => json_encode($e),\n            ]);\n\n            return new JsonResponse(status: 500, headers: $headers);\n        }\n    }\n\n    #[OA\\Response(\n        response: 200,\n        description: 'Deleted the existing push subscription',\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\\Schema(type: 'integer')),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'You are not allowed to create push notifications',\n        content: new OA\\JsonContent(ref: new Model(type: ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Notification not found',\n        content: new OA\\JsonContent(ref: new Model(type: NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\\Schema(type: 'integer')),\n        ],\n        content: new OA\\JsonContent(ref: new Model(type: TooManyRequestsErrorSchema::class))\n    )]\n    #[OA\\Tag(name: 'notification')]\n    #[Security(name: 'oauth2', scopes: ['user:notification:read'])]\n    #[IsGranted('ROLE_OAUTH2_USER:NOTIFICATION:READ')]\n    /**\n     * Delete the existing push subscription.\n     */\n    public function deleteSubscription(\n        RateLimiterFactoryInterface $apiNotificationLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiNotificationLimiter);\n        $user = $this->getUserOrThrow();\n        $token = $this->getOAuthToken();\n        $apiToken = $this->getAccessToken($token);\n\n        try {\n            $conn = $this->entityManager->getConnection();\n            $stmt = $conn->prepare('DELETE FROM user_push_subscription WHERE user_id = :user AND api_token = :token');\n            $stmt->bindValue('user', $user->getId(), ParameterType::INTEGER);\n            $stmt->bindValue('token', $apiToken->getIdentifier());\n            $stmt->executeQuery();\n\n            return new JsonResponse(headers: $headers);\n        } catch (\\Exception $e) {\n            $this->logger->error('There was an exception while deleting a UserPushSubscription: {e} - {m}. {o}', [\n                'e' => \\get_class($e),\n                'm' => $e->getMessage(),\n                'o' => json_encode($e),\n            ]);\n\n            return new JsonResponse(status: 500, headers: $headers);\n        }\n    }\n\n    #[OA\\Response(\n        response: 200,\n        description: 'A test notification should arrive shortly',\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\\Schema(type: 'integer')),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'You are not allowed to create push notifications',\n        content: new OA\\JsonContent(ref: new Model(type: ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\\Schema(type: 'integer')),\n        ],\n        content: new OA\\JsonContent(ref: new Model(type: TooManyRequestsErrorSchema::class))\n    )]\n    #[OA\\Tag(name: 'notification')]\n    #[Security(name: 'oauth2', scopes: ['user:notification:read'])]\n    #[IsGranted('ROLE_OAUTH2_USER:NOTIFICATION:READ')]\n    /**\n     * Send a test push notification.\n     */\n    public function testSubscription(\n        RateLimiterFactoryInterface $apiNotificationLimiter,\n        UserPushSubscriptionRepository $repository,\n        UserPushSubscriptionManager $pushSubscriptionManager,\n        TranslatorInterface $translator,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiNotificationLimiter);\n        $user = $this->getUserOrThrow();\n        $token = $this->getOAuthToken();\n        $apiToken = $this->getAccessToken($token);\n\n        $sub = $repository->findOneBy(['user' => $user, 'apiToken' => $apiToken]);\n        if ($sub) {\n            $testNotification = new PushNotification(null, '', $translator->trans('test_push_message', locale: $sub->locale));\n            try {\n                $pushSubscriptionManager->sendTextToUser($user, $testNotification, specificToken: $apiToken);\n\n                return new JsonResponse(headers: $headers);\n            } catch (\\ErrorException $e) {\n                $this->logger->error('There was an exception while deleting a UserPushSubscription: {e} - {m}. {o}', [\n                    'e' => \\get_class($e),\n                    'm' => $e->getMessage(),\n                    'o' => json_encode($e),\n                ]);\n\n                return new JsonResponse(status: 500, headers: $headers);\n            }\n        } else {\n            throw new BadRequestException(message: 'PushSubscription not found', statusCode: 404);\n        }\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Notification/NotificationReadApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Notification;\n\nuse App\\Controller\\Traits\\PrivateContentTrait;\nuse App\\Entity\\Notification;\nuse App\\Schema\\NotificationSchema;\nuse App\\Service\\NotificationManager;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse Nelmio\\ApiDocBundle\\Attribute\\Security;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass NotificationReadApi extends NotificationBaseApi\n{\n    use PrivateContentTrait;\n\n    #[OA\\Response(\n        response: 200,\n        description: 'Marked the notification as read',\n        content: new Model(type: NotificationSchema::class),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'You are not allowed to mark this notification as read',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Notification not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'notification_id',\n        in: 'path',\n        description: 'The notification to read',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Tag(name: 'notification')]\n    #[Security(name: 'oauth2', scopes: ['user:notification:read'])]\n    #[IsGranted('ROLE_OAUTH2_USER:NOTIFICATION:READ')]\n    #[IsGranted('view', 'notification')]\n    public function read(\n        #[MapEntity(id: 'notification_id')]\n        Notification $notification,\n        RateLimiterFactoryInterface $apiNotificationLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiNotificationLimiter);\n\n        $notification->status = Notification::STATUS_READ;\n        $this->entityManager->flush();\n\n        return new JsonResponse(\n            $this->serializeNotification($notification),\n            headers: $headers\n        );\n    }\n\n    #[OA\\Response(\n        response: 204,\n        description: 'Marked all notifications as read',\n        content: null,\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'You are not allowed to mark notifications as read',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Notification not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Tag(name: 'notification')]\n    #[Security(name: 'oauth2', scopes: ['user:notification:read'])]\n    #[IsGranted('ROLE_OAUTH2_USER:NOTIFICATION:READ')]\n    public function readAll(\n        NotificationManager $manager,\n        RateLimiterFactoryInterface $apiNotificationLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiNotificationLimiter);\n\n        $manager->markAllAsRead($this->getUserOrThrow());\n\n        return new JsonResponse(\n            status: 204,\n            headers: $headers\n        );\n    }\n\n    #[OA\\Response(\n        response: 200,\n        description: 'Marked the notification as new',\n        content: new Model(type: NotificationSchema::class),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'You are not allowed to mark this notification as new',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Notification not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'notification_id',\n        in: 'path',\n        description: 'The notification to mark as new',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Tag(name: 'notification')]\n    #[Security(name: 'oauth2', scopes: ['user:notification:read'])]\n    #[IsGranted('ROLE_OAUTH2_USER:NOTIFICATION:READ')]\n    #[IsGranted('view', 'notification')]\n    public function unread(\n        #[MapEntity(id: 'notification_id')]\n        Notification $notification,\n        RateLimiterFactoryInterface $apiNotificationLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiNotificationLimiter);\n\n        $notification->status = Notification::STATUS_NEW;\n        $this->entityManager->flush();\n\n        return new JsonResponse(\n            $this->serializeNotification($notification),\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Notification/NotificationRetrieveApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Notification;\n\nuse App\\Controller\\Traits\\PrivateContentTrait;\nuse App\\Entity\\Notification;\nuse App\\Repository\\NotificationRepository;\nuse App\\Schema\\NotificationSchema;\nuse App\\Schema\\PaginationSchema;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse Nelmio\\ApiDocBundle\\Attribute\\Security;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpKernel\\Exception\\BadRequestHttpException;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass NotificationRetrieveApi extends NotificationBaseApi\n{\n    use PrivateContentTrait;\n\n    #[OA\\Response(\n        response: 200,\n        description: 'Returns the Notification',\n        content: new Model(type: NotificationSchema::class),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'You do not have permission to view this notification',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Notification not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'notification_id',\n        in: 'path',\n        description: 'The notification to retrieve',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Tag(name: 'notification')]\n    #[Security(name: 'oauth2', scopes: ['user:notification:read'])]\n    #[IsGranted('ROLE_OAUTH2_USER:NOTIFICATION:READ')]\n    #[IsGranted('view', 'notification')]\n    public function __invoke(\n        #[MapEntity(id: 'notification_id')]\n        Notification $notification,\n        RateLimiterFactoryInterface $apiNotificationLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiNotificationLimiter);\n\n        return new JsonResponse(\n            $this->serializeNotification($notification),\n            headers: $headers\n        );\n    }\n\n    #[OA\\Response(\n        response: 200,\n        description: 'Returns a paginated list of notifications for the current user',\n        content: new OA\\JsonContent(\n            type: 'object',\n            properties: [\n                new OA\\Property(\n                    property: 'items',\n                    type: 'array',\n                    items: new OA\\Items(ref: new Model(type: NotificationSchema::class))\n                ),\n                new OA\\Property(\n                    property: 'pagination',\n                    ref: new Model(type: PaginationSchema::class)\n                ),\n            ]\n        ),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 400,\n        description: 'Invalid status type requested',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\BadRequestErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'You do not have permission to view notifications',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'p',\n        description: 'Page of notifications to retrieve',\n        in: 'query',\n        schema: new OA\\Schema(type: 'integer', default: 1, minimum: 1)\n    )]\n    #[OA\\Parameter(\n        name: 'perPage',\n        description: 'Number of notifications per page',\n        in: 'query',\n        schema: new OA\\Schema(type: 'integer', default: NotificationRepository::PER_PAGE, minimum: self::MIN_PER_PAGE, maximum: self::MAX_PER_PAGE)\n    )]\n    #[OA\\Parameter(\n        name: 'status',\n        description: 'Notification status to retrieve',\n        in: 'path',\n        schema: new OA\\Schema(type: 'string', default: NotificationRepository::STATUS_ALL, enum: NotificationRepository::STATUS_OPTIONS)\n    )]\n    #[OA\\Tag(name: 'notification')]\n    #[Security(name: 'oauth2', scopes: ['user:notification:read'])]\n    #[IsGranted('ROLE_OAUTH2_USER:NOTIFICATION:READ')]\n    public function collection(\n        string $status,\n        NotificationRepository $repository,\n        RateLimiterFactoryInterface $apiNotificationLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiNotificationLimiter);\n\n        // 0 is falsy so need to compare with false to be certain the item was not found\n        if (false === array_search($status, NotificationRepository::STATUS_OPTIONS)) {\n            throw new BadRequestHttpException();\n        }\n\n        $request = $this->request->getCurrentRequest();\n        $notifications = $repository->findByUser(\n            $this->getUserOrThrow(),\n            $this->getPageNb($request),\n            $status,\n            $this->constrainPerPage($request->get('perPage', NotificationRepository::PER_PAGE))\n        );\n\n        $dtos = [];\n        foreach ($notifications->getCurrentPageResults() as $value) {\n            array_push($dtos, $this->serializeNotification($value));\n        }\n\n        return new JsonResponse(\n            $this->serializePaginated($dtos, $notifications),\n            headers: $headers\n        );\n    }\n\n    #[OA\\Response(\n        response: 200,\n        description: 'Returns the number of unread notifications for the current user',\n        content: new OA\\JsonContent(\n            type: 'object',\n            properties: [\n                new OA\\Property(\n                    property: 'count',\n                    type: 'integer',\n                    minimum: 0\n                ),\n            ]\n        ),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'You do not have permission to view notification counts',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Tag(name: 'notification')]\n    #[Security(name: 'oauth2', scopes: ['user:notification:read'])]\n    #[IsGranted('ROLE_OAUTH2_USER:NOTIFICATION:READ')]\n    public function count(\n        NotificationRepository $repository,\n        RateLimiterFactoryInterface $apiNotificationLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiNotificationLimiter);\n\n        $count = $repository->countUnreadNotifications($this->getUserOrThrow());\n\n        return new JsonResponse(\n            [\n                'count' => $count,\n            ],\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Notification/NotificationSettingApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Notification;\n\nuse App\\Controller\\Traits\\PrivateContentTrait;\nuse App\\Entity\\Entry;\nuse App\\Entity\\Magazine;\nuse App\\Entity\\Post;\nuse App\\Entity\\User;\nuse App\\Enums\\ENotificationStatus;\nuse App\\Schema\\Errors\\NotFoundErrorSchema;\nuse App\\Schema\\Errors\\TooManyRequestsErrorSchema;\nuse App\\Schema\\Errors\\UnauthorizedErrorSchema;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse Nelmio\\ApiDocBundle\\Attribute\\Security;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass NotificationSettingApi extends NotificationBaseApi\n{\n    use PrivateContentTrait;\n\n    #[OA\\Response(\n        response: 204,\n        description: 'Updated the notification status',\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\\Schema(type: 'integer')),\n        ],\n        content: null\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Target not found',\n        content: new OA\\JsonContent(ref: new Model(type: NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\\Schema(type: 'integer')),\n        ],\n        content: new OA\\JsonContent(ref: new Model(type: TooManyRequestsErrorSchema::class))\n    )]\n    #[OA\\Parameter(\n        name: 'target_id',\n        description: 'The id of the target',\n        in: 'path',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Parameter(\n        name: 'target_type',\n        description: 'The type of the target',\n        in: 'path',\n        schema: new OA\\Schema(enum: ['entry', 'post', 'magazine', 'user']),\n    )]\n    #[OA\\Parameter(\n        name: 'setting',\n        description: 'The new notification setting',\n        in: 'path',\n        schema: new OA\\Schema(enum: ENotificationStatus::Values),\n    )]\n    #[OA\\Tag(name: 'notification')]\n    #[Security(name: 'oauth2', scopes: ['user:notification:edit'])]\n    #[IsGranted('ROLE_OAUTH2_USER:NOTIFICATION:EDIT')]\n    public function update(\n        string $targetType,\n        int $targetId,\n        string $setting,\n        RateLimiterFactoryInterface $apiUpdateLimiter,\n    ): JsonResponse {\n        $this->rateLimit($apiUpdateLimiter);\n        $user = $this->getUserOrThrow();\n        $notificationSetting = ENotificationStatus::getFromString($setting);\n        if (null === $notificationSetting) {\n            throw $this->createNotFoundException('setting does not exist');\n        }\n\n        if ('entry' === $targetType) {\n            $repo = $this->entityManager->getRepository(Entry::class);\n        } elseif ('post' === $targetType) {\n            $repo = $this->entityManager->getRepository(Post::class);\n        } elseif ('magazine' === $targetType) {\n            $repo = $this->entityManager->getRepository(Magazine::class);\n        } elseif ('user' === $targetType) {\n            $repo = $this->entityManager->getRepository(User::class);\n        } else {\n            throw new \\LogicException();\n        }\n        $target = $repo->find($targetId);\n        if (null === $target) {\n            throw $this->createNotFoundException();\n        }\n        $this->notificationSettingsRepository->setStatusByTarget($user, $target, $notificationSetting);\n\n        return new JsonResponse();\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/OAuth2/Admin/RetrieveClientApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\OAuth2\\Admin;\n\nuse App\\Controller\\Api\\BaseApi;\nuse App\\DTO\\ClientResponseDto;\nuse App\\Entity\\Client;\nuse App\\Schema\\PaginationSchema;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Doctrine\\ORM\\EntityRepository;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse Nelmio\\ApiDocBundle\\Attribute\\Security;\nuse OpenApi\\Attributes as OA;\nuse Pagerfanta\\Doctrine\\ORM\\QueryAdapter;\nuse Pagerfanta\\Exception\\NotValidCurrentPageException;\nuse Pagerfanta\\Pagerfanta;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpKernel\\Exception\\NotFoundHttpException;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass RetrieveClientApi extends BaseApi\n{\n    public const PER_PAGE = 15;\n\n    #[OA\\Response(\n        response: 200,\n        description: 'Returns the OAuth2 Client',\n        content: new Model(type: ClientResponseDto::class),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'You are not allowed to view clients on this instance',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Client not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'client_identifier',\n        in: 'path',\n        description: 'The OAuth2 client to retrieve',\n        schema: new OA\\Schema(type: 'string'),\n    )]\n    #[OA\\Tag(name: 'admin/oauth2')]\n    #[IsGranted('ROLE_ADMIN')]\n    #[Security(name: 'oauth2', scopes: ['admin:oauth_clients:read'])]\n    #[IsGranted('ROLE_OAUTH2_ADMIN:OAUTH_CLIENTS:READ')]\n    public function __invoke(\n        #[MapEntity(mapping: ['client_identifier' => 'identifier'])]\n        Client $client,\n        RateLimiterFactoryInterface $apiModerateLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiModerateLimiter);\n\n        $dto = new ClientResponseDto($client);\n\n        return new JsonResponse(\n            $dto,\n            headers: $headers\n        );\n    }\n\n    #[OA\\Response(\n        response: 200,\n        description: 'Returns a paginated list of clients',\n        content: new OA\\JsonContent(\n            type: 'object',\n            properties: [\n                new OA\\Property(\n                    property: 'items',\n                    type: 'array',\n                    items: new OA\\Items(ref: new Model(type: ClientResponseDto::class))\n                ),\n                new OA\\Property(\n                    property: 'pagination',\n                    ref: new Model(type: PaginationSchema::class)\n                ),\n            ]\n        ),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'You are not permitted to read a list of oauth2 clients on this instance',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Page does not exist',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'p',\n        description: 'Page of clients to retrieve',\n        in: 'query',\n        schema: new OA\\Schema(type: 'integer', default: 1, minimum: 1)\n    )]\n    #[OA\\Parameter(\n        name: 'perPage',\n        description: 'Number of clients per page',\n        in: 'query',\n        schema: new OA\\Schema(type: 'integer', default: self::PER_PAGE, minimum: self::MIN_PER_PAGE, maximum: self::MAX_PER_PAGE)\n    )]\n    #[OA\\Tag(name: 'admin/oauth2')]\n    #[IsGranted('ROLE_ADMIN')]\n    #[Security(name: 'oauth2', scopes: ['admin:oauth_clients:read'])]\n    #[IsGranted('ROLE_OAUTH2_ADMIN:OAUTH_CLIENTS:READ')]\n    public function collection(\n        EntityManagerInterface $manager,\n        Request $request,\n        RateLimiterFactoryInterface $apiModerateLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiModerateLimiter);\n\n        $page = $this->getPageNb($request);\n        $perPage = self::constrainPerPage($request->get('perPage', self::PER_PAGE));\n\n        /** @var EntityRepository $repository */\n        $repository = $manager->getRepository(Client::class);\n\n        $qb = $repository->createQueryBuilder('c');\n\n        $pagerfanta = new Pagerfanta(\n            new QueryAdapter(\n                $qb\n            )\n        );\n\n        try {\n            $pagerfanta->setMaxPerPage($perPage);\n            $pagerfanta->setCurrentPage($page);\n        } catch (NotValidCurrentPageException $e) {\n            throw new NotFoundHttpException();\n        }\n\n        $dtos = [];\n        foreach ($pagerfanta->getCurrentPageResults() as $client) {\n            \\assert($client instanceof Client);\n            array_push($dtos, new ClientResponseDto($client));\n        }\n\n        return new JsonResponse(\n            $this->serializePaginated($dtos, $pagerfanta),\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/OAuth2/Admin/RetrieveClientStatsApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\OAuth2\\Admin;\n\nuse App\\Controller\\Api\\BaseApi;\nuse App\\DTO\\ClientAccessStatsResponseDto;\nuse App\\Repository\\OAuth2ClientAccessRepository;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse Nelmio\\ApiDocBundle\\Attribute\\Security;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpKernel\\Exception\\BadRequestHttpException;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass RetrieveClientStatsApi extends BaseApi\n{\n    #[OA\\Response(\n        response: 200,\n        description: 'Accesses by interval retrieved. These are not guaranteed to be continuous.',\n        content: new OA\\JsonContent(\n            properties: [\n                new OA\\Property(\n                    'data',\n                    type: 'array',\n                    items: new OA\\Items(ref: new Model(type: ClientAccessStatsResponseDto::class))\n                ),\n            ]\n        ),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 400,\n        description: 'Invalid parameters',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\BadRequestErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'You do not have permission to view the OAuth2 client stats',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'start',\n        in: 'query',\n        description: 'The start date of the window to retrieve views in. If not provided defaults to 1 `resolution` ago',\n        schema: new OA\\Schema(type: 'string', format: 'date'),\n    )]\n    #[OA\\Parameter(\n        name: 'end',\n        in: 'query',\n        description: 'The end date of the window to retrieve views in. If not provided defaults to today',\n        schema: new OA\\Schema(type: 'string', format: 'date'),\n    )]\n    #[OA\\Parameter(\n        name: 'resolution',\n        required: true,\n        in: 'query',\n        description: 'The size of chunks to aggregate views in',\n        schema: new OA\\Schema(type: 'string', enum: ['all', 'year', 'month', 'day', 'hour', 'second', 'milliseconds']),\n    )]\n    #[OA\\Tag(name: 'admin/oauth2')]\n    #[IsGranted('ROLE_ADMIN')]\n    #[Security(name: 'oauth2', scopes: ['admin:oauth_clients:read'])]\n    #[IsGranted('ROLE_OAUTH2_ADMIN:OAUTH_CLIENTS:READ')]\n    /**\n     * Retrieve oauth2 client access stats in a particular interval.\n     */\n    public function __invoke(\n        Request $request,\n        OAuth2ClientAccessRepository $repository,\n        RateLimiterFactoryInterface $apiModerateLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiModerateLimiter);\n        $resolution = $request->get('resolution');\n\n        try {\n            $startString = $request->get('start');\n            if (null === $startString) {\n                $start = null;\n            } else {\n                $start = new \\DateTime($startString);\n            }\n\n            $endString = $request->get('end');\n            if (null === $endString) {\n                $end = null;\n            } else {\n                $end = new \\DateTime($endString);\n            }\n        } catch (\\Exception $e) {\n            throw new BadRequestHttpException('Failed to parse start or end time');\n        }\n\n        if (null === $resolution) {\n            throw new BadRequestHttpException('Resolution must be provided!');\n        }\n\n        try {\n            $stats = $repository->getStats($resolution, $start, $end);\n        } catch (\\LogicException $e) {\n            throw new BadRequestHttpException($e->getMessage());\n        }\n\n        return new JsonResponse(\n            [\n                'data' => $stats,\n            ],\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/OAuth2/CreateClientApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\OAuth2;\n\nuse App\\Controller\\Api\\BaseApi;\nuse App\\DTO\\ImageUploadDto;\nuse App\\DTO\\OAuth2ClientDto;\nuse App\\DTO\\UserDto;\nuse App\\Entity\\Client;\nuse App\\Factory\\ClientFactory;\nuse App\\Repository\\UserRepository;\nuse App\\Service\\SettingsManager;\nuse App\\Service\\UserManager;\nuse League\\Bundle\\OAuth2ServerBundle\\Manager\\ClientManagerInterface;\nuse League\\Bundle\\OAuth2ServerBundle\\ValueObject\\Grant;\nuse League\\Bundle\\OAuth2ServerBundle\\ValueObject\\RedirectUri;\nuse League\\Bundle\\OAuth2ServerBundle\\ValueObject\\Scope;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpKernel\\Exception\\AccessDeniedHttpException;\nuse Symfony\\Component\\HttpKernel\\Exception\\BadRequestHttpException;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Serializer\\SerializerInterface;\nuse Symfony\\Component\\Validator\\Validator\\ValidatorInterface;\n\nclass CreateClientApi extends BaseApi\n{\n    #[OA\\Response(\n        response: 201,\n        description: 'Returns the created oauth2 client. Be sure to save the identifier and secret since these will be how you obtain tokens for the API.',\n        content: new Model(type: OAuth2ClientDto::class, groups: ['created', 'common']),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 400,\n        description: 'Grant type(s), scope(s), redirectUri(s) were invalid, or username was taken',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\BadRequestErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'This instance only allows admins to create clients',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\RequestBody(content: new Model(\n        type: OAuth2ClientDto::class,\n        groups: ['creating']\n    ))]\n    #[OA\\Tag(name: 'oauth')]\n    /**\n     * This endpoint can create an OAuth2 client for your application.\n     *\n     * You can create a public or confidential client with any of 3 flows available. It's\n     * recommended that you pick **either** `client_credentials`, **or** `authorization_code` *and* `refresh_token`.\n     *\n     * When creating clients with the client_credentials grant type, you must provide a unique\n     * username and contact email. The username and email will be used to create a new bot user,\n     * which your client authenticates as during the client_credentials flow. This user will be\n     * tagged as a bot on all of their posts, comments, and on their profile. In addition, the bot\n     * will not be allowed to use the API to vote on content.\n     *\n     * If you are creating a client that will be used on a native app or webapp, the client\n     * should be marked as public. This will skip generation of a client secret and will require\n     * the client to use the PKCE (https://www.oauth.com/oauth2-servers/pkce/) extension during\n     * authorization_code flow. A public client cannot use the client_credentials flow. Public clients\n     * are recommended because apps running on user devices technically cannot store secrets safely -\n     * if they're determined enough, the user could retrieve the secret from their device's memory.\n     */\n    public function __invoke(\n        ClientManagerInterface $manager,\n        ClientFactory $clientFactory,\n        UserManager $userManager,\n        UserRepository $userRepository,\n        SettingsManager $settingsManager,\n        ValidatorInterface $validator,\n        SerializerInterface $serializer,\n        RateLimiterFactoryInterface $apiOauthClientLimiter,\n    ): JsonResponse {\n        if ($settingsManager->get('KBIN_ADMIN_ONLY_OAUTH_CLIENTS') && !$this->isGranted('ROLE_ADMIN')) {\n            throw new AccessDeniedHttpException('This instance only allows admins to create oauth clients');\n        }\n\n        $headers = $this->rateLimit($apiOauthClientLimiter, $apiOauthClientLimiter);\n\n        $request = $this->request->getCurrentRequest();\n        /** @var OAuth2ClientDto $dto */\n        $dto = $serializer->deserialize($request->getContent(), OAuth2ClientDto::class, 'json', ['groups' => ['creating']]);\n\n        $validatorGroups = ['Default', 'creating'];\n        // If the client being requested wishes to use the client_credentials flow,\n        //   validate that it has a username.\n        if (false !== array_search('client_credentials', $dto->grants)) {\n            $validatorGroups[] = 'client_credentials';\n        }\n\n        $errors = $validator->validate($dto, groups: $validatorGroups);\n        if (0 < \\count($errors)) {\n            throw new BadRequestHttpException((string) $errors);\n        }\n\n        $identifier = hash('md5', random_bytes(16));\n        // If a public client is requested, use null for the secret\n        $secret = $dto->public ? null : hash('sha512', random_bytes(32));\n        $client = new Client($dto->name, $identifier, $secret);\n\n        if (false !== array_search('client_credentials', $dto->grants)) {\n            if ($userRepository->findOneByUsername($dto->username)) {\n                throw new BadRequestHttpException('That username/email is taken!');\n            }\n            if ($userRepository->findOneBy(['email' => $dto->contactEmail])) {\n                throw new BadRequestHttpException('That username/email is taken!');\n            }\n            $userDto = new UserDto();\n            $userDto->username = $dto->username;\n            $userDto->email = $dto->contactEmail;\n            // Only way to authenticate as this user will be to use client_credentials, unless they guess the very random password\n            $userDto->plainPassword = hash('sha512', random_bytes(32));\n            // This user is a bot user.\n            $userDto->isBot = true;\n            // Rate limiting is handled by the apiClientLimiter\n            $user = $userManager->create($userDto, false, false);\n            $client->setUser($user);\n        }\n        $client->setDescription($dto->description);\n        $client->setContactEmail($dto->contactEmail);\n        $client->setGrants(...array_map(fn (string $grant) => new Grant($grant), $dto->grants));\n        $client->setScopes(...array_map(fn (string $scope) => new Scope($scope), $dto->scopes));\n        $client->setRedirectUris(...array_map(fn (string $redirectUri) => new RedirectUri($redirectUri), $dto->redirectUris));\n\n        $manager->save($client);\n\n        $dto = $clientFactory->createDto($client);\n\n        return new JsonResponse(\n            $dto,\n            status: 201,\n            headers: $headers\n        );\n    }\n\n    #[OA\\Response(\n        response: 201,\n        description: 'Returns the created oauth2 client. Be sure to save the identifier and secret since these will be how you obtain tokens for the API.',\n        content: new Model(type: OAuth2ClientDto::class, groups: ['Default', 'created', 'common']),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 400,\n        description: 'Grant type(s), scope(s), redirectUri(s) were invalid, or username/email was taken',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\BadRequestErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'This instance only allows admins to create clients',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\RequestBody(content: new OA\\MediaType(\n        'multipart/form-data',\n        schema: new OA\\Schema(\n            ref: new Model(\n                type: OAuth2ClientDto::class,\n                groups: [\n                    'creating',\n                    ImageUploadDto::IMAGE_UPLOAD_NO_ALT,\n                ]\n            )\n        )\n    ))]\n    #[OA\\Tag(name: 'oauth')]\n    /**\n     * This endpoint can create an OAuth2 client with a logo for your application.\n     *\n     * The image uploaded to this endpoint will be shown to users on the consent page as your application's logo.\n     *\n     * You can create a public or confidential client with any of 3 flows available. It's\n     * recommended that you pick **either** `client_credentials`, **or** `authorization_code` *and* `refresh_token`.\n     *\n     * When creating clients with the client_credentials grant type, you must provide a unique\n     * username and contact email. The username and email will be used to create a new bot user,\n     * which your client authenticates as during the client_credentials flow. This user will be\n     * tagged as a bot on all of their posts, comments, and on their profile. In addition, the bot\n     * will not be allowed to use the API to vote on content.\n     *\n     * If you are creating a client that will be used on a native app or webapp, the client\n     * should be marked as public. This will skip generation of a client secret and will require\n     * the client to use the PKCE (https://www.oauth.com/oauth2-servers/pkce/) extension during\n     * authorization_code flow. A public client cannot use the client_credentials flow. Public clients\n     * are recommended because apps running on user devices technically cannot store secrets safely -\n     * if they're determined enough, the user could retrieve the secret from their device's memory.\n     */\n    public function uploadImage(\n        ClientManagerInterface $manager,\n        ClientFactory $clientFactory,\n        UserManager $userManager,\n        UserRepository $userRepository,\n        SettingsManager $settingsManager,\n        ValidatorInterface $validator,\n        RateLimiterFactoryInterface $apiOauthClientLimiter,\n    ): JsonResponse {\n        if ($settingsManager->get('KBIN_ADMIN_ONLY_OAUTH_CLIENTS') && !$this->isGranted('ROLE_ADMIN')) {\n            throw new AccessDeniedHttpException('This instance only allows admins to create oauth clients');\n        }\n\n        $headers = $this->rateLimit($apiOauthClientLimiter, $apiOauthClientLimiter);\n\n        $image = $this->handleUploadedImage();\n\n        $dto = $this->deserializeClientFromForm();\n\n        $validatorGroups = ['Default'];\n        // If the client being requested wishes to use the client_credentials flow,\n        //   validate that it has a username.\n        if (false !== array_search('client_credentials', $dto->grants)) {\n            $validatorGroups[] = 'client_credentials';\n        }\n\n        $errors = $validator->validate($dto, groups: $validatorGroups);\n        if (0 < \\count($errors)) {\n            throw new BadRequestHttpException((string) $errors);\n        }\n\n        $identifier = hash('md5', random_bytes(16));\n        // If a public client is requested, use null for the secret\n        $secret = $dto->public ? null : hash('sha512', random_bytes(32));\n        $client = new Client($dto->name, $identifier, $secret);\n\n        if (false !== array_search('client_credentials', $dto->grants)) {\n            if ($userRepository->findOneByUsername($dto->username)) {\n                throw new BadRequestHttpException('That username/email is taken!');\n            }\n            if ($userRepository->findOneBy(['email' => $dto->contactEmail])) {\n                throw new BadRequestHttpException('That username/email is taken!');\n            }\n            $userDto = new UserDto();\n            $userDto->username = $dto->username;\n            $userDto->email = $dto->contactEmail;\n            // Only way to authenticate as this user will be to use client_credentials, unless they guess the very random password\n            $userDto->plainPassword = hash('sha512', random_bytes(32));\n            // This user is a bot user.\n            $userDto->isBot = true;\n            // Rate limiting is handled by the apiClientLimiter\n            $user = $userManager->create($userDto, false, false);\n            $client->setUser($user);\n        }\n        $client->setDescription($dto->description);\n        $client->setContactEmail($dto->contactEmail);\n        $client->setGrants(...array_map(fn (string $grant) => new Grant($grant), $dto->grants));\n        $client->setScopes(...array_map(fn (string $scope) => new Scope($scope), $dto->scopes));\n        $client->setRedirectUris(...array_map(fn (string $redirectUri) => new RedirectUri($redirectUri), $dto->redirectUris));\n        $client->setImage($image);\n\n        $manager->save($client);\n\n        $dto = $clientFactory->createDto($client);\n\n        return new JsonResponse(\n            $dto,\n            status: 201,\n            headers: $headers\n        );\n    }\n\n    protected function deserializeClientFromForm(?OAuth2ClientDto $dto = null): OAuth2ClientDto\n    {\n        $request = $this->request->getCurrentRequest();\n        $dto = $dto ? $dto : new OAuth2ClientDto();\n        $dto->name = $request->get('name', $dto->name);\n        $dto->contactEmail = $request->get('contactEmail', $dto->contactEmail);\n        $dto->description = $request->get('description', $dto->description);\n        $dto->public = filter_var($request->get('public', $dto->public), FILTER_VALIDATE_BOOL);\n        $dto->username = $request->get('username', $dto->username);\n\n        $redirectUris = $request->get('redirectUris', $dto->redirectUris);\n        if (\\is_string($redirectUris)) {\n            $redirectUris = preg_split('/(,| )/', $redirectUris, flags: PREG_SPLIT_NO_EMPTY);\n        }\n        $dto->redirectUris = $redirectUris;\n\n        $grants = $request->get('grants', $dto->grants);\n        if (\\is_string($grants)) {\n            $grants = preg_split('/(,| )/', $grants, flags: PREG_SPLIT_NO_EMPTY);\n        }\n        $dto->grants = $grants;\n\n        $scopes = $request->get('scopes', $dto->scopes);\n        if (\\is_string($scopes)) {\n            $scopes = preg_split('/(,| )/', $scopes, flags: PREG_SPLIT_NO_EMPTY);\n        }\n        $dto->scopes = $scopes;\n\n        return $dto;\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/OAuth2/DeleteClientApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\OAuth2;\n\nuse App\\Controller\\Api\\BaseApi;\nuse App\\DTO\\OAuth2ClientDto;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse League\\Bundle\\OAuth2ServerBundle\\Manager\\ClientManagerInterface;\nuse League\\Bundle\\OAuth2ServerBundle\\Service\\CredentialsRevokerInterface;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpKernel\\Exception\\BadRequestHttpException;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Validator\\Validator\\ValidatorInterface;\n\nclass DeleteClientApi extends BaseApi\n{\n    #[OA\\Response(\n        response: 204,\n        description: 'The client has been deactivated',\n        content: null,\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 400,\n        description: 'The operation does not apply to that client',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\BadRequestErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(name: 'client_id', in: 'query', schema: new OA\\Schema(type: 'string'), required: true)]\n    #[OA\\Parameter(name: 'client_secret', in: 'query', schema: new OA\\Schema(type: 'string'), required: true)]\n    #[OA\\Tag(name: 'oauth')]\n    /**\n     * This endpoint deactivates a client given their client_id and client_secret.\n     *\n     * This is useful if a confidential client has had their secret compromised and a\n     * new client needs to be created. A public client cannot be deleted in this manner\n     * since it does not have a secret to be compromised\n     */\n    public function __invoke(\n        Request $request,\n        ClientManagerInterface $manager,\n        EntityManagerInterface $entityManager,\n        CredentialsRevokerInterface $revoker,\n        ValidatorInterface $validator,\n        RateLimiterFactoryInterface $apiOauthClientDeleteLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit(anonLimiterFactory: $apiOauthClientDeleteLimiter);\n\n        $dto = new OAuth2ClientDto(null);\n        $dto->identifier = $request->get('client_id');\n        $dto->secret = $request->get('client_secret');\n\n        $validatorGroups = ['deleting'];\n        $errors = $validator->validate($dto, groups: $validatorGroups);\n        if (0 < \\count($errors)) {\n            throw new BadRequestHttpException((string) $errors);\n        }\n\n        $client = $manager->find($dto->identifier);\n        if (null === $client || null === $client->getSecret()) {\n            throw new BadRequestHttpException();\n        }\n\n        if (!hash_equals($client->getSecret(), $dto->secret)) {\n            throw new BadRequestHttpException();\n        }\n\n        $client->setActive(false);\n        $revoker->revokeCredentialsForClient($client);\n        $entityManager->flush();\n\n        return new JsonResponse(\n            status: 204,\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/OAuth2/RevokeTokenApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\OAuth2;\n\nuse App\\Controller\\Api\\BaseApi;\nuse App\\Entity\\Client;\nuse App\\Service\\OAuthTokenRevoker;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass RevokeTokenApi extends BaseApi\n{\n    #[OA\\Response(\n        response: 204,\n        description: 'Revoked the token',\n        content: null,\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'You are not allowed to revoke this token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Tag(name: 'oauth')]\n    #[IsGranted('ROLE_USER')]\n    /**\n     * This API revokes any tokens associated with the authenticated user and client.\n     */\n    public function __invoke(\n        EntityManagerInterface $entityManager,\n        OAuthTokenRevoker $revoker,\n        RateLimiterFactoryInterface $apiOauthTokenRevokeLimiter,\n    ) {\n        $headers = $this->rateLimit($apiOauthTokenRevokeLimiter);\n\n        $token = $this->container->get('security.token_storage')->getToken();\n        $user = $this->getUserOrThrow();\n        $client = $entityManager->getReference(Client::class, $token->getOAuthClientId());\n\n        $revoker->revokeCredentialsForUserWithClient($user, $client);\n\n        return new JsonResponse(\n            status: 204,\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Post/Admin/PostsPurgeApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Post\\Admin;\n\nuse App\\Controller\\Api\\Post\\PostsBaseApi;\nuse App\\Entity\\Post;\nuse App\\Service\\PostManager;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse Nelmio\\ApiDocBundle\\Attribute\\Security;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass PostsPurgeApi extends PostsBaseApi\n{\n    #[OA\\Response(\n        response: 204,\n        description: 'Post purged',\n        content: null,\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'You are not authorized to purge this post',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Post not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'post_id',\n        in: 'path',\n        description: 'The post to purge',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Tag(name: 'admin/post')]\n    #[IsGranted('ROLE_ADMIN')]\n    #[Security(name: 'oauth2', scopes: ['admin:post:purge'])]\n    #[IsGranted('ROLE_OAUTH2_ADMIN:POST:PURGE')]\n    #[IsGranted('purge', subject: 'post')]\n    /**\n     * Purges a post from the instance, deleting it completely. This action is irreversible.\n     */\n    public function __invoke(\n        #[MapEntity(id: 'post_id')]\n        Post $post,\n        PostManager $manager,\n        RateLimiterFactoryInterface $apiModerateLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiModerateLimiter);\n\n        $manager->purge($this->getUserOrThrow(), $post);\n\n        return new JsonResponse(\n            status: 204,\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Post/Comments/Admin/PostCommentsPurgeApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Post\\Comments\\Admin;\n\nuse App\\Controller\\Api\\Post\\PostsBaseApi;\nuse App\\Entity\\PostComment;\nuse App\\Service\\PostCommentManager;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse Nelmio\\ApiDocBundle\\Attribute\\Security;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass PostCommentsPurgeApi extends PostsBaseApi\n{\n    #[OA\\Response(\n        response: 204,\n        description: 'Comment purged',\n        content: null,\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'You are not authorized to purge this comment',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Comment not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'comment_id',\n        in: 'path',\n        description: 'The comment to purge',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Tag(name: 'admin/post_comment')]\n    #[IsGranted('ROLE_ADMIN')]\n    #[Security(name: 'oauth2', scopes: ['admin:post_comment:purge'])]\n    #[IsGranted('ROLE_OAUTH2_ADMIN:POST_COMMENT:PURGE')]\n    #[IsGranted('purge', subject: 'comment')]\n    /**\n     * Purges a post comment from the instance, deleting it completely. This action is irreversible.\n     */\n    public function __invoke(\n        #[MapEntity(id: 'comment_id')]\n        PostComment $comment,\n        PostCommentManager $manager,\n        RateLimiterFactoryInterface $apiModerateLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiModerateLimiter);\n\n        $manager->purge($this->getUserOrThrow(), $comment);\n\n        return new JsonResponse(\n            status: 204,\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Post/Comments/Moderate/PostCommentsSetAdultApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Post\\Comments\\Moderate;\n\nuse App\\Controller\\Api\\Post\\PostsBaseApi;\nuse App\\DTO\\PostCommentResponseDto;\nuse App\\Entity\\PostComment;\nuse App\\Factory\\PostCommentFactory;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse Nelmio\\ApiDocBundle\\Attribute\\Security;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass PostCommentsSetAdultApi extends PostsBaseApi\n{\n    #[OA\\Response(\n        response: 200,\n        description: 'Comment isAdult status set',\n        content: new Model(type: PostCommentResponseDto::class),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'You are not authorized to moderate this comment',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Comment not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'comment_id',\n        in: 'path',\n        description: 'The comment to set adult status on',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Parameter(\n        name: 'adult',\n        in: 'path',\n        description: 'new isAdult status',\n        schema: new OA\\Schema(type: 'boolean', default: true),\n    )]\n    #[OA\\Tag(name: 'moderation/post_comment')]\n    #[Security(name: 'oauth2', scopes: ['moderate:post_comment:set_adult'])]\n    #[IsGranted('ROLE_OAUTH2_MODERATE:POST_COMMENT:SET_ADULT')]\n    #[IsGranted('moderate', subject: 'comment')]\n    public function __invoke(\n        #[MapEntity(id: 'comment_id')]\n        PostComment $comment,\n        PostCommentFactory $factory,\n        EntityManagerInterface $manager,\n        RateLimiterFactoryInterface $apiModerateLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiModerateLimiter);\n\n        $request = $this->request->getCurrentRequest();\n        // Returns true for \"1\", \"true\", \"on\" and \"yes\". Returns false otherwise.\n        $comment->isAdult = filter_var($request->get('adult', 'true'), FILTER_VALIDATE_BOOL);\n\n        $manager->flush();\n\n        return new JsonResponse(\n            $this->serializePostComment($factory->createDto($comment), $this->tagLinkRepository->getTagsOfContent($comment)),\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Post/Comments/Moderate/PostCommentsSetLanguageApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Post\\Comments\\Moderate;\n\nuse App\\Controller\\Api\\Post\\PostsBaseApi;\nuse App\\DTO\\PostCommentResponseDto;\nuse App\\Entity\\PostComment;\nuse App\\Factory\\PostCommentFactory;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse Nelmio\\ApiDocBundle\\Attribute\\Security;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpKernel\\Exception\\BadRequestHttpException;\nuse Symfony\\Component\\Intl\\Languages;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass PostCommentsSetLanguageApi extends PostsBaseApi\n{\n    #[OA\\Response(\n        response: 200,\n        description: 'Comment language changed',\n        content: new Model(type: PostCommentResponseDto::class),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 400,\n        description: 'Given language is not valid',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\BadRequestErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'You are not authorized to moderate this comment',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Comment not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'comment_id',\n        in: 'path',\n        description: 'The comment to change language of',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Parameter(\n        name: 'lang',\n        in: 'path',\n        description: 'new language',\n        schema: new OA\\Schema(type: 'string', minLength: 2, maxLength: 3),\n    )]\n    #[OA\\Tag(name: 'moderation/post_comment')]\n    #[Security(name: 'oauth2', scopes: ['moderate:post_comment:language'])]\n    #[IsGranted('ROLE_OAUTH2_MODERATE:POST_COMMENT:LANGUAGE')]\n    #[IsGranted('moderate', subject: 'comment')]\n    public function __invoke(\n        #[MapEntity(id: 'comment_id')]\n        PostComment $comment,\n        PostCommentFactory $factory,\n        EntityManagerInterface $manager,\n        RateLimiterFactoryInterface $apiModerateLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiModerateLimiter);\n\n        $request = $this->request->getCurrentRequest();\n        $newLang = $request->get('lang', '');\n\n        $valid = false !== array_search($newLang, Languages::getLanguageCodes());\n\n        if (!$valid) {\n            throw new BadRequestHttpException('The given language is not valid!');\n        }\n\n        $comment->lang = $newLang;\n\n        $manager->flush();\n\n        return new JsonResponse(\n            $this->serializePostComment($factory->createDto($comment), $this->tagLinkRepository->getTagsOfContent($comment)),\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Post/Comments/Moderate/PostCommentsTrashApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Post\\Comments\\Moderate;\n\nuse App\\Controller\\Api\\Post\\PostsBaseApi;\nuse App\\DTO\\PostCommentResponseDto;\nuse App\\Entity\\Contracts\\VisibilityInterface;\nuse App\\Entity\\PostComment;\nuse App\\Factory\\PostCommentFactory;\nuse App\\Service\\PostCommentManager;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse Nelmio\\ApiDocBundle\\Attribute\\Security;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpKernel\\Exception\\BadRequestHttpException;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass PostCommentsTrashApi extends PostsBaseApi\n{\n    #[OA\\Response(\n        response: 200,\n        description: 'Comment trashed',\n        content: new Model(type: PostCommentResponseDto::class),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'You are not authorized to trash this comment',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Comment not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'comment_id',\n        in: 'path',\n        description: 'The comment to trash',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Tag(name: 'moderation/post_comment')]\n    #[Security(name: 'oauth2', scopes: ['moderate:post_comment:trash'])]\n    #[IsGranted('ROLE_OAUTH2_MODERATE:POST_COMMENT:TRASH')]\n    #[IsGranted('moderate', subject: 'comment')]\n    public function trash(\n        #[MapEntity(id: 'comment_id')]\n        PostComment $comment,\n        PostCommentManager $manager,\n        PostCommentFactory $factory,\n        RateLimiterFactoryInterface $apiModerateLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiModerateLimiter);\n\n        $moderator = $this->getUserOrThrow();\n\n        $manager->trash($moderator, $comment);\n\n        // Force response to have all fields visible\n        $visibility = $comment->visibility;\n        $comment->visibility = VisibilityInterface::VISIBILITY_VISIBLE;\n        $response = $this->serializePostComment($factory->createDto($comment), $this->tagLinkRepository->getTagsOfContent($comment))->jsonSerialize();\n        $response['visibility'] = $visibility;\n\n        return new JsonResponse(\n            $response,\n            headers: $headers\n        );\n    }\n\n    #[OA\\Response(\n        response: 200,\n        description: 'Comment restored',\n        content: new Model(type: PostCommentResponseDto::class),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 400,\n        description: 'The comment was not in the trashed state',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\BadRequestErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'You are not authorized to restore this comment',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Comment not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'comment_id',\n        in: 'path',\n        description: 'The comment to restore',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Tag(name: 'moderation/post_comment')]\n    #[Security(name: 'oauth2', scopes: ['moderate:post_comment:trash'])]\n    #[IsGranted('ROLE_OAUTH2_MODERATE:POST_COMMENT:TRASH')]\n    #[IsGranted('moderate', subject: 'comment')]\n    public function restore(\n        #[MapEntity(id: 'comment_id')]\n        PostComment $comment,\n        PostCommentManager $manager,\n        PostCommentFactory $factory,\n        RateLimiterFactoryInterface $apiModerateLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiModerateLimiter);\n\n        $moderator = $this->getUserOrThrow();\n\n        try {\n            $manager->restore($moderator, $comment);\n        } catch (\\Exception $e) {\n            throw new BadRequestHttpException('The comment cannot be restored because it was not trashed!');\n        }\n\n        return new JsonResponse(\n            $this->serializePostComment($factory->createDto($comment), $this->tagLinkRepository->getTagsOfContent($comment)),\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Post/Comments/PostCommentsActivityApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Post\\Comments;\n\nuse App\\Controller\\Api\\Post\\PostsBaseApi;\nuse App\\Controller\\Traits\\PrivateContentTrait;\nuse App\\DTO\\ActivitiesResponseDto;\nuse App\\Entity\\PostComment;\nuse App\\Factory\\ContentActivityDtoFactory;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\n\nclass PostCommentsActivityApi extends PostsBaseApi\n{\n    use PrivateContentTrait;\n\n    #[OA\\Response(\n        response: 200,\n        description: 'Vote activity of the comment',\n        content: new Model(type: ActivitiesResponseDto::class),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'You are not authorized to access this comment',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Comment not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'comment_id',\n        in: 'path',\n        description: 'The comment to query',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Tag(name: 'post_comment')]\n    public function __invoke(\n        #[MapEntity(id: 'comment_id')]\n        PostComment $comment,\n        ContentActivityDtoFactory $dtoFactory,\n        RateLimiterFactoryInterface $apiReadLimiter,\n        RateLimiterFactoryInterface $anonymousApiReadLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter);\n\n        $this->handlePrivateContent($comment);\n\n        $dto = $dtoFactory->createActivitiesDto($comment);\n\n        return new JsonResponse(\n            $dto,\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Post/Comments/PostCommentsCreateApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Post\\Comments;\n\nuse App\\Controller\\Api\\Post\\PostsBaseApi;\nuse App\\Controller\\Traits\\PrivateContentTrait;\nuse App\\DTO\\ImageUploadDto;\nuse App\\DTO\\PostCommentRequestDto;\nuse App\\DTO\\PostCommentResponseDto;\nuse App\\Entity\\Post;\nuse App\\Entity\\PostComment;\nuse App\\Factory\\PostCommentFactory;\nuse App\\Service\\PostCommentManager;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse Nelmio\\ApiDocBundle\\Attribute\\Security;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpKernel\\Exception\\AccessDeniedHttpException;\nuse Symfony\\Component\\HttpKernel\\Exception\\BadRequestHttpException;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\nuse Symfony\\Component\\Validator\\Validator\\ValidatorInterface;\n\nclass PostCommentsCreateApi extends PostsBaseApi\n{\n    use PrivateContentTrait;\n\n    #[OA\\Response(\n        response: 201,\n        description: 'Post comment created',\n        content: new Model(type: PostCommentResponseDto::class),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 400,\n        description: 'The request body was invalid or the comment you are replying to does not belong to the post you are trying to add the new comment to.',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\BadRequestErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'You are not permitted to add comments to this post',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Post or parent comment not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'post_id',\n        in: 'path',\n        description: 'Post to which the new comment will belong',\n        schema: new OA\\Schema(type: 'integer')\n    )]\n    #[OA\\RequestBody(content: new Model(\n        type: PostCommentRequestDto::class,\n        groups: [\n            'common',\n            'comment',\n            'no-upload',\n        ]\n    ))]\n    #[OA\\Tag(name: 'post_comment')]\n    #[Security(name: 'oauth2', scopes: ['post_comment:create'])]\n    #[IsGranted('ROLE_OAUTH2_POST_COMMENT:CREATE')]\n    #[IsGranted('comment', subject: 'post')]\n    public function __invoke(\n        #[MapEntity(id: 'post_id')]\n        Post $post,\n        #[MapEntity(id: 'comment_id')]\n        ?PostComment $parent,\n        PostCommentManager $manager,\n        PostCommentFactory $factory,\n        ValidatorInterface $validator,\n        RateLimiterFactoryInterface $apiCommentLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiCommentLimiter);\n\n        if (!$this->isGranted('create_content', $post->magazine)) {\n            throw new AccessDeniedHttpException();\n        }\n\n        if ($parent && $parent->post->getId() !== $post->getId()) {\n            throw new BadRequestHttpException('The parent comment does not belong to that post!');\n        }\n        $dto = $this->deserializePostComment();\n\n        $dto->post = $post;\n        $dto->magazine = $post->magazine;\n        $dto->parent = $parent;\n\n        $errors = $validator->validate($dto);\n        if (\\count($errors) > 0) {\n            throw new BadRequestHttpException((string) $errors);\n        }\n\n        // Rate limit handled above\n        $comment = $manager->create($dto, $this->getUserOrThrow(), rateLimit: false);\n\n        return new JsonResponse(\n            $this->serializePostComment($factory->createDto($comment), $this->tagLinkRepository->getTagsOfContent($comment)),\n            status: 201,\n            headers: $headers\n        );\n    }\n\n    #[OA\\Response(\n        response: 201,\n        description: 'Post comment created',\n        content: new Model(type: PostCommentResponseDto::class),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 400,\n        description: 'The request body was invalid or the comment you are replying to does not belong to the post you are trying to add the new comment to.',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\BadRequestErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'You are not permitted to add comments to this post',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Post or parent comment not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'post_id',\n        in: 'path',\n        description: 'Post to which the new comment will belong',\n        schema: new OA\\Schema(type: 'integer')\n    )]\n    #[OA\\RequestBody(content: new OA\\MediaType(\n        'multipart/form-data',\n        schema: new OA\\Schema(\n            ref: new Model(\n                type: PostCommentRequestDto::class,\n                groups: [\n                    'common',\n                    'comment',\n                    ImageUploadDto::IMAGE_UPLOAD,\n                ]\n            )\n        )\n    ))]\n    #[OA\\Tag(name: 'post_comment')]\n    #[Security(name: 'oauth2', scopes: ['post_comment:create'])]\n    #[IsGranted('ROLE_OAUTH2_POST_COMMENT:CREATE')]\n    #[IsGranted('comment', subject: 'post')]\n    public function uploadImage(\n        #[MapEntity(id: 'post_id')]\n        Post $post,\n        #[MapEntity(id: 'comment_id')]\n        ?PostComment $parent,\n        PostCommentManager $manager,\n        PostCommentFactory $factory,\n        ValidatorInterface $validator,\n        RateLimiterFactoryInterface $apiImageLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiImageLimiter);\n\n        if (!$this->isGranted('create_content', $post->magazine)) {\n            throw new AccessDeniedHttpException();\n        }\n\n        $image = $this->handleUploadedImage();\n\n        if ($parent && $parent->post->getId() !== $post->getId()) {\n            throw new BadRequestHttpException('The parent comment does not belong to that post!');\n        }\n        $dto = $this->deserializePostCommentFromForm();\n\n        $dto->post = $post;\n        $dto->magazine = $post->magazine;\n        $dto->parent = $parent;\n        $dto->image = $this->imageFactory->createDto($image);\n\n        $errors = $validator->validate($dto);\n        if (\\count($errors) > 0) {\n            throw new BadRequestHttpException((string) $errors);\n        }\n\n        // Rate limit handled above\n        $comment = $manager->create($dto, $this->getUserOrThrow(), rateLimit: false);\n\n        return new JsonResponse(\n            $this->serializePostComment($factory->createDto($comment), $this->tagLinkRepository->getTagsOfContent($comment)),\n            status: 201,\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Post/Comments/PostCommentsDeleteApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Post\\Comments;\n\nuse App\\Controller\\Api\\Post\\PostsBaseApi;\nuse App\\Controller\\Traits\\PrivateContentTrait;\nuse App\\Entity\\PostComment;\nuse App\\Service\\PostCommentManager;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse Nelmio\\ApiDocBundle\\Attribute\\Security;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass PostCommentsDeleteApi extends PostsBaseApi\n{\n    use PrivateContentTrait;\n\n    #[OA\\Response(\n        response: 204,\n        description: 'Comment deleted successfully',\n        content: null,\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'You are not authorized to delete this comment',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Comment not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'comment_id',\n        in: 'path',\n        description: 'The comment to delete',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Tag(name: 'post_comment')]\n    #[Security(name: 'oauth2', scopes: ['post_comment:delete'])]\n    #[IsGranted('ROLE_OAUTH2_POST_COMMENT:DELETE')]\n    #[IsGranted('delete', subject: 'comment')]\n    public function __invoke(\n        #[MapEntity(id: 'comment_id')]\n        PostComment $comment,\n        PostCommentManager $manager,\n        RateLimiterFactoryInterface $apiDeleteLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiDeleteLimiter);\n\n        $manager->delete($this->getUserOrThrow(), $comment);\n\n        return new JsonResponse(status: 204, headers: $headers);\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Post/Comments/PostCommentsFavouriteApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Post\\Comments;\n\nuse App\\Controller\\Api\\Post\\PostsBaseApi;\nuse App\\DTO\\PostCommentResponseDto;\nuse App\\Entity\\PostComment;\nuse App\\Factory\\PostCommentFactory;\nuse App\\Service\\FavouriteManager;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse Nelmio\\ApiDocBundle\\Attribute\\Security;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass PostCommentsFavouriteApi extends PostsBaseApi\n{\n    #[OA\\Response(\n        response: 200,\n        description: 'Comment favourite status toggled',\n        content: new Model(type: PostCommentResponseDto::class),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Comment not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'comment_id',\n        in: 'path',\n        description: 'The comment to favourite',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Parameter(\n        name: 'd',\n        in: 'query',\n        description: 'Comment tree depth to retrieve (-1 for unlimited depth)',\n        schema: new OA\\Schema(type: 'integer', default: -1),\n    )]\n    #[OA\\Tag(name: 'post_comment')]\n    // TODO: Bots should not be able to vote\n    //       *sad beep boop*\n    #[Security(name: 'oauth2', scopes: ['post_comment:vote'])]\n    #[IsGranted('ROLE_OAUTH2_POST_COMMENT:VOTE')]\n    public function __invoke(\n        #[MapEntity(id: 'comment_id')]\n        PostComment $comment,\n        FavouriteManager $manager,\n        PostCommentFactory $factory,\n        RateLimiterFactoryInterface $apiVoteLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiVoteLimiter);\n\n        $manager->toggle($this->getUserOrThrow(), $comment);\n\n        return new JsonResponse(\n            $this->serializePostComment($factory->createDto($comment), $this->tagLinkRepository->getTagsOfContent($comment)),\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Post/Comments/PostCommentsReportApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Post\\Comments;\n\nuse App\\Controller\\Api\\Post\\PostsBaseApi;\nuse App\\Controller\\Traits\\PrivateContentTrait;\nuse App\\DTO\\ReportRequestDto;\nuse App\\Entity\\PostComment;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse Nelmio\\ApiDocBundle\\Attribute\\Security;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass PostCommentsReportApi extends PostsBaseApi\n{\n    use PrivateContentTrait;\n\n    #[OA\\Response(\n        response: 204,\n        description: 'Report created',\n        content: null,\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'You have not been authorized to report this post',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Comment not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'comment_id',\n        in: 'path',\n        description: 'The post to report',\n        schema: new OA\\Schema(type: 'integer')\n    )]\n    #[OA\\RequestBody(content: new Model(type: ReportRequestDto::class))]\n    #[OA\\Tag(name: 'post_comment')]\n    #[Security(name: 'oauth2', scopes: ['post_comment:report'])]\n    #[IsGranted('ROLE_OAUTH2_POST_COMMENT:REPORT')]\n    public function __invoke(\n        #[MapEntity(id: 'comment_id')]\n        PostComment $comment,\n        RateLimiterFactoryInterface $apiReportLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiReportLimiter);\n\n        $this->reportContent($comment);\n\n        return new JsonResponse(\n            status: 204,\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Post/Comments/PostCommentsRetrieveApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Post\\Comments;\n\nuse App\\Controller\\Api\\Post\\PostsBaseApi;\nuse App\\Controller\\Traits\\PrivateContentTrait;\nuse App\\DTO\\PostCommentResponseDto;\nuse App\\Entity\\Post;\nuse App\\Entity\\PostComment;\nuse App\\PageView\\PostCommentPageView;\nuse App\\Repository\\Criteria;\nuse App\\Repository\\PostCommentRepository;\nuse App\\Schema\\PaginationSchema;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Bundle\\SecurityBundle\\Security;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\n\nclass PostCommentsRetrieveApi extends PostsBaseApi\n{\n    use PrivateContentTrait;\n\n    #[OA\\Response(\n        response: 200,\n        description: 'Returns the Post Comment',\n        content: new Model(type: PostCommentResponseDto::class),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Comment not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'comment_id',\n        in: 'path',\n        description: 'The post comment to retrieve',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Parameter(\n        name: 'd',\n        in: 'query',\n        description: 'Comment tree depth to retrieve',\n        schema: new OA\\Schema(type: 'integer', default: self::DEPTH, minimum: self::MIN_DEPTH, maximum: self::MAX_DEPTH),\n    )]\n    #[OA\\Tag(name: 'post_comment')]\n    public function __invoke(\n        #[MapEntity(id: 'comment_id')]\n        PostComment $comment,\n        PostCommentRepository $repository,\n        RateLimiterFactoryInterface $apiReadLimiter,\n        RateLimiterFactoryInterface $anonymousApiReadLimiter,\n        Security $security,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter);\n\n        $this->handlePrivateContent($comment);\n        $criteria = new PostCommentPageView(0, $security);\n\n        $repository->hydrate($comment);\n\n        return new JsonResponse(\n            $this->serializePostCommentTree($comment, $criteria),\n            headers: $headers\n        );\n    }\n\n    #[OA\\Response(\n        response: 200,\n        description: 'Returns a paginated list of post comments',\n        content: new OA\\JsonContent(\n            type: 'object',\n            properties: [\n                new OA\\Property(\n                    property: 'items',\n                    type: 'array',\n                    items: new OA\\Items(ref: new Model(type: PostCommentResponseDto::class))\n                ),\n                new OA\\Property(\n                    property: 'pagination',\n                    ref: new Model(type: PaginationSchema::class)\n                ),\n            ]\n        ),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'post_id',\n        description: 'Post to retrieve comments from',\n        in: 'path',\n        schema: new OA\\Schema(type: 'integer')\n    )]\n    #[OA\\Parameter(\n        name: 'p',\n        description: 'Page of comments to retrieve',\n        in: 'query',\n        schema: new OA\\Schema(type: 'integer', default: 1, minimum: 1)\n    )]\n    #[OA\\Parameter(\n        name: 'd',\n        description: 'Max depth of comment tree to retrieve',\n        in: 'query',\n        schema: new OA\\Schema(type: 'integer', default: self::DEPTH, minimum: self::MIN_DEPTH, maximum: self::MAX_DEPTH)\n    )]\n    #[OA\\Parameter(\n        name: 'perPage',\n        description: 'Number of posts per page to retrieve',\n        in: 'query',\n        schema: new OA\\Schema(type: 'integer', default: PostCommentRepository::PER_PAGE, minimum: self::MIN_PER_PAGE, maximum: self::MAX_PER_PAGE)\n    )]\n    #[OA\\Parameter(\n        name: 'sort',\n        description: 'Sort method to use when retrieving comments',\n        in: 'path',\n        schema: new OA\\Schema(type: 'string', default: Criteria::SORT_HOT, enum: Criteria::SORT_OPTIONS)\n    )]\n    #[OA\\Parameter(\n        name: 'time',\n        description: 'Max age of retrieved posts',\n        in: 'path',\n        schema: new OA\\Schema(type: 'string', default: Criteria::TIME_ALL, enum: Criteria::TIME_ROUTES_EN)\n    )]\n    #[OA\\Parameter(\n        name: 'lang[]',\n        description: 'Language(s) of comments to return',\n        in: 'query',\n        explode: true,\n        allowReserved: true,\n        schema: new OA\\Schema(\n            type: 'array',\n            items: new OA\\Items(type: 'string')\n        )\n    )]\n    #[OA\\Parameter(\n        name: 'usePreferredLangs',\n        description: 'Filter by a user\\'s preferred languages? (Requires authentication and takes precedence over lang[])',\n        in: 'query',\n        schema: new OA\\Schema(type: 'boolean', default: false),\n    )]\n    #[OA\\Tag(name: 'post')]\n    public function collection(\n        #[MapEntity(id: 'post_id')]\n        Post $post,\n        PostCommentRepository $repository,\n        RateLimiterFactoryInterface $apiReadLimiter,\n        RateLimiterFactoryInterface $anonymousApiReadLimiter,\n        Security $security,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter);\n\n        $request = $this->request->getCurrentRequest();\n        $criteria = new PostCommentPageView($this->getPageNb($request), $security);\n        $criteria->post = $post;\n        $criteria->sortOption = $criteria->resolveSort($request->get('sort', Criteria::SORT_HOT));\n        $criteria->time = $criteria->resolveTime($request->get('time', Criteria::TIME_ALL));\n        $criteria->perPage = self::constrainPerPage($request->get('perPage', PostCommentRepository::PER_PAGE));\n\n        $this->handleLanguageCriteria($criteria);\n\n        $comments = $repository->findByCriteria($criteria);\n\n        $dtos = [];\n        foreach ($comments->getCurrentPageResults() as $value) {\n            \\assert($value instanceof PostComment);\n            try {\n                $this->handlePrivateContent($value);\n                $dtos[] = $this->serializePostCommentTree($value, $criteria);\n            } catch (\\Exception $e) {\n                continue;\n            }\n        }\n\n        return new JsonResponse(\n            $this->serializePaginated($dtos, $comments),\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Post/Comments/PostCommentsUpdateApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Post\\Comments;\n\nuse App\\Controller\\Api\\Post\\PostsBaseApi;\nuse App\\Controller\\Traits\\PrivateContentTrait;\nuse App\\DTO\\PostCommentRequestDto;\nuse App\\DTO\\PostCommentResponseDto;\nuse App\\Entity\\PostComment;\nuse App\\Factory\\PostCommentFactory;\nuse App\\PageView\\PostCommentPageView;\nuse App\\Service\\PostCommentManager;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse Nelmio\\ApiDocBundle\\Attribute\\Security;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Bundle\\SecurityBundle\\Security as SymfonySecurity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpKernel\\Exception\\AccessDeniedHttpException;\nuse Symfony\\Component\\HttpKernel\\Exception\\BadRequestHttpException;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\nuse Symfony\\Component\\Validator\\Validator\\ValidatorInterface;\n\nclass PostCommentsUpdateApi extends PostsBaseApi\n{\n    use PrivateContentTrait;\n\n    #[OA\\Response(\n        response: 200,\n        description: 'Comment updated',\n        content: new Model(type: PostCommentResponseDto::class),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'You do not have permission to update this comment',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Comment not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'comment_id',\n        in: 'path',\n        description: 'The id of the comment to update',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Parameter(\n        name: 'd',\n        in: 'query',\n        description: 'Comment tree depth to retrieve (-1 for unlimited depth)',\n        schema: new OA\\Schema(type: 'integer', default: -1),\n    )]\n    #[OA\\RequestBody(content: new Model(\n        type: PostCommentRequestDto::class,\n        groups: [\n            'common',\n            'comment',\n            'no-upload',\n        ]\n    ))]\n    #[OA\\Tag(name: 'post_comment')]\n    #[Security(name: 'oauth2', scopes: ['post_comment:edit'])]\n    #[IsGranted('ROLE_OAUTH2_POST_COMMENT:EDIT')]\n    #[IsGranted('edit', subject: 'comment')]\n    public function __invoke(\n        #[MapEntity(id: 'comment_id')]\n        PostComment $comment,\n        PostCommentManager $manager,\n        PostCommentFactory $factory,\n        ValidatorInterface $validator,\n        RateLimiterFactoryInterface $apiUpdateLimiter,\n        SymfonySecurity $security,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiUpdateLimiter);\n\n        if (!$this->isGranted('create_content', $comment->magazine)) {\n            throw new AccessDeniedHttpException();\n        }\n        $dto = $this->deserializePostComment($factory->createDto($comment));\n\n        $errors = $validator->validate($dto);\n        if (\\count($errors) > 0) {\n            throw new BadRequestHttpException((string) $errors);\n        }\n\n        $comment = $manager->edit($comment, $dto, $this->getUserOrThrow());\n        $criteria = new PostCommentPageView(0, $security);\n\n        return new JsonResponse(\n            $this->serializePostCommentTree($comment, $criteria),\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Post/Comments/PostCommentsVoteApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Post\\Comments;\n\nuse App\\Controller\\Api\\Post\\PostsBaseApi;\nuse App\\DTO\\PostCommentResponseDto;\nuse App\\Entity\\Contracts\\VotableInterface;\nuse App\\Entity\\PostComment;\nuse App\\Factory\\PostCommentFactory;\nuse App\\Service\\SettingsManager;\nuse App\\Service\\VoteManager;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse Nelmio\\ApiDocBundle\\Attribute\\Security;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpKernel\\Exception\\BadRequestHttpException;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass PostCommentsVoteApi extends PostsBaseApi\n{\n    // TODO: Bots should not be able to vote\n    //       *sad beep boop*\n    #[OA\\Response(\n        response: 200,\n        description: 'Comment vote changed',\n        content: new Model(type: PostCommentResponseDto::class),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 400,\n        description: 'Vote choice was not valid',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\BadRequestErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Comment not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'comment_id',\n        in: 'path',\n        description: 'The comment to vote upon',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Parameter(\n        name: 'choice',\n        in: 'path',\n        description: 'The user\\'s voting choice. 0 clears the user\\'s vote.',\n        schema: new OA\\Schema(type: 'integer', enum: VotableInterface::VOTE_CHOICES),\n    )]\n    #[OA\\Tag(name: 'post_comment')]\n    #[Security(name: 'oauth2', scopes: ['post_comment:vote'])]\n    #[IsGranted('ROLE_OAUTH2_POST_COMMENT:VOTE')]\n    public function __invoke(\n        #[MapEntity(id: 'comment_id')]\n        PostComment $comment,\n        int $choice,\n        VoteManager $manager,\n        PostCommentFactory $factory,\n        RateLimiterFactoryInterface $apiVoteLimiter,\n        SettingsManager $settingsManager,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiVoteLimiter);\n\n        if (!\\in_array($choice, VotableInterface::VOTE_CHOICES)) {\n            throw new BadRequestHttpException('Vote must be either -1, 0, or 1');\n        }\n\n        if (VotableInterface::VOTE_DOWN === $choice) {\n            throw new BadRequestHttpException('Downvotes for post comments are disabled!');\n        }\n\n        // Rate limit handled above\n        $manager->vote($choice, $comment, $this->getUserOrThrow(), rateLimit: false);\n\n        return new JsonResponse(\n            $this->serializePostComment($factory->createDto($comment), $this->tagLinkRepository->getTagsOfContent($comment)),\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Post/Comments/UserPostCommentsRetrieveApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Post\\Comments;\n\nuse App\\Controller\\Api\\Post\\PostsBaseApi;\nuse App\\Controller\\Traits\\PrivateContentTrait;\nuse App\\DTO\\PostCommentResponseDto;\nuse App\\Entity\\PostComment;\nuse App\\Entity\\User;\nuse App\\PageView\\PostCommentPageView;\nuse App\\Repository\\Criteria;\nuse App\\Repository\\PostCommentRepository;\nuse App\\Schema\\PaginationSchema;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Bundle\\SecurityBundle\\Security;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\n\nclass UserPostCommentsRetrieveApi extends PostsBaseApi\n{\n    use PrivateContentTrait;\n\n    #[OA\\Response(\n        response: 200,\n        description: 'Returns a paginated list of post comments from a specific user',\n        content: new OA\\JsonContent(\n            type: 'object',\n            properties: [\n                new OA\\Property(\n                    property: 'items',\n                    type: 'array',\n                    items: new OA\\Items(ref: new Model(type: PostCommentResponseDto::class))\n                ),\n                new OA\\Property(\n                    property: 'pagination',\n                    ref: new Model(type: PaginationSchema::class)\n                ),\n            ]\n        ),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'The user was not found.',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'user_id',\n        description: 'User whose comments to retrieve',\n        in: 'path',\n        schema: new OA\\Schema(type: 'integer')\n    )]\n    #[OA\\Parameter(\n        name: 'p',\n        description: 'Page of comments to retrieve',\n        in: 'query',\n        schema: new OA\\Schema(type: 'integer', default: 1, minimum: 1)\n    )]\n    #[OA\\Parameter(\n        name: 'd',\n        description: 'Max depth of comment tree to retrieve',\n        in: 'query',\n        schema: new OA\\Schema(type: 'integer', default: self::DEPTH, minimum: self::MIN_DEPTH, maximum: self::MAX_DEPTH)\n    )]\n    #[OA\\Parameter(\n        name: 'perPage',\n        description: 'Number of posts per page to retrieve',\n        in: 'query',\n        schema: new OA\\Schema(type: 'integer', default: PostCommentRepository::PER_PAGE, minimum: self::MIN_PER_PAGE, maximum: self::MAX_PER_PAGE)\n    )]\n    #[OA\\Parameter(\n        name: 'sort',\n        description: 'Sort method to use when retrieving comments',\n        in: 'query',\n        schema: new OA\\Schema(type: 'string', default: Criteria::SORT_HOT, enum: Criteria::SORT_OPTIONS)\n    )]\n    #[OA\\Parameter(\n        name: 'time',\n        description: 'Max age of retrieved posts',\n        in: 'query',\n        schema: new OA\\Schema(type: 'string', default: Criteria::TIME_ALL, enum: Criteria::TIME_ROUTES_EN)\n    )]\n    #[OA\\Parameter(\n        name: 'lang[]',\n        description: 'Language(s) of comments to return',\n        in: 'query',\n        explode: true,\n        allowReserved: true,\n        schema: new OA\\Schema(\n            type: 'array',\n            items: new OA\\Items(type: 'string')\n        )\n    )]\n    #[OA\\Parameter(\n        name: 'usePreferredLangs',\n        description: 'Filter by a user\\'s preferred languages? (Requires authentication and takes precedence over lang[])',\n        in: 'query',\n        schema: new OA\\Schema(type: 'boolean', default: false),\n    )]\n    #[OA\\Tag(name: 'user')]\n    public function __invoke(\n        #[MapEntity(id: 'user_id')]\n        User $user,\n        PostCommentRepository $repository,\n        RateLimiterFactoryInterface $apiReadLimiter,\n        RateLimiterFactoryInterface $anonymousApiReadLimiter,\n        Security $security,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter);\n\n        $request = $this->request->getCurrentRequest();\n        $criteria = new PostCommentPageView($this->getPageNb($request), $security);\n        $criteria->user = $user;\n        $criteria->sortOption = $criteria->resolveSort($request->get('sort', Criteria::SORT_HOT));\n        $criteria->time = $criteria->resolveTime($request->get('time', Criteria::TIME_ALL));\n        $criteria->perPage = self::constrainPerPage($request->get('perPage', PostCommentRepository::PER_PAGE));\n        $criteria->onlyParents = false;\n\n        $this->handleLanguageCriteria($criteria);\n\n        $comments = $repository->findByCriteria($criteria);\n\n        $dtos = [];\n        foreach ($comments->getCurrentPageResults() as $value) {\n            \\assert($value instanceof PostComment);\n            try {\n                $this->handlePrivateContent($value);\n                $dtos[] = $this->serializePostCommentTree($value, $criteria);\n            } catch (\\Exception $e) {\n                continue;\n            }\n        }\n\n        return new JsonResponse(\n            $this->serializePaginated($dtos, $comments),\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Post/Moderate/PostsLockApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Post\\Moderate;\n\nuse App\\Controller\\Api\\Post\\PostsBaseApi;\nuse App\\DTO\\PostResponseDto;\nuse App\\Entity\\Post;\nuse App\\Factory\\PostFactory;\nuse App\\Schema\\Errors\\ForbiddenErrorSchema;\nuse App\\Schema\\Errors\\NotFoundErrorSchema;\nuse App\\Schema\\Errors\\TooManyRequestsErrorSchema;\nuse App\\Schema\\Errors\\UnauthorizedErrorSchema;\nuse App\\Service\\PostManager;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse Nelmio\\ApiDocBundle\\Attribute\\Security;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass PostsLockApi extends PostsBaseApi\n{\n    #[OA\\Response(\n        response: 200,\n        description: 'Post lock status toggled',\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\\Schema(type: 'integer')),\n        ],\n        content: new Model(type: PostResponseDto::class)\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'You are not authorized to lock this post',\n        content: new OA\\JsonContent(ref: new Model(type: ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Post not found',\n        content: new OA\\JsonContent(ref: new Model(type: NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\\Schema(type: 'integer')),\n        ],\n        content: new OA\\JsonContent(ref: new Model(type: TooManyRequestsErrorSchema::class))\n    )]\n    #[OA\\Parameter(\n        name: 'post_id',\n        description: 'The post to lock or unlock',\n        in: 'path',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Tag(name: 'moderation/post')]\n    #[Security(name: 'oauth2', scopes: ['moderate:post:lock'])]\n    #[IsGranted('ROLE_OAUTH2_MODERATE:POST:LOCK')]\n    #[IsGranted('lock', subject: 'post')]\n    public function __invoke(\n        #[MapEntity(id: 'post_id')]\n        Post $post,\n        PostManager $manager,\n        PostFactory $factory,\n        RateLimiterFactoryInterface $apiModerateLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiModerateLimiter);\n\n        $manager->toggleLock($post, $this->getUserOrThrow());\n\n        return new JsonResponse(\n            $this->serializePost($factory->createDto($post), $this->tagLinkRepository->getTagsOfContent($post)),\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Post/Moderate/PostsPinApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Post\\Moderate;\n\nuse App\\Controller\\Api\\Post\\PostsBaseApi;\nuse App\\DTO\\PostResponseDto;\nuse App\\Entity\\Post;\nuse App\\Factory\\PostFactory;\nuse App\\Service\\PostManager;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse Nelmio\\ApiDocBundle\\Attribute\\Security;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass PostsPinApi extends PostsBaseApi\n{\n    #[OA\\Response(\n        response: 200,\n        description: 'Post pin status toggled',\n        content: new Model(type: PostResponseDto::class),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'You are not authorized to pin this post',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Post not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'post_id',\n        in: 'path',\n        description: 'The post to pin or unpin',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Tag(name: 'moderation/post')]\n    #[Security(name: 'oauth2', scopes: ['moderate:post:pin'])]\n    #[IsGranted('ROLE_OAUTH2_MODERATE:POST:PIN')]\n    #[IsGranted('moderate', subject: 'post')]\n    public function __invoke(\n        #[MapEntity(id: 'post_id')]\n        Post $post,\n        PostManager $manager,\n        PostFactory $factory,\n        RateLimiterFactoryInterface $apiModerateLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiModerateLimiter);\n\n        $manager->pin($post);\n\n        return new JsonResponse(\n            $this->serializePost($factory->createDto($post), $this->tagLinkRepository->getTagsOfContent($post)),\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Post/Moderate/PostsSetAdultApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Post\\Moderate;\n\nuse App\\Controller\\Api\\Post\\PostsBaseApi;\nuse App\\DTO\\PostResponseDto;\nuse App\\Entity\\Post;\nuse App\\Factory\\PostFactory;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse Nelmio\\ApiDocBundle\\Attribute\\Security;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass PostsSetAdultApi extends PostsBaseApi\n{\n    #[OA\\Response(\n        response: 200,\n        description: 'Post isAdult status set',\n        content: new Model(type: PostResponseDto::class),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'You are not authorized to moderate this post',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Post not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'post_id',\n        in: 'path',\n        description: 'The post to set adult status on',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Parameter(\n        name: 'adult',\n        in: 'path',\n        description: 'new isAdult status',\n        schema: new OA\\Schema(type: 'boolean', default: true),\n    )]\n    #[OA\\Tag(name: 'moderation/post')]\n    #[Security(name: 'oauth2', scopes: ['moderate:post:set_adult'])]\n    #[IsGranted('ROLE_OAUTH2_MODERATE:POST:SET_ADULT')]\n    #[IsGranted('moderate', subject: 'post')]\n    public function __invoke(\n        #[MapEntity(id: 'post_id')]\n        Post $post,\n        EntityManagerInterface $manager,\n        PostFactory $factory,\n        RateLimiterFactoryInterface $apiModerateLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiModerateLimiter);\n\n        $request = $this->request->getCurrentRequest();\n        // Returns true for \"1\", \"true\", \"on\" and \"yes\". Returns false otherwise.\n        $post->isAdult = filter_var($request->get('adult', 'true'), FILTER_VALIDATE_BOOL);\n\n        $manager->flush();\n\n        return new JsonResponse(\n            $this->serializePost($factory->createDto($post), $this->tagLinkRepository->getTagsOfContent($post)),\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Post/Moderate/PostsSetLanguageApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Post\\Moderate;\n\nuse App\\Controller\\Api\\Post\\PostsBaseApi;\nuse App\\DTO\\PostResponseDto;\nuse App\\Entity\\Post;\nuse App\\Factory\\PostFactory;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse Nelmio\\ApiDocBundle\\Attribute\\Security;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpKernel\\Exception\\BadRequestHttpException;\nuse Symfony\\Component\\Intl\\Languages;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass PostsSetLanguageApi extends PostsBaseApi\n{\n    #[OA\\Response(\n        response: 200,\n        description: 'Post language changed',\n        content: new Model(type: PostResponseDto::class),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 400,\n        description: 'Given language is not valid',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\BadRequestErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'You are not authorized to moderate this post',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Post not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'post_id',\n        in: 'path',\n        description: 'The post to change language of',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Parameter(\n        name: 'lang',\n        in: 'path',\n        description: 'new language',\n        schema: new OA\\Schema(type: 'string', minLength: 2, maxLength: 3),\n    )]\n    #[OA\\Tag(name: 'moderation/post')]\n    #[Security(name: 'oauth2', scopes: ['moderate:post:language'])]\n    #[IsGranted('ROLE_OAUTH2_MODERATE:POST:LANGUAGE')]\n    #[IsGranted('moderate', subject: 'post')]\n    public function __invoke(\n        #[MapEntity(id: 'post_id')]\n        Post $post,\n        EntityManagerInterface $manager,\n        PostFactory $factory,\n        RateLimiterFactoryInterface $apiModerateLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiModerateLimiter);\n\n        $request = $this->request->getCurrentRequest();\n        $newLang = $request->get('lang', '');\n\n        $valid = false !== array_search($newLang, Languages::getLanguageCodes());\n\n        if (!$valid) {\n            throw new BadRequestHttpException('The given language is not valid!');\n        }\n\n        $post->lang = $newLang;\n\n        $manager->flush();\n\n        return new JsonResponse(\n            $this->serializePost($factory->createDto($post), $this->tagLinkRepository->getTagsOfContent($post)),\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Post/Moderate/PostsTrashApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Post\\Moderate;\n\nuse App\\Controller\\Api\\Post\\PostsBaseApi;\nuse App\\DTO\\PostResponseDto;\nuse App\\Entity\\Contracts\\VisibilityInterface;\nuse App\\Entity\\Post;\nuse App\\Factory\\PostFactory;\nuse App\\Service\\PostManager;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse Nelmio\\ApiDocBundle\\Attribute\\Security;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpKernel\\Exception\\BadRequestHttpException;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass PostsTrashApi extends PostsBaseApi\n{\n    #[OA\\Response(\n        response: 200,\n        description: 'Post trashed',\n        content: new Model(type: PostResponseDto::class),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'You are not authorized to trash this post',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Post not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'post_id',\n        in: 'path',\n        description: 'The post to trash',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Tag(name: 'moderation/post')]\n    #[Security(name: 'oauth2', scopes: ['moderate:post:trash'])]\n    #[IsGranted('ROLE_OAUTH2_MODERATE:POST:TRASH')]\n    #[IsGranted('moderate', subject: 'post')]\n    public function trash(\n        #[MapEntity(id: 'post_id')]\n        Post $post,\n        PostManager $manager,\n        PostFactory $factory,\n        RateLimiterFactoryInterface $apiModerateLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiModerateLimiter);\n\n        $moderator = $this->getUserOrThrow();\n\n        $manager->trash($moderator, $post);\n\n        // Force response to have all fields visible\n        $visibility = $post->visibility;\n        $post->visibility = VisibilityInterface::VISIBILITY_VISIBLE;\n        $response = $this->serializePost($factory->createDto($post), $this->tagLinkRepository->getTagsOfContent($post))->jsonSerialize();\n        $response['visibility'] = $visibility;\n\n        return new JsonResponse(\n            $response,\n            headers: $headers\n        );\n    }\n\n    #[OA\\Response(\n        response: 200,\n        description: 'Post restored',\n        content: new Model(type: PostResponseDto::class),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 400,\n        description: 'The post was not in the trashed state',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\BadRequestErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'You are not authorized to restore this post',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Post not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'post_id',\n        in: 'path',\n        description: 'The post to restore',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Tag(name: 'moderation/post')]\n    #[Security(name: 'oauth2', scopes: ['moderate:post:trash'])]\n    #[IsGranted('ROLE_OAUTH2_MODERATE:POST:TRASH')]\n    #[IsGranted('moderate', subject: 'post')]\n    public function restore(\n        #[MapEntity(id: 'post_id')]\n        Post $post,\n        PostManager $manager,\n        PostFactory $factory,\n        RateLimiterFactoryInterface $apiModerateLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiModerateLimiter);\n\n        $moderator = $this->getUserOrThrow();\n\n        try {\n            $manager->restore($moderator, $post);\n        } catch (\\Exception $e) {\n            throw new BadRequestHttpException('The post cannot be restored because it was not trashed!');\n        }\n\n        return new JsonResponse(\n            $this->serializePost($factory->createDto($post), $this->tagLinkRepository->getTagsOfContent($post)),\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Post/PostsActivityApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Post;\n\nuse App\\Controller\\Traits\\PrivateContentTrait;\nuse App\\DTO\\ActivitiesResponseDto;\nuse App\\Entity\\Post;\nuse App\\Factory\\ContentActivityDtoFactory;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\n\nclass PostsActivityApi extends PostsBaseApi\n{\n    use PrivateContentTrait;\n\n    #[OA\\Response(\n        response: 200,\n        description: 'Vote activity of the post',\n        content: new Model(type: ActivitiesResponseDto::class),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'You are not authorized to access this post',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Post not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'post_id',\n        in: 'path',\n        description: 'The post to query',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Tag(name: 'post')]\n    public function __invoke(\n        #[MapEntity(id: 'post_id')]\n        Post $post,\n        ContentActivityDtoFactory $dtoFactory,\n        RateLimiterFactoryInterface $apiReadLimiter,\n        RateLimiterFactoryInterface $anonymousApiReadLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter);\n\n        $this->handlePrivateContent($post);\n\n        $dto = $dtoFactory->createActivitiesDto($post);\n\n        return new JsonResponse(\n            $dto,\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Post/PostsBaseApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Post;\n\nuse App\\Controller\\Api\\BaseApi;\nuse App\\DTO\\PostCommentDto;\nuse App\\DTO\\PostCommentRequestDto;\nuse App\\DTO\\PostDto;\nuse App\\DTO\\PostRequestDto;\nuse App\\Entity\\PostComment;\nuse App\\PageView\\PostCommentPageView;\n\nclass PostsBaseApi extends BaseApi\n{\n    /**\n     * Deserialize a post from JSON.\n     *\n     * @param ?PostDto $dto The EntryDto to modify with new values (default: null to create a new PostDto)\n     *\n     * @return PostDto A post with only certain fields allowed to be modified by the user\n     */\n    protected function deserializePost(?PostDto $dto = null): PostDto\n    {\n        $dto = $dto ? $dto : new PostDto();\n        $deserialized = $this->serializer->deserialize($this->request->getCurrentRequest()->getContent(), PostRequestDto::class, 'json', [\n            'groups' => [\n                'common',\n                'post',\n                'no-upload',\n            ],\n        ]);\n        \\assert($deserialized instanceof PostRequestDto);\n\n        $dto = $deserialized->mergeIntoDto($dto, $this->settingsManager);\n\n        return $dto;\n    }\n\n    protected function deserializePostFromForm(?PostDto $dto = null): PostDto\n    {\n        $request = $this->request->getCurrentRequest();\n        $dto = $dto ? $dto : new PostDto();\n        $deserialized = new PostRequestDto();\n        $deserialized->body = $request->get('body');\n        $deserialized->lang = $request->get('lang');\n        $deserialized->isAdult = filter_var($request->get('isAdult'), FILTER_VALIDATE_BOOL);\n\n        $dto = $deserialized->mergeIntoDto($dto, $this->settingsManager);\n\n        return $dto;\n    }\n\n    /**\n     * Deserialize a comment from JSON.\n     *\n     * @param ?PostCommentDto $dto The EntryCommentDto to modify with new values (default: null to create a new EntryCommentDto)\n     *\n     * @return PostCommentDto A comment with only certain fields allowed to be modified by the user\n     *\n     * Modifies:\n     *  * body\n     *  * isAdult\n     *  * lang\n     *  * imageAlt (currently not working to modify the image)\n     *  * imageUrl (currently not working to modify the image)\n     */\n    protected function deserializePostComment(?PostCommentDto $dto = null): PostCommentDto\n    {\n        $request = $this->request->getCurrentRequest();\n        $dto = $dto ? $dto : new PostCommentDto();\n        $deserialized = $this->serializer->deserialize($request->getContent(), PostCommentRequestDto::class, 'json', [\n            'groups' => [\n                'common',\n                'comment',\n                'no-upload',\n            ],\n        ]);\n\n        \\assert($deserialized instanceof PostCommentRequestDto);\n\n        return $deserialized->mergeIntoDto($dto, $this->settingsManager);\n    }\n\n    protected function deserializePostCommentFromForm(?PostCommentDto $dto = null): PostCommentDto\n    {\n        $request = $this->request->getCurrentRequest();\n        $dto = $dto ? $dto : new PostCommentDto();\n        $deserialized = new PostCommentRequestDto();\n        $deserialized->body = $request->get('body');\n        $deserialized->lang = $request->get('lang');\n\n        $dto = $deserialized->mergeIntoDto($dto, $this->settingsManager);\n\n        return $dto;\n    }\n\n    /**\n     * Serialize a comment tree to JSON.\n     *\n     * @param ?PostComment $comment The root comment to base the tree on\n     * @param ?int         $depth   how many levels of children to include. If null (default), retrieves depth from query parameter 'd'.\n     *\n     * @return array An associative array representation of the comment's hierarchy, to be used as JSON\n     */\n    protected function serializePostCommentTree(?PostComment $comment, PostCommentPageView $commentPageView, ?int $depth = null): array\n    {\n        if (null === $comment) {\n            return [];\n        }\n\n        if (null === $depth) {\n            $depth = self::constrainDepth($this->request->getCurrentRequest()->get('d', self::DEPTH));\n        }\n\n        $canModerate = null;\n        if ($user = $this->getUser()) {\n            $canModerate = $comment->getMagazine()->userIsModerator($user) || $user->isModerator() || $user->isAdmin();\n        }\n\n        $commentTree = $this->postCommentFactory->createResponseTree($comment, $commentPageView, $depth, $canModerate);\n        $commentTree->canAuthUserModerate = $canModerate;\n\n        return $commentTree->jsonSerialize();\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Post/PostsCreateApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Post;\n\nuse App\\Controller\\Traits\\PrivateContentTrait;\nuse App\\DTO\\ImageUploadDto;\nuse App\\DTO\\PostDto;\nuse App\\DTO\\PostRequestDto;\nuse App\\DTO\\PostResponseDto;\nuse App\\Entity\\Magazine;\nuse App\\Service\\PostManager;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse Nelmio\\ApiDocBundle\\Attribute\\Security;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpKernel\\Exception\\AccessDeniedHttpException;\nuse Symfony\\Component\\HttpKernel\\Exception\\BadRequestHttpException;\nuse Symfony\\Component\\HttpKernel\\Exception\\NotFoundHttpException;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\nuse Symfony\\Component\\Validator\\Validator\\ValidatorInterface;\n\nclass PostsCreateApi extends PostsBaseApi\n{\n    use PrivateContentTrait;\n\n    #[OA\\Response(\n        response: 201,\n        description: 'Post created',\n        content: new Model(type: PostResponseDto::class),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'Banned from magazine',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Magazine not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'magazine_id',\n        description: 'The magazine to create the post in. Use the id of the \"random\" magazine to submit posts which should not be posted to a specific magazine.',\n        in: 'path',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\RequestBody(content: new Model(\n        type: PostRequestDto::class,\n        groups: [\n            'common',\n            'post',\n            'no-upload',\n        ]\n    ))]\n    #[OA\\Tag(name: 'magazine')]\n    #[Security(name: 'oauth2', scopes: ['post:create'])]\n    #[IsGranted('ROLE_OAUTH2_POST:CREATE')]\n    public function __invoke(\n        #[MapEntity(id: 'magazine_id')]\n        Magazine $magazine,\n        PostManager $manager,\n        ValidatorInterface $validator,\n        RateLimiterFactoryInterface $apiPostLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiPostLimiter);\n\n        if (!$this->isGranted('create_content', $magazine)) {\n            throw new AccessDeniedHttpException('Create content permission not granted');\n        }\n\n        $dto = new PostDto();\n        $dto->magazine = $magazine;\n\n        if (null === $dto->magazine) {\n            throw new NotFoundHttpException('Magazine not found');\n        }\n\n        $dto = $this->deserializePost($dto);\n\n        $errors = $validator->validate($dto);\n        if (\\count($errors) > 0) {\n            throw new BadRequestHttpException((string) $errors);\n        }\n\n        // Rate limit handled elsewhere\n        $post = $manager->create($dto, $this->getUserOrThrow(), rateLimit: false);\n\n        return new JsonResponse(\n            $this->serializePost($manager->createDto($post), $this->tagLinkRepository->getTagsOfContent($post)),\n            status: 201,\n            headers: $headers\n        );\n    }\n\n    #[OA\\Response(\n        response: 201,\n        description: 'Post created',\n        content: new Model(type: PostResponseDto::class),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'Banned from magazine',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Magazine not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'magazine_id',\n        description: 'The magazine to create the post in. Use the id of the \"random\" magazine to submit posts which should not be posted to a specific magazine.',\n        in: 'path',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\RequestBody(content: new OA\\MediaType(\n        'multipart/form-data',\n        schema: new OA\\Schema(\n            ref: new Model(\n                type: PostRequestDto::class,\n                groups: [\n                    'common',\n                    'post',\n                    ImageUploadDto::IMAGE_UPLOAD,\n                ]\n            )\n        )\n    ))]\n    #[OA\\Tag(name: 'magazine')]\n    #[Security(name: 'oauth2', scopes: ['post:create'])]\n    #[IsGranted('ROLE_OAUTH2_POST:CREATE')]\n    public function uploadImage(\n        #[MapEntity(id: 'magazine_id')]\n        Magazine $magazine,\n        PostManager $manager,\n        ValidatorInterface $validator,\n        RateLimiterFactoryInterface $apiImageLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiImageLimiter);\n\n        if (!$this->isGranted('create_content', $magazine)) {\n            throw new AccessDeniedHttpException('Create content permission not granted');\n        }\n\n        $image = $this->handleUploadedImage();\n\n        $dto = new PostDto();\n        $dto->magazine = $magazine;\n        $dto->image = $this->imageFactory->createDto($image);\n\n        if (null === $dto->magazine) {\n            throw new NotFoundHttpException('Magazine not found');\n        }\n\n        $dto = $this->deserializePostFromForm($dto);\n\n        $errors = $validator->validate($dto);\n        if (\\count($errors) > 0) {\n            throw new BadRequestHttpException((string) $errors);\n        }\n\n        // Rate limit handled elsewhere\n        $post = $manager->create($dto, $this->getUserOrThrow(), rateLimit: false);\n\n        return new JsonResponse(\n            $this->serializePost($manager->createDto($post), $this->tagLinkRepository->getTagsOfContent($post)),\n            status: 201,\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Post/PostsDeleteApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Post;\n\nuse App\\Entity\\Post;\nuse App\\Service\\PostManager;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse Nelmio\\ApiDocBundle\\Attribute\\Security;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpKernel\\Exception\\AccessDeniedHttpException;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass PostsDeleteApi extends PostsBaseApi\n{\n    #[OA\\Response(\n        response: 204,\n        description: 'Post deleted',\n        content: null,\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'You are not authorized to delete this post',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Post not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'post_id',\n        in: 'path',\n        description: 'The post to delete',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Tag(name: 'post')]\n    #[Security(name: 'oauth2', scopes: ['post:delete'])]\n    #[IsGranted('ROLE_OAUTH2_POST:DELETE')]\n    #[IsGranted('delete', subject: 'post')]\n    public function __invoke(\n        #[MapEntity(id: 'post_id')]\n        Post $post,\n        PostManager $manager,\n        RateLimiterFactoryInterface $apiDeleteLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiDeleteLimiter);\n\n        if ($post->user->getId() !== $this->getUserOrThrow()->getId()) {\n            throw new AccessDeniedHttpException();\n        }\n\n        $manager->delete($this->getUserOrThrow(), $post);\n\n        return new JsonResponse(status: 204, headers: $headers);\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Post/PostsFavouriteApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Post;\n\nuse App\\DTO\\PostResponseDto;\nuse App\\Entity\\Post;\nuse App\\Factory\\PostFactory;\nuse App\\Service\\FavouriteManager;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse Nelmio\\ApiDocBundle\\Attribute\\Security;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass PostsFavouriteApi extends PostsBaseApi\n{\n    #[OA\\Response(\n        response: 200,\n        description: 'Post favourite status toggled',\n        content: new Model(type: PostResponseDto::class),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Post not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'post_id',\n        in: 'path',\n        description: 'The post to favourite',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Tag(name: 'post')]\n    #[Security(name: 'oauth2', scopes: ['post:vote'])]\n    #[IsGranted('ROLE_OAUTH2_POST:VOTE')]\n    public function __invoke(\n        #[MapEntity(id: 'post_id')]\n        Post $post,\n        FavouriteManager $manager,\n        PostFactory $factory,\n        RateLimiterFactoryInterface $apiVoteLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiVoteLimiter);\n\n        $manager->toggle($this->getUserOrThrow(), $post);\n\n        return new JsonResponse(\n            $this->serializePost($factory->createDto($post), $this->tagLinkRepository->getTagsOfContent($post)),\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Post/PostsReportApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Post;\n\nuse App\\Controller\\Traits\\PrivateContentTrait;\nuse App\\DTO\\ReportRequestDto;\nuse App\\Entity\\Post;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse Nelmio\\ApiDocBundle\\Attribute\\Security;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass PostsReportApi extends PostsBaseApi\n{\n    use PrivateContentTrait;\n\n    #[OA\\Response(\n        response: 204,\n        description: 'Report created',\n        content: null,\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'You have not been authorized to report this post',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Post not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'post_id',\n        in: 'path',\n        description: 'The post to report',\n        schema: new OA\\Schema(type: 'integer')\n    )]\n    #[OA\\RequestBody(content: new Model(type: ReportRequestDto::class))]\n    #[OA\\Tag(name: 'post')]\n    #[Security(name: 'oauth2', scopes: ['post:report'])]\n    #[IsGranted('ROLE_OAUTH2_POST:REPORT')]\n    public function __invoke(\n        #[MapEntity(id: 'post_id')]\n        Post $post,\n        RateLimiterFactoryInterface $apiReportLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiReportLimiter);\n\n        $this->reportContent($post);\n\n        return new JsonResponse(\n            status: 204,\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Post/PostsRetrieveApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Post;\n\nuse App\\Controller\\Traits\\PrivateContentTrait;\nuse App\\DTO\\ContentResponseDto;\nuse App\\DTO\\PostResponseDto;\nuse App\\Entity\\Magazine;\nuse App\\Entity\\Post;\nuse App\\Entity\\PostComment;\nuse App\\Entity\\User;\nuse App\\Event\\Post\\PostHasBeenSeenEvent;\nuse App\\Factory\\PostFactory;\nuse App\\PageView\\PostPageView;\nuse App\\Repository\\ContentRepository;\nuse App\\Repository\\Criteria;\nuse App\\Repository\\PostRepository;\nuse App\\Schema\\PaginationSchema;\nuse App\\Utils\\SqlHelpers;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse Nelmio\\ApiDocBundle\\Attribute\\Security;\nuse OpenApi\\Attributes as OA;\nuse Psr\\EventDispatcher\\EventDispatcherInterface;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Bundle\\SecurityBundle\\Security as SymfonySecurity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpFoundation\\RequestStack;\nuse Symfony\\Component\\HttpKernel\\Attribute\\MapQueryParameter;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass PostsRetrieveApi extends PostsBaseApi\n{\n    use PrivateContentTrait;\n\n    #[OA\\Response(\n        response: 200,\n        description: 'The retrieved post',\n        content: new Model(type: PostResponseDto::class),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Post not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'post_id',\n        in: 'path',\n        description: 'The post to retrieve',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Tag(name: 'post')]\n    public function __invoke(\n        #[MapEntity(id: 'post_id')]\n        Post $post,\n        PostFactory $factory,\n        EventDispatcherInterface $dispatcher,\n        RateLimiterFactoryInterface $apiReadLimiter,\n        RateLimiterFactoryInterface $anonymousApiReadLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter);\n\n        $this->handlePrivateContent($post);\n\n        $dispatcher->dispatch(new PostHasBeenSeenEvent($post));\n\n        $dto = $factory->createDto($post);\n\n        return new JsonResponse(\n            $this->serializePost($dto, $this->tagLinkRepository->getTagsOfContent($post)),\n            headers: $headers\n        );\n    }\n\n    #[OA\\Response(\n        response: 200,\n        description: 'A paginated list of posts',\n        content: new OA\\JsonContent(\n            type: 'object',\n            properties: [\n                new OA\\Property(\n                    property: 'items',\n                    type: 'array',\n                    items: new OA\\Items(ref: new Model(type: PostResponseDto::class))\n                ),\n                new OA\\Property(\n                    property: 'pagination',\n                    ref: new Model(type: PaginationSchema::class)\n                ),\n            ]\n        ),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'p',\n        description: 'Page of posts to retrieve',\n        in: 'query',\n        schema: new OA\\Schema(type: 'integer', default: 1, minimum: 1)\n    )]\n    #[OA\\Parameter(\n        name: 'perPage',\n        description: 'Number of posts to retrieve per page',\n        in: 'query',\n        schema: new OA\\Schema(type: 'integer', default: PostRepository::PER_PAGE, minimum: self::MIN_PER_PAGE, maximum: self::MAX_PER_PAGE)\n    )]\n    #[OA\\Parameter(\n        name: 'sort',\n        description: 'Sort method to use when retrieving posts',\n        in: 'query',\n        schema: new OA\\Schema(type: 'string', default: Criteria::SORT_HOT, enum: Criteria::SORT_OPTIONS)\n    )]\n    #[OA\\Parameter(\n        name: 'time',\n        description: 'Max age of retrieved posts',\n        in: 'query',\n        schema: new OA\\Schema(type: 'string', default: Criteria::TIME_ALL, enum: Criteria::TIME_ROUTES_EN)\n    )]\n    #[OA\\Parameter(\n        name: 'lang[]',\n        description: 'Language(s) of posts to return',\n        in: 'query',\n        explode: true,\n        allowReserved: true,\n        schema: new OA\\Schema(\n            type: 'array',\n            items: new OA\\Items(type: 'string', default: null, minLength: 2, maxLength: 3)\n        )\n    )]\n    #[OA\\Parameter(\n        name: 'usePreferredLangs',\n        description: 'Filter by a user\\'s preferred languages? (Requires authentication and takes precedence over lang[])',\n        in: 'query',\n        schema: new OA\\Schema(type: 'boolean', default: false),\n    )]\n    #[OA\\Parameter(\n        name: 'federation',\n        description: 'What type of federated entries to retrieve',\n        in: 'query',\n        schema: new OA\\Schema(type: 'string', default: Criteria::AP_ALL, enum: Criteria::AP_OPTIONS)\n    )]\n    #[OA\\Tag(name: 'post')]\n    public function collection(\n        PostRepository $repository,\n        PostFactory $factory,\n        RequestStack $request,\n        RateLimiterFactoryInterface $apiReadLimiter,\n        RateLimiterFactoryInterface $anonymousApiReadLimiter,\n        SymfonySecurity $security,\n        #[MapQueryParameter] ?string $federation,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter);\n\n        $criteria = new PostPageView((int) $request->getCurrentRequest()->get('p', 1), $security);\n        $criteria->sortOption = $request->getCurrentRequest()->get('sort', Criteria::SORT_HOT);\n        $criteria->time = $criteria->resolveTime(\n            $request->getCurrentRequest()->get('time', Criteria::TIME_ALL)\n        );\n        $criteria->perPage = self::constrainPerPage($request->getCurrentRequest()->get('perPage', PostRepository::PER_PAGE));\n        $criteria->setFederation($federation ?? Criteria::AP_ALL);\n\n        $this->handleLanguageCriteria($criteria);\n\n        $posts = $repository->findByCriteria($criteria);\n\n        $dtos = [];\n        foreach ($posts->getCurrentPageResults() as $value) {\n            try {\n                \\assert($value instanceof Post);\n                $this->handlePrivateContent($value);\n                array_push($dtos, $this->serializePost($factory->createDto($value), $this->tagLinkRepository->getTagsOfContent($value)));\n            } catch (\\Exception $e) {\n                continue;\n            }\n        }\n\n        return new JsonResponse(\n            $this->serializePaginated($dtos, $posts),\n            headers: $headers\n        );\n    }\n\n    #[OA\\Response(\n        response: 200,\n        description: 'A paginated list of posts from user\\'s subscribed magazines',\n        content: new OA\\JsonContent(\n            type: 'object',\n            properties: [\n                new OA\\Property(\n                    property: 'items',\n                    type: 'array',\n                    items: new OA\\Items(ref: new Model(type: PostResponseDto::class))\n                ),\n                new OA\\Property(\n                    property: 'pagination',\n                    ref: new Model(type: PaginationSchema::class)\n                ),\n            ]\n        ),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'p',\n        description: 'Page of posts to retrieve',\n        in: 'query',\n        schema: new OA\\Schema(type: 'integer', default: 1, minimum: 1)\n    )]\n    #[OA\\Parameter(\n        name: 'perPage',\n        description: 'Number of posts to retrieve per page',\n        in: 'query',\n        schema: new OA\\Schema(type: 'integer', default: PostRepository::PER_PAGE, minimum: self::MIN_PER_PAGE, maximum: self::MAX_PER_PAGE)\n    )]\n    #[OA\\Parameter(\n        name: 'sort',\n        description: 'Sort method to use when retrieving posts',\n        in: 'query',\n        schema: new OA\\Schema(type: 'string', default: Criteria::SORT_HOT, enum: Criteria::SORT_OPTIONS)\n    )]\n    #[OA\\Parameter(\n        name: 'time',\n        description: 'Max age of retrieved posts',\n        in: 'query',\n        schema: new OA\\Schema(type: 'string', default: Criteria::TIME_ALL, enum: Criteria::TIME_ROUTES_EN)\n    )]\n    #[OA\\Parameter(\n        name: 'lang[]',\n        description: 'Language(s) of posts to return',\n        in: 'query',\n        explode: true,\n        allowReserved: true,\n        schema: new OA\\Schema(\n            type: 'array',\n            items: new OA\\Items(type: 'string', default: null, minLength: 2, maxLength: 3)\n        )\n    )]\n    #[OA\\Parameter(\n        name: 'usePreferredLangs',\n        description: 'Filter by a user\\'s preferred languages? (Requires authentication and takes precedence over lang[])',\n        in: 'query',\n        schema: new OA\\Schema(type: 'boolean', default: false),\n    )]\n    #[OA\\Parameter(\n        name: 'federation',\n        description: 'What type of federated entries to retrieve',\n        in: 'query',\n        schema: new OA\\Schema(type: 'string', default: Criteria::AP_ALL, enum: Criteria::AP_OPTIONS)\n    )]\n    #[OA\\Tag(name: 'post')]\n    #[Security(name: 'oauth2', scopes: ['read'])]\n    #[IsGranted('ROLE_OAUTH2_READ')]\n    public function subscribed(\n        ContentRepository $repository,\n        RateLimiterFactoryInterface $apiReadLimiter,\n        RateLimiterFactoryInterface $anonymousApiReadLimiter,\n        SymfonySecurity $security,\n        SqlHelpers $sqlHelpers,\n        #[MapQueryParameter] ?int $p,\n        #[MapQueryParameter] ?int $perPage,\n        #[MapQueryParameter] ?string $sort,\n        #[MapQueryParameter] ?string $time,\n        #[MapQueryParameter] ?string $federation,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter);\n\n        $criteria = new PostPageView($p ?? 1, $security);\n        $criteria->sortOption = $sort ?? Criteria::SORT_HOT;\n        $criteria->time = $criteria->resolveTime($time ?? Criteria::TIME_ALL);\n        $criteria->perPage = self::constrainPerPage($perPage ?? ContentRepository::PER_PAGE);\n        $criteria->setFederation($federation ?? Criteria::AP_ALL);\n\n        $criteria->subscribed = true;\n        $criteria->setContent(Criteria::CONTENT_MICROBLOG);\n\n        $this->handleLanguageCriteria($criteria);\n\n        $user = $security->getUser();\n        if ($user instanceof User) {\n            $criteria->fetchCachedItems($sqlHelpers, $user);\n        }\n\n        $posts = $repository->findByCriteria($criteria);\n\n        $dtos = [];\n        foreach ($posts->getCurrentPageResults() as $value) {\n            try {\n                \\assert($value instanceof Post);\n                $this->handlePrivateContent($value);\n                $dtos[] = $this->serializePost($this->postFactory->createDto($value), $this->tagLinkRepository->getTagsOfContent($value));\n            } catch (\\Exception $e) {\n                continue;\n            }\n        }\n\n        return new JsonResponse(\n            $this->serializePaginated($dtos, $posts),\n            headers: $headers\n        );\n    }\n\n    #[OA\\Response(\n        response: 200,\n        description: 'A paginated list of posts from user\\'s subscribed magazines',\n        content: new OA\\JsonContent(\n            type: 'object',\n            properties: [\n                new OA\\Property(\n                    property: 'items',\n                    type: 'array',\n                    items: new OA\\Items(ref: new Model(type: ContentResponseDto::class))\n                ),\n                new OA\\Property(\n                    property: 'pagination',\n                    ref: new Model(type: PaginationSchema::class)\n                ),\n            ]\n        ),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'p',\n        description: 'Page of posts to retrieve',\n        in: 'query',\n        schema: new OA\\Schema(type: 'integer', default: 1, minimum: 1)\n    )]\n    #[OA\\Parameter(\n        name: 'perPage',\n        description: 'Number of posts to retrieve per page',\n        in: 'query',\n        schema: new OA\\Schema(type: 'integer', default: PostRepository::PER_PAGE, minimum: self::MIN_PER_PAGE, maximum: self::MAX_PER_PAGE)\n    )]\n    #[OA\\Parameter(\n        name: 'sort',\n        description: 'Sort method to use when retrieving posts',\n        in: 'query',\n        schema: new OA\\Schema(type: 'string', default: Criteria::SORT_HOT, enum: Criteria::SORT_OPTIONS)\n    )]\n    #[OA\\Parameter(\n        name: 'time',\n        description: 'Max age of retrieved posts',\n        in: 'query',\n        schema: new OA\\Schema(type: 'string', default: Criteria::TIME_ALL, enum: Criteria::TIME_ROUTES_EN)\n    )]\n    #[OA\\Parameter(\n        name: 'lang[]',\n        description: 'Language(s) of posts to return',\n        in: 'query',\n        explode: true,\n        allowReserved: true,\n        schema: new OA\\Schema(\n            type: 'array',\n            items: new OA\\Items(type: 'string', default: null, minLength: 2, maxLength: 3)\n        )\n    )]\n    #[OA\\Parameter(\n        name: 'usePreferredLangs',\n        description: 'Filter by a user\\'s preferred languages? (Requires authentication and takes precedence over lang[])',\n        in: 'query',\n        schema: new OA\\Schema(type: 'boolean', default: false),\n    )]\n    #[OA\\Parameter(\n        name: 'federation',\n        description: 'What type of federated entries to retrieve',\n        in: 'query',\n        schema: new OA\\Schema(type: 'string', default: Criteria::AP_ALL, enum: Criteria::AP_OPTIONS)\n    )]\n    #[OA\\Parameter(\n        name: 'includeBoosts',\n        description: 'if true then boosted content from followed users are included',\n        in: 'query',\n        schema: new OA\\Schema(type: 'boolean', default: false)\n    )]\n    #[OA\\Tag(name: 'post')]\n    #[Security(name: 'oauth2', scopes: ['read'])]\n    #[IsGranted('ROLE_OAUTH2_READ')]\n    public function subscribedWithBoosts(\n        ContentRepository $repository,\n        RateLimiterFactoryInterface $apiReadLimiter,\n        RateLimiterFactoryInterface $anonymousApiReadLimiter,\n        SymfonySecurity $security,\n        SqlHelpers $sqlHelpers,\n        #[MapQueryParameter] ?int $p,\n        #[MapQueryParameter] ?int $perPage,\n        #[MapQueryParameter] ?string $sort,\n        #[MapQueryParameter] ?string $time,\n        #[MapQueryParameter] ?string $federation,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter);\n\n        $criteria = new PostPageView($p ?? 1, $security);\n        $criteria->sortOption = $sort ?? Criteria::SORT_HOT;\n        $criteria->time = $criteria->resolveTime($time ?? Criteria::TIME_ALL);\n        $criteria->perPage = self::constrainPerPage($perPage ?? ContentRepository::PER_PAGE);\n        $criteria->setFederation($federation ?? Criteria::AP_ALL);\n\n        $criteria->subscribed = true;\n        $criteria->includeBoosts = true;\n        $criteria->setContent(Criteria::CONTENT_MICROBLOG);\n\n        $this->handleLanguageCriteria($criteria);\n\n        $user = $this->getUserOrThrow();\n        $criteria->fetchCachedItems($sqlHelpers, $user);\n\n        $posts = $repository->findByCriteria($criteria);\n\n        $dtos = [];\n        foreach ($posts->getCurrentPageResults() as $value) {\n            try {\n                if ($value instanceof Post) {\n                    $this->handlePrivateContent($value);\n                    $dtos[] = new ContentResponseDto(post: $this->serializePost($this->postFactory->createDto($value), $this->tagLinkRepository->getTagsOfContent($value)));\n                } elseif ($value instanceof PostComment) {\n                    $this->handlePrivateContent($value);\n                    $dtos[] = new ContentResponseDto(postComment: $this->serializePostComment($this->postCommentFactory->createDto($value), $this->tagLinkRepository->getTagsOfContent($value)));\n                } else {\n                    throw new \\AssertionError('got unexpected type '.\\get_class($value));\n                }\n            } catch (\\Exception $e) {\n                continue;\n            }\n        }\n\n        return new JsonResponse(\n            $this->serializePaginated($dtos, $posts),\n            headers: $headers\n        );\n    }\n\n    #[OA\\Response(\n        response: 200,\n        description: 'A paginated list of posts from user\\'s moderated magazines',\n        content: new OA\\JsonContent(\n            type: 'object',\n            properties: [\n                new OA\\Property(\n                    property: 'items',\n                    type: 'array',\n                    items: new OA\\Items(ref: new Model(type: PostResponseDto::class))\n                ),\n                new OA\\Property(\n                    property: 'pagination',\n                    ref: new Model(type: PaginationSchema::class)\n                ),\n            ]\n        ),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'The client does not have permission to perform moderation actions on posts',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'p',\n        description: 'Page of posts to retrieve',\n        in: 'query',\n        schema: new OA\\Schema(type: 'integer', default: 1, minimum: 1)\n    )]\n    #[OA\\Parameter(\n        name: 'perPage',\n        description: 'Number of posts to retrieve per page',\n        in: 'query',\n        schema: new OA\\Schema(type: 'integer', default: PostRepository::PER_PAGE, minimum: self::MIN_PER_PAGE, maximum: self::MAX_PER_PAGE)\n    )]\n    #[OA\\Parameter(\n        name: 'sort',\n        description: 'Sort method to use when retrieving posts',\n        in: 'query',\n        schema: new OA\\Schema(type: 'string', default: Criteria::SORT_NEW, enum: Criteria::SORT_OPTIONS)\n    )]\n    #[OA\\Parameter(\n        name: 'time',\n        description: 'Max age of retrieved posts',\n        in: 'query',\n        schema: new OA\\Schema(type: 'string', default: Criteria::TIME_ALL, enum: Criteria::TIME_ROUTES_EN)\n    )]\n    #[OA\\Parameter(\n        name: 'federation',\n        description: 'What type of federated entries to retrieve',\n        in: 'query',\n        schema: new OA\\Schema(type: 'string', default: Criteria::AP_ALL, enum: Criteria::AP_OPTIONS)\n    )]\n    #[OA\\Tag(name: 'post')]\n    #[Security(name: 'oauth2', scopes: ['moderate:post'])]\n    #[IsGranted('ROLE_OAUTH2_MODERATE:POST')]\n    public function moderated(\n        ContentRepository $repository,\n        PostFactory $factory,\n        RateLimiterFactoryInterface $apiReadLimiter,\n        RateLimiterFactoryInterface $anonymousApiReadLimiter,\n        SymfonySecurity $security,\n        SqlHelpers $sqlHelpers,\n        #[MapQueryParameter] ?int $p,\n        #[MapQueryParameter] ?int $perPage,\n        #[MapQueryParameter] ?string $sort,\n        #[MapQueryParameter] ?string $time,\n        #[MapQueryParameter] ?string $federation,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter);\n\n        $criteria = new PostPageView($p ?? 1, $security);\n        $criteria->sortOption = $sort ?? Criteria::SORT_HOT;\n        $criteria->time = $criteria->resolveTime($time ?? Criteria::TIME_ALL);\n        $criteria->perPage = self::constrainPerPage($perPage ?? ContentRepository::PER_PAGE);\n        $criteria->setFederation($federation ?? Criteria::AP_ALL);\n\n        $criteria->moderated = true;\n        $criteria->setContent(Criteria::CONTENT_MICROBLOG);\n\n        $user = $security->getUser();\n        if ($user instanceof User) {\n            $criteria->fetchCachedItems($sqlHelpers, $user);\n        }\n\n        $posts = $repository->findByCriteria($criteria);\n\n        $dtos = [];\n        foreach ($posts->getCurrentPageResults() as $value) {\n            try {\n                \\assert($value instanceof Post);\n                $this->handlePrivateContent($value);\n                array_push($dtos, $this->serializePost($factory->createDto($value), $this->tagLinkRepository->getTagsOfContent($value)));\n            } catch (\\Exception $e) {\n                continue;\n            }\n        }\n\n        return new JsonResponse(\n            $this->serializePaginated($dtos, $posts),\n            headers: $headers\n        );\n    }\n\n    #[OA\\Response(\n        response: 200,\n        description: 'A paginated list of user\\'s favourited posts',\n        content: new OA\\JsonContent(\n            type: 'object',\n            properties: [\n                new OA\\Property(\n                    property: 'items',\n                    type: 'array',\n                    items: new OA\\Items(ref: new Model(type: PostResponseDto::class))\n                ),\n                new OA\\Property(\n                    property: 'pagination',\n                    ref: new Model(type: PaginationSchema::class)\n                ),\n            ]\n        ),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'p',\n        description: 'Page of posts to retrieve',\n        in: 'query',\n        schema: new OA\\Schema(type: 'integer', default: 1, minimum: 1)\n    )]\n    #[OA\\Parameter(\n        name: 'perPage',\n        description: 'Number of posts to retrieve per page',\n        in: 'query',\n        schema: new OA\\Schema(type: 'integer', default: PostRepository::PER_PAGE, minimum: self::MIN_PER_PAGE, maximum: self::MAX_PER_PAGE)\n    )]\n    #[OA\\Parameter(\n        name: 'sort',\n        description: 'Sort method to use when retrieving posts',\n        in: 'query',\n        schema: new OA\\Schema(type: 'string', default: Criteria::SORT_HOT, enum: Criteria::SORT_OPTIONS)\n    )]\n    #[OA\\Parameter(\n        name: 'time',\n        description: 'Max age of retrieved posts',\n        in: 'query',\n        schema: new OA\\Schema(type: 'string', default: Criteria::TIME_ALL, enum: Criteria::TIME_ROUTES_EN)\n    )]\n    #[OA\\Parameter(\n        name: 'federation',\n        description: 'What type of federated entries to retrieve',\n        in: 'query',\n        schema: new OA\\Schema(type: 'string', default: Criteria::AP_ALL, enum: Criteria::AP_OPTIONS)\n    )]\n    #[OA\\Tag(name: 'post')]\n    #[Security(name: 'oauth2', scopes: ['post:vote'])]\n    #[IsGranted('ROLE_OAUTH2_POST:VOTE')]\n    public function favourited(\n        ContentRepository $repository,\n        PostFactory $factory,\n        RateLimiterFactoryInterface $apiReadLimiter,\n        RateLimiterFactoryInterface $anonymousApiReadLimiter,\n        SymfonySecurity $security,\n        SqlHelpers $sqlHelpers,\n        #[MapQueryParameter] ?int $p,\n        #[MapQueryParameter] ?int $perPage,\n        #[MapQueryParameter] ?string $sort,\n        #[MapQueryParameter] ?string $time,\n        #[MapQueryParameter] ?string $federation,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter);\n\n        $criteria = new PostPageView($p ?? 1, $security);\n        $criteria->sortOption = $sort ?? Criteria::SORT_HOT;\n        $criteria->time = $criteria->resolveTime($time ?? Criteria::TIME_ALL);\n        $criteria->perPage = self::constrainPerPage($perPage ?? ContentRepository::PER_PAGE);\n        $criteria->setFederation($federation ?? Criteria::AP_ALL);\n\n        $criteria->favourite = true;\n        $criteria->setContent(Criteria::CONTENT_MICROBLOG);\n\n        $this->logger->debug(var_export($criteria, true));\n\n        $user = $security->getUser();\n        if ($user instanceof User) {\n            $criteria->fetchCachedItems($sqlHelpers, $user);\n        }\n\n        $posts = $repository->findByCriteria($criteria);\n\n        $dtos = [];\n        foreach ($posts->getCurrentPageResults() as $value) {\n            try {\n                \\assert($value instanceof Post);\n                $this->handlePrivateContent($value);\n                array_push($dtos, $this->serializePost($factory->createDto($value), $this->tagLinkRepository->getTagsOfContent($value)));\n            } catch (\\Exception $e) {\n                continue;\n            }\n        }\n\n        return new JsonResponse(\n            $this->serializePaginated($dtos, $posts),\n            headers: $headers\n        );\n    }\n\n    #[OA\\Response(\n        response: 200,\n        description: 'A paginated list of posts from the magazine',\n        content: new OA\\JsonContent(\n            type: 'object',\n            properties: [\n                new OA\\Property(\n                    property: 'items',\n                    type: 'array',\n                    items: new OA\\Items(ref: new Model(type: PostResponseDto::class))\n                ),\n                new OA\\Property(\n                    property: 'pagination',\n                    ref: new Model(type: PaginationSchema::class)\n                ),\n            ]\n        ),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Magazine not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'magazine_id',\n        description: 'Magazine to retrieve posts from',\n        in: 'path',\n        schema: new OA\\Schema(type: 'integer')\n    )]\n    #[OA\\Parameter(\n        name: 'p',\n        description: 'Page of posts to retrieve',\n        in: 'query',\n        schema: new OA\\Schema(type: 'integer', default: 1, minimum: 1)\n    )]\n    #[OA\\Parameter(\n        name: 'perPage',\n        description: 'Number of posts to retrieve per page',\n        in: 'query',\n        schema: new OA\\Schema(type: 'integer', default: PostRepository::PER_PAGE, minimum: self::MIN_PER_PAGE, maximum: self::MAX_PER_PAGE)\n    )]\n    #[OA\\Parameter(\n        name: 'sort',\n        description: 'Sort method to use when retrieving posts',\n        in: 'query',\n        schema: new OA\\Schema(type: 'string', default: Criteria::SORT_HOT, enum: Criteria::SORT_OPTIONS)\n    )]\n    #[OA\\Parameter(\n        name: 'time',\n        description: 'Max age of retrieved posts',\n        in: 'query',\n        schema: new OA\\Schema(type: 'string', default: Criteria::TIME_ALL, enum: Criteria::TIME_ROUTES_EN)\n    )]\n    #[OA\\Parameter(\n        name: 'lang[]',\n        description: 'Language(s) of posts to return',\n        in: 'query',\n        explode: true,\n        allowReserved: true,\n        schema: new OA\\Schema(\n            type: 'array',\n            items: new OA\\Items(type: 'string', default: null, minLength: 2, maxLength: 3)\n        )\n    )]\n    #[OA\\Parameter(\n        name: 'usePreferredLangs',\n        description: 'Filter by a user\\'s preferred languages? (Requires authentication and takes precedence over lang[])',\n        in: 'query',\n        schema: new OA\\Schema(type: 'boolean', default: false),\n    )]\n    #[OA\\Parameter(\n        name: 'federation',\n        description: 'What type of federated entries to retrieve',\n        in: 'query',\n        schema: new OA\\Schema(type: 'string', default: Criteria::AP_ALL, enum: Criteria::AP_OPTIONS)\n    )]\n    #[OA\\Tag(name: 'magazine')]\n    public function byMagazine(\n        #[MapEntity(id: 'magazine_id')]\n        Magazine $magazine,\n        ContentRepository $repository,\n        PostFactory $factory,\n        RateLimiterFactoryInterface $apiReadLimiter,\n        RateLimiterFactoryInterface $anonymousApiReadLimiter,\n        SymfonySecurity $security,\n        SqlHelpers $sqlHelpers,\n        #[MapQueryParameter] ?int $p,\n        #[MapQueryParameter] ?int $perPage,\n        #[MapQueryParameter] ?string $sort,\n        #[MapQueryParameter] ?string $time,\n        #[MapQueryParameter] ?string $federation,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter);\n\n        $criteria = new PostPageView($p ?? 1, $security);\n        $criteria->sortOption = $sort ?? Criteria::SORT_HOT;\n        $criteria->time = $criteria->resolveTime($time ?? Criteria::TIME_ALL);\n        $criteria->perPage = self::constrainPerPage($perPage ?? ContentRepository::PER_PAGE);\n        $criteria->setFederation($federation ?? Criteria::AP_ALL);\n        $criteria->stickiesFirst = true;\n\n        $this->handleLanguageCriteria($criteria);\n\n        $criteria->magazine = $magazine;\n        $criteria->setContent(Criteria::CONTENT_MICROBLOG);\n\n        $user = $security->getUser();\n        if ($user instanceof User) {\n            $criteria->fetchCachedItems($sqlHelpers, $user);\n        }\n\n        $posts = $repository->findByCriteria($criteria);\n\n        $dtos = [];\n        foreach ($posts->getCurrentPageResults() as $value) {\n            try {\n                \\assert($value instanceof Post);\n                $this->handlePrivateContent($value);\n                array_push($dtos, $this->serializePost($factory->createDto($value), $this->tagLinkRepository->getTagsOfContent($value)));\n            } catch (\\Exception $e) {\n                continue;\n            }\n        }\n\n        return new JsonResponse(\n            $this->serializePaginated($dtos, $posts),\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Post/PostsUpdateApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Post;\n\nuse App\\DTO\\PostRequestDto;\nuse App\\DTO\\PostResponseDto;\nuse App\\Entity\\Post;\nuse App\\Factory\\PostFactory;\nuse App\\Service\\PostManager;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse Nelmio\\ApiDocBundle\\Attribute\\Security;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpKernel\\Exception\\AccessDeniedHttpException;\nuse Symfony\\Component\\HttpKernel\\Exception\\BadRequestHttpException;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\nuse Symfony\\Component\\Validator\\Validator\\ValidatorInterface;\n\nclass PostsUpdateApi extends PostsBaseApi\n{\n    #[OA\\Response(\n        response: 200,\n        description: 'Post updated',\n        content: new Model(type: PostResponseDto::class),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'You do not have permission to update this post',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Post not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'post_id',\n        in: 'path',\n        description: 'The id of the post to update',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\RequestBody(content: new Model(\n        type: PostRequestDto::class,\n        groups: [\n            'common',\n            'post',\n            'no-upload',\n        ]\n    ))]\n    #[OA\\Tag(name: 'post')]\n    #[Security(name: 'oauth2', scopes: ['post:edit'])]\n    #[IsGranted('ROLE_OAUTH2_POST:EDIT')]\n    #[IsGranted('edit', subject: 'post')]\n    public function __invoke(\n        #[MapEntity(id: 'post_id')]\n        Post $post,\n        PostManager $manager,\n        ValidatorInterface $validator,\n        PostFactory $factory,\n        RateLimiterFactoryInterface $apiUpdateLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiUpdateLimiter);\n\n        $user = $this->getUserOrThrow();\n        if ($post->user->getId() !== $user->getId()) {\n            throw new AccessDeniedHttpException();\n        }\n\n        $dto = $this->deserializePost($manager->createDto($post));\n\n        $errors = $validator->validate($dto);\n        if (\\count($errors) > 0) {\n            throw new BadRequestHttpException((string) $errors);\n        }\n\n        $post = $manager->edit($post, $dto, $user);\n\n        return new JsonResponse(\n            $this->serializePost($factory->createDto($post), $this->tagLinkRepository->getTagsOfContent($post)),\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Post/PostsVoteApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Post;\n\nuse App\\DTO\\PostResponseDto;\nuse App\\Entity\\Contracts\\VotableInterface;\nuse App\\Entity\\Post;\nuse App\\Factory\\PostFactory;\nuse App\\Service\\SettingsManager;\nuse App\\Service\\VoteManager;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse Nelmio\\ApiDocBundle\\Attribute\\Security;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpKernel\\Exception\\BadRequestHttpException;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass PostsVoteApi extends PostsBaseApi\n{\n    // TODO: Bots should not be able to vote\n    //       *sad beep boop*\n    #[OA\\Response(\n        response: 200,\n        description: 'Post vote changed',\n        content: new Model(type: PostResponseDto::class),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 400,\n        description: 'Vote choice was not valid',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\BadRequestErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Post not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'post_id',\n        in: 'path',\n        description: 'The post to vote upon',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Parameter(\n        name: 'choice',\n        in: 'path',\n        description: 'The user\\'s voting choice. 0 clears the user\\'s vote.',\n        schema: new OA\\Schema(\n            type: 'integer',\n            enum: VotableInterface::VOTE_CHOICES,\n            default: VotableInterface::VOTE_UP\n        ),\n    )]\n    #[OA\\Tag(name: 'post')]\n    #[Security(name: 'oauth2', scopes: ['post:vote'])]\n    #[IsGranted('ROLE_OAUTH2_POST:VOTE')]\n    public function __invoke(\n        #[MapEntity(id: 'post_id')]\n        Post $post,\n        int $choice,\n        VoteManager $manager,\n        PostFactory $factory,\n        RateLimiterFactoryInterface $apiVoteLimiter,\n        SettingsManager $settingsManager,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiVoteLimiter);\n\n        if (!\\in_array($choice, VotableInterface::VOTE_CHOICES)) {\n            throw new BadRequestHttpException('Vote must be either -1, 0, or 1');\n        }\n\n        if (VotableInterface::VOTE_DOWN === $choice) {\n            throw new BadRequestHttpException('Downvotes for posts are disabled!');\n        }\n\n        // Rate limit handled above\n        $manager->vote($choice, $post, $this->getUserOrThrow(), rateLimit: false);\n\n        return new JsonResponse(\n            $this->serializePost($factory->createDto($post), $this->tagLinkRepository->getTagsOfContent($post)),\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Post/UserPostsRetrieveApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Post;\n\nuse App\\Controller\\Traits\\PrivateContentTrait;\nuse App\\DTO\\PostResponseDto;\nuse App\\Entity\\Post;\nuse App\\Entity\\User;\nuse App\\Factory\\PostFactory;\nuse App\\PageView\\PostPageView;\nuse App\\Repository\\Criteria;\nuse App\\Repository\\PostRepository;\nuse App\\Schema\\PaginationSchema;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Bundle\\SecurityBundle\\Security;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpFoundation\\RequestStack;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\n\nclass UserPostsRetrieveApi extends PostsBaseApi\n{\n    use PrivateContentTrait;\n\n    #[OA\\Response(\n        response: 200,\n        description: 'A paginated list of posts from the user',\n        content: new OA\\JsonContent(\n            type: 'object',\n            properties: [\n                new OA\\Property(\n                    property: 'items',\n                    type: 'array',\n                    items: new OA\\Items(ref: new Model(type: PostResponseDto::class))\n                ),\n                new OA\\Property(\n                    property: 'pagination',\n                    ref: new Model(type: PaginationSchema::class)\n                ),\n            ]\n        ),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'User not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'user_id',\n        description: 'User whose posts to retrieve',\n        in: 'path',\n        schema: new OA\\Schema(type: 'integer')\n    )]\n    #[OA\\Parameter(\n        name: 'p',\n        description: 'Page of posts to retrieve',\n        in: 'query',\n        schema: new OA\\Schema(type: 'integer', default: 1, minimum: 1)\n    )]\n    #[OA\\Parameter(\n        name: 'perPage',\n        description: 'Number of posts to retrieve per page',\n        in: 'query',\n        schema: new OA\\Schema(type: 'integer', default: PostRepository::PER_PAGE, minimum: self::MIN_PER_PAGE, maximum: self::MAX_PER_PAGE)\n    )]\n    #[OA\\Parameter(\n        name: 'sort',\n        description: 'Sort method to use when retrieving posts',\n        in: 'query',\n        schema: new OA\\Schema(type: 'string', default: Criteria::SORT_HOT, enum: Criteria::SORT_OPTIONS)\n    )]\n    #[OA\\Parameter(\n        name: 'time',\n        description: 'Max age of retrieved posts',\n        in: 'query',\n        schema: new OA\\Schema(type: 'string', default: Criteria::TIME_ALL, enum: Criteria::TIME_ROUTES_EN)\n    )]\n    #[OA\\Parameter(\n        name: 'lang[]',\n        description: 'Language(s) of posts to return',\n        in: 'query',\n        explode: true,\n        allowReserved: true,\n        schema: new OA\\Schema(\n            type: 'array',\n            items: new OA\\Items(type: 'string', default: null, minLength: 2, maxLength: 3)\n        )\n    )]\n    #[OA\\Parameter(\n        name: 'usePreferredLangs',\n        description: 'Filter by a user\\'s preferred languages? (Requires authentication and takes precedence over lang[])',\n        in: 'query',\n        schema: new OA\\Schema(type: 'boolean', default: false),\n    )]\n    #[OA\\Tag(name: 'user')]\n    public function __invoke(\n        #[MapEntity(id: 'user_id')]\n        User $user,\n        PostRepository $repository,\n        PostFactory $factory,\n        RequestStack $request,\n        RateLimiterFactoryInterface $apiReadLimiter,\n        RateLimiterFactoryInterface $anonymousApiReadLimiter,\n        Security $security,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter);\n\n        $criteria = new PostPageView((int) $request->getCurrentRequest()->get('p', 1), $security);\n        $criteria->sortOption = $request->getCurrentRequest()->get('sort', Criteria::SORT_HOT);\n        $criteria->time = $criteria->resolveTime(\n            $request->getCurrentRequest()->get('time', Criteria::TIME_ALL)\n        );\n        $criteria->perPage = self::constrainPerPage($request->getCurrentRequest()->get('perPage', PostRepository::PER_PAGE));\n\n        $this->handleLanguageCriteria($criteria);\n\n        $criteria->user = $user;\n\n        $posts = $repository->findByCriteria($criteria);\n\n        $dtos = [];\n        foreach ($posts->getCurrentPageResults() as $value) {\n            try {\n                \\assert($value instanceof Post);\n                $this->handlePrivateContent($value);\n                array_push($dtos, $this->serializePost($factory->createDto($value), $this->tagLinkRepository->getTagsOfContent($value)));\n            } catch (\\Exception $e) {\n                continue;\n            }\n        }\n\n        return new JsonResponse(\n            $this->serializePaginated($dtos, $posts),\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/PostComments.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api;\n\nuse App\\ApiDataProvider\\DtoPaginator;\nuse App\\Controller\\AbstractController;\nuse App\\Entity\\Post;\nuse App\\Factory\\PostCommentFactory;\nuse App\\PageView\\PostCommentPageView;\nuse App\\Repository\\PostCommentRepository;\nuse Symfony\\Bundle\\SecurityBundle\\Security;\nuse Symfony\\Component\\HttpFoundation\\RequestStack;\n\nclass PostComments extends AbstractController\n{\n    public function __construct(\n        private readonly PostCommentRepository $repository,\n        private readonly PostCommentFactory $factory,\n        private readonly RequestStack $request,\n        private readonly Security $security,\n    ) {\n    }\n\n    public function __invoke(Post $post)\n    {\n        try {\n            $criteria = new PostCommentPageView((int) $this->request->getCurrentRequest()->get('p', 1), $this->security);\n            $criteria->post = $post;\n            $criteria->onlyParents = false;\n\n            $comments = $this->repository->findByCriteria($criteria);\n        } catch (\\Exception $e) {\n            return [];\n        }\n\n        $dtos = array_map(fn ($comment) => $this->factory->createDto($comment),\n            (array) $comments->getCurrentPageResults());\n\n        return new DtoPaginator($dtos, 0, PostCommentRepository::PER_PAGE, $comments->getNbResults());\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/RandomMagazine.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api;\n\nuse App\\ApiDataProvider\\DtoPaginator;\nuse App\\Controller\\AbstractController;\nuse App\\Factory\\MagazineFactory;\nuse App\\Repository\\MagazineRepository;\n\nclass RandomMagazine extends AbstractController\n{\n    public string $titleTag = 'span';\n\n    public function __construct(\n        private readonly MagazineFactory $factory,\n        private readonly MagazineRepository $repository,\n    ) {\n    }\n\n    public function __invoke()\n    {\n        try {\n            $magazine = $this->repository->findRandom();\n        } catch (\\Exception $e) {\n            return [];\n        }\n        $dtos = [$this->factory->createDto($magazine)];\n\n        return new DtoPaginator($dtos, 0, 1, 1);\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/Search/SearchRetrieveApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\Search;\n\nuse App\\Controller\\Api\\BaseApi;\nuse App\\Controller\\Traits\\PrivateContentTrait;\nuse App\\DTO\\SearchResponseDto;\nuse App\\Entity\\Contracts\\ContentInterface;\nuse App\\Entity\\Entry;\nuse App\\Entity\\EntryComment;\nuse App\\Entity\\Magazine;\nuse App\\Entity\\Post;\nuse App\\Entity\\PostComment;\nuse App\\Entity\\User;\nuse App\\Factory\\MagazineFactory;\nuse App\\Factory\\UserFactory;\nuse App\\Repository\\SearchRepository;\nuse App\\Schema\\ContentSchema;\nuse App\\Schema\\PaginationSchema;\nuse App\\Schema\\SearchActorSchema;\nuse App\\Service\\SearchManager;\nuse App\\Service\\SettingsManager;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\HttpKernel\\Attribute\\MapQueryParameter;\nuse Symfony\\Component\\HttpKernel\\Exception\\BadRequestHttpException;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\n\nclass SearchRetrieveApi extends BaseApi\n{\n    use PrivateContentTrait;\n\n    #[OA\\Response(\n        response: 200,\n        description: 'Returns a paginated list of content, along with any ActivityPub actors that matched the query by username, or ActivityPub objects that matched the query by URL. Actors and objects are not paginated',\n        content: new OA\\JsonContent(\n            type: 'object',\n            properties: [\n                new OA\\Property(\n                    property: 'items',\n                    type: 'array',\n                    items: new OA\\Items(\n                        ref: new Model(type: ContentSchema::class)\n                    )\n                ),\n                new OA\\Property(\n                    property: 'pagination',\n                    ref: new Model(type: PaginationSchema::class)\n                ),\n                new OA\\Property(\n                    property: 'apActors',\n                    type: 'array',\n                    items: new OA\\Items(\n                        ref: new Model(type: SearchActorSchema::class)\n                    )\n                ),\n                new OA\\Property(\n                    property: 'apObjects',\n                    type: 'array',\n                    items: new OA\\Items(\n                        ref: new Model(type: ContentSchema::class)\n                    )\n                ),\n            ]\n        ),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 400,\n        description: 'The search query parameter `q` is required!',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\BadRequestErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'p',\n        description: 'Page of items to retrieve',\n        in: 'query',\n        schema: new OA\\Schema(type: 'integer', default: 1, minimum: 1)\n    )]\n    #[OA\\Parameter(\n        name: 'perPage',\n        description: 'Number of items per page',\n        in: 'query',\n        schema: new OA\\Schema(type: 'integer', default: SearchRepository::PER_PAGE, minimum: self::MIN_PER_PAGE, maximum: self::MAX_PER_PAGE)\n    )]\n    #[OA\\Parameter(\n        name: 'q',\n        description: 'Search term',\n        in: 'query',\n        required: true,\n        schema: new OA\\Schema(type: 'string')\n    )]\n    #[OA\\Parameter(\n        name: 'authorId',\n        description: 'User id of the author',\n        in: 'query',\n        required: false,\n        schema: new OA\\Schema(type: 'integer')\n    )]\n    #[OA\\Parameter(\n        name: 'magazineId',\n        description: 'Id of the magazine',\n        in: 'query',\n        required: false,\n        schema: new OA\\Schema(type: 'integer')\n    )]\n    #[OA\\Parameter(\n        name: 'type',\n        description: 'The type of content',\n        in: 'query',\n        required: false,\n        schema: new OA\\Schema(type: 'string', enum: ['', 'entry', 'post'])\n    )]\n    #[OA\\Tag(name: 'search')]\n    #[OA\\Get(deprecated: true)]\n    public function searchV1(\n        SearchManager $manager,\n        UserFactory $userFactory,\n        MagazineFactory $magazineFactory,\n        SettingsManager $settingsManager,\n        RateLimiterFactoryInterface $apiReadLimiter,\n        RateLimiterFactoryInterface $anonymousApiReadLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter);\n\n        $request = $this->request->getCurrentRequest();\n        $q = $request->get('q');\n        if (null === $q) {\n            throw new BadRequestHttpException();\n        }\n\n        $page = $this->getPageNb($request);\n        $perPage = self::constrainPerPage($request->get('perPage', SearchRepository::PER_PAGE));\n        $authorIdRaw = $request->get('authorId');\n        $authorId = null === $authorIdRaw ? null : \\intval($authorIdRaw);\n        $magazineIdRaw = $request->get('magazineId');\n        $magazineId = null === $magazineIdRaw ? null : \\intval($magazineIdRaw);\n        $type = $request->get('type');\n        if ('entry' !== $type && 'post' !== $type && null !== $type) {\n            throw new BadRequestHttpException();\n        }\n\n        $items = $manager->findPaginated($this->getUser(), $q, $page, $perPage, authorId: $authorId, magazineId: $magazineId, specificType: $type);\n        $dtos = [];\n        foreach ($items->getCurrentPageResults() as $value) {\n            \\assert($value instanceof ContentInterface);\n            array_push($dtos, $this->serializeContentInterface($value));\n        }\n\n        $response = $this->serializePaginated($dtos, $items);\n\n        $response['apActors'] = [];\n        $response['apObjects'] = [];\n        $actors = [];\n        $objects = [];\n        if (!$settingsManager->get('KBIN_FEDERATED_SEARCH_ONLY_LOGGEDIN') || $this->getUser()) {\n            $actors = $manager->findActivityPubActorsByUsername($q);\n            $objects = $manager->findActivityPubObjectsByURL($q);\n        }\n\n        foreach ($actors as $actor) {\n            switch ($actor['type']) {\n                case 'user':\n                    $response['apActors'][] = [\n                        'type' => 'user',\n                        'object' => $this->serializeUser($userFactory->createDto($actor['object'])),\n                    ];\n                    break;\n                case 'magazine':\n                    $response['apActors'][] = [\n                        'type' => 'magazine',\n                        'object' => $this->serializeMagazine($magazineFactory->createDto($actor['object'])),\n                    ];\n                    break;\n            }\n        }\n\n        foreach ($objects as $object) {\n            \\assert($object instanceof ContentInterface);\n            $response['apObjects'][] = $this->serializeContentInterface($object);\n        }\n\n        return new JsonResponse(\n            $response,\n            headers: $headers\n        );\n    }\n\n    #[OA\\Response(\n        response: 200,\n        description: 'Returns a paginated list of content, along with any ActivityPub actors that matched the query by username, or ActivityPub objects that matched the query by URL. AP-Objects are not paginated.',\n        content: new OA\\JsonContent(\n            type: 'object',\n            properties: [\n                new OA\\Property(\n                    property: 'items',\n                    type: 'array',\n                    items: new OA\\Items(ref: new Model(type: SearchResponseDto::class))\n                ),\n                new OA\\Property(\n                    property: 'pagination',\n                    ref: new Model(type: PaginationSchema::class)\n                ),\n                new OA\\Property(\n                    property: 'apResults',\n                    type: 'array',\n                    items: new OA\\Items(ref: new Model(type: SearchResponseDto::class))\n                ),\n            ]\n        ),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 400,\n        description: 'The search query parameter `q` is required!',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\BadRequestErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'p',\n        description: 'Page of items to retrieve',\n        in: 'query',\n        schema: new OA\\Schema(type: 'integer', default: 1, minimum: 1)\n    )]\n    #[OA\\Parameter(\n        name: 'perPage',\n        description: 'Number of items per page',\n        in: 'query',\n        schema: new OA\\Schema(type: 'integer', default: SearchRepository::PER_PAGE, minimum: self::MIN_PER_PAGE, maximum: self::MAX_PER_PAGE)\n    )]\n    #[OA\\Parameter(\n        name: 'q',\n        description: 'Search term',\n        in: 'query',\n        required: true,\n        schema: new OA\\Schema(type: 'string')\n    )]\n    #[OA\\Parameter(\n        name: 'authorId',\n        description: 'User id of the author',\n        in: 'query',\n        required: false,\n        schema: new OA\\Schema(type: 'integer')\n    )]\n    #[OA\\Parameter(\n        name: 'magazineId',\n        description: 'Id of the magazine',\n        in: 'query',\n        required: false,\n        schema: new OA\\Schema(type: 'integer')\n    )]\n    #[OA\\Parameter(\n        name: 'type',\n        description: 'The type of content',\n        in: 'query',\n        required: false,\n        schema: new OA\\Schema(type: 'string', enum: ['', 'entry', 'post'])\n    )]\n    #[OA\\Tag(name: 'search')]\n    public function searchV2(\n        SearchManager $manager,\n        RateLimiterFactoryInterface $apiReadLimiter,\n        RateLimiterFactoryInterface $anonymousApiReadLimiter,\n        #[MapQueryParameter(validationFailedStatusCode: Response::HTTP_BAD_REQUEST)]\n        string $q,\n        #[MapQueryParameter(validationFailedStatusCode: Response::HTTP_BAD_REQUEST)]\n        int $perPage = SearchRepository::PER_PAGE,\n        #[MapQueryParameter('authorId', validationFailedStatusCode: Response::HTTP_BAD_REQUEST)]\n        ?int $authorId = null,\n        #[MapQueryParameter('magazineId', validationFailedStatusCode: Response::HTTP_BAD_REQUEST)]\n        ?int $magazineId = null,\n        #[MapQueryParameter]\n        ?string $type = null,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter);\n\n        $request = $this->request->getCurrentRequest();\n        $page = $this->getPageNb($request);\n\n        if ('entry' !== $type && 'post' !== $type && null !== $type) {\n            throw new BadRequestHttpException();\n        }\n\n        /** @var ?SearchResponseDto[] $searchResults */\n        $searchResults = [];\n        $items = $manager->findPaginated($this->getUser(), $q, $page, $perPage, authorId: $authorId, magazineId: $magazineId, specificType: $type);\n        foreach ($items->getCurrentPageResults() as $item) {\n            $searchResults[] = $this->serializeItem($item);\n        }\n\n        /** @var ?SearchResponseDto $apResults */\n        $apResults = [];\n        if ($this->federatedSearchAllowed()) {\n            $objects = $manager->findActivityPubActorsOrObjects($q);\n\n            foreach ($objects['errors'] as $error) {\n                /** @var \\Throwable $error */\n                $this->logger->warning(\n                    'Exception while resolving AP handle / url {q}: {type}: {msg}',\n                    [\n                        'q' => $q,\n                        'type' => \\get_class($error),\n                        'msg' => $error->getMessage(),\n                    ]\n                );\n            }\n\n            foreach ($objects['results'] as $object) {\n                $apResults[] = $this->serializeItem($object['object']);\n            }\n        }\n\n        $response = $this->serializePaginated($searchResults, $items);\n        $response['apResults'] = $apResults;\n\n        return new JsonResponse(\n            $response,\n            headers: $headers\n        );\n    }\n\n    private function federatedSearchAllowed(): bool\n    {\n        return !$this->settingsManager->get('KBIN_FEDERATED_SEARCH_ONLY_LOGGEDIN')\n            || $this->getUser();\n    }\n\n    private function serializeItem(object $item): ?SearchResponseDto\n    {\n        if ($item instanceof Entry) {\n            $this->handlePrivateContent($item);\n\n            return new SearchResponseDto(entry: $this->serializeEntry($this->entryFactory->createDto($item), $this->tagLinkRepository->getTagsOfContent($item)));\n        } elseif ($item instanceof Post) {\n            $this->handlePrivateContent($item);\n\n            return new SearchResponseDto(post: $this->serializePost($this->postFactory->createDto($item), $this->tagLinkRepository->getTagsOfContent($item)));\n        } elseif ($item instanceof EntryComment) {\n            $this->handlePrivateContent($item);\n\n            return new SearchResponseDto(entryComment: $this->serializeEntryComment($this->entryCommentFactory->createDto($item), $this->tagLinkRepository->getTagsOfContent($item)));\n        } elseif ($item instanceof PostComment) {\n            $this->handlePrivateContent($item);\n\n            return new SearchResponseDto(postComment: $this->serializePostComment($this->postCommentFactory->createDto($item), $this->tagLinkRepository->getTagsOfContent($item)));\n        } elseif ($item instanceof Magazine) {\n            return new SearchResponseDto(magazine: $this->serializeMagazine($this->magazineFactory->createDto($item)));\n        } elseif ($item instanceof User) {\n            return new SearchResponseDto(user: $this->serializeUser($this->userFactory->createDto($item)));\n        } else {\n            $this->logger->error('Unexpected result type: '.\\get_class($item));\n\n            return null;\n        }\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/User/Admin/UserApplicationApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\User\\Admin;\n\nuse App\\Controller\\Api\\User\\UserBaseApi;\nuse App\\DTO\\UserSignupResponseDto;\nuse App\\Entity\\User;\nuse App\\Factory\\UserFactory;\nuse App\\Schema\\Errors\\ForbiddenErrorSchema;\nuse App\\Schema\\Errors\\NotFoundErrorSchema;\nuse App\\Schema\\Errors\\TooManyRequestsErrorSchema;\nuse App\\Schema\\Errors\\UnauthorizedErrorSchema;\nuse App\\Schema\\PaginationSchema;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse Nelmio\\ApiDocBundle\\Attribute\\Security;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\ExpressionLanguage\\Expression;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpKernel\\Attribute\\MapQueryParameter;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass UserApplicationApi extends UserBaseApi\n{\n    #[OA\\Response(\n        response: 200,\n        description: 'Returns a paginated list of users that are awaiting admin approval',\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\\Schema(type: 'integer')),\n        ],\n        content: new OA\\JsonContent(\n            properties: [\n                new OA\\Property(\n                    property: 'items',\n                    type: 'array',\n                    items: new OA\\Items(ref: new Model(type: UserSignupResponseDto::class))\n                ),\n                new OA\\Property(\n                    property: 'pagination',\n                    ref: new Model(type: PaginationSchema::class)\n                ),\n            ],\n            type: 'object'\n        )\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'You are not authorized to view users waiting for approval',\n        content: new OA\\JsonContent(ref: new Model(type: ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\\Schema(type: 'integer')),\n        ],\n        content: new OA\\JsonContent(ref: new Model(type: TooManyRequestsErrorSchema::class))\n    )]\n    #[OA\\Parameter(\n        name: 'p',\n        description: 'Page of users to retrieve',\n        in: 'query',\n        schema: new OA\\Schema(type: 'integer', default: 1, minimum: 1)\n    )]\n    #[OA\\Tag(name: 'admin/user')]\n    #[IsGranted(new Expression('is_granted(\"ROLE_ADMIN\") or is_granted(\"ROLE_MODERATOR\")'))]\n    #[Security(name: 'oauth2', scopes: ['admin:user:application'])]\n    #[IsGranted('ROLE_OAUTH2_ADMIN:USER:APPLICATION')]\n    /** Retrieve users waiting for admin approval */\n    public function retrieve(\n        UserFactory $userFactory,\n        RateLimiterFactoryInterface $apiReadLimiter,\n        RateLimiterFactoryInterface $anonymousApiReadLimiter,\n        #[MapQueryParameter] int $p = 1,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter);\n        $users = $this->userRepository->findAllSignupRequestsPaginated($p);\n\n        $dtos = [];\n        foreach ($users->getCurrentPageResults() as $value) {\n            \\assert($value instanceof User);\n            $dtos[] = $userFactory->createSignupResponseDto($value);\n        }\n\n        return new JsonResponse(\n            $this->serializePaginated($dtos, $users),\n            headers: $headers\n        );\n    }\n\n    #[OA\\Response(\n        response: 200,\n        description: 'Returns nothing on success',\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\\Schema(type: 'integer')),\n        ],\n        content: null\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'You are not authorized to verify this user',\n        content: new OA\\JsonContent(ref: new Model(type: ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'User not found',\n        content: new OA\\JsonContent(ref: new Model(type: NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\\Schema(type: 'integer')),\n        ],\n        content: new OA\\JsonContent(ref: new Model(type: TooManyRequestsErrorSchema::class))\n    )]\n    #[OA\\Parameter(\n        name: 'user_id',\n        description: 'The user to approve',\n        in: 'path',\n        schema: new OA\\Schema(type: 'integer', minimum: 1)\n    )]\n    #[OA\\Tag(name: 'admin/user')]\n    #[IsGranted(new Expression('is_granted(\"ROLE_ADMIN\") or is_granted(\"ROLE_MODERATOR\")'))]\n    #[Security(name: 'oauth2', scopes: ['admin:user:application'])]\n    #[IsGranted('ROLE_OAUTH2_ADMIN:USER:APPLICATION')]\n    public function approve(\n        RateLimiterFactoryInterface $apiReadLimiter,\n        RateLimiterFactoryInterface $anonymousApiReadLimiter,\n        #[MapEntity(id: 'user_id')] User $user,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter);\n        $this->userManager->approveUserApplication($user);\n\n        return new JsonResponse(null, headers: $headers);\n    }\n\n    #[OA\\Response(\n        response: 200,\n        description: 'Returns nothing on success',\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\\Schema(type: 'integer')),\n        ],\n        content: null\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'You are not authorized to verify this user',\n        content: new OA\\JsonContent(ref: new Model(type: ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'User not found',\n        content: new OA\\JsonContent(ref: new Model(type: NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\\Schema(type: 'integer')),\n        ],\n        content: new OA\\JsonContent(ref: new Model(type: TooManyRequestsErrorSchema::class))\n    )]\n    #[OA\\Parameter(\n        name: 'user_id',\n        description: 'The user to reject',\n        in: 'path',\n        schema: new OA\\Schema(type: 'integer', minimum: 1)\n    )]\n    #[OA\\Tag(name: 'admin/user')]\n    #[IsGranted(new Expression('is_granted(\"ROLE_ADMIN\") or is_granted(\"ROLE_MODERATOR\")'))]\n    #[Security(name: 'oauth2', scopes: ['admin:user:application'])]\n    #[IsGranted('ROLE_OAUTH2_ADMIN:USER:APPLICATION')]\n    public function reject(\n        RateLimiterFactoryInterface $apiReadLimiter,\n        RateLimiterFactoryInterface $anonymousApiReadLimiter,\n        #[MapEntity(id: 'user_id')] User $user,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter);\n        $this->userManager->rejectUserApplication($user);\n\n        return new JsonResponse(null, headers: $headers);\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/User/Admin/UserBanApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\User\\Admin;\n\nuse App\\Controller\\Api\\User\\UserBaseApi;\nuse App\\DTO\\UserBanResponseDto;\nuse App\\Entity\\User;\nuse App\\Factory\\UserFactory;\nuse App\\Service\\UserManager;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse Nelmio\\ApiDocBundle\\Attribute\\Security;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass UserBanApi extends UserBaseApi\n{\n    #[OA\\Response(\n        response: 200,\n        description: 'User banned',\n        content: new Model(type: UserBanResponseDto::class),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'You are not authorized to ban this user',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'User not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'user_id',\n        in: 'path',\n        description: 'The user to ban',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Tag(name: 'admin/user')]\n    #[IsGranted('ROLE_ADMIN')]\n    #[Security(name: 'oauth2', scopes: ['admin:user:ban'])]\n    #[IsGranted('ROLE_OAUTH2_ADMIN:USER:BAN')]\n    /** Bans a user from the instance */\n    public function ban(\n        #[MapEntity(id: 'user_id')]\n        User $user,\n        UserManager $manager,\n        UserFactory $factory,\n        RateLimiterFactoryInterface $apiModerateLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiModerateLimiter);\n\n        $manager->ban($user, $this->getUserOrThrow(), null);\n        // Response needs to be an array to insert isBanned\n        $response = $this->serializeUser($factory->createDto($user))->jsonSerialize();\n        $response['isBanned'] = $user->isBanned;\n\n        return new JsonResponse(\n            $response,\n            headers: $headers\n        );\n    }\n\n    #[OA\\Response(\n        response: 200,\n        description: 'User unbanned',\n        content: new Model(type: UserBanResponseDto::class),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'You are not authorized to unban this user',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'User not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'user_id',\n        in: 'path',\n        description: 'The user to unban',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Tag(name: 'admin/user')]\n    #[IsGranted('ROLE_ADMIN')]\n    #[Security(name: 'oauth2', scopes: ['admin:user:ban'])]\n    #[IsGranted('ROLE_OAUTH2_ADMIN:USER:BAN')]\n    /** Unbans a user from the instance */\n    public function unban(\n        #[MapEntity(id: 'user_id')]\n        User $user,\n        UserManager $manager,\n        UserFactory $factory,\n        RateLimiterFactoryInterface $apiModerateLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiModerateLimiter);\n\n        $manager->unban($user, $this->getUserOrThrow(), null);\n        // Response needs to be an array to insert isBanned\n        $response = $this->serializeUser($factory->createDto($user))->jsonSerialize();\n        $response['isBanned'] = $user->isBanned;\n\n        return new JsonResponse(\n            $response,\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/User/Admin/UserDeleteApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\User\\Admin;\n\nuse App\\Controller\\Api\\User\\UserBaseApi;\nuse App\\DTO\\UserResponseDto;\nuse App\\Entity\\User;\nuse App\\Factory\\UserFactory;\nuse App\\Service\\UserManager;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse Nelmio\\ApiDocBundle\\Attribute\\Security;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass UserDeleteApi extends UserBaseApi\n{\n    #[OA\\Response(\n        response: 200,\n        description: 'User deleted',\n        content: new Model(type: UserResponseDto::class),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'You are not authorized to delete this user',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'User not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'user_id',\n        in: 'path',\n        description: 'The user to delete',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Tag(name: 'admin/user')]\n    #[IsGranted('ROLE_ADMIN')]\n    #[Security(name: 'oauth2', scopes: ['admin:user:delete'])]\n    #[IsGranted('ROLE_OAUTH2_ADMIN:USER:DELETE')]\n    /**\n     * Marks the user for deletion in 30 days.\n     */\n    public function __invoke(\n        #[MapEntity(id: 'user_id')]\n        User $user,\n        UserManager $manager,\n        UserFactory $factory,\n        RateLimiterFactoryInterface $apiModerateLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiModerateLimiter);\n\n        $manager->deleteRequest($user, false);\n\n        return new JsonResponse(\n            $this->serializeUser($factory->createDto($user)),\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/User/Admin/UserPurgeApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\User\\Admin;\n\nuse App\\Controller\\Api\\User\\UserBaseApi;\nuse App\\Entity\\User;\nuse App\\Service\\UserManager;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse Nelmio\\ApiDocBundle\\Attribute\\Security;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass UserPurgeApi extends UserBaseApi\n{\n    #[OA\\Response(\n        response: 204,\n        description: 'User purged',\n        content: null,\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'You are not authorized to purge this user',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'User not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'user_id',\n        in: 'path',\n        description: 'The user to purge',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Tag(name: 'admin/user')]\n    #[IsGranted('ROLE_ADMIN')]\n    #[Security(name: 'oauth2', scopes: ['admin:user:purge'])]\n    #[IsGranted('ROLE_OAUTH2_ADMIN:USER:PURGE')]\n    /**\n     * Deletes the user from the instance completely.\n     * This action is irreversable.\n     */\n    public function __invoke(\n        #[MapEntity(id: 'user_id')]\n        User $user,\n        UserManager $manager,\n        RateLimiterFactoryInterface $apiModerateLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiModerateLimiter);\n\n        $manager->delete($user);\n\n        return new JsonResponse(\n            status: 204,\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/User/Admin/UserRetrieveBannedApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\User\\Admin;\n\nuse App\\Controller\\Api\\User\\UserBaseApi;\nuse App\\DTO\\UserBanResponseDto;\nuse App\\Entity\\User;\nuse App\\Factory\\UserFactory;\nuse App\\Repository\\UserRepository;\nuse App\\Schema\\PaginationSchema;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse Nelmio\\ApiDocBundle\\Attribute\\Security;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass UserRetrieveBannedApi extends UserBaseApi\n{\n    #[OA\\Response(\n        response: 200,\n        description: 'Returns a paginated list of banned users',\n        content: new OA\\JsonContent(\n            type: 'object',\n            properties: [\n                new OA\\Property(\n                    property: 'items',\n                    type: 'array',\n                    items: new OA\\Items(ref: new Model(type: UserBanResponseDto::class))\n                ),\n                new OA\\Property(\n                    property: 'pagination',\n                    ref: new Model(type: PaginationSchema::class)\n                ),\n            ]\n        ),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'You are not permitted to view the list of banned users',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'p',\n        description: 'Page of users to retrieve',\n        in: 'query',\n        schema: new OA\\Schema(type: 'integer', default: 1, minimum: 1)\n    )]\n    #[OA\\Parameter(\n        name: 'perPage',\n        description: 'Number of users per page',\n        in: 'query',\n        schema: new OA\\Schema(type: 'integer', default: UserRepository::PER_PAGE, minimum: self::MIN_PER_PAGE, maximum: self::MAX_PER_PAGE)\n    )]\n    #[OA\\Parameter(\n        name: 'group',\n        description: 'What group of users to retrieve',\n        in: 'query',\n        schema: new OA\\Schema(type: 'string', default: UserRepository::USERS_ALL, enum: UserRepository::USERS_OPTIONS)\n    )]\n    #[OA\\Tag(name: 'admin/user')]\n    #[IsGranted('ROLE_ADMIN')]\n    #[Security(name: 'oauth2', scopes: ['admin:user:ban'])]\n    #[IsGranted('ROLE_OAUTH2_ADMIN:USER:BAN')]\n    /** Retrieves a list of users currently banned from the instance */\n    public function collection(\n        UserRepository $userRepository,\n        UserFactory $factory,\n        RateLimiterFactoryInterface $apiModerateLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiModerateLimiter);\n\n        $request = $this->request->getCurrentRequest();\n        $group = $request->get('group', UserRepository::USERS_ALL);\n\n        $users = $userRepository->findBannedPaginated(\n            $this->getPageNb($request),\n            $group,\n            $this->constrainPerPage($request->get('perPage', UserRepository::PER_PAGE))\n        );\n\n        $dtos = [];\n        foreach ($users->getCurrentPageResults() as $value) {\n            \\assert($value instanceof User);\n            array_push($dtos, new UserBanResponseDto($factory->createDto($value), $value->isBanned));\n        }\n\n        return new JsonResponse(\n            $this->serializePaginated($dtos, $users),\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/User/Admin/UserVerifyApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\User\\Admin;\n\nuse App\\Controller\\Api\\User\\UserBaseApi;\nuse App\\DTO\\UserResponseDto;\nuse App\\Entity\\User;\nuse App\\Factory\\UserFactory;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse Nelmio\\ApiDocBundle\\Attribute\\Security;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass UserVerifyApi extends UserBaseApi\n{\n    #[OA\\Response(\n        response: 200,\n        description: 'User verified',\n        content: new Model(type: UserResponseDto::class),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'You are not authorized to verify this user',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'User not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'user_id',\n        in: 'path',\n        description: 'The user to verify',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Tag(name: 'admin/user')]\n    #[IsGranted('ROLE_ADMIN')]\n    #[Security(name: 'oauth2', scopes: ['admin:user:verify'])]\n    #[IsGranted('ROLE_OAUTH2_ADMIN:USER:VERIFY')]\n    /** Forcibly verifies a user on the instance, with no regard for the email confirmation */\n    public function __invoke(\n        #[MapEntity(id: 'user_id')]\n        User $user,\n        EntityManagerInterface $manager,\n        UserFactory $factory,\n        RateLimiterFactoryInterface $apiModerateLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiModerateLimiter);\n\n        $user->isVerified = true;\n\n        $manager->persist($user);\n        $manager->flush();\n\n        // Response needs to be an array to insert isVerified\n        $response = $this->serializeUser($factory->createDto($user))->jsonSerialize();\n        $response['isVerified'] = $user->isVerified;\n\n        return new JsonResponse(\n            $response,\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/User/UserBaseApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\User;\n\nuse App\\Controller\\Api\\BaseApi;\nuse App\\DTO\\UserSettingsDto;\n\nclass UserBaseApi extends BaseApi\n{\n    /**\n     * Deserialize a user's settings from JSON.\n     *\n     * @param UserSettingsDto $dto The UserSettingsDto to modify with new values\n     *\n     * @return UserSettingsDto An user with only certain fields allowed to be modified by the user\n     */\n    protected function deserializeUserSettings(UserSettingsDto $dto): UserSettingsDto\n    {\n        $request = $this->request->getCurrentRequest();\n        $deserialized = $this->serializer->deserialize($request->getContent(), UserSettingsDto::class, 'json');\n        \\assert($deserialized instanceof UserSettingsDto);\n\n        $dto = $deserialized->mergeIntoDto($dto);\n\n        return $dto;\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/User/UserBlockApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\User;\n\nuse App\\DTO\\UserResponseDto;\nuse App\\Entity\\User;\nuse App\\Factory\\UserFactory;\nuse App\\Service\\UserManager;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse Nelmio\\ApiDocBundle\\Attribute\\Security;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpKernel\\Exception\\BadRequestHttpException;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass UserBlockApi extends UserBaseApi\n{\n    #[OA\\Response(\n        response: 200,\n        description: 'User blocked',\n        content: new Model(type: UserResponseDto::class),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 400,\n        description: 'You cannot block yourself',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\BadRequestErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'User not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'user_id',\n        in: 'path',\n        description: 'The user to block',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Tag(name: 'user')]\n    #[Security(name: 'oauth2', scopes: ['user:block'])]\n    #[IsGranted('ROLE_OAUTH2_USER:BLOCK')]\n    public function block(\n        #[MapEntity(id: 'user_id')]\n        User $user,\n        UserManager $manager,\n        UserFactory $factory,\n        RateLimiterFactoryInterface $apiUpdateLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiUpdateLimiter);\n\n        if ($user->getId() === $this->getUserOrThrow()->getId()) {\n            throw new BadRequestHttpException('You cannot block yourself');\n        }\n\n        $manager->block($this->getUserOrThrow(), $user);\n\n        return new JsonResponse(\n            $this->serializeUser($factory->createDto($user)),\n            headers: $headers\n        );\n    }\n\n    #[OA\\Response(\n        response: 200,\n        description: 'User unblocked',\n        content: new Model(type: UserResponseDto::class),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 400,\n        description: 'You cannot block yourself',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\BadRequestErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'User not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'user_id',\n        in: 'path',\n        description: 'The user to unblock',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Tag(name: 'user')]\n    #[Security(name: 'oauth2', scopes: ['user:block'])]\n    #[IsGranted('ROLE_OAUTH2_USER:BLOCK')]\n    public function unblock(\n        #[MapEntity(id: 'user_id')]\n        User $user,\n        UserManager $manager,\n        UserFactory $factory,\n        RateLimiterFactoryInterface $apiUpdateLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiUpdateLimiter);\n\n        if ($user->getId() === $this->getUserOrThrow()->getId()) {\n            throw new BadRequestHttpException('You cannot block yourself');\n        }\n\n        $manager->unblock($this->getUserOrThrow(), $user);\n\n        return new JsonResponse(\n            $this->serializeUser($factory->createDto($user)),\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/User/UserContentApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\User;\n\nuse App\\Controller\\Traits\\PrivateContentTrait;\nuse App\\DTO\\ExtendedContentResponseDto;\nuse App\\Entity\\Entry;\nuse App\\Entity\\EntryComment;\nuse App\\Entity\\Post;\nuse App\\Entity\\PostComment;\nuse App\\Entity\\User;\nuse App\\Repository\\SearchRepository;\nuse App\\Schema\\Errors\\TooManyRequestsErrorSchema;\nuse App\\Schema\\Errors\\UnauthorizedErrorSchema;\nuse App\\Schema\\PaginationSchema;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\HttpKernel\\Attribute\\MapQueryParameter;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\n\nclass UserContentApi extends UserBaseApi\n{\n    use PrivateContentTrait;\n\n    #[OA\\Response(\n        response: 200,\n        description: 'A paginated list of combined entries, posts, comments and replies boosted by the given user',\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\\Schema(type: 'integer')),\n        ],\n        content: new OA\\JsonContent(\n            properties: [\n                new OA\\Property(\n                    property: 'items',\n                    type: 'array',\n                    items: new OA\\Items(ref: new Model(type: ExtendedContentResponseDto::class))\n                ),\n                new OA\\Property(\n                    property: 'pagination',\n                    ref: new Model(type: PaginationSchema::class)\n                ),\n            ],\n            type: 'object'\n        )\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'user not found or you are not allowed to access them',\n        content: new OA\\JsonContent(ref: new Model(type: UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\\Schema(type: 'integer')),\n        ],\n        content: new OA\\JsonContent(ref: new Model(type: TooManyRequestsErrorSchema::class))\n    )]\n    #[OA\\Parameter(\n        name: 'p',\n        description: 'Page of content to retrieve',\n        in: 'query',\n        schema: new OA\\Schema(type: 'integer', default: 1, minimum: 1)\n    )]\n    #[OA\\Tag(name: 'user')]\n    public function getBoostedContent(\n        #[MapEntity(id: 'user_id')]\n        User $user,\n        #[MapQueryParameter]\n        ?int $p,\n        SearchRepository $repository,\n        RateLimiterFactoryInterface $apiReadLimiter,\n        RateLimiterFactoryInterface $anonymousApiReadLimiter,\n    ): Response {\n        $headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter);\n\n        $this->checkUserAccess($user);\n\n        $search = $repository->findBoosts($p ?? 1, $user);\n        $result = $this->serializeResults($search->getCurrentPageResults());\n\n        return new JsonResponse(\n            $this->serializePaginated($result, $search),\n            headers: $headers\n        );\n    }\n\n    #[OA\\Response(\n        response: 200,\n        description: 'A paginated list of combined entries, posts, comments and replies boosted by the given user',\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\\Schema(type: 'integer')),\n        ],\n        content: new OA\\JsonContent(\n            properties: [\n                new OA\\Property(\n                    property: 'items',\n                    type: 'array',\n                    items: new OA\\Items(ref: new Model(type: ExtendedContentResponseDto::class))\n                ),\n                new OA\\Property(\n                    property: 'pagination',\n                    ref: new Model(type: PaginationSchema::class)\n                ),\n            ],\n            type: 'object'\n        )\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'user not found or you are not allowed to access them',\n        content: new OA\\JsonContent(ref: new Model(type: UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\\Schema(type: 'integer')),\n        ],\n        content: new OA\\JsonContent(ref: new Model(type: TooManyRequestsErrorSchema::class))\n    )]\n    #[OA\\Parameter(\n        name: 'hideAdult',\n        description: 'If true exclude all adult content',\n        in: 'query',\n        schema: new OA\\Schema(type: 'boolean', default: false)\n    )]\n    #[OA\\Parameter(\n        name: 'p',\n        description: 'Page of content to retrieve',\n        in: 'query',\n        schema: new OA\\Schema(type: 'integer', default: 1, minimum: 1)\n    )]\n    #[OA\\Tag(name: 'user')]\n    public function getUserContent(\n        #[MapEntity(id: 'user_id')]\n        User $user,\n        #[MapQueryParameter(filter: \\FILTER_VALIDATE_BOOLEAN)]\n        ?bool $hideAdult,\n        #[MapQueryParameter]\n        ?int $p,\n        SearchRepository $repository,\n        RateLimiterFactoryInterface $apiReadLimiter,\n        RateLimiterFactoryInterface $anonymousApiReadLimiter,\n    ): Response {\n        $headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter);\n\n        $this->checkUserAccess($user);\n\n        $search = $repository->findUserPublicActivity($p ?? 1, $user, $hideAdult ?? false);\n        $result = $this->serializeResults($search->getCurrentPageResults());\n\n        return new JsonResponse(\n            $this->serializePaginated($result, $search),\n            headers: $headers\n        );\n    }\n\n    private function checkUserAccess(User $user)\n    {\n        $requestingUser = $this->getUser();\n        if ($user->isDeleted && (!$requestingUser || (!$requestingUser->isAdmin() && !$requestingUser->isModerator()) || null === $user->markedForDeletionAt)) {\n            throw $this->createNotFoundException();\n        }\n    }\n\n    private function serializeResults(array $results): array\n    {\n        $result = [];\n        foreach ($results as $item) {\n            try {\n                if ($item instanceof Entry) {\n                    $this->handlePrivateContent($item);\n                    $result[] = new ExtendedContentResponseDto(entry: $this->serializeEntry($this->entryFactory->createDto($item), $this->tagLinkRepository->getTagsOfContent($item)));\n                } elseif ($item instanceof Post) {\n                    $this->handlePrivateContent($item);\n                    $result[] = new ExtendedContentResponseDto(post: $this->serializePost($this->postFactory->createDto($item), $this->tagLinkRepository->getTagsOfContent($item)));\n                } elseif ($item instanceof EntryComment) {\n                    $this->handlePrivateContent($item);\n                    $result[] = new ExtendedContentResponseDto(entryComment: $this->serializeEntryComment($this->entryCommentFactory->createDto($item), $this->tagLinkRepository->getTagsOfContent($item)));\n                } elseif ($item instanceof PostComment) {\n                    $this->handlePrivateContent($item);\n                    $result[] = new ExtendedContentResponseDto(postComment: $this->serializePostComment($this->postCommentFactory->createDto($item), $this->tagLinkRepository->getTagsOfContent($item)));\n                }\n            } catch (\\Exception) {\n            }\n        }\n\n        return $result;\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/User/UserDeleteImagesApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\User;\n\nuse App\\DTO\\UserResponseDto;\nuse App\\Event\\User\\UserEditedEvent;\nuse App\\Factory\\UserFactory;\nuse App\\Service\\UserManager;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse Nelmio\\ApiDocBundle\\Attribute\\Security;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass UserDeleteImagesApi extends UserBaseApi\n{\n    #[OA\\Response(\n        response: 200,\n        description: 'User avatar deleted',\n        content: new Model(type: UserResponseDto::class),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Tag(name: 'user')]\n    #[Security(name: 'oauth2', scopes: ['user:profile:edit'])]\n    #[IsGranted('ROLE_OAUTH2_USER:PROFILE:EDIT')]\n    public function avatar(\n        UserManager $manager,\n        UserFactory $factory,\n        RateLimiterFactoryInterface $apiImageLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiImageLimiter);\n\n        $user = $this->getUserOrThrow();\n        $manager->detachAvatar($user);\n        /*\n         * Call edit so the @see UserEditedEvent is triggered and the changes are federated\n         */\n        $manager->edit($user, $manager->createDto($user));\n\n        return new JsonResponse(\n            $this->serializeUser($factory->createDto($this->getUserOrThrow())),\n            headers: $headers\n        );\n    }\n\n    #[OA\\Response(\n        response: 200,\n        description: 'User cover deleted',\n        content: new Model(type: UserResponseDto::class),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Tag(name: 'user')]\n    #[Security(name: 'oauth2', scopes: ['user:profile:edit'])]\n    #[IsGranted('ROLE_OAUTH2_USER:PROFILE:EDIT')]\n    public function cover(\n        UserManager $manager,\n        UserFactory $factory,\n        RateLimiterFactoryInterface $apiImageLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiImageLimiter);\n\n        $user = $this->getUserOrThrow();\n        $manager->detachCover($user);\n        /*\n         * Call edit so the @see UserEditedEvent is triggered and the changes are federated\n         */\n        $manager->edit($user, $manager->createDto($user));\n\n        return new JsonResponse(\n            $this->serializeUser($factory->createDto($this->getUserOrThrow())),\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/User/UserFilterListApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\User;\n\nuse App\\DTO\\UserFilterListDto;\nuse App\\DTO\\UserFilterListResponseDto;\nuse App\\Entity\\UserFilterList;\nuse App\\Schema\\Errors\\TooManyRequestsErrorSchema;\nuse App\\Schema\\Errors\\UnauthorizedErrorSchema;\nuse App\\Security\\Voter\\FilterListVoter;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse Nelmio\\ApiDocBundle\\Attribute\\Security;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpKernel\\Attribute\\MapRequestPayload;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass UserFilterListApi extends UserBaseApi\n{\n    #[OA\\Response(\n        response: 200,\n        description: 'The List',\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\\Schema(type: 'integer')),\n        ],\n        content: new OA\\JsonContent(\n            properties: [\n                new OA\\Property(\n                    property: 'items',\n                    type: 'array',\n                    items: new OA\\Items(ref: new Model(type: UserFilterListResponseDto::class))\n                ),\n            ]\n        )\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\\Schema(type: 'integer')),\n        ],\n        content: new OA\\JsonContent(ref: new Model(type: TooManyRequestsErrorSchema::class))\n    )]\n    #[OA\\Tag(name: 'user')]\n    #[Security(name: 'oauth2', scopes: ['user:profile:edit'])]\n    #[IsGranted('ROLE_OAUTH2_USER:PROFILE:EDIT')]\n    public function retrieve(): JsonResponse\n    {\n        $user = $this->getUserOrThrow();\n        $items = [];\n        foreach ($user->filterLists as $list) {\n            $items[] = $this->serializeFilterList($list);\n        }\n\n        return new JsonResponse([\n            'items' => $items,\n        ]);\n    }\n\n    #[OA\\Response(\n        response: 200,\n        description: 'Filter list created',\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\\Schema(type: 'integer')),\n        ],\n        content: new Model(type: UserFilterListResponseDto::class)\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\\Schema(type: 'integer')),\n        ],\n        content: new OA\\JsonContent(ref: new Model(type: TooManyRequestsErrorSchema::class))\n    )]\n    #[OA\\RequestBody(content: new Model(type: UserFilterListDto::class))]\n    #[OA\\Tag(name: 'user')]\n    #[Security(name: 'oauth2', scopes: ['user:profile:edit'])]\n    #[IsGranted('ROLE_OAUTH2_USER:PROFILE:EDIT')]\n    public function create(\n        #[MapRequestPayload] UserFilterListDto $dto,\n        RateLimiterFactoryInterface $apiUpdateLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiUpdateLimiter);\n\n        $user = $this->getUserOrThrow();\n        $list = new UserFilterList();\n        $list->name = $dto->name;\n        $list->expirationDate = $dto->expirationDate;\n        $list->feeds = $dto->feeds;\n        $list->comments = $dto->comments;\n        $list->profile = $dto->profile;\n        $list->user = $user;\n        $list->words = $dto->wordsToArray();\n        $this->entityManager->persist($list);\n        $this->entityManager->flush();\n\n        $freshList = $this->entityManager->getRepository(UserFilterList::class)->find($list->getId());\n\n        return new JsonResponse(\n            $this->serializeFilterList($freshList),\n            headers: $headers\n        );\n    }\n\n    #[OA\\Response(\n        response: 200,\n        description: 'Filter list updated',\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\\Schema(type: 'integer')),\n        ],\n        content: new Model(type: UserFilterListResponseDto::class)\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\\Schema(type: 'integer')),\n        ],\n        content: new OA\\JsonContent(ref: new Model(type: TooManyRequestsErrorSchema::class))\n    )]\n    #[OA\\RequestBody(content: new Model(type: UserFilterListDto::class))]\n    #[OA\\Tag(name: 'user')]\n    #[Security(name: 'oauth2', scopes: ['user:profile:edit'])]\n    #[IsGranted('ROLE_OAUTH2_USER:PROFILE:EDIT')]\n    #[IsGranted(FilterListVoter::EDIT, 'list')]\n    public function edit(\n        RateLimiterFactoryInterface $apiUpdateLimiter,\n        #[MapEntity] UserFilterList $list,\n        #[MapRequestPayload] UserFilterListDto $dto,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiUpdateLimiter);\n        $list->name = $dto->name;\n        $list->expirationDate = $dto->expirationDate;\n        $list->feeds = $dto->feeds;\n        $list->comments = $dto->comments;\n        $list->profile = $dto->profile;\n        $list->words = $dto->wordsToArray();\n\n        $this->entityManager->persist($list);\n        $this->entityManager->flush();\n        $freshList = $this->entityManager->getRepository(UserFilterList::class)->find($list->getId());\n\n        return new JsonResponse(\n            $this->serializeFilterList($freshList),\n            headers: $headers\n        );\n    }\n\n    #[OA\\Response(\n        response: 204,\n        description: 'Filter list deleted',\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\\Schema(type: 'integer')),\n        ],\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\\Schema(type: 'integer')),\n        ],\n        content: new OA\\JsonContent(ref: new Model(type: TooManyRequestsErrorSchema::class))\n    )]\n    #[OA\\Tag(name: 'user')]\n    #[Security(name: 'oauth2', scopes: ['user:profile:edit'])]\n    #[IsGranted('ROLE_OAUTH2_USER:PROFILE:EDIT')]\n    #[IsGranted(FilterListVoter::DELETE, 'list')]\n    public function delete(\n        RateLimiterFactoryInterface $apiDeleteLimiter,\n        #[MapEntity(class: UserFilterList::class)] UserFilterList $list,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiDeleteLimiter);\n\n        $this->entityManager->remove($list);\n        $this->entityManager->flush();\n\n        return new JsonResponse(\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/User/UserFollowApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\User;\n\nuse App\\DTO\\UserResponseDto;\nuse App\\Entity\\User;\nuse App\\Factory\\UserFactory;\nuse App\\Service\\UserManager;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse Nelmio\\ApiDocBundle\\Attribute\\Security;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpKernel\\Exception\\BadRequestHttpException;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass UserFollowApi extends UserBaseApi\n{\n    #[OA\\Response(\n        response: 200,\n        description: 'User follow status updated',\n        content: new Model(type: UserResponseDto::class),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 400,\n        description: 'You cannot follow yourself',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\BadRequestErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'User not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'user_id',\n        in: 'path',\n        description: 'The user to follow',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Tag(name: 'user')]\n    #[Security(name: 'oauth2', scopes: ['user:follow'])]\n    #[IsGranted('ROLE_OAUTH2_USER:FOLLOW')]\n    #[IsGranted('follow', subject: 'user')]\n    public function follow(\n        #[MapEntity(id: 'user_id')]\n        User $user,\n        UserManager $manager,\n        UserFactory $factory,\n        RateLimiterFactoryInterface $apiUpdateLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiUpdateLimiter);\n\n        if ($user->getId() === $this->getUserOrThrow()->getId()) {\n            throw new BadRequestHttpException('You cannot follow yourself');\n        }\n\n        $manager->follow($this->getUserOrThrow(), $user);\n\n        return new JsonResponse(\n            $this->serializeUser($factory->createDto($user)),\n            headers: $headers\n        );\n    }\n\n    #[OA\\Response(\n        response: 200,\n        description: 'User follow status updated',\n        content: new Model(type: UserResponseDto::class),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 400,\n        description: 'You cannot follow yourself',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\BadRequestErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'User not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'user_id',\n        in: 'path',\n        description: 'The user to unfollow',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Tag(name: 'user')]\n    #[Security(name: 'oauth2', scopes: ['user:follow'])]\n    #[IsGranted('ROLE_OAUTH2_USER:FOLLOW')]\n    #[IsGranted('follow', subject: 'user')]\n    public function unfollow(\n        #[MapEntity(id: 'user_id')]\n        User $user,\n        UserManager $manager,\n        UserFactory $factory,\n        RateLimiterFactoryInterface $apiUpdateLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiUpdateLimiter);\n\n        if ($user->getId() === $this->getUserOrThrow()->getId()) {\n            throw new BadRequestHttpException('You cannot follow yourself');\n        }\n\n        $manager->unfollow($this->getUserOrThrow(), $user);\n\n        return new JsonResponse(\n            $this->serializeUser($factory->createDto($user)),\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/User/UserModeratesApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\User;\n\nuse App\\DTO\\MagazineSmallResponseDto;\nuse App\\Entity\\User;\nuse App\\Repository\\MagazineRepository;\nuse App\\Schema\\Errors\\TooManyRequestsErrorSchema;\nuse App\\Schema\\Errors\\UnauthorizedErrorSchema;\nuse App\\Schema\\PaginationSchema;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpKernel\\Attribute\\MapQueryParameter;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\n\nclass UserModeratesApi extends UserBaseApi\n{\n    #[OA\\Response(\n        response: 200,\n        description: 'A paginated list of magazines which are moderated by the given user',\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\\Schema(type: 'integer')),\n        ],\n        content: new OA\\JsonContent(\n            properties: [\n                new OA\\Property(\n                    property: 'items',\n                    type: 'array',\n                    items: new OA\\Items(ref: new Model(type: MagazineSmallResponseDto::class))\n                ),\n                new OA\\Property(\n                    property: 'pagination',\n                    ref: new Model(type: PaginationSchema::class)\n                ),\n            ],\n            type: 'object'\n        )\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'user not found or you are not allowed to access them',\n        content: new OA\\JsonContent(ref: new Model(type: UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\\Schema(type: 'integer')),\n        ],\n        content: new OA\\JsonContent(ref: new Model(type: TooManyRequestsErrorSchema::class))\n    )]\n    #[OA\\Parameter(\n        name: 'p',\n        description: 'Page of content to retrieve',\n        in: 'query',\n        schema: new OA\\Schema(type: 'integer', default: 1, minimum: 1)\n    )]\n    #[OA\\Tag(name: 'user')]\n    public function __invoke(\n        #[MapEntity(id: 'user_id')]\n        User $user,\n        #[MapQueryParameter]\n        ?int $p,\n        MagazineRepository $repository,\n        RateLimiterFactoryInterface $apiReadLimiter,\n        RateLimiterFactoryInterface $anonymousApiReadLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter);\n\n        $requestingUser = $this->getUser();\n        if ($user->isDeleted && (!$requestingUser || (!$requestingUser->isAdmin() && !$requestingUser->isModerator()) || null === $user->markedForDeletionAt)) {\n            throw $this->createNotFoundException();\n        }\n\n        $magazines = $repository->findModeratedMagazines($user, $p ?? 1);\n\n        $result = [];\n        foreach ($magazines as $magazine) {\n            $result[] = $this->magazineFactory->createSmallDto($magazine);\n        }\n\n        return new JsonResponse(\n            $this->serializePaginated($result, $magazines),\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/User/UserRetrieveApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\User;\n\nuse App\\Controller\\Traits\\PrivateContentTrait;\nuse App\\DTO\\UserResponseDto;\nuse App\\DTO\\UserSettingsDto;\nuse App\\Entity\\User;\nuse App\\Entity\\UserBlock;\nuse App\\Entity\\UserFollow;\nuse App\\Factory\\UserFactory;\nuse App\\Repository\\UserRepository;\nuse App\\Schema\\PaginationSchema;\nuse App\\Service\\UserSettingsManager;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse Nelmio\\ApiDocBundle\\Attribute\\Security;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpKernel\\Exception\\AccessDeniedHttpException;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass UserRetrieveApi extends UserBaseApi\n{\n    use PrivateContentTrait;\n\n    #[OA\\Response(\n        response: 200,\n        description: 'Returns the User',\n        content: new Model(type: UserResponseDto::class),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'User not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'user_id',\n        in: 'path',\n        description: 'The user to retrieve',\n        schema: new OA\\Schema(type: 'integer'),\n    )]\n    #[OA\\Tag(name: 'user')]\n    public function __invoke(\n        #[MapEntity(id: 'user_id')]\n        User $user,\n        UserFactory $factory,\n        RateLimiterFactoryInterface $apiReadLimiter,\n        RateLimiterFactoryInterface $anonymousApiReadLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter);\n\n        $dto = $factory->createDto($user);\n\n        return new JsonResponse(\n            $this->serializeUser($dto),\n            headers: $headers\n        );\n    }\n\n    #[OA\\Response(\n        response: 200,\n        description: 'Returns the user by username',\n        content: new Model(type: UserResponseDto::class),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'User not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'username',\n        in: 'path',\n        description: 'The user to retrieve',\n        schema: new OA\\Schema(type: 'string'),\n    )]\n    #[OA\\Tag(name: 'user')]\n    public function username(\n        #[MapEntity(mapping: ['username' => 'username'])]\n        User $user,\n        UserFactory $factory,\n        RateLimiterFactoryInterface $apiReadLimiter,\n        RateLimiterFactoryInterface $anonymousApiReadLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter);\n\n        $dto = $factory->createDto($user, $this->reputationRepository->getUserReputationTotal($user));\n\n        return new JsonResponse(\n            $this->serializeUser($dto),\n            headers: $headers\n        );\n    }\n\n    #[OA\\Response(\n        response: 200,\n        description: 'Returns the current user',\n        content: new Model(type: UserResponseDto::class),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Tag(name: 'user')]\n    #[Security(name: 'oauth2', scopes: ['user:profile:read'])]\n    #[IsGranted('ROLE_OAUTH2_USER:PROFILE:READ')]\n    public function me(\n        UserFactory $factory,\n        RateLimiterFactoryInterface $apiReadLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiReadLimiter);\n        $user = $this->getUserOrThrow();\n\n        $dto = $factory->createDto($user, $this->reputationRepository->getUserReputationTotal($user));\n\n        return new JsonResponse(\n            $this->serializeUser($dto),\n            headers: $headers\n        );\n    }\n\n    #[OA\\Response(\n        response: 200,\n        description: 'Returns the current user\\'s settings',\n        content: new Model(type: UserSettingsDto::class),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Tag(name: 'user')]\n    #[Security(name: 'oauth2', scopes: ['user:profile:read'])]\n    #[IsGranted('ROLE_OAUTH2_USER:PROFILE:READ')]\n    public function settings(\n        UserSettingsManager $manager,\n        RateLimiterFactoryInterface $apiReadLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiReadLimiter);\n\n        $dto = $manager->createDto($this->getUserOrThrow());\n\n        return new JsonResponse(\n            $dto,\n            headers: $headers\n        );\n    }\n\n    #[OA\\Response(\n        response: 200,\n        description: 'Returns a paginated list of users',\n        content: new OA\\JsonContent(\n            type: 'object',\n            properties: [\n                new OA\\Property(\n                    property: 'items',\n                    type: 'array',\n                    items: new OA\\Items(ref: new Model(type: UserResponseDto::class))\n                ),\n                new OA\\Property(\n                    property: 'pagination',\n                    ref: new Model(type: PaginationSchema::class)\n                ),\n            ]\n        ),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'p',\n        description: 'Page of users to retrieve',\n        in: 'query',\n        schema: new OA\\Schema(type: 'integer', default: 1, minimum: 1)\n    )]\n    #[OA\\Parameter(\n        name: 'perPage',\n        description: 'Number of users per page',\n        in: 'query',\n        schema: new OA\\Schema(type: 'integer', default: UserRepository::PER_PAGE, minimum: self::MIN_PER_PAGE, maximum: self::MAX_PER_PAGE)\n    )]\n    #[OA\\Parameter(\n        name: 'group',\n        description: 'What group of users to retrieve',\n        in: 'query',\n        schema: new OA\\Schema(type: 'string', default: UserRepository::USERS_ALL, enum: UserRepository::USERS_OPTIONS)\n    )]\n    #[OA\\Parameter(\n        name: 'q',\n        description: 'The term to search for',\n        in: 'query',\n        schema: new OA\\Schema(type: 'string')\n    )]\n    #[OA\\Parameter(\n        name: 'withAbout',\n        description: 'Only include users with a filled in profile',\n        in: 'query',\n        schema: new OA\\Schema(type: 'boolean')\n    )]\n    #[OA\\Tag(name: 'user')]\n    public function collection(\n        UserRepository $userRepository,\n        UserFactory $userFactory,\n        RateLimiterFactoryInterface $apiReadLimiter,\n        RateLimiterFactoryInterface $anonymousApiReadLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter);\n\n        $request = $this->request->getCurrentRequest();\n        $group = $request->get('group', UserRepository::USERS_ALL);\n        $withAboutRaw = $request->get('withAbout');\n        $withAbout = null === $withAboutRaw ? false : \\boolval($withAboutRaw);\n\n        $users = $userRepository->findPaginated(\n            $this->getPageNb($request),\n            $withAbout,\n            $group,\n            $this->constrainPerPage($request->get('perPage', UserRepository::PER_PAGE)),\n            $request->get('q'),\n        );\n\n        $dtos = [];\n        foreach ($users->getCurrentPageResults() as $value) {\n            \\assert($value instanceof User);\n            array_push($dtos, $this->serializeUser($userFactory->createDto($value)));\n        }\n\n        return new JsonResponse(\n            $this->serializePaginated($dtos, $users),\n            headers: $headers\n        );\n    }\n\n    #[OA\\Response(\n        response: 200,\n        description: 'Returns a paginated list of users being followed by given user',\n        content: new OA\\JsonContent(\n            type: 'object',\n            properties: [\n                new OA\\Property(\n                    property: 'items',\n                    type: 'array',\n                    items: new OA\\Items(ref: new Model(type: UserResponseDto::class))\n                ),\n                new OA\\Property(\n                    property: 'pagination',\n                    ref: new Model(type: PaginationSchema::class)\n                ),\n            ]\n        ),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'This user does not allow others to view the users they follow',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'user_id',\n        description: 'User from which to retrieve followed users',\n        in: 'path',\n        schema: new OA\\Schema(type: 'integer')\n    )]\n    #[OA\\Parameter(\n        name: 'p',\n        description: 'Page of users to retrieve',\n        in: 'query',\n        schema: new OA\\Schema(type: 'integer', default: 1, minimum: 1)\n    )]\n    #[OA\\Parameter(\n        name: 'perPage',\n        description: 'Number of users per page',\n        in: 'query',\n        schema: new OA\\Schema(\n            type: 'integer',\n            default: UserRepository::PER_PAGE,\n            minimum: self::MIN_PER_PAGE,\n            maximum: self::MAX_PER_PAGE\n        )\n    )]\n    #[OA\\Tag(name: 'user')]\n    #[Security(name: 'oauth2', scopes: ['user:follow'])]\n    #[IsGranted('ROLE_OAUTH2_USER:FOLLOW')]\n    public function followed(\n        #[MapEntity(id: 'user_id')]\n        User $user,\n        UserRepository $repository,\n        UserFactory $factory,\n        RateLimiterFactoryInterface $apiReadLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiReadLimiter);\n\n        if ($user->getId() !== $this->getUserOrThrow()->getId() && !$user->getShowProfileFollowings()) {\n            throw new AccessDeniedHttpException('You are not permitted to view the users followed by this user');\n        }\n\n        $request = $this->request->getCurrentRequest();\n        $users = $repository->findFollowing(\n            $this->getPageNb($request),\n            $user,\n            self::constrainPerPage($request->get('perPage', UserRepository::PER_PAGE))\n        );\n\n        $dtos = [];\n        foreach ($users->getCurrentPageResults() as $value) {\n            \\assert($value instanceof UserFollow);\n            array_push($dtos, $this->serializeUser($factory->createDto($value->following)));\n        }\n\n        return new JsonResponse(\n            $this->serializePaginated($dtos, $users),\n            headers: $headers\n        );\n    }\n\n    #[OA\\Response(\n        response: 200,\n        description: 'Returns a paginated list of users following the given user',\n        content: new OA\\JsonContent(\n            type: 'object',\n            properties: [\n                new OA\\Property(\n                    property: 'items',\n                    type: 'array',\n                    items: new OA\\Items(ref: new Model(type: UserResponseDto::class))\n                ),\n                new OA\\Property(\n                    property: 'pagination',\n                    ref: new Model(type: PaginationSchema::class)\n                ),\n            ]\n        ),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'user_id',\n        description: 'User from which to retrieve following users',\n        in: 'path',\n        schema: new OA\\Schema(type: 'integer')\n    )]\n    #[OA\\Parameter(\n        name: 'p',\n        description: 'Page of users to retrieve',\n        in: 'query',\n        schema: new OA\\Schema(type: 'integer', default: 1, minimum: 1)\n    )]\n    #[OA\\Parameter(\n        name: 'perPage',\n        description: 'Number of users per page',\n        in: 'query',\n        schema: new OA\\Schema(\n            type: 'integer',\n            default: UserRepository::PER_PAGE,\n            minimum: self::MIN_PER_PAGE,\n            maximum: self::MAX_PER_PAGE\n        )\n    )]\n    #[OA\\Tag(name: 'user')]\n    #[Security(name: 'oauth2', scopes: ['user:follow'])]\n    #[IsGranted('ROLE_OAUTH2_USER:FOLLOW')]\n    public function followers(\n        #[MapEntity(id: 'user_id')]\n        User $user,\n        UserRepository $repository,\n        UserFactory $factory,\n        RateLimiterFactoryInterface $apiReadLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiReadLimiter);\n\n        $request = $this->request->getCurrentRequest();\n        $users = $repository->findFollowers(\n            $this->getPageNb($request),\n            $user,\n            self::constrainPerPage($request->get('perPage', UserRepository::PER_PAGE))\n        );\n\n        $dtos = [];\n        foreach ($users->getCurrentPageResults() as $value) {\n            \\assert($value instanceof UserFollow);\n            array_push($dtos, $this->serializeUser($factory->createDto($value->follower)));\n        }\n\n        return new JsonResponse(\n            $this->serializePaginated($dtos, $users),\n            headers: $headers\n        );\n    }\n\n    #[OA\\Response(\n        response: 200,\n        description: 'Returns a paginated list of users being followed by the current user',\n        content: new OA\\JsonContent(\n            type: 'object',\n            properties: [\n                new OA\\Property(\n                    property: 'items',\n                    type: 'array',\n                    items: new OA\\Items(ref: new Model(type: UserResponseDto::class))\n                ),\n                new OA\\Property(\n                    property: 'pagination',\n                    ref: new Model(type: PaginationSchema::class)\n                ),\n            ]\n        ),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'This user does not allow others to view the users they follow',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'p',\n        description: 'Page of users to retrieve',\n        in: 'query',\n        schema: new OA\\Schema(type: 'integer', default: 1, minimum: 1)\n    )]\n    #[OA\\Parameter(\n        name: 'perPage',\n        description: 'Number of users per page',\n        in: 'query',\n        schema: new OA\\Schema(\n            type: 'integer',\n            default: UserRepository::PER_PAGE,\n            minimum: self::MIN_PER_PAGE,\n            maximum: self::MAX_PER_PAGE\n        )\n    )]\n    #[OA\\Tag(name: 'user')]\n    #[Security(name: 'oauth2', scopes: ['user:follow'])]\n    #[IsGranted('ROLE_OAUTH2_USER:FOLLOW')]\n    public function followedByCurrent(\n        UserRepository $repository,\n        UserFactory $factory,\n        RateLimiterFactoryInterface $apiReadLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiReadLimiter);\n\n        $request = $this->request->getCurrentRequest();\n        $users = $repository->findFollowing(\n            $this->getPageNb($request),\n            $this->getUserOrThrow(),\n            self::constrainPerPage($request->get('perPage', UserRepository::PER_PAGE))\n        );\n\n        $dtos = [];\n        foreach ($users->getCurrentPageResults() as $value) {\n            \\assert($value instanceof UserFollow);\n            array_push($dtos, $this->serializeUser($factory->createDto($value->following)));\n        }\n\n        return new JsonResponse(\n            $this->serializePaginated($dtos, $users),\n            headers: $headers\n        );\n    }\n\n    #[OA\\Response(\n        response: 200,\n        description: 'Returns a paginated list of users following the current user',\n        content: new OA\\JsonContent(\n            type: 'object',\n            properties: [\n                new OA\\Property(\n                    property: 'items',\n                    type: 'array',\n                    items: new OA\\Items(ref: new Model(type: UserResponseDto::class))\n                ),\n                new OA\\Property(\n                    property: 'pagination',\n                    ref: new Model(type: PaginationSchema::class)\n                ),\n            ]\n        ),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'p',\n        description: 'Page of users to retrieve',\n        in: 'query',\n        schema: new OA\\Schema(type: 'integer', default: 1, minimum: 1)\n    )]\n    #[OA\\Parameter(\n        name: 'perPage',\n        description: 'Number of users per page',\n        in: 'query',\n        schema: new OA\\Schema(\n            type: 'integer',\n            default: UserRepository::PER_PAGE,\n            minimum: self::MIN_PER_PAGE,\n            maximum: self::MAX_PER_PAGE\n        )\n    )]\n    #[OA\\Tag(name: 'user')]\n    #[Security(name: 'oauth2', scopes: ['user:follow'])]\n    #[IsGranted('ROLE_OAUTH2_USER:FOLLOW')]\n    public function followersOfCurrent(\n        UserRepository $repository,\n        UserFactory $factory,\n        RateLimiterFactoryInterface $apiReadLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiReadLimiter);\n\n        $request = $this->request->getCurrentRequest();\n        $users = $repository->findFollowers(\n            $this->getPageNb($request),\n            $this->getUserOrThrow(),\n            self::constrainPerPage($request->get('perPage', UserRepository::PER_PAGE))\n        );\n\n        $dtos = [];\n        foreach ($users->getCurrentPageResults() as $value) {\n            \\assert($value instanceof UserFollow);\n            array_push($dtos, $this->serializeUser($factory->createDto($value->follower)));\n        }\n\n        return new JsonResponse(\n            $this->serializePaginated($dtos, $users),\n            headers: $headers\n        );\n    }\n\n    #[OA\\Response(\n        response: 200,\n        description: 'Returns a paginated list of users blocked by the current user',\n        content: new OA\\JsonContent(\n            type: 'object',\n            properties: [\n                new OA\\Property(\n                    property: 'items',\n                    type: 'array',\n                    items: new OA\\Items(ref: new Model(type: UserResponseDto::class))\n                ),\n                new OA\\Property(\n                    property: 'pagination',\n                    ref: new Model(type: PaginationSchema::class)\n                ),\n            ]\n        ),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'p',\n        description: 'Page of users to retrieve',\n        in: 'query',\n        schema: new OA\\Schema(type: 'integer', default: 1, minimum: 1)\n    )]\n    #[OA\\Parameter(\n        name: 'perPage',\n        description: 'Number of users per page',\n        in: 'query',\n        schema: new OA\\Schema(type: 'integer', default: UserRepository::PER_PAGE, minimum: self::MIN_PER_PAGE, maximum: self::MAX_PER_PAGE)\n    )]\n    #[OA\\Tag(name: 'user')]\n    #[Security(name: 'oauth2', scopes: ['user:block'])]\n    #[IsGranted('ROLE_OAUTH2_USER:BLOCK')]\n    public function blocked(\n        UserRepository $repository,\n        UserFactory $factory,\n        RateLimiterFactoryInterface $apiReadLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiReadLimiter);\n\n        $request = $this->request->getCurrentRequest();\n        $users = $repository->findBlockedUsers(\n            $this->getPageNb($request),\n            $this->getUserOrThrow(),\n            self::constrainPerPage($request->get('perPage', UserRepository::PER_PAGE))\n        );\n\n        $dtos = [];\n        foreach ($users->getCurrentPageResults() as $value) {\n            \\assert($value instanceof UserBlock);\n            array_push($dtos, $this->serializeUser($factory->createDto($value->blocked)));\n        }\n\n        return new JsonResponse(\n            $this->serializePaginated($dtos, $users),\n            headers: $headers\n        );\n    }\n\n    #[OA\\Response(\n        response: 200,\n        description: 'Returns all instance admins',\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\\Schema(type: 'integer')),\n        ],\n        content: new OA\\JsonContent(\n            properties: [\n                new OA\\Property(property: 'items', type: 'array', items: new OA\\Items(ref: new Model(type: UserResponseDto::class))),\n                new OA\\Property(property: 'pagination', ref: new Model(type: PaginationSchema::class)),\n            ],\n            type: 'object'\n        )\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\\Schema(type: 'integer')),\n        ],\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class))\n    )]\n    #[OA\\Tag(name: 'instance')]\n    public function admins(\n        UserRepository $repository,\n        UserFactory $factory,\n        RateLimiterFactoryInterface $apiReadLimiter,\n        RateLimiterFactoryInterface $anonymousApiReadLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter);\n\n        $users = $repository->findAllAdmins();\n\n        $dtos = [];\n        foreach ($users as $value) {\n            \\assert($value instanceof User);\n            $dtos[] = $this->serializeUser($factory->createDto($value));\n        }\n\n        return new JsonResponse(['items' => $dtos], headers: $headers);\n    }\n\n    #[OA\\Response(\n        response: 200,\n        description: 'Returns all instance moderators',\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\\Schema(type: 'integer')),\n        ],\n        content: new OA\\JsonContent(\n            properties: [\n                new OA\\Property(property: 'items', type: 'array', items: new OA\\Items(ref: new Model(type: UserResponseDto::class))),\n                new OA\\Property(property: 'pagination', ref: new Model(type: PaginationSchema::class)),\n            ],\n            type: 'object'\n        )\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\\Schema(type: 'integer')),\n            new OA\\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\\Schema(type: 'integer')),\n        ],\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class))\n    )]\n    #[OA\\Tag(name: 'instance')]\n    public function moderators(\n        UserRepository $repository,\n        UserFactory $factory,\n        RateLimiterFactoryInterface $apiReadLimiter,\n        RateLimiterFactoryInterface $anonymousApiReadLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter);\n\n        $users = $repository->findAllModerators();\n\n        $dtos = [];\n        foreach ($users as $value) {\n            \\assert($value instanceof User);\n            $dtos[] = $this->serializeUser($factory->createDto($value));\n        }\n\n        return new JsonResponse(['items' => $dtos], headers: $headers);\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/User/UserRetrieveOAuthConsentsApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\User;\n\nuse App\\DTO\\ClientConsentsResponseDto;\nuse App\\Entity\\OAuth2UserConsent;\nuse App\\Factory\\ClientConsentsFactory;\nuse App\\Schema\\PaginationSchema;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse Nelmio\\ApiDocBundle\\Attribute\\Security;\nuse OpenApi\\Attributes as OA;\nuse Pagerfanta\\Doctrine\\Collections\\CollectionAdapter;\nuse Pagerfanta\\Exception\\NotValidCurrentPageException;\nuse Pagerfanta\\Pagerfanta;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpKernel\\Exception\\NotFoundHttpException;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass UserRetrieveOAuthConsentsApi extends UserBaseApi\n{\n    public const PER_PAGE = 15;\n\n    #[OA\\Response(\n        response: 200,\n        description: 'Returns the specific OAuth2 consent',\n        content: new Model(type: ClientConsentsResponseDto::class),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'You do not have permission to view this consent',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Consent not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'consent_id',\n        description: 'Client consent to retrieve',\n        in: 'path',\n        schema: new OA\\Schema(type: 'integer')\n    )]\n    #[OA\\Tag(name: 'oauth')]\n    #[Security(name: 'oauth2', scopes: ['user:oauth_clients:read'])]\n    #[IsGranted('ROLE_OAUTH2_USER:OAUTH_CLIENTS:READ')]\n    #[IsGranted('view', subject: 'consent')]\n    public function __invoke(\n        #[MapEntity(id: 'consent_id')]\n        OAuth2UserConsent $consent,\n        ClientConsentsFactory $factory,\n        RateLimiterFactoryInterface $apiReadLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiReadLimiter);\n\n        return new JsonResponse(\n            $factory->createDto($consent),\n            headers: $headers\n        );\n    }\n\n    #[OA\\Response(\n        response: 200,\n        description: 'Returns a paginated list of OAuth2 consents given to clients by the user',\n        content: new OA\\JsonContent(\n            type: 'object',\n            properties: [\n                new OA\\Property(\n                    property: 'items',\n                    type: 'array',\n                    items: new OA\\Items(ref: new Model(type: ClientConsentsResponseDto::class))\n                ),\n                new OA\\Property(\n                    property: 'pagination',\n                    ref: new Model(type: PaginationSchema::class)\n                ),\n            ]\n        ),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'You are not authorized to view this page',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Page not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'p',\n        description: 'Page of clients to retrieve',\n        in: 'query',\n        schema: new OA\\Schema(type: 'integer', default: 1, minimum: 1)\n    )]\n    #[OA\\Parameter(\n        name: 'perPage',\n        description: 'Number of clients to retrieve per page',\n        in: 'query',\n        schema: new OA\\Schema(type: 'integer', default: self::PER_PAGE, minimum: self::MIN_PER_PAGE, maximum: self::MAX_PER_PAGE)\n    )]\n    #[OA\\Tag(name: 'oauth')]\n    #[Security(name: 'oauth2', scopes: ['user:oauth_clients:read'])]\n    #[IsGranted('ROLE_OAUTH2_USER:OAUTH_CLIENTS:READ')]\n    public function collection(\n        ClientConsentsFactory $factory,\n        RateLimiterFactoryInterface $apiReadLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiReadLimiter);\n\n        $pagerfanta = new Pagerfanta(\n            new CollectionAdapter(\n                $this->getUserOrThrow()->getOAuth2UserConsents()\n            )\n        );\n\n        $request = $this->request->getCurrentRequest();\n        $page = $this->getPageNb($request);\n        $perPage = self::constrainPerPage($request->get('perPage', self::PER_PAGE));\n\n        try {\n            $pagerfanta->setMaxPerPage($perPage);\n            $pagerfanta->setCurrentPage($page);\n        } catch (NotValidCurrentPageException $e) {\n            throw new NotFoundHttpException();\n        }\n\n        $dtos = [];\n        foreach ($pagerfanta->getCurrentPageResults() as $consent) {\n            \\assert($consent instanceof OAuth2UserConsent);\n            array_push($dtos, $factory->createDto($consent));\n        }\n\n        return new JsonResponse(\n            $this->serializePaginated($dtos, $pagerfanta),\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/User/UserUpdateApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\User;\n\nuse App\\DTO\\UserProfileRequestDto;\nuse App\\DTO\\UserResponseDto;\nuse App\\DTO\\UserSettingsDto;\nuse App\\Factory\\UserFactory;\nuse App\\Service\\UserManager;\nuse App\\Service\\UserSettingsManager;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse Nelmio\\ApiDocBundle\\Attribute\\Security;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpKernel\\Exception\\BadRequestHttpException;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\nuse Symfony\\Component\\Validator\\Validator\\ValidatorInterface;\n\nclass UserUpdateApi extends UserBaseApi\n{\n    #[OA\\Response(\n        response: 200,\n        description: 'User updated',\n        content: new Model(type: UserResponseDto::class),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\RequestBody(content: new Model(type: UserProfileRequestDto::class))]\n    #[OA\\Tag(name: 'user')]\n    #[Security(name: 'oauth2', scopes: ['user:profile:edit'])]\n    #[IsGranted('ROLE_OAUTH2_USER:PROFILE:EDIT')]\n    public function profile(\n        UserManager $manager,\n        ValidatorInterface $validator,\n        UserFactory $factory,\n        RateLimiterFactoryInterface $apiUpdateLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiUpdateLimiter);\n\n        $request = $this->request->getCurrentRequest();\n        /** @var UserProfileRequestDto $dto */\n        $deserialized = $this->serializer->deserialize($request->getContent(), UserProfileRequestDto::class, 'json');\n\n        $errors = $validator->validate($deserialized);\n        if (\\count($errors) > 0) {\n            throw new BadRequestHttpException((string) $errors);\n        }\n\n        $dto = $manager->createDto($this->getUserOrThrow());\n\n        $dto->about = $deserialized->about;\n        $dto->title = $deserialized->title;\n\n        $user = $manager->edit($this->getUserOrThrow(), $dto);\n\n        return new JsonResponse(\n            $this->serializeUser($factory->createDto($user)),\n            headers: $headers\n        );\n    }\n\n    #[OA\\Response(\n        response: 200,\n        description: 'User settings updated',\n        content: new Model(type: UserSettingsDto::class),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\RequestBody(content: new Model(type: UserSettingsDto::class))]\n    #[OA\\Tag(name: 'user')]\n    #[Security(name: 'oauth2', scopes: ['user:profile:edit'])]\n    #[IsGranted('ROLE_OAUTH2_USER:PROFILE:EDIT')]\n    public function settings(\n        UserSettingsManager $manager,\n        ValidatorInterface $validator,\n        RateLimiterFactoryInterface $apiUpdateLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiUpdateLimiter);\n\n        $settings = $manager->createDto($this->getUserOrThrow());\n\n        $dto = $this->deserializeUserSettings($settings);\n\n        $errors = $validator->validate($dto);\n        if (\\count($errors) > 0) {\n            throw new BadRequestHttpException((string) $errors);\n        }\n\n        $manager->update($this->getUserOrThrow(), $dto);\n\n        return new JsonResponse(\n            $manager->createDto($this->getUserOrThrow()),\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/User/UserUpdateImagesApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\User;\n\nuse App\\DTO\\ImageUploadDto;\nuse App\\DTO\\UserResponseDto;\nuse App\\Factory\\UserFactory;\nuse App\\Service\\UserManager;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse Nelmio\\ApiDocBundle\\Attribute\\Security;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass UserUpdateImagesApi extends UserBaseApi\n{\n    #[OA\\Response(\n        response: 200,\n        description: 'User avatar updated',\n        content: new Model(type: UserResponseDto::class),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 400,\n        description: 'The uploaded image was missing or invalid',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\BadRequestErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'You are not authorized to update the user\\'s profile',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\RequestBody(content: new OA\\MediaType(\n        'multipart/form-data',\n        schema: new OA\\Schema(\n            ref: new Model(\n                type: ImageUploadDto::class,\n                groups: [\n                    ImageUploadDto::IMAGE_UPLOAD_NO_ALT,\n                ]\n            )\n        )\n    ))]\n    #[OA\\Tag(name: 'user')]\n    #[Security(name: 'oauth2', scopes: ['user:profile:edit'])]\n    #[IsGranted('ROLE_OAUTH2_USER:PROFILE:EDIT')]\n    public function avatar(\n        UserManager $manager,\n        UserFactory $factory,\n        RateLimiterFactoryInterface $apiImageLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiImageLimiter);\n\n        $image = $this->handleUploadedImage();\n\n        $dto = $manager->createDto($this->getUserOrThrow());\n\n        $dto->avatar = $image ? $this->imageFactory->createDto($image) : $dto->avatar;\n\n        $user = $manager->edit($this->getUserOrThrow(), $dto);\n\n        return new JsonResponse(\n            $this->serializeUser($factory->createDto($user)),\n            headers: $headers\n        );\n    }\n\n    #[OA\\Response(\n        response: 200,\n        description: 'User cover updated',\n        content: new Model(type: UserResponseDto::class),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 400,\n        description: 'The uploaded image was missing or invalid',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\BadRequestErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'You are not authorized to update the user\\'s profile',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\RequestBody(content: new OA\\MediaType(\n        'multipart/form-data',\n        schema: new OA\\Schema(\n            ref: new Model(\n                type: ImageUploadDto::class,\n                groups: [\n                    ImageUploadDto::IMAGE_UPLOAD_NO_ALT,\n                ]\n            )\n        )\n    ))]\n    #[OA\\Tag(name: 'user')]\n    #[Security(name: 'oauth2', scopes: ['user:profile:edit'])]\n    #[IsGranted('ROLE_OAUTH2_USER:PROFILE:EDIT')]\n    public function cover(\n        UserManager $manager,\n        UserFactory $factory,\n        RateLimiterFactoryInterface $apiImageLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiImageLimiter);\n\n        $image = $this->handleUploadedImage();\n\n        $dto = $manager->createDto($this->getUserOrThrow());\n\n        $dto->cover = $image ? $this->imageFactory->createDto($image) : $dto->cover;\n\n        $user = $manager->edit($this->getUserOrThrow(), $dto);\n\n        return new JsonResponse(\n            $this->serializeUser($factory->createDto($user)),\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Api/User/UserUpdateOAuthConsentsApi.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Api\\User;\n\nuse App\\DTO\\ClientConsentsRequestDto;\nuse App\\DTO\\ClientConsentsResponseDto;\nuse App\\Entity\\OAuth2UserConsent;\nuse App\\Factory\\ClientConsentsFactory;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse Nelmio\\ApiDocBundle\\Attribute\\Security;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpKernel\\Exception\\AccessDeniedHttpException;\nuse Symfony\\Component\\HttpKernel\\Exception\\BadRequestHttpException;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass UserUpdateOAuthConsentsApi extends UserBaseApi\n{\n    public const PER_PAGE = 15;\n\n    #[OA\\Response(\n        response: 200,\n        description: 'Updates the consent',\n        content: new Model(type: ClientConsentsResponseDto::class),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Response(\n        response: 400,\n        description: 'The request was invalid',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\BadRequestErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 401,\n        description: 'Permission denied due to missing or expired token',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\UnauthorizedErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 403,\n        description: 'Either you do not have permission to edit this consent, or you attempted to add additional consents not already granted',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\ForbiddenErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 404,\n        description: 'Consent not found',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\NotFoundErrorSchema::class))\n    )]\n    #[OA\\Response(\n        response: 429,\n        description: 'You are being rate limited',\n        content: new OA\\JsonContent(ref: new Model(type: \\App\\Schema\\Errors\\TooManyRequestsErrorSchema::class)),\n        headers: [\n            new OA\\Header(header: 'X-RateLimit-Remaining', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'),\n            new OA\\Header(header: 'X-RateLimit-Retry-After', schema: new OA\\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'),\n            new OA\\Header(header: 'X-RateLimit-Limit', schema: new OA\\Schema(type: 'integer'), description: 'Number of requests available'),\n        ]\n    )]\n    #[OA\\Parameter(\n        name: 'consent_id',\n        description: 'Client consent to update',\n        in: 'path',\n        schema: new OA\\Schema(type: 'integer')\n    )]\n    #[OA\\RequestBody(content: new Model(type: ClientConsentsRequestDto::class))]\n    #[OA\\Tag(name: 'oauth')]\n    #[Security(name: 'oauth2', scopes: ['user:oauth_clients:edit'])]\n    #[IsGranted('ROLE_OAUTH2_USER:OAUTH_CLIENTS:EDIT')]\n    #[IsGranted('edit', subject: 'consent')]\n    /**\n     * This API can be used to remove scopes from an oauth client.\n     *\n     * The API cannot, however, add extra scopes the user has not consented to. That's what the OAuth flow is for ;)\n     * This endpoint will not revoke any tokens that currently exist with the given scopes, those tokens will need to be revoked elsewhere.\n     */\n    public function __invoke(\n        #[MapEntity(id: 'consent_id')]\n        OAuth2UserConsent $consent,\n        ClientConsentsFactory $factory,\n        RateLimiterFactoryInterface $apiReadLimiter,\n    ): JsonResponse {\n        $headers = $this->rateLimit($apiReadLimiter);\n\n        $request = $this->request->getCurrentRequest();\n        /** @var ClientConsentsRequestDto $dto */\n        $dto = $this->serializer->deserialize($request->getContent(), ClientConsentsRequestDto::class, 'json');\n\n        $errors = $this->validator->validate($dto);\n        if (0 < \\count($errors)) {\n            throw new BadRequestHttpException((string) $errors);\n        }\n\n        if (array_intersect($dto->scopes, $consent->getScopes()) !== $dto->scopes) {\n            // $dto->scopesGranted is not a subset of the current scopes\n            // The client is attempting to request more scopes than it currently has\n            throw new AccessDeniedHttpException('An API client cannot add scopes with this API, only remove them.');\n        }\n\n        $consent->setScopes($dto->scopes);\n        $this->entityManager->flush();\n\n        return new JsonResponse(\n            $factory->createDto($consent),\n            headers: $headers\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/BookmarkController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller;\n\nuse App\\Entity\\BookmarkList;\nuse App\\Repository\\BookmarkRepository;\nuse App\\Service\\BookmarkManager;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\HttpKernel\\Exception\\AccessDeniedHttpException;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass BookmarkController extends AbstractController\n{\n    public function __construct(\n        private readonly BookmarkManager $bookmarkManager,\n        private readonly BookmarkRepository $bookmarkRepository,\n        private readonly EntityManagerInterface $entityManager,\n    ) {\n    }\n\n    #[IsGranted('ROLE_USER')]\n    public function subjectBookmarkStandard(int $subject_id, string $subject_type, Request $request): Response\n    {\n        $subjectClass = BookmarkManager::GetClassFromSubjectType($subject_type);\n        $subjectEntity = $this->entityManager->getRepository($subjectClass)->findOneBy(['id' => $subject_id]);\n        $this->bookmarkManager->addBookmarkToDefaultList($this->getUserOrThrow(), $subjectEntity);\n        if ($request->isXmlHttpRequest()) {\n            return new JsonResponse([\n                'html' => $this->renderView('components/_ajax.html.twig', [\n                    'component' => 'bookmark_standard',\n                    'attributes' => [\n                        'subject' => $subjectEntity,\n                        'subjectClass' => $subjectClass,\n                    ],\n                ]\n                ),\n            ]);\n        }\n\n        return $this->redirect($request->headers->get('Referer'));\n    }\n\n    #[IsGranted('ROLE_USER')]\n    public function subjectBookmarkRefresh(int $subject_id, string $subject_type, Request $request): Response\n    {\n        $subjectClass = BookmarkManager::GetClassFromSubjectType($subject_type);\n        $subjectEntity = $this->entityManager->getRepository($subjectClass)->findOneBy(['id' => $subject_id]);\n        if ($request->isXmlHttpRequest()) {\n            return new JsonResponse([\n                'html' => $this->renderView('components/_ajax.html.twig', [\n                    'component' => 'bookmark_standard',\n                    'attributes' => [\n                        'subject' => $subjectEntity,\n                        'subjectClass' => $subjectClass,\n                    ],\n                ]\n                ),\n            ]);\n        }\n\n        return $this->redirect($request->headers->get('Referer'));\n    }\n\n    #[IsGranted('ROLE_USER')]\n    public function subjectBookmarkToList(int $subject_id, string $subject_type, #[MapEntity] BookmarkList $list, Request $request): Response\n    {\n        $subjectClass = BookmarkManager::GetClassFromSubjectType($subject_type);\n        $subjectEntity = $this->entityManager->getRepository($subjectClass)->findOneBy(['id' => $subject_id]);\n        $user = $this->getUserOrThrow();\n        if ($user->getId() !== $list->user->getId()) {\n            throw new AccessDeniedHttpException();\n        }\n        $this->bookmarkManager->addBookmark($user, $list, $subjectEntity);\n        if ($request->isXmlHttpRequest()) {\n            return new JsonResponse([\n                'html' => $this->renderView('components/_ajax.html.twig', [\n                    'component' => 'bookmark_list',\n                    'attributes' => [\n                        'subject' => $subjectEntity,\n                        'subjectClass' => $subjectClass,\n                        'list' => $list,\n                    ],\n                ]\n                ),\n            ]);\n        }\n\n        return $this->redirect($request->headers->get('Referer'));\n    }\n\n    #[IsGranted('ROLE_USER')]\n    public function subjectRemoveBookmarks(int $subject_id, string $subject_type, Request $request): Response\n    {\n        $subjectClass = BookmarkManager::GetClassFromSubjectType($subject_type);\n        $subjectEntity = $this->entityManager->getRepository($subjectClass)->findOneBy(['id' => $subject_id]);\n        $this->bookmarkRepository->removeAllBookmarksForContent($this->getUserOrThrow(), $subjectEntity);\n        if ($request->isXmlHttpRequest()) {\n            return new JsonResponse([\n                'html' => $this->renderView('components/_ajax.html.twig', [\n                    'component' => 'bookmark_standard',\n                    'attributes' => [\n                        'subject' => $subjectEntity,\n                        'subjectClass' => $subjectClass,\n                    ],\n                ]\n                ),\n            ]);\n        }\n\n        return $this->redirect($request->headers->get('Referer'));\n    }\n\n    #[IsGranted('ROLE_USER')]\n    public function subjectRemoveBookmarkFromList(int $subject_id, string $subject_type, #[MapEntity] BookmarkList $list, Request $request): Response\n    {\n        $subjectClass = BookmarkManager::GetClassFromSubjectType($subject_type);\n        $subjectEntity = $this->entityManager->getRepository($subjectClass)->findOneBy(['id' => $subject_id]);\n        $user = $this->getUserOrThrow();\n        if ($user->getId() !== $list->user->getId()) {\n            throw new AccessDeniedHttpException();\n        }\n        $this->bookmarkRepository->removeBookmarkFromList($user, $list, $subjectEntity);\n        if ($request->isXmlHttpRequest()) {\n            return new JsonResponse([\n                'html' => $this->renderView('components/_ajax.html.twig', [\n                    'component' => 'bookmark_list',\n                    'attributes' => [\n                        'subject' => $subjectEntity,\n                        'subjectClass' => $subjectClass,\n                        'list' => $list,\n                    ],\n                ]\n                ),\n            ]);\n        }\n\n        return $this->redirect($request->headers->get('Referer'));\n    }\n}\n"
  },
  {
    "path": "src/Controller/BookmarkListController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller;\n\nuse App\\DTO\\BookmarkListDto;\nuse App\\Entity\\BookmarkList;\nuse App\\Form\\BookmarkListType;\nuse App\\PageView\\EntryPageView;\nuse App\\Repository\\BookmarkListRepository;\nuse App\\Repository\\BookmarkRepository;\nuse App\\Repository\\Criteria;\nuse App\\Service\\BookmarkManager;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Psr\\Log\\LoggerInterface;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Bundle\\SecurityBundle\\Security;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\HttpKernel\\Attribute\\MapQueryParameter;\nuse Symfony\\Component\\HttpKernel\\Exception\\AccessDeniedHttpException;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass BookmarkListController extends AbstractController\n{\n    public function __construct(\n        private readonly BookmarkListRepository $bookmarkListRepository,\n        private readonly BookmarkRepository $bookmarkRepository,\n        private readonly BookmarkManager $bookmarkManager,\n        private readonly LoggerInterface $logger,\n        private readonly EntityManagerInterface $entityManager,\n        private readonly Security $security,\n    ) {\n    }\n\n    #[IsGranted('ROLE_USER')]\n    public function front(\n        ?string $list,\n        ?string $sortBy,\n        ?string $time,\n        string $federation,\n        #[MapQueryParameter] ?string $type,\n        Request $request,\n    ): Response {\n        $page = $this->getPageNb($request);\n        $user = $this->getUserOrThrow();\n        $criteria = new EntryPageView($page, $this->security);\n        $criteria->setTime($criteria->resolveTime($time));\n        $criteria->setType($criteria->resolveType($type));\n        $criteria->showSortOption($criteria->resolveSort($sortBy ?? Criteria::SORT_NEW));\n        $criteria->setFederation($federation);\n\n        if (null !== $list) {\n            $bookmarkList = $this->bookmarkListRepository->findOneByUserAndName($user, $list);\n        } else {\n            $bookmarkList = $this->bookmarkListRepository->findOneByUserDefault($user);\n        }\n        $res = $this->bookmarkRepository->findPopulatedByList($bookmarkList, $criteria);\n        $objects = $res->getCurrentPageResults();\n        $lists = $this->bookmarkListRepository->findByUser($user);\n\n        $this->logger->info('got results in list {l}: {r}', ['l' => $list, 'r' => $objects]);\n\n        if ($request->isXmlHttpRequest()) {\n            return new JsonResponse([\n                'html' => $this->renderView('layout/_subject_list.html.twig', [\n                    'results' => $objects,\n                    'pagination' => $res,\n                ]),\n            ]);\n        }\n\n        return $this->render(\n            'bookmark/front.html.twig',\n            [\n                'criteria' => $criteria,\n                'list' => $bookmarkList,\n                'lists' => $lists,\n                'results' => $objects,\n                'pagination' => $res,\n            ]\n        );\n    }\n\n    #[IsGranted('ROLE_USER')]\n    public function list(Request $request): Response\n    {\n        $user = $this->getUserOrThrow();\n        $dto = new BookmarkListDto();\n        $form = $this->createForm(BookmarkListType::class, $dto);\n        $form->handleRequest($request);\n        if ($form->isSubmitted() && $form->isValid()) {\n            /** @var BookmarkListDto $dto */\n            $dto = $form->getData();\n            $list = $this->bookmarkManager->createList($user, $dto->name);\n            if ($dto->isDefault) {\n                $this->bookmarkListRepository->makeListDefault($user, $list);\n            }\n\n            return $this->redirectToRoute('bookmark_lists');\n        }\n\n        return $this->render('bookmark/overview.html.twig', [\n            'lists' => $this->bookmarkListRepository->findByUser($user),\n            'form' => $form->createView(),\n        ],\n            new Response(null, $form->isSubmitted() && !$form->isValid() ? 422 : 200)\n        );\n    }\n\n    #[IsGranted('ROLE_USER')]\n    public function subjectBookmarkMenuListRefresh(int $subject_id, string $subject_type, Request $request): Response\n    {\n        $user = $this->getUserOrThrow();\n        $bookmarkLists = $this->bookmarkListRepository->findByUser($user);\n        $subjectClass = BookmarkManager::GetClassFromSubjectType($subject_type);\n        $subjectEntity = $this->entityManager->getRepository($subjectClass)->findOneBy(['id' => $subject_id]);\n        if ($request->isXmlHttpRequest()) {\n            return new JsonResponse([\n                'html' => $this->renderView('components/_ajax.html.twig', [\n                    'component' => 'bookmark_menu_list',\n                    'attributes' => [\n                        'subject' => $subjectEntity,\n                        'subjectClass' => $subjectClass,\n                        'bookmarkLists' => $bookmarkLists,\n                    ],\n                ]\n                ),\n            ]);\n        }\n\n        return $this->redirect($request->headers->get('Referer'));\n    }\n\n    #[IsGranted('ROLE_USER')]\n    public function makeDefault(#[MapQueryParameter] ?int $makeDefault): Response\n    {\n        $user = $this->getUserOrThrow();\n        $this->logger->info('making list id {id} default for user {u}', ['user' => $user->username, 'id' => $makeDefault]);\n        if (null !== $makeDefault) {\n            $list = $this->bookmarkListRepository->findOneBy(['id' => $makeDefault]);\n            $this->bookmarkListRepository->makeListDefault($user, $list);\n        }\n\n        return $this->redirectToRoute('bookmark_lists');\n    }\n\n    #[IsGranted('ROLE_USER')]\n    public function editList(#[MapEntity] BookmarkList $list, Request $request): Response\n    {\n        $user = $this->getUserOrThrow();\n        $dto = BookmarkListDto::fromList($list);\n        $form = $this->createForm(BookmarkListType::class, $dto);\n        $form->handleRequest($request);\n        if ($form->isSubmitted() && $form->isValid()) {\n            $dto = $form->getData();\n            $this->bookmarkListRepository->editList($user, $list, $dto);\n\n            return $this->redirectToRoute('bookmark_lists');\n        }\n\n        return $this->render('bookmark/edit.html.twig', [\n            'list' => $list,\n            'form' => $form->createView(),\n        ]);\n    }\n\n    #[IsGranted('ROLE_USER')]\n    public function deleteList(#[MapEntity] BookmarkList $list): Response\n    {\n        $user = $this->getUserOrThrow();\n        if ($user->getId() !== $list->user->getId()) {\n            $this->logger->error('user {u} tried to delete a list that is not his own: {l}', ['u' => $user->username, 'l' => \"$list->name ({$list->getId()})\"]);\n            throw new AccessDeniedHttpException();\n        }\n        $this->bookmarkListRepository->deleteList($list);\n\n        return $this->redirectToRoute('bookmark_lists');\n    }\n}\n"
  },
  {
    "path": "src/Controller/BoostController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller;\n\nuse App\\Entity\\Contracts\\VotableInterface;\nuse App\\Service\\GenerateHtmlClassService;\nuse App\\Service\\VoteManager;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass BoostController extends AbstractController\n{\n    public function __construct(\n        private readonly GenerateHtmlClassService $classService,\n        private readonly VoteManager $manager,\n    ) {\n    }\n\n    #[IsGranted('ROLE_USER')]\n    public function __invoke(VotableInterface $subject, Request $request): Response\n    {\n        $this->manager->vote(VotableInterface::VOTE_UP, $subject, $this->getUserOrThrow());\n\n        if ($request->isXmlHttpRequest()) {\n            return new JsonResponse(\n                [\n                    'html' => $this->renderView(\n                        'components/_ajax.html.twig',\n                        [\n                            'component' => 'boost',\n                            'attributes' => [\n                                'subject' => $subject,\n                                'path' => $request->attributes->get('_route'),\n                            ],\n                        ]\n                    ),\n                ]\n            );\n        }\n\n        return $this->redirectToRefererOrHome($request, $this->classService->fromEntity($subject));\n    }\n}\n"
  },
  {
    "path": "src/Controller/ContactController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller;\n\nuse App\\DTO\\ContactDto;\nuse App\\Form\\ContactType;\nuse App\\Repository\\SiteRepository;\nuse App\\Service\\ContactManager;\nuse App\\Service\\IpResolver;\nuse Psr\\Log\\LoggerInterface;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\n\nclass ContactController extends AbstractController\n{\n    public function __construct(\n        private readonly LoggerInterface $logger,\n    ) {\n    }\n\n    public function __invoke(SiteRepository $repository, ContactManager $manager, IpResolver $ipResolver, Request $request): Response\n    {\n        $site = $repository->findAll();\n\n        $form = $this->createForm(ContactType::class, options: [\n            'antispam_profile' => 'default',\n        ]);\n        try {\n            $form->handleRequest($request);\n\n            if ($form->isSubmitted() && $form->isValid()) {\n                /**\n                 * @var ContactDto $dto\n                 */\n                $dto = $form->getData();\n                $dto->ip = $ipResolver->resolve();\n\n                if (!$dto->surname) {\n                    $manager->send($dto);\n                }\n\n                $this->addFlash('success', 'flash_email_was_sent');\n\n                return $this->redirectToRefererOrHome($request);\n            }\n        } catch (\\Exception $e) {\n            // Show an error to the user\n            $this->addFlash('error', 'flash_email_failed_to_sent');\n\n            $this->logger->error('there was an exception sending an email: {e} - {m}', ['e' => \\get_class($e), 'm' => $e->getMessage(), 'exception' => $e]);\n        }\n\n        return $this->render(\n            'page/contact.html.twig', [\n                'body' => $site[0]->contact ?? '',\n                'form' => $form->createView(),\n            ]\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/CrosspostController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller;\n\nuse App\\Entity\\Entry;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\Routing\\Generator\\UrlGeneratorInterface;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass CrosspostController extends AbstractController\n{\n    public function __construct(\n        private readonly UrlGeneratorInterface $urlGenerator,\n    ) {\n    }\n\n    #[IsGranted('ROLE_USER')]\n    public function __invoke(\n        #[MapEntity(id: 'id')]\n        Entry $entry,\n        Request $request,\n    ): Response {\n        $query = [];\n\n        $query['isNsfw'] = $entry->isAdult ? '1' : '0';\n        $query['isOc'] = $entry->isOc ? '1' : '0';\n\n        if ('' !== $entry->title) {\n            $query['title'] = $entry->title;\n        }\n        if (null !== $entry->url && '' !== $entry->url) {\n            $query['url'] = $entry->url;\n        }\n\n        if (null !== $entry->image) {\n            $query['imageHash'] = strtok($entry->image->fileName, '.');\n\n            if (null !== $entry->image->altText && '' !== $entry->image->altText) {\n                $query['imageAlt'] = $entry->image->altText;\n            }\n        }\n\n        $tagNum = 0;\n        foreach ($entry->hashtags as $hashtag) {\n            /* @var $hashtag \\App\\Entity\\HashtagLink */\n            $query[\"tags[$tagNum]\"] = $hashtag->hashtag->tag;\n            ++$tagNum;\n        }\n\n        if (null !== $entry->apId) {\n            $entryUrl = $entry->apId;\n        } else {\n            $entryUrl = $this->urlGenerator->generate(\n                'ap_entry',\n                ['magazine_name' => $entry->magazine->name, 'entry_id' => $entry->getId()],\n                UrlGeneratorInterface::ABSOLUTE_URL\n            );\n        }\n        $body = 'Crossposted from ['.$entryUrl.']('.$entryUrl.')';\n        if (null !== $entry->body && '' !== $entry->body) {\n            $bodyLines = explode(\"\\n\", $entry->body);\n            $body = $body.\"\\n\";\n            foreach ($bodyLines as $line) {\n                $body = $body.\"\\n> \".$line;\n            }\n        }\n        $query['body'] = $body;\n\n        return $this->redirectToRoute('entry_create', $query);\n    }\n}\n"
  },
  {
    "path": "src/Controller/CustomStyleController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller;\n\nuse App\\Repository\\MagazineRepository;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\n\nclass CustomStyleController extends AbstractController\n{\n    public function __invoke(Request $request, MagazineRepository $repository): Response\n    {\n        $magazineName = $request->query->get('magazine');\n        $magazine = $repository->findOneByName($magazineName);\n\n        $css = $this->renderView('styles/custom.css.twig', [\n            'magazine' => $magazine,\n        ]);\n\n        return $this->createResponse($request, $css);\n    }\n\n    private function createResponse(Request $request, ?string $customCss): Response\n    {\n        $response = new Response();\n        $response->headers->set('Content-Type', 'text/css');\n        $response->setPrivate();\n\n        if (!empty($customCss)) {\n            $response->setContent($customCss);\n            $response->setEtag(md5($response->getContent()));\n            $response->isNotModified($request);\n        } else {\n            $response->setStatusCode(Response::HTTP_NOT_FOUND);\n        }\n\n        return $response;\n    }\n}\n"
  },
  {
    "path": "src/Controller/Domain/DomainBlockController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Domain;\n\nuse App\\Controller\\AbstractController;\nuse App\\Entity\\Domain;\nuse App\\Service\\DomainManager;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass DomainBlockController extends AbstractController\n{\n    public function __construct(\n        private readonly DomainManager $manager,\n    ) {\n    }\n\n    #[IsGranted('ROLE_USER')]\n    public function block(#[MapEntity(mapping: ['name' => 'name'])] Domain $domain, Request $request): Response\n    {\n        $this->manager->block($domain, $this->getUserOrThrow());\n\n        if ($request->isXmlHttpRequest()) {\n            return $this->getJsonResponse($domain);\n        }\n\n        return $this->redirectToRefererOrHome($request);\n    }\n\n    #[IsGranted('ROLE_USER')]\n    public function unblock(#[MapEntity(mapping: ['name' => 'name'])] Domain $domain, Request $request): Response\n    {\n        $this->manager->unblock($domain, $this->getUserOrThrow());\n\n        if ($request->isXmlHttpRequest()) {\n            return $this->getJsonResponse($domain);\n        }\n\n        return $this->redirectToRefererOrHome($request);\n    }\n\n    private function getJsonResponse(Domain $domain): JsonResponse\n    {\n        return new JsonResponse(\n            [\n                'html' => $this->renderView(\n                    'components/_ajax.html.twig',\n                    [\n                        'component' => 'domain_sub',\n                        'attributes' => [\n                            'domain' => $domain,\n                        ],\n                    ]\n                ),\n            ]\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Domain/DomainCommentFrontController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Domain;\n\nuse App\\Controller\\AbstractController;\nuse App\\PageView\\EntryCommentPageView;\nuse App\\Repository\\DomainRepository;\nuse App\\Repository\\EntryCommentRepository;\nuse Symfony\\Bundle\\SecurityBundle\\Security;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\n\nclass DomainCommentFrontController extends AbstractController\n{\n    public function __construct(\n        private readonly EntryCommentRepository $commentRepository,\n        private readonly DomainRepository $domainRepository,\n        private readonly Security $security,\n    ) {\n    }\n\n    public function __invoke(string $name, ?string $sortBy, ?string $time, Request $request): Response\n    {\n        if (!$domain = $this->domainRepository->findOneBy(['name' => $name])) {\n            throw $this->createNotFoundException();\n        }\n\n        $params = [];\n        $criteria = new EntryCommentPageView($this->getPageNb($request), $this->security);\n        $criteria->showSortOption($criteria->resolveSort($sortBy))\n            ->setTime($criteria->resolveTime($time))\n            ->setDomain($name);\n\n        $params['comments'] = $this->commentRepository->findByCriteria($criteria);\n        $params['domain'] = $domain;\n        $params['criteria'] = $criteria;\n\n        return $this->render(\n            'domain/comment/front.html.twig',\n            $params\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Domain/DomainFrontController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Domain;\n\nuse App\\Controller\\AbstractController;\nuse App\\Entity\\User;\nuse App\\PageView\\EntryPageView;\nuse App\\Repository\\ContentRepository;\nuse App\\Repository\\DomainRepository;\nuse App\\Utils\\SqlHelpers;\nuse Symfony\\Bundle\\SecurityBundle\\Security;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\HttpKernel\\Attribute\\MapQueryParameter;\n\nclass DomainFrontController extends AbstractController\n{\n    public function __construct(\n        private readonly ContentRepository $contentRepository,\n        private readonly DomainRepository $domainRepository,\n        private readonly SqlHelpers $sqlHelpers,\n    ) {\n    }\n\n    public function __invoke(\n        ?string $name,\n        ?string $sortBy,\n        ?string $time,\n        #[MapQueryParameter]\n        ?string $type,\n        Request $request,\n        Security $security,\n    ): Response {\n        if (!$domain = $this->domainRepository->findOneBy(['name' => $name])) {\n            throw $this->createNotFoundException();\n        }\n\n        $criteria = new EntryPageView($this->getPageNb($request), $security);\n        $criteria->showSortOption($criteria->resolveSort($sortBy))\n            ->setTime($criteria->resolveTime($time))\n            ->setType($criteria->resolveType($type))\n            ->setDomain($name);\n        $resolvedSort = $criteria->resolveSort($sortBy);\n        $criteria->sortOption = $resolvedSort;\n\n        $user = $security->getUser();\n        if ($user instanceof User) {\n            $criteria->fetchCachedItems($this->sqlHelpers, $user);\n        }\n\n        $listing = $this->contentRepository->findByCriteria($criteria);\n\n        if ($request->isXmlHttpRequest()) {\n            return new JsonResponse(\n                [\n                    'html' => $this->renderView(\n                        'entry/_list.html.twig',\n                        [\n                            'entries' => $listing,\n                        ]\n                    ),\n                ]\n            );\n        }\n\n        return $this->render(\n            'domain/front.html.twig',\n            [\n                'domain' => $domain,\n                'entries' => $listing,\n                'criteria' => $criteria,\n            ]\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Domain/DomainSubController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Domain;\n\nuse App\\Controller\\AbstractController;\nuse App\\Entity\\Domain;\nuse App\\Service\\DomainManager;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass DomainSubController extends AbstractController\n{\n    public function __construct(\n        private readonly DomainManager $manager,\n    ) {\n    }\n\n    #[IsGranted('ROLE_USER')]\n    public function subscribe(#[MapEntity(mapping: ['name' => 'name'])] Domain $domain, Request $request): Response\n    {\n        $this->manager->subscribe($domain, $this->getUserOrThrow());\n\n        if ($request->isXmlHttpRequest()) {\n            return $this->getJsonResponse($domain);\n        }\n\n        return $this->redirectToRefererOrHome($request);\n    }\n\n    #[IsGranted('ROLE_USER')]\n    public function unsubscribe(#[MapEntity(mapping: ['name' => 'name'])] Domain $domain, Request $request): Response\n    {\n        $this->manager->unsubscribe($domain, $this->getUserOrThrow());\n\n        if ($request->isXmlHttpRequest()) {\n            return $this->getJsonResponse($domain);\n        }\n\n        return $this->redirectToRefererOrHome($request);\n    }\n\n    private function getJsonResponse(Domain $domain): JsonResponse\n    {\n        return new JsonResponse(\n            [\n                'html' => $this->renderView(\n                    'components/_ajax.html.twig',\n                    [\n                        'component' => 'domain_sub',\n                        'attributes' => [\n                            'domain' => $domain,\n                        ],\n                    ]\n                ),\n            ]\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Entry/Comment/EntryCommentChangeAdultController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Entry\\Comment;\n\nuse App\\Controller\\AbstractController;\nuse App\\Entity\\Entry;\nuse App\\Entity\\EntryComment;\nuse App\\Entity\\Magazine;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass EntryCommentChangeAdultController extends AbstractController\n{\n    public function __construct(\n        private readonly EntityManagerInterface $entityManager,\n    ) {\n    }\n\n    #[IsGranted('moderate', subject: 'comment')]\n    public function __invoke(\n        #[MapEntity(mapping: ['magazine_name' => 'name'])]\n        Magazine $magazine,\n        #[MapEntity(id: 'entry_id')]\n        Entry $entry,\n        #[MapEntity(id: 'comment_id')]\n        EntryComment $comment,\n        Request $request,\n    ): Response {\n        $this->validateCsrf('change_adult', $request->getPayload()->get('token'));\n\n        $comment->isAdult = 'on' === $request->get('adult');\n\n        $this->entityManager->flush();\n\n        $this->addFlash(\n            'success',\n            $comment->isAdult ? 'flash_mark_as_adult_success' : 'flash_unmark_as_adult_success'\n        );\n\n        return $this->redirectToRefererOrHome($request);\n    }\n}\n"
  },
  {
    "path": "src/Controller/Entry/Comment/EntryCommentChangeLangController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Entry\\Comment;\n\nuse App\\Controller\\AbstractController;\nuse App\\Entity\\Entry;\nuse App\\Entity\\EntryComment;\nuse App\\Entity\\Magazine;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass EntryCommentChangeLangController extends AbstractController\n{\n    public function __construct(\n        private readonly EntityManagerInterface $entityManager,\n    ) {\n    }\n\n    #[IsGranted('moderate', subject: 'entry')]\n    public function __invoke(\n        #[MapEntity(mapping: ['magazine_name' => 'name'])]\n        Magazine $magazine,\n        #[MapEntity(id: 'entry_id')]\n        Entry $entry,\n        #[MapEntity(id: 'comment_id')]\n        EntryComment $comment,\n        Request $request,\n    ): Response {\n        $comment->lang = $request->get('lang')['lang'];\n\n        $this->entityManager->flush();\n\n        return $this->redirectToRefererOrHome($request);\n    }\n}\n"
  },
  {
    "path": "src/Controller/Entry/Comment/EntryCommentCreateController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Entry\\Comment;\n\nuse App\\Controller\\AbstractController;\nuse App\\DTO\\EntryCommentDto;\nuse App\\Entity\\Entry;\nuse App\\Entity\\EntryComment;\nuse App\\Entity\\Magazine;\nuse App\\Exception\\InstanceBannedException;\nuse App\\Form\\EntryCommentType;\nuse App\\PageView\\EntryCommentPageView;\nuse App\\Service\\EntryCommentManager;\nuse App\\Service\\IpResolver;\nuse App\\Service\\MentionManager;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Bundle\\SecurityBundle\\Security;\nuse Symfony\\Component\\Form\\FormInterface;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\RequestStack;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\HttpKernel\\Exception\\AccessDeniedHttpException;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass EntryCommentCreateController extends AbstractController\n{\n    use EntryCommentResponseTrait;\n\n    public function __construct(\n        private readonly EntryCommentManager $manager,\n        private readonly RequestStack $requestStack,\n        private readonly IpResolver $ipResolver,\n        private readonly MentionManager $mentionManager,\n    ) {\n    }\n\n    #[IsGranted('ROLE_USER')]\n    #[IsGranted('comment', subject: 'entry')]\n    public function __invoke(\n        #[MapEntity(mapping: ['magazine_name' => 'name'])]\n        Magazine $magazine,\n        #[MapEntity(id: 'entry_id')]\n        Entry $entry,\n        #[MapEntity(id: 'parent_comment_id')]\n        ?EntryComment $parent,\n        Request $request,\n        Security $security,\n    ): Response {\n        $form = $this->getForm($entry, $parent);\n        try {\n            // Could thrown an error on event handlers (eg. onPostSubmit if a user upload an incorrect image)\n            $form->handleRequest($request);\n\n            if ($form->isSubmitted() && $form->isValid()) {\n                $dto = $form->getData();\n                $dto->magazine = $magazine;\n                $dto->entry = $entry;\n                $dto->parent = $parent;\n                $dto->ip = $this->ipResolver->resolve();\n\n                if (!$this->isGranted('create_content', $dto->magazine)) {\n                    throw new AccessDeniedHttpException();\n                }\n\n                return $this->handleValidRequest($dto, $request);\n            }\n        } catch (InstanceBannedException) {\n            $this->addFlash('error', 'flash_instance_banned_error');\n        } catch (\\Exception $e) {\n            // Show an error to the user\n            $this->addFlash('error', 'flash_comment_new_error');\n        }\n\n        if ($request->isXmlHttpRequest()) {\n            return $this->getJsonFormResponse(\n                $form,\n                'entry/comment/_form_comment.html.twig',\n                ['entry' => $entry, 'parent' => $parent]\n            );\n        }\n\n        $user = $this->getUserOrThrow();\n        $criteria = new EntryCommentPageView($this->getPageNb($request), $security);\n        $criteria->entry = $entry;\n\n        return $this->getEntryCommentPageResponse(\n            'entry/comment/create.html.twig',\n            $user,\n            $criteria,\n            $form,\n            $request,\n            $parent\n        );\n    }\n\n    private function getForm(Entry $entry, ?EntryComment $parent = null): FormInterface\n    {\n        $dto = new EntryCommentDto();\n\n        if ($parent && $this->getUser()->addMentionsEntries) {\n            $handle = $this->mentionManager->addHandle([$parent->user->username])[0];\n\n            if ($parent->user !== $this->getUser()) {\n                $dto->body = $handle;\n            } else {\n                $dto->body .= PHP_EOL;\n            }\n\n            if ($parent->mentions) {\n                $mentions = $this->mentionManager->addHandle($parent->mentions);\n                $mentions = array_filter(\n                    $mentions,\n                    fn (string $mention) => $mention !== $handle && $mention !== $this->mentionManager->addHandle(\n                        [$this->getUser()->username]\n                    )[0]\n                );\n\n                $dto->body .= PHP_EOL.PHP_EOL;\n                $dto->body .= implode(' ', array_unique($mentions));\n            }\n        }\n\n        return $this->createForm(\n            EntryCommentType::class,\n            $dto,\n            [\n                'action' => $this->generateUrl(\n                    'entry_comment_create',\n                    [\n                        'magazine_name' => $entry->magazine->name,\n                        'entry_id' => $entry->getId(),\n                        'parent_comment_id' => $parent?->getId(),\n                    ]\n                ),\n                'parentLanguage' => $parent?->lang ?? $entry->lang,\n            ]\n        );\n    }\n\n    private function handleValidRequest(EntryCommentDto $dto, Request $request): Response\n    {\n        $comment = $this->manager->create($dto, $this->getUserOrThrow());\n\n        if ($request->isXmlHttpRequest()) {\n            return $this->getJsonCommentSuccessResponse($comment);\n        }\n\n        $this->addFlash('success', 'flash_comment_new_success');\n\n        return $this->redirectToEntry($comment->entry);\n    }\n}\n"
  },
  {
    "path": "src/Controller/Entry/Comment/EntryCommentDeleteController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Entry\\Comment;\n\nuse App\\Controller\\AbstractController;\nuse App\\Entity\\Entry;\nuse App\\Entity\\EntryComment;\nuse App\\Entity\\Magazine;\nuse App\\Service\\EntryCommentManager;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass EntryCommentDeleteController extends AbstractController\n{\n    public function __construct(\n        private readonly EntryCommentManager $manager,\n    ) {\n    }\n\n    #[IsGranted('ROLE_USER')]\n    #[IsGranted('delete', subject: 'comment')]\n    public function delete(\n        #[MapEntity(mapping: ['magazine_name' => 'name'])]\n        Magazine $magazine,\n        #[MapEntity(id: 'entry_id')]\n        Entry $entry,\n        #[MapEntity(id: 'comment_id')]\n        EntryComment $comment,\n        Request $request,\n    ): Response {\n        $this->validateCsrf('entry_comment_delete', $request->getPayload()->get('token'));\n\n        $this->manager->delete($this->getUserOrThrow(), $comment);\n\n        return $this->redirectToEntry($entry);\n    }\n\n    #[IsGranted('ROLE_USER')]\n    #[IsGranted('delete', subject: 'comment')]\n    public function restore(\n        #[MapEntity(mapping: ['magazine_name' => 'name'])]\n        Magazine $magazine,\n        #[MapEntity(id: 'entry_id')]\n        Entry $entry,\n        #[MapEntity(id: 'comment_id')]\n        EntryComment $comment,\n        Request $request,\n    ): Response {\n        $this->validateCsrf('entry_comment_restore', $request->getPayload()->get('token'));\n\n        $this->manager->restore($this->getUserOrThrow(), $comment);\n\n        return $this->redirectToEntry($entry);\n    }\n\n    #[IsGranted('ROLE_USER')]\n    #[IsGranted('purge', subject: 'comment')]\n    public function purge(\n        #[MapEntity(mapping: ['magazine_name' => 'name'])]\n        Magazine $magazine,\n        #[MapEntity(id: 'entry_id')]\n        Entry $entry,\n        #[MapEntity(id: 'comment_id')]\n        EntryComment $comment,\n        Request $request,\n    ): Response {\n        $this->validateCsrf('entry_comment_purge', $request->getPayload()->get('token'));\n\n        $this->manager->purge($this->getUserOrThrow(), $comment);\n\n        return $this->redirectToRefererOrHome($request);\n    }\n}\n"
  },
  {
    "path": "src/Controller/Entry/Comment/EntryCommentDeleteImageController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Entry\\Comment;\n\nuse App\\Controller\\AbstractController;\nuse App\\Entity\\Entry;\nuse App\\Entity\\EntryComment;\nuse App\\Entity\\Magazine;\nuse App\\Service\\EntryCommentManager;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass EntryCommentDeleteImageController extends AbstractController\n{\n    public function __construct(\n        private readonly EntryCommentManager $manager,\n    ) {\n    }\n\n    #[IsGranted('ROLE_USER')]\n    #[IsGranted('delete', subject: 'comment')]\n    public function __invoke(\n        #[MapEntity(mapping: ['magazine_name' => 'name'])]\n        Magazine $magazine,\n        #[MapEntity(id: 'entry_id')]\n        Entry $entry,\n        #[MapEntity(id: 'comment_id')]\n        EntryComment $comment,\n        Request $request,\n    ): Response {\n        $this->manager->detachImage($comment);\n\n        if ($request->isXmlHttpRequest()) {\n            return new JsonResponse(\n                [\n                    'success' => true,\n                ]\n            );\n        }\n\n        return $this->redirectToRefererOrHome($request);\n    }\n}\n"
  },
  {
    "path": "src/Controller/Entry/Comment/EntryCommentEditController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Entry\\Comment;\n\nuse App\\Controller\\AbstractController;\nuse App\\DTO\\EntryCommentDto;\nuse App\\Entity\\Entry;\nuse App\\Entity\\EntryComment;\nuse App\\Entity\\Magazine;\nuse App\\Form\\EntryCommentType;\nuse App\\PageView\\EntryCommentPageView;\nuse App\\Repository\\EntryCommentRepository;\nuse App\\Service\\EntryCommentManager;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Bundle\\SecurityBundle\\Security;\nuse Symfony\\Component\\Form\\FormInterface;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\HttpKernel\\Exception\\AccessDeniedHttpException;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass EntryCommentEditController extends AbstractController\n{\n    use EntryCommentResponseTrait;\n\n    public function __construct(\n        private readonly EntryCommentManager $manager,\n        private readonly EntryCommentRepository $repository,\n    ) {\n    }\n\n    #[IsGranted('ROLE_USER')]\n    #[IsGranted('edit', subject: 'comment')]\n    public function __invoke(\n        #[MapEntity(mapping: ['magazine_name' => 'name'])]\n        Magazine $magazine,\n        #[MapEntity(id: 'entry_id')]\n        Entry $entry,\n        #[MapEntity(id: 'comment_id')]\n        EntryComment $comment,\n        Request $request,\n        Security $security,\n    ): Response {\n        $dto = $this->manager->createDto($comment);\n\n        $form = $this->getForm($dto, $comment);\n        try {\n            // Could thrown an error on event handlers (eg. onPostSubmit if a user upload an incorrect image)\n            $form->handleRequest($request);\n\n            if ($form->isSubmitted() && $form->isValid()) {\n                if (!$this->isGranted('create_content', $dto->magazine)) {\n                    throw new AccessDeniedHttpException();\n                }\n\n                return $this->handleValidRequest($dto, $comment, $request);\n            }\n        } catch (\\Exception $e) {\n            // Show an error to the user\n            $this->addFlash('error', 'flash_comment_edit_error');\n        }\n\n        if ($request->isXmlHttpRequest()) {\n            return $this->getJsonFormResponse(\n                $form,\n                'entry/comment/_form_comment.html.twig',\n                ['comment' => $comment, 'entry' => $entry, 'edit' => true]\n            );\n        }\n\n        $user = $this->getUserOrThrow();\n        $criteria = new EntryCommentPageView($this->getPageNb($request), $security);\n        $criteria->entry = $entry;\n\n        return $this->getEntryCommentPageResponse('entry/comment/edit.html.twig', $user, $criteria, $form, $request, $comment);\n    }\n\n    private function getForm(EntryCommentDto $dto, EntryComment $comment): FormInterface\n    {\n        return $this->createForm(\n            EntryCommentType::class,\n            $dto,\n            [\n                'action' => $this->generateUrl(\n                    'entry_comment_edit',\n                    [\n                        'magazine_name' => $comment->magazine->name,\n                        'entry_id' => $comment->entry->getId(),\n                        'comment_id' => $comment->getId(),\n                    ]\n                ),\n            ]\n        );\n    }\n\n    private function handleValidRequest(EntryCommentDto $dto, EntryComment $comment, Request $request): Response\n    {\n        $comment = $this->manager->edit($comment, $dto, $this->getUserOrThrow());\n\n        if ($request->isXmlHttpRequest()) {\n            return $this->getJsonCommentSuccessResponse($comment);\n        }\n\n        $this->addFlash('success', 'flash_comment_edit_success');\n\n        return $this->redirectToEntry($comment->entry);\n    }\n}\n"
  },
  {
    "path": "src/Controller/Entry/Comment/EntryCommentFavouriteController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Entry\\Comment;\n\nuse App\\Controller\\AbstractController;\nuse App\\Entity\\Entry;\nuse App\\Entity\\EntryComment;\nuse App\\Entity\\Magazine;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\n\nclass EntryCommentFavouriteController extends AbstractController\n{\n    public function __invoke(\n        #[MapEntity(mapping: ['magazine_name' => 'name'])]\n        Magazine $magazine,\n        #[MapEntity(id: 'entry_id')]\n        Entry $entry,\n        #[MapEntity(id: 'comment_id')]\n        EntryComment $comment,\n        Request $request,\n    ): Response {\n        return $this->render('entry/comment/favourites.html.twig', [\n            'magazine' => $magazine,\n            'entry' => $entry,\n            'comment' => $comment,\n            'favourites' => $comment->favourites,\n        ]);\n    }\n}\n"
  },
  {
    "path": "src/Controller/Entry/Comment/EntryCommentFrontController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Entry\\Comment;\n\nuse App\\Controller\\AbstractController;\nuse App\\Entity\\Magazine;\nuse App\\PageView\\EntryCommentPageView;\nuse App\\Repository\\Criteria;\nuse App\\Repository\\EntryCommentRepository;\nuse Symfony\\Bundle\\SecurityBundle\\Security;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\HttpKernel\\Attribute\\MapQueryParameter;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass EntryCommentFrontController extends AbstractController\n{\n    public function __construct(\n        private readonly EntryCommentRepository $repository,\n        private readonly Security $security,\n    ) {\n    }\n\n    public function front(\n        ?Magazine $magazine,\n        ?string $sortBy,\n        ?string $time,\n        Request $request,\n        #[MapQueryParameter] ?string $federation,\n    ): Response {\n        $params = [];\n        $criteria = new EntryCommentPageView($this->getPageNb($request), $this->security);\n        $criteria->showSortOption($criteria->resolveSort($sortBy ?? Criteria::SORT_DEFAULT))\n            ->setTime($criteria->resolveTime($time));\n        $criteria->setFederation($federation ?? Criteria::AP_ALL);\n\n        if ($magazine) {\n            $criteria->magazine = $params['magazine'] = $magazine;\n        }\n\n        $params['comments'] = $this->repository->findByCriteria($criteria);\n        $params['criteria'] = $criteria;\n\n        return $this->render(\n            'entry/comment/front.html.twig',\n            $params\n        );\n    }\n\n    #[IsGranted('ROLE_USER')]\n    public function subscribed(?string $sortBy, ?string $time, Request $request): Response\n    {\n        $params = [];\n        $criteria = new EntryCommentPageView($this->getPageNb($request), $this->security);\n        $criteria->showSortOption($criteria->resolveSort($sortBy))\n            ->setTime($criteria->resolveTime($time));\n        $criteria->subscribed = true;\n\n        $params['comments'] = $this->repository->findByCriteria($criteria);\n        $params['criteria'] = $criteria;\n\n        return $this->render(\n            'entry/comment/front.html.twig',\n            $params\n        );\n    }\n\n    #[IsGranted('ROLE_USER')]\n    public function moderated(?string $sortBy, ?string $time, Request $request): Response\n    {\n        $params = [];\n        $criteria = new EntryCommentPageView($this->getPageNb($request), $this->security);\n        $criteria->showSortOption($criteria->resolveSort($sortBy))\n            ->setTime($criteria->resolveTime($time));\n        $criteria->moderated = true;\n\n        $params['comments'] = $this->repository->findByCriteria($criteria);\n        $params['criteria'] = $criteria;\n\n        return $this->render(\n            'entry/comment/front.html.twig',\n            $params\n        );\n    }\n\n    #[IsGranted('ROLE_USER')]\n    public function favourite(?string $sortBy, ?string $time, Request $request): Response\n    {\n        $params = [];\n        $criteria = new EntryCommentPageView($this->getPageNb($request), $this->security);\n        $criteria->showSortOption($criteria->resolveSort($sortBy))\n            ->setTime($criteria->resolveTime($time));\n        $criteria->favourite = true;\n\n        $params['comments'] = $this->repository->findByCriteria($criteria);\n        $params['criteria'] = $criteria;\n\n        return $this->render(\n            'entry/comment/front.html.twig',\n            $params\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Entry/Comment/EntryCommentModerateController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Entry\\Comment;\n\nuse App\\Controller\\AbstractController;\nuse App\\Entity\\Entry;\nuse App\\Entity\\EntryComment;\nuse App\\Entity\\Magazine;\nuse App\\Form\\LangType;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass EntryCommentModerateController extends AbstractController\n{\n    #[IsGranted('moderate', subject: 'comment')]\n    public function __invoke(\n        #[MapEntity(mapping: ['magazine_name' => 'name'])]\n        Magazine $magazine,\n        #[MapEntity(id: 'entry_id')]\n        Entry $entry,\n        #[MapEntity(id: 'comment_id')]\n        EntryComment $comment,\n        Request $request,\n    ): Response {\n        if ($entry->magazine !== $magazine) {\n            return $this->redirectToRoute(\n                'entry_single',\n                ['magazine_name' => $entry->magazine->name, 'entry_id' => $entry->getId(), 'slug' => $entry->slug],\n                301\n            );\n        }\n\n        $form = $this->createForm(LangType::class);\n        $form->get('lang')\n            ->setData($comment->lang);\n\n        if ($request->isXmlHttpRequest()) {\n            return new JsonResponse([\n                'html' => $this->renderView('entry/comment/_moderate_panel.html.twig', [\n                    'magazine' => $magazine,\n                    'entry' => $entry,\n                    'comment' => $comment,\n                    'form' => $form->createView(),\n                ]),\n            ]);\n        }\n\n        return $this->render('entry/comment/moderate.html.twig', [\n            'magazine' => $magazine,\n            'entry' => $entry,\n            'comment' => $comment,\n            'form' => $form->createView(),\n        ]);\n    }\n}\n"
  },
  {
    "path": "src/Controller/Entry/Comment/EntryCommentResponseTrait.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Entry\\Comment;\n\nuse App\\Entity\\EntryComment;\nuse App\\Entity\\User;\nuse App\\PageView\\EntryCommentPageView;\nuse Symfony\\Component\\Form\\FormInterface;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\n\n/**\n * @method getJsonFormResponse(FormInterface $form, string $string, ?array $variables = null)\n * @method render(string $template, array $array, Response $param)\n */\ntrait EntryCommentResponseTrait\n{\n    private function getEntryCommentPageResponse(\n        string $template,\n        User $user,\n        EntryCommentPageView $criteria,\n        FormInterface $form,\n        Request $request,\n        ?EntryComment $parent = null,\n    ): Response {\n        if ($request->isXmlHttpRequest()) {\n            $this->getJsonFormResponse($form, 'entry/comment/_form.html.twig');\n        }\n\n        return $this->render(\n            $template,\n            [\n                'user' => $user,\n                'magazine' => $criteria->entry->magazine,\n                'entry' => $criteria->entry,\n                'parent' => $parent,\n                'comment' => $parent,\n                'form' => $form->createView(),\n            ],\n            new Response(null, $form->isSubmitted() && !$form->isValid() ? 322 : 200)\n        );\n    }\n\n    private function getJsonCommentSuccessResponse(EntryComment $comment): Response\n    {\n        return new JsonResponse(\n            [\n                'id' => $comment->getId(),\n                'html' => $this->renderView(\n                    'components/_ajax.html.twig',\n                    [\n                        'component' => 'entry_comment',\n                        'attributes' => [\n                            'comment' => $comment,\n                            'showEntryTitle' => false,\n                            'showMagazineName' => false,\n                        ],\n                    ]\n                ),\n            ]\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Entry/Comment/EntryCommentViewController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Entry\\Comment;\n\nuse App\\Controller\\AbstractController;\nuse App\\Controller\\Traits\\PrivateContentTrait;\nuse App\\Entity\\Entry;\nuse App\\Entity\\EntryComment;\nuse App\\Entity\\Magazine;\nuse App\\Event\\Entry\\EntryHasBeenSeenEvent;\nuse App\\PageView\\EntryCommentPageView;\nuse App\\Repository\\EntryCommentRepository;\nuse App\\Repository\\EntryRepository;\nuse Psr\\EventDispatcher\\EventDispatcherInterface;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Bundle\\SecurityBundle\\Security;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\RequestStack;\nuse Symfony\\Component\\HttpFoundation\\Response;\n\nclass EntryCommentViewController extends AbstractController\n{\n    use PrivateContentTrait;\n\n    public function __construct(\n        private readonly RequestStack $requestStack,\n        private readonly EventDispatcherInterface $dispatcher,\n        private readonly EntryRepository $entryRepository,\n        private readonly EntryCommentRepository $entryCommentRepository,\n    ) {\n    }\n\n    public function __invoke(\n        #[MapEntity(mapping: ['magazine_name' => 'name'])]\n        Magazine $magazine,\n        #[MapEntity(id: 'entry_id')]\n        Entry $entry,\n        #[MapEntity(id: 'comment_id')]\n        ?EntryComment $comment,\n        Request $request,\n        Security $security,\n    ): Response {\n        $this->handlePrivateContent($entry);\n\n        // @TODO there is no entry comment has been seen event, maybe\n        // it should be added so one comment view does not mark all as read in the same entry\n        $this->dispatcher->dispatch(new EntryHasBeenSeenEvent($entry));\n\n        $this->entryRepository->hydrate($entry);\n        // Both comment and root comment can be null\n        if (null !== $comment?->root) {\n            $this->entryCommentRepository->hydrateChildren($comment->root);\n        }\n        $criteria = new EntryCommentPageView(1, $security);\n\n        return $this->render(\n            'entry/comment/view.html.twig',\n            [\n                'magazine' => $magazine,\n                'entry' => $entry,\n                'comment' => $comment,\n                'criteria' => $criteria,\n            ]\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Entry/Comment/EntryCommentVotersController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Entry\\Comment;\n\nuse App\\Controller\\AbstractController;\nuse App\\Entity\\Contracts\\VotableInterface;\nuse App\\Entity\\Entry;\nuse App\\Entity\\EntryComment;\nuse App\\Entity\\Magazine;\nuse App\\Service\\SettingsManager;\nuse App\\Utils\\DownvotesMode;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\n\nclass EntryCommentVotersController extends AbstractController\n{\n    public function __construct(\n        private readonly SettingsManager $settingsManager,\n    ) {\n    }\n\n    public function __invoke(\n        #[MapEntity(mapping: ['magazine_name' => 'name'])]\n        Magazine $magazine,\n        #[MapEntity(id: 'entry_id')]\n        Entry $entry,\n        #[MapEntity(id: 'comment_id')]\n        EntryComment $comment,\n        Request $request,\n        string $type,\n    ): Response {\n        if ('down' === $type && DownvotesMode::Enabled !== $this->settingsManager->getDownvotesMode()) {\n            $votes = [];\n        } else {\n            $votes = $comment->votes->filter(\n                fn ($e) => $e->choice === ('up' === $type ? VotableInterface::VOTE_UP : VotableInterface::VOTE_DOWN)\n            );\n        }\n\n        if ($request->isXmlHttpRequest()) {\n            return new JsonResponse([\n                'html' => $this->renderView('components/voters_inline.html.twig', [\n                    'votes' => $votes,\n                    'more' => null,\n                ]),\n            ]);\n        }\n\n        return $this->render('entry/comment/voters.html.twig', [\n            'magazine' => $magazine,\n            'entry' => $entry,\n            'comment' => $comment,\n            'votes' => $votes,\n        ]);\n    }\n}\n"
  },
  {
    "path": "src/Controller/Entry/EntryChangeAdultController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Entry;\n\nuse App\\Controller\\AbstractController;\nuse App\\Entity\\Entry;\nuse App\\Entity\\Magazine;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass EntryChangeAdultController extends AbstractController\n{\n    public function __construct(\n        private readonly EntityManagerInterface $entityManager,\n    ) {\n    }\n\n    #[IsGranted('moderate', subject: 'entry')]\n    public function __invoke(\n        #[MapEntity(mapping: ['magazine_name' => 'name'])]\n        Magazine $magazine,\n        #[MapEntity(id: 'entry_id')]\n        Entry $entry,\n        Request $request,\n    ): Response {\n        $this->validateCsrf('change_adult', $request->getPayload()->get('token'));\n\n        $entry->isAdult = 'on' === $request->get('adult');\n\n        $this->entityManager->flush();\n\n        $this->addFlash(\n            'success',\n            $entry->isAdult ? 'flash_mark_as_adult_success' : 'flash_unmark_as_adult_success'\n        );\n\n        return $this->redirectToRefererOrHome($request);\n    }\n}\n"
  },
  {
    "path": "src/Controller/Entry/EntryChangeLangController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Entry;\n\nuse App\\Controller\\AbstractController;\nuse App\\Entity\\Entry;\nuse App\\Entity\\Magazine;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass EntryChangeLangController extends AbstractController\n{\n    public function __construct(\n        private readonly EntityManagerInterface $entityManager,\n    ) {\n    }\n\n    #[IsGranted('moderate', subject: 'entry')]\n    public function __invoke(\n        #[MapEntity(mapping: ['magazine_name' => 'name'])]\n        Magazine $magazine,\n        #[MapEntity(id: 'entry_id')]\n        Entry $entry,\n        Request $request,\n    ): Response {\n        $entry->lang = $request->get('lang')['lang'];\n\n        $this->entityManager->flush();\n\n        return $this->redirectToRefererOrHome($request);\n    }\n}\n"
  },
  {
    "path": "src/Controller/Entry/EntryChangeMagazineController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Entry;\n\nuse App\\Controller\\AbstractController;\nuse App\\Entity\\Entry;\nuse App\\Entity\\Magazine;\nuse App\\Repository\\MagazineRepository;\nuse App\\Service\\EntryManager;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass EntryChangeMagazineController extends AbstractController\n{\n    public function __construct(\n        private readonly EntryManager $manager,\n        private readonly MagazineRepository $repository,\n    ) {\n    }\n\n    #[IsGranted('moderate', subject: 'entry')]\n    public function __invoke(\n        #[MapEntity(mapping: ['magazine_name' => 'name'])]\n        Magazine $magazine,\n        #[MapEntity(id: 'entry_id')]\n        Entry $entry,\n        Request $request,\n    ): Response {\n        $this->validateCsrf('change_magazine', $request->getPayload()->get('token'));\n\n        $newMagazine = $this->repository->findOneByName($request->get('change_magazine')['new_magazine']);\n\n        $this->manager->changeMagazine($entry, $newMagazine);\n\n        return $this->redirectToRefererOrHome($request);\n    }\n}\n"
  },
  {
    "path": "src/Controller/Entry/EntryCreateController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Entry;\n\nuse App\\Controller\\AbstractController;\nuse App\\DTO\\EntryDto;\nuse App\\Entity\\Magazine;\nuse App\\Entity\\User;\nuse App\\Exception\\ImageDownloadTooLargeException;\nuse App\\Exception\\InstanceBannedException;\nuse App\\Exception\\PostingRestrictedException;\nuse App\\Exception\\TagBannedException;\nuse App\\Factory\\ImageFactory;\nuse App\\Form\\EntryType;\nuse App\\Repository\\Criteria;\nuse App\\Repository\\ImageRepository;\nuse App\\Repository\\TagLinkRepository;\nuse App\\Repository\\TagRepository;\nuse App\\Service\\EntryCommentManager;\nuse App\\Service\\EntryManager;\nuse App\\Service\\IpResolver;\nuse App\\Service\\SettingsManager;\nuse Psr\\Log\\LoggerInterface;\nuse Symfony\\Bundle\\SecurityBundle\\Security;\nuse Symfony\\Component\\Form\\FormInterface;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\HttpKernel\\Attribute\\MapQueryParameter;\nuse Symfony\\Component\\HttpKernel\\Exception\\AccessDeniedHttpException;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\nuse Symfony\\Component\\Validator\\Validator\\ValidatorInterface;\nuse Symfony\\Contracts\\Translation\\TranslatorInterface;\n\nclass EntryCreateController extends AbstractController\n{\n    use EntryTemplateTrait;\n\n    public function __construct(\n        private readonly TranslatorInterface $translator,\n        private readonly SettingsManager $settingsManager,\n        private readonly LoggerInterface $logger,\n        private readonly TagLinkRepository $tagLinkRepository,\n        private readonly TagRepository $tagRepository,\n        private readonly EntryManager $manager,\n        private readonly EntryCommentManager $commentManager,\n        private readonly ImageRepository $imageRepository,\n        private readonly ImageFactory $imageFactory,\n        private readonly ValidatorInterface $validator,\n        private readonly IpResolver $ipResolver,\n        private readonly Security $security,\n    ) {\n    }\n\n    /**\n     * @param string[]|null $tags\n     */\n    #[IsGranted('ROLE_USER')]\n    public function __invoke(\n        ?Magazine $magazine,\n        #[MapQueryParameter]\n        ?string $title,\n        #[MapQueryParameter]\n        ?string $url,\n        #[MapQueryParameter]\n        ?string $body,\n        #[MapQueryParameter]\n        ?string $imageAlt,\n        #[MapQueryParameter]\n        ?string $isNsfw,\n        #[MapQueryParameter]\n        ?string $isOc,\n        #[MapQueryParameter]\n        ?array $tags,\n        #[MapQueryParameter]\n        ?string $imageHash,\n        Request $request,\n    ): Response {\n        $user = $this->getUserOrThrow();\n        $maxBytes = $this->settingsManager->getMaxImageByteString();\n\n        $dto = new EntryDto();\n        $dto->magazine = $magazine;\n        $dto->title = $title;\n        $dto->url = $url;\n        $dto->body = $body;\n        $dto->imageAlt = $imageAlt;\n        $dto->isAdult = '1' === $isNsfw;\n        $dto->isOc = '1' === $isOc;\n        $dto->tags = $tags;\n\n        if (null !== $imageHash) {\n            $img = $this->imageRepository->findOneBySha256(hex2bin($imageHash));\n            if (null !== $img) {\n                $dto->image = $this->imageFactory->createDto($img);\n            } else {\n                $form = $this->createForm(EntryType::class, $dto);\n\n                return $this->showFailure('flash_thread_ref_image_not_found', 400, $magazine, $user, $form, $maxBytes);\n            }\n        }\n\n        $form = $this->createForm(EntryType::class, $dto);\n        try {\n            // Could throw an error on event handlers (e.g. onPostSubmit if a user upload an incorrect image)\n            $form->handleRequest($request);\n\n            if ($form->isSubmitted() && $form->isValid()) {\n                /** @var EntryDto $dto */\n                $dto = $form->getData();\n                $dto->ip = $this->ipResolver->resolve();\n\n                if (!$this->isGranted('create_content', $dto->magazine)) {\n                    throw new AccessDeniedHttpException();\n                }\n\n                $entry = $this->manager->create($dto, $this->getUserOrThrow());\n                foreach ($dto->tags ?? [] as $tag) {\n                    $hashtag = $this->tagRepository->findOneBy(['tag' => $tag]);\n                    if (!$hashtag) {\n                        $hashtag = $this->tagRepository->create($tag);\n                    } elseif ($this->tagLinkRepository->entryHasTag($entry, $hashtag)) {\n                        continue;\n                    }\n                    $this->tagLinkRepository->addTagToEntry($entry, $hashtag);\n                }\n\n                $this->addFlash('success', 'flash_thread_new_success');\n\n                return $this->redirectToMagazine(\n                    $entry->magazine,\n                    Criteria::SORT_NEW\n                );\n            }\n\n            return $this->render(\n                $this->getTemplateName(),\n                [\n                    'magazine' => $magazine,\n                    'user' => $user,\n                    'form' => $form->createView(),\n                    'maxSize' => $maxBytes,\n                ],\n                new Response(null, $form->isSubmitted() && !$form->isValid() ? 422 : 200)\n            );\n        } catch (TagBannedException $e) {\n            $this->logger->error($e);\n\n            return $this->showFailure('flash_thread_tag_banned_error', 422, $magazine, $user, $form, $maxBytes);\n        } catch (InstanceBannedException $e) {\n            $this->logger->error($e);\n\n            return $this->showFailure('flash_thread_instance_banned', 422, $magazine, $user, $form, $maxBytes);\n        } catch (PostingRestrictedException $e) {\n            $this->logger->error($e);\n\n            return $this->showFailure('flash_posting_restricted_error', 422, $magazine, $user, $form, $maxBytes);\n        } catch (ImageDownloadTooLargeException $e) {\n            $this->logger->error($e);\n\n            return $this->showFailure(\n                $this->translator->trans('flash_image_download_too_large_error', ['%bytes%' => $maxBytes]),\n                422,\n                $magazine,\n                $user,\n                $form,\n                $maxBytes\n            );\n        } catch (\\Exception $e) {\n            $this->logger->error($e);\n\n            return $this->showFailure('flash_thread_new_error', 422, $magazine, $user, $form, $maxBytes);\n        }\n    }\n\n    private function showFailure(string $flashMessage, int $httpCode, ?Magazine $magazine, User $user, FormInterface $form, string $maxBytes): Response\n    {\n        $this->addFlash('error', $flashMessage);\n\n        return $this->render(\n            $this->getTemplateName(),\n            [\n                'magazine' => $magazine,\n                'user' => $user,\n                'form' => $form->createView(),\n                'maxSize' => $maxBytes,\n            ],\n            new Response(null, $httpCode),\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Entry/EntryDeleteController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Entry;\n\nuse App\\Controller\\AbstractController;\nuse App\\Entity\\Entry;\nuse App\\Entity\\Magazine;\nuse App\\Service\\EntryManager;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass EntryDeleteController extends AbstractController\n{\n    public function __construct(\n        private readonly EntryManager $manager,\n    ) {\n    }\n\n    #[IsGranted('ROLE_USER')]\n    #[IsGranted('delete', subject: 'entry')]\n    public function delete(\n        #[MapEntity(mapping: ['magazine_name' => 'name'])]\n        Magazine $magazine,\n        #[MapEntity(id: 'entry_id')]\n        Entry $entry,\n        Request $request,\n    ): Response {\n        $this->validateCsrf('entry_delete', $request->getPayload()->get('token'));\n\n        $this->manager->delete($this->getUserOrThrow(), $entry);\n\n        $this->addFlash(\n            'danger',\n            'flash_thread_delete_success'\n        );\n\n        return $this->redirectToMagazine($magazine);\n    }\n\n    #[IsGranted('ROLE_USER')]\n    #[IsGranted('delete', subject: 'entry')]\n    public function restore(\n        #[MapEntity(mapping: ['magazine_name' => 'name'])]\n        Magazine $magazine,\n        #[MapEntity(id: 'entry_id')]\n        Entry $entry,\n        Request $request,\n    ): Response {\n        $this->validateCsrf('entry_restore', $request->getPayload()->get('token'));\n\n        $this->manager->restore($this->getUserOrThrow(), $entry);\n\n        return $this->redirectToMagazine($magazine);\n    }\n\n    #[IsGranted('ROLE_USER')]\n    #[IsGranted('purge', subject: 'entry')]\n    public function purge(\n        #[MapEntity(mapping: ['magazine_name' => 'name'])]\n        Magazine $magazine,\n        #[MapEntity(id: 'entry_id')]\n        Entry $entry,\n        Request $request,\n    ): Response {\n        $this->validateCsrf('entry_purge', $request->getPayload()->get('token'));\n\n        $this->manager->purge($this->getUserOrThrow(), $entry);\n\n        return $this->redirectToRefererOrHome($request);\n    }\n}\n"
  },
  {
    "path": "src/Controller/Entry/EntryDeleteImageController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Entry;\n\nuse App\\Controller\\AbstractController;\nuse App\\Entity\\Entry;\nuse App\\Entity\\Magazine;\nuse App\\Service\\EntryManager;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass EntryDeleteImageController extends AbstractController\n{\n    public function __construct(\n        private readonly EntryManager $manager,\n    ) {\n    }\n\n    #[IsGranted('ROLE_USER')]\n    #[IsGranted('delete', subject: 'entry')]\n    public function __invoke(\n        #[MapEntity(mapping: ['magazine_name' => 'name'])]\n        Magazine $magazine,\n        #[MapEntity(id: 'entry_id')]\n        Entry $entry,\n        Request $request,\n    ): Response {\n        $this->manager->detachImage($entry);\n\n        if ($request->isXmlHttpRequest()) {\n            return new JsonResponse(\n                [\n                    'success' => true,\n                ]\n            );\n        }\n\n        return $this->redirectToRefererOrHome($request);\n    }\n}\n"
  },
  {
    "path": "src/Controller/Entry/EntryEditController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Entry;\n\nuse App\\Controller\\AbstractController;\nuse App\\DTO\\EntryDto;\nuse App\\Entity\\Entry;\nuse App\\Entity\\Magazine;\nuse App\\Form\\EntryEditType;\nuse App\\Service\\EntryManager;\nuse App\\Service\\SettingsManager;\nuse Psr\\Log\\LoggerInterface;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\HttpKernel\\Exception\\AccessDeniedHttpException;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass EntryEditController extends AbstractController\n{\n    use EntryTemplateTrait;\n\n    public function __construct(\n        private readonly EntryManager $manager,\n        private readonly LoggerInterface $logger,\n        private readonly SettingsManager $settingsManager,\n    ) {\n    }\n\n    #[IsGranted('ROLE_USER')]\n    #[IsGranted('edit', subject: 'entry')]\n    public function __invoke(\n        #[MapEntity(mapping: ['magazine_name' => 'name'])]\n        Magazine $magazine,\n        #[MapEntity(id: 'entry_id')]\n        Entry $entry,\n        Request $request,\n    ): Response {\n        $dto = $this->manager->createDto($entry);\n        $maxBytes = $this->settingsManager->getMaxImageByteString();\n\n        $form = $this->createForm(EntryEditType::class, $dto);\n        try {\n            $form->handleRequest($request);\n\n            if ($form->isSubmitted() && $form->isValid()) {\n                if (!$this->isGranted('create_content', $dto->magazine)) {\n                    throw new AccessDeniedHttpException();\n                }\n                /** @var EntryDto $dto */\n                $dto = $form->getData();\n\n                $entry = $this->manager->edit($entry, $dto, $this->getUserOrThrow());\n\n                $this->addFlash('success', 'flash_thread_edit_success');\n\n                return $this->redirectToEntry($entry);\n            }\n        } catch (\\Exception $e) {\n            // Show an error to the user\n            $this->addFlash('error', 'flash_thread_edit_error');\n        }\n\n        return $this->render(\n            $this->getTemplateName(edit: true),\n            [\n                'magazine' => $magazine,\n                'entry' => $entry,\n                'form' => $form->createView(),\n                'maxSize' => $maxBytes,\n            ],\n            new Response(null, $form->isSubmitted() && !$form->isValid() ? 422 : 200)\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Entry/EntryFavouriteController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Entry;\n\nuse App\\Controller\\AbstractController;\nuse App\\Entity\\Entry;\nuse App\\Entity\\Magazine;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\n\nclass EntryFavouriteController extends AbstractController\n{\n    public function __invoke(\n        #[MapEntity(mapping: ['magazine_name' => 'name'])]\n        Magazine $magazine,\n        #[MapEntity(id: 'entry_id')]\n        Entry $entry,\n        Request $request,\n    ): Response {\n        return $this->render('entry/favourites.html.twig', [\n            'magazine' => $magazine,\n            'entry' => $entry,\n            'favourites' => $entry->favourites,\n        ]);\n    }\n}\n"
  },
  {
    "path": "src/Controller/Entry/EntryFrontController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Entry;\n\nuse App\\Controller\\AbstractController;\nuse App\\DTO\\PostDto;\nuse App\\Entity\\Magazine;\nuse App\\Entity\\User;\nuse App\\Form\\PostType;\nuse App\\PageView\\EntryPageView;\nuse App\\PageView\\PostPageView;\nuse App\\Pagination\\Pagerfanta as MbinPagerfanta;\nuse App\\Repository\\ContentRepository;\nuse App\\Repository\\Criteria;\nuse App\\Repository\\MagazineRepository;\nuse App\\Utils\\SqlHelpers;\nuse Pagerfanta\\PagerfantaInterface;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Bundle\\SecurityBundle\\Security;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\HttpKernel\\Attribute\\MapQueryParameter;\n\nclass EntryFrontController extends AbstractController\n{\n    public function __construct(\n        private readonly ContentRepository $contentRepository,\n        private readonly MagazineRepository $magazineRepository,\n        private readonly Security $security,\n        private readonly SqlHelpers $sqlHelpers,\n    ) {\n    }\n\n    public function front(\n        string $subscription,\n        string $content,\n        ?string $sortBy,\n        ?string $time,\n        string $federation,\n        #[MapQueryParameter]\n        ?string $type,\n        Request $request,\n        #[MapQueryParameter] ?string $cursor = null,\n    ): Response {\n        $user = $this->getUser();\n\n        $criteria = $this->createCriteria($content, $request, $user);\n        $criteria->showSortOption($criteria->resolveSort($sortBy))\n            ->setFederation($federation)\n            ->setTime($criteria->resolveTime($time))\n            ->setType($criteria->resolveType($type));\n\n        if ('home' === $subscription) {\n            $subscription = $this->subscriptionFor($user);\n        }\n        $this->handleSubscription($subscription, $criteria);\n\n        $this->setUserPreferences($user, $criteria);\n\n        if (null !== $user) {\n            $criteria->fetchCachedItems($this->sqlHelpers, $user);\n        }\n\n        $entities = $this->contentRepository->findByCriteriaCursored($criteria, $this->getCursorByCriteria($criteria->sortOption, $cursor));\n        $templatePath = 'content/';\n        $dataKey = 'results';\n\n        return $this->renderResponse(\n            $request,\n            $criteria,\n            [$dataKey => $entities],\n            $templatePath,\n            $user\n        );\n    }\n\n    public function frontRedirect(\n        string $content,\n        ?string $sortBy,\n        ?string $time,\n        string $federation,\n        #[MapQueryParameter]\n        ?string $type,\n        Request $request,\n    ): Response {\n        $user = $this->getUser();\n        $subscription = $this->subscriptionFor($user);\n\n        return $this->redirectToRoute('front', [\n            'subscription' => $subscription,\n            'sortBy' => $sortBy,\n            'time' => $time,\n            'type' => $type,\n            'federation' => $federation,\n            'content' => $content,\n        ]);\n    }\n\n    public function magazine(\n        #[MapEntity(expr: 'repository.findOneByName(name)')]\n        Magazine $magazine,\n        string $content,\n        ?string $sortBy,\n        ?string $time,\n        string $federation,\n        #[MapQueryParameter]\n        ?string $type,\n        Request $request,\n        #[MapQueryParameter] ?string $cursor = null,\n        #[MapQueryParameter] ?string $cursor2 = null,\n    ): Response {\n        $user = $this->getUser();\n        $response = new Response();\n        if ($magazine->apId) {\n            $response->headers->set('X-Robots-Tag', 'noindex, nofollow');\n        }\n\n        $criteria = $this->createCriteria($content, $request, $user);\n        $criteria->showSortOption($criteria->resolveSort($sortBy))\n            ->setFederation($federation)\n            ->setTime($criteria->resolveTime($time))\n            ->setType($criteria->resolveType($type));\n        $criteria->magazine = $magazine;\n        $criteria->stickiesFirst = true;\n\n        $subscription = $request->query->get('subscription') ?: 'all';\n        $this->handleSubscription($subscription, $criteria);\n\n        $this->setUserPreferences($user, $criteria);\n        if (null !== $user) {\n            $criteria->fetchCachedItems($this->sqlHelpers, $user);\n        }\n        $cursorValue = $this->getCursorByCriteria($criteria->sortOption, $cursor);\n        $cursor2Value = $cursor2 ? $this->getCursorByCriteria(Criteria::SORT_NEW, $cursor2) : null;\n        $results = $this->contentRepository->findByCriteriaCursored($criteria, $cursorValue, $cursor2Value);\n\n        return $this->renderResponse(\n            $request,\n            $criteria,\n            ['results' => $results, 'magazine' => $magazine],\n            'content/',\n            $user\n        );\n    }\n\n    /**\n     * @param string $name magazine name\n     */\n    public function magazineRedirect(\n        string $name,\n        string $content,\n        ?string $sortBy,\n        ?string $time,\n        string $federation,\n        #[MapQueryParameter]\n        ?string $type,\n    ): Response {\n        $user = $this->getUser(); // Fetch the user\n        $subscription = $this->subscriptionFor($user); // Determine the subscription filter based on the user\n\n        return $this->redirectToRoute('front_magazine', [\n            'name' => $name,\n            'subscription' => $subscription,\n            'sortBy' => $sortBy,\n            'time' => $time,\n            'type' => $type,\n            'federation' => $federation,\n            'content' => $content,\n        ]);\n    }\n\n    private function createCriteria(string $content, Request $request, ?User $user): Criteria\n    {\n        if ('default' === $content) {\n            $content = $user?->frontDefaultContent ?? 'threads';\n        }\n\n        if ('threads' === $content || 'combined' === $content) {\n            $criteria = new EntryPageView($this->getPageNb($request), $this->security);\n        } elseif ('microblog' === $content) {\n            $criteria = new PostPageView($this->getPageNb($request), $this->security);\n        } else {\n            throw new \\LogicException('Invalid content '.$content);\n        }\n\n        return $criteria->setContent($content);\n    }\n\n    private function handleSubscription(string $subscription, &$criteria)\n    {\n        if (\\in_array($subscription, ['sub', 'mod', 'fav'])) {\n            $this->denyAccessUnlessGranted('ROLE_USER');\n            $this->getUserOrThrow();\n        }\n\n        if ('sub' === $subscription) {\n            $criteria->subscribed = true;\n        } elseif ('mod' === $subscription) {\n            $criteria->moderated = true;\n        } elseif ('fav' === $subscription) {\n            $criteria->favourite = true;\n        } elseif ($subscription && 'all' !== $subscription) {\n            throw new \\LogicException('Invalid subscription filter '.$subscription);\n        }\n    }\n\n    private function setUserPreferences(?User $user, Criteria &$criteria): void\n    {\n        if (null === $user) {\n            return;\n        }\n\n        $criteria->includeBoosts = $user->showBoostsOfFollowing;\n\n        if (0 < \\count($user->preferredLanguages)) {\n            $criteria->languages = $user->preferredLanguages;\n        }\n    }\n\n    private function renderResponse(Request $request, Criteria $criteria, array $data, string $templatePath, ?User $user): Response\n    {\n        $baseData = array_merge(['criteria' => $criteria], $data);\n\n        if ('microblog' === $criteria->content) {\n            $dto = new PostDto();\n\n            if (isset($data['magazine'])) {\n                $dto->magazine = $data['magazine'];\n            } else {\n                // check if the \"random\" magazine exists and if so, use it\n                $randomMagazine = $this->magazineRepository->findOneByName('random');\n                if (null !== $randomMagazine) {\n                    $dto->magazine = $randomMagazine;\n                }\n            }\n\n            $baseData['form'] = $this->createForm(PostType::class)->setData($dto)->createView();\n            $baseData['user'] = $user;\n        }\n\n        if ($request->isXmlHttpRequest()) {\n            return new JsonResponse([\n                'html' => $this->renderView($templatePath.'_list.html.twig', $baseData),\n            ]);\n        }\n\n        return $this->render($templatePath.'front.html.twig', $baseData);\n    }\n\n    private function subscriptionFor(?User $user): string\n    {\n        if ($user) {\n            return match ($user->homepage) {\n                User::HOMEPAGE_SUB => 'sub',\n                User::HOMEPAGE_MOD => 'mod',\n                User::HOMEPAGE_FAV => 'fav',\n                default => 'all',\n            };\n        } else {\n            return 'all'; // Global default\n        }\n    }\n\n    private function handleCrossposts($pagination): PagerfantaInterface\n    {\n        $posts = $pagination->getCurrentPageResults();\n\n        $firstIndexes = [];\n        $tmp = [];\n        $duplicates = [];\n\n        foreach ($posts as $post) {\n            $groupingField = !empty($post->url) ? $post->url : $post->title;\n\n            if (!\\in_array($groupingField, $firstIndexes) || (empty($post->url) && \\strlen($post->title) <= 10)) {\n                $tmp[] = $post;\n                $firstIndexes[] = $groupingField;\n            } else {\n                if (!\\in_array($groupingField, array_column($duplicates, 'groupingField'), true)) {\n                    $duplicates[] = (object) [\n                        'groupingField' => $groupingField,\n                        'items' => [],\n                    ];\n                }\n\n                $duplicateIndex = array_search($groupingField, array_column($duplicates, 'groupingField'));\n                $duplicates[$duplicateIndex]->items[] = $post;\n\n                $post->cross = true;\n            }\n        }\n\n        $results = [];\n        foreach ($tmp as $item) {\n            $results[] = $item;\n            $groupingField = !empty($item->url) ? $item->url : $item->title;\n\n            $duplicateIndex = array_search($groupingField, array_column($duplicates, 'groupingField'));\n            if (false !== $duplicateIndex) {\n                foreach ($duplicates[$duplicateIndex]->items as $duplicateItem) {\n                    $results[] = $duplicateItem;\n                }\n            }\n        }\n\n        $pagerfanta = new MbinPagerfanta($pagination->getAdapter());\n        $pagerfanta->setCurrentPage($pagination->getCurrentPage());\n        $pagerfanta->setMaxNbPages($pagination->getNbPages());\n        $pagerfanta->setCurrentPageResults($results);\n\n        return $pagerfanta;\n    }\n\n    /**\n     * @throws \\DateMalformedStringException\n     */\n    private function getCursorByCriteria(string $sortOption, ?string $cursor): int|\\DateTimeImmutable\n    {\n        $guessedCursor = $this->contentRepository->guessInitialCursor($sortOption);\n        if ($guessedCursor instanceof \\DateTimeImmutable) {\n            $currentCursor = null !== $cursor ? new \\DateTimeImmutable($cursor) : $guessedCursor;\n        } elseif (\\is_int($guessedCursor)) {\n            $currentCursor = null !== $cursor ? \\intval($cursor) : $guessedCursor;\n        } else {\n            throw new \\LogicException(\\get_class($guessedCursor).' is not accounted for');\n        }\n\n        return $currentCursor;\n    }\n}\n"
  },
  {
    "path": "src/Controller/Entry/EntryLockController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Entry;\n\nuse App\\Controller\\AbstractController;\nuse App\\Entity\\Entry;\nuse App\\Entity\\Magazine;\nuse App\\Service\\EntryManager;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass EntryLockController extends AbstractController\n{\n    public function __construct(\n        private readonly EntryManager $manager,\n    ) {\n    }\n\n    #[IsGranted('ROLE_USER')]\n    #[IsGranted('lock', subject: 'entry')]\n    public function __invoke(\n        #[MapEntity(mapping: ['magazine_name' => 'name'])]\n        Magazine $magazine,\n        #[MapEntity(id: 'entry_id')]\n        Entry $entry,\n        Request $request,\n    ): Response {\n        $this->validateCsrf('entry_lock', $request->getPayload()->get('token'));\n\n        $entry = $this->manager->toggleLock($entry, $this->getUserOrThrow());\n\n        $this->addFlash(\n            'success',\n            $entry->isLocked ? 'flash_thread_lock_success' : 'flash_thread_unlock_success'\n        );\n\n        return $this->redirectToRefererOrHome($request);\n    }\n}\n"
  },
  {
    "path": "src/Controller/Entry/EntryModerateController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Entry;\n\nuse App\\Controller\\AbstractController;\nuse App\\Entity\\Entry;\nuse App\\Entity\\Magazine;\nuse App\\Form\\LangType;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass EntryModerateController extends AbstractController\n{\n    #[IsGranted('ROLE_USER')]\n    #[IsGranted('moderate', subject: 'entry')]\n    public function __invoke(\n        #[MapEntity(mapping: ['magazine_name' => 'name'])]\n        Magazine $magazine,\n        #[MapEntity(id: 'entry_id')]\n        Entry $entry,\n        Request $request,\n    ): Response {\n        if ($entry->magazine !== $magazine) {\n            return $this->redirectToRoute(\n                'entry_single',\n                ['magazine_name' => $entry->magazine->name, 'entry_id' => $entry->getId(), 'slug' => $entry->slug],\n                301\n            );\n        }\n\n        $form = $this->createForm(LangType::class);\n        //        $form->get('lang')->setData(['lang' => $entry->lang]);\n\n        if ($request->isXmlHttpRequest()) {\n            return new JsonResponse([\n                'html' => $this->renderView('entry/_moderate_panel.html.twig', [\n                    'magazine' => $magazine,\n                    'entry' => $entry,\n                    'form' => $form->createView(),\n                ]),\n            ]);\n        }\n\n        return $this->render('entry/moderate.html.twig', [\n            'magazine' => $magazine,\n            'entry' => $entry,\n            'form' => $form->createView(),\n        ]);\n    }\n}\n"
  },
  {
    "path": "src/Controller/Entry/EntryPinController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Entry;\n\nuse App\\Controller\\AbstractController;\nuse App\\Entity\\Entry;\nuse App\\Entity\\Magazine;\nuse App\\Service\\EntryManager;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass EntryPinController extends AbstractController\n{\n    public function __construct(\n        private readonly EntryManager $manager,\n    ) {\n    }\n\n    #[IsGranted('ROLE_USER')]\n    #[IsGranted('moderate', subject: 'entry')]\n    public function __invoke(\n        #[MapEntity(mapping: ['magazine_name' => 'name'])]\n        Magazine $magazine,\n        #[MapEntity(id: 'entry_id')]\n        Entry $entry,\n        Request $request,\n    ): Response {\n        $this->validateCsrf('entry_pin', $request->getPayload()->get('token'));\n\n        $entry = $this->manager->pin($entry, $this->getUserOrThrow());\n\n        $this->addFlash(\n            'success',\n            $entry->sticky ? 'flash_thread_pin_success' : 'flash_thread_unpin_success'\n        );\n\n        return $this->redirectToRefererOrHome($request);\n    }\n}\n"
  },
  {
    "path": "src/Controller/Entry/EntrySingleController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Entry;\n\nuse App\\Controller\\AbstractController;\nuse App\\Controller\\Traits\\PrivateContentTrait;\nuse App\\Controller\\User\\ThemeSettingsController;\nuse App\\DTO\\EntryCommentDto;\nuse App\\Entity\\Entry;\nuse App\\Entity\\Magazine;\nuse App\\Event\\Entry\\EntryHasBeenSeenEvent;\nuse App\\Form\\EntryCommentType;\nuse App\\PageView\\EntryCommentPageView;\nuse App\\Repository\\Criteria;\nuse App\\Repository\\EntryCommentRepository;\nuse App\\Repository\\ImageRepository;\nuse App\\Service\\MentionManager;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Pagerfanta\\PagerfantaInterface;\nuse Psr\\EventDispatcher\\EventDispatcherInterface;\nuse Psr\\Log\\LoggerInterface;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Bundle\\SecurityBundle\\Security;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\n\nclass EntrySingleController extends AbstractController\n{\n    use PrivateContentTrait;\n\n    public function __construct(\n        private readonly Security $security,\n        private readonly ImageRepository $imageRepository,\n        private readonly EntryCommentRepository $commentRepository,\n        private readonly EventDispatcherInterface $dispatcher,\n        private readonly MentionManager $mentionManager,\n        private readonly LoggerInterface $logger,\n        private readonly EntityManagerInterface $entityManager,\n    ) {\n    }\n\n    public function __invoke(\n        #[MapEntity(mapping: ['magazine_name' => 'name'])]\n        Magazine $magazine,\n        #[MapEntity(id: 'entry_id')]\n        Entry $entry,\n        ?string $sortBy,\n        Request $request,\n    ): Response {\n        if ($entry->magazine !== $magazine) {\n            return $this->redirectToRoute(\n                'entry_single',\n                ['magazine_name' => $entry->magazine->name, 'entry_id' => $entry->getId(), 'slug' => $entry->slug],\n                301\n            );\n        }\n\n        $response = new Response();\n        if ($entry->apId && $entry->user->apId) {\n            $response->headers->set('X-Robots-Tag', 'noindex, nofollow');\n        }\n\n        $this->handlePrivateContent($entry);\n\n        $images = [];\n        if ($entry->image) {\n            $images[] = $entry->image;\n        }\n        $images = array_merge($images, $this->commentRepository->findImagesByEntry($entry));\n        $this->imageRepository->redownloadImagesIfNecessary($images);\n\n        $criteria = new EntryCommentPageView($this->getPageNb($request), $this->security);\n        $criteria->showSortOption($criteria->resolveSort($sortBy));\n        $criteria->entry = $entry;\n\n        if (ThemeSettingsController::CHAT === $request->cookies->get(\n            ThemeSettingsController::ENTRY_COMMENTS_VIEW\n        )) {\n            $criteria->showSortOption(Criteria::SORT_OLD);\n            $criteria->perPage = 100;\n            $criteria->onlyParents = false;\n        }\n\n        $comments = $this->commentRepository->findByCriteria($criteria);\n\n        $commentObjects = [...$comments->getCurrentPageResults()];\n        $this->commentRepository->hydrate(...$commentObjects);\n        $this->commentRepository->hydrateChildren(...$commentObjects);\n\n        $this->dispatcher->dispatch(new EntryHasBeenSeenEvent($entry));\n\n        if ($request->isXmlHttpRequest()) {\n            return $this->getJsonResponse($magazine, $entry, $comments);\n        }\n\n        $user = $this->getUser();\n\n        $dto = new EntryCommentDto();\n        if ($user && $user->addMentionsEntries && $entry->user !== $user) {\n            $dto->body = $this->mentionManager->addHandle([$entry->user->username])[0];\n        }\n\n        return $this->render(\n            'entry/single.html.twig',\n            [\n                'user' => $user,\n                'magazine' => $magazine,\n                'comments' => $comments,\n                'entry' => $entry,\n                'criteria' => $criteria,\n                'form' => $this->createForm(EntryCommentType::class, $dto, [\n                    'action' => $this->generateUrl(\n                        'entry_comment_create',\n                        [\n                            'magazine_name' => $entry->magazine->name,\n                            'entry_id' => $entry->getId(),\n                        ]\n                    ),\n                    'parentLanguage' => $entry->lang,\n                ])->createView(),\n            ],\n            $response\n        );\n    }\n\n    private function getJsonResponse(Magazine $magazine, Entry $entry, PagerfantaInterface $comments): JsonResponse\n    {\n        return new JsonResponse(\n            [\n                'html' => $this->renderView(\n                    'entry/_single_popup.html.twig',\n                    [\n                        'magazine' => $magazine,\n                        'comments' => $comments,\n                        'entry' => $entry,\n                    ]\n                ),\n            ]\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Entry/EntryTemplateTrait.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Entry;\n\ntrait EntryTemplateTrait\n{\n    private function getTemplateName(?bool $edit = false): string\n    {\n        $prefix = $edit ? 'edit' : 'create';\n\n        return \"entry/{$prefix}_entry.html.twig\";\n    }\n}\n"
  },
  {
    "path": "src/Controller/Entry/EntryVotersController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Entry;\n\nuse App\\Controller\\AbstractController;\nuse App\\Entity\\Contracts\\VotableInterface;\nuse App\\Entity\\Entry;\nuse App\\Entity\\Magazine;\nuse App\\Service\\SettingsManager;\nuse App\\Utils\\DownvotesMode;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\n\nclass EntryVotersController extends AbstractController\n{\n    public function __construct(\n        private readonly SettingsManager $settingsManager,\n    ) {\n    }\n\n    public function __invoke(\n        string $type,\n        #[MapEntity(mapping: ['magazine_name' => 'name'])]\n        Magazine $magazine,\n        #[MapEntity(id: 'entry_id')]\n        Entry $entry,\n        Request $request,\n    ): Response {\n        if ('down' === $type && DownvotesMode::Enabled !== $this->settingsManager->getDownvotesMode()) {\n            $votes = [];\n        } else {\n            $votes = $entry->votes->filter(\n                fn ($e) => $e->choice === ('up' === $type ? VotableInterface::VOTE_UP : VotableInterface::VOTE_DOWN)\n            );\n        }\n\n        return $this->render('entry/voters.html.twig', [\n            'magazine' => $magazine,\n            'entry' => $entry,\n            'votes' => $votes,\n        ]);\n    }\n}\n"
  },
  {
    "path": "src/Controller/FaqController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller;\n\nuse App\\Repository\\SiteRepository;\nuse App\\Service\\SettingsManager;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\n\nclass FaqController extends AbstractController\n{\n    public function __invoke(SettingsManager $settings, SiteRepository $repository, Request $request): Response\n    {\n        $site = $repository->findAll();\n\n        return $this->render(\n            'page/faq.html.twig',\n            [\n                'body' => $site[0]->faq ?? '',\n            ]\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/FavouriteController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller;\n\nuse App\\Entity\\Contracts\\FavouriteInterface;\nuse App\\Service\\FavouriteManager;\nuse App\\Service\\GenerateHtmlClassService;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass FavouriteController extends AbstractController\n{\n    public function __construct(private readonly GenerateHtmlClassService $classService)\n    {\n    }\n\n    #[IsGranted('ROLE_USER')]\n    public function __invoke(FavouriteInterface $subject, Request $request, FavouriteManager $manager): Response\n    {\n        $manager->toggle($this->getUserOrThrow(), $subject);\n\n        if ($request->isXmlHttpRequest()) {\n            return new JsonResponse(\n                [\n                    'html' => $this->renderView('components/_ajax.html.twig', [\n                        'component' => 'vote',\n                        'attributes' => [\n                            'subject' => $subject,\n                            'showDownvote' => str_contains(\\get_class($subject), 'Entry'),\n                        ],\n                    ]\n                    ),\n                ]\n            );\n        }\n\n        return $this->redirectToRefererOrHome($request, $this->classService->fromEntity($subject));\n    }\n}\n"
  },
  {
    "path": "src/Controller/FederationController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller;\n\nuse App\\Repository\\InstanceRepository;\nuse App\\Service\\SettingsManager;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\n\nclass FederationController extends AbstractController\n{\n    public function __invoke(InstanceRepository $instanceRepository, SettingsManager $settings, Request $request): Response\n    {\n        if (!$settings->get('KBIN_FEDERATION_PAGE_ENABLED')) {\n            return $this->redirectToRoute('front');\n        }\n\n        $allowedInstances = $instanceRepository->getAllowedInstances($settings->getUseAllowList());\n        $defederatedInstances = $instanceRepository->getBannedInstances();\n        $deadInstances = $instanceRepository->getDeadInstances();\n\n        return $this->render(\n            'page/federation.html.twig',\n            [\n                'allowedInstances' => $allowedInstances,\n                'defederatedInstances' => $defederatedInstances,\n                'deadInstances' => $deadInstances,\n            ]\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Magazine/MagazineAbandonedController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Magazine;\n\nuse App\\Controller\\AbstractController;\nuse App\\Repository\\MagazineRepository;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\n\nclass MagazineAbandonedController extends AbstractController\n{\n    public function __construct(\n        private readonly MagazineRepository $repository,\n    ) {\n    }\n\n    public function __invoke(Request $request): Response\n    {\n        return $this->render(\n            'magazine/list_abandoned.html.twig',\n            [\n                'magazines' => $this->repository->findAbandoned($request->query->getInt('p', 1)),\n            ]\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Magazine/MagazineBlockController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Magazine;\n\nuse App\\Controller\\AbstractController;\nuse App\\Entity\\Magazine;\nuse App\\Service\\MagazineManager;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass MagazineBlockController extends AbstractController\n{\n    public function __construct(private readonly MagazineManager $manager)\n    {\n    }\n\n    #[IsGranted('ROLE_USER')]\n    #[IsGranted('block', subject: 'magazine')]\n    public function block(#[MapEntity(mapping: ['name' => 'name'])] Magazine $magazine, Request $request): Response\n    {\n        $this->manager->block($magazine, $this->getUserOrThrow());\n\n        if ($request->isXmlHttpRequest()) {\n            return $this->getJsonResponse($magazine);\n        }\n\n        return $this->redirectToRefererOrHome($request);\n    }\n\n    #[IsGranted('ROLE_USER')]\n    #[IsGranted('block', subject: 'magazine')]\n    public function unblock(#[MapEntity(mapping: ['name' => 'name'])] Magazine $magazine, Request $request): Response\n    {\n        $this->manager->unblock($magazine, $this->getUserOrThrow());\n\n        if ($request->isXmlHttpRequest()) {\n            return $this->getJsonResponse($magazine);\n        }\n\n        return $this->redirectToRefererOrHome($request);\n    }\n\n    private function getJsonResponse(Magazine $magazine): JsonResponse\n    {\n        return new JsonResponse(\n            [\n                'html' => $this->renderView(\n                    'components/_ajax.html.twig',\n                    [\n                        'component' => 'magazine_sub',\n                        'attributes' => [\n                            'magazine' => $magazine,\n                        ],\n                    ]\n                ),\n            ]\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Magazine/MagazineCreateController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Magazine;\n\nuse App\\Controller\\AbstractController;\nuse App\\Form\\MagazineType;\nuse App\\Service\\IpResolver;\nuse App\\Service\\MagazineManager;\nuse App\\Service\\SettingsManager;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\Security\\Core\\Exception\\AccessDeniedException;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass MagazineCreateController extends AbstractController\n{\n    public function __construct(\n        private readonly MagazineManager $manager,\n        private readonly IpResolver $ipResolver,\n        private readonly SettingsManager $settingsManager,\n    ) {\n    }\n\n    #[IsGranted('ROLE_USER')]\n    public function __invoke(Request $request): Response\n    {\n        $user = $this->getUserOrThrow();\n\n        if (true === $this->settingsManager->get('MBIN_RESTRICT_MAGAZINE_CREATION') && !$user->isAdmin() && !$user->isModerator()) {\n            throw new AccessDeniedException();\n        }\n\n        $form = $this->createForm(MagazineType::class);\n        $form->handleRequest($request);\n\n        if ($form->isSubmitted() && $form->isValid()) {\n            $dto = $form->getData();\n            $dto->ip = $this->ipResolver->resolve();\n            $magazine = $this->manager->create($dto, $this->getUserOrThrow());\n\n            $this->addFlash('success', 'flash_magazine_new_success');\n\n            return $this->redirectToMagazine($magazine);\n        }\n\n        return $this->render(\n            'magazine/create.html.twig',\n            [\n                'user' => $user,\n                'form' => $form->createView(),\n            ],\n            new Response(null, $form->isSubmitted() && !$form->isValid() ? 422 : 200)\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Magazine/MagazineDeleteController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Magazine;\n\nuse App\\Controller\\AbstractController;\nuse App\\Entity\\Magazine;\nuse App\\Service\\MagazineManager;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass MagazineDeleteController extends AbstractController\n{\n    public function __construct(private readonly MagazineManager $manager)\n    {\n    }\n\n    #[IsGranted('ROLE_USER')]\n    #[IsGranted('delete', subject: 'magazine')]\n    public function delete(#[MapEntity(mapping: ['name' => 'name'])] Magazine $magazine, Request $request): Response\n    {\n        $this->validateCsrf('magazine_delete', $request->getPayload()->get('token'));\n\n        $this->manager->delete($magazine);\n\n        return $this->redirectToRefererOrHome($request);\n    }\n\n    #[IsGranted('ROLE_USER')]\n    #[IsGranted('delete', subject: 'magazine')]\n    public function restore(#[MapEntity(mapping: ['name' => 'name'])] Magazine $magazine, Request $request): Response\n    {\n        $this->validateCsrf('magazine_restore', $request->getPayload()->get('token'));\n\n        $this->manager->restore($magazine);\n\n        return $this->redirectToRefererOrHome($request);\n    }\n\n    #[IsGranted('ROLE_USER')]\n    #[IsGranted('purge', subject: 'magazine')]\n    public function purge(#[MapEntity(mapping: ['name' => 'name'])] Magazine $magazine, Request $request): Response\n    {\n        $this->validateCsrf('magazine_purge', $request->getPayload()->get('token'));\n\n        $this->manager->purge($magazine);\n\n        return $this->redirectToRoute('front');\n    }\n\n    #[IsGranted('ROLE_USER')]\n    #[IsGranted('purge', subject: 'magazine')]\n    public function purgeContent(#[MapEntity(mapping: ['name' => 'name'])] Magazine $magazine, Request $request): Response\n    {\n        $this->validateCsrf('magazine_purge_content', $request->getPayload()->get('token'));\n\n        $this->manager->purge($magazine, true);\n\n        return $this->redirectToRefererOrHome($request);\n    }\n}\n"
  },
  {
    "path": "src/Controller/Magazine/MagazineListController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Magazine;\n\nuse App\\Controller\\AbstractController;\nuse App\\Entity\\User;\nuse App\\Form\\MagazinePageViewType;\nuse App\\PageView\\MagazinePageView;\nuse App\\Repository\\Criteria;\nuse App\\Repository\\MagazineRepository;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\Security\\Core\\Authentication\\Token\\Storage\\TokenStorageInterface;\n\nclass MagazineListController extends AbstractController\n{\n    public function __construct(\n        private readonly TokenStorageInterface $tokenStorage,\n        private readonly MagazineRepository $repository,\n    ) {\n    }\n\n    public function __invoke(string $sortBy, string $view, Request $request): Response\n    {\n        /** @var User|null $user */\n        $user = $this->tokenStorage->getToken()?->getUser();\n\n        $criteria = new MagazinePageView(\n            $this->getPageNb($request),\n            $sortBy,\n            Criteria::AP_ALL,\n            $user?->hideAdult ? MagazinePageView::ADULT_HIDE : MagazinePageView::ADULT_SHOW,\n        );\n\n        $form = $this->createForm(MagazinePageViewType::class, $criteria);\n\n        $form->handleRequest($request);\n\n        $magazines = $this->repository->findPaginated($criteria);\n\n        return $this->render(\n            'magazine/list_all.html.twig',\n            [\n                'form' => $form,\n                'magazines' => $magazines,\n                'view' => $view,\n                'criteria' => $criteria,\n            ]\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Magazine/MagazineModController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Magazine;\n\nuse App\\Controller\\AbstractController;\nuse App\\Entity\\Magazine;\nuse App\\Repository\\MagazineRepository;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\n\nclass MagazineModController extends AbstractController\n{\n    public function __invoke(\n        #[MapEntity(mapping: ['name' => 'name'])]\n        Magazine $magazine,\n        MagazineRepository $repository,\n        Request $request,\n    ): Response {\n        $moderators = $repository->findModerators($magazine, $this->getPageNb($request));\n\n        return $this->render(\n            'magazine/moderators.html.twig',\n            [\n                'magazine' => $magazine,\n                'moderators' => $moderators,\n            ]\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Magazine/MagazineModeratorRequestController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Magazine;\n\nuse App\\Controller\\AbstractController;\nuse App\\Entity\\Magazine;\nuse App\\Service\\MagazineManager;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\Security\\Core\\Exception\\AccessDeniedException;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass MagazineModeratorRequestController extends AbstractController\n{\n    public function __construct(private readonly MagazineManager $manager)\n    {\n    }\n\n    #[IsGranted('ROLE_USER')]\n    #[IsGranted('subscribe', subject: 'magazine')]\n    public function __invoke(#[MapEntity(mapping: ['name' => 'name'])] Magazine $magazine, Request $request): Response\n    {\n        // applying to be a moderator is only supported for local magazines\n        if ($magazine->apId) {\n            throw new AccessDeniedException();\n        }\n\n        $this->validateCsrf('moderator_request', $request->getPayload()->get('token'));\n\n        $this->manager->toggleModeratorRequest($magazine, $this->getUserOrThrow());\n\n        return $this->redirectToRefererOrHome($request);\n    }\n}\n"
  },
  {
    "path": "src/Controller/Magazine/MagazineOwnershipRequestController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Magazine;\n\nuse App\\Controller\\AbstractController;\nuse App\\Entity\\Magazine;\nuse App\\Service\\MagazineManager;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\Security\\Core\\Exception\\AccessDeniedException;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass MagazineOwnershipRequestController extends AbstractController\n{\n    public function __construct(private readonly MagazineManager $manager)\n    {\n    }\n\n    #[IsGranted('ROLE_USER')]\n    #[IsGranted('subscribe', subject: 'magazine')]\n    public function toggle(#[MapEntity(mapping: ['name' => 'name'])] Magazine $magazine, Request $request): Response\n    {\n        // applying to be owner is only supported for local magazines\n        if ($magazine->apId) {\n            throw new AccessDeniedException();\n        }\n\n        $this->validateCsrf('magazine_ownership_request', $request->getPayload()->get('token'));\n\n        $this->manager->toggleOwnershipRequest($magazine, $this->getUserOrThrow());\n\n        return $this->redirectToRefererOrHome($request);\n    }\n\n    #[IsGranted('ROLE_ADMIN')]\n    public function accept(#[MapEntity(mapping: ['name' => 'name'])] Magazine $magazine, Request $request): Response\n    {\n        $this->validateCsrf('magazine_ownership_request', $request->getPayload()->get('token'));\n\n        $user = $this->getUserOrThrow();\n        $this->manager->acceptOwnershipRequest($magazine, $user, $user);\n\n        return $this->redirectToRefererOrHome($request);\n    }\n}\n"
  },
  {
    "path": "src/Controller/Magazine/MagazinePeopleFrontController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Magazine;\n\nuse App\\Controller\\AbstractController;\nuse App\\Entity\\Magazine;\nuse App\\Repository\\MagazineRepository;\nuse App\\Repository\\UserRepository;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\n\nclass MagazinePeopleFrontController extends AbstractController\n{\n    public function __construct(\n        private readonly MagazineRepository $magazineRepository,\n        private readonly UserRepository $userRepository,\n    ) {\n    }\n\n    public function __invoke(\n        #[MapEntity(mapping: ['name' => 'name'])]\n        Magazine $magazine,\n        ?string $category,\n        Request $request,\n    ): Response {\n        return $this->render(\n            'people/front.html.twig', [\n                'magazine' => $magazine,\n                'magazines' => array_filter(\n                    $this->magazineRepository->findByActivity(),\n                    fn ($val) => 'random' !== $val->name && $val !== $magazine\n                ),\n                'local' => $this->userRepository->findUsersForMagazine($magazine, limit: 28, limitTime: $magazine->getContentCount() > 1000),\n                'federated' => $this->userRepository->findUsersForMagazine($magazine, true, limit: 28, limitTime: $magazine->getContentCount() > 1000),\n            ]\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Magazine/MagazineRemoveSubscriptionsController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Magazine;\n\nuse App\\Controller\\AbstractController;\nuse App\\Entity\\Magazine;\nuse App\\Service\\MagazineManager;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass MagazineRemoveSubscriptionsController extends AbstractController\n{\n    public function __construct(private readonly MagazineManager $manager)\n    {\n    }\n\n    #[IsGranted('ROLE_ADMIN')]\n    public function __invoke(#[MapEntity(mapping: ['name' => 'name'])] Magazine $magazine, Request $request): Response\n    {\n        $this->validateCsrf('magazine_remove_subscriptions', $request->getPayload()->get('token'));\n\n        $this->manager->removeSubscriptions($magazine);\n\n        return $this->redirectToRefererOrHome($request);\n    }\n}\n"
  },
  {
    "path": "src/Controller/Magazine/MagazineSubController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Magazine;\n\nuse App\\Controller\\AbstractController;\nuse App\\Entity\\Magazine;\nuse App\\Service\\MagazineManager;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass MagazineSubController extends AbstractController\n{\n    public function __construct(private readonly MagazineManager $manager)\n    {\n    }\n\n    #[IsGranted('ROLE_USER')]\n    #[IsGranted('subscribe', subject: 'magazine')]\n    public function subscribe(#[MapEntity(mapping: ['name' => 'name'])] Magazine $magazine, Request $request): Response\n    {\n        $this->manager->subscribe($magazine, $this->getUserOrThrow());\n\n        if ($request->isXmlHttpRequest()) {\n            return $this->getJsonResponse($magazine);\n        }\n\n        return $this->redirectToRefererOrHome($request);\n    }\n\n    #[IsGranted('ROLE_USER')]\n    #[IsGranted('subscribe', subject: 'magazine')]\n    public function unsubscribe(#[MapEntity(mapping: ['name' => 'name'])] Magazine $magazine, Request $request): Response\n    {\n        $this->manager->unsubscribe($magazine, $this->getUserOrThrow());\n\n        if ($request->isXmlHttpRequest()) {\n            return $this->getJsonResponse($magazine);\n        }\n\n        return $this->redirectToRefererOrHome($request);\n    }\n\n    private function getJsonResponse(Magazine $magazine): JsonResponse\n    {\n        return new JsonResponse(\n            [\n                'html' => $this->renderView(\n                    'components/_ajax.html.twig',\n                    [\n                        'component' => 'magazine_sub',\n                        'attributes' => [\n                            'magazine' => $magazine,\n                        ],\n                    ]\n                ),\n            ]\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Magazine/Panel/MagazineBadgeController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Magazine\\Panel;\n\nuse App\\Controller\\AbstractController;\nuse App\\DTO\\BadgeDto;\nuse App\\Entity\\Badge;\nuse App\\Entity\\Magazine;\nuse App\\Form\\BadgeType;\nuse App\\Repository\\MagazineRepository;\nuse App\\Service\\BadgeManager;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass MagazineBadgeController extends AbstractController\n{\n    public function __construct(\n        private readonly MagazineRepository $repository,\n    ) {\n    }\n\n    #[IsGranted('ROLE_USER')]\n    #[IsGranted('moderate', subject: 'magazine')]\n    public function badges(\n        #[MapEntity(mapping: ['name' => 'name'])]\n        Magazine $magazine,\n        BadgeManager $manager,\n        Request $request,\n    ): Response {\n        $badges = $this->repository->findBadges($magazine);\n\n        $dto = new BadgeDto();\n\n        $form = $this->createForm(BadgeType::class, $dto);\n        $form->handleRequest($request);\n\n        if ($form->isSubmitted() && $form->isValid()) {\n            $dto->magazine = $magazine;\n            $manager->create($dto);\n\n            return $this->redirectToRefererOrHome($request);\n        }\n\n        return $this->render(\n            'magazine/panel/badges.html.twig',\n            [\n                'badges' => $badges,\n                'magazine' => $magazine,\n                'form' => $form->createView(),\n            ]\n        );\n    }\n\n    #[IsGranted('ROLE_USER')]\n    #[IsGranted('moderate', subject: 'magazine')]\n    public function remove(\n        #[MapEntity(mapping: ['magazine_name' => 'name'])]\n        Magazine $magazine,\n        #[MapEntity(id: 'badge_id')]\n        Badge $badge,\n        BadgeManager $manager,\n        Request $request,\n    ): Response {\n        $this->validateCsrf('badge_remove', $request->getPayload()->get('token'));\n\n        $manager->delete($badge);\n\n        return $this->redirectToRefererOrHome($request);\n    }\n}\n"
  },
  {
    "path": "src/Controller/Magazine/Panel/MagazineBanController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Magazine\\Panel;\n\nuse App\\Controller\\AbstractController;\nuse App\\DTO\\MagazineBanDto;\nuse App\\Entity\\Magazine;\nuse App\\Entity\\User;\nuse App\\Form\\MagazineBanType;\nuse App\\Repository\\MagazineRepository;\nuse App\\Repository\\UserRepository;\nuse App\\Service\\MagazineManager;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass MagazineBanController extends AbstractController\n{\n    public function __construct(\n        private readonly MagazineManager $manager,\n        private readonly MagazineRepository $repository,\n        private readonly UserRepository $userRepository,\n    ) {\n    }\n\n    #[IsGranted('ROLE_USER')]\n    #[IsGranted('moderate', subject: 'magazine')]\n    public function bans(\n        #[MapEntity(mapping: ['name' => 'name'])]\n        Magazine $magazine,\n        UserRepository $repository,\n        Request $request,\n    ): Response {\n        return $this->render(\n            'magazine/panel/bans.html.twig',\n            [\n                'bans' => $this->repository->findBans($magazine, $this->getPageNb($request)),\n                'magazine' => $magazine,\n            ]\n        );\n    }\n\n    #[IsGranted('ROLE_USER')]\n    #[IsGranted('moderate', subject: 'magazine')]\n    public function ban(\n        #[MapEntity(mapping: ['name' => 'name'])]\n        Magazine $magazine,\n        Request $request,\n        #[MapEntity(mapping: ['username' => 'username'])]\n        ?User $user = null,\n    ): Response {\n        if (!$user) {\n            $user = $this->userRepository->findOneByUsername($request->query->get('username'));\n        }\n\n        $form = $this->createForm(MagazineBanType::class, $magazineBanDto = new MagazineBanDto());\n        $form->handleRequest($request);\n\n        if ($form->isSubmitted() && $form->isValid()) {\n            $this->manager->ban($magazine, $user, $this->getUserOrThrow(), $magazineBanDto);\n\n            return $this->redirectToRoute('magazine_panel_bans', ['name' => $magazine->name]);\n        }\n\n        return $this->render(\n            'magazine/panel/ban.html.twig',\n            [\n                'magazine' => $magazine,\n                'user' => $user,\n                'form' => $form->createView(),\n            ]\n        );\n    }\n\n    #[IsGranted('ROLE_USER')]\n    #[IsGranted('moderate', subject: 'magazine')]\n    public function unban(\n        #[MapEntity(mapping: ['name' => 'name'])]\n        Magazine $magazine,\n        #[MapEntity(mapping: ['username' => 'username'])]\n        User $user,\n        Request $request,\n    ): Response {\n        $this->validateCsrf('magazine_unban', $request->getPayload()->get('token'));\n\n        $this->manager->unban($magazine, $user);\n\n        return $this->redirectToRefererOrHome($request);\n    }\n}\n"
  },
  {
    "path": "src/Controller/Magazine/Panel/MagazineEditController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Magazine\\Panel;\n\nuse App\\Controller\\AbstractController;\nuse App\\Entity\\Magazine;\nuse App\\Form\\MagazineType;\nuse App\\Service\\MagazineManager;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass MagazineEditController extends AbstractController\n{\n    public function __construct(\n        private readonly MagazineManager $manager,\n    ) {\n    }\n\n    #[IsGranted('ROLE_USER')]\n    #[IsGranted('edit', subject: 'magazine')]\n    public function __invoke(\n        #[MapEntity(mapping: ['name' => 'name'])]\n        Magazine $magazine,\n        Request $request,\n    ): Response {\n        $magazineDto = $this->manager->createDto($magazine);\n\n        $form = $this->createForm(MagazineType::class, $magazineDto);\n        $form->handleRequest($request);\n\n        if ($form->isSubmitted() && $form->isValid()) {\n            $this->manager->edit($magazine, $magazineDto, $this->getUserOrThrow());\n\n            $this->addFlash('success', 'flash_magazine_edit_success');\n\n            return $this->redirectToRefererOrHome($request);\n        }\n\n        return $this->render(\n            'magazine/panel/general.html.twig',\n            [\n                'magazine' => $magazine,\n                'form' => $form->createView(),\n            ]\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Magazine/Panel/MagazineModeratorController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Magazine\\Panel;\n\nuse App\\Controller\\AbstractController;\nuse App\\DTO\\ModeratorDto;\nuse App\\Entity\\Magazine;\nuse App\\Entity\\Moderator;\nuse App\\Form\\ModeratorType;\nuse App\\Repository\\MagazineRepository;\nuse App\\Service\\MagazineManager;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass MagazineModeratorController extends AbstractController\n{\n    public function __construct(\n        private readonly MagazineManager $manager,\n        private readonly MagazineRepository $repository,\n    ) {\n    }\n\n    #[IsGranted('ROLE_USER')]\n    #[IsGranted('edit', subject: 'magazine')]\n    public function moderators(\n        #[MapEntity(mapping: ['name' => 'name'])]\n        Magazine $magazine,\n        Request $request,\n    ): Response {\n        $dto = new ModeratorDto($magazine);\n\n        $form = $this->createForm(ModeratorType::class, $dto);\n        $form->handleRequest($request);\n\n        if ($form->isSubmitted() && $form->isValid()) {\n            $dto->addedBy = $this->getUserOrThrow();\n            $this->manager->addModerator($dto);\n        }\n\n        $moderators = $this->repository->findModerators($magazine, $this->getPageNb($request));\n\n        return $this->render(\n            'magazine/panel/moderators.html.twig',\n            [\n                'moderators' => $moderators,\n                'magazine' => $magazine,\n                'form' => $form->createView(),\n            ]\n        );\n    }\n\n    #[IsGranted('ROLE_USER')]\n    #[IsGranted('edit', subject: 'magazine')]\n    public function remove(\n        #[MapEntity(mapping: ['magazine_name' => 'name'])]\n        Magazine $magazine,\n        #[MapEntity(id: 'moderator_id')]\n        Moderator $moderator,\n        Request $request,\n    ): Response {\n        $this->validateCsrf('remove_moderator', $request->getPayload()->get('token'));\n\n        $this->manager->removeModerator($moderator, $this->getUser());\n\n        return $this->redirectToRefererOrHome($request);\n    }\n}\n"
  },
  {
    "path": "src/Controller/Magazine/Panel/MagazineModeratorRequestsController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Magazine\\Panel;\n\nuse App\\Controller\\AbstractController;\nuse App\\Entity\\Magazine;\nuse App\\Entity\\User;\nuse App\\Repository\\ModeratorRequestRepository;\nuse App\\Service\\MagazineManager;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass MagazineModeratorRequestsController extends AbstractController\n{\n    public function __construct(\n        private readonly MagazineManager $manager,\n        private readonly ModeratorRequestRepository $repository,\n    ) {\n    }\n\n    #[IsGranted('edit', subject: 'magazine')]\n    public function requests(\n        #[MapEntity(mapping: ['name' => 'name'])]\n        Magazine $magazine,\n        Request $request,\n    ): Response {\n        return $this->render('magazine/panel/moderator_requests.html.twig', [\n            'magazine' => $magazine,\n            'requests' => $this->repository->findAllPaginated($magazine, $request->get('page', 1)),\n        ]);\n    }\n\n    #[IsGranted('ROLE_USER')]\n    #[IsGranted('edit', subject: 'magazine')]\n    public function accept(\n        #[MapEntity(mapping: ['name' => 'name'])]\n        Magazine $magazine,\n        #[MapEntity(mapping: ['username' => 'username'])]\n        User $user,\n        Request $request,\n    ): Response {\n        $this->validateCsrf('magazine_panel_moderator_request_accept', $request->getPayload()->get('token'));\n\n        $this->manager->acceptModeratorRequest($magazine, $user, $this->getUserOrThrow());\n\n        return $this->redirectToRefererOrHome($request);\n    }\n\n    #[IsGranted('ROLE_USER')]\n    #[IsGranted('edit', subject: 'magazine')]\n    public function reject(\n        #[MapEntity(mapping: ['name' => 'name'])]\n        Magazine $magazine,\n        #[MapEntity(mapping: ['username' => 'username'])]\n        User $user,\n        Request $request,\n    ): Response {\n        $this->validateCsrf('magazine_panel_moderator_request_reject', $request->getPayload()->get('token'));\n\n        $this->manager->toggleModeratorRequest($magazine, $user);\n\n        return $this->redirectToRefererOrHome($request);\n    }\n}\n"
  },
  {
    "path": "src/Controller/Magazine/Panel/MagazineReportController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Magazine\\Panel;\n\nuse App\\Controller\\AbstractController;\nuse App\\Entity\\Magazine;\nuse App\\Entity\\Report;\nuse App\\Repository\\MagazineRepository;\nuse App\\Repository\\NotificationRepository;\nuse App\\Service\\ReportManager;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass MagazineReportController extends AbstractController\n{\n    public function __construct(\n        private readonly MagazineRepository $repository,\n        private readonly NotificationRepository $notificationRepository,\n        private readonly ReportManager $reportManager,\n    ) {\n    }\n\n    #[IsGranted('ROLE_USER')]\n    #[IsGranted('moderate', subject: 'magazine')]\n    public function reports(\n        #[MapEntity(mapping: ['name' => 'name'])]\n        Magazine $magazine,\n        Request $request,\n        string $status,\n    ): Response {\n        $reports = $this->repository->findReports($magazine, $this->getPageNb($request), status: $status);\n        $this->notificationRepository->markReportNotificationsInMagazineAsRead($this->getUserOrThrow(), $magazine);\n\n        return $this->render(\n            'magazine/panel/reports.html.twig',\n            [\n                'reports' => $reports,\n                'magazine' => $magazine,\n            ]\n        );\n    }\n\n    #[IsGranted('ROLE_USER')]\n    #[IsGranted('moderate', subject: 'magazine')]\n    public function reportApprove(\n        #[MapEntity(mapping: ['magazine_name' => 'name'])]\n        Magazine $magazine,\n        #[MapEntity(id: 'report_id')]\n        Report $report,\n        Request $request,\n    ): Response {\n        $this->validateCsrf('report_approve', $request->getPayload()->get('token'));\n\n        $this->reportManager->accept($report, $this->getUserOrThrow());\n\n        return $this->redirectToRefererOrHome($request);\n    }\n\n    #[IsGranted('ROLE_USER')]\n    #[IsGranted('moderate', subject: 'magazine')]\n    public function reportReject(\n        #[MapEntity(mapping: ['magazine_name' => 'name'])]\n        Magazine $magazine,\n        #[MapEntity(id: 'report_id')]\n        Report $report,\n        Request $request,\n    ): Response {\n        $this->validateCsrf('report_decline', $request->getPayload()->get('token'));\n\n        $this->reportManager->reject($report, $this->getUserOrThrow());\n\n        return $this->redirectToRefererOrHome($request);\n    }\n}\n"
  },
  {
    "path": "src/Controller/Magazine/Panel/MagazineStatsController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Magazine\\Panel;\n\nuse App\\Controller\\AbstractController;\nuse App\\Entity\\Magazine;\nuse App\\Repository\\StatsRepository;\nuse App\\Service\\StatsManager;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass MagazineStatsController extends AbstractController\n{\n    public function __construct(private readonly StatsManager $manager)\n    {\n    }\n\n    #[IsGranted('ROLE_USER')]\n    #[IsGranted('edit', subject: 'magazine')]\n    public function __invoke(\n        #[MapEntity(mapping: ['name' => 'name'])]\n        Magazine $magazine,\n        ?string $statsType,\n        ?int $statsPeriod,\n        ?bool $withFederated,\n        Request $request,\n    ): Response {\n        $this->denyAccessUnlessGranted('edit_profile', $this->getUserOrThrow());\n\n        $statsType = $this->manager->resolveType($statsType);\n\n        if (!$statsPeriod) {\n            $statsPeriod = 31;\n        }\n\n        if (-1 === $statsPeriod) {\n            $statsPeriod = null;\n        }\n\n        if ($statsPeriod) {\n            $statsPeriod = min($statsPeriod, 365);\n            $start = (new \\DateTime())->modify(\"-$statsPeriod days\");\n        }\n        if (null === $withFederated) {\n            $withFederated = false;\n        }\n        $results = match ($statsType) {\n            StatsRepository::TYPE_VOTES => $statsPeriod\n                ? $this->manager->drawDailyVotesStatsByTime($start, null, $magazine, !$withFederated)\n                : $this->manager->drawMonthlyVotesChart(null, $magazine, !$withFederated),\n            default => $statsPeriod\n                ? $this->manager->drawDailyContentStatsByTime($start, null, $magazine, !$withFederated)\n                : $this->manager->drawMonthlyContentChart(null, $magazine, !$withFederated),\n        };\n\n        return $this->render(\n            'magazine/panel/stats.html.twig', [\n                'magazine' => $magazine,\n                'period' => $statsPeriod,\n                'chart' => $results,\n                'withFederated' => $withFederated,\n            ]\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Magazine/Panel/MagazineTagController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Magazine\\Panel;\n\nuse App\\Controller\\AbstractController;\nuse App\\Entity\\Magazine;\nuse App\\Form\\MagazineTagsType;\nuse App\\Service\\BadgeManager;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass MagazineTagController extends AbstractController\n{\n    public function __construct(\n        private readonly EntityManagerInterface $entityManager,\n    ) {\n    }\n\n    #[IsGranted('ROLE_USER')]\n    #[IsGranted('moderate', subject: 'magazine')]\n    public function __invoke(\n        #[MapEntity(mapping: ['name' => 'name'])]\n        Magazine $magazine,\n        BadgeManager $manager,\n        Request $request,\n    ): Response {\n        $form = $this->createForm(MagazineTagsType::class, $magazine);\n\n        $form->handleRequest($request);\n        if ($form->isSubmitted() && $form->isValid()) {\n            $form->getData();\n            $this->entityManager->flush();\n\n            return $this->redirectToRefererOrHome($request);\n        }\n\n        return $this->render('magazine/panel/tags.html.twig', [\n            'magazine' => $magazine,\n            'form' => $form,\n        ]);\n    }\n}\n"
  },
  {
    "path": "src/Controller/Magazine/Panel/MagazineThemeController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Magazine\\Panel;\n\nuse App\\Controller\\AbstractController;\nuse App\\DTO\\MagazineThemeDto;\nuse App\\Entity\\Magazine;\nuse App\\Form\\MagazineThemeType;\nuse App\\Service\\MagazineManager;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass MagazineThemeController extends AbstractController\n{\n    public function __construct(private readonly MagazineManager $manager)\n    {\n    }\n\n    #[IsGranted('ROLE_USER')]\n    #[IsGranted('edit', subject: 'magazine')]\n    public function __invoke(\n        #[MapEntity(mapping: ['name' => 'name'])]\n        Magazine $magazine,\n        Request $request,\n    ): Response {\n        $dto = new MagazineThemeDto($magazine);\n\n        $form = $this->createForm(MagazineThemeType::class, $dto);\n\n        try {\n            // Could thrown an error on event handlers (eg. onPostSubmit if a user upload an incorrect image)\n            $form->handleRequest($request);\n\n            if ($form->isSubmitted() && $form->isValid()) {\n                $magazine = $this->manager->changeTheme($dto);\n\n                $this->addFlash('success', 'flash_magazine_theme_changed_success');\n                $this->redirectToRefererOrHome($request);\n            }\n        } catch (\\Exception $e) {\n            // Show an error to the user\n            $this->addFlash('error', 'flash_magazine_theme_changed_error');\n        }\n\n        return $this->render(\n            'magazine/panel/theme.html.twig',\n            [\n                'magazine' => $magazine,\n                'form' => $form->createView(),\n            ]\n        );\n    }\n\n    #[IsGranted('ROLE_USER')]\n    #[IsGranted('edit', subject: 'magazine')]\n    public function detachIcon(#[MapEntity] Magazine $magazine): Response\n    {\n        $this->manager->detachIcon($magazine);\n        $this->addFlash('success', 'flash_magazine_theme_icon_detached_success');\n\n        return $this->redirectToRoute('magazine_panel_theme', ['name' => $magazine->name]);\n    }\n\n    #[IsGranted('ROLE_USER')]\n    #[IsGranted('edit', subject: 'magazine')]\n    public function detachBanner(#[MapEntity] Magazine $magazine): Response\n    {\n        $this->manager->detachBanner($magazine);\n        $this->addFlash('success', 'flash_magazine_theme_banner_detached_success');\n\n        return $this->redirectToRoute('magazine_panel_theme', ['name' => $magazine->name]);\n    }\n}\n"
  },
  {
    "path": "src/Controller/Magazine/Panel/MagazineTrashController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Magazine\\Panel;\n\nuse App\\Controller\\AbstractController;\nuse App\\Entity\\Magazine;\nuse App\\Repository\\MagazineRepository;\nuse App\\Service\\BadgeManager;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass MagazineTrashController extends AbstractController\n{\n    public function __construct(private readonly MagazineRepository $repository)\n    {\n    }\n\n    #[IsGranted('ROLE_USER')]\n    #[IsGranted('moderate', subject: 'magazine')]\n    public function __invoke(\n        #[MapEntity(mapping: ['name' => 'name'])]\n        Magazine $magazine,\n        BadgeManager $manager,\n        Request $request,\n    ): Response {\n        return $this->render(\n            'magazine/panel/trash.html.twig',\n            [\n                'magazine' => $magazine,\n                'results' => $this->repository->findTrashed($magazine, $this->getPageNb($request)),\n            ]\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Message/MessageCreateThreadController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Message;\n\nuse App\\Controller\\AbstractController;\nuse App\\Entity\\User;\nuse App\\Form\\MessageType;\nuse App\\Repository\\MessageThreadRepository;\nuse App\\Service\\MessageManager;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass MessageCreateThreadController extends AbstractController\n{\n    public function __construct(\n        private readonly MessageManager $manager,\n        private readonly MessageThreadRepository $threadRepository,\n    ) {\n    }\n\n    #[IsGranted('ROLE_USER')]\n    #[IsGranted('message', subject: 'receiver')]\n    public function __invoke(#[MapEntity(mapping: ['username' => 'username'])] User $receiver, Request $request): Response\n    {\n        $threads = $this->threadRepository->findByParticipants([$this->getUserOrThrow(), $receiver]);\n        if ($threads && \\sizeof($threads) > 0) {\n            return $this->redirectToRoute('messages_single', ['id' => $threads[0]->getId()]);\n        }\n\n        $form = $this->createForm(MessageType::class);\n        $form->handleRequest($request);\n\n        if ($form->isSubmitted() && $form->isValid()) {\n            $this->manager->toThread($form->getData(), $this->getUserOrThrow(), $receiver);\n\n            return $this->redirectToRoute(\n                'messages_front'\n            );\n        }\n\n        return $this->render(\n            'user/message.html.twig',\n            [\n                'user' => $receiver,\n                'form' => $form->createView(),\n            ],\n            new Response(null, $form->isSubmitted() && !$form->isValid() ? 422 : 200)\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Message/MessageThreadController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Message;\n\nuse App\\Controller\\AbstractController;\nuse App\\Entity\\MessageThread;\nuse App\\Form\\MessageType;\nuse App\\Service\\MessageManager;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass MessageThreadController extends AbstractController\n{\n    public function __construct(private readonly MessageManager $manager)\n    {\n    }\n\n    #[IsGranted('ROLE_USER')]\n    #[IsGranted('show', subject: 'thread', statusCode: 403)]\n    public function __invoke(#[MapEntity(id: 'id')] MessageThread $thread, Request $request): Response\n    {\n        $form = $this->createForm(MessageType::class);\n        $form->handleRequest($request);\n\n        if ($form->isSubmitted() && $form->isValid()) {\n            $this->manager->toMessage($form->getData(), $thread, $this->getUserOrThrow());\n\n            return $this->redirectToRoute('messages_single', ['id' => $thread->getId()]);\n        }\n\n        $this->manager->readMessages($thread, $this->getUserOrThrow());\n\n        return $this->render(\n            'messages/single.html.twig',\n            [\n                'user' => $this->getUserOrThrow(),\n                'thread' => $thread,\n                'form' => $form->createView(),\n            ]\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Message/MessageThreadListController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Message;\n\nuse App\\Controller\\AbstractController;\nuse App\\Repository\\MessageThreadRepository;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass MessageThreadListController extends AbstractController\n{\n    #[IsGranted('ROLE_USER')]\n    public function __invoke(MessageThreadRepository $repository, Request $request): Response\n    {\n        return $this->render(\n            'messages/front.html.twig',\n            [\n                'threads' => $repository->findUserMessages($this->getUser(), $this->getPageNb($request)),\n            ]\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/ModlogController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller;\n\nuse App\\DTO\\ModlogFilterDto;\nuse App\\Entity\\Magazine;\nuse App\\Form\\ModlogFilterType;\nuse App\\Repository\\MagazineLogRepository;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\n\nclass ModlogController extends AbstractController\n{\n    public function __construct(\n        private readonly MagazineLogRepository $magazineLogRepository,\n    ) {\n    }\n\n    public function instance(Request $request): Response\n    {\n        $dto = new ModlogFilterDto();\n        $dto->magazine = null;\n        $form = $this->createForm(ModlogFilterType::class, $dto, ['method' => 'GET']);\n        $form->handleRequest($request);\n        if ($form->isSubmitted() && $form->isValid()) {\n            /** @var ModlogFilterDto $dto */\n            $dto = $form->getData();\n\n            if (null !== $dto->magazine) {\n                return $this->redirectToRoute('magazine_modlog', ['name' => $dto->magazine->name]);\n            }\n            $logs = $this->magazineLogRepository->findByCustom($this->getPageNb($request), types: $dto->types);\n        } else {\n            $logs = $this->magazineLogRepository->findByCustom($this->getPageNb($request));\n        }\n\n        return $this->render(\n            'modlog/front.html.twig',\n            [\n                'logs' => $logs,\n                'form' => $form,\n            ]\n        );\n    }\n\n    public function magazine(#[MapEntity] ?Magazine $magazine, Request $request): Response\n    {\n        $dto = new ModlogFilterDto();\n        $dto->magazine = $magazine;\n        $form = $this->createForm(ModlogFilterType::class, $dto, ['method' => 'GET']);\n        $form->handleRequest($request);\n        if ($form->isSubmitted() && $form->isValid()) {\n            /** @var ModlogFilterDto $dto */\n            $dto = $form->getData();\n            if (null === $dto->magazine) {\n                return $this->redirectToRoute('modlog');\n            } elseif ($dto->magazine?->name !== $magazine->name) {\n                return $this->redirectToRoute('magazine_modlog', ['name' => $dto->magazine->name]);\n            }\n            $logs = $this->magazineLogRepository->findByCustom($this->getPageNb($request), types: $dto->types, magazine: $magazine);\n        } else {\n            $logs = $this->magazineLogRepository->findByCustom($this->getPageNb($request), magazine: $magazine);\n        }\n\n        return $this->render(\n            'modlog/front.html.twig',\n            [\n                'magazine' => $magazine,\n                'logs' => $logs,\n                'form' => $form,\n            ]\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/NotificationSettingsController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller;\n\nuse App\\Entity\\Entry;\nuse App\\Entity\\Magazine;\nuse App\\Entity\\Post;\nuse App\\Entity\\User;\nuse App\\Enums\\ENotificationStatus;\nuse App\\Repository\\NotificationSettingsRepository;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass NotificationSettingsController extends AbstractController\n{\n    public function __construct(\n        private readonly EntityManagerInterface $entityManager,\n        private readonly NotificationSettingsRepository $repository,\n    ) {\n    }\n\n    #[IsGranted('ROLE_USER')]\n    public function changeSetting(int $subject_id, string $subject_type, string $status, Request $request): Response\n    {\n        $status = ENotificationStatus::getFromString($status);\n        $subject = $this->entityManager->getRepository(self::GetClassFromSubjectType($subject_type))->findOneBy(['id' => $subject_id]);\n        $user = $this->getUserOrThrow();\n        $this->repository->setStatusByTarget($user, $subject, $status);\n        if ($request->isXmlHttpRequest()) {\n            return new JsonResponse([\n                'html' => $this->renderView('components/_ajax.html.twig', [\n                    'component' => 'notification_switch',\n                    'attributes' => [\n                        'target' => $subject,\n                    ],\n                ]\n                ),\n            ]);\n        }\n\n        return $this->redirect($request->headers->get('Referer'));\n    }\n\n    protected static function GetClassFromSubjectType(string $subjectType): string\n    {\n        return match ($subjectType) {\n            'entry' => Entry::class,\n            'post' => Post::class,\n            'user' => User::class,\n            'magazine' => Magazine::class,\n            default => throw new \\LogicException(\"cannot match type $subjectType\"),\n        };\n    }\n}\n"
  },
  {
    "path": "src/Controller/People/PeopleFrontController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\People;\n\nuse App\\Controller\\AbstractController;\nuse App\\Repository\\MagazineRepository;\nuse App\\Service\\PeopleManager;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\n\nclass PeopleFrontController extends AbstractController\n{\n    public function __construct(private readonly PeopleManager $manager, private readonly MagazineRepository $magazineRepository)\n    {\n    }\n\n    public function __invoke(?string $category, Request $request): Response\n    {\n        return $this->render(\n            'people/front.html.twig', [\n                'magazines' => array_filter(\n                    $this->magazineRepository->findByActivity(),\n                    fn ($val) => 'random' !== $val->name\n                ),\n                'local' => $this->manager->general(),\n                'federated' => $this->manager->general(true),\n            ]\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Post/Comment/PostCommentChangeAdultController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Post\\Comment;\n\nuse App\\Controller\\AbstractController;\nuse App\\Entity\\Magazine;\nuse App\\Entity\\Post;\nuse App\\Entity\\PostComment;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass PostCommentChangeAdultController extends AbstractController\n{\n    public function __construct(\n        private readonly EntityManagerInterface $entityManager,\n    ) {\n    }\n\n    #[IsGranted('moderate', subject: 'comment')]\n    public function __invoke(\n        #[MapEntity(mapping: ['magazine_name' => 'name'])]\n        Magazine $magazine,\n        #[MapEntity(id: 'post_id')]\n        Post $post,\n        #[MapEntity(id: 'comment_id')]\n        PostComment $comment,\n        Request $request,\n    ): Response {\n        $this->validateCsrf('change_adult', $request->getPayload()->get('token'));\n\n        $comment->isAdult = 'on' === $request->get('adult');\n\n        $this->entityManager->flush();\n\n        $this->addFlash(\n            'success',\n            $comment->isAdult ? 'flash_mark_as_adult_success' : 'flash_unmark_as_adult_success'\n        );\n\n        return $this->redirectToRefererOrHome($request);\n    }\n}\n"
  },
  {
    "path": "src/Controller/Post/Comment/PostCommentChangeLangController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Post\\Comment;\n\nuse App\\Controller\\AbstractController;\nuse App\\Entity\\Magazine;\nuse App\\Entity\\Post;\nuse App\\Entity\\PostComment;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass PostCommentChangeLangController extends AbstractController\n{\n    public function __construct(\n        private readonly EntityManagerInterface $entityManager,\n    ) {\n    }\n\n    #[IsGranted('moderate', subject: 'comment')]\n    public function __invoke(\n        #[MapEntity(mapping: ['magazine_name' => 'name'])]\n        Magazine $magazine,\n        #[MapEntity(id: 'post_id')]\n        Post $post,\n        #[MapEntity(id: 'comment_id')]\n        PostComment $comment,\n        Request $request,\n    ): Response {\n        $comment->lang = $request->get('lang')['lang'];\n\n        $this->entityManager->flush();\n\n        return $this->redirectToRefererOrHome($request);\n    }\n}\n"
  },
  {
    "path": "src/Controller/Post/Comment/PostCommentCreateController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Post\\Comment;\n\nuse App\\Controller\\AbstractController;\nuse App\\DTO\\PostCommentDto;\nuse App\\Entity\\Magazine;\nuse App\\Entity\\Post;\nuse App\\Entity\\PostComment;\nuse App\\Exception\\InstanceBannedException;\nuse App\\Exception\\TagBannedException;\nuse App\\Exception\\UserBannedException;\nuse App\\Form\\PostCommentType;\nuse App\\PageView\\PostCommentPageView;\nuse App\\Repository\\PostCommentRepository;\nuse App\\Service\\IpResolver;\nuse App\\Service\\MentionManager;\nuse App\\Service\\PostCommentManager;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Bundle\\SecurityBundle\\Security;\nuse Symfony\\Component\\Form\\FormInterface;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\HttpKernel\\Exception\\AccessDeniedHttpException;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass PostCommentCreateController extends AbstractController\n{\n    use PostCommentResponseTrait;\n\n    public function __construct(\n        private readonly PostCommentManager $manager,\n        private readonly PostCommentRepository $repository,\n        private readonly IpResolver $ipResolver,\n        private readonly MentionManager $mentionManager,\n        private readonly Security $security,\n    ) {\n    }\n\n    #[IsGranted('ROLE_USER')]\n    #[IsGranted('comment', subject: 'post')]\n    public function __invoke(\n        #[MapEntity(mapping: ['magazine_name' => 'name'])]\n        Magazine $magazine,\n        #[MapEntity(id: 'post_id')]\n        Post $post,\n        #[MapEntity(id: 'parent_comment_id')]\n        ?PostComment $parent,\n        Request $request,\n    ): Response {\n        $form = $this->getForm($post, $parent);\n        try {\n            // Could thrown an error on event handlers (eg. onPostSubmit if a user upload an incorrect image)\n            $form->handleRequest($request);\n\n            if ($form->isSubmitted() && $form->isValid()) {\n                $dto = $form->getData();\n                $dto->post = $post;\n                $dto->magazine = $magazine;\n                $dto->parent = $parent;\n                $dto->ip = $this->ipResolver->resolve();\n\n                if (!$this->isGranted('create_content', $dto->magazine)) {\n                    throw new AccessDeniedHttpException();\n                }\n\n                return $this->handleValidRequest($dto, $request);\n            }\n        } catch (InstanceBannedException) {\n            $this->addFlash('error', 'flash_instance_banned_error');\n        } catch (\\Exception $e) {\n            // Show an error to the user\n            $this->addFlash('error', 'flash_comment_new_error');\n        }\n\n        if ($request->isXmlHttpRequest()) {\n            return $this->getJsonFormResponse(\n                $form,\n                'post/comment/_form_comment.html.twig',\n                ['post' => $post, 'parent' => $parent]\n            );\n        }\n\n        $user = $this->getUserOrThrow();\n        $criteria = new PostCommentPageView($this->getPageNb($request), $this->security);\n        $criteria->post = $post;\n\n        $comments = $this->repository->findByCriteria($criteria);\n\n        return $this->render(\n            'post/comment/create.html.twig',\n            [\n                'user' => $user,\n                'magazine' => $magazine,\n                'post' => $post,\n                'comments' => $comments,\n                'parent' => $parent,\n                'form' => $form->createView(),\n            ],\n            new Response(null, $form->isSubmitted() && !$form->isValid() ? 422 : 200)\n        );\n    }\n\n    private function getForm(Post $post, ?PostComment $parent): FormInterface\n    {\n        $dto = new PostCommentDto();\n\n        if ($parent && $this->getUser()->addMentionsPosts) {\n            $handle = $this->mentionManager->addHandle([$parent->user->username])[0];\n\n            if ($parent->user !== $this->getUser()) {\n                $dto->body = $handle;\n            } else {\n                $dto->body .= PHP_EOL;\n            }\n\n            if ($parent->mentions) {\n                $mentions = $this->mentionManager->addHandle($parent->mentions);\n                $mentions = array_filter(\n                    $mentions,\n                    fn (string $mention) => $mention !== $handle && $mention !== $this->mentionManager->addHandle([$this->getUser()->username])[0]\n                );\n\n                $dto->body .= PHP_EOL.PHP_EOL;\n                $dto->body .= implode(' ', array_unique($mentions));\n            }\n        } elseif ($this->getUser()->addMentionsPosts) {\n            if ($post->user !== $this->getUser()) {\n                $dto->body = $this->mentionManager->addHandle([$post->user->username])[0];\n            }\n        }\n\n        return $this->createForm(\n            PostCommentType::class,\n            $dto,\n            [\n                'action' => $this->generateUrl(\n                    'post_comment_create',\n                    [\n                        'magazine_name' => $post->magazine->name,\n                        'post_id' => $post->getId(),\n                        'parent_comment_id' => $parent?->getId(),\n                    ]\n                ),\n                'parentLanguage' => $parent?->lang ?? $post->lang,\n            ]\n        );\n    }\n\n    /**\n     * @throws InstanceBannedException\n     * @throws TagBannedException\n     * @throws UserBannedException\n     */\n    private function handleValidRequest(PostCommentDto $dto, Request $request): Response\n    {\n        $comment = $this->manager->create($dto, $this->getUserOrThrow());\n\n        if ($request->isXmlHttpRequest()) {\n            return $this->getPostCommentJsonSuccessResponse($comment);\n        }\n\n        $this->addFlash('success', 'flash_comment_new_success');\n\n        return $this->redirectToPost($comment->post);\n    }\n}\n"
  },
  {
    "path": "src/Controller/Post/Comment/PostCommentDeleteController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Post\\Comment;\n\nuse App\\Controller\\AbstractController;\nuse App\\Entity\\Magazine;\nuse App\\Entity\\PostComment;\nuse App\\Service\\PostCommentManager;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass PostCommentDeleteController extends AbstractController\n{\n    public function __construct(private readonly PostCommentManager $manager)\n    {\n    }\n\n    #[IsGranted('ROLE_USER')]\n    #[IsGranted('delete', subject: 'comment')]\n    public function delete(\n        #[MapEntity(mapping: ['magazine_name' => 'name'])]\n        Magazine $magazine,\n        #[MapEntity(id: 'comment_id')]\n        PostComment $comment,\n        Request $request,\n    ): Response {\n        $this->validateCsrf('post_comment_delete', $request->getPayload()->get('token'));\n\n        $this->manager->delete($this->getUserOrThrow(), $comment);\n\n        return $this->redirectToRefererOrHome($request);\n    }\n\n    #[IsGranted('ROLE_USER')]\n    #[IsGranted('delete', subject: 'comment')]\n    public function restore(\n        #[MapEntity(mapping: ['magazine_name' => 'name'])]\n        Magazine $magazine,\n        #[MapEntity(id: 'comment_id')]\n        PostComment $comment,\n        Request $request,\n    ): Response {\n        $this->validateCsrf('post_comment_restore', $request->getPayload()->get('token'));\n\n        $this->manager->restore($this->getUserOrThrow(), $comment);\n\n        return $this->redirectToRefererOrHome($request);\n    }\n\n    #[IsGranted('ROLE_USER')]\n    #[IsGranted('purge', subject: 'comment')]\n    public function purge(\n        #[MapEntity(mapping: ['magazine_name' => 'name'])]\n        Magazine $magazine,\n        #[MapEntity(id: 'comment_id')]\n        PostComment $comment,\n        Request $request,\n    ): Response {\n        $this->validateCsrf('post_comment_purge', $request->getPayload()->get('token'));\n\n        $this->manager->purge($this->getUserOrThrow(), $comment);\n\n        return $this->redirectToRefererOrHome($request);\n    }\n}\n"
  },
  {
    "path": "src/Controller/Post/Comment/PostCommentDeleteImageController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Post\\Comment;\n\nuse App\\Controller\\AbstractController;\nuse App\\Entity\\Magazine;\nuse App\\Entity\\Post;\nuse App\\Entity\\PostComment;\nuse App\\Service\\PostCommentManager;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass PostCommentDeleteImageController extends AbstractController\n{\n    public function __construct(\n        private readonly PostCommentManager $manager,\n    ) {\n    }\n\n    #[IsGranted('ROLE_USER')]\n    #[IsGranted('delete', subject: 'comment')]\n    public function __invoke(\n        #[MapEntity(mapping: ['magazine_name' => 'name'])]\n        Magazine $magazine,\n        #[MapEntity(id: 'post_id')]\n        Post $post,\n        #[MapEntity(id: 'comment_id')]\n        PostComment $comment,\n        Request $request,\n    ): Response {\n        $this->manager->detachImage($comment);\n\n        if ($request->isXmlHttpRequest()) {\n            return $this->getJsonSuccessResponse();\n        }\n\n        return $this->redirectToRefererOrHome($request);\n    }\n}\n"
  },
  {
    "path": "src/Controller/Post/Comment/PostCommentEditController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Post\\Comment;\n\nuse App\\Controller\\AbstractController;\nuse App\\DTO\\PostCommentDto;\nuse App\\Entity\\Magazine;\nuse App\\Entity\\Post;\nuse App\\Entity\\PostComment;\nuse App\\Form\\PostCommentType;\nuse App\\PageView\\PostCommentPageView;\nuse App\\Repository\\PostCommentRepository;\nuse App\\Service\\PostCommentManager;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Bundle\\SecurityBundle\\Security;\nuse Symfony\\Component\\Form\\FormInterface;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\HttpKernel\\Exception\\AccessDeniedHttpException;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass PostCommentEditController extends AbstractController\n{\n    use PostCommentResponseTrait;\n\n    public function __construct(\n        private readonly PostCommentManager $manager,\n        private readonly PostCommentRepository $repository,\n        private readonly Security $security,\n    ) {\n    }\n\n    #[IsGranted('ROLE_USER')]\n    #[IsGranted('edit', subject: 'comment')]\n    public function __invoke(\n        #[MapEntity(mapping: ['magazine_name' => 'name'])]\n        Magazine $magazine,\n        #[MapEntity(id: 'post_id')]\n        Post $post,\n        #[MapEntity(id: 'comment_id')]\n        PostComment $comment,\n        Request $request,\n    ): Response {\n        $dto = $this->manager->createDto($comment);\n\n        $form = $this->getCreateForm($dto, $comment);\n        try {\n            // Could thrown an error on event handlers (eg. onPostSubmit if a user upload an incorrect image)\n            $form->handleRequest($request);\n\n            if ($form->isSubmitted() && $form->isValid()) {\n                if (!$this->isGranted('create_content', $dto->magazine)) {\n                    throw new AccessDeniedHttpException();\n                }\n\n                return $this->handleValidRequest($dto, $comment, $request);\n            }\n        } catch (\\Exception $e) {\n            // Show an error to the user\n            $this->addFlash('error', 'flash_comment_edit_error');\n        }\n\n        $criteria = new PostCommentPageView($this->getPageNb($request), $this->security);\n        $criteria->post = $post;\n\n        if ($request->isXmlHttpRequest()) {\n            return $this->getJsonFormResponse(\n                $form,\n                'post/comment/_form_comment.html.twig',\n                ['comment' => $comment, 'post' => $post, 'edit' => true]\n            );\n        }\n\n        $comments = $this->repository->findByCriteria($criteria);\n\n        return $this->render(\n            'post/comment/edit.html.twig',\n            [\n                'magazine' => $post->magazine,\n                'post' => $post,\n                'comments' => $comments,\n                'comment' => $comment,\n                'form' => $form->createView(),\n            ],\n            new Response(null, $form->isSubmitted() && !$form->isValid() ? 422 : 200)\n        );\n    }\n\n    private function getCreateForm(PostCommentDto $dto, PostComment $comment): FormInterface\n    {\n        return $this->createForm(\n            PostCommentType::class,\n            $dto,\n            [\n                'action' => $this->generateUrl(\n                    'post_comment_edit',\n                    [\n                        'magazine_name' => $comment->magazine->name,\n                        'post_id' => $comment->post->getId(),\n                        'comment_id' => $comment->getId(),\n                    ]\n                ),\n            ]\n        );\n    }\n\n    private function handleValidRequest(PostCommentDto $dto, PostComment $comment, Request $request): Response\n    {\n        $comment = $this->manager->edit($comment, $dto, $this->getUserOrThrow());\n\n        if ($request->isXmlHttpRequest()) {\n            return $this->getPostCommentJsonSuccessResponse($comment);\n        }\n\n        $this->addFlash('success', 'flash_comment_edit_success');\n\n        return $this->redirectToPost($comment->post);\n    }\n}\n"
  },
  {
    "path": "src/Controller/Post/Comment/PostCommentFavouriteController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Post\\Comment;\n\nuse App\\Controller\\AbstractController;\nuse App\\Entity\\Magazine;\nuse App\\Entity\\Post;\nuse App\\Entity\\PostComment;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\n\nclass PostCommentFavouriteController extends AbstractController\n{\n    public function __invoke(\n        #[MapEntity(mapping: ['magazine_name' => 'name'])]\n        Magazine $magazine,\n        #[MapEntity(id: 'post_id')]\n        Post $post,\n        #[MapEntity(id: 'comment_id')]\n        PostComment $comment,\n        Request $request,\n    ): Response {\n        return $this->render('post/comment/favourites.html.twig', [\n            'magazine' => $magazine,\n            'post' => $post,\n            'comment' => $comment,\n            'favourites' => $comment->favourites,\n        ]);\n    }\n}\n"
  },
  {
    "path": "src/Controller/Post/Comment/PostCommentModerateController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Post\\Comment;\n\nuse App\\Controller\\AbstractController;\nuse App\\Entity\\Magazine;\nuse App\\Entity\\Post;\nuse App\\Entity\\PostComment;\nuse App\\Form\\LangType;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass PostCommentModerateController extends AbstractController\n{\n    #[IsGranted('ROLE_USER')]\n    #[IsGranted('moderate', subject: 'comment')]\n    public function __invoke(\n        #[MapEntity(mapping: ['magazine_name' => 'name'])]\n        Magazine $magazine,\n        #[MapEntity(id: 'post_id')]\n        Post $post,\n        #[MapEntity(id: 'comment_id')]\n        PostComment $comment,\n        Request $request,\n    ): Response {\n        if ($post->magazine !== $magazine) {\n            return $this->redirectToRoute(\n                'post_single',\n                ['magazine_name' => $post->magazine->name, 'post_id' => $post->getId(), 'slug' => $post->slug],\n                301\n            );\n        }\n\n        $form = $this->createForm(LangType::class);\n        $form->get('lang')\n            ->setData($comment->lang);\n\n        if ($request->isXmlHttpRequest()) {\n            return new JsonResponse([\n                'html' => $this->renderView('post/comment/_moderate_panel.html.twig', [\n                    'magazine' => $magazine,\n                    'post' => $post,\n                    'comment' => $comment,\n                    'form' => $form->createView(),\n                ]),\n            ]);\n        }\n\n        return $this->render('post/comment/moderate.html.twig', [\n            'magazine' => $magazine,\n            'post' => $post,\n            'comment' => $comment,\n            'form' => $form->createView(),\n        ]);\n    }\n}\n"
  },
  {
    "path": "src/Controller/Post/Comment/PostCommentResponseTrait.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Post\\Comment;\n\nuse App\\Entity\\PostComment;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpFoundation\\Response;\n\ntrait PostCommentResponseTrait\n{\n    private function getPostCommentJsonSuccessResponse(PostComment $comment): Response\n    {\n        return new JsonResponse(\n            [\n                'id' => $comment->getId(),\n                'html' => $this->renderView(\n                    'components/_ajax.html.twig',\n                    [\n                        'component' => 'post_comment',\n                        'attributes' => [\n                            'comment' => $comment,\n                            'showEntryTitle' => false,\n                        ],\n                    ]\n                ),\n            ]\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Post/Comment/PostCommentVotersController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Post\\Comment;\n\nuse App\\Controller\\AbstractController;\nuse App\\Entity\\Magazine;\nuse App\\Entity\\Post;\nuse App\\Entity\\PostComment;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\n\nclass PostCommentVotersController extends AbstractController\n{\n    public function __invoke(\n        #[MapEntity(mapping: ['magazine_name' => 'name'])]\n        Magazine $magazine,\n        #[MapEntity(id: 'post_id')]\n        Post $post,\n        #[MapEntity(id: 'comment_id')]\n        PostComment $comment,\n        Request $request,\n    ): Response {\n        if ($request->isXmlHttpRequest()) {\n            return new JsonResponse([\n                'html' => $this->renderView('_layout/_voters_inline.html.twig', [\n                    'votes' => $comment->getUpVotes(),\n                    'more' => null,\n                ]),\n            ]);\n        }\n\n        return $this->render('post/comment/voters.html.twig', [\n            'magazine' => $magazine,\n            'post' => $post,\n            'comment' => $comment,\n            'votes' => $comment->getUpVotes(),\n        ]);\n    }\n}\n"
  },
  {
    "path": "src/Controller/Post/PostChangeAdultController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Post;\n\nuse App\\Controller\\AbstractController;\nuse App\\Entity\\Magazine;\nuse App\\Entity\\Post;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass PostChangeAdultController extends AbstractController\n{\n    public function __construct(private readonly EntityManagerInterface $entityManager)\n    {\n    }\n\n    #[IsGranted('moderate', 'post')]\n    public function __invoke(\n        #[MapEntity(mapping: ['magazine_name' => 'name'])]\n        Magazine $magazine,\n        #[MapEntity(id: 'post_id')]\n        Post $post,\n        Request $request,\n    ): Response {\n        $this->validateCsrf('change_adult', $request->getPayload()->get('token'));\n\n        $post->isAdult = 'on' === $request->get('adult');\n\n        $this->entityManager->flush();\n\n        $this->addFlash(\n            'success',\n            $post->isAdult ? 'flash_mark_as_adult_success' : 'flash_unmark_as_adult_success'\n        );\n\n        return $this->redirectToRefererOrHome($request);\n    }\n}\n"
  },
  {
    "path": "src/Controller/Post/PostChangeLangController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Post;\n\nuse App\\Controller\\AbstractController;\nuse App\\Entity\\Magazine;\nuse App\\Entity\\Post;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass PostChangeLangController extends AbstractController\n{\n    public function __construct(private readonly EntityManagerInterface $entityManager)\n    {\n    }\n\n    #[IsGranted('moderate', 'post')]\n    public function __invoke(\n        #[MapEntity(mapping: ['magazine_name' => 'name'])]\n        Magazine $magazine,\n        #[MapEntity(id: 'post_id')]\n        Post $post,\n        Request $request,\n    ): Response {\n        $post->lang = $request->get('lang')['lang'];\n\n        $this->entityManager->flush();\n\n        return $this->redirectToRefererOrHome($request);\n    }\n}\n"
  },
  {
    "path": "src/Controller/Post/PostChangeMagazineController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Post;\n\nuse App\\Controller\\AbstractController;\nuse App\\Entity\\Magazine;\nuse App\\Entity\\Post;\nuse App\\Repository\\MagazineRepository;\nuse App\\Service\\PostManager;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass PostChangeMagazineController extends AbstractController\n{\n    public function __construct(\n        private readonly PostManager $manager,\n        private readonly MagazineRepository $repository,\n    ) {\n    }\n\n    #[IsGranted('moderate', 'post')]\n    public function __invoke(\n        #[MapEntity(mapping: ['magazine_name' => 'name'])]\n        Magazine $magazine,\n        #[MapEntity(id: 'post_id')]\n        Post $post,\n        Request $request,\n    ): Response {\n        $this->validateCsrf('change_magazine', $request->getPayload()->get('token'));\n\n        $newMagazine = $this->repository->findOneByName($request->get('change_magazine')['new_magazine']);\n\n        $this->manager->changeMagazine($post, $newMagazine);\n\n        return $this->redirectToRefererOrHome($request);\n    }\n}\n"
  },
  {
    "path": "src/Controller/Post/PostCreateController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Post;\n\nuse App\\Controller\\AbstractController;\nuse App\\DTO\\PostDto;\nuse App\\Exception\\InstanceBannedException;\nuse App\\Form\\PostType;\nuse App\\Repository\\Criteria;\nuse App\\Repository\\MagazineRepository;\nuse App\\Service\\IpResolver;\nuse App\\Service\\PostManager;\nuse Psr\\Log\\LoggerInterface;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\HttpKernel\\Exception\\AccessDeniedHttpException;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass PostCreateController extends AbstractController\n{\n    public function __construct(\n        private readonly LoggerInterface $logger,\n        private readonly PostManager $manager,\n        private readonly MagazineRepository $magazineRepository,\n        private readonly IpResolver $ipResolver,\n    ) {\n    }\n\n    #[IsGranted('ROLE_USER')]\n    public function __invoke(Request $request): Response\n    {\n        $dto = new PostDto();\n        // check if the \"random\" magazine exists and if so, use it\n        $randomMagazine = $this->magazineRepository->findOneByName('random');\n        if (null !== $randomMagazine) {\n            $dto->magazine = $randomMagazine;\n        }\n\n        $form = $this->createForm(PostType::class, $dto);\n        $user = $this->getUserOrThrow();\n        try {\n            // Could thrown an error on event handlers (eg. onPostSubmit if a user upload an incorrect image)\n            $form->handleRequest($request);\n\n            if ($form->isSubmitted() && $form->isValid()) {\n                $dto = $form->getData();\n                $dto->ip = $this->ipResolver->resolve();\n\n                if (!$this->isGranted('create_content', $dto->magazine)) {\n                    throw new AccessDeniedHttpException();\n                }\n\n                $this->manager->create($dto, $user);\n\n                $this->addFlash('success', 'flash_post_new_success');\n\n                return $this->redirectToRoute(\n                    'magazine_posts',\n                    [\n                        'name' => $dto->magazine->name,\n                        'sortBy' => Criteria::SORT_NEW,\n                    ]\n                );\n            }\n        } catch (InstanceBannedException) {\n            $this->addFlash('error', 'flash_instance_banned_error');\n        } catch (\\Exception $e) {\n            $this->logger->error('{user} tried to create a post, but an exception occurred: {ex} - {message}', ['user' => $user->username, 'ex' => \\get_class($e), 'message' => $e->getMessage(), 'stacktrace' => $e->getTrace()]);\n            // Show an error to the user\n            $this->addFlash('error', 'flash_post_new_error');\n        }\n\n        return $this->render('post/create.html.twig', [\n            'form' => $form->createView(),\n        ]);\n    }\n}\n"
  },
  {
    "path": "src/Controller/Post/PostDeleteController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Post;\n\nuse App\\Controller\\AbstractController;\nuse App\\Entity\\Magazine;\nuse App\\Entity\\Post;\nuse App\\Service\\PostManager;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass PostDeleteController extends AbstractController\n{\n    public function __construct(private readonly PostManager $manager)\n    {\n    }\n\n    #[IsGranted('ROLE_USER')]\n    #[IsGranted('delete', subject: 'post')]\n    public function delete(\n        #[MapEntity(mapping: ['magazine_name' => 'name'])]\n        Magazine $magazine,\n        #[MapEntity(id: 'post_id')]\n        Post $post,\n        Request $request,\n    ): Response {\n        $this->manager->delete($this->getUserOrThrow(), $post);\n\n        return $this->redirectToRefererOrHome($request);\n    }\n\n    #[IsGranted('ROLE_USER')]\n    #[IsGranted('delete', subject: 'post')]\n    public function restore(\n        #[MapEntity(mapping: ['magazine_name' => 'name'])]\n        Magazine $magazine,\n        #[MapEntity(id: 'post_id')]\n        Post $post,\n        Request $request,\n    ): Response {\n        $this->manager->restore($this->getUserOrThrow(), $post);\n\n        return $this->redirectToRefererOrHome($request);\n    }\n\n    #[IsGranted('ROLE_USER')]\n    #[IsGranted('purge', subject: 'post')]\n    public function purge(\n        #[MapEntity(mapping: ['magazine_name' => 'name'])]\n        Magazine $magazine,\n        #[MapEntity(id: 'post_id')]\n        Post $post,\n        Request $request,\n    ): Response {\n        $this->manager->purge($this->getUserOrThrow(), $post);\n\n        return $this->redirectToMagazine($magazine);\n    }\n}\n"
  },
  {
    "path": "src/Controller/Post/PostDeleteImageController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Post;\n\nuse App\\Controller\\AbstractController;\nuse App\\Entity\\Magazine;\nuse App\\Entity\\Post;\nuse App\\Service\\PostManager;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass PostDeleteImageController extends AbstractController\n{\n    public function __construct(private readonly PostManager $manager)\n    {\n    }\n\n    #[IsGranted('ROLE_USER')]\n    #[IsGranted('delete', subject: 'post')]\n    public function __invoke(\n        #[MapEntity(mapping: ['magazine_name' => 'name'])]\n        Magazine $magazine,\n        #[MapEntity(id: 'post_id')]\n        Post $post,\n        Request $request,\n    ): Response {\n        $this->manager->detachImage($post);\n\n        if ($request->isXmlHttpRequest()) {\n            return new JsonResponse(\n                [\n                    'success' => true,\n                ]\n            );\n        }\n\n        return $this->redirectToRefererOrHome($request);\n    }\n}\n"
  },
  {
    "path": "src/Controller/Post/PostEditController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Post;\n\nuse App\\Controller\\AbstractController;\nuse App\\Entity\\Magazine;\nuse App\\Entity\\Post;\nuse App\\Form\\PostType;\nuse App\\PageView\\PostCommentPageView;\nuse App\\Repository\\PostCommentRepository;\nuse App\\Service\\PostManager;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Bundle\\SecurityBundle\\Security;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\HttpKernel\\Exception\\AccessDeniedHttpException;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass PostEditController extends AbstractController\n{\n    public function __construct(\n        private readonly PostManager $manager,\n        private readonly Security $security,\n    ) {\n    }\n\n    #[IsGranted('ROLE_USER')]\n    #[IsGranted('edit', subject: 'post')]\n    public function __invoke(\n        #[MapEntity(mapping: ['magazine_name' => 'name'])]\n        Magazine $magazine,\n        #[MapEntity(id: 'post_id')]\n        Post $post,\n        Request $request,\n        PostCommentRepository $repository,\n    ): Response {\n        $dto = $this->manager->createDto($post);\n\n        $form = $this->createForm(PostType::class, $dto);\n        try {\n            // Could thrown an error on event handlers (eg. onPostSubmit if a user upload an incorrect image)\n            $form->handleRequest($request);\n\n            if ($form->isSubmitted() && $form->isValid()) {\n                if (!$this->isGranted('create_content', $magazine)) {\n                    throw new AccessDeniedHttpException();\n                }\n\n                $post = $this->manager->edit($post, $dto, $this->getUserOrThrow());\n\n                if ($request->isXmlHttpRequest()) {\n                    return new JsonResponse(\n                        [\n                            'id' => $post->getId(),\n                            'html' => $this->renderView(\n                                'components/_ajax.html.twig',\n                                [\n                                    'component' => 'post',\n                                    'attributes' => [\n                                        'post' => $post,\n                                        'showMagazineName' => false,\n                                    ],\n                                ]\n                            ),\n                        ]\n                    );\n                }\n\n                $this->addFlash('success', 'flash_post_edit_success');\n\n                return $this->redirectToPost($post);\n            }\n        } catch (\\Exception $e) {\n            // Show an error to the user\n            $this->addFlash('error', 'flash_post_edit_error');\n        }\n\n        $criteria = new PostCommentPageView($this->getPageNb($request), $this->security);\n        $criteria->post = $post;\n\n        if ($request->isXmlHttpRequest()) {\n            return $this->getJsonFormResponse(\n                $form,\n                'post/_form_post.html.twig',\n                ['post' => $post, 'edit' => true]\n            );\n        }\n\n        return $this->render(\n            'post/edit.html.twig',\n            [\n                'magazine' => $magazine,\n                'post' => $post,\n                'comments' => $repository->findByCriteria($criteria),\n                'form' => $form->createView(),\n                'criteria' => $criteria,\n            ],\n            new Response(null, $form->isSubmitted() && !$form->isValid() ? 422 : 200)\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Post/PostFavouriteController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Post;\n\nuse App\\Controller\\AbstractController;\nuse App\\Entity\\Magazine;\nuse App\\Entity\\Post;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\n\nclass PostFavouriteController extends AbstractController\n{\n    public function __invoke(\n        #[MapEntity(mapping: ['magazine_name' => 'name'])]\n        Magazine $magazine,\n        #[MapEntity(id: 'post_id')]\n        Post $post,\n        Request $request,\n    ): Response {\n        return $this->render('post/favourites.html.twig', [\n            'magazine' => $magazine,\n            'post' => $post,\n            'favourites' => $post->favourites,\n        ]);\n    }\n}\n"
  },
  {
    "path": "src/Controller/Post/PostLockController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Post;\n\nuse App\\Controller\\AbstractController;\nuse App\\Entity\\Magazine;\nuse App\\Entity\\Post;\nuse App\\Service\\PostManager;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass PostLockController extends AbstractController\n{\n    public function __construct(\n        private readonly PostManager $manager,\n    ) {\n    }\n\n    #[IsGranted('ROLE_USER')]\n    #[IsGranted('lock', subject: 'post')]\n    public function __invoke(\n        #[MapEntity(mapping: ['magazine_name' => 'name'])]\n        Magazine $magazine,\n        #[MapEntity(id: 'post_id')]\n        Post $post,\n        Request $request,\n    ): Response {\n        $this->validateCsrf('post_lock', $request->getPayload()->get('token'));\n\n        $entry = $this->manager->toggleLock($post, $this->getUserOrThrow());\n\n        $this->addFlash(\n            'success',\n            $entry->isLocked ? 'flash_post_lock_success' : 'flash_post_unlock_success'\n        );\n\n        return $this->redirectToRefererOrHome($request);\n    }\n}\n"
  },
  {
    "path": "src/Controller/Post/PostModerateController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Post;\n\nuse App\\Controller\\AbstractController;\nuse App\\Entity\\Magazine;\nuse App\\Entity\\Post;\nuse App\\Form\\LangType;\nuse App\\Repository\\PostCommentRepository;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass PostModerateController extends AbstractController\n{\n    #[IsGranted('ROLE_USER')]\n    #[IsGranted('moderate', subject: 'post')]\n    public function __invoke(\n        #[MapEntity(mapping: ['magazine_name' => 'name'])]\n        Magazine $magazine,\n        #[MapEntity(id: 'post_id')]\n        Post $post,\n        Request $request,\n        PostCommentRepository $repository,\n    ): Response {\n        if ($post->magazine !== $magazine) {\n            return $this->redirectToRoute(\n                'post_single',\n                ['magazine_name' => $post->magazine->name, 'post_id' => $post->getId(), 'slug' => $post->slug],\n                301\n            );\n        }\n\n        $form = $this->createForm(LangType::class);\n        $form->get('lang')\n            ->setData($post->lang);\n\n        if ($request->isXmlHttpRequest()) {\n            return new JsonResponse([\n                'html' => $this->renderView('post/_moderate_panel.html.twig', [\n                    'magazine' => $magazine,\n                    'post' => $post,\n                    'form' => $form->createView(),\n                ]),\n            ]);\n        }\n\n        return $this->render('post/moderate.html.twig', [\n            'magazine' => $magazine,\n            'post' => $post,\n            'form' => $form->createView(),\n        ]);\n    }\n}\n"
  },
  {
    "path": "src/Controller/Post/PostPinController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Post;\n\nuse App\\Controller\\AbstractController;\nuse App\\Entity\\Magazine;\nuse App\\Entity\\Post;\nuse App\\Service\\PostManager;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass PostPinController extends AbstractController\n{\n    public function __construct(\n        private readonly PostManager $manager,\n    ) {\n    }\n\n    #[IsGranted('ROLE_USER')]\n    #[IsGranted('moderate', subject: 'post')]\n    public function __invoke(\n        #[MapEntity(mapping: ['magazine_name' => 'name'])]\n        Magazine $magazine,\n        #[MapEntity(id: 'post_id')]\n        Post $post,\n        Request $request,\n    ): Response {\n        $this->validateCsrf('post_pin', $request->getPayload()->get('token'));\n\n        $entry = $this->manager->pin($post);\n\n        $this->addFlash(\n            'success',\n            $entry->sticky ? 'flash_post_pin_success' : 'flash_post_unpin_success'\n        );\n\n        return $this->redirectToRefererOrHome($request);\n    }\n}\n"
  },
  {
    "path": "src/Controller/Post/PostSingleController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Post;\n\nuse App\\Controller\\AbstractController;\nuse App\\Controller\\Traits\\PrivateContentTrait;\nuse App\\Controller\\User\\ThemeSettingsController;\nuse App\\DTO\\PostCommentDto;\nuse App\\Entity\\Magazine;\nuse App\\Entity\\Post;\nuse App\\Event\\Post\\PostHasBeenSeenEvent;\nuse App\\Form\\PostCommentType;\nuse App\\PageView\\PostCommentPageView;\nuse App\\Repository\\Criteria;\nuse App\\Repository\\ImageRepository;\nuse App\\Repository\\PostCommentRepository;\nuse App\\Service\\MentionManager;\nuse Pagerfanta\\PagerfantaInterface;\nuse Psr\\EventDispatcher\\EventDispatcherInterface;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Bundle\\SecurityBundle\\Security;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\n\nclass PostSingleController extends AbstractController\n{\n    use PrivateContentTrait;\n\n    public function __construct(\n        private readonly PostCommentRepository $commentRepository,\n        private readonly EventDispatcherInterface $dispatcher,\n        private readonly MentionManager $mentionManager,\n        private readonly Security $security,\n        private readonly ImageRepository $imageRepository,\n    ) {\n    }\n\n    public function __invoke(\n        #[MapEntity(mapping: ['magazine_name' => 'name'])]\n        Magazine $magazine,\n        #[MapEntity(id: 'post_id')]\n        Post $post,\n        ?string $sortBy,\n        Request $request,\n    ): Response {\n        if ($post->magazine !== $magazine) {\n            return $this->redirectToRoute(\n                'post_single',\n                ['magazine_name' => $post->magazine->name, 'post_id' => $post->getId(), 'slug' => $post->slug],\n                301\n            );\n        }\n\n        $response = new Response();\n        if ($post->apId && $post->user->apId) {\n            $response->headers->set('X-Robots-Tag', 'noindex, nofollow');\n        }\n\n        $this->handlePrivateContent($post);\n\n        $images = [];\n        if ($post->image) {\n            $images[] = $post->image;\n        }\n        $images = array_merge($images, $this->commentRepository->findImagesByPost($post));\n        $this->imageRepository->redownloadImagesIfNecessary($images);\n\n        $criteria = new PostCommentPageView($this->getPageNb($request), $this->security);\n        $criteria->showSortOption($criteria->resolveSort($sortBy));\n        $criteria->content = Criteria::CONTENT_MICROBLOG;\n        $criteria->post = $post;\n        $criteria->onlyParents = true;\n        $criteria->perPage = 25;\n\n        if (ThemeSettingsController::CHAT === $request->cookies->get(\n            ThemeSettingsController::POST_COMMENTS_VIEW\n        )) {\n            $criteria->showSortOption(Criteria::SORT_OLD);\n            $criteria->perPage = 100;\n            $criteria->onlyParents = false;\n        }\n\n        $comments = $this->commentRepository->findByCriteria($criteria);\n\n        $commentObjects = [...$comments->getCurrentPageResults()];\n        $this->commentRepository->hydrate(...$commentObjects);\n        $this->commentRepository->hydrateChildren(...$commentObjects);\n\n        $this->dispatcher->dispatch(new PostHasBeenSeenEvent($post));\n\n        if ($request->isXmlHttpRequest()) {\n            return $this->getJsonResponse($magazine, $post, $comments);\n        }\n\n        $dto = new PostCommentDto();\n        if ($this->getUser() && $this->getUser()->addMentionsPosts && $post->user !== $this->getUser()) {\n            $dto->body = $this->mentionManager->addHandle([$post->user->username])[0];\n        }\n\n        return $this->render(\n            'post/single.html.twig',\n            [\n                'magazine' => $magazine,\n                'post' => $post,\n                'comments' => $comments,\n                'criteria' => $criteria,\n                'form' => $this->createForm(\n                    PostCommentType::class,\n                    $dto,\n                    [\n                        'parentLanguage' => $post->lang,\n                    ]\n                )->createView(),\n            ],\n            $response\n        );\n    }\n\n    private function getJsonResponse(Magazine $magazine, Post $post, PagerfantaInterface $comments): JsonResponse\n    {\n        return new JsonResponse(\n            [\n                'html' => $this->renderView(\n                    'post/_single_popup.html.twig',\n                    [\n                        'magazine' => $magazine,\n                        'post' => $post,\n                        'comments' => $comments,\n                    ]\n                ),\n            ]\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Post/PostVotersController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Post;\n\nuse App\\Controller\\AbstractController;\nuse App\\Entity\\Magazine;\nuse App\\Entity\\Post;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\UX\\TwigComponent\\ComponentAttributes;\nuse Twig\\Runtime\\EscaperRuntime;\n\nclass PostVotersController extends AbstractController\n{\n    public function __invoke(\n        #[MapEntity(mapping: ['magazine_name' => 'name'])]\n        Magazine $magazine,\n        #[MapEntity(id: 'post_id')]\n        Post $post,\n        Request $request,\n    ): Response {\n        if ($request->isXmlHttpRequest()) {\n            return new JsonResponse([\n                'html' => $this->renderView('components/voters_inline.html.twig', [\n                    'voters' => $post->getUpVotes()->map(fn ($vote) => $vote->user->username),\n                    'attributes' => new ComponentAttributes([], new EscaperRuntime()),\n                    'count' => 0,\n                ]),\n            ]);\n        }\n\n        return $this->render('post/voters.html.twig', [\n            'magazine' => $magazine,\n            'post' => $post,\n            'votes' => $post->getUpVotes(),\n        ]);\n    }\n}\n"
  },
  {
    "path": "src/Controller/PrivacyPolicyController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller;\n\nuse App\\Repository\\SiteRepository;\nuse App\\Service\\SettingsManager;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\n\nclass PrivacyPolicyController extends AbstractController\n{\n    public function __invoke(SettingsManager $settings, SiteRepository $repository, Request $request): Response\n    {\n        $site = $repository->findAll();\n\n        return $this->render(\n            'page/privacy_policy.html.twig',\n            [\n                'body' => \\count($site) ? $site[0]->privacyPolicy : '',\n            ]\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/ReportController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller;\n\nuse App\\DTO\\ReportDto;\nuse App\\Entity\\Contracts\\ReportInterface;\nuse App\\Exception\\SubjectHasBeenReportedException;\nuse App\\Form\\ReportType;\nuse App\\Service\\ReportManager;\nuse Symfony\\Component\\Form\\FormInterface;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\nuse Symfony\\Contracts\\Translation\\TranslatorInterface;\n\nclass ReportController extends AbstractController\n{\n    public function __construct(\n        private readonly ReportManager $manager,\n        private readonly TranslatorInterface $translator,\n    ) {\n    }\n\n    #[IsGranted('ROLE_USER')]\n    public function __invoke(ReportInterface $subject, Request $request): Response\n    {\n        $dto = ReportDto::create($subject);\n\n        $form = $this->getForm($dto, $subject);\n        $form->handleRequest($request);\n\n        if ($form->isSubmitted() && $form->isValid()) {\n            return $this->handleReportRequest($dto, $request);\n        }\n\n        if ($request->isXmlHttpRequest()) {\n            return $this->getJsonFormResponse($form, 'report/_form_report.html.twig');\n        }\n\n        return $this->render(\n            'report/create.html.twig',\n            [\n                'form' => $form->createView(),\n                'magazine' => $subject->magazine,\n                'subject' => $subject,\n            ]\n        );\n    }\n\n    private function getForm(ReportDto $dto, ReportInterface $subject): FormInterface\n    {\n        return $this->createForm(\n            ReportType::class,\n            $dto,\n            [\n                'action' => $this->generateUrl($dto->getRouteName(), ['id' => $subject->getId()]),\n            ]\n        );\n    }\n\n    private function handleReportRequest(ReportDto $dto, Request $request): Response\n    {\n        $reportError = false;\n        try {\n            $this->manager->report($dto, $this->getUserOrThrow());\n            $responseMessage = $this->translator->trans('subject_reported');\n        } catch (SubjectHasBeenReportedException $exception) {\n            $reportError = true;\n            $responseMessage = $this->translator->trans('subject_reported_exists');\n        } finally {\n            if ($request->isXmlHttpRequest()) {\n                return new JsonResponse(\n                    [\n                        'success' => true,\n                        'html' => \\sprintf(\"<div class='alert %s'>%s</div>\", ($reportError) ? 'alert__danger' : 'alert__info', $responseMessage),\n                    ]\n                );\n            }\n\n            $this->addFlash($reportError ? 'error' : 'info', $responseMessage);\n\n            return $this->redirectToRefererOrHome($request);\n        }\n    }\n}\n"
  },
  {
    "path": "src/Controller/SearchController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller;\n\nuse App\\DTO\\SearchDto;\nuse App\\Form\\SearchType;\nuse App\\Service\\SearchManager;\nuse App\\Service\\SettingsManager;\nuse Psr\\Log\\LoggerInterface;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\n\nclass SearchController extends AbstractController\n{\n    public function __construct(\n        private readonly SearchManager $manager,\n        private readonly SettingsManager $settingsManager,\n        private readonly LoggerInterface $logger,\n    ) {\n    }\n\n    public function __invoke(Request $request): Response\n    {\n        $dto = new SearchDto();\n        $dto->since = new \\DateTimeImmutable('@0');\n        $form = $this->createForm(SearchType::class, $dto, ['csrf_protection' => false]);\n        try {\n            $form = $form->handleRequest($request);\n            if ($form->isSubmitted() && $form->isValid()) {\n                /** @var SearchDto $dto */\n                $dto = $form->getData();\n                $query = trim($dto->q);\n                $this->logger->debug('searching for {query}', ['query' => $query]);\n\n                $objects = [];\n                if ($this->federatedSearchAllowed() && (str_contains($query, '@') || false !== filter_var($query, FILTER_VALIDATE_URL))) {\n                    $this->logger->debug('searching for a matched handle or ap url {query}', ['query' => $query]);\n                    $objects = $this->findObjectsByAp($query);\n                }\n\n                $user = $this->getUser();\n                $res = $this->manager->findPaginated($user, $query, $this->getPageNb($request), authorId: $dto->user?->getId(), magazineId: $dto->magazine?->getId(), specificType: $dto->type, sinceDate: $dto->since);\n\n                $this->logger->debug('results: {num}', ['num' => $res->count()]);\n\n                if ($request->isXmlHttpRequest()) {\n                    return new JsonResponse([\n                        'html' => $this->renderView('search/_list.html.twig', [\n                            'results' => $res,\n                        ]),\n                    ]);\n                }\n\n                return $this->render(\n                    'search/front.html.twig',\n                    [\n                        'objects' => $objects,\n                        'results' => $res,\n                        'pagination' => $res,\n                        'form' => $form->createView(),\n                        'q' => $query,\n                    ]\n                );\n            }\n        } catch (\\Exception $e) {\n            $this->logger->error($e);\n        }\n\n        return $this->render(\n            'search/front.html.twig',\n            [\n                'objects' => [],\n                'results' => [],\n                'form' => $form->createView(),\n            ]\n        );\n    }\n\n    private function federatedSearchAllowed(): bool\n    {\n        return !$this->settingsManager->get('KBIN_FEDERATED_SEARCH_ONLY_LOGGEDIN')\n            || $this->getUser();\n    }\n\n    private function findObjectsByAp(string $urlOrHandle): array\n    {\n        $result = $this->manager->findActivityPubActorsOrObjects($urlOrHandle);\n\n        foreach ($result['errors'] as $error) {\n            /** @var \\Throwable $error */\n            $this->addFlash('error', $error->getMessage());\n        }\n\n        return $result['results'];\n    }\n}\n"
  },
  {
    "path": "src/Controller/Security/AuthentikController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Security;\n\nuse App\\Controller\\AbstractController;\nuse KnpU\\OAuth2ClientBundle\\Client\\ClientRegistry;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\n\nclass AuthentikController extends AbstractController\n{\n    public function connect(ClientRegistry $clientRegistry): Response\n    {\n        return $clientRegistry\n            ->getClient('authentik')\n            ->redirect([\n                'openid',\n                'email',\n                'profile',\n            ]);\n    }\n\n    public function verify(Request $request, ClientRegistry $client)\n    {\n    }\n}\n"
  },
  {
    "path": "src/Controller/Security/AzureController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Security;\n\nuse App\\Controller\\AbstractController;\nuse KnpU\\OAuth2ClientBundle\\Client\\ClientRegistry;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\n\nclass AzureController extends AbstractController\n{\n    public function connect(ClientRegistry $clientRegistry): Response\n    {\n        return $clientRegistry\n            ->getClient('azure')\n            ->redirect([\n                'User.Read.All',\n            ]);\n    }\n\n    public function verify(Request $request, ClientRegistry $client)\n    {\n    }\n}\n"
  },
  {
    "path": "src/Controller/Security/DiscordController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Security;\n\nuse App\\Controller\\AbstractController;\nuse KnpU\\OAuth2ClientBundle\\Client\\ClientRegistry;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\n\nclass DiscordController extends AbstractController\n{\n    public function connect(ClientRegistry $clientRegistry): Response\n    {\n        return $clientRegistry\n            ->getClient('discord')\n            ->redirect();\n    }\n\n    public function verify(Request $request, ClientRegistry $client)\n    {\n    }\n}\n"
  },
  {
    "path": "src/Controller/Security/FacebookController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Security;\n\nuse App\\Controller\\AbstractController;\nuse KnpU\\OAuth2ClientBundle\\Client\\ClientRegistry;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\n\nclass FacebookController extends AbstractController\n{\n    public function connect(ClientRegistry $clientRegistry): Response\n    {\n        return $clientRegistry\n            ->getClient('facebook')\n            ->redirect([\n                'public_profile',\n                'email',\n            ]);\n    }\n\n    public function verify(Request $request, ClientRegistry $client)\n    {\n    }\n}\n"
  },
  {
    "path": "src/Controller/Security/GithubController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Security;\n\nuse App\\Controller\\AbstractController;\nuse KnpU\\OAuth2ClientBundle\\Client\\ClientRegistry;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\n\nclass GithubController extends AbstractController\n{\n    public function connect(ClientRegistry $clientRegistry): Response\n    {\n        return $clientRegistry\n            ->getClient('github')\n            ->redirect(\n                ['read:user', 'user:email'],\n                [\n                    'scope' => 'read:user,user:email',\n                ]\n            );\n    }\n\n    public function verify(Request $request, ClientRegistry $client)\n    {\n    }\n}\n"
  },
  {
    "path": "src/Controller/Security/GoogleController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Security;\n\nuse App\\Controller\\AbstractController;\nuse KnpU\\OAuth2ClientBundle\\Client\\ClientRegistry;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\n\nclass GoogleController extends AbstractController\n{\n    public function connect(ClientRegistry $clientRegistry): Response\n    {\n        return $clientRegistry\n            ->getClient('google')\n            ->redirect();\n    }\n\n    public function verify(Request $request, ClientRegistry $client)\n    {\n    }\n}\n"
  },
  {
    "path": "src/Controller/Security/KeycloakController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Security;\n\nuse App\\Controller\\AbstractController;\nuse KnpU\\OAuth2ClientBundle\\Client\\ClientRegistry;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\n\nclass KeycloakController extends AbstractController\n{\n    public function connect(ClientRegistry $clientRegistry): Response\n    {\n        return $clientRegistry\n            ->getClient('keycloak')\n            ->redirect([\n                'openid',\n                'email',\n                'profile',\n                'address',\n            ]);\n    }\n\n    public function verify(Request $request, ClientRegistry $client)\n    {\n    }\n}\n"
  },
  {
    "path": "src/Controller/Security/LoginController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Security;\n\nuse App\\Controller\\AbstractController;\nuse App\\Entity\\Client;\nuse App\\Entity\\OAuth2UserConsent;\nuse App\\Entity\\User;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\Security\\Http\\Authentication\\AuthenticationUtils;\n\nclass LoginController extends AbstractController\n{\n    public function __invoke(AuthenticationUtils $utils, Request $request): Response\n    {\n        if ($user = $this->getUser()) {\n            return $this->redirectToRoute('front');\n        }\n\n        $error = $utils->getLastAuthenticationError();\n        $lastUsername = $utils->getLastUsername();\n\n        return $this->render('user/login.html.twig', ['last_username' => $lastUsername, 'error' => $error]);\n    }\n\n    public function consent(Request $request, EntityManagerInterface $entityManager): Response\n    {\n        $clientId = $request->query->get('client_id');\n        if (!$clientId || !ctype_alnum($clientId) || !$this->getUser()) {\n            return $this->redirectToRoute('front');\n        }\n\n        /** @var Client $appClient */\n        $appClient = $entityManager->getRepository(Client::class)->findOneBy(['identifier' => $clientId]);\n        if (!$appClient) {\n            $this->addFlash('danger', 'oauth.client_identifier.invalid');\n\n            return $this->redirectToRoute('front');\n        }\n\n        $appName = $appClient->getName();\n\n        // Get the client scopes\n        $requestedScopes = explode(' ', $request->query->get('scope'));\n        // Get the client scopes in the database\n        $clientScopes = $appClient->getScopes();\n\n        // Check all requested scopes are in the client scopes, if not return an error\n        if (0 < \\count(array_diff($requestedScopes, $clientScopes))) {\n            $request->getSession()->set('consent_granted', false);\n\n            return $this->redirectToRoute('oauth2_authorize', $request->query->all());\n        }\n\n        // Check if the user has already consented to the scopes\n        /** @var User $user */\n        $user = $this->getUser();\n        /** @var ?OAuth2UserConsent $userConsents */\n        $userConsents = $user->getOAuth2UserConsents()->filter(\n            fn (OAuth2UserConsent $consent) => $consent->getClient() === $appClient\n        )->first() ?: null;\n        if ($userConsents) {\n            $userScopes = $userConsents->getScopes();\n        } else {\n            $userScopes = [];\n        }\n        $hasExistingScopes = \\count($userScopes) > 0;\n\n        // If user has already consented to the scopes, give consent\n        if (0 === \\count(array_diff($requestedScopes, $userScopes))) {\n            $request->getSession()->set('consent_granted', true);\n\n            return $this->redirectToRoute('oauth2_authorize', $request->query->all());\n        }\n\n        // Remove the scopes to which the user has already consented\n        $requestedScopes = array_diff($requestedScopes, $userScopes);\n\n        // Get all the scope translation keys in the requested scopes.\n        $requestedScopeNames = array_map(fn ($scope) => OAuth2UserConsent::SCOPE_DESCRIPTIONS[$scope], $requestedScopes);\n        $existingScopes = array_map(fn ($scope) => OAuth2UserConsent::SCOPE_DESCRIPTIONS[$scope], $userScopes);\n\n        if ($request->isMethod('POST')) {\n            if ('yes' === $request->request->get('consent')) {\n                $request->getSession()->set('consent_granted', true);\n                // Add the requested scopes to the user's scopes\n                $consents = $userConsents ?? new OAuth2UserConsent();\n                $consents->setScopes(array_merge($requestedScopes, $userScopes));\n                $consents->setClient($appClient);\n                $consents->setCreatedAt(new \\DateTimeImmutable());\n                $consents->setExpiresAt(new \\DateTimeImmutable('+30 days'));\n                $consents->setIpAddress($request->getClientIp());\n                $user->addOAuth2UserConsent($consents);\n                $entityManager->persist($consents);\n                $entityManager->flush();\n            }\n            if ('no' === $request->request->get('consent')) {\n                $request->getSession()->set('consent_granted', false);\n            }\n\n            return $this->redirectToRoute('oauth2_authorize', $request->query->all());\n        }\n\n        return $this->render('user/consent.html.twig', [\n            'app_name' => $appName,\n            'scopes' => $requestedScopeNames,\n            'has_existing_scopes' => $hasExistingScopes,\n            'existing_scopes' => $existingScopes,\n            'image' => $appClient->getImage(),\n        ]);\n    }\n}\n"
  },
  {
    "path": "src/Controller/Security/LogoutController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Security;\n\nuse App\\Controller\\AbstractController;\n\nclass LogoutController extends AbstractController\n{\n    public function __invoke()\n    {\n        throw new \\LogicException('This method can be blank - it will be intercepted by the logout key on your firewall.');\n    }\n}\n"
  },
  {
    "path": "src/Controller/Security/PrivacyPortalController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Security;\n\nuse App\\Controller\\AbstractController;\nuse KnpU\\OAuth2ClientBundle\\Client\\ClientRegistry;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\n\nclass PrivacyPortalController extends AbstractController\n{\n    public function connect(ClientRegistry $clientRegistry): Response\n    {\n        return $clientRegistry\n            ->getClient('privacyportal')\n            ->redirect([\n                'openid',\n                'name',\n                'email',\n            ]);\n    }\n\n    public function verify(Request $request, ClientRegistry $client)\n    {\n    }\n}\n"
  },
  {
    "path": "src/Controller/Security/RegisterController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Security;\n\nuse App\\Controller\\AbstractController;\nuse App\\DTO\\UserDto;\nuse App\\Form\\UserRegisterType;\nuse App\\Service\\IpResolver;\nuse App\\Service\\SettingsManager;\nuse App\\Service\\UserManager;\nuse Psr\\Log\\LoggerInterface;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\n\nclass RegisterController extends AbstractController\n{\n    public function __construct(\n        private readonly UserManager $manager,\n        private readonly IpResolver $ipResolver,\n        private readonly SettingsManager $settingsManager,\n        private readonly LoggerInterface $logger,\n    ) {\n    }\n\n    public function __invoke(Request $request): Response\n    {\n        if (true === $this->settingsManager->get('MBIN_SSO_ONLY_MODE')) {\n            return $this->redirectToRoute('app_login');\n        }\n\n        if ($this->getUser()) {\n            return $this->redirectToRoute('front');\n        }\n\n        $form = $this->createForm(UserRegisterType::class, options: [\n            'antispam_profile' => 'default',\n        ]);\n        $form->handleRequest($request);\n\n        if ($form->isSubmitted() && $form->isValid()) {\n            /** @var UserDto $dto */\n            $dto = $form->getData();\n            $dto->ip = $this->ipResolver->resolve();\n\n            $this->manager->create($dto);\n\n            $this->addFlash(\n                'success',\n                'flash_register_success'\n            );\n\n            if ($this->settingsManager->getNewUsersNeedApproval()) {\n                $this->addFlash(\n                    'success',\n                    'flash_application_info'\n                );\n            }\n\n            return $this->redirectToRoute('app_login');\n        } elseif ($form->isSubmitted() && !$form->isValid()) {\n            $this->logger->error('Registration form submission was invalid.', [\n                'errors' => $form->getErrors(true, false),\n            ]);\n        }\n\n        return $this->render(\n            'user/register.html.twig',\n            [\n                'form' => $form->createView(),\n            ],\n            new Response(null, $form->isSubmitted() && !$form->isValid() ? 422 : 200)\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Security/ResendActivationEmailController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Security;\n\nuse App\\Controller\\AbstractController;\nuse App\\Entity\\User;\nuse App\\Form\\ResendEmailActivationFormType;\nuse App\\MessageHandler\\SentUserConfirmationEmailHandler;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Psr\\Log\\LoggerInterface;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\n\nclass ResendActivationEmailController extends AbstractController\n{\n    public function __construct(\n        private readonly EntityManagerInterface $entityManager,\n        private readonly LoggerInterface $logger,\n    ) {\n    }\n\n    public function resend(Request $request, SentUserConfirmationEmailHandler $confirmationHandler): Response\n    {\n        $form = $this->createForm(ResendEmailActivationFormType::class);\n        $form->handleRequest($request);\n\n        if ($form->isSubmitted() && $form->isValid()) {\n            $email = $form->get('email')->getData();\n            $user = $this->entityManager->getRepository(User::class)->findOneBy([\n                'email' => $email,\n            ]);\n\n            if (\\is_null($user) || $user->isVerified || $user->isDeleted) {\n                $this->addFlash('error', 'resend_account_activation_email_error');\n\n                return $this->redirectToRoute('app_resend_email_activation');\n            }\n\n            try {\n                // send confirmation email to user\n                $confirmationHandler->sendConfirmationEmail($user);\n                $this->addFlash('success', 'resend_account_activation_email_success');\n\n                return $this->redirectToRoute('app_resend_email_activation');\n            } catch (\\Exception $e) {\n                $this->logger->error('There was an exception trying to re-send the activation email to: {u} - {mail}: {e} - {msg}', ['u' => $user->username, 'mail' => $user->email, 'e' => \\get_class($e), 'msg' => $e->getMessage()]);\n                $this->addFlash('error', 'resend_account_activation_email_error');\n\n                return $this->redirectToRoute('app_resend_email_activation');\n            }\n        }\n\n        return $this->render('resend_verification_email/resend.html.twig', [\n            'form' => $form,\n        ]);\n    }\n}\n"
  },
  {
    "path": "src/Controller/Security/ResetPasswordController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Security;\n\nuse App\\Entity\\User;\nuse App\\Form\\ChangePasswordFormType;\nuse App\\Form\\ResetPasswordRequestFormType;\nuse App\\Service\\SettingsManager;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Symfony\\Bridge\\Twig\\Mime\\TemplatedEmail;\nuse Symfony\\Bundle\\FrameworkBundle\\Controller\\AbstractController;\nuse Symfony\\Component\\HttpFoundation\\RedirectResponse;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\Mailer\\MailerInterface;\nuse Symfony\\Component\\Mime\\Address;\nuse Symfony\\Component\\PasswordHasher\\Hasher\\UserPasswordHasherInterface;\nuse Symfony\\Contracts\\Translation\\TranslatorInterface;\nuse SymfonyCasts\\Bundle\\ResetPassword\\Controller\\ResetPasswordControllerTrait;\nuse SymfonyCasts\\Bundle\\ResetPassword\\Exception\\ResetPasswordExceptionInterface;\nuse SymfonyCasts\\Bundle\\ResetPassword\\ResetPasswordHelperInterface;\n\nclass ResetPasswordController extends AbstractController\n{\n    use ResetPasswordControllerTrait;\n\n    public function __construct(\n        private readonly SettingsManager $settingsManager,\n        private readonly ResetPasswordHelperInterface $resetPasswordHelper,\n        private readonly EntityManagerInterface $entityManager,\n    ) {\n    }\n\n    public function request(Request $request, MailerInterface $mailer, TranslatorInterface $translator): Response\n    {\n        $form = $this->createForm(ResetPasswordRequestFormType::class);\n        $form->handleRequest($request);\n\n        if ($form->isSubmitted() && $form->isValid()) {\n            return $this->processSendingPasswordResetEmail(\n                $form->get('email')->getData(),\n                $mailer,\n                $translator\n            );\n        }\n\n        return $this->render('reset_password/request.html.twig', [\n            'form' => $form->createView(),\n        ]);\n    }\n\n    private function processSendingPasswordResetEmail(\n        string $emailFormData,\n        MailerInterface $mailer,\n        TranslatorInterface $translator,\n    ): RedirectResponse {\n        $user = $this->entityManager->getRepository(User::class)->findOneBy([\n            'email' => $emailFormData,\n        ]);\n\n        // Do not reveal whether a user account was found or not.\n        if (!$user) {\n            return $this->redirectToRoute('app_check_email');\n        }\n\n        try {\n            $resetToken = $this->resetPasswordHelper->generateResetToken($user);\n        } catch (ResetPasswordExceptionInterface $e) {\n            // If you want to tell the user why a reset email was not sent, uncomment\n            // the lines below and change the redirect to 'app_forgot_password_request'.\n            // Caution: This may reveal if a user is registered or not.\n            //\n            // $this->addFlash('reset_password_error', sprintf(\n            //     '%s - %s',\n            //     $translator->trans(ResetPasswordExceptionInterface::MESSAGE_PROBLEM_HANDLE, [], 'ResetPasswordBundle'),\n            //     $translator->trans($e->getReason(), [], 'ResetPasswordBundle')\n            // ));\n\n            return $this->redirectToRoute('app_check_email');\n        }\n\n        $email = (new TemplatedEmail())\n            ->from(new Address($this->settingsManager->get('KBIN_SENDER_EMAIL'), $this->settingsManager->get('KBIN_DOMAIN')))\n            ->to($user->getEmail())\n            ->subject($translator->trans('reset_password'))\n            ->htmlTemplate('_email/reset_pass_confirm.html.twig')\n            ->context([\n                'resetToken' => $resetToken,\n            ]);\n\n        $mailer->send($email);\n\n        // Store the token object in session for retrieval in check-email route.\n        $this->setTokenObjectInSession($resetToken);\n\n        return $this->redirectToRoute('app_check_email');\n    }\n\n    public function checkEmail(): Response\n    {\n        // Generate a fake token if the user does not exist or someone hit this page directly.\n        // This prevents exposing whether or not a user was found with the given email address or not\n        if (null === ($resetToken = $this->getTokenObjectFromSession())) {\n            $resetToken = $this->resetPasswordHelper->generateFakeResetToken();\n        }\n\n        return $this->render('reset_password/check_email.html.twig', [\n            'resetToken' => $resetToken,\n        ]);\n    }\n\n    public function reset(\n        Request $request,\n        UserPasswordHasherInterface $userPasswordHasher,\n        TranslatorInterface $translator,\n        ?string $token = null,\n    ): Response {\n        if ($token) {\n            // We store the token in session and remove it from the URL, to avoid the URL being\n            // loaded in a browser and potentially leaking the token to 3rd party JavaScript.\n            $this->storeTokenInSession($token);\n\n            return $this->redirectToRoute('app_reset_password');\n        }\n\n        $token = $this->getTokenFromSession();\n        if (null === $token) {\n            throw $this->createNotFoundException('No reset password token found in the URL or in the session.');\n        }\n\n        try {\n            $user = $this->resetPasswordHelper->validateTokenAndFetchUser($token);\n        } catch (ResetPasswordExceptionInterface $e) {\n            $this->addFlash(\n                'reset_password_error',\n                \\sprintf(\n                    '%s - %s',\n                    $translator->trans(\n                        ResetPasswordExceptionInterface::MESSAGE_PROBLEM_VALIDATE,\n                        [],\n                        'ResetPasswordBundle'\n                    ),\n                    $translator->trans($e->getReason(), [], 'ResetPasswordBundle')\n                )\n            );\n\n            return $this->redirectToRoute('app_forgot_password_request');\n        }\n\n        // The token is valid; allow the user to change their password.\n        $form = $this->createForm(ChangePasswordFormType::class);\n        $form->handleRequest($request);\n\n        if ($form->isSubmitted() && $form->isValid()) {\n            // A password reset token should be used only once, remove it.\n            $this->resetPasswordHelper->removeResetRequest($token);\n\n            // Encode(hash) the plain password, and set it.\n            $encodedPassword = $userPasswordHasher->hashPassword(\n                $user,\n                $form->get('plainPassword')->getData()\n            );\n\n            $user->setPassword($encodedPassword);\n            $this->entityManager->flush();\n\n            // The session is cleaned up after the password has been changed.\n            $this->cleanSessionAfterReset();\n\n            return $this->redirectToRoute('app_login');\n        }\n\n        return $this->render('reset_password/reset.html.twig', [\n            'form' => $form->createView(),\n        ]);\n    }\n}\n"
  },
  {
    "path": "src/Controller/Security/SimpleLoginController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Security;\n\nuse App\\Controller\\AbstractController;\nuse KnpU\\OAuth2ClientBundle\\Client\\ClientRegistry;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\n\nclass SimpleLoginController extends AbstractController\n{\n    public function connect(ClientRegistry $clientRegistry): Response\n    {\n        return $clientRegistry\n            ->getClient('simplelogin')\n            ->redirect([\n                'openid',\n                'email',\n                'profile',\n            ]);\n    }\n\n    public function verify(Request $request, ClientRegistry $client)\n    {\n    }\n}\n"
  },
  {
    "path": "src/Controller/Security/VerifyEmailController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Security;\n\nuse App\\Controller\\AbstractController;\nuse App\\Repository\\UserRepository;\nuse App\\Service\\UserManager;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse SymfonyCasts\\Bundle\\VerifyEmail\\Exception\\VerifyEmailExceptionInterface;\n\nclass VerifyEmailController extends AbstractController\n{\n    public function __invoke(Request $request, UserRepository $repository, UserManager $manager): Response\n    {\n        $id = $request->get('id');\n\n        if (null === $id) {\n            return $this->redirectToRoute('app_register');\n        }\n\n        try {\n            $user = $repository->find($id);\n        } catch (\\Exception) {\n            return $this->redirectToRoute('app_register');\n        }\n\n        if (null === $user) {\n            return $this->redirectToRoute('app_register');\n        }\n\n        try {\n            $manager->verify($request, $user);\n        } catch (VerifyEmailExceptionInterface $exception) {\n            return $this->redirectToRoute('app_register');\n        }\n\n        return $this->redirectToRoute('app_login');\n    }\n}\n"
  },
  {
    "path": "src/Controller/Security/ZitadelController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Security;\n\nuse App\\Controller\\AbstractController;\nuse KnpU\\OAuth2ClientBundle\\Client\\ClientRegistry;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\n\nclass ZitadelController extends AbstractController\n{\n    public function connect(ClientRegistry $clientRegistry): Response\n    {\n        return $clientRegistry\n            ->getClient('zitadel')\n            ->redirect([\n                'openid',\n                'email',\n                'profile',\n            ]);\n    }\n\n    public function verify(Request $request, ClientRegistry $client)\n    {\n    }\n}\n"
  },
  {
    "path": "src/Controller/StatsController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller;\n\nuse App\\Repository\\StatsRepository;\nuse App\\Service\\InstanceStatsManager;\nuse App\\Service\\StatsManager;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\n\nclass StatsController extends AbstractController\n{\n    public function __construct(private readonly InstanceStatsManager $counter, private readonly StatsManager $manager)\n    {\n    }\n\n    public function __invoke(?string $statsType, ?int $statsPeriod, ?bool $withFederated, Request $request): Response\n    {\n        $statsType = $this->manager->resolveType($statsType);\n\n        if (!$statsPeriod) {\n            $statsPeriod = 31;\n        }\n\n        if (-1 === $statsPeriod) {\n            $statsPeriod = null;\n        }\n\n        if ($statsPeriod) {\n            $statsPeriod = min($statsPeriod, 365);\n            $start = (new \\DateTime())->modify(\"-$statsPeriod days\");\n        }\n\n        if (null === $withFederated) {\n            $withFederated = false;\n        }\n\n        $results = match ($statsType) {\n            StatsRepository::TYPE_CONTENT => $statsPeriod\n                ? $this->manager->drawDailyContentStatsByTime($start, onlyLocal: !$withFederated)\n                : $this->manager->drawMonthlyContentChart(onlyLocal: !$withFederated),\n            StatsRepository::TYPE_VOTES => $statsPeriod\n                ? $this->manager->drawDailyVotesStatsByTime($start, onlyLocal: !$withFederated)\n                : $this->manager->drawMonthlyVotesChart(onlyLocal: !$withFederated),\n            default => null,\n        };\n\n        return $this->render(\n            'stats/front.html.twig',\n            [\n                'type' => $statsType ?? StatsRepository::TYPE_GENERAL,\n                'period' => $statsPeriod,\n                'chart' => $results,\n                'withFederated' => $withFederated,\n            ] + ((!$statsType || StatsRepository::TYPE_GENERAL === $statsType) ? $this->counter->count($statsPeriod ? \"-$statsPeriod days\" : null, $withFederated) : []),\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Tag/TagBanController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Tag;\n\nuse App\\Controller\\AbstractController;\nuse App\\Repository\\TagRepository;\nuse App\\Service\\TagManager;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass TagBanController extends AbstractController\n{\n    public function __construct(\n        private readonly TagManager $tagManager,\n        private readonly TagRepository $tagRepository,\n    ) {\n    }\n\n    #[IsGranted('ROLE_ADMIN')]\n    public function ban(string $name, Request $request): Response\n    {\n        $this->validateCsrf('ban', $request->getPayload()->get('token'));\n\n        $hashtag = $this->tagRepository->findOneBy(['tag' => $name]);\n        if (null === $hashtag) {\n            $hashtag = $this->tagRepository->create($name);\n        }\n        $this->tagManager->ban($hashtag);\n\n        return $this->redirectToRoute('tag_overview', ['name' => $hashtag->tag]);\n    }\n\n    #[IsGranted('ROLE_ADMIN')]\n    public function unban(string $name, Request $request): Response\n    {\n        $this->validateCsrf('ban', $request->getPayload()->get('token'));\n\n        $hashtag = $this->tagRepository->findOneBy(['tag' => $name]);\n        if ($hashtag) {\n            $this->tagManager->unban($hashtag);\n\n            return $this->redirectToRoute('tag_overview', ['name' => $hashtag->tag]);\n        } else {\n            throw $this->createNotFoundException();\n        }\n    }\n}\n"
  },
  {
    "path": "src/Controller/Tag/TagCommentFrontController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Tag;\n\nuse App\\Controller\\AbstractController;\nuse App\\PageView\\EntryCommentPageView;\nuse App\\Repository\\EntryCommentRepository;\nuse App\\Repository\\TagRepository;\nuse App\\Service\\TagExtractor;\nuse Symfony\\Bundle\\SecurityBundle\\Security;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\n\nclass TagCommentFrontController extends AbstractController\n{\n    public function __construct(\n        private readonly EntryCommentRepository $repository,\n        private readonly TagRepository $tagRepository,\n        private readonly TagExtractor $tagManager,\n        private readonly Security $security,\n    ) {\n    }\n\n    public function __invoke(string $name, ?string $sortBy, ?string $time, Request $request): Response\n    {\n        $criteria = new EntryCommentPageView($this->getPageNb($request), $this->security);\n        $criteria->showSortOption($criteria->resolveSort($sortBy))\n            ->setTime($criteria->resolveTime($time))\n            ->setTag($this->tagManager->transliterate(strtolower($name)));\n\n        $params = [\n            'comments' => $this->repository->findByCriteria($criteria),\n            'tag' => $name,\n            'counts' => $this->tagRepository->getCounts($name),\n        ];\n\n        return $this->render(\n            'tag/comments.html.twig',\n            $params\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Tag/TagEntryFrontController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Tag;\n\nuse App\\Controller\\AbstractController;\nuse App\\PageView\\EntryPageView;\nuse App\\Repository\\Criteria;\nuse App\\Repository\\EntryRepository;\nuse App\\Repository\\TagRepository;\nuse App\\Service\\TagExtractor;\nuse Pagerfanta\\PagerfantaInterface;\nuse Symfony\\Bundle\\SecurityBundle\\Security;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\n\nclass TagEntryFrontController extends AbstractController\n{\n    public function __construct(\n        private readonly EntryRepository $entryRepository,\n        private readonly TagRepository $tagRepository,\n        private readonly TagExtractor $tagManager,\n        private readonly Security $security,\n    ) {\n    }\n\n    public function __invoke(?string $name, ?string $sortBy, ?string $time, ?string $type, Request $request): Response\n    {\n        $criteria = new EntryPageView($this->getPageNb($request), $this->security);\n        $criteria->showSortOption($criteria->resolveSort($sortBy))\n            ->setTime($criteria->resolveTime($time))\n            ->setType($criteria->resolveType($type))\n            ->setTag($this->tagManager->transliterate(strtolower($name)));\n        $method = $criteria->resolveSort($sortBy);\n        $listing = $this->$method($criteria);\n\n        return $this->render(\n            'tag/front.html.twig',\n            [\n                'tag' => $name,\n                'entries' => $listing,\n                'counts' => $this->tagRepository->getCounts($name),\n            ]\n        );\n    }\n\n    private function hot(EntryPageView $criteria): PagerfantaInterface\n    {\n        return $this->entryRepository->findByCriteria($criteria->showSortOption(Criteria::SORT_HOT));\n    }\n\n    private function top(EntryPageView $criteria): PagerfantaInterface\n    {\n        return $this->entryRepository->findByCriteria($criteria->showSortOption(Criteria::SORT_TOP));\n    }\n\n    private function active(EntryPageView $criteria): PagerfantaInterface\n    {\n        return $this->entryRepository->findByCriteria($criteria->showSortOption(Criteria::SORT_ACTIVE));\n    }\n\n    private function newest(EntryPageView $criteria): PagerfantaInterface\n    {\n        return $this->entryRepository->findByCriteria($criteria->showSortOption(Criteria::SORT_NEW));\n    }\n\n    private function commented(EntryPageView $criteria): PagerfantaInterface\n    {\n        return $this->entryRepository->findByCriteria($criteria->showSortOption(Criteria::SORT_COMMENTED));\n    }\n}\n"
  },
  {
    "path": "src/Controller/Tag/TagOverviewController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Tag;\n\nuse App\\Controller\\AbstractController;\nuse App\\Repository\\TagRepository;\nuse App\\Service\\SubjectOverviewManager;\nuse App\\Service\\TagExtractor;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\n\nclass TagOverviewController extends AbstractController\n{\n    public function __construct(\n        private readonly TagExtractor $tagManager,\n        private readonly TagRepository $tagRepository,\n        private readonly SubjectOverviewManager $overviewManager,\n    ) {\n    }\n\n    public function __invoke(string $name, Request $request): Response\n    {\n        $activity = $this->tagRepository->findOverall(\n            $this->getPageNb($request),\n            $this->tagManager->transliterate(strtolower($name))\n        );\n\n        $params = [\n            'tag' => $name,\n            'results' => $this->overviewManager->buildList($activity),\n            'pagination' => $activity,\n            'counts' => $this->tagRepository->getCounts($name),\n        ];\n\n        if ($request->isXmlHttpRequest()) {\n            return new JsonResponse(['html' => $this->renderView('tag/_list.html.twig', $params)]);\n        }\n\n        return $this->render('tag/overview.html.twig', $params);\n    }\n}\n"
  },
  {
    "path": "src/Controller/Tag/TagPeopleFrontController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Tag;\n\nuse App\\Controller\\AbstractController;\nuse App\\Repository\\MagazineRepository;\nuse App\\Repository\\PostRepository;\nuse App\\Repository\\TagRepository;\nuse App\\Service\\PeopleManager;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\n\nclass TagPeopleFrontController extends AbstractController\n{\n    public function __construct(\n        private readonly PeopleManager $manager,\n        private readonly TagRepository $tagRepository,\n        private readonly MagazineRepository $magazineRepository,\n    ) {\n    }\n\n    public function __invoke(\n        string $name,\n        ?string $sortBy,\n        ?string $time,\n        PostRepository $repository,\n        Request $request,\n    ): Response {\n        return $this->render(\n            'tag/people.html.twig', [\n                'tag' => $name,\n                'magazines' => array_filter(\n                    $this->magazineRepository->findByActivity(),\n                    fn ($val) => 'random' !== $val->name\n                ),\n                'local' => $this->manager->general(),\n                'federated' => $this->manager->general(true),\n                'counts' => $this->tagRepository->getCounts($name),\n            ]\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Tag/TagPostFrontController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Tag;\n\nuse App\\Controller\\AbstractController;\nuse App\\PageView\\PostPageView;\nuse App\\Repository\\PostRepository;\nuse App\\Repository\\TagRepository;\nuse App\\Service\\TagExtractor;\nuse Symfony\\Bundle\\SecurityBundle\\Security;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\n\nclass TagPostFrontController extends AbstractController\n{\n    public function __construct(\n        private readonly TagExtractor $tagManager,\n        private readonly TagRepository $tagRepository,\n        private readonly Security $security,\n    ) {\n    }\n\n    public function __invoke(\n        string $name,\n        ?string $sortBy,\n        ?string $time,\n        PostRepository $repository,\n        Request $request,\n    ): Response {\n        $criteria = new PostPageView($this->getPageNb($request), $this->security);\n        $criteria->showSortOption($criteria->resolveSort($sortBy))\n            ->setTime($criteria->resolveTime($time))\n            ->setTag($this->tagManager->transliterate(strtolower($name)));\n\n        $posts = $repository->findByCriteria($criteria);\n\n        return $this->render(\n            'tag/posts.html.twig',\n            [\n                'tag' => $name,\n                'posts' => $posts,\n                'counts' => $this->tagRepository->getCounts($name),\n            ]\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/TermsController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller;\n\nuse App\\Repository\\SiteRepository;\nuse App\\Service\\SettingsManager;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\n\nclass TermsController extends AbstractController\n{\n    public function __invoke(SettingsManager $settings, SiteRepository $repository, Request $request): Response\n    {\n        $site = $repository->findAll();\n\n        return $this->render(\n            'page/terms.html.twig',\n            [\n                'body' => $site[0]->terms ?? '',\n            ]\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/Traits/PrivateContentTrait.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\Traits;\n\nuse App\\Entity\\Contracts\\ContentInterface;\n\n/**\n * @method createAccessDeniedException()\n */\ntrait PrivateContentTrait\n{\n    private function handlePrivateContent(ContentInterface $entry): void\n    {\n        if (true === $entry->isPrivate()) {\n            if (null === $this->getUser()) {\n                throw $this->createAccessDeniedException();\n            }\n\n            if (false === $this->getUser()->isFollowing($entry->user)) {\n                throw $this->createAccessDeniedException();\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "src/Controller/User/AccountDeletionController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\User;\n\nuse App\\Controller\\AbstractController;\nuse App\\Form\\UserAccountDeletionType;\nuse App\\Service\\IpResolver;\nuse App\\Service\\UserManager;\nuse Psr\\Log\\LoggerInterface;\nuse Symfony\\Bundle\\SecurityBundle\\Security;\nuse Symfony\\Component\\Form\\FormError;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\HttpKernel\\Exception\\TooManyRequestsHttpException;\nuse Symfony\\Component\\PasswordHasher\\Hasher\\UserPasswordHasherInterface;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\nuse Symfony\\Contracts\\Translation\\TranslatorInterface;\n\nclass AccountDeletionController extends AbstractController\n{\n    public function __construct(\n        private readonly Security $security,\n        private readonly UserManager $userManager,\n        private readonly RateLimiterFactoryInterface $userDeleteLimiter,\n        private readonly IpResolver $ipResolver,\n        private readonly LoggerInterface $logger,\n        private readonly UserPasswordHasherInterface $userPasswordHasher,\n        private readonly TranslatorInterface $translator,\n    ) {\n    }\n\n    #[IsGranted('ROLE_USER')]\n    public function __invoke(Request $request): Response\n    {\n        $this->denyAccessUnlessGranted('edit_profile', $this->getUserOrThrow());\n\n        $form = $this->createForm(UserAccountDeletionType::class);\n        $user = $this->getUserOrThrow();\n\n        if ($user->isAdmin()) {\n            return $this->redirectToRoute('user_settings_general');\n        }\n\n        try {\n            // Could throw an error\n            $form->handleRequest($request);\n            if ($form->isSubmitted() && $form->has('currentPassword')) {\n                if (!$this->userPasswordHasher->isPasswordValid($user, $form->get('currentPassword')->getData())) {\n                    $form->get('currentPassword')->addError(new FormError($this->translator->trans('Password is invalid')));\n                }\n            }\n\n            if ($form->isSubmitted() && $form->isValid()) {\n                $limiter = $this->userDeleteLimiter->create($this->ipResolver->resolve());\n                if (false === $limiter->consume()->isAccepted()) {\n                    throw new TooManyRequestsHttpException();\n                }\n                $this->userManager->deleteRequest($user, true === $form->get('instantDelete')->getData());\n                $this->security->logout(false);\n\n                return $this->redirect('/');\n            }\n        } catch (\\Exception $e) {\n            // Show an error to the user\n            $this->logger->error('An error occurred during account deletion of user {username}: {error}', ['username' => $user->username, 'error' => \\get_class($e).': '.$e->getMessage()]);\n            $this->addFlash('error', 'flash_user_settings_general_error');\n        }\n\n        return $this->render(\n            'user/settings/account_deletion.html.twig',\n            ['user' => $user, 'form' => $form->createView()],\n            new Response(null, $form->isSubmitted() && !$form->isValid() ? 422 : 200)\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/User/FilterListsController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\User;\n\nuse App\\Controller\\AbstractController;\nuse App\\DTO\\UserFilterListDto;\nuse App\\Entity\\UserFilterList;\nuse App\\Form\\UserFilterListType;\nuse App\\Security\\Voter\\FilterListVoter;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass FilterListsController extends AbstractController\n{\n    public function __construct(\n        private readonly EntityManagerInterface $entityManager,\n    ) {\n    }\n\n    #[IsGranted('ROLE_USER')]\n    public function __invoke(): Response\n    {\n        return $this->render('user/settings/filter_lists.html.twig');\n    }\n\n    #[IsGranted('ROLE_USER')]\n    public function create(Request $request): Response\n    {\n        $dto = new UserFilterListDto();\n        $dto->addEmptyWords();\n        $form = $this->createForm(UserFilterListType::class, $dto);\n        $form->handleRequest($request);\n        if ($form->isSubmitted() && $form->isValid()) {\n            /** @var UserFilterListDto $data */\n            $data = $form->getData();\n            $list = $this->createFromDto($data);\n\n            $this->entityManager->persist($list);\n            $this->entityManager->flush();\n\n            return $this->redirectToRoute('user_settings_filter_lists');\n        }\n\n        return $this->render(\n            'user/settings/filter_lists_create.html.twig',\n            [\n                'form' => $form->createView(),\n            ],\n            new Response(null, $form->isSubmitted() && !$form->isValid() ? 422 : 200)\n        );\n    }\n\n    #[IsGranted('ROLE_USER')]\n    #[IsGranted(FilterListVoter::EDIT, 'list')]\n    public function edit(Request $request, #[MapEntity(id: 'id')] UserFilterList $list): Response\n    {\n        $dto = UserFilterListDto::fromList($list);\n        $dto->addEmptyWords();\n        $form = $this->createForm(UserFilterListType::class, $dto);\n        $form->handleRequest($request);\n        if ($form->isSubmitted() && $form->isValid()) {\n            /** @var UserFilterListDto $data */\n            $data = $form->getData();\n            $list->name = $data->name;\n            $list->expirationDate = $data->expirationDate;\n            $list->feeds = $data->feeds;\n            $list->comments = $data->comments;\n            $list->profile = $data->profile;\n            $list->words = $data->wordsToArray();\n            $this->entityManager->persist($list);\n            $this->entityManager->flush();\n\n            return $this->redirectToRoute('user_settings_filter_lists');\n        }\n\n        return $this->render(\n            'user/settings/filter_lists_edit.html.twig',\n            [\n                'form' => $form->createView(),\n            ],\n            new Response(null, $form->isSubmitted() && !$form->isValid() ? 422 : 200)\n        );\n    }\n\n    #[IsGranted('ROLE_USER')]\n    #[IsGranted(FilterListVoter::DELETE, 'list')]\n    public function delete(#[MapEntity(id: 'id')] UserFilterList $list): Response\n    {\n        $this->entityManager->remove($list);\n        $this->entityManager->flush();\n\n        return $this->redirectToRoute('user_settings_filter_lists');\n    }\n\n    private function createFromDto(UserFilterListDto $data): UserFilterList\n    {\n        $list = new UserFilterList();\n        $list->user = $this->getUserOrThrow();\n        $list->name = $data->name;\n        $list->expirationDate = $data->expirationDate;\n        $list->feeds = $data->feeds;\n        $list->comments = $data->comments;\n        $list->profile = $data->profile;\n        $list->words = $data->wordsToArray();\n\n        return $list;\n    }\n}\n"
  },
  {
    "path": "src/Controller/User/Profile/User2FAController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\User\\Profile;\n\nuse App\\Controller\\AbstractController;\nuse App\\DTO\\Temp2FADto;\nuse App\\DTO\\UserDto;\nuse App\\Entity\\User;\nuse App\\Form\\UserDisable2FAType;\nuse App\\Form\\UserRegenerate2FABackupType;\nuse App\\Form\\UserTwoFactorType;\nuse App\\Service\\TwoFactorManager;\nuse App\\Service\\UserManager;\nuse Endroid\\QrCode\\Builder\\Builder;\nuse Endroid\\QrCode\\Encoding\\Encoding;\nuse Endroid\\QrCode\\ErrorCorrectionLevel;\nuse Endroid\\QrCode\\RoundBlockSizeMode;\nuse Endroid\\QrCode\\Writer\\PngWriter;\nuse Psr\\Log\\LoggerInterface;\nuse Scheb\\TwoFactorBundle\\Security\\TwoFactor\\Provider\\Totp\\TotpAuthenticatorInterface;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Bundle\\SecurityBundle\\Security;\nuse Symfony\\Component\\Form\\FormError;\nuse Symfony\\Component\\Form\\FormInterface;\nuse Symfony\\Component\\HttpFoundation\\Exception\\SuspiciousOperationException;\nuse Symfony\\Component\\HttpFoundation\\File\\Exception\\AccessDeniedException;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\PasswordHasher\\Hasher\\UserPasswordHasherInterface;\nuse Symfony\\Component\\Security\\Core\\Exception\\AccessDeniedException as CoreAccessDeniedException;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\nuse Symfony\\Contracts\\Translation\\TranslatorInterface;\n\nclass User2FAController extends AbstractController\n{\n    public const TOTP_SESSION_KEY = 'totp_user_secret';\n    public const BACKUP_SESSION_KEY = 'totp_backup_codes';\n\n    public function __construct(\n        private readonly UserManager $manager,\n        private readonly TwoFactorManager $twoFactorManager,\n        private readonly TotpAuthenticatorInterface $totpAuthenticator,\n        private readonly TranslatorInterface $translator,\n        private readonly Security $security,\n        private readonly LoggerInterface $logger,\n        private readonly UserPasswordHasherInterface $userPasswordHasher,\n    ) {\n    }\n\n    #[IsGranted('ROLE_USER')]\n    public function enable(Request $request): Response\n    {\n        $user = $this->getUserOrThrow();\n        $this->denyAccessUnlessGranted('edit_profile', $user);\n\n        if ($user->isSsoControlled()) {\n            throw new CoreAccessDeniedException();\n        }\n\n        if ($user->isTotpAuthenticationEnabled()) {\n            throw new SuspiciousOperationException('User accessed 2fa enable path with existing 2fa in place');\n        }\n\n        $totpSecret = $request->getSession()->get(self::TOTP_SESSION_KEY, null);\n        if (null === $totpSecret || 'GET' === $request->getMethod()) {\n            $totpSecret = $this->totpAuthenticator->generateSecret();\n            $request->getSession()->set(self::TOTP_SESSION_KEY, $totpSecret);\n        }\n\n        $backupCodes = $request->getSession()->get(self::BACKUP_SESSION_KEY, null);\n        if (null === $backupCodes || 'GET' === $request->getMethod()) {\n            $backupCodes = $this->twoFactorManager->createBackupCodes($user);\n            $request->getSession()->set(self::BACKUP_SESSION_KEY, $backupCodes);\n        }\n\n        $dto = $this->manager->createDto($user);\n        $dto->totpSecret = $totpSecret;\n\n        $temp2fa = new Temp2FADto($user->username, $totpSecret);\n        $qrCodeContent = $this->totpAuthenticator->getQRContent($temp2fa);\n\n        $form = $this->handleForm($this->createForm(UserTwoFactorType::class, $dto), $dto, $request);\n        if (!$form instanceof FormInterface) {\n            return $form;\n        }\n\n        return $this->render(\n            'user/settings/2fa.html.twig',\n            [\n                'form' => $form->createView(),\n                'two_fa_url' => $qrCodeContent,\n                'codes' => $backupCodes,\n                'secret' => $totpSecret,\n            ],\n            new Response(\n                null,\n                $form->isSubmitted() && !$form->isValid() ? 422 : 200\n            )\n        );\n    }\n\n    #[IsGranted('ROLE_USER')]\n    public function disable(Request $request): Response\n    {\n        $user = $this->getUserOrThrow();\n        if (!$user->isTotpAuthenticationEnabled()) {\n            throw new SuspiciousOperationException('User accessed 2fa disable path without existing 2fa in place');\n        }\n\n        $dto = $this->manager->createDto($user);\n        $dto->totpSecret = $user->getTotpSecret();\n        $form = $this->createForm(UserDisable2FAType::class, $dto);\n        $form->handleRequest($request);\n        $this->handleCurrentPassword($form);\n        $this->handleTotpCode($form, $dto);\n\n        if ($form->isValid()) {\n            $this->twoFactorManager->remove2FA($user);\n        } else {\n            $errors = $form->getErrors(true);\n            foreach ($errors as $error) {\n                /** @var FormError $error */\n                $this->addFlash('error', $error->getMessage());\n            }\n        }\n\n        return $this->redirectToRefererOrHome($request);\n    }\n\n    #[IsGranted('ROLE_USER')]\n    public function qrCode(Request $request): Response\n    {\n        $user = $this->getUserOrThrow();\n        $this->denyAccessUnlessGranted('edit_profile', $user);\n\n        $totpSecret = $request->getSession()->get(self::TOTP_SESSION_KEY, null);\n        if (null === $totpSecret) {\n            throw new AccessDeniedException('/settings/2fa/qrcode');\n        }\n        $temp2fa = new Temp2FADto($user->username, $totpSecret);\n\n        $builder = new Builder(\n            writer: new PngWriter(),\n            writerOptions: [],\n            data: $this->totpAuthenticator->getQRContent($temp2fa),\n            encoding: new Encoding('UTF-8'),\n            errorCorrectionLevel: ErrorCorrectionLevel::High,\n            size: 250,\n            margin: 0,\n            roundBlockSizeMode: RoundBlockSizeMode::Margin,\n            logoPath: $this->getParameter('kernel.project_dir').'/public/logo.png',\n            logoResizeToWidth: 60,\n        );\n        $result = $builder->build();\n\n        return new Response($result->getString(), 200, ['Content-Type' => 'image/png']);\n    }\n\n    #[IsGranted('ROLE_ADMIN')]\n    public function remove(#[MapEntity(mapping: ['username' => 'username'])] User $user, Request $request): Response\n    {\n        $this->validateCsrf('user_2fa_remove', $request->getPayload()->get('token'));\n\n        $this->twoFactorManager->remove2FA($user);\n\n        if ($request->isXmlHttpRequest()) {\n            return new JsonResponse(\n                [\n                    'has2FA' => false,\n                ]\n            );\n        }\n\n        return $this->redirectToRefererOrHome($request);\n    }\n\n    #[IsGranted('ROLE_USER')]\n    public function backup(Request $request): Response\n    {\n        $user = $this->getUserOrThrow();\n        $this->denyAccessUnlessGranted('edit_profile', $user);\n\n        if (!$user->isTotpAuthenticationEnabled()) {\n            throw new SuspiciousOperationException('User accessed 2fa backup path without existing 2fa');\n        }\n\n        $dto = $this->manager->createDto($user);\n        $dto->totpSecret = $user->getTotpSecret();\n        $form = $this->createForm(UserRegenerate2FABackupType::class, $dto);\n        $form->handleRequest($request);\n        $this->handleCurrentPassword($form);\n        $this->handleTotpCode($form, $dto);\n\n        if (!$form->isValid()) {\n            $errors = $form->getErrors(true);\n            foreach ($errors as $error) {\n                /** @var FormError $error */\n                $this->addFlash('error', $error->getMessage());\n            }\n\n            return $this->redirectToRefererOrHome($request);\n        }\n\n        return $this->render(\n            'user/settings/2fa_backup.html.twig',\n            [\n                'codes' => $this->twoFactorManager->createBackupCodes($user),\n            ]\n        );\n    }\n\n    private function handleForm(\n        FormInterface $form,\n        UserDto $dto,\n        Request $request,\n    ): FormInterface|Response {\n        $form->handleRequest($request);\n\n        if (!$form->isSubmitted()) {\n            return $form;\n        }\n\n        $this->handleTotpCode($form, $dto);\n\n        if (!$form->isValid()) {\n            $this->logger->warning('2fa error occurred user \"{username}\" submitting the form \"{errors}\"', [\n                'username' => $dto->username,\n                'errors' => $form->getErrors(),\n            ]);\n            $form->get('totpCode')->addError(new FormError($this->translator->trans('2fa.setup_error')));\n\n            return $form;\n        }\n\n        $this->handleCurrentPassword($form);\n        if (!$form->isValid()) {\n            return $form;\n        }\n\n        $this->manager->edit($this->getUser(), $dto);\n\n        if (!$dto->totpSecret) {\n            return $this->redirectToRoute('user_settings_profile');\n        }\n\n        $this->security->logout(false);\n\n        $this->addFlash('success', 'flash_account_settings_changed');\n\n        return $this->redirectToRoute('app_login');\n    }\n\n    private function handleTotpCode(FormInterface $form, UserDto $dto): void\n    {\n        if ($form->has('totpCode')\n            && !$this->setupHasValidCode($dto->totpSecret, $form->get('totpCode')->getData())) {\n            $form->get('totpCode')->addError(new FormError($this->translator->trans('2fa.code_invalid')));\n        }\n    }\n\n    private function handleCurrentPassword(FormInterface $form): void\n    {\n        if ($form->has('currentPassword')) {\n            if (!$this->userPasswordHasher->isPasswordValid(\n                $this->getUser(),\n                $form->get('currentPassword')->getData()\n            )) {\n                $form->get('currentPassword')->addError(new FormError($this->translator->trans('Password is invalid')));\n            }\n        }\n    }\n\n    private function setupHasValidCode(string $totpSecret, string $submittedCode): bool\n    {\n        $user = $this->getUserOrThrow();\n        $temp = new Temp2FADto($user->username, $totpSecret);\n\n        $isValid = false;\n        if ($this->totpAuthenticator->checkCode($temp, $submittedCode)) {\n            $isValid = true;\n        }\n\n        return $isValid;\n    }\n}\n"
  },
  {
    "path": "src/Controller/User/Profile/UserBlockController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\User\\Profile;\n\nuse App\\Controller\\AbstractController;\nuse App\\Repository\\DomainRepository;\nuse App\\Repository\\MagazineRepository;\nuse App\\Repository\\UserRepository;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass UserBlockController extends AbstractController\n{\n    #[IsGranted('ROLE_USER')]\n    public function magazines(MagazineRepository $repository, Request $request): Response\n    {\n        $user = $this->getUserOrThrow();\n\n        return $this->render(\n            'user/settings/block_magazines.html.twig',\n            [\n                'user' => $user,\n                'magazines' => $repository->findBlockedMagazines($this->getPageNb($request), $user),\n            ]\n        );\n    }\n\n    #[IsGranted('ROLE_USER')]\n    public function users(UserRepository $repository, Request $request): Response\n    {\n        $user = $this->getUserOrThrow();\n\n        return $this->render(\n            'user/settings/block_users.html.twig',\n            [\n                'user' => $user,\n                'users' => $repository->findBlockedUsers($this->getPageNb($request), $user),\n            ]\n        );\n    }\n\n    #[IsGranted('ROLE_USER')]\n    public function domains(DomainRepository $repository, Request $request): Response\n    {\n        $user = $this->getUserOrThrow();\n\n        return $this->render(\n            'user/settings/block_domains.html.twig',\n            [\n                'user' => $user,\n                'domains' => $repository->findBlockedDomains($this->getPageNb($request), $user),\n            ]\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/User/Profile/UserEditController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\User\\Profile;\n\nuse App\\Controller\\AbstractController;\nuse App\\DTO\\UserDto;\nuse App\\Entity\\User;\nuse App\\Exception\\ImageDownloadTooLargeException;\nuse App\\Form\\UserBasicType;\nuse App\\Form\\UserDisable2FAType;\nuse App\\Form\\UserEmailType;\nuse App\\Form\\UserPasswordType;\nuse App\\Form\\UserRegenerate2FABackupType;\nuse App\\Service\\SettingsManager;\nuse App\\Service\\UserManager;\nuse Scheb\\TwoFactorBundle\\Security\\TwoFactor\\Provider\\Totp\\TotpAuthenticatorInterface;\nuse Symfony\\Bundle\\SecurityBundle\\Security;\nuse Symfony\\Component\\Form\\FormError;\nuse Symfony\\Component\\Form\\FormInterface;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\PasswordHasher\\Hasher\\UserPasswordHasherInterface;\nuse Symfony\\Component\\Security\\Core\\Exception\\AccessDeniedException;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\nuse Symfony\\Contracts\\Translation\\TranslatorInterface;\n\nclass UserEditController extends AbstractController\n{\n    public function __construct(\n        private readonly UserManager $manager,\n        private readonly UserPasswordHasherInterface $userPasswordHasher,\n        private readonly TranslatorInterface $translator,\n        private readonly Security $security,\n        private readonly SettingsManager $settingsManager,\n        private readonly TotpAuthenticatorInterface $totpAuthenticator,\n    ) {\n    }\n\n    #[IsGranted('ROLE_USER')]\n    public function profile(Request $request): Response\n    {\n        $user = $this->getUserOrThrow();\n        $this->denyAccessUnlessGranted('edit_profile', $user);\n\n        $dto = $this->manager->createDto($user);\n\n        $form = $this->createForm(UserBasicType::class, $dto);\n        $formHandler = $this->handleForm($form, $dto, $request, $user);\n        if (null === $formHandler) {\n            $this->addFlash('error', 'flash_user_edit_profile_error');\n        } else {\n            if (!$formHandler instanceof FormInterface) {\n                return $formHandler;\n            }\n        }\n\n        return $this->render(\n            'user/settings/profile.html.twig',\n            [\n                'user' => $user,\n                'form' => $form->createView(),\n            ],\n            new Response(\n                null,\n                $form->isSubmitted() && !$form->isValid() ? 422 : 200\n            )\n        );\n    }\n\n    #[IsGranted('ROLE_USER')]\n    public function email(Request $request): Response\n    {\n        $user = $this->getUserOrThrow();\n        $this->denyAccessUnlessGranted('edit_profile', $user);\n\n        $dto = $this->manager->createDto($user);\n\n        $form = $this->createForm(UserEmailType::class, $dto);\n        $formHandler = $this->handleForm($form, $dto, $request, $user);\n        if (null === $formHandler) {\n            $this->addFlash('error', 'flash_user_edit_email_error');\n        } else {\n            if (!$formHandler instanceof FormInterface) {\n                return $formHandler;\n            }\n        }\n\n        return $this->render(\n            'user/settings/email.html.twig',\n            [\n                'user' => $user,\n                'form' => $form->createView(),\n            ],\n            new Response(\n                null,\n                $form->isSubmitted() && !$form->isValid() ? 422 : 200\n            )\n        );\n    }\n\n    #[IsGranted('ROLE_USER')]\n    public function password(Request $request): Response\n    {\n        $user = $this->getUserOrThrow();\n        $this->denyAccessUnlessGranted('edit_profile', $user);\n\n        if ($user->isSsoControlled()) {\n            throw new AccessDeniedException();\n        }\n\n        $dto = $this->manager->createDto($user);\n\n        $form = $this->createForm(UserPasswordType::class, $dto);\n        $formHandler = $this->handleForm($form, $dto, $request, $user);\n        if (null === $formHandler) {\n            $this->addFlash('error', 'flash_user_edit_password_error');\n        } else {\n            if (!$formHandler instanceof FormInterface) {\n                return $formHandler;\n            }\n        }\n\n        $dto2 = $this->manager->createDto($user);\n        $disable2faForm = $this->createForm(UserDisable2FAType::class, $dto2);\n\n        $dto3 = $this->manager->createDto($user);\n        $regenerateBackupCodesForm = $this->createForm(UserRegenerate2FABackupType::class, $dto3);\n\n        return $this->render(\n            'user/settings/password.html.twig',\n            [\n                'user' => $user,\n                'form' => $form->createView(),\n                'disable2faForm' => $disable2faForm->createView(),\n                'regenerateBackupCodes' => $regenerateBackupCodesForm->createView(),\n                'has2fa' => $user->isTotpAuthenticationEnabled(),\n            ],\n            new Response(\n                null,\n                $form->isSubmitted() && !$form->isValid() ? 422 : 200\n            )\n        );\n    }\n\n    /**\n     * Handle form submit request.\n     */\n    private function handleForm(\n        FormInterface $form,\n        UserDto $dto,\n        Request $request,\n        User $user,\n    ): FormInterface|Response|null {\n        try {\n            // Could throw an error on event handlers (eg. onPostSubmit if a user upload an incorrect image)\n            $form->handleRequest($request);\n\n            if ($form->isSubmitted() && $form->has('currentPassword')) {\n                if (!$this->userPasswordHasher->isPasswordValid(\n                    $this->getUser(),\n                    $form->get('currentPassword')->getData()\n                )) {\n                    $form->get('currentPassword')->addError(new FormError($this->translator->trans('Password is invalid')));\n                }\n            }\n\n            if ($form->isSubmitted() && $form->has('totpCode') && $user->isTotpAuthenticationEnabled()) {\n                if (!$this->totpAuthenticator->checkCode(\n                    $this->getUser(),\n                    $form->get('totpCode')->getData()\n                )) {\n                    $form->get('totpCode')->addError(new FormError($this->translator->trans('2fa.code_invalid')));\n                }\n            }\n\n            if ($form->has('newEmail')) {\n                $dto->email = $form->get('newEmail')->getData();\n            }\n\n            if ($form->isSubmitted() && $form->isValid()) {\n                $email = $this->getUser()->email;\n                $this->manager->edit($this->getUser(), $dto);\n\n                // Check successful to use if profile was changed (which contains the about field)\n                if ($form->has('about')) {\n                    $this->addFlash('success', 'flash_user_edit_profile_success');\n                }\n\n                // Show successful message to user and tell them to re-login\n                // In case of an email change or password change\n                if ($dto->email !== $email || $dto->plainPassword) {\n                    $this->security->logout(false);\n\n                    $this->addFlash('success', 'flash_account_settings_changed');\n\n                    return $this->redirectToRoute('app_login');\n                }\n\n                return $this->redirectToRoute('user_settings_profile');\n            }\n\n            return $form;\n        } catch (ImageDownloadTooLargeException $e) {\n            $this->addFlash('error', $this->translator->trans('flash_image_download_too_large_error', ['%bytes%' => $this->settingsManager->getMaxImageByteString()]));\n\n            return null;\n        } catch (\\Exception $e) {\n            return null;\n        }\n    }\n}\n"
  },
  {
    "path": "src/Controller/User/Profile/UserNotificationController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\User\\Profile;\n\nuse App\\Controller\\AbstractController;\nuse App\\Repository\\NotificationRepository;\nuse App\\Repository\\SiteRepository;\nuse App\\Service\\NotificationManager;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass UserNotificationController extends AbstractController\n{\n    #[IsGranted('ROLE_USER')]\n    public function notifications(NotificationRepository $repository, Request $request, SiteRepository $siteRepository): Response\n    {\n        return $this->render(\n            'notifications/front.html.twig',\n            [\n                'applicationServerKey' => $siteRepository->findAll()[0]->pushPublicKey,\n                'notifications' => $repository->findByUser($this->getUserOrThrow(), $this->getPageNb($request)),\n            ]\n        );\n    }\n\n    #[IsGranted('ROLE_USER')]\n    public function read(NotificationManager $manager, Request $request): Response\n    {\n        $manager->markAllAsRead($this->getUserOrThrow());\n\n        return $this->redirectToRefererOrHome($request);\n    }\n\n    #[IsGranted('ROLE_USER')]\n    public function clear(NotificationManager $manager, Request $request): Response\n    {\n        $manager->clear($this->getUserOrThrow());\n\n        return $this->redirectToRefererOrHome($request);\n    }\n}\n"
  },
  {
    "path": "src/Controller/User/Profile/UserReportsController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\User\\Profile;\n\nuse App\\Controller\\AbstractController;\nuse App\\Repository\\MagazineRepository;\nuse App\\Repository\\NotificationRepository;\nuse App\\Repository\\ReportRepository;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass UserReportsController extends AbstractController\n{\n    public const MODERATED = 'moderated';\n\n    public function __construct(\n        private readonly ReportRepository $repository,\n        private readonly NotificationRepository $notificationRepository,\n    ) {\n    }\n\n    #[IsGranted('ROLE_USER')]\n    public function __invoke(MagazineRepository $repository, Request $request, string $status): Response\n    {\n        $user = $this->getUserOrThrow();\n        $reports = $this->repository->findByUserPaginated($user, $this->getPageNb($request), status: $status);\n        $this->notificationRepository->markOwnReportNotificationsAsRead($this->getUserOrThrow());\n\n        return $this->render(\n            'user/settings/reports.html.twig',\n            [\n                'user' => $user,\n                'reports' => $reports,\n            ]\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/User/Profile/UserReportsModController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\User\\Profile;\n\nuse App\\Controller\\AbstractController;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass UserReportsModController extends AbstractController\n{\n    #[IsGranted('ROLE_USER')]\n    public function __invoke(): Response\n    {\n        return new Response();\n    }\n}\n"
  },
  {
    "path": "src/Controller/User/Profile/UserSettingController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\User\\Profile;\n\nuse App\\Controller\\AbstractController;\nuse App\\Form\\UserSettingsType;\nuse App\\Service\\UserSettingsManager;\nuse Psr\\Log\\LoggerInterface;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass UserSettingController extends AbstractController\n{\n    public function __construct(\n        private readonly LoggerInterface $logger,\n    ) {\n    }\n\n    #[IsGranted('ROLE_USER')]\n    public function __invoke(UserSettingsManager $manager, Request $request): Response\n    {\n        $user = $this->getUserOrThrow();\n        $dto = $manager->createDto($user);\n\n        $form = $this->createForm(UserSettingsType::class, $dto);\n        try {\n            // Could thrown an error\n            $form->handleRequest($request);\n\n            if ($form->isSubmitted() && $form->isValid()) {\n                $manager->update($user, $dto);\n\n                $this->addFlash('success', 'flash_user_settings_general_success');\n                $this->redirectToRefererOrHome($request);\n            }\n        } catch (\\Exception $e) {\n            $this->logger->error('There was an error saving the user {u}\\'s settings: {e} - {m}', ['u' => $user->username, 'e' => \\get_class($e), 'm' => $e->getMessage()]);\n            // Show an error to the user\n            $this->addFlash('error', 'flash_user_settings_general_error');\n        }\n\n        return $this->render(\n            'user/settings/general.html.twig',\n            [\n                'user' => $user,\n                'form' => $form->createView(),\n            ],\n            new Response(null, $form->isSubmitted() && !$form->isValid() ? 422 : 200)\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/User/Profile/UserStatsController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\User\\Profile;\n\nuse App\\Controller\\AbstractController;\nuse App\\Repository\\StatsRepository;\nuse App\\Service\\StatsManager;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass UserStatsController extends AbstractController\n{\n    public function __construct(private readonly StatsManager $manager)\n    {\n    }\n\n    #[IsGranted('ROLE_USER')]\n    public function __invoke(?string $statsType, ?int $statsPeriod, ?bool $withFederated, Request $request): Response\n    {\n        $user = $this->getUserOrThrow();\n\n        $this->denyAccessUnlessGranted('edit_profile', $user);\n\n        $statsType = $this->manager->resolveType($statsType);\n\n        if (!$statsPeriod) {\n            $statsPeriod = 31;\n        }\n\n        if (-1 === $statsPeriod) {\n            $statsPeriod = null;\n        }\n\n        if ($statsPeriod) {\n            $statsPeriod = min($statsPeriod, 256);\n            $start = (new \\DateTime())->modify(\"-$statsPeriod days\");\n        }\n\n        if (null === $withFederated) {\n            $withFederated = false;\n        }\n\n        $results = match ($statsType) {\n            StatsRepository::TYPE_VOTES => $statsPeriod\n                ? $this->manager->drawDailyVotesStatsByTime($start, $user, null, !$withFederated)\n                : $this->manager->drawMonthlyVotesChart($user, null, !$withFederated),\n            default => $statsPeriod\n                ? $this->manager->drawDailyContentStatsByTime($start, $user, null, !$withFederated)\n                : $this->manager->drawMonthlyContentChart($user, null, !$withFederated),\n        };\n\n        return $this->render(\n            'user/settings/stats.html.twig', [\n                'user' => $user,\n                'period' => $statsPeriod,\n                'chart' => $results,\n                'withFederated' => $withFederated,\n            ]\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/User/Profile/UserSubController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\User\\Profile;\n\nuse App\\Controller\\AbstractController;\nuse App\\Repository\\DomainRepository;\nuse App\\Repository\\MagazineRepository;\nuse App\\Repository\\UserRepository;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass UserSubController extends AbstractController\n{\n    #[IsGranted('ROLE_USER')]\n    public function magazines(MagazineRepository $repository, Request $request): Response\n    {\n        $user = $this->getUserOrThrow();\n\n        return $this->render(\n            'user/settings/sub_magazines.html.twig',\n            [\n                'user' => $user,\n                'magazines' => $repository->findSubscribedMagazines(\n                    $this->getPageNb($request),\n                    $user\n                ),\n            ]\n        );\n    }\n\n    #[IsGranted('ROLE_USER')]\n    public function users(UserRepository $repository, Request $request): Response\n    {\n        $user = $this->getUserOrThrow();\n\n        return $this->render(\n            'user/settings/sub_users.html.twig',\n            [\n                'user' => $user,\n                'users' => $repository->findFollowing($this->getPageNb($request), $user),\n            ]\n        );\n    }\n\n    #[IsGranted('ROLE_USER')]\n    public function domains(DomainRepository $repository, Request $request): Response\n    {\n        $user = $this->getUserOrThrow();\n\n        return $this->render(\n            'user/settings/sub_domains.html.twig',\n            [\n                'user' => $user,\n                'domains' => $repository->findSubscribedDomains($this->getPageNb($request), $user),\n            ]\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/User/Profile/UserVerifyController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\User\\Profile;\n\nuse App\\Controller\\AbstractController;\nuse App\\Entity\\User;\nuse App\\Service\\UserManager;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass UserVerifyController extends AbstractController\n{\n    public function __construct(\n        private readonly UserManager $manager,\n    ) {\n    }\n\n    #[IsGranted('ROLE_ADMIN')]\n    public function __invoke(#[MapEntity(mapping: ['username' => 'username'])] User $user, Request $request): Response\n    {\n        $this->validateCsrf('user_verify', $request->getPayload()->get('token'));\n\n        $this->manager->adminUserVerify($user);\n\n        if ($request->isXmlHttpRequest()) {\n            return new JsonResponse(\n                [\n                    'isVerified' => true,\n                ]\n            );\n        }\n\n        return $this->redirectToRefererOrHome($request);\n    }\n}\n"
  },
  {
    "path": "src/Controller/User/ThemeSettingsController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\User;\n\nuse App\\Controller\\AbstractController;\nuse Symfony\\Component\\HttpFoundation\\Cookie;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\n\nclass ThemeSettingsController extends AbstractController\n{\n    public const MBIN_LANG = 'mbin_lang';\n    public const ENTRIES_VIEW = 'entries_view';\n    public const ENTRY_COMMENTS_VIEW = 'entry_comments_view';\n    public const POST_COMMENTS_VIEW = 'post_comments_view';\n    public const KBIN_THEME = 'kbin_theme';\n    public const KBIN_FONT_SIZE = 'kbin_font_size';\n    public const KBIN_PAGE_WIDTH = 'kbin_page_width';\n    public const MBIN_SHOW_USER_DOMAIN = 'mbin_show_users_domain';\n    public const MBIN_SHOW_MAGAZINE_DOMAIN = 'mbin_show_magazine_domain';\n    public const KBIN_ENTRIES_SHOW_USERS_AVATARS = 'kbin_entries_show_users_avatars';\n    public const KBIN_ENTRIES_SHOW_MAGAZINES_ICONS = 'kbin_entries_show_magazines_icons';\n    public const KBIN_ENTRIES_SHOW_THUMBNAILS = 'kbin_entries_show_thumbnails';\n    public const KBIN_ENTRIES_SHOW_PREVIEW = 'kbin_entries_show_preview';\n    public const KBIN_ENTRIES_COMPACT = 'kbin_entries_compact';\n    public const MBIN_ENTRIES_SHOW_RICH_MENTION = 'mbin_entries_show_rich_mention';\n    public const MBIN_ENTRIES_SHOW_RICH_MENTION_MAGAZINE = 'mbin_entries_show_rich_mention_magazine';\n    public const MBIN_ENTRIES_SHOW_RICH_AP_LINK = 'mbin_entries_show_rich_ap_link';\n    public const KBIN_POSTS_SHOW_PREVIEW = 'kbin_posts_show_preview';\n    public const KBIN_POSTS_SHOW_USERS_AVATARS = 'kbin_posts_show_users_avatars';\n    public const MBIN_POSTS_SHOW_RICH_MENTION = 'mbin_posts_show_rich_mention';\n    public const MBIN_POSTS_SHOW_RICH_MENTION_MAGAZINE = 'mbin_posts_show_rich_mention_magazine';\n    public const MBIN_POSTS_SHOW_RICH_AP_LINK = 'mbin_posts_show_rich_ap_link';\n    public const KBIN_GENERAL_ROUNDED_EDGES = 'kbin_general_rounded_edges';\n    public const KBIN_GENERAL_INFINITE_SCROLL = 'kbin_general_infinite_scroll';\n    public const KBIN_GENERAL_TOPBAR = 'kbin_general_topbar';\n    public const KBIN_GENERAL_FIXED_NAVBAR = 'kbin_general_fixed_navbar';\n    public const KBIN_GENERAL_SIDEBAR_POSITION = 'kbin_general_sidebar_position';\n    public const KBIN_GENERAL_DYNAMIC_LISTS = 'kbin_general_dynamic_lists';\n    public const KBIN_GENERAL_FILTER_LABELS = 'kbin_general_filter_labels';\n    public const MBIN_GENERAL_SHOW_RELATED_POSTS = 'mbin_general_show_related_posts';\n    public const MBIN_GENERAL_SHOW_RELATED_ENTRIES = 'mbin_general_show_related_entries';\n    public const MBIN_GENERAL_SHOW_RELATED_MAGAZINES = 'mbin_general_show_related_magazines';\n    public const MBIN_GENERAL_SHOW_ACTIVE_USERS = 'mbin_general_show_active_users';\n    public const KBIN_COMMENTS_SHOW_USER_AVATAR = 'kbin_comments_show_user_avatar';\n    public const KBIN_COMMENTS_REPLY_POSITION = 'kbin_comments_reply_position';\n    public const KBIN_SUBSCRIPTIONS_SHOW = 'kbin_subscriptions_show';\n    public const KBIN_SUBSCRIPTIONS_SORT = 'kbin_subscriptions_sort';\n    public const KBIN_SUBSCRIPTIONS_IN_SEPARATE_SIDEBAR = 'kbin_subscriptions_in_separate_sidebar';\n    public const KBIN_SUBSCRIPTIONS_SIDEBARS_SAME_SIDE = 'kbin_subscriptions_sidebars_same_side';\n    public const KBIN_SUBSCRIPTIONS_LARGE_PANEL = 'kbin_subscriptions_large_panel';\n    public const KBIN_SUBSCRIPTIONS_SHOW_MAGAZINE_ICON = 'kbin_subscriptions_show_magazine_icon';\n    public const MBIN_MODERATION_LOG_SHOW_USER_AVATARS = 'mbin_moderation_log_show_user_avatars';\n    public const MBIN_MODERATION_LOG_SHOW_MAGAZINE_ICONS = 'mbin_moderation_log_show_magazine_icons';\n    public const MBIN_MODERATION_LOG_SHOW_NEW_ICONS = 'mbin_moderation_log_show_new_icons';\n    public const MBIN_LIST_IMAGE_LIGHTBOX = 'mbin_list_image_lightbox';\n\n    public const CLASSIC = 'classic';\n    public const CHAT = 'chat';\n    public const TREE = 'tree';\n    public const COMPACT = 'compact';\n    public const LIGHT = 'light';\n    public const DARK = 'dark';\n    public const KBIN = 'kbin';\n    public const SOLARIZED_LIGHT = 'solarized-light';\n    public const SOLARIZED_DARK = 'solarized-dark';\n    public const TOKYO_NIGHT = 'tokyo-night';\n    public const TRUE = 'true';\n    public const FALSE = 'false';\n    public const LEFT = 'left';\n    public const RIGHT = 'right';\n    public const TOP = 'top';\n    public const BOTTOM = 'bottom';\n    public const ALPHABETICALLY = 'alphabetically';\n    public const LAST_ACTIVE = 'last_active';\n    public const MAX = 'max';\n    public const AUTO = 'auto';\n    public const FIXED = 'fixed';\n    public const ON = 'on';\n    public const OFF = 'off';\n\n    public const KEYS = [\n        self::ENTRIES_VIEW,\n        self::ENTRY_COMMENTS_VIEW,\n        self::POST_COMMENTS_VIEW,\n        self::KBIN_THEME,\n        self::KBIN_FONT_SIZE,\n        self::KBIN_PAGE_WIDTH,\n        self::KBIN_ENTRIES_SHOW_USERS_AVATARS,\n        self::KBIN_ENTRIES_SHOW_MAGAZINES_ICONS,\n        self::KBIN_ENTRIES_SHOW_THUMBNAILS,\n        self::KBIN_ENTRIES_COMPACT,\n        self::KBIN_GENERAL_ROUNDED_EDGES,\n        self::KBIN_GENERAL_INFINITE_SCROLL,\n        self::KBIN_GENERAL_TOPBAR,\n        self::KBIN_GENERAL_FIXED_NAVBAR,\n        self::KBIN_GENERAL_SIDEBAR_POSITION,\n        self::KBIN_GENERAL_FILTER_LABELS,\n        self::KBIN_ENTRIES_SHOW_PREVIEW,\n        self::KBIN_POSTS_SHOW_PREVIEW,\n        self::KBIN_POSTS_SHOW_USERS_AVATARS,\n        self::KBIN_GENERAL_DYNAMIC_LISTS,\n        self::MBIN_LANG,\n        self::KBIN_COMMENTS_SHOW_USER_AVATAR,\n        self::KBIN_COMMENTS_REPLY_POSITION,\n        self::KBIN_SUBSCRIPTIONS_SHOW,\n        self::KBIN_SUBSCRIPTIONS_SORT,\n        self::KBIN_SUBSCRIPTIONS_IN_SEPARATE_SIDEBAR,\n        self::KBIN_SUBSCRIPTIONS_SIDEBARS_SAME_SIDE,\n        self::KBIN_SUBSCRIPTIONS_LARGE_PANEL,\n        self::KBIN_SUBSCRIPTIONS_SHOW_MAGAZINE_ICON,\n        self::MBIN_MODERATION_LOG_SHOW_USER_AVATARS,\n        self::MBIN_MODERATION_LOG_SHOW_MAGAZINE_ICONS,\n        self::MBIN_MODERATION_LOG_SHOW_NEW_ICONS,\n        self::MBIN_GENERAL_SHOW_RELATED_POSTS,\n        self::MBIN_GENERAL_SHOW_RELATED_ENTRIES,\n        self::MBIN_GENERAL_SHOW_RELATED_MAGAZINES,\n        self::MBIN_GENERAL_SHOW_ACTIVE_USERS,\n        self::MBIN_SHOW_MAGAZINE_DOMAIN,\n        self::MBIN_SHOW_USER_DOMAIN,\n        self::MBIN_LIST_IMAGE_LIGHTBOX,\n        self::MBIN_ENTRIES_SHOW_RICH_MENTION,\n        self::MBIN_ENTRIES_SHOW_RICH_MENTION_MAGAZINE,\n        self::MBIN_ENTRIES_SHOW_RICH_AP_LINK,\n        self::MBIN_POSTS_SHOW_RICH_MENTION,\n        self::MBIN_POSTS_SHOW_RICH_MENTION_MAGAZINE,\n        self::MBIN_POSTS_SHOW_RICH_AP_LINK,\n    ];\n\n    public const VALUES = [\n        self::CLASSIC,\n        self::CHAT,\n        self::TREE,\n        self::COMPACT,\n        self::LIGHT,\n        self::DARK,\n        self::KBIN,\n        self::SOLARIZED_LIGHT,\n        self::SOLARIZED_DARK,\n        self::TOKYO_NIGHT,\n        self::TRUE,\n        self::FALSE,\n        self::LEFT,\n        self::RIGHT,\n        self::TOP,\n        self::BOTTOM,\n        self::ALPHABETICALLY,\n        self::LAST_ACTIVE,\n        self::ON,\n        self::OFF,\n        '80',\n        '90',\n        '100',\n        '120',\n        '150',\n        self::MAX,\n        self::AUTO,\n        self::FIXED,\n    ];\n\n    public function __invoke(string $key, string $value, Request $request): Response\n    {\n        $response = new Response();\n\n        if (\\in_array($key, self::KEYS) && \\in_array($value, self::VALUES)) {\n            $response->headers->setCookie(new Cookie($key, $value, strtotime('+1 year')));\n        }\n\n        if (self::MBIN_LANG === $key) {\n            $response->headers->setCookie(new Cookie(self::MBIN_LANG, $value, strtotime('+1 year')));\n        }\n\n        if ($request->isXmlHttpRequest()) {\n            return new JsonResponse(['success' => true]);\n        }\n\n        return new \\Symfony\\Component\\HttpFoundation\\RedirectResponse(\n            ($request->headers->get('referer') ?? '/').'#settings',\n            302,\n            $response->headers->all()\n        );\n    }\n\n    public static function getShowUserFullName(?Request $request): bool\n    {\n        if (null === $request) {\n            return false;\n        }\n\n        return self::TRUE === $request->cookies->get(self::MBIN_SHOW_USER_DOMAIN, 'false');\n    }\n\n    public static function getShowMagazineFullName(?Request $request): bool\n    {\n        if (null === $request) {\n            return false;\n        }\n\n        return self::TRUE === $request->cookies->get(self::MBIN_SHOW_MAGAZINE_DOMAIN, 'false');\n    }\n\n    public static function getShowRichMentionEntry(?Request $request): bool\n    {\n        if (null === $request) {\n            return true;\n        }\n\n        return self::TRUE === $request->cookies->get(self::MBIN_ENTRIES_SHOW_RICH_MENTION, self::TRUE);\n    }\n\n    public static function getShowRichMentionPosts(?Request $request): bool\n    {\n        if (null === $request) {\n            return false;\n        }\n\n        return self::TRUE === $request->cookies->get(self::MBIN_POSTS_SHOW_RICH_MENTION, self::FALSE);\n    }\n\n    public static function getShowRichMagazineMentionEntry(?Request $request): bool\n    {\n        if (null === $request) {\n            return true;\n        }\n\n        return self::TRUE === $request->cookies->get(self::MBIN_ENTRIES_SHOW_RICH_MENTION_MAGAZINE, self::TRUE);\n    }\n\n    public static function getShowRichMagazineMentionPosts(?Request $request): bool\n    {\n        if (null === $request) {\n            return true;\n        }\n\n        return self::TRUE === $request->cookies->get(self::MBIN_POSTS_SHOW_RICH_MENTION_MAGAZINE, self::TRUE);\n    }\n\n    public static function getShowRichAPLinkEntries(?Request $request): bool\n    {\n        if (null === $request) {\n            return true;\n        }\n\n        return self::TRUE === $request->cookies->get(self::MBIN_ENTRIES_SHOW_RICH_AP_LINK, self::TRUE);\n    }\n\n    public static function getShowRichAPLinkPosts(?Request $request): bool\n    {\n        if (null === $request) {\n            return true;\n        }\n\n        return self::TRUE === $request->cookies->get(self::MBIN_POSTS_SHOW_RICH_AP_LINK, self::TRUE);\n    }\n}\n"
  },
  {
    "path": "src/Controller/User/UserAvatarDeleteController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\User;\n\nuse App\\Controller\\AbstractController;\nuse App\\Service\\UserManager;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass UserAvatarDeleteController extends AbstractController\n{\n    public function __construct(private readonly UserManager $userManager)\n    {\n    }\n\n    #[IsGranted('ROLE_USER')]\n    public function __invoke(Request $request): Response\n    {\n        $this->denyAccessUnlessGranted('edit_profile', $this->getUserOrThrow());\n\n        $user = $this->getUserOrThrow();\n        $this->userManager->detachAvatar($user);\n        /*\n         * Call edit so the @see UserEditedEvent is triggered and the changes are federated\n         */\n        $this->userManager->edit($user, $this->userManager->createDto($user));\n\n        if ($request->isXmlHttpRequest()) {\n            return new JsonResponse(\n                [\n                    'success' => true,\n                ]\n            );\n        }\n\n        return $this->redirectToRefererOrHome($request);\n    }\n}\n"
  },
  {
    "path": "src/Controller/User/UserBanController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\User;\n\nuse App\\Controller\\AbstractController;\nuse App\\Entity\\User;\nuse App\\Service\\UserManager;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\ExpressionLanguage\\Expression;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass UserBanController extends AbstractController\n{\n    #[IsGranted(new Expression('is_granted(\"ROLE_ADMIN\") or is_granted(\"ROLE_MODERATOR\")'))]\n    public function ban(\n        #[MapEntity(mapping: ['username' => 'username'])] User $user,\n        UserManager $manager,\n        Request $request,\n    ): Response {\n        $this->validateCsrf('user_ban', $request->getPayload()->get('token'));\n\n        $manager->ban($user, $this->getUserOrThrow(), reason: null);\n\n        if ($request->isXmlHttpRequest()) {\n            return new JsonResponse(\n                [\n                    'isBanned' => true,\n                ]\n            );\n        }\n\n        $this->addFlash('success', 'account_banned');\n\n        return $this->redirectToRefererOrHome($request);\n    }\n\n    #[IsGranted(new Expression('is_granted(\"ROLE_ADMIN\") or is_granted(\"ROLE_MODERATOR\")'))]\n    public function unban(\n        #[MapEntity(mapping: ['username' => 'username'])]\n        User $user,\n        UserManager $manager,\n        Request $request,\n    ): Response {\n        $this->validateCsrf('user_ban', $request->getPayload()->get('token'));\n\n        $manager->unban($user, $this->getUserOrThrow(), reason: null);\n\n        if ($request->isXmlHttpRequest()) {\n            return new JsonResponse(\n                [\n                    'isBanned' => false,\n                ]\n            );\n        }\n\n        $this->addFlash('success', 'account_unbanned');\n\n        return $this->redirectToRefererOrHome($request);\n    }\n}\n"
  },
  {
    "path": "src/Controller/User/UserBlockController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\User;\n\nuse App\\Controller\\AbstractController;\nuse App\\Entity\\User;\nuse App\\Service\\UserManager;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass UserBlockController extends AbstractController\n{\n    #[IsGranted('ROLE_USER')]\n    public function block(\n        #[MapEntity(mapping: ['username' => 'username'])]\n        User $blocked,\n        UserManager $manager,\n        Request $request,\n    ): Response {\n        $manager->block($this->getUserOrThrow(), $blocked);\n\n        if ($request->isXmlHttpRequest()) {\n            return $this->getJsonResponse($blocked);\n        }\n\n        return $this->redirectToRefererOrHome($request);\n    }\n\n    #[IsGranted('ROLE_USER')]\n    public function unblock(\n        #[MapEntity(mapping: ['username' => 'username'])]\n        User $blocked,\n        UserManager $manager,\n        Request $request,\n    ): Response {\n        $manager->unblock($this->getUserOrThrow(), $blocked);\n\n        if ($request->isXmlHttpRequest()) {\n            return $this->getJsonResponse($blocked);\n        }\n\n        return $this->redirectToRefererOrHome($request);\n    }\n\n    private function getJsonResponse(User $user): JsonResponse\n    {\n        return new JsonResponse(\n            [\n                'html' => $this->renderView(\n                    'components/_ajax.html.twig',\n                    [\n                        'component' => 'user_actions',\n                        'attributes' => [\n                            'user' => $user,\n                        ],\n                    ]\n                ),\n            ]\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/User/UserCoverDeleteController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\User;\n\nuse App\\Controller\\AbstractController;\nuse App\\Service\\UserManager;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass UserCoverDeleteController extends AbstractController\n{\n    public function __construct(private readonly UserManager $userManager)\n    {\n    }\n\n    #[IsGranted('ROLE_USER')]\n    public function __invoke(Request $request): Response\n    {\n        $this->denyAccessUnlessGranted('edit_profile', $this->getUserOrThrow());\n\n        $user = $this->getUserOrThrow();\n        $this->userManager->detachCover($user);\n        /*\n         * Call edit so the @see UserEditedEvent is triggered and the changes are federated\n         */\n        $this->userManager->edit($user, $this->userManager->createDto($user));\n\n        if ($request->isXmlHttpRequest()) {\n            return new JsonResponse(\n                [\n                    'success' => true,\n                ]\n            );\n        }\n\n        return $this->redirectToRefererOrHome($request);\n    }\n}\n"
  },
  {
    "path": "src/Controller/User/UserDeleteController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\User;\n\nuse App\\Controller\\AbstractController;\nuse App\\Entity\\User;\nuse App\\Service\\UserManager;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass UserDeleteController extends AbstractController\n{\n    #[IsGranted('ROLE_ADMIN')]\n    public function deleteAccount(\n        #[MapEntity(mapping: ['username' => 'username'])]\n        User $user,\n        UserManager $manager,\n        Request $request,\n    ): Response {\n        $this->validateCsrf('user_delete_account', $request->getPayload()->get('token'));\n\n        $manager->delete($user);\n\n        return $this->redirectToRoute('front');\n    }\n\n    #[IsGranted('ROLE_ADMIN')]\n    public function scheduleDeleteAccount(\n        #[MapEntity(mapping: ['username' => 'username'])]\n        User $user,\n        UserManager $manager,\n        Request $request,\n    ): Response {\n        $this->validateCsrf('schedule_user_delete_account', $request->getPayload()->get('token'));\n\n        $manager->deleteRequest($user, false);\n\n        return $this->redirectToRoute('user_overview', ['username' => $user->username]);\n    }\n\n    #[IsGranted('ROLE_ADMIN')]\n    public function removeScheduleDeleteAccount(\n        #[MapEntity(mapping: ['username' => 'username'])]\n        User $user,\n        UserManager $manager,\n        Request $request,\n    ): Response {\n        $this->validateCsrf('remove_schedule_user_delete_account', $request->getPayload()->get('token'));\n\n        $manager->removeDeleteRequest($user);\n\n        return $this->redirectToRoute('user_overview', ['username' => $user->username]);\n    }\n}\n"
  },
  {
    "path": "src/Controller/User/UserFollowController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\User;\n\nuse App\\Controller\\AbstractController;\nuse App\\Entity\\User;\nuse App\\Service\\UserManager;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass UserFollowController extends AbstractController\n{\n    #[IsGranted('ROLE_USER')]\n    #[IsGranted('follow', subject: 'following')]\n    public function follow(\n        #[MapEntity(mapping: ['username' => 'username'])]\n        User $following,\n        UserManager $manager,\n        Request $request,\n    ): Response {\n        $manager->follow($this->getUserOrThrow(), $following);\n\n        if ($request->isXmlHttpRequest()) {\n            return $this->getJsonResponse($following);\n        }\n\n        return $this->redirectToRefererOrHome($request);\n    }\n\n    #[IsGranted('ROLE_USER')]\n    #[IsGranted('follow', subject: 'following')]\n    public function unfollow(\n        #[MapEntity(mapping: ['username' => 'username'])]\n        User $following,\n        UserManager $manager,\n        Request $request,\n    ): Response {\n        $manager->unfollow($this->getUserOrThrow(), $following);\n\n        if ($request->isXmlHttpRequest()) {\n            return $this->getJsonResponse($following);\n        }\n\n        return $this->redirectToRefererOrHome($request);\n    }\n\n    private function getJsonResponse(User $user): JsonResponse\n    {\n        return new JsonResponse(\n            [\n                'html' => $this->renderView(\n                    'components/_ajax.html.twig',\n                    [\n                        'component' => 'user_actions',\n                        'attributes' => [\n                            'user' => $user,\n                        ],\n                    ]\n                ),\n            ]\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/User/UserFrontController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\User;\n\nuse App\\Controller\\AbstractController;\nuse App\\Entity\\User;\nuse App\\Enums\\EApplicationStatus;\nuse App\\PageView\\EntryCommentPageView;\nuse App\\PageView\\EntryPageView;\nuse App\\PageView\\MagazinePageView;\nuse App\\PageView\\PostCommentPageView;\nuse App\\PageView\\PostPageView;\nuse App\\Repository\\Criteria;\nuse App\\Repository\\EntryCommentRepository;\nuse App\\Repository\\EntryRepository;\nuse App\\Repository\\MagazineRepository;\nuse App\\Repository\\NotificationRepository;\nuse App\\Repository\\PostCommentRepository;\nuse App\\Repository\\PostRepository;\nuse App\\Repository\\SearchRepository;\nuse App\\Repository\\UserRepository;\nuse App\\Service\\SubjectOverviewManager;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Bundle\\SecurityBundle\\Security;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\Security\\Core\\Exception\\AccessDeniedException;\n\nclass UserFrontController extends AbstractController\n{\n    public function __construct(\n        private readonly SubjectOverviewManager $overviewManager,\n        private readonly NotificationRepository $notificationRepository,\n        private readonly Security $security,\n    ) {\n    }\n\n    public function front(\n        #[MapEntity(mapping: ['username' => 'username'])]\n        User $user,\n        Request $request,\n        SearchRepository $repository,\n    ): Response {\n        $response = new Response();\n        if ($user->apId) {\n            $response->headers->set('X-Robots-Tag', 'noindex, nofollow');\n        }\n\n        $requestedByUser = $this->getUser();\n        $hideAdult = (!$requestedByUser || $requestedByUser->hideAdult);\n\n        if (EApplicationStatus::Approved !== $user->getApplicationStatus()) {\n            throw $this->createNotFoundException();\n        }\n\n        if ($user->isDeleted && (!$requestedByUser || (!$requestedByUser->isAdmin() && !$requestedByUser->isModerator()) || null === $user->markedForDeletionAt)) {\n            throw $this->createNotFoundException();\n        }\n\n        if ($loggedInUser = $this->getUser()) {\n            $this->notificationRepository->markUserSignupNotificationsAsRead($loggedInUser, $user);\n        }\n\n        $activity = $repository->findUserPublicActivity($this->getPageNb($request), $user, $hideAdult);\n        $results = $this->overviewManager->buildList($activity);\n\n        if ($request->isXmlHttpRequest()) {\n            return new JsonResponse([\n                'html' => $this->renderView(\n                    'layout/_generic_subject_list.html.twig',\n                    [\n                        'results' => $results,\n                        'pagination' => $activity,\n                    ]\n                ),\n            ]);\n        }\n\n        return $this->render(\n            'user/overview.html.twig',\n            [\n                'user' => $user,\n                'results' => $results,\n                'pagination' => $activity,\n            ],\n            $response\n        );\n    }\n\n    public function entries(\n        #[MapEntity(mapping: ['username' => 'username'])]\n        User $user,\n        Request $request,\n        EntryRepository $repository,\n    ): Response {\n        $response = new Response();\n        if ($user->apId) {\n            $response->headers->set('X-Robots-Tag', 'noindex, nofollow');\n        }\n        $requestedByUser = $this->getUser();\n        if ($user->isDeleted && (!$requestedByUser || (!$requestedByUser->isAdmin() && !$requestedByUser->isModerator()) || null === $user->markedForDeletionAt)) {\n            throw $this->createNotFoundException();\n        }\n\n        $criteria = new EntryPageView($this->getPageNb($request), $this->security);\n        $criteria->sortOption = Criteria::SORT_NEW;\n        $criteria->user = $user;\n        $entries = $repository->findByCriteria($criteria);\n\n        if ($request->isXmlHttpRequest()) {\n            return new JsonResponse([\n                'html' => $this->renderView(\n                    'entry/_list.html.twig',\n                    [\n                        'entries' => $entries,\n                    ]\n                ),\n            ]);\n        }\n\n        return $this->render(\n            'user/entries.html.twig',\n            [\n                'user' => $user,\n                'entries' => $entries,\n            ],\n            $response\n        );\n    }\n\n    public function comments(\n        #[MapEntity(mapping: ['username' => 'username'])]\n        User $user,\n        Request $request,\n        EntryCommentRepository $repository,\n    ): Response {\n        $response = new Response();\n        if ($user->apId) {\n            $response->headers->set('X-Robots-Tag', 'noindex, nofollow');\n        }\n\n        $requestedByUser = $this->getUser();\n        if ($user->isDeleted && (!$requestedByUser || (!$requestedByUser->isAdmin() && !$requestedByUser->isModerator()) || null === $user->markedForDeletionAt)) {\n            throw $this->createNotFoundException();\n        }\n\n        $criteria = new EntryCommentPageView($this->getPageNb($request), $this->security);\n        $criteria->sortOption = Criteria::SORT_NEW;\n        $criteria->user = $user;\n        $criteria->onlyParents = false;\n\n        $comments = $repository->findByCriteria($criteria);\n\n        if ($request->isXmlHttpRequest()) {\n            return new JsonResponse([\n                'html' => $this->renderView(\n                    'entry/comment/_list.html.twig',\n                    [\n                        'comments' => $comments,\n                        'criteria' => $criteria,\n                        'showNested' => false,\n                    ]\n                ),\n            ]);\n        }\n\n        return $this->render(\n            'user/comments.html.twig',\n            [\n                'user' => $user,\n                'comments' => $comments,\n                'criteria' => $criteria,\n            ],\n            $response\n        );\n    }\n\n    public function posts(\n        #[MapEntity(mapping: ['username' => 'username'])]\n        User $user,\n        Request $request,\n        PostRepository $repository,\n    ): Response {\n        $response = new Response();\n        if ($user->apId) {\n            $response->headers->set('X-Robots-Tag', 'noindex, nofollow');\n        }\n\n        $requestedByUser = $this->getUser();\n        if ($user->isDeleted && (!$requestedByUser || (!$requestedByUser->isAdmin() && !$requestedByUser->isModerator()) || null === $user->markedForDeletionAt)) {\n            throw $this->createNotFoundException();\n        }\n        $criteria = new PostPageView($this->getPageNb($request), $this->security);\n        $criteria->sortOption = Criteria::SORT_NEW;\n        $criteria->user = $user;\n\n        $posts = $repository->findByCriteria($criteria);\n\n        if ($request->isXmlHttpRequest()) {\n            return new JsonResponse([\n                'html' => $this->renderView(\n                    'post/_list.html.twig',\n                    [\n                        'posts' => $posts,\n                    ]\n                ),\n            ]);\n        }\n\n        return $this->render(\n            'user/posts.html.twig',\n            [\n                'user' => $user,\n                'posts' => $posts,\n            ],\n            $response\n        );\n    }\n\n    public function replies(\n        #[MapEntity(mapping: ['username' => 'username'])]\n        User $user,\n        Request $request,\n        PostCommentRepository $repository,\n    ): Response {\n        $response = new Response();\n        if ($user->apId) {\n            $response->headers->set('X-Robots-Tag', 'noindex, nofollow');\n        }\n\n        $requestedByUser = $this->getUser();\n        if ($user->isDeleted && (!$requestedByUser || (!$requestedByUser->isAdmin() && !$requestedByUser->isModerator()) || null === $user->markedForDeletionAt)) {\n            throw $this->createNotFoundException();\n        }\n\n        $criteria = new PostCommentPageView($this->getPageNb($request), $this->security);\n        $criteria->sortOption = Criteria::SORT_NEW;\n        $criteria->onlyParents = false;\n        $criteria->user = $user;\n\n        $comments = $repository->findByCriteria($criteria);\n\n        if ($request->isXmlHttpRequest()) {\n            return new JsonResponse([\n                'html' => $this->renderView(\n                    'layout/_subject_list.html.twig',\n                    [\n                        'results' => $comments,\n                        'criteria' => $criteria,\n                        'postCommentAttributes' => [\n                            'showNested' => false,\n                            'withPost' => true,\n                        ],\n                    ]\n                ),\n            ]);\n        }\n\n        return $this->render(\n            'user/replies.html.twig',\n            [\n                'user' => $user,\n                'results' => $comments,\n                'criteria' => $criteria,\n            ],\n            $response\n        );\n    }\n\n    public function moderated(\n        #[MapEntity(mapping: ['username' => 'username'])]\n        User $user,\n        MagazineRepository $repository,\n        Request $request,\n    ): Response {\n        $requestedByUser = $this->getUser();\n        if ($user->isDeleted && (!$requestedByUser || (!$requestedByUser->isAdmin() && !$requestedByUser->isModerator()) || null === $user->markedForDeletionAt)) {\n            throw $this->createNotFoundException();\n        }\n        $criteria = new MagazinePageView(\n            $this->getPageNb($request),\n            Criteria::SORT_ACTIVE,\n            Criteria::AP_ALL,\n            MagazinePageView::ADULT_SHOW,\n        );\n\n        $response = new Response();\n        if ($user->apId) {\n            $response->headers->set('X-Robots-Tag', 'noindex, nofollow');\n        }\n\n        return $this->render(\n            'user/moderated.html.twig',\n            [\n                'view' => 'list',\n                'user' => $user,\n                'magazines' => $repository->findModeratedMagazines($user, (int) $request->get('p', 1)),\n                'criteria' => $criteria,\n            ],\n            $response\n        );\n    }\n\n    public function subscriptions(\n        #[MapEntity(mapping: ['username' => 'username'])]\n        User $user,\n        MagazineRepository $repository,\n        Request $request,\n    ): Response {\n        $requestedByUser = $this->getUser();\n        if ($user->isDeleted && (!$requestedByUser || (!$requestedByUser->isAdmin() && !$requestedByUser->isModerator()) || null === $user->markedForDeletionAt)) {\n            throw $this->createNotFoundException();\n        }\n\n        $response = new Response();\n        if ($user->apId) {\n            $response->headers->set('X-Robots-Tag', 'noindex, nofollow');\n        }\n\n        if (!$user->showProfileSubscriptions) {\n            if ($user !== $this->getUser()) {\n                throw new AccessDeniedException();\n            }\n        }\n\n        return $this->render(\n            'user/subscriptions.html.twig',\n            [\n                'user' => $user,\n                'magazines' => $repository->findSubscribedMagazines($this->getPageNb($request), $user),\n            ],\n            $response\n        );\n    }\n\n    public function followers(\n        #[MapEntity(mapping: ['username' => 'username'])]\n        User $user,\n        UserRepository $repository,\n        Request $request,\n    ): Response {\n        $requestedByUser = $this->getUser();\n        if ($user->isDeleted && (!$requestedByUser || (!$requestedByUser->isAdmin() && !$requestedByUser->isModerator()) || null === $user->markedForDeletionAt)) {\n            throw $this->createNotFoundException();\n        }\n\n        $response = new Response();\n        if ($user->apId) {\n            $response->headers->set('X-Robots-Tag', 'noindex, nofollow');\n        }\n\n        return $this->render(\n            'user/followers.html.twig',\n            [\n                'user' => $user,\n                'users' => $repository->findFollowers($this->getPageNb($request), $user),\n            ],\n            $response\n        );\n    }\n\n    public function following(\n        #[MapEntity(mapping: ['username' => 'username'])]\n        User $user,\n        UserRepository $manager,\n        Request $request,\n    ): Response {\n        $requestedByUser = $this->getUser();\n        if ($user->isDeleted && (!$requestedByUser || (!$requestedByUser->isAdmin() && !$requestedByUser->isModerator()) || null === $user->markedForDeletionAt)) {\n            throw $this->createNotFoundException();\n        }\n\n        $response = new Response();\n        if ($user->apId) {\n            $response->headers->set('X-Robots-Tag', 'noindex, nofollow');\n        }\n\n        if (!$user->showProfileFollowings && !$user->apId) {\n            if ($user !== $this->getUser()) {\n                throw new AccessDeniedException();\n            }\n        }\n\n        return $this->render(\n            'user/following.html.twig',\n            [\n                'user' => $user,\n                'users' => $manager->findFollowing($this->getPageNb($request), $user),\n            ],\n            $response\n        );\n    }\n\n    public function boosts(\n        #[MapEntity(mapping: ['username' => 'username'])]\n        User $user,\n        Request $request,\n        SearchRepository $repository,\n    ): Response {\n        $requestedByUser = $this->getUser();\n        if ($user->isDeleted && (!$requestedByUser || (!$requestedByUser->isAdmin() && !$requestedByUser->isModerator()) || null === $user->markedForDeletionAt)) {\n            throw $this->createNotFoundException();\n        }\n\n        $response = new Response();\n        if ($user->apId) {\n            $response->headers->set('X-Robots-Tag', 'noindex, nofollow');\n        }\n\n        $activity = $repository->findBoosts($this->getPageNb($request), $user);\n\n        if ($request->isXmlHttpRequest()) {\n            return new JsonResponse([\n                'html' => $this->renderView('user/_boost_list.html.twig', [\n                    'results' => $activity->getCurrentPageResults(),\n                    'pagination' => $activity,\n                ]),\n            ]);\n        }\n\n        return $this->render(\n            'user/overview.html.twig',\n            [\n                'user' => $user,\n                'results' => $activity->getCurrentPageResults(),\n                'pagination' => $activity,\n            ],\n            $response\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/User/UserNoteController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\User;\n\nuse App\\Controller\\AbstractController;\nuse App\\Entity\\User;\nuse App\\Form\\UserNoteType;\nuse App\\Service\\UserNoteManager;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass UserNoteController extends AbstractController\n{\n    public function __construct(private readonly UserNoteManager $manager)\n    {\n    }\n\n    #[IsGranted('ROLE_USER')]\n    public function __invoke(#[MapEntity(mapping: ['username' => 'username'])] User $user, Request $request): Response\n    {\n        $dto = $this->manager->createDto($this->getUserOrThrow(), $user);\n\n        $form = $this->createForm(UserNoteType::class, $dto);\n        $form->handleRequest($request);\n\n        if ($form->isSubmitted() && $form->isValid()) {\n            $dto = $form->getData();\n\n            if ($dto->body) {\n                $this->manager->save($this->getUserOrThrow(), $user, $dto->body);\n            } else {\n                $this->manager->clear($this->getUserOrThrow(), $user);\n            }\n        }\n\n        if ($request->isXmlHttpRequest()) {\n            return $this->getJsonSuccessResponse();\n        }\n\n        return $this->redirectToRefererOrHome($request);\n    }\n}\n"
  },
  {
    "path": "src/Controller/User/UserRemoveFollowing.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\User;\n\nuse App\\Controller\\AbstractController;\nuse App\\Entity\\User;\nuse App\\Service\\UserManager;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass UserRemoveFollowing extends AbstractController\n{\n    #[IsGranted('ROLE_ADMIN')]\n    public function __invoke(\n        #[MapEntity(mapping: ['username' => 'username'])]\n        User $user,\n        UserManager $manager,\n        Request $request,\n    ): Response {\n        $this->validateCsrf('user_remove_following', $request->getPayload()->get('token'));\n\n        $manager->removeFollowing($user);\n\n        return $this->redirectToRefererOrHome($request);\n    }\n}\n"
  },
  {
    "path": "src/Controller/User/UserReputationController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\User;\n\nuse App\\Controller\\AbstractController;\nuse App\\Entity\\Entry;\nuse App\\Entity\\EntryComment;\nuse App\\Entity\\Post;\nuse App\\Entity\\PostComment;\nuse App\\Entity\\User;\nuse App\\Repository\\ReputationRepository;\nuse App\\Service\\ReputationManager;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\n\nclass UserReputationController extends AbstractController\n{\n    public function __construct(\n        private readonly ReputationRepository $repository,\n        private readonly ReputationManager $manager,\n    ) {\n    }\n\n    public function __invoke(\n        #[MapEntity(mapping: ['username' => 'username'])]\n        User $user,\n        ?string $reputationType,\n        Request $request,\n    ): Response {\n        $requestedByUser = $this->getUser();\n        if ($user->isDeleted && (!$requestedByUser || (!$requestedByUser->isAdmin() && !$requestedByUser->isModerator()) || null === $user->markedForDeletionAt)) {\n            throw $this->createNotFoundException();\n        }\n\n        $page = (int) $request->get('p', 1);\n\n        $results = match ($this->manager->resolveType($reputationType)) {\n            ReputationRepository::TYPE_ENTRY => $this->repository->getUserReputation($user, Entry::class, $page),\n            ReputationRepository::TYPE_ENTRY_COMMENT => $this->repository->getUserReputation(\n                $user,\n                EntryComment::class,\n                $page\n            ),\n            ReputationRepository::TYPE_POST => $this->repository->getUserReputation($user, Post::class, $page),\n            ReputationRepository::TYPE_POST_COMMENT => $this->repository->getUserReputation(\n                $user,\n                PostComment::class,\n                $page\n            ),\n            default => null,\n        };\n\n        return $this->render(\n            'user/reputation.html.twig',\n            [\n                'type' => $reputationType,\n                'user' => $user,\n                'results' => $results,\n            ]\n        );\n    }\n}\n"
  },
  {
    "path": "src/Controller/User/UserSuspendController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\User;\n\nuse App\\Controller\\AbstractController;\nuse App\\Entity\\User;\nuse App\\Service\\UserManager;\nuse Symfony\\Bridge\\Doctrine\\Attribute\\MapEntity;\nuse Symfony\\Component\\ExpressionLanguage\\Expression;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass UserSuspendController extends AbstractController\n{\n    public function __construct(\n        private readonly UserManager $userManager,\n    ) {\n    }\n\n    #[IsGranted(new Expression('is_granted(\"ROLE_ADMIN\") or is_granted(\"ROLE_MODERATOR\")'))]\n    public function suspend(#[MapEntity(mapping: ['username' => 'username'])] User $user, Request $request): Response\n    {\n        $this->validateCsrf('user_suspend', $request->getPayload()->get('token'));\n\n        $this->userManager->suspend($user);\n\n        $this->addFlash('success', 'account_suspended');\n\n        return $this->redirectToRefererOrHome($request);\n    }\n\n    #[IsGranted(new Expression('is_granted(\"ROLE_ADMIN\") or is_granted(\"ROLE_MODERATOR\")'))]\n    public function unsuspend(#[MapEntity(mapping: ['username' => 'username'])] User $user, Request $request): Response\n    {\n        $this->validateCsrf('user_suspend', $request->getPayload()->get('token'));\n\n        $this->userManager->unsuspend($user);\n\n        $this->addFlash('success', 'account_unsuspended');\n\n        return $this->redirectToRefererOrHome($request);\n    }\n}\n"
  },
  {
    "path": "src/Controller/User/UserThemeController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller\\User;\n\nuse App\\Controller\\AbstractController;\nuse App\\Service\\UserManager;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass UserThemeController extends AbstractController\n{\n    #[IsGranted('ROLE_USER')]\n    public function __invoke(UserManager $manager, Request $request): Response\n    {\n        $manager->toggleTheme($this->getUserOrThrow());\n\n        if ($request->isXmlHttpRequest()) {\n            return new JsonResponse(\n                [\n                    'success' => true,\n                ]\n            );\n        }\n\n        return $this->redirectToRefererOrHome($request);\n    }\n}\n"
  },
  {
    "path": "src/Controller/VoteController.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Controller;\n\nuse App\\Entity\\Contracts\\VotableInterface;\nuse App\\Entity\\Entry;\nuse App\\Entity\\EntryComment;\nuse App\\Entity\\Post;\nuse App\\Entity\\PostComment;\nuse App\\Service\\SettingsManager;\nuse App\\Service\\VoteManager;\nuse App\\Utils\\DownvotesMode;\nuse Symfony\\Component\\HttpFoundation\\Exception\\BadRequestException;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\Security\\Http\\Attribute\\IsGranted;\n\nclass VoteController extends AbstractController\n{\n    public function __construct(\n        private readonly VoteManager $manager,\n        private readonly SettingsManager $settingsManager,\n    ) {\n    }\n\n    #[IsGranted('ROLE_USER')]\n    #[IsGranted('vote', subject: 'votable')]\n    public function __invoke(VotableInterface $votable, int $choice, Request $request): Response\n    {\n        if (VotableInterface::VOTE_DOWN === $choice && DownvotesMode::Disabled === $this->settingsManager->getDownvotesMode()) {\n            throw new BadRequestException('Downvotes are disabled!');\n        }\n\n        $vote = $this->manager->vote($choice, $votable, $this->getUserOrThrow());\n\n        if ($request->isXmlHttpRequest()) {\n            return new JsonResponse(\n                [\n                    'html' => $this->renderView('components/_ajax.html.twig', [\n                        'component' => 'vote',\n                        'attributes' => [\n                            'subject' => $vote->getSubject(),\n                            'showDownvote' => str_contains(\\get_class($vote->getSubject()), 'Entry'),\n                        ],\n                    ]\n                    ),\n                ]\n            );\n        }\n\n        if (!$request->headers->has('Referer')) {\n            return $this->redirectToRoute('front', ['_fragment' => $this->getFragment($votable)]);\n        }\n\n        return $this->redirect($request->headers->get('Referer').'#'.$this->getFragment($votable));\n    }\n\n    public function getFragment($votable): string\n    {\n        return match (true) {\n            $votable instanceof Entry => 'entry-'.$votable->getId(),\n            $votable instanceof EntryComment => 'entry-comment-'.$votable->getId(),\n            $votable instanceof Post => 'post-'.$votable->getId(),\n            $votable instanceof PostComment => 'post-comment-'.$votable->getId(),\n            default => throw new \\InvalidArgumentException('Invalid votable type'),\n        };\n    }\n}\n"
  },
  {
    "path": "src/DTO/ActivitiesResponseDto.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\DTO;\n\nuse OpenApi\\Attributes as OA;\n\n#[OA\\Schema]\nclass ActivitiesResponseDto implements \\JsonSerializable\n{\n    #[OA\\Property(type: 'array', nullable: true, items: new OA\\Items(type: UserSmallResponseDto::class), description: 'null if the user is not allowed to access the data or it is not supported by the subject')]\n    public ?array $boosts = null;\n    #[OA\\Property(type: 'array', nullable: true, items: new OA\\Items(type: UserSmallResponseDto::class), description: 'null if the user is not allowed to access the data or it is not supported by the subject')]\n    public ?array $upvotes = null;\n    #[OA\\Property(type: 'array', nullable: true, items: new OA\\Items(type: UserSmallResponseDto::class), description: 'null if the user is not allowed to access the data or it is not supported by the subject')]\n    public ?array $downvotes = null;\n\n    /**\n     * @param UserSmallResponseDto[]|null $boosts\n     * @param UserSmallResponseDto[]|null $upvotes\n     * @param UserSmallResponseDto[]|null $downvotes\n     */\n    public static function create(\n        ?array $boosts = null,\n        ?array $upvotes = null,\n        ?array $downvotes = null,\n    ): ActivitiesResponseDto {\n        $dto = new self();\n        $dto->boosts = $boosts;\n        $dto->upvotes = $upvotes;\n        $dto->downvotes = $downvotes;\n\n        return $dto;\n    }\n\n    public function jsonSerialize(): mixed\n    {\n        return [\n            'boosts' => $this->boosts,\n            'upvotes' => $this->upvotes,\n            'downvotes' => $this->downvotes,\n        ];\n    }\n}\n"
  },
  {
    "path": "src/DTO/ActivityPub/ImageDto.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\DTO\\ActivityPub;\n\nuse Symfony\\Component\\Validator\\Constraints as Assert;\n\nclass ImageDto\n{\n    #[Assert\\NotBlank]\n    public string $url;\n    #[Assert\\NotBlank]\n    public string $format;\n    public ?string $name;\n\n    public function create(string $url, string $format, ?string $name): self\n    {\n        $this->url = $url;\n        $this->format = $format;\n        $this->name = $name ?? $format;\n\n        return $this;\n    }\n}\n"
  },
  {
    "path": "src/DTO/ActivityPub/VideoDto.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\DTO\\ActivityPub;\n\nuse Symfony\\Component\\Validator\\Constraints as Assert;\n\nclass VideoDto\n{\n    #[Assert\\NotBlank]\n    public string $url;\n    #[Assert\\NotBlank]\n    public string $format;\n    public ?string $name;\n\n    public function create(string $url, string $format, ?string $name): self\n    {\n        $this->url = $url;\n        $this->format = $format;\n        $this->name = $name ?? $format;\n\n        return $this;\n    }\n}\n"
  },
  {
    "path": "src/DTO/BadgeDto.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\DTO;\n\nuse App\\Entity\\Badge;\nuse App\\Entity\\Magazine;\nuse App\\Validator\\Unique;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Component\\Serializer\\Annotation\\Groups;\nuse Symfony\\Component\\Validator\\Constraints as Assert;\n\n#[Unique(Badge::class, errorPath: 'name', fields: ['magazine', 'name'])]\n#[OA\\Schema()]\nclass BadgeDto\n{\n    public Magazine|MagazineDto|null $magazine = null;\n    #[Assert\\NotBlank]\n    #[Assert\\Length(min: 1, max: 20)]\n    #[Groups(['create-badge'])]\n    #[OA\\Property(nullable: false)]\n    public ?string $name = null;\n    private ?int $id = null;\n\n    public static function create(Magazine $magazine, string $name, ?int $id = null): self\n    {\n        $dto = new BadgeDto();\n        $dto->id = $id;\n        $dto->magazine = $magazine;\n        $dto->name = $name;\n\n        return $dto;\n    }\n\n    public function getId(): ?int\n    {\n        return $this->id;\n    }\n}\n"
  },
  {
    "path": "src/DTO/BadgeResponseDto.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\DTO;\n\nuse App\\Entity\\Badge;\nuse OpenApi\\Attributes as OA;\n\n#[OA\\Schema()]\nclass BadgeResponseDto implements \\JsonSerializable\n{\n    public ?int $magazineId = null;\n    public ?string $name = null;\n    public ?int $badgeId = null;\n\n    public function __construct(BadgeDto|Badge $badge)\n    {\n        $this->magazineId = $badge->magazine->getId();\n        $this->name = $badge->name;\n        $this->badgeId = $badge->getId();\n    }\n\n    public function jsonSerialize(): mixed\n    {\n        return [\n            'magazineId' => $this->magazineId,\n            'name' => $this->name,\n            'badgeId' => $this->badgeId,\n        ];\n    }\n}\n"
  },
  {
    "path": "src/DTO/BookmarkListDto.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\DTO;\n\nuse App\\Entity\\BookmarkList;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Component\\Validator\\Constraints\\NotBlank;\n\n#[OA\\Schema()]\nclass BookmarkListDto implements \\JsonSerializable\n{\n    #[NotBlank]\n    #[OA\\Property(description: 'The name of the list')]\n    public string $name;\n\n    #[OA\\Property(description: 'Whether this is the default list')]\n    public bool $isDefault = false;\n\n    #[OA\\Property(description: 'The total number of items in the list')]\n    public int $count = 0;\n\n    public static function fromList(BookmarkList $list): BookmarkListDto\n    {\n        $dto = new BookmarkListDto();\n        $dto->name = $list->name;\n        $dto->isDefault = $list->isDefault;\n        $dto->count = $list->entities->count();\n\n        return $dto;\n    }\n\n    public function jsonSerialize(): array\n    {\n        return [\n            'name' => $this->name,\n            'isDefault' => $this->isDefault,\n            'count' => $this->count,\n        ];\n    }\n}\n"
  },
  {
    "path": "src/DTO/BookmarksDto.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\DTO;\n\nclass BookmarksDto\n{\n    /**\n     * @var string[]|null\n     */\n    public ?array $bookmarks = null;\n}\n"
  },
  {
    "path": "src/DTO/ClientAccessStatsResponseDto.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\DTO;\n\nuse OpenApi\\Attributes as OA;\n\n#[OA\\Schema()]\nclass ClientAccessStatsResponseDto\n{\n    public ?string $client = null;\n    #[OA\\Property(description: \"Timestamp of form 'YYYY-MM-DD HH:MM:SS' in UTC\")]\n    public ?string $datetime = null;\n    public ?int $count = null;\n}\n"
  },
  {
    "path": "src/DTO/ClientConsentsRequestDto.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\DTO;\n\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Component\\Validator\\Constraints as Assert;\n\n#[OA\\Schema()]\nclass ClientConsentsRequestDto\n{\n    #[Assert\\NotBlank]\n    #[OA\\Property(description: 'The scopes the app has permission to access', type: 'array', items: new OA\\Items(type: 'string', enum: OAuth2ClientDto::AVAILABLE_SCOPES))]\n    public ?array $scopes = null;\n}\n"
  },
  {
    "path": "src/DTO/ClientConsentsResponseDto.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\DTO;\n\nuse OpenApi\\Attributes as OA;\n\n#[OA\\Schema()]\nclass ClientConsentsResponseDto implements \\JsonSerializable\n{\n    public ?int $consentId = null;\n    public ?string $client = null;\n    public ?string $description = null;\n    public ?ImageDto $clientLogo = null;\n    #[OA\\Property(description: 'The scopes the app currently has permission to access', type: 'array', items: new OA\\Items(type: 'string', enum: OAuth2ClientDto::AVAILABLE_SCOPES))]\n    public ?array $scopesGranted = null;\n    #[OA\\Property(description: 'The scopes the app may request', type: 'array', items: new OA\\Items(type: 'string', enum: OAuth2ClientDto::AVAILABLE_SCOPES))]\n    public ?array $scopesAvailable = null;\n\n    public static function create(int $consentId, string $clientName, ?string $clientDescription, ?ImageDto $logo, array $scopesGranted, array $scopesAvailable): self\n    {\n        $toReturn = new ClientConsentsResponseDto();\n        $toReturn->consentId = $consentId;\n        $toReturn->client = $clientName;\n        $toReturn->description = $clientDescription;\n        $toReturn->clientLogo = $logo;\n        $toReturn->scopesGranted = $scopesGranted;\n        $toReturn->scopesAvailable = $scopesAvailable;\n\n        return $toReturn;\n    }\n\n    public function jsonSerialize(): mixed\n    {\n        return [\n            'consentId' => $this->consentId,\n            'client' => $this->client,\n            'description' => $this->description,\n            'clientLogo' => $this->clientLogo?->jsonSerialize(),\n            'scopesGranted' => $this->scopesGranted,\n            'scopesAvailable' => $this->scopesAvailable,\n        ];\n    }\n}\n"
  },
  {
    "path": "src/DTO/ClientResponseDto.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\DTO;\n\nuse App\\Entity\\Client;\nuse League\\Bundle\\OAuth2ServerBundle\\ValueObject\\Grant;\nuse League\\Bundle\\OAuth2ServerBundle\\ValueObject\\RedirectUri;\nuse League\\Bundle\\OAuth2ServerBundle\\ValueObject\\Scope;\nuse OpenApi\\Attributes as OA;\n\n#[OA\\Schema()]\nclass ClientResponseDto implements \\JsonSerializable\n{\n    public ?string $identifier = null;\n    public ?string $name = null;\n    public ?string $contactEmail = null;\n    public ?string $description = null;\n    public ?UserSmallResponseDto $user = null;\n    public ?bool $active = null;\n    public ?\\DateTimeImmutable $createdAt = null;\n    #[OA\\Property(type: 'array', items: new OA\\Items(type: 'string', format: 'uri'))]\n    public ?array $redirectUris = null;\n    #[OA\\Property(type: 'array', items: new OA\\Items(type: 'string'))]\n    public ?array $grants = null;\n    #[OA\\Property(type: 'array', items: new OA\\Items(type: 'string'))]\n    public ?array $scopes = null;\n\n    public function __construct(?Client $client)\n    {\n        if ($client) {\n            $user = $client->getUser();\n            $this->identifier = $client->getIdentifier();\n            $this->name = $client->getName();\n            $this->contactEmail = $client->getContactEmail();\n            $this->description = $client->getDescription();\n            $this->user = $user ? new UserSmallResponseDto($user) : null;\n            $this->active = $client->isActive();\n            $this->createdAt = $client->getCreatedAt();\n            $this->redirectUris = array_map(fn (RedirectUri $uri) => (string) $uri, $client->getRedirectUris());\n            $this->grants = array_map(fn (Grant $grant) => (string) $grant, $client->getGrants());\n            $this->scopes = array_map(fn (Scope $scope) => (string) $scope, $client->getScopes());\n        }\n    }\n\n    public function jsonSerialize(): mixed\n    {\n        return [\n            'identifier' => $this->identifier,\n            'name' => $this->name,\n            'contactEmail' => $this->contactEmail,\n            'description' => $this->description,\n            'user' => $this->user?->jsonSerialize(),\n            'active' => $this->active,\n            'createdAt' => $this->createdAt->format(\\DateTimeImmutable::ATOM),\n            'redirectUris' => $this->redirectUris,\n            'grants' => $this->grants,\n            'scopes' => $this->scopes,\n        ];\n    }\n}\n"
  },
  {
    "path": "src/DTO/ConfirmDefederationDto.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\DTO;\n\nuse Symfony\\Component\\Validator\\Constraints as Assert;\n\nclass ConfirmDefederationDto\n{\n    #[Assert\\NotNull]\n    #[Assert\\IsTrue]\n    public bool $confirm;\n}\n"
  },
  {
    "path": "src/DTO/ContactDto.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\DTO;\n\nuse Symfony\\Component\\Validator\\Constraints as Assert;\n\nclass ContactDto\n{\n    #[Assert\\NotBlank]\n    public string $name;\n    #[Assert\\NotBlank]\n    #[Assert\\Email]\n    public string $email;\n    #[Assert\\NotBlank]\n    public string $message;\n    public ?string $ip = null;\n    public ?string $surname = null; // honeypot\n}\n"
  },
  {
    "path": "src/DTO/ContentRequestDto.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\DTO;\n\nuse App\\Entity\\Entry;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Component\\Serializer\\Annotation\\Groups;\n\n#[OA\\Schema()]\nclass ContentRequestDto extends ImageUploadDto\n{\n    #[Groups([\n        Entry::ENTRY_TYPE_ARTICLE,\n        Entry::ENTRY_TYPE_LINK,\n        Entry::ENTRY_TYPE_IMAGE,\n        Entry::ENTRY_TYPE_VIDEO,\n        'post',\n        'comment',\n    ])]\n    #[OA\\Property(example: 'We can post cat pics from the API now! What are you going to do with this power?')]\n    public ?string $body = null;\n    #[Groups(['common'])]\n    #[OA\\Property(example: 'en', nullable: true, minLength: 2, maxLength: 3)]\n    public ?string $lang = null;\n    #[Groups(['common'])]\n    #[OA\\Property(example: false)]\n    public bool $isAdult = false;\n}\n"
  },
  {
    "path": "src/DTO/ContentResponseDto.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\DTO;\n\nuse OpenApi\\Attributes as OA;\n\n/**\n * This class is just used to have a single return type in case of an array that can contain multiple content types.\n */\n#[OA\\Schema()]\nclass ContentResponseDto\n{\n    public function __construct(\n        public ?EntryResponseDto $entry = null,\n        public ?PostResponseDto $post = null,\n        public ?EntryCommentResponseDto $entryComment = null,\n        public ?PostCommentResponseDto $postComment = null,\n    ) {\n    }\n}\n"
  },
  {
    "path": "src/DTO/ContentStatsResponseDto.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\DTO;\n\nuse OpenApi\\Attributes as OA;\n\n#[OA\\Schema()]\nclass ContentStatsResponseDto\n{\n    public ?string $datetime = null;\n    public ?int $count = null;\n}\n"
  },
  {
    "path": "src/DTO/Contracts/UserDtoInterface.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\DTO\\Contracts;\n\ninterface UserDtoInterface\n{\n}\n"
  },
  {
    "path": "src/DTO/Contracts/VisibilityAwareDtoTrait.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\DTO\\Contracts;\n\nuse App\\Entity\\Contracts\\VisibilityInterface;\nuse OpenApi\\Attributes as OA;\n\ntrait VisibilityAwareDtoTrait\n{\n    #[OA\\Property(default: VisibilityInterface::VISIBILITY_VISIBLE, nullable: false, enum: [VisibilityInterface::VISIBILITY_PRIVATE, VisibilityInterface::VISIBILITY_TRASHED, VisibilityInterface::VISIBILITY_SOFT_DELETED, VisibilityInterface::VISIBILITY_VISIBLE])]\n    public ?string $visibility = VisibilityInterface::VISIBILITY_VISIBLE;\n    private static ?array $keysToDelete = null;\n\n    private function handleDeletion(array $value): array\n    {\n        if (null === self::$keysToDelete) {\n            throw new \\LogicException('handleDeletion requires $keysToDelete to be set.');\n        }\n        if (\n            false !== array_search($this->visibility, [\n                VisibilityInterface::VISIBILITY_VISIBLE,\n                VisibilityInterface::VISIBILITY_PRIVATE,\n            ])\n        ) {\n            return $value;\n        }\n\n        array_walk($value, fn (&$val, $key) => $val = false !== array_search($key, self::$keysToDelete) ? null : $val);\n\n        return $value;\n    }\n}\n"
  },
  {
    "path": "src/DTO/DomainDto.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\DTO;\n\nuse OpenApi\\Attributes as OA;\n\n#[OA\\Schema()]\nclass DomainDto implements \\JsonSerializable\n{\n    public ?string $name = null;\n    public ?int $entryCount = null;\n    public ?int $subscriptionsCount = null;\n    public ?bool $isUserSubscribed = null;\n    public ?bool $isBlockedByUser = null;\n    #[OA\\Property('domainId')]\n    private ?int $id;\n\n    public static function create(string $name, ?int $entryCount, ?int $subscriptionsCount, ?int $id = null): self\n    {\n        $toReturn = new DomainDto();\n        $toReturn->id = $id;\n        $toReturn->name = $name;\n        $toReturn->entryCount = $entryCount;\n        $toReturn->subscriptionsCount = $subscriptionsCount;\n\n        return $toReturn;\n    }\n\n    public function getId(): ?int\n    {\n        return $this->id;\n    }\n\n    public function jsonSerialize(): mixed\n    {\n        return [\n            'domainId' => $this->getId(),\n            'name' => $this->name,\n            'entryCount' => $this->entryCount,\n            'subscriptionsCount' => $this->subscriptionsCount,\n            'isUserSubscribed' => $this->isUserSubscribed,\n            'isBlockedByUser' => $this->isBlockedByUser,\n        ];\n    }\n}\n"
  },
  {
    "path": "src/DTO/EntryCommentDto.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\DTO;\n\nuse App\\Entity\\Contracts\\ContentVisibilityInterface;\nuse App\\Entity\\Contracts\\VisibilityInterface;\nuse App\\Entity\\Entry;\nuse App\\Entity\\EntryComment;\nuse App\\Entity\\Image;\nuse App\\Entity\\Magazine;\nuse App\\Entity\\User;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\Validator\\Constraints as Assert;\nuse Symfony\\Component\\Validator\\Context\\ExecutionContextInterface;\n\nclass EntryCommentDto implements ContentVisibilityInterface\n{\n    public const MAX_BODY_LENGTH = 5000;\n\n    public Magazine|MagazineDto|null $magazine = null;\n    public User|UserDto|null $user = null;\n    public Entry|EntryDto|null $entry = null;\n    public ?EntryComment $parent = null;\n    public ?EntryComment $root = null;\n    public ?ImageDto $image = null;\n    public ?string $imageUrl = null;\n    public ?string $imageAlt = null;\n    #[Assert\\Length(max: self::MAX_BODY_LENGTH, countUnit: Assert\\Length::COUNT_GRAPHEMES)]\n    public ?string $body = null;\n    public ?string $lang = null;\n    public bool $isAdult = false;\n    public ?int $uv = null;\n    public ?int $dv = null;\n    public ?int $favouriteCount = null;\n    public ?bool $isFavourited = null;\n    public ?int $userVote = null;\n    public ?string $visibility = VisibilityInterface::VISIBILITY_VISIBLE;\n    public ?string $ip = null;\n    public ?string $apId = null;\n    public ?int $apLikeCount = null;\n    public ?int $apDislikeCount = null;\n    public ?int $apShareCount = null;\n    public ?array $mentions = null;\n    public ?\\DateTimeImmutable $createdAt = null;\n    public ?\\DateTimeImmutable $editedAt = null;\n    public ?\\DateTime $lastActive = null;\n    private ?int $id = null;\n\n    #[Assert\\Callback]\n    public function validate(\n        ExecutionContextInterface $context,\n        $payload,\n    ) {\n        if (empty($this->image)) {\n            $image = Request::createFromGlobals()->files->filter('entry_comment');\n\n            if (\\is_array($image) && isset($image['image'])) {\n                $image = $image['image'];\n            } else {\n                $image = $context->getValue()->image;\n            }\n        } else {\n            $image = $this->image;\n        }\n\n        if (empty($this->body) && empty($image)) {\n            $this->buildViolation($context, 'body');\n        }\n    }\n\n    private function buildViolation(ExecutionContextInterface $context, $path)\n    {\n        $context->buildViolation('This value should not be blank.')\n            ->atPath($path)\n            ->addViolation();\n    }\n\n    public function createWithParent(\n        Entry $entry,\n        ?EntryComment $parent,\n        ?Image $image = null,\n        ?string $body = null,\n    ): self {\n        $this->entry = $entry;\n        $this->parent = $parent;\n        $this->body = $body;\n        $this->image = $image;\n\n        if ($parent) {\n            $this->root = $parent->root ?? $parent;\n        }\n\n        return $this;\n    }\n\n    public function getId(): ?int\n    {\n        return $this->id;\n    }\n\n    public function setId(int $id): void\n    {\n        $this->id = $id;\n    }\n\n    public function isFavored(): ?bool\n    {\n        return $this->isFavourited;\n    }\n\n    public function userChoice(): ?int\n    {\n        return $this->userVote;\n    }\n\n    public function getApId(): ?string\n    {\n        return $this->apId;\n    }\n\n    public function getMagazine(): ?Magazine\n    {\n        return $this->magazine;\n    }\n\n    public function getUser(): ?User\n    {\n        return $this->user;\n    }\n\n    public function getVisibility(): string\n    {\n        return $this->visibility;\n    }\n\n    public function isPrivate(): bool\n    {\n        return VisibilityInterface::VISIBILITY_PRIVATE === $this->visibility;\n    }\n\n    public function isSoftDeleted(): bool\n    {\n        return VisibilityInterface::VISIBILITY_SOFT_DELETED === $this->visibility;\n    }\n\n    public function isTrashed(): bool\n    {\n        return VisibilityInterface::VISIBILITY_TRASHED === $this->visibility;\n    }\n\n    public function isVisible(): bool\n    {\n        return VisibilityInterface::VISIBILITY_VISIBLE === $this->visibility;\n    }\n}\n"
  },
  {
    "path": "src/DTO/EntryCommentRequestDto.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\DTO;\n\nuse App\\Service\\SettingsManager;\nuse OpenApi\\Attributes as OA;\n\n#[OA\\Schema()]\nclass EntryCommentRequestDto extends ContentRequestDto\n{\n    /**\n     * Merges this EntryCommentRequestDto with an EntryCommentDto, replacing the $dto's fields with non-null\n     * fields from the request schema object.\n     *\n     * @param EntryCommentDto $dto The data to merge into\n     *\n     * @return EntryCommentDto The newly merged entry\n     */\n    public function mergeIntoDto(EntryCommentDto $dto, SettingsManager $settingsManager): EntryCommentDto\n    {\n        $dto->body = $this->body ?? $dto->body;\n        $dto->lang = $this->lang ?? $dto->lang ?? $settingsManager->getValue('KBIN_DEFAULT_LANG');\n        $dto->isAdult = $this->isAdult ?? $dto->isAdult;\n\n        return $dto;\n    }\n}\n"
  },
  {
    "path": "src/DTO/EntryCommentResponseDto.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\DTO;\n\nuse App\\DTO\\Contracts\\VisibilityAwareDtoTrait;\nuse App\\Entity\\EntryComment;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse OpenApi\\Attributes as OA;\n\n#[OA\\Schema()]\nclass EntryCommentResponseDto implements \\JsonSerializable\n{\n    use VisibilityAwareDtoTrait;\n\n    public int $commentId;\n    public ?UserSmallResponseDto $user = null;\n    public ?MagazineSmallResponseDto $magazine = null;\n    public ?int $entryId = null;\n    public ?int $parentId = null;\n    public ?int $rootId = null;\n    public ?ImageDto $image;\n    public ?string $body = null;\n    #[OA\\Property(example: 'en', nullable: true)]\n    public ?string $lang = null;\n    #[OA\\Property(type: 'array', items: new OA\\Items(type: 'string'))]\n    public ?array $mentions = null;\n    #[OA\\Property(type: 'array', items: new OA\\Items(type: 'string'))]\n    public ?array $tags = null;\n    public ?int $uv = 0;\n    public ?int $dv = 0;\n    public ?int $favourites = 0;\n    public ?bool $isFavourited = null;\n    public ?int $userVote = null;\n    public bool $isAdult = false;\n    public ?\\DateTimeImmutable $createdAt = null;\n    public ?\\DateTimeImmutable $editedAt = null;\n    public ?\\DateTime $lastActive = null;\n    public ?string $apId = null;\n    #[OA\\Property(\n        type: 'array',\n        description: 'Array of comments',\n        items: new OA\\Items(\n            ref: new Model(type: EntryCommentResponseDto::class)\n        ),\n        example: [\n            [\n                'commentid' => 0,\n                'user' => [\n                    'userId' => 0,\n                    'username' => 'test',\n                ],\n                'magazine' => [\n                    'magazineId' => 0,\n                    'name' => 'test',\n                ],\n                'entryId' => 0,\n                'parentId' => 0,\n                'rootId' => 0,\n                'image' => [\n                    'filePath' => 'x/y/z.png',\n                    'width' => 3000,\n                    'height' => 4000,\n                ],\n                'body' => 'string',\n                'lang' => 'en',\n                'isAdult' => false,\n                'uv' => 0,\n                'dv' => 0,\n                'favourites' => 0,\n                'visibility' => 'visible',\n                'apId' => 'string',\n                'mentions' => [\n                    '@user@instance',\n                ],\n                'tags' => [\n                    'string',\n                ],\n                'createdAt' => '2023-06-18 11:59:41-07:00',\n                'editedAt' => '2023-06-18 11:59:41-07:00',\n                'lastActive' => '2023-06-18 12:00:45-07:00',\n                'childCount' => 0,\n                'children' => [],\n            ],\n        ]\n    )]\n    public array $children = [];\n    #[OA\\Property(description: 'The total number of children the comment has.')]\n    public int $childCount = 0;\n    public ?bool $canAuthUserModerate = null;\n\n    /** @var string[]|null */\n    #[OA\\Property(type: 'array', items: new OA\\Items(type: 'string'))]\n    public ?array $bookmarks = null;\n    public ?bool $isAuthorModeratorInMagazine = null;\n\n    public static function create(\n        ?int $id = null,\n        ?UserSmallResponseDto $user = null,\n        ?MagazineSmallResponseDto $magazine = null,\n        ?int $entryId = null,\n        ?int $parentId = null,\n        ?int $rootId = null,\n        ?ImageDto $image = null,\n        ?string $body = null,\n        ?string $lang = null,\n        ?bool $isAdult = null,\n        ?int $uv = null,\n        ?int $dv = null,\n        ?int $favourites = null,\n        ?string $visibility = null,\n        ?string $apId = null,\n        ?array $mentions = null,\n        ?array $tags = null,\n        ?\\DateTimeImmutable $createdAt = null,\n        ?\\DateTimeImmutable $editedAt = null,\n        ?\\DateTime $lastActive = null,\n        int $childCount = 0,\n        ?bool $canAuthUserModerate = null,\n        ?array $bookmarks = null,\n        ?bool $isAuthorModeratorInMagazine = null,\n    ): self {\n        $dto = new EntryCommentResponseDto();\n        $dto->commentId = $id;\n        $dto->user = $user;\n        $dto->magazine = $magazine;\n        $dto->entryId = $entryId;\n        $dto->parentId = $parentId;\n        $dto->rootId = $rootId;\n        $dto->image = $image;\n        $dto->body = $body;\n        $dto->lang = $lang;\n        $dto->isAdult = $isAdult;\n        $dto->uv = $uv;\n        $dto->dv = $dv;\n        $dto->favourites = $favourites;\n        $dto->visibility = $visibility;\n        $dto->apId = $apId;\n        $dto->mentions = $mentions;\n        $dto->tags = $tags;\n        $dto->createdAt = $createdAt;\n        $dto->editedAt = $editedAt;\n        $dto->lastActive = $lastActive;\n        $dto->childCount = $childCount;\n        $dto->canAuthUserModerate = $canAuthUserModerate;\n        $dto->bookmarks = $bookmarks;\n        $dto->isAuthorModeratorInMagazine = $isAuthorModeratorInMagazine;\n\n        return $dto;\n    }\n\n    public static function recursiveChildCount(int $initial, EntryComment $child): int\n    {\n        return 1 + array_reduce($child->children->toArray(), self::class.'::recursiveChildCount', $initial);\n    }\n\n    public function jsonSerialize(): mixed\n    {\n        if (null === self::$keysToDelete) {\n            self::$keysToDelete = [\n                'image',\n                'body',\n                'tags',\n                'uv',\n                'dv',\n                'favourites',\n                'isFavourited',\n                'userVote',\n                'mentions',\n            ];\n        }\n\n        return $this->handleDeletion([\n            'commentId' => $this->commentId,\n            'user' => $this->user->jsonSerialize(),\n            'magazine' => $this->magazine->jsonSerialize(),\n            'entryId' => $this->entryId,\n            'parentId' => $this->parentId,\n            'rootId' => $this->rootId,\n            'image' => $this->image?->jsonSerialize(),\n            'body' => $this->body,\n            'lang' => $this->lang,\n            'isAdult' => $this->isAdult,\n            'uv' => $this->uv,\n            'dv' => $this->dv,\n            'favourites' => $this->favourites,\n            'isFavourited' => $this->isFavourited,\n            'userVote' => $this->userVote,\n            'visibility' => $this->visibility,\n            'apId' => $this->apId,\n            'mentions' => $this->mentions,\n            'tags' => $this->tags,\n            'createdAt' => $this->createdAt->format(\\DateTimeInterface::ATOM),\n            'editedAt' => $this->editedAt?->format(\\DateTimeInterface::ATOM),\n            'lastActive' => $this->lastActive?->format(\\DateTimeInterface::ATOM),\n            'childCount' => $this->childCount,\n            'children' => $this->children,\n            'canAuthUserModerate' => $this->canAuthUserModerate,\n            'bookmarks' => $this->bookmarks,\n            'isAuthorModeratorInMagazine' => $this->isAuthorModeratorInMagazine,\n        ]);\n    }\n}\n"
  },
  {
    "path": "src/DTO/EntryDto.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\DTO;\n\nuse App\\Entity\\Contracts\\ContentVisibilityInterface;\nuse App\\Entity\\Contracts\\VisibilityInterface;\nuse App\\Entity\\Entry;\nuse App\\Entity\\Magazine;\nuse App\\Entity\\User;\nuse Doctrine\\Common\\Collections\\Collection;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\Validator\\Constraints as Assert;\nuse Symfony\\Component\\Validator\\Context\\ExecutionContextInterface;\n\nclass EntryDto implements ContentVisibilityInterface\n{\n    #[Assert\\NotBlank]\n    public Magazine|MagazineDto|null $magazine = null;\n    public User|UserDto|null $user = null;\n    public ?ImageDto $image = null;\n    public ?string $imageAlt = null;\n    public ?string $imageUrl = null;\n    public ?DomainDto $domain = null;\n    #[Assert\\NotBlank]\n    #[Assert\\Length(min: 2, max: 255, countUnit: Assert\\Length::COUNT_GRAPHEMES)]\n    public ?string $title = null;\n    #[Assert\\Url(requireTld: true)]\n    public ?string $url = null;\n    #[Assert\\Length(max: Entry::MAX_BODY_LENGTH, countUnit: Assert\\Length::COUNT_GRAPHEMES)]\n    public ?string $body = null;\n    public ?string $lang = null;\n    public string $type = Entry::ENTRY_TYPE_ARTICLE;\n    public int $comments = 0;\n    public int $uv = 0;\n    public int $dv = 0;\n    public int $favouriteCount = 0;\n    public ?bool $isFavourited = null;\n    public ?int $userVote = null;\n    public bool $isOc = false;\n    public bool $isAdult = false;\n    public bool $isPinned = false;\n    public bool $isLocked = false;\n    public ?Collection $badges = null;\n    public ?string $slug = null;\n    public int $score = 0;\n    public ?string $visibility = VisibilityInterface::VISIBILITY_VISIBLE;\n    public ?string $ip = null;\n    public ?string $apId = null;\n    public ?int $apLikeCount = null;\n    public ?int $apDislikeCount = null;\n    public ?int $apShareCount = null;\n    public ?array $tags = null;\n    public ?\\DateTimeImmutable $createdAt = null;\n    public ?\\DateTimeImmutable $editedAt = null;\n    public ?\\DateTime $lastActive = null;\n    private ?int $id = null;\n\n    #[Assert\\Callback]\n    public function validate(\n        ExecutionContextInterface $context,\n        $payload,\n    ) {\n        if (empty($this->image)) {\n            $keys = ['entry', 'entry_edit'];\n            for ($i = 0; $i < \\sizeof($keys) && empty($image); ++$i) {\n                $image = Request::createFromGlobals()->files->filter($keys[$i]);\n                if (\\is_array($image)) {\n                    $image = $image['image'];\n                } else {\n                    $image = $context->getValue()->image;\n                }\n            }\n        } else {\n            $image = $this->image;\n        }\n\n        if (empty($this->body) && empty($this->url) && empty($image)) {\n            $this->buildViolation($context, 'url');\n            $this->buildViolation($context, 'body');\n            $this->buildViolation($context, 'image');\n        }\n    }\n\n    private function buildViolation(ExecutionContextInterface $context, $path)\n    {\n        $context->buildViolation('One of these values should not be blank.')\n            ->atPath($path)\n            ->addViolation();\n    }\n\n    public function getId(): ?int\n    {\n        return $this->id;\n    }\n\n    public function getApId(): ?string\n    {\n        return $this->apId;\n    }\n\n    public function setId(int $id): void\n    {\n        $this->id = $id;\n    }\n\n    public function getMagazine(): ?Magazine\n    {\n        return $this->magazine;\n    }\n\n    public function getUser(): ?User\n    {\n        return $this->user;\n    }\n\n    public function getVisibility(): string\n    {\n        return $this->visibility;\n    }\n\n    public function isVisible(): bool\n    {\n        return VisibilityInterface::VISIBILITY_VISIBLE === $this->visibility;\n    }\n\n    public function isPrivate(): bool\n    {\n        return VisibilityInterface::VISIBILITY_PRIVATE === $this->visibility;\n    }\n\n    public function isSoftDeleted(): bool\n    {\n        return VisibilityInterface::VISIBILITY_SOFT_DELETED === $this->visibility;\n    }\n\n    public function isTrashed(): bool\n    {\n        return VisibilityInterface::VISIBILITY_TRASHED === $this->visibility;\n    }\n\n    public function getType(): string\n    {\n        if ($this->url) {\n            return Entry::ENTRY_TYPE_LINK;\n        }\n\n        $type = Entry::ENTRY_TYPE_IMAGE;\n\n        if ($this->body) {\n            $type = Entry::ENTRY_TYPE_ARTICLE;\n        }\n\n        return $type;\n    }\n}\n"
  },
  {
    "path": "src/DTO/EntryRequestDto.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\DTO;\n\nuse App\\Entity\\Entry;\nuse App\\Service\\SettingsManager;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Component\\Serializer\\Annotation\\Groups;\n\n#[OA\\Schema(required: ['title'])]\nclass EntryRequestDto extends ContentRequestDto\n{\n    #[Groups([\n        Entry::ENTRY_TYPE_ARTICLE,\n        Entry::ENTRY_TYPE_LINK,\n        Entry::ENTRY_TYPE_IMAGE,\n        Entry::ENTRY_TYPE_VIDEO,\n    ])]\n    #[OA\\Property(example: 'Posted from the API!')]\n    public ?string $title = null;\n    #[Groups([\n        Entry::ENTRY_TYPE_LINK,\n        Entry::ENTRY_TYPE_VIDEO,\n    ])]\n    public ?string $url = null;\n    #[Groups([\n        Entry::ENTRY_TYPE_ARTICLE,\n        Entry::ENTRY_TYPE_LINK,\n        Entry::ENTRY_TYPE_IMAGE,\n        Entry::ENTRY_TYPE_VIDEO,\n    ])]\n    #[OA\\Property(type: 'array', nullable: true, items: new OA\\Items(type: 'string'), example: ['cat', 'blep', 'cute'])]\n    public ?array $tags = null;\n\n    // TODO: Support badges whenever/however they're implemented\n    // #[Groups([\n    //     Entry::ENTRY_TYPE_ARTICLE,\n    //     Entry::ENTRY_TYPE_LINK,\n    //     Entry::ENTRY_TYPE_IMAGE,\n    //     Entry::ENTRY_TYPE_VIDEO,\n    // ])]\n    // #[OA\\Property(type: 'array', items: new OA\\Items(type: 'string'))]\n    // public ?array $badges = null;\n\n    #[Groups([\n        Entry::ENTRY_TYPE_ARTICLE,\n        Entry::ENTRY_TYPE_LINK,\n        Entry::ENTRY_TYPE_IMAGE,\n        Entry::ENTRY_TYPE_VIDEO,\n    ])]\n    #[OA\\Property(example: false)]\n    public bool $isOc = false;\n\n    /**\n     * Merges this EntryRequestDto with an EntryDto, replacing the $dto's fields with non-null\n     * fields from the request object.\n     *\n     * @param EntryDto $dto The data to merge into\n     *\n     * @return EntryDto The newly merged entry\n     */\n    public function mergeIntoDto(EntryDto $dto, SettingsManager $settingsManager): EntryDto\n    {\n        $dto->title = $this->title ?? $dto->title;\n        $dto->body = $this->body ?? $dto->body;\n        // TODO: Support for badges when they're implemented\n        // $dto->badges = $this->badges ?? $dto->badges;\n        $dto->isAdult = $this->isAdult ?? $dto->isAdult;\n        $dto->isOc = $this->isOc ?? $dto->isOc;\n        $dto->lang = $this->lang ?? $dto->lang ?? $settingsManager->getValue('KBIN_DEFAULT_LANG');\n        $dto->url = $this->url ?? $dto->url;\n        $dto->tags = $this->tags ?? $dto->tags;\n\n        return $dto;\n    }\n}\n"
  },
  {
    "path": "src/DTO/EntryResponseDto.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\DTO;\n\nuse App\\DTO\\Contracts\\VisibilityAwareDtoTrait;\nuse App\\Entity\\Entry;\nuse App\\Enums\\ENotificationStatus;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse OpenApi\\Attributes as OA;\n\n#[OA\\Schema()]\nclass EntryResponseDto implements \\JsonSerializable\n{\n    use VisibilityAwareDtoTrait;\n\n    public int $entryId;\n    public ?MagazineSmallResponseDto $magazine = null;\n    public ?UserSmallResponseDto $user = null;\n    public ?DomainDto $domain = null;\n    public ?string $title = null;\n    public ?string $url = null;\n    public ?ImageDto $image = null;\n    public ?string $body = null;\n    #[OA\\Property(example: 'en', nullable: true)]\n    public ?string $lang = null;\n    #[OA\\Property(type: 'array', items: new OA\\Items(type: 'string'))]\n    public ?array $tags = null;\n    #[OA\\Property(type: 'array', description: 'Not implemented currently.', items: new OA\\Items(ref: new Model(type: BadgeResponseDto::class)))]\n    public ?array $badges = null;\n    public int $numComments;\n    public ?int $uv = 0;\n    public ?int $dv = 0;\n    public ?int $favourites = 0;\n    public ?bool $isFavourited = null;\n    public ?int $userVote = null;\n    public bool $isOc = false;\n    public bool $isAdult = false;\n    public bool $isPinned = false;\n    public bool $isLocked = false;\n    public ?\\DateTimeImmutable $createdAt = null;\n    public ?\\DateTimeImmutable $editedAt = null;\n    public ?\\DateTime $lastActive = null;\n    #[OA\\Property(example: Entry::ENTRY_TYPE_ARTICLE, enum: Entry::ENTRY_TYPE_OPTIONS)]\n    public ?string $type = null;\n    public ?string $slug = null;\n    public ?string $apId = null;\n    public ?bool $canAuthUserModerate = null;\n    public ?ENotificationStatus $notificationStatus = null;\n    public ?bool $isAuthorModeratorInMagazine = null;\n\n    /** @var string[]|null */\n    #[OA\\Property(type: 'array', items: new OA\\Items(type: 'string'))]\n    public ?array $bookmarks = null;\n\n    /**\n     * @var EntryResponseDto[]|null $crosspostedEntries other entries that share either the link or the title (if that is longer than 10 characters).\n     *                              If this property is null it means that this endpoint does not contain information about it,\n     *                              only an empty array means that there are no crossposts\n     */\n    #[OA\\Property(type: 'array', items: new OA\\Items(ref: new Model(type: EntryResponseDto::class)))]\n    public ?array $crosspostedEntries;\n\n    /**\n     * @param string[]|null $bookmarks\n     */\n    public static function create(\n        ?int $id = null,\n        ?MagazineSmallResponseDto $magazine = null,\n        ?UserSmallResponseDto $user = null,\n        ?DomainDto $domain = null,\n        ?string $title = null,\n        ?string $url = null,\n        ?ImageDto $image = null,\n        ?string $body = null,\n        ?string $lang = null,\n        ?array $tags = null,\n        ?array $badges = null,\n        ?int $comments = null,\n        ?int $uv = null,\n        ?int $dv = null,\n        ?bool $isPinned = null,\n        ?bool $isLocked = null,\n        ?string $visibility = null,\n        ?int $favouriteCount = null,\n        ?bool $isOc = null,\n        ?bool $isAdult = null,\n        ?\\DateTimeImmutable $createdAt = null,\n        ?\\DateTimeImmutable $editedAt = null,\n        ?\\DateTime $lastActive = null,\n        ?string $type = null,\n        ?string $slug = null,\n        ?string $apId = null,\n        ?bool $canAuthUserModerate = null,\n        ?array $bookmarks = null,\n        ?array $crosspostedEntries = null,\n        ?bool $isAuthorModeratorInMagazine = null,\n    ): self {\n        $dto = new EntryResponseDto();\n        $dto->entryId = $id;\n        $dto->magazine = $magazine;\n        $dto->user = $user;\n        $dto->domain = $domain;\n        $dto->title = $title;\n        $dto->url = $url;\n        $dto->image = $image;\n        $dto->body = $body;\n        $dto->lang = $lang;\n        $dto->tags = $tags;\n        $dto->badges = $badges;\n        $dto->numComments = $comments;\n        $dto->uv = $uv;\n        $dto->dv = $dv;\n        $dto->isPinned = $isPinned;\n        $dto->isLocked = $isLocked;\n        $dto->visibility = $visibility;\n        $dto->favourites = $favouriteCount;\n        $dto->isOc = $isOc;\n        $dto->isAdult = $isAdult;\n        $dto->createdAt = $createdAt;\n        $dto->editedAt = $editedAt;\n        $dto->lastActive = $lastActive;\n        $dto->type = $type;\n        $dto->slug = $slug;\n        $dto->apId = $apId;\n        $dto->canAuthUserModerate = $canAuthUserModerate;\n        $dto->bookmarks = $bookmarks;\n        $dto->crosspostedEntries = $crosspostedEntries;\n        $dto->isAuthorModeratorInMagazine = $isAuthorModeratorInMagazine;\n\n        return $dto;\n    }\n\n    public function jsonSerialize(): mixed\n    {\n        if (null === self::$keysToDelete) {\n            self::$keysToDelete = [\n                'domain',\n                'title',\n                'url',\n                'image',\n                'body',\n                'tags',\n                'badges',\n                'uv',\n                'dv',\n                'favourites',\n                'isFavourited',\n                'userVote',\n                'slug',\n            ];\n        }\n\n        return $this->handleDeletion([\n            'entryId' => $this->entryId,\n            'magazine' => $this->magazine->jsonSerialize(),\n            'user' => $this->user->jsonSerialize(),\n            'domain' => $this->domain?->jsonSerialize(),\n            'title' => $this->title,\n            'url' => $this->url,\n            'image' => $this->image?->jsonSerialize(),\n            'body' => $this->body,\n            'lang' => $this->lang,\n            'tags' => $this->tags,\n            'badges' => $this->badges,\n            'numComments' => $this->numComments,\n            'uv' => $this->uv,\n            'dv' => $this->dv,\n            'favourites' => $this->favourites,\n            'isFavourited' => $this->isFavourited,\n            'userVote' => $this->userVote,\n            'isOc' => $this->isOc,\n            'isAdult' => $this->isAdult,\n            'isPinned' => $this->isPinned,\n            'isLocked' => $this->isLocked,\n            'createdAt' => $this->createdAt->format(\\DateTimeInterface::ATOM),\n            'editedAt' => $this->editedAt?->format(\\DateTimeInterface::ATOM),\n            'lastActive' => $this->lastActive?->format(\\DateTimeInterface::ATOM),\n            'visibility' => $this->visibility,\n            'type' => $this->type,\n            'slug' => $this->slug,\n            'apId' => $this->apId,\n            'canAuthUserModerate' => $this->canAuthUserModerate,\n            'notificationStatus' => $this->notificationStatus,\n            'bookmarks' => $this->bookmarks,\n            'crosspostedEntries' => $this->crosspostedEntries,\n            'isAuthorModeratorInMagazine' => $this->isAuthorModeratorInMagazine,\n        ]);\n    }\n}\n"
  },
  {
    "path": "src/DTO/ExtendedContentResponseDto.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\DTO;\n\nuse OpenApi\\Attributes as OA;\n\n/**\n * This class is just used to have a single return type in case of an array that can contain multiple content types.\n */\n#[OA\\Schema()]\nclass ExtendedContentResponseDto\n{\n    public function __construct(\n        public ?EntryResponseDto $entry = null,\n        public ?PostResponseDto $post = null,\n        public ?EntryCommentResponseDto $entryComment = null,\n        public ?PostCommentResponseDto $postComment = null,\n    ) {\n    }\n}\n"
  },
  {
    "path": "src/DTO/FederationSettingsDto.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\DTO;\n\nuse OpenApi\\Attributes as OA;\n\n#[OA\\Schema()]\nclass FederationSettingsDto implements \\JsonSerializable\n{\n    public function __construct(\n        public bool $federationEnabled,\n        public bool $federationUsesAllowList,\n        public bool $federationPageEnabled,\n    ) {\n    }\n\n    public function jsonSerialize(): mixed\n    {\n        return [\n            'federationEnabled' => $this->federationEnabled,\n            'federationUsesAllowList' => $this->federationUsesAllowList,\n            'federationPageEnabled' => $this->federationPageEnabled,\n        ];\n    }\n}\n"
  },
  {
    "path": "src/DTO/GroupedMonitoringQueryDto.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\DTO;\n\nclass GroupedMonitoringQueryDto\n{\n    public string $query;\n    public float $minExecutionTime;\n    public float $maxExecutionTime;\n    public float $meanExecutionTime;\n    public float $totalExecutionTime;\n    public int $count;\n}\n"
  },
  {
    "path": "src/DTO/ImageDto.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\DTO;\n\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Component\\Serializer\\Annotation\\Groups;\nuse Symfony\\Component\\Serializer\\Annotation\\Ignore;\n\n#[OA\\Schema()]\nclass ImageDto implements \\JsonSerializable\n{\n    #[Groups(['common'])]\n    public ?string $filePath = null;\n    #[Groups(['common'])]\n    public ?string $sourceUrl = null;\n    #[Groups(['common'])]\n    public ?string $storageUrl = null;\n    #[Groups(['common'])]\n    public ?string $altText = null;\n    #[Groups(['common'])]\n    public ?int $width = null;\n    #[Groups(['common'])]\n    public ?int $height = null;\n    #[Groups(['common'])]\n    public ?string $blurHash = null;\n    #[Ignore]\n    public ?int $id = null;\n\n    public static function create(int $id, ?string $filePath, ?int $width = null, ?int $height = null, ?string $altText = null, ?string $sourceUrl = null, ?string $storageUrl = null, ?string $blurHash = null): self\n    {\n        $dto = new ImageDto();\n        $dto->filePath = $filePath;\n        $dto->altText = $altText;\n        $dto->width = $width;\n        $dto->height = $height;\n        $dto->sourceUrl = $sourceUrl;\n        $dto->storageUrl = $storageUrl;\n        $dto->blurHash = $blurHash;\n        $dto->id = $id;\n\n        return $dto;\n    }\n\n    public function jsonSerialize(): array\n    {\n        return [\n            'filePath' => $this->filePath,\n            'sourceUrl' => $this->sourceUrl,\n            'storageUrl' => $this->storageUrl,\n            'altText' => $this->altText,\n            'width' => $this->width,\n            'height' => $this->height,\n            'blurHash' => $this->blurHash,\n        ];\n    }\n}\n"
  },
  {
    "path": "src/DTO/ImageUploadDto.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\DTO;\n\nuse App\\Service\\ImageManager;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Component\\HttpFoundation\\File\\UploadedFile;\nuse Symfony\\Component\\Serializer\\Annotation\\Groups;\n\n#[OA\\Schema()]\nclass ImageUploadDto\n{\n    public const IMAGE_UPLOAD = 'imageUpload';\n    /**\n     * Only use this in cases where alt text will be added through different means.\n     */\n    public const IMAGE_UPLOAD_NO_ALT = 'imageUploadNoAlt';\n    #[Groups([\n        self::IMAGE_UPLOAD,\n    ])]\n    public ?string $alt = null;\n    #[Groups([\n        self::IMAGE_UPLOAD,\n        self::IMAGE_UPLOAD_NO_ALT,\n    ])]\n    #[OA\\Property(\n        type: 'string', format: 'binary', nullable: true,\n        encoding: new OA\\Encoding(property: 'uploadImage', contentType: ImageManager::IMAGE_MIMETYPE_STR)\n    )]\n    public ?UploadedFile $uploadImage = null;\n}\n"
  },
  {
    "path": "src/DTO/InstanceDto.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\DTO;\n\nuse OpenApi\\Attributes as OA;\n\n#[OA\\Schema()]\nclass InstanceDto implements \\JsonSerializable\n{\n    public function __construct(\n        #[OA\\Property(description: 'the domain of the instance', example: 'instance.tld')]\n        public string $domain,\n        #[OA\\Property(description: 'the software the instance is running on', example: 'mbin')]\n        public ?string $software = null,\n        #[OA\\Property(description: 'the version of the software', example: '1.6.0')]\n        public ?string $version = null,\n    ) {\n    }\n\n    public function jsonSerialize(): mixed\n    {\n        return [\n            'domain' => $this->domain,\n            'software' => $this->software,\n            'version' => $this->version,\n        ];\n    }\n}\n"
  },
  {
    "path": "src/DTO/InstancesDto.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\DTO;\n\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Component\\Validator\\Constraints as Assert;\n\n#[OA\\Schema()]\nclass InstancesDto implements \\JsonSerializable\n{\n    public function __construct(\n        #[Assert\\All([\n            new Assert\\Hostname(),\n        ])]\n        #[OA\\Property(type: 'array', items: new OA\\Items(type: 'string', format: 'url'))]\n        public ?array $instances,\n    ) {\n    }\n\n    public function jsonSerialize(): mixed\n    {\n        return [\n            'instances' => $this->instances,\n        ];\n    }\n}\n"
  },
  {
    "path": "src/DTO/InstancesDtoV2.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\DTO;\n\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse OpenApi\\Attributes as OA;\n\n#[OA\\Schema()]\nclass InstancesDtoV2 implements \\JsonSerializable\n{\n    public function __construct(\n        #[OA\\Property(type: 'array', items: new OA\\Items(ref: new Model(type: InstanceDto::class)))]\n        public ?array $instances,\n    ) {\n    }\n\n    public function jsonSerialize(): mixed\n    {\n        return [\n            'instances' => $this->instances,\n        ];\n    }\n}\n"
  },
  {
    "path": "src/DTO/MagazineBanDto.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\DTO;\n\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Component\\Serializer\\Annotation\\Ignore;\n\n#[OA\\Schema()]\nclass MagazineBanDto\n{\n    public ?string $reason = null;\n    public ?\\DateTimeImmutable $expiredAt = null;\n    #[Ignore]\n    private ?int $id = null;\n\n    public static function create(\n        ?string $reason = null,\n        ?\\DateTimeImmutable $expiredAt = null,\n        ?int $id = null,\n    ): self {\n        $dto = new MagazineBanDto();\n        $dto->reason = $reason;\n        $dto->expiredAt = $expiredAt;\n\n        return $dto;\n    }\n\n    public function getId(): ?int\n    {\n        return $this->id;\n    }\n}\n"
  },
  {
    "path": "src/DTO/MagazineBanResponseDto.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\DTO;\n\nuse OpenApi\\Attributes as OA;\n\n#[OA\\Schema()]\nclass MagazineBanResponseDto implements \\JsonSerializable\n{\n    public ?int $banId = null;\n    public ?string $reason = null;\n    public ?\\DateTimeInterface $expiredAt = null;\n    public ?MagazineSmallResponseDto $magazine = null;\n    public ?UserSmallResponseDto $bannedUser = null;\n    public ?UserSmallResponseDto $bannedBy = null;\n\n    public static function create(\n        int $id,\n        ?string $reason = null,\n        ?\\DateTimeInterface $expiredAt = null,\n        ?MagazineSmallResponseDto $magazine = null,\n        ?UserSmallResponseDto $user = null,\n        ?UserSmallResponseDto $bannedBy = null,\n    ): self {\n        $dto = new MagazineBanResponseDto();\n        $dto->reason = $reason;\n        $dto->expiredAt = $expiredAt;\n        $dto->magazine = $magazine;\n        $dto->bannedUser = $user;\n        $dto->bannedBy = $bannedBy;\n        $dto->banId = $id;\n\n        return $dto;\n    }\n\n    public function getExpired(): bool\n    {\n        return $this->expiredAt && (new \\DateTime('+10 seconds')) >= $this->expiredAt;\n    }\n\n    public function jsonSerialize(): mixed\n    {\n        return [\n            'banId' => $this->banId,\n            'reason' => $this->reason,\n            'expired' => $this->getExpired(),\n            'expiredAt' => $this->expiredAt?->format(\\DateTimeInterface::ATOM),\n            'bannedUser' => $this->bannedUser->jsonSerialize(),\n            'bannedBy' => $this->bannedBy->jsonSerialize(),\n            'magazine' => $this->magazine->jsonSerialize(),\n        ];\n    }\n}\n"
  },
  {
    "path": "src/DTO/MagazineDto.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\DTO;\n\nuse App\\Entity\\Image;\nuse App\\Entity\\Magazine;\nuse App\\Entity\\User;\nuse App\\Utils\\RegPatterns;\nuse App\\Validator\\Unique;\nuse Doctrine\\Common\\Collections\\Collection;\nuse Symfony\\Component\\Validator\\Constraints as Assert;\n\n#[Unique(Magazine::class, errorPath: 'name', fields: ['name'], idFields: ['id'])]\nclass MagazineDto\n{\n    public const MAX_NAME_LENGTH = 25;\n    public const MAX_TITLE_LENGTH = 50;\n\n    private User|UserDto|null $owner = null;\n    public Image|ImageDto|null $icon = null;\n    public Image|ImageDto|null $banner = null;\n    #[Assert\\NotBlank]\n    #[Assert\\Length(min: 2, max: self::MAX_NAME_LENGTH, countUnit: Assert\\Length::COUNT_GRAPHEMES)]\n    #[Assert\\Regex(pattern: RegPatterns::MAGAZINE_NAME, match: true)]\n    public ?string $name = null;\n    #[Assert\\NotBlank]\n    #[Assert\\Length(min: 3, max: self::MAX_TITLE_LENGTH, countUnit: Assert\\Length::COUNT_GRAPHEMES)]\n    public ?string $title = null;\n    #[Assert\\Length(min: 0, max: Magazine::MAX_DESCRIPTION_LENGTH, countUnit: Assert\\Length::COUNT_GRAPHEMES)]\n    public ?string $description = null;\n    #[Assert\\Length(min: 0, max: Magazine::MAX_RULES_LENGTH, countUnit: Assert\\Length::COUNT_GRAPHEMES)]\n    public ?string $rules = null;\n    public ?string $visibility = null;\n    public int $subscriptionsCount = 0;\n    public int $entryCount = 0;\n    public int $entryCommentCount = 0;\n    public int $postCount = 0;\n    public int $postCommentCount = 0;\n    public bool $isAdult = false;\n    public bool $isPostingRestrictedToMods = false;\n    public ?bool $indexable = null;\n    public ?bool $isUserSubscribed = null;\n    public ?bool $isBlockedByUser = null;\n    public ?int $localSubscribers = null;\n    public ?array $tags = null;\n    public ?Collection $badges = null;\n    public ?Collection $moderators = null;\n    public ?string $ip = null;\n    public ?string $apId = null;\n    public ?string $apProfileId = null;\n    public ?string $apFeaturedUrl = null;\n    public ?string $serverSoftware = null;\n    public ?string $serverSoftwareVersion = null;\n    private ?int $id = null;\n    public ?bool $discoverable = null;\n    public ?bool $nameAsTag = null;\n\n    public function getId(): ?int\n    {\n        return $this->id;\n    }\n\n    public function setId(int $id): void\n    {\n        $this->id = $id;\n    }\n\n    public function getOwner(): User|UserDto|null\n    {\n        return $this->owner;\n    }\n\n    public function setOwner(User|UserDto|null $owner): void\n    {\n        $this->owner = $owner;\n    }\n}\n"
  },
  {
    "path": "src/DTO/MagazineLogResponseDto.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\DTO;\n\nuse App\\Entity\\Contracts\\ContentInterface;\nuse App\\Entity\\Entry;\nuse App\\Entity\\EntryComment;\nuse App\\Entity\\Post;\nuse App\\Entity\\PostComment;\nuse App\\Factory\\EntryCommentFactory;\nuse App\\Factory\\EntryFactory;\nuse App\\Factory\\PostCommentFactory;\nuse App\\Factory\\PostFactory;\nuse App\\Repository\\TagLinkRepository;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Component\\Serializer\\Annotation\\Ignore;\n\n#[OA\\Schema()]\nclass MagazineLogResponseDto implements \\JsonSerializable\n{\n    public const LOG_TYPES = [\n        'log_entry_deleted',\n        'log_entry_restored',\n        'log_entry_comment_deleted',\n        'log_entry_comment_restored',\n        'log_post_deleted',\n        'log_post_restored',\n        'log_post_comment_deleted',\n        'log_post_comment_restored',\n        'log_ban',\n        'log_unban',\n        'log_entry_pinned',\n        'log_entry_unpinned',\n    ];\n\n    #[OA\\Property(enum: self::LOG_TYPES)]\n    public ?string $type = null;\n    public ?\\DateTimeImmutable $createdAt = null;\n    public ?MagazineSmallResponseDto $magazine = null;\n    public ?UserSmallResponseDto $moderator = null;\n    /**\n     * The type of the subject is depended on the type of the log entry:\n     * - EntryResponseDto when type is 'log_entry_deleted', 'log_entry_restored', 'log_entry_pinned' or 'log_entry_unpinned'\n     * - EntryCommentResponseDto when type is 'log_entry_comment_deleted' or 'log_entry_comment_restored'\n     * - PostResponseDto when type is 'log_post_deleted' or 'log_post_restored'\n     * - PostCommentResponseDto when type is 'log_post_comment_deleted' or 'log_post_comment_restored'\n     * - MagazineBanResponseDto when type is 'log_ban' or 'log_unban'\n     * - UserSmallResponseDto when type is 'log_moderator_add' or 'log_moderator_remove'\n     */\n    #[OA\\Property('subject')]\n    // If this property is named 'subject' the api doc generator will not pick it up.\n    // It is still serialized as 'subject', see the jsonSerialize method.\n    public EntryResponseDto|EntryCommentResponseDto|PostResponseDto|PostCommentResponseDto|MagazineBanResponseDto|UserSmallResponseDto|null $subject2 = null;\n\n    public static function create(\n        MagazineSmallResponseDto $magazine,\n        UserSmallResponseDto $moderator,\n        \\DateTimeImmutable $createdAt,\n        string $type,\n    ): self {\n        $dto = new MagazineLogResponseDto();\n        $dto->magazine = $magazine;\n        $dto->moderator = $moderator;\n        $dto->createdAt = $createdAt;\n        $dto->type = $type;\n\n        return $dto;\n    }\n\n    public static function createBanUnban(\n        MagazineSmallResponseDto $magazine,\n        UserSmallResponseDto $moderator,\n        \\DateTimeImmutable $createdAt,\n        string $type,\n        MagazineBanResponseDto $banSubject,\n    ): self {\n        $dto = self::create($magazine, $moderator, $createdAt, $type);\n        $dto->subject2 = $banSubject;\n\n        return $dto;\n    }\n\n    public static function createModeratorAddRemove(\n        MagazineSmallResponseDto $magazine,\n        UserSmallResponseDto $moderator,\n        \\DateTimeImmutable $createdAt,\n        string $type,\n        UserSmallResponseDto $moderatorSubject,\n    ): self {\n        $dto = self::create($magazine, $moderator, $createdAt, $type);\n        $dto->subject2 = $moderatorSubject;\n\n        return $dto;\n    }\n\n    #[Ignore]\n    public function setSubject(\n        ?ContentInterface $subject,\n        EntryFactory $entryFactory,\n        EntryCommentFactory $entryCommentFactory,\n        PostFactory $postFactory,\n        PostCommentFactory $postCommentFactory,\n        TagLinkRepository $tagLinkRepository,\n    ): void {\n        switch ($this->type) {\n            case 'log_entry_deleted':\n            case 'log_entry_restored':\n            case 'log_entry_pinned':\n            case 'log_entry_unpinned':\n                \\assert($subject instanceof Entry);\n                $this->subject2 = $entryFactory->createResponseDto($subject, tags: $tagLinkRepository->getTagsOfContent($subject));\n                break;\n            case 'log_entry_comment_deleted':\n            case 'log_entry_comment_restored':\n                \\assert($subject instanceof EntryComment);\n                $this->subject2 = $entryCommentFactory->createResponseDto($subject, tags: $tagLinkRepository->getTagsOfContent($subject));\n                break;\n            case 'log_post_deleted':\n            case 'log_post_restored':\n                \\assert($subject instanceof Post);\n                $this->subject2 = $postFactory->createResponseDto($subject, tags: $tagLinkRepository->getTagsOfContent($subject));\n                break;\n            case 'log_post_comment_deleted':\n            case 'log_post_comment_restored':\n                \\assert($subject instanceof PostComment);\n                $this->subject2 = $postCommentFactory->createResponseDto($subject, tags: $tagLinkRepository->getTagsOfContent($subject));\n                break;\n            default:\n                break;\n        }\n    }\n\n    public function jsonSerialize(): mixed\n    {\n        return [\n            'type' => $this->type,\n            'createdAt' => $this->createdAt->format(\\DateTimeInterface::ATOM),\n            'magazine' => $this->magazine,\n            'moderator' => $this->moderator,\n            'subject' => $this->subject2?->jsonSerialize(),\n        ];\n    }\n}\n"
  },
  {
    "path": "src/DTO/MagazineRequestDto.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\DTO;\n\nuse OpenApi\\Attributes as OA;\n\n#[OA\\Schema()]\nclass MagazineRequestDto\n{\n    public ?string $name = null;\n    public ?string $title = null;\n    public ?string $description = null;\n\n    #[OA\\Property(description: 'If this field is populated, it will throw a BadRequestException.', deprecated: true)]\n    public ?string $rules = null;\n    public ?bool $isAdult = null;\n    public ?bool $isPostingRestrictedToMods = null;\n    public ?bool $discoverable = null;\n    public ?bool $indexable = null;\n\n    public function mergeIntoDto(MagazineDto $dto): MagazineDto\n    {\n        $dto->name = $this->name ?? $dto->name;\n        $dto->title = $this->title ?? $dto->title;\n        $dto->description = $this->description ?? $dto->description;\n        $dto->rules = $this->rules ?? $dto->rules;\n        $dto->isAdult = null !== $this->isAdult ? $this->isAdult : $dto->isAdult;\n        $dto->isPostingRestrictedToMods = $this->isPostingRestrictedToMods ?? false;\n        $dto->discoverable = $this->discoverable ?? $dto->discoverable ?? true;\n        $dto->indexable = $this->indexable ?? $dto->indexable ?? true;\n\n        return $dto;\n    }\n}\n"
  },
  {
    "path": "src/DTO/MagazineResponseDto.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\DTO;\n\nuse App\\Enums\\ENotificationStatus;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse OpenApi\\Attributes as OA;\n\n#[OA\\Schema()]\nclass MagazineResponseDto implements \\JsonSerializable\n{\n    public ?ModeratorResponseDto $owner = null;\n    public ?ImageDto $icon = null;\n    public ?ImageDto $banner = null;\n    public ?string $name = null;\n    public ?string $title = null;\n    public ?string $description = null;\n    public ?string $rules = null;\n    public int $subscriptionsCount = 0;\n    public int $entryCount = 0;\n    public int $entryCommentCount = 0;\n    public int $postCount = 0;\n    public int $postCommentCount = 0;\n    public bool $isAdult = false;\n    public ?bool $isUserSubscribed = null;\n    public ?bool $isBlockedByUser = null;\n    #[OA\\Property(type: 'array', description: 'Magazine tags', items: new OA\\Items(type: 'string'))]\n    public ?array $tags = null;\n    #[OA\\Property(type: 'array', description: 'Magazine badges', items: new OA\\Items(ref: new Model(type: BadgeResponseDto::class)))]\n    public ?array $badges = null;\n    #[OA\\Property(type: 'array', description: 'Moderator list', items: new OA\\Items(ref: new Model(type: ModeratorResponseDto::class)))]\n    public ?array $moderators = null;\n    public ?string $apId = null;\n    public ?string $apProfileId = null;\n    public ?int $magazineId = null;\n    public ?string $serverSoftware = null;\n    public ?string $serverSoftwareVersion = null;\n    public bool $isPostingRestrictedToMods = false;\n    public ?int $localSubscribers = null;\n    public ?ENotificationStatus $notificationStatus = null;\n    public ?bool $discoverable = null;\n    public ?bool $indexable = null;\n\n    public static function create(\n        ?ModeratorResponseDto $owner = null,\n        ?ImageDto $icon = null,\n        ?ImageDto $banner = null,\n        ?string $name = null,\n        ?string $title = null,\n        ?string $description = null,\n        ?string $rules = null,\n        int $subscriptionsCount = 0,\n        int $entryCount = 0,\n        int $entryCommentCount = 0,\n        int $postCount = 0,\n        int $postCommentCount = 0,\n        bool $isAdult = false,\n        ?bool $isUserSubscribed = null,\n        ?bool $isBlockedByUser = null,\n        ?array $tags = null,\n        ?array $badges = null,\n        ?array $moderators = null,\n        ?string $apId = null,\n        ?string $apProfileId = null,\n        ?int $magazineId = null,\n        ?string $serverSoftware = null,\n        ?string $serverSoftwareVersion = null,\n        bool $isPostingRestrictedToMods = false,\n        ?int $localSubscribers = null,\n        ?bool $discoverable = null,\n        ?bool $indexable = null,\n    ): self {\n        $dto = new MagazineResponseDto();\n        $dto->owner = $owner;\n        $dto->icon = $icon;\n        $dto->banner = $banner;\n        $dto->name = $name;\n        $dto->title = $title;\n        $dto->description = $description;\n        $dto->rules = $rules;\n        $dto->subscriptionsCount = $subscriptionsCount;\n        $dto->entryCount = $entryCount;\n        $dto->entryCommentCount = $entryCommentCount;\n        $dto->postCount = $postCount;\n        $dto->postCommentCount = $postCommentCount;\n        $dto->isAdult = $isAdult;\n        $dto->isUserSubscribed = $isUserSubscribed;\n        $dto->isBlockedByUser = $isBlockedByUser;\n        $dto->tags = $tags;\n        $dto->badges = $badges;\n        $dto->moderators = $moderators;\n        $dto->apId = $apId;\n        $dto->apProfileId = $apProfileId;\n        $dto->magazineId = $magazineId;\n        $dto->serverSoftware = $serverSoftware;\n        $dto->serverSoftwareVersion = $serverSoftwareVersion;\n        $dto->isPostingRestrictedToMods = $isPostingRestrictedToMods;\n        $dto->localSubscribers = $localSubscribers;\n        $dto->discoverable = $discoverable;\n        $dto->indexable = $indexable;\n\n        return $dto;\n    }\n\n    public function jsonSerialize(): mixed\n    {\n        return [\n            'magazineId' => $this->magazineId,\n            'owner' => $this->owner?->jsonSerialize(),\n            'icon' => $this->icon ? $this->icon->jsonSerialize() : null,\n            'banner' => $this->banner?->jsonSerialize(),\n            'name' => $this->name,\n            'title' => $this->title,\n            'description' => $this->description,\n            'rules' => $this->rules,\n            'subscriptionsCount' => $this->subscriptionsCount,\n            'entryCount' => $this->entryCount,\n            'entryCommentCount' => $this->entryCommentCount,\n            'postCount' => $this->postCount,\n            'postCommentCount' => $this->postCommentCount,\n            'isAdult' => $this->isAdult,\n            'isUserSubscribed' => $this->isUserSubscribed,\n            'isBlockedByUser' => $this->isBlockedByUser,\n            'tags' => $this->tags,\n            'badges' => array_map(fn (BadgeResponseDto $badge) => $badge->jsonSerialize(), $this->badges),\n            'moderators' => array_map(fn (ModeratorResponseDto $moderator) => $moderator->jsonSerialize(), $this->moderators),\n            'apId' => $this->apId,\n            'apProfileId' => $this->apProfileId,\n            'serverSoftware' => $this->serverSoftware,\n            'serverSoftwareVersion' => $this->serverSoftwareVersion,\n            'isPostingRestrictedToMods' => $this->isPostingRestrictedToMods,\n            'localSubscribers' => $this->localSubscribers,\n            'notificationStatus' => $this->notificationStatus,\n            'discoverable' => $this->discoverable,\n            'indexable' => $this->indexable,\n        ];\n    }\n}\n"
  },
  {
    "path": "src/DTO/MagazineSmallResponseDto.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\DTO;\n\nuse OpenApi\\Attributes as OA;\n\n#[OA\\Schema()]\nclass MagazineSmallResponseDto implements \\JsonSerializable\n{\n    public ?string $name = null;\n    public ?int $magazineId = null;\n    public ?ImageDto $icon = null;\n    public ?ImageDto $banner = null;\n    public ?bool $isUserSubscribed = null;\n    public ?bool $isBlockedByUser = null;\n    public ?string $apId = null;\n    public ?string $apProfileId = null;\n    public ?bool $discoverable = null;\n    public ?bool $indexable = null;\n\n    public function __construct(MagazineDto $dto)\n    {\n        $this->name = $dto->name;\n        $this->magazineId = $dto->getId();\n        $this->icon = $dto->icon;\n        $this->banner = $dto->banner;\n        $this->isUserSubscribed = $dto->isUserSubscribed;\n        $this->isBlockedByUser = $dto->isBlockedByUser;\n        $this->apId = $dto->apId;\n        $this->apProfileId = $dto->apProfileId;\n        $this->discoverable = $dto->discoverable;\n        $this->indexable = $dto->indexable;\n    }\n\n    public function jsonSerialize(): mixed\n    {\n        return [\n            'magazineId' => $this->magazineId,\n            'name' => $this->name,\n            'icon' => $this->icon,\n            'banner' => $this->banner,\n            'isUserSubscribed' => $this->isUserSubscribed,\n            'isBlockedByUser' => $this->isBlockedByUser,\n            'apId' => $this->apId,\n            'apProfileId' => $this->apProfileId,\n            'discoverable' => $this->discoverable,\n            'indexable' => $this->indexable,\n        ];\n    }\n}\n"
  },
  {
    "path": "src/DTO/MagazineThemeDto.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\DTO;\n\nuse App\\Entity\\Magazine;\n\nclass MagazineThemeDto\n{\n    public ?Magazine $magazine = null;\n    public ?ImageDto $icon = null;\n    public ?ImageDto $banner = null;\n    public ?string $customCss = null;\n    public ?string $customJs = null;\n    public ?string $primaryColor = null;\n    public ?string $primaryDarkerColor = null;\n    public ?string $backgroundImage = null;\n\n    public function __construct(Magazine $magazine)\n    {\n        $this->magazine = $magazine;\n        $this->customCss = $magazine->customCss;\n    }\n\n    public function create(?ImageDto $icon)\n    {\n        $this->icon = $icon;\n    }\n\n    public function createBanner(ImageDto $banner): void\n    {\n        $this->banner = $banner;\n    }\n}\n"
  },
  {
    "path": "src/DTO/MagazineThemeRequestDto.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\DTO;\n\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Component\\Serializer\\Annotation\\Groups;\n\n#[OA\\Schema()]\nclass MagazineThemeRequestDto extends ImageUploadDto\n{\n    #[Groups(['common'])]\n    public ?string $customCss = null;\n    // Currently not used\n    // #[Groups(['common'])]\n    // public ?string $customJs = null;\n    // #[Groups(['common'])]\n    // public ?string $primaryColor = null;\n    // #[Groups(['common'])]\n    // public ?string $primaryDarkerColor = null;\n    #[Groups(['common'])]\n    #[OA\\Property(enum: ['shape1', 'shape2'])]\n    public ?string $backgroundImage = null;\n\n    public function mergeIntoDto(MagazineThemeDto $dto): MagazineThemeDto\n    {\n        $dto->customCss = $this->customCss ?? $dto->customCss;\n        $dto->backgroundImage = $this->backgroundImage ?? $dto->backgroundImage;\n\n        return $dto;\n    }\n}\n"
  },
  {
    "path": "src/DTO/MagazineThemeResponseDto.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\DTO;\n\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse OpenApi\\Attributes as OA;\n\n#[OA\\Schema()]\nclass MagazineThemeResponseDto implements \\JsonSerializable\n{\n    // Weird. Swagger thinks this is a magazine unless I specify the model in this annotation\n    #[OA\\Property(ref: new Model(type: MagazineSmallResponseDto::class))]\n    public ?MagazineSmallResponseDto $magazine = null;\n\n    public ?string $customCss = null;\n    public ?ImageDto $icon = null;\n    public ?ImageDto $banner;\n\n    public static function create(MagazineDto $magazine, ?string $customCss = null, ?ImageDto $icon = null, ?ImageDto $banner = null): self\n    {\n        $dto = new MagazineThemeResponseDto();\n        $dto->magazine = new MagazineSmallResponseDto($magazine);\n        $dto->customCss = $customCss;\n        $dto->icon = $icon;\n        $dto->banner = $banner;\n\n        return $dto;\n    }\n\n    public function jsonSerialize(): mixed\n    {\n        return [\n            'magazine' => $this->magazine->jsonSerialize(),\n            'customCss' => $this->customCss,\n            'icon' => $this->icon?->jsonSerialize(),\n            'banner' => $this->banner?->jsonSerialize(),\n        ];\n    }\n}\n"
  },
  {
    "path": "src/DTO/MagazineUpdateRequestDto.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\DTO;\n\nuse App\\Repository\\ImageRepository;\nuse OpenApi\\Attributes as OA;\n\n#[OA\\Schema()]\nclass MagazineUpdateRequestDto\n{\n    public ?int $iconId = null;\n    public ?string $title = null;\n    public ?string $description = null;\n    #[OA\\Property(description: 'This field is deprecated. Only changing existing rules is supported. Adding rules to a magazine that did not have any previously will throw a BadRequestException.', deprecated: true)]\n    public ?string $rules = null;\n    public ?bool $isAdult = null;\n    public ?bool $isPostingRestrictedToMods = null;\n    public ?bool $discoverable = null;\n    public ?bool $indexable = null;\n\n    public function mergeIntoDto(MagazineDto $dto, ImageRepository $imageRepository): MagazineDto\n    {\n        $dto->icon = null !== $this->iconId ? $imageRepository->find($this->iconId) : $dto->icon;\n        $dto->title = $this->title ?? $dto->title;\n        $dto->description = $this->description ?? $dto->description;\n        $dto->rules = $this->rules ?? $dto->rules;\n        $dto->isAdult = null === $this->isAdult ? $this->isAdult : $dto->isAdult;\n        $dto->isPostingRestrictedToMods = $this->isPostingRestrictedToMods ?? false;\n        $dto->discoverable = $this->discoverable ?? $dto->discoverable ?? true;\n        $dto->indexable = $this->indexable ?? $dto->indexable ?? true;\n\n        return $dto;\n    }\n}\n"
  },
  {
    "path": "src/DTO/MessageDto.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\DTO;\n\nuse Symfony\\Component\\Validator\\Constraints as Assert;\n\nclass MessageDto\n{\n    #[Assert\\NotBlank]\n    #[Assert\\Length(min: 2, max: 5000, countUnit: Assert\\Length::COUNT_GRAPHEMES)]\n    public ?string $body = null;\n\n    public ?string $apId = null;\n}\n"
  },
  {
    "path": "src/DTO/MessageResponseDto.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\DTO;\n\nuse App\\Entity\\Message;\nuse OpenApi\\Attributes as OA;\n\n#[OA\\Schema()]\nclass MessageResponseDto implements \\JsonSerializable\n{\n    public ?UserSmallResponseDto $sender = null;\n    public ?string $body = null;\n    #[OA\\Property(enum: Message::STATUS_OPTIONS, default: Message::STATUS_NEW)]\n    public ?string $status = null;\n    public ?int $threadId = null;\n    public ?\\DateTimeImmutable $createdAt = null;\n    public ?int $messageId = null;\n\n    // public function __construct(Message $message)\n    // {\n    //     $this->sender = new UserSmallResponseDto($message->sender);\n    //     $this->body = $message->body;\n    //     $this->status = $message->status;\n    //     $this->threadId = $message->thread->getId();\n    //     $this->createdAt = $message->createdAt;\n    //     $this->messageId = $message->getId();\n    // }\n\n    public static function create(UserSmallResponseDto $sender, string $body, string $status, int $threadId, \\DateTimeImmutable $createdAt, int $messageId): self\n    {\n        $dto = new MessageResponseDto();\n        $dto->sender = $sender;\n        $dto->body = $body;\n        $dto->status = $status;\n        $dto->threadId = $threadId;\n        $dto->createdAt = $createdAt;\n        $dto->messageId = $messageId;\n\n        return $dto;\n    }\n\n    public function jsonSerialize(): mixed\n    {\n        return [\n            'messageId' => $this->messageId,\n            'threadId' => $this->threadId,\n            'sender' => $this->sender?->jsonSerialize(),\n            'body' => $this->body,\n            'status' => $this->status,\n            'createdAt' => $this->createdAt->format(\\DateTimeInterface::ATOM),\n        ];\n    }\n}\n"
  },
  {
    "path": "src/DTO/MessageThreadResponseDto.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\DTO;\n\nuse OpenApi\\Attributes as OA;\n\n#[OA\\Schema()]\nclass MessageThreadResponseDto implements \\JsonSerializable\n{\n    #[OA\\Property(type: 'array', description: 'Users in thread', items: new OA\\Items(ref: '#/components/schemas/UserResponseDto'))]\n    public ?array $participants = null;\n    public ?int $messageCount = null;\n    #[OA\\Property(type: 'array', description: 'Messages in thread', items: new OA\\Items(ref: '#/components/schemas/MessageResponseDto'))]\n    public ?array $messages = null;\n    public ?int $threadId = null;\n\n    public static function create(array $participants, int $messageCount, array $messages, int $threadId): self\n    {\n        $dto = new MessageThreadResponseDto();\n        $dto->participants = $participants;\n        $dto->messageCount = $messageCount;\n        $dto->messages = $messages;\n        $dto->threadId = $threadId;\n\n        return $dto;\n    }\n\n    public function jsonSerialize(): mixed\n    {\n        return [\n            'threadId' => $this->threadId,\n            'participants' => $this->participants,\n            'messageCount' => $this->messageCount,\n            'messages' => $this->messages,\n        ];\n    }\n}\n"
  },
  {
    "path": "src/DTO/ModeratorDto.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\DTO;\n\nuse App\\Entity\\Magazine;\nuse App\\Entity\\Moderator;\nuse App\\Entity\\User;\nuse App\\Validator\\Unique;\nuse Symfony\\Component\\Validator\\Constraints as Assert;\n\n#[Unique(Moderator::class, errorPath: 'user', fields: ['magazine', 'user'])]\nclass ModeratorDto\n{\n    public ?Magazine $magazine = null;\n    #[Assert\\NotBlank]\n    public ?User $user = null;\n    public ?User $addedBy = null;\n\n    public function __construct(?Magazine $magazine, ?User $user = null, ?User $addedBy = null)\n    {\n        $this->magazine = $magazine;\n        $this->user = $user;\n        $this->addedBy = $addedBy;\n    }\n}\n"
  },
  {
    "path": "src/DTO/ModeratorResponseDto.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\DTO;\n\nuse OpenApi\\Attributes as OA;\n\n#[OA\\Schema()]\nclass ModeratorResponseDto implements \\JsonSerializable\n{\n    public ?int $magazineId = null;\n    public ?int $userId = null;\n    public ?ImageDto $avatar = null;\n    public ?string $username = null;\n    public ?string $apId = null;\n\n    public static function create(\n        ?int $magazineId = null,\n        ?int $userId = null,\n        ?string $username = null,\n        ?string $apId = null,\n        ?ImageDto $avatar = null,\n    ): self {\n        $dto = new ModeratorResponseDto();\n        $dto->magazineId = $magazineId;\n        $dto->userId = $userId;\n        $dto->avatar = $avatar;\n        $dto->username = $username;\n        $dto->apId = $apId;\n\n        return $dto;\n    }\n\n    public function jsonSerialize(): mixed\n    {\n        return [\n            'magazineId' => $this->magazineId,\n            'userId' => $this->userId,\n            'avatar' => $this->avatar?->jsonSerialize(),\n            'username' => $this->username,\n            'apId' => $this->apId,\n        ];\n    }\n}\n"
  },
  {
    "path": "src/DTO/ModlogFilterDto.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\DTO;\n\nuse App\\Entity\\Magazine;\n\nclass ModlogFilterDto\n{\n    /**\n     * @var string[]\n     */\n    public array $types;\n\n    public ?Magazine $magazine;\n}\n"
  },
  {
    "path": "src/DTO/MonitoringExecutionContextFilterDto.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\DTO;\n\nuse Doctrine\\Common\\Collections\\Criteria;\n\nclass MonitoringExecutionContextFilterDto\n{\n    public ?string $executionType = null;\n\n    public ?string $path = null;\n\n    public ?string $handler = null;\n\n    public ?string $userType = null;\n\n    public ?bool $hasException = null;\n\n    public ?\\DateTimeImmutable $createdFrom = null;\n\n    public ?\\DateTimeImmutable $createdTo = null;\n\n    public ?float $durationMinimum = null;\n\n    /**\n     * @var string 'total'|'mean'\n     */\n    public string $chartOrdering = 'total';\n\n    public function toCriteria(): Criteria\n    {\n        $criteria = new Criteria();\n        if (null !== $this->executionType) {\n            $criteria->andWhere(Criteria::expr()->eq('executionType', $this->executionType));\n        }\n        if (null !== $this->userType) {\n            $criteria->andWhere(Criteria::expr()->eq('userType', $this->userType));\n        }\n        if (null !== $this->path) {\n            $criteria->andWhere(Criteria::expr()->eq('path', $this->path));\n        }\n        if (null !== $this->handler) {\n            $criteria->andWhere(Criteria::expr()->eq('handler', $this->handler));\n        }\n        if (null !== $this->hasException) {\n            if ($this->hasException) {\n                $criteria->andWhere(Criteria::expr()->isNotNull('exception'));\n            } else {\n                $criteria->andWhere(Criteria::expr()->isNull('exception'));\n            }\n        }\n        if (null !== $this->durationMinimum) {\n            $criteria->andWhere(Criteria::expr()->gt('durationMilliseconds', $this->durationMinimum));\n        }\n        if (null !== $this->createdFrom) {\n            $criteria->andWhere(Criteria::expr()->gt('createdAt', $this->createdFrom));\n        }\n        if (null !== $this->createdTo) {\n            $criteria->andWhere(Criteria::expr()->lt('createdAt', $this->createdTo));\n        }\n\n        return $criteria;\n    }\n\n    /**\n     * @return array{whereConditions: string[], parameters: array<string,string>}\n     */\n    public function toSqlWheres(): array\n    {\n        $criteria = [];\n        $parameters = [];\n        if (null !== $this->executionType) {\n            $criteria[] = 'execution_type = :executionType';\n            $parameters[':executionType'] = $this->executionType;\n        }\n        if (null !== $this->userType) {\n            $criteria[] = 'user_type = :userType';\n            $parameters[':userType'] = $this->userType;\n        }\n        if (null !== $this->path) {\n            $criteria[] = 'path = :path';\n            $parameters['path'] = $this->path;\n        }\n        if (null !== $this->handler) {\n            $criteria[] = 'handler = :handler';\n            $parameters[':handler'] = $this->handler;\n        }\n        if (null !== $this->hasException) {\n            if ($this->hasException) {\n                $criteria[] = 'exception IS NOT NULL';\n            } else {\n                $criteria[] = 'exception IS NULL';\n            }\n        }\n        if (null !== $this->durationMinimum) {\n            $criteria[] = 'duration_milliseconds > :durationMin';\n            $parameters[':durationMin'] = $this->durationMinimum;\n        }\n        if (null !== $this->createdFrom) {\n            $criteria[] = 'created_at > :createdFrom';\n            $parameters['createdFrom'] = $this->createdFrom->format(DATE_ATOM);\n        }\n        if (null !== $this->createdTo) {\n            $criteria[] = 'created_at < :createdTo';\n            $parameters['createdTo'] = $this->createdTo->format(DATE_ATOM);\n        }\n\n        return [\n            'whereConditions' => $criteria,\n            'parameters' => $parameters,\n        ];\n    }\n}\n"
  },
  {
    "path": "src/DTO/NotificationPushSubscriptionRequestDto.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\DTO;\n\nuse OpenApi\\Attributes as OA;\n\nclass NotificationPushSubscriptionRequestDto\n{\n    #[OA\\Property(description: \"The URL of the push endpoint messages will be sent to, normally you'll get this address when you register your application on a push service\")]\n    public string $endpoint;\n    #[OA\\Property(description: \"On web push this would be called the 'auth' key, which is used to authenticate the server to the push service. According to https://web-push-book.gauntface.com/web-push-protocol/ this is a 'just' a 'secret'\")]\n    public string $serverKey;\n    #[OA\\Property(description: 'The public key of your key pair (client public key), which is used to encrypt the content. This should be a ECDH, p256 key')]\n    public string $contentPublicKey;\n}\n"
  },
  {
    "path": "src/DTO/OAuth2ClientDto.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\DTO;\n\nuse App\\Entity\\OAuth2UserConsent;\nuse App\\Utils\\RegPatterns;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Component\\Serializer\\Annotation\\Groups;\nuse Symfony\\Component\\Validator\\Constraints as Assert;\nuse Symfony\\Component\\Validator\\Context\\ExecutionContextInterface;\n\n#[OA\\Schema()]\nclass OAuth2ClientDto extends ImageUploadDto implements \\JsonSerializable\n{\n    public const AVAILABLE_GRANTS = [\n        'client_credentials',\n        'authorization_code',\n        'refresh_token',\n    ];\n\n    public const AVAILABLE_SCOPES = [\n        'read',\n        'write',\n        'delete',\n        'subscribe',\n        'block',\n        'vote',\n        'report',\n        'domain',\n        'domain:subscribe',\n        'domain:block',\n        'entry',\n        'entry:create',\n        'entry:edit',\n        'entry:delete',\n        'entry:vote',\n        'entry:report',\n        'entry_comment',\n        'entry_comment:create',\n        'entry_comment:edit',\n        'entry_comment:delete',\n        'entry_comment:vote',\n        'entry_comment:report',\n        'magazine',\n        'magazine:subscribe',\n        'magazine:block',\n        'post',\n        'post:create',\n        'post:edit',\n        'post:delete',\n        'post:vote',\n        'post:report',\n        'post_comment',\n        'post_comment:create',\n        'post_comment:edit',\n        'post_comment:delete',\n        'post_comment:vote',\n        'post_comment:report',\n        'user',\n        'user:profile',\n        'user:profile:read',\n        'user:profile:edit',\n        'bookmark',\n        'bookmark:add',\n        'bookmark:remove',\n        'bookmark_list',\n        'bookmark_list:read',\n        'bookmark_list:edit',\n        'bookmark_list:delete',\n        'user:message',\n        'user:message:read',\n        'user:message:create',\n        'user:notification',\n        'user:notification:read',\n        'user:notification:delete',\n        'user:notification:edit',\n        'user:oauth_clients',\n        'user:oauth_clients:read',\n        'user:oauth_clients:edit',\n        'user:follow',\n        'user:block',\n        'moderate',\n        'moderate:entry',\n        'moderate:entry:language',\n        'moderate:entry:pin',\n        'moderate:entry:lock',\n        'moderate:entry:set_adult',\n        'moderate:entry:trash',\n        'moderate:entry_comment',\n        'moderate:entry_comment:language',\n        'moderate:entry_comment:set_adult',\n        'moderate:entry_comment:trash',\n        'moderate:post',\n        'moderate:post:language',\n        'moderate:post:pin',\n        'moderate:post:lock',\n        'moderate:post:set_adult',\n        'moderate:post:trash',\n        'moderate:post_comment',\n        'moderate:post_comment:language',\n        'moderate:post_comment:set_adult',\n        'moderate:post_comment:trash',\n        'moderate:magazine',\n        'moderate:magazine:ban',\n        'moderate:magazine:ban:read',\n        'moderate:magazine:ban:create',\n        'moderate:magazine:ban:delete',\n        'moderate:magazine:list',\n        'moderate:magazine:reports',\n        'moderate:magazine:reports:read',\n        'moderate:magazine:reports:action',\n        'moderate:magazine:trash:read',\n        'moderate:magazine_admin',\n        'moderate:magazine_admin:create',\n        'moderate:magazine_admin:delete',\n        'moderate:magazine_admin:update',\n        'moderate:magazine_admin:theme',\n        'moderate:magazine_admin:moderators',\n        'moderate:magazine_admin:badges',\n        'moderate:magazine_admin:tags',\n        'moderate:magazine_admin:stats',\n        'admin',\n        'admin:entry:purge',\n        'admin:entry_comment:purge',\n        'admin:post:purge',\n        'admin:post_comment:purge',\n        'admin:magazine',\n        'admin:magazine:move_entry',\n        'admin:magazine:purge',\n        'admin:magazine:moderate',\n        'admin:user',\n        'admin:user:ban',\n        'admin:user:verify',\n        'admin:user:delete',\n        'admin:user:purge',\n        'admin:instance',\n        'admin:instance:stats',\n        'admin:instance:settings',\n        'admin:instance:settings:read',\n        'admin:instance:settings:edit',\n        'admin:instance:information:edit',\n        'admin:federation',\n        'admin:federation:read',\n        'admin:federation:update',\n        'admin:oauth_clients',\n        'admin:oauth_clients:read',\n        'admin:oauth_clients:revoke',\n    ];\n\n    #[Assert\\NotBlank(groups: ['deleting'])]\n    #[Groups(['created', 'deleting'])]\n    public ?string $identifier = null;\n    #[Assert\\NotBlank(groups: ['deleting'])]\n    #[Groups(['created', 'deleting'])]\n    public ?string $secret = null;\n    #[Assert\\NotBlank(groups: ['creating'])]\n    #[Groups(['creating', 'created'])]\n    #[OA\\Property(nullable: false)]\n    public ?string $name = null;\n    #[Assert\\NotBlank(groups: ['creating'])]\n    #[Assert\\Email]\n    #[Groups(['creating', 'created'])]\n    #[OA\\Property(nullable: false)]\n    public ?string $contactEmail = null;\n    #[Groups(['creating', 'created'])]\n    public ?string $description = null;\n    #[Groups(['creating'])]\n    #[OA\\Property(description: 'Native applications installed on user devices and web apps are considered public since they cannot store secrets securely, so they should use PKCE. https://www.oauth.com/oauth2-servers/pkce/')]\n    public ?bool $public = null;\n    #[Groups(['creating'])]\n    #[Assert\\Regex(pattern: RegPatterns::USERNAME, match: true, groups: ['client_credentials'])]\n    #[OA\\Property(description: 'Required if using the client_credentials grant type. Will attempt to create a bot user with the given username.')]\n    public ?string $username = null;\n    #[Groups(['created'])]\n    public ?UserSmallResponseDto $user = null;\n    #[OA\\Property(type: 'array', items: new OA\\Items(type: 'string'), example: ['http://localhost:3000/redirect'])]\n    #[Groups(['creating', 'created'])]\n    public array $redirectUris = [];\n    #[Assert\\NotBlank(groups: ['creating'])]\n    #[OA\\Property(type: 'array', items: new OA\\Items(type: 'string', enum: self::AVAILABLE_GRANTS), minItems: 1, example: ['authorization_code', 'refresh_token'])]\n    #[Groups(['creating', 'created'])]\n    public array $grants = [];\n    #[Assert\\NotBlank(groups: ['creating'])]\n    #[OA\\Property(type: 'array', items: new OA\\Items(type: 'string', enum: self::AVAILABLE_SCOPES), minItems: 1, example: ['read'])]\n    #[Groups(['creating', 'created'])]\n    public array $scopes = ['read'];\n    #[Groups(['created'])]\n    public ?ImageDto $image = null;\n\n    #[Assert\\Callback]\n    public function validate(\n        ExecutionContextInterface $context,\n        $payload,\n    ) {\n        $validUris = array_filter($this->redirectUris, fn (string $uri) => filter_var($uri, FILTER_VALIDATE_URL) && !parse_url($uri, PHP_URL_QUERY));\n        $invalidUris = array_diff($this->redirectUris, $validUris);\n        foreach ($invalidUris as $invalid) {\n            $context->buildViolation('Invalid redirect uri \"'.$invalid.'\"'.(parse_url($invalid, PHP_URL_QUERY) ? ' - the query must be empty' : ''))\n                ->atPath('redirectUris')\n                ->addViolation();\n        }\n\n        $validScopes = array_filter($this->scopes, fn (string $scope) => \\array_key_exists($scope, OAuth2UserConsent::SCOPE_DESCRIPTIONS));\n        $invalidScopes = array_diff($this->scopes, $validScopes);\n        foreach ($invalidScopes as $invalid) {\n            $context->buildViolation('Invalid scope \"'.$invalid.'\"')\n                ->atPath('scopes')\n                ->addViolation();\n        }\n\n        $validGrants = array_filter($this->grants, fn (string $grant) => false !== array_search($grant, ['client_credentials', 'authorization_code', 'refresh_token']));\n        $invalidGrants = array_diff($this->grants, $validGrants);\n        foreach ($invalidGrants as $invalid) {\n            $context->buildViolation('Invalid grant \"'.$invalid.'\"')\n                ->atPath('grants')\n                ->addViolation();\n        }\n\n        if (false !== array_search('client_credentials', $validGrants) && null === $this->username) {\n            $context->buildViolation('client_credentials grant type requires a username for the bot user representing your application.')\n                ->atPath('username')\n                ->addViolation();\n        }\n\n        if (false !== array_search('client_credentials', $validGrants) && $this->public) {\n            $context->buildViolation('client_credentials grant type requires a confidential client.')\n                ->atPath('username')\n                ->addViolation();\n        }\n    }\n\n    public static function create(string $identifier, ?string $secret, string $name, ?UserSmallResponseDto $user = null, ?string $contactEmail = null, ?string $description = null, array $redirectUris = [], array $grants = [], array $scopes = ['read'], ?ImageDto $image = null): OAuth2ClientDto\n    {\n        $dto = new OAuth2ClientDto();\n        $dto->identifier = $identifier;\n        $dto->secret = $secret;\n        $dto->name = $name;\n        $dto->user = $user;\n        $dto->contactEmail = $contactEmail;\n        $dto->description = $description;\n        $dto->redirectUris = $redirectUris;\n        $dto->grants = $grants;\n        $dto->scopes = $scopes;\n        $dto->image = $image;\n\n        return $dto;\n    }\n\n    public function jsonSerialize(): mixed\n    {\n        return [\n            'identifier' => $this->identifier,\n            'secret' => $this->secret,\n            'name' => $this->name,\n            'contactEmail' => $this->contactEmail,\n            'description' => $this->description,\n            'user' => $this->user?->jsonSerialize(),\n            'redirectUris' => $this->redirectUris,\n            'grants' => $this->grants,\n            'scopes' => $this->scopes,\n            'image' => $this->image?->jsonSerialize(),\n        ];\n    }\n}\n"
  },
  {
    "path": "src/DTO/PageDto.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\DTO;\n\nclass PageDto\n{\n    public string $body;\n\n    public function create(string $body): self\n    {\n        $this->body = $body;\n\n        return $this;\n    }\n}\n"
  },
  {
    "path": "src/DTO/PostCommentDto.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\DTO;\n\nuse App\\Entity\\Contracts\\ContentVisibilityInterface;\nuse App\\Entity\\Contracts\\VisibilityInterface;\nuse App\\Entity\\Magazine;\nuse App\\Entity\\Post;\nuse App\\Entity\\PostComment;\nuse App\\Entity\\User;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\Validator\\Constraints as Assert;\nuse Symfony\\Component\\Validator\\Context\\ExecutionContextInterface;\n\nclass PostCommentDto implements ContentVisibilityInterface\n{\n    public const MAX_BODY_LENGTH = 5000;\n\n    public Magazine|MagazineDto|null $magazine = null;\n    public User|UserDto|null $user = null;\n    public Post|PostDto|null $post = null;\n    public ?PostComment $parent = null;\n    public ?PostComment $root = null;\n    public ?ImageDto $image = null;\n    public ?string $imageUrl = null;\n    public ?string $imageAlt = null;\n    #[Assert\\Length(max: self::MAX_BODY_LENGTH, countUnit: Assert\\Length::COUNT_GRAPHEMES)]\n    public ?string $body = null;\n    public ?string $lang = null;\n    public bool $isAdult = false;\n    public int $uv = 0;\n    public int $dv = 0;\n    public int $favourites = 0;\n    public ?bool $isFavourited = null;\n    public ?int $userVote = null;\n    public ?string $visibility = VisibilityInterface::VISIBILITY_VISIBLE;\n    public ?string $ip = null;\n    public ?string $apId = null;\n    public ?int $apLikeCount = null;\n    public ?int $apDislikeCount = null;\n    public ?int $apShareCount = null;\n    public ?array $mentions = null;\n    public ?\\DateTimeImmutable $createdAt = null;\n    public ?\\DateTimeImmutable $editedAt = null;\n    public ?\\DateTime $lastActive = null;\n    private ?int $id = null;\n\n    #[Assert\\Callback]\n    public function validate(\n        ExecutionContextInterface $context,\n        $payload,\n    ) {\n        if (empty($this->image)) {\n            $image = Request::createFromGlobals()->files->filter('post_comment');\n\n            if (\\is_array($image) && isset($image['image'])) {\n                $image = $image['image'];\n            } else {\n                $image = $context->getValue()->image;\n            }\n        } else {\n            $image = $this->image;\n        }\n\n        if (empty($this->body) && empty($image)) {\n            $this->buildViolation($context, 'body');\n        }\n    }\n\n    private function buildViolation(ExecutionContextInterface $context, $path)\n    {\n        $context->buildViolation('This value should not be blank.')\n            ->atPath($path)\n            ->addViolation();\n    }\n\n    public function createWithParent(Post $post, ?PostComment $parent, ?ImageDto $image = null, ?string $body = null): self\n    {\n        $this->post = $post;\n        $this->parent = $parent;\n        $this->body = $body;\n        $this->image = $image;\n\n        if ($parent) {\n            $this->root = $parent->root ?? $parent;\n        }\n\n        return $this;\n    }\n\n    public function getId(): ?int\n    {\n        return $this->id;\n    }\n\n    public function getApId(): ?string\n    {\n        return $this->apId;\n    }\n\n    public function setId(int $id): void\n    {\n        $this->id = $id;\n    }\n\n    public function getVisibility(): string\n    {\n        return $this->visibility;\n    }\n\n    public function isPrivate(): bool\n    {\n        return VisibilityInterface::VISIBILITY_PRIVATE === $this->visibility;\n    }\n\n    public function isSoftDeleted(): bool\n    {\n        return VisibilityInterface::VISIBILITY_SOFT_DELETED === $this->visibility;\n    }\n\n    public function isTrashed(): bool\n    {\n        return VisibilityInterface::VISIBILITY_TRASHED === $this->visibility;\n    }\n\n    public function isVisible(): bool\n    {\n        return VisibilityInterface::VISIBILITY_VISIBLE === $this->visibility;\n    }\n\n    public function getMagazine(): ?Magazine\n    {\n        return $this->magazine;\n    }\n\n    public function getUser(): ?User\n    {\n        return $this->user;\n    }\n}\n"
  },
  {
    "path": "src/DTO/PostCommentRequestDto.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\DTO;\n\nuse App\\Service\\SettingsManager;\nuse OpenApi\\Attributes as OA;\n\n#[OA\\Schema()]\nclass PostCommentRequestDto extends ContentRequestDto\n{\n    public function mergeIntoDto(PostCommentDto $dto, SettingsManager $settingsManager): PostCommentDto\n    {\n        $dto->image = $this->image ?? $dto->image;\n        $dto->body = $this->body ?? $dto->body;\n        $dto->lang = $this->lang ?? $dto->lang ?? $settingsManager->getValue('KBIN_DEFAULT_LANG');\n        $dto->isAdult = $this->isAdult ?? $dto->isAdult;\n\n        return $dto;\n    }\n}\n"
  },
  {
    "path": "src/DTO/PostCommentResponseDto.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\DTO;\n\nuse App\\DTO\\Contracts\\VisibilityAwareDtoTrait;\nuse App\\Entity\\Post;\nuse App\\Entity\\PostComment;\nuse OpenApi\\Attributes as OA;\n\n#[OA\\Schema()]\nclass PostCommentResponseDto implements \\JsonSerializable\n{\n    use VisibilityAwareDtoTrait;\n\n    public int $commentId;\n    public ?UserSmallResponseDto $user = null;\n    public ?MagazineSmallResponseDto $magazine = null;\n    public ?int $postId = null;\n    public ?int $parentId = null;\n    public ?int $rootId = null;\n    public ?ImageDto $image = null;\n    public ?string $body = null;\n    #[OA\\Property(example: 'en', nullable: true)]\n    public ?string $lang = null;\n    public bool $isAdult = false;\n    public ?int $uv = 0;\n    public ?int $dv = 0;\n    public ?int $favourites = 0;\n    public ?bool $isFavourited = null;\n    public ?int $userVote = null;\n    public ?string $apId = null;\n    #[OA\\Property(type: 'array', items: new OA\\Items(type: 'string'))]\n    public ?array $mentions = null;\n    #[OA\\Property(type: 'array', items: new OA\\Items(type: 'string'))]\n    public ?array $tags = null;\n    public ?\\DateTimeImmutable $createdAt = null;\n    public ?\\DateTimeImmutable $editedAt = null;\n    public ?\\DateTime $lastActive = null;\n    public int $childCount = 0;\n    #[OA\\Property(\n        type: 'array',\n        description: 'Array of comments',\n        items: new OA\\Items(\n            ref: '#/components/schemas/PostCommentResponseDto'\n        ),\n        example: [\n            [\n                'commentId' => 0,\n                'userId' => 0,\n                'magazineId' => 0,\n                'postId' => 0,\n                'parentId' => 0,\n                'rootId' => 0,\n                'image' => [\n                    'filePath' => 'x/y/z.png',\n                    'width' => 3000,\n                    'height' => 4000,\n                ],\n                'body' => 'comment body',\n                'lang' => 'en',\n                'isAdult' => false,\n                'uv' => 0,\n                'dv' => 0,\n                'favourites' => 0,\n                'visibility' => 'visible',\n                'apId' => 'string',\n                'mentions' => [\n                    '@user@instance',\n                ],\n                'tags' => [\n                    'sometag',\n                ],\n                'createdAt' => '2023-06-18 11:59:41+00:00',\n                'lastActive' => '2023-06-18 12:00:45+00:00',\n                'childCount' => 0,\n                'children' => [],\n            ],\n        ]\n    )]\n    public array $children = [];\n    public ?bool $canAuthUserModerate = null;\n    public ?bool $isAuthorModeratorInMagazine = null;\n\n    /** @var string[]|null */\n    #[OA\\Property(type: 'array', items: new OA\\Items(type: 'string'))]\n    public ?array $bookmarks = null;\n\n    /**\n     * @param string[] $bookmarks\n     */\n    public static function create(\n        int $id,\n        ?UserSmallResponseDto $user = null,\n        ?MagazineSmallResponseDto $magazine = null,\n        ?Post $post = null,\n        ?PostComment $parent = null,\n        int $childCount = 0,\n        ?ImageDto $image = null,\n        ?string $body = null,\n        ?string $lang = null,\n        ?bool $isAdult = null,\n        ?int $uv = null,\n        ?int $dv = null,\n        ?int $favourites = null,\n        ?string $visibility = null,\n        ?string $apId = null,\n        ?array $mentions = null,\n        ?array $tags = null,\n        ?\\DateTimeImmutable $createdAt = null,\n        ?\\DateTimeImmutable $editedAt = null,\n        ?\\DateTime $lastActive = null,\n        ?bool $canAuthUserModerate = null,\n        ?array $bookmarks = null,\n        ?bool $isAuthorModeratorInMagazine = null,\n    ): self {\n        $dto = new PostCommentResponseDto();\n        $dto->commentId = $id;\n        $dto->user = $user;\n        $dto->magazine = $magazine;\n        $dto->postId = $post->getId();\n        $dto->parentId = $parent ? $parent->getId() : null;\n        $dto->rootId = $parent ? ($parent->root ? $parent->root->getId() : $parent->getId()) : null;\n        $dto->image = $image;\n        $dto->body = $body;\n        $dto->lang = $lang;\n        $dto->isAdult = $isAdult;\n        $dto->uv = $uv;\n        $dto->dv = $dv;\n        $dto->favourites = $favourites;\n        $dto->visibility = $visibility;\n        $dto->apId = $apId;\n        $dto->mentions = $mentions;\n        $dto->tags = $tags;\n        $dto->createdAt = $createdAt;\n        $dto->editedAt = $editedAt;\n        $dto->lastActive = $lastActive;\n        $dto->childCount = $childCount;\n        $dto->canAuthUserModerate = $canAuthUserModerate;\n        $dto->bookmarks = $bookmarks;\n        $dto->isAuthorModeratorInMagazine = $isAuthorModeratorInMagazine;\n\n        return $dto;\n    }\n\n    public function jsonSerialize(): mixed\n    {\n        if (null === self::$keysToDelete) {\n            self::$keysToDelete = [\n                'image',\n                'body',\n                'tags',\n                'uv',\n                'dv',\n                'favourites',\n                'isFavourited',\n                'userVote',\n                'slug',\n                'mentions',\n            ];\n        }\n\n        return $this->handleDeletion([\n            'commentId' => $this->commentId,\n            'user' => $this->user,\n            'magazine' => $this->magazine,\n            'postId' => $this->postId,\n            'parentId' => $this->parentId,\n            'rootId' => $this->rootId,\n            'image' => $this->image,\n            'body' => $this->body,\n            'lang' => $this->lang,\n            'isAdult' => $this->isAdult,\n            'uv' => $this->uv,\n            'dv' => $this->dv,\n            'favourites' => $this->favourites,\n            'isFavourited' => $this->isFavourited,\n            'userVote' => $this->userVote,\n            'visibility' => $this->visibility,\n            'apId' => $this->apId,\n            'mentions' => $this->mentions,\n            'tags' => $this->tags,\n            'createdAt' => $this->createdAt->format(\\DateTimeInterface::ATOM),\n            'editedAt' => $this->editedAt?->format(\\DateTimeInterface::ATOM),\n            'lastActive' => $this->lastActive?->format(\\DateTimeInterface::ATOM),\n            'childCount' => $this->childCount,\n            'children' => $this->children,\n            'canAuthUserModerate' => $this->canAuthUserModerate,\n            'bookmarks' => $this->bookmarks,\n            'isAuthorModeratorInMagazine' => $this->isAuthorModeratorInMagazine,\n        ]);\n    }\n\n    public static function recursiveChildCount(int $initial, PostComment $child): int\n    {\n        return 1 + array_reduce($child->children->toArray(), self::class.'::recursiveChildCount', $initial);\n    }\n}\n"
  },
  {
    "path": "src/DTO/PostDto.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\DTO;\n\nuse App\\Entity\\Contracts\\ContentVisibilityInterface;\nuse App\\Entity\\Contracts\\VisibilityInterface;\nuse App\\Entity\\Magazine;\nuse App\\Entity\\User;\nuse Doctrine\\Common\\Collections\\Collection;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\Validator\\Constraints as Assert;\nuse Symfony\\Component\\Validator\\Context\\ExecutionContextInterface;\n\nclass PostDto implements ContentVisibilityInterface\n{\n    public const MAX_BODY_LENGTH = 5000;\n\n    public Magazine|MagazineDto|null $magazine = null;\n    public User|UserDto|null $user = null;\n    public ?ImageDto $image = null;\n    public ?string $imageUrl = null;\n    public ?string $imageAlt = null;\n    #[Assert\\Length(max: self::MAX_BODY_LENGTH, countUnit: Assert\\Length::COUNT_GRAPHEMES)]\n    public ?string $body = null;\n    public ?string $lang = null;\n    public bool $isAdult = false;\n    public bool $isPinned = false;\n    public bool $isLocked = false;\n    public ?string $slug = null;\n    public int $comments = 0;\n    public int $uv = 0;\n    public int $dv = 0;\n    public int $favouriteCount = 0;\n    public ?bool $isFavourited = null;\n    public ?int $userVote = null;\n    public ?string $visibility = VisibilityInterface::VISIBILITY_VISIBLE;\n    public ?string $ip = null;\n    public ?array $mentions = null;\n    public ?string $apId = null;\n    public ?int $apLikeCount = null;\n    public ?int $apDislikeCount = null;\n    public ?int $apShareCount = null;\n    public ?\\DateTimeImmutable $createdAt = null;\n    public ?\\DateTimeImmutable $editedAt = null;\n    public ?\\DateTime $lastActive = null;\n    public ?Collection $bestComments = null;\n    private ?int $id = null;\n\n    #[Assert\\Callback]\n    public function validate(\n        ExecutionContextInterface $context,\n        $payload,\n    ) {\n        if (empty($this->image)) {\n            $image = Request::createFromGlobals()->files->filter('post');\n\n            if (\\is_array($image) && isset($image['image'])) {\n                $image = $image['image'];\n            } else {\n                $image = $context->getValue()->image;\n            }\n        } else {\n            $image = $this->image;\n        }\n\n        if (empty($this->body) && empty($image)) {\n            $this->buildViolation($context, 'body');\n        }\n    }\n\n    private function buildViolation(ExecutionContextInterface $context, $path)\n    {\n        $context->buildViolation('This value should not be blank.')\n            ->atPath($path)\n            ->addViolation();\n    }\n\n    public function getId(): ?int\n    {\n        return $this->id;\n    }\n\n    public function getApId(): ?string\n    {\n        return $this->apId;\n    }\n\n    public function setId(int $id): void\n    {\n        $this->id = $id;\n    }\n\n    public function getVisibility(): string\n    {\n        return $this->visibility;\n    }\n\n    public function isPrivate(): bool\n    {\n        return VisibilityInterface::VISIBILITY_PRIVATE === $this->visibility;\n    }\n\n    public function isSoftDeleted(): bool\n    {\n        return VisibilityInterface::VISIBILITY_SOFT_DELETED === $this->visibility;\n    }\n\n    public function isTrashed(): bool\n    {\n        return VisibilityInterface::VISIBILITY_TRASHED === $this->visibility;\n    }\n\n    public function isVisible(): bool\n    {\n        return VisibilityInterface::VISIBILITY_VISIBLE === $this->visibility;\n    }\n\n    public function getMagazine(): ?Magazine\n    {\n        return $this->magazine;\n    }\n\n    public function getUser(): ?User\n    {\n        return $this->user;\n    }\n}\n"
  },
  {
    "path": "src/DTO/PostRequestDto.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\DTO;\n\nuse App\\Service\\SettingsManager;\nuse OpenApi\\Attributes as OA;\n\n#[OA\\Schema()]\nclass PostRequestDto extends ContentRequestDto\n{\n    public function mergeIntoDto(PostDto $dto, SettingsManager $settingsManager): PostDto\n    {\n        $dto->body = $this->body ?? $dto->body;\n        $dto->lang = $this->lang ?? $dto->lang ?? $settingsManager->getValue('KBIN_DEFAULT_LANG');\n        $dto->isAdult = $this->isAdult ?? $dto->isAdult;\n\n        return $dto;\n    }\n}\n"
  },
  {
    "path": "src/DTO/PostResponseDto.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\DTO;\n\nuse App\\DTO\\Contracts\\VisibilityAwareDtoTrait;\nuse App\\Enums\\ENotificationStatus;\nuse OpenApi\\Attributes as OA;\n\n#[OA\\Schema()]\nclass PostResponseDto implements \\JsonSerializable\n{\n    use VisibilityAwareDtoTrait;\n\n    public int $postId;\n    public ?UserSmallResponseDto $user = null;\n    public ?MagazineSmallResponseDto $magazine = null;\n    public ?ImageDto $image = null;\n    public ?string $body = null;\n    #[OA\\Property(example: 'en', nullable: true, minLength: 2, maxLength: 3)]\n    public ?string $lang = null;\n    public bool $isAdult = false;\n    public bool $isPinned = false;\n    public bool $isLocked = false;\n    public ?string $slug = null;\n    public int $comments = 0;\n    public ?int $uv = 0;\n    public ?int $dv = 0;\n    public ?int $favourites = 0;\n    public ?bool $isFavourited = null;\n    public ?int $userVote = null;\n    #[OA\\Property(type: 'array', items: new OA\\Items(type: 'string'))]\n    public ?array $tags = null;\n    #[OA\\Property(type: 'array', items: new OA\\Items(type: 'string'))]\n    public ?array $mentions = null;\n    public ?string $apId = null;\n    public ?\\DateTimeImmutable $createdAt = null;\n    public ?\\DateTimeImmutable $editedAt = null;\n    public ?\\DateTime $lastActive = null;\n    public ?bool $canAuthUserModerate = null;\n    public ?ENotificationStatus $notificationStatus = null;\n\n    /** @var string[]|null */\n    #[OA\\Property(type: 'array', items: new OA\\Items(type: 'string'))]\n    public ?array $bookmarks = null;\n    public ?bool $isAuthorModeratorInMagazine = null;\n\n    /**\n     * @param string[] $bookmarks\n     */\n    public static function create(\n        int $id,\n        UserSmallResponseDto $user,\n        MagazineSmallResponseDto $magazine,\n        ?ImageDto $image = null,\n        ?string $body = null,\n        ?string $lang = null,\n        ?bool $isAdult = null,\n        bool $isPinned = false,\n        bool $isLocked = false,\n        ?int $comments = null,\n        ?int $uv = null,\n        ?int $dv = null,\n        ?int $favouriteCount = null,\n        ?string $visibility = null,\n        ?array $tags = null,\n        ?array $mentions = null,\n        ?string $apId = null,\n        ?\\DateTimeImmutable $createdAt = null,\n        ?\\DateTimeImmutable $editedAt = null,\n        ?\\DateTime $lastActive = null,\n        ?string $slug = null,\n        ?bool $canAuthUserModerate = null,\n        ?array $bookmarks = null,\n        ?bool $isAuthorModeratorInMagazine = null,\n    ): self {\n        $dto = new PostResponseDto();\n        $dto->postId = $id;\n        $dto->user = $user;\n        $dto->magazine = $magazine;\n        $dto->image = $image;\n        $dto->body = $body;\n        $dto->lang = $lang;\n        $dto->isAdult = $isAdult;\n        $dto->isPinned = $isPinned;\n        $dto->isLocked = $isLocked;\n        $dto->comments = $comments;\n        $dto->uv = $uv;\n        $dto->dv = $dv;\n        $dto->favourites = $favouriteCount;\n        $dto->visibility = $visibility;\n        $dto->tags = $tags;\n        $dto->mentions = $mentions;\n        $dto->apId = $apId;\n        $dto->createdAt = $createdAt;\n        $dto->editedAt = $editedAt;\n        $dto->lastActive = $lastActive;\n        $dto->slug = $slug;\n        $dto->canAuthUserModerate = $canAuthUserModerate;\n        $dto->bookmarks = $bookmarks;\n        $dto->isAuthorModeratorInMagazine = $isAuthorModeratorInMagazine;\n\n        return $dto;\n    }\n\n    public function jsonSerialize(): mixed\n    {\n        if (null === self::$keysToDelete) {\n            self::$keysToDelete = [\n                'image',\n                'body',\n                'tags',\n                'uv',\n                'dv',\n                'favourites',\n                'isFavourited',\n                'userVote',\n                'slug',\n                'mentions',\n            ];\n        }\n\n        return $this->handleDeletion([\n            'postId' => $this->postId,\n            'user' => $this->user,\n            'magazine' => $this->magazine,\n            'image' => $this->image,\n            'body' => $this->body,\n            'lang' => $this->lang,\n            'isAdult' => $this->isAdult,\n            'isPinned' => $this->isPinned,\n            'isLocked' => $this->isLocked,\n            'comments' => $this->comments,\n            'uv' => $this->uv,\n            'dv' => $this->dv,\n            'favourites' => $this->favourites,\n            'isFavourited' => $this->isFavourited,\n            'userVote' => $this->userVote,\n            'visibility' => $this->visibility,\n            'apId' => $this->apId,\n            'tags' => $this->tags,\n            'mentions' => $this->mentions,\n            'createdAt' => $this->createdAt->format(\\DateTimeInterface::ATOM),\n            'editedAt' => $this->editedAt?->format(\\DateTimeInterface::ATOM),\n            'lastActive' => $this->lastActive?->format(\\DateTimeInterface::ATOM),\n            'slug' => $this->slug,\n            'canAuthUserModerate' => $this->canAuthUserModerate,\n            'notificationStatus' => $this->notificationStatus,\n            'bookmarks' => $this->bookmarks,\n            'isAuthorModeratorInMagazine' => $this->isAuthorModeratorInMagazine,\n        ]);\n    }\n}\n"
  },
  {
    "path": "src/DTO/RemoteInstanceDto.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\DTO;\n\nuse App\\Entity\\Instance;\nuse OpenApi\\Attributes as OA;\n\n#[OA\\Schema]\nclass RemoteInstanceDto implements \\JsonSerializable\n{\n    public function __construct(\n        public string $domain,\n        public int $id,\n        public int $magazines,\n        public int $users,\n        public ?string $software = null,\n        public ?string $version = null,\n        public ?\\DateTimeImmutable $lastSuccessfulDeliver = null,\n        public ?\\DateTimeImmutable $lastFailedDeliver = null,\n        public ?\\DateTimeImmutable $lastSuccessfulReceive = null,\n        public int $failedDelivers = 0,\n        public bool $isBanned = false,\n        public bool $isExplicitlyAllowed = false,\n        #[OA\\Property(description: 'Amount of users from our instance following users on their instance')]\n        public int $ourUserFollows,\n        #[OA\\Property(description: 'Amount of users from their instance following users on our instance')]\n        public int $theirUserFollows,\n        #[OA\\Property(description: 'Amount of users on our instance subscribed to magazines from their instance')]\n        public int $ourSubscriptions,\n        #[OA\\Property(description: 'Amount of users from their instance subscribed to magazines on our instance')]\n        public int $theirSubscriptions,\n    ) {\n    }\n\n    /**\n     * @param array{magazines: int, users: int, theirUserFollows: int, ourUserFollows: int, theirSubscriptions: int, ourSubscriptions: int} $instanceCounts\n     */\n    public static function create(Instance $instance, array $instanceCounts): RemoteInstanceDto\n    {\n        return new self(\n            $instance->domain,\n            $instance->getId(),\n            $instanceCounts['magazines'],\n            $instanceCounts['users'],\n            $instance->software,\n            $instance->version,\n            $instance->getLastSuccessfulDeliver(),\n            $instance->getLastFailedDeliver(),\n            $instance->getLastSuccessfulReceive(),\n            $instance->getFailedDelivers(),\n            $instance->isBanned,\n            $instance->isExplicitlyAllowed,\n            $instanceCounts['ourUserFollows'],\n            $instanceCounts['theirUserFollows'],\n            $instanceCounts['ourSubscriptions'],\n            $instanceCounts['theirSubscriptions'],\n        );\n    }\n\n    public function jsonSerialize(): mixed\n    {\n        return [\n            'software' => $this->software,\n            'version' => $this->version,\n            'domain' => $this->domain,\n            'lastSuccessfulDeliver' => $this->lastSuccessfulDeliver,\n            'lastFailedDeliver' => $this->lastFailedDeliver,\n            'lastSuccessfulReceive' => $this->lastSuccessfulReceive,\n            'failedDelivers' => $this->failedDelivers,\n            'isBanned' => $this->isBanned,\n            'isExplicitlyAllowed' => $this->isExplicitlyAllowed,\n            'id' => $this->id,\n            'magazines' => $this->magazines,\n            'users' => $this->users,\n            'ourUserFollows' => $this->ourUserFollows,\n            'theirUserFollows' => $this->theirUserFollows,\n            'ourSubscriptions' => $this->ourSubscriptions,\n            'theirSubscriptions' => $this->theirSubscriptions,\n        ];\n    }\n}\n"
  },
  {
    "path": "src/DTO/ReportDto.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\DTO;\n\nuse App\\Entity\\Contracts\\ReportInterface;\nuse App\\Entity\\Entry;\nuse App\\Entity\\EntryComment;\nuse App\\Entity\\Magazine;\nuse App\\Entity\\Post;\nuse App\\Entity\\PostComment;\nuse App\\Entity\\User;\n\nclass ReportDto\n{\n    public ?Magazine $magazine = null;\n    public ?User $reported = null;\n    public ?ReportInterface $subject = null;\n    public ?string $reason = null;\n    private ?int $id = null;\n\n    public static function create(ReportInterface $subject, ?string $reason = null, ?int $id = null): self\n    {\n        $dto = new ReportDto();\n        $dto->id = $id;\n        $dto->subject = $subject;\n        $dto->reason = $reason;\n\n        $dto->magazine = $subject->magazine;\n        $dto->reported = $subject->user;\n\n        return $dto;\n    }\n\n    public function getId(): ?int\n    {\n        return $this->id;\n    }\n\n    public function getRouteName(): string\n    {\n        switch (\\get_class($this->getSubject())) {\n            case Entry::class:\n                return 'entry_report';\n            case EntryComment::class:\n                return 'entry_comment_report';\n            case Post::class:\n                return 'post_report';\n            case PostComment::class:\n                return 'post_comment_report';\n        }\n\n        throw new \\LogicException();\n    }\n\n    public function getSubject(): ReportInterface\n    {\n        return $this->subject;\n    }\n}\n"
  },
  {
    "path": "src/DTO/ReportRequestDto.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\DTO;\n\nuse OpenApi\\Attributes as OA;\n\n#[OA\\Schema()]\nclass ReportRequestDto\n{\n    public ?string $reason = null;\n}\n"
  },
  {
    "path": "src/DTO/ReportResponseDto.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\DTO;\n\nuse App\\Entity\\Contracts\\VisibilityInterface;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse OpenApi\\Attributes as OA;\n\n#[OA\\Schema()]\nclass ReportResponseDto implements \\JsonSerializable\n{\n    public ?MagazineSmallResponseDto $magazine = null;\n    public ?UserSmallResponseDto $reported = null;\n    public ?UserSmallResponseDto $reporting = null;\n    #[OA\\Property(oneOf: [\n        new OA\\Schema(ref: new Model(type: EntryResponseDto::class)),\n        new OA\\Schema(ref: new Model(type: EntryCommentResponseDto::class)),\n        new OA\\Schema(ref: new Model(type: PostResponseDto::class)),\n        new OA\\Schema(ref: new Model(type: PostCommentResponseDto::class)),\n    ])]\n    public ?\\JsonSerializable $subject = null;\n    public ?string $reason = null;\n    public ?string $status = null;\n    public ?\\DateTimeImmutable $createdAt = null;\n    public ?\\DateTimeImmutable $consideredAt = null;\n    public ?UserSmallResponseDto $consideredBy = null;\n    public ?int $weight = null;\n    public ?int $reportId = null;\n\n    public static function create(\n        ?int $id = null,\n        ?MagazineSmallResponseDto $magazine = null,\n        ?UserSmallResponseDto $reported = null,\n        ?UserSmallResponseDto $reporting = null,\n        ?string $reason = null,\n        ?string $status = null,\n        ?int $weight = null,\n        ?\\DateTimeImmutable $createdAt = null,\n        ?\\DateTimeImmutable $consideredAt = null,\n        ?UserSmallResponseDto $consideredBy = null,\n    ): self {\n        $dto = new ReportResponseDto();\n        $dto->reportId = $id;\n        $dto->magazine = $magazine;\n        $dto->reported = $reported;\n        $dto->reporting = $reporting;\n        $dto->reason = $reason;\n        $dto->status = $status;\n        $dto->weight = $weight;\n        $dto->createdAt = $createdAt;\n        $dto->consideredAt = $consideredAt;\n        $dto->consideredBy = $consideredBy;\n\n        return $dto;\n    }\n\n    #[OA\\Property(\n        'type',\n        enum: [\n            'entry_report',\n            'entry_comment_report',\n            'post_report',\n            'post_comment_report',\n            'null_report',\n        ]\n    )]\n    public function getType(): string\n    {\n        if (null === $this->subject) {\n            // item was purged\n            return 'null_report';\n        }\n\n        switch (\\get_class($this->subject)) {\n            case EntryResponseDto::class:\n                return 'entry_report';\n            case EntryCommentResponseDto::class:\n                return 'entry_comment_report';\n            case PostResponseDto::class:\n                return 'post_report';\n            case PostCommentResponseDto::class:\n                return 'post_comment_report';\n        }\n\n        throw new \\LogicException();\n    }\n\n    public function jsonSerialize(): mixed\n    {\n        $serializedSubject = null;\n        if ($this->subject) {\n            $visibility = $this->subject->visibility;\n            $this->subject->visibility = VisibilityInterface::VISIBILITY_VISIBLE;\n            $serializedSubject = $this->subject->jsonSerialize();\n            $serializedSubject['visibility'] = $visibility;\n        }\n\n        return [\n            'reportId' => $this->reportId,\n            'type' => $this->getType(),\n            'magazine' => $this->magazine->jsonSerialize(),\n            'reason' => $this->reason,\n            'reported' => $this->reported->jsonSerialize(),\n            'reporting' => $this->reporting->jsonSerialize(),\n            'subject' => $serializedSubject,\n            'status' => $this->status,\n            'weight' => $this->weight,\n            'createdAt' => $this->createdAt->format(\\DateTimeInterface::ATOM),\n            'consideredAt' => $this->consideredAt?->format(\\DateTimeInterface::ATOM),\n            'consideredBy' => $this->consideredBy?->jsonSerialize(),\n        ];\n    }\n}\n"
  },
  {
    "path": "src/DTO/SearchDto.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\DTO;\n\nuse App\\Entity\\Magazine;\nuse App\\Entity\\User;\n\nclass SearchDto\n{\n    public string $q = '';\n    public ?string $type = null;\n    public ?User $user = null;\n    public ?Magazine $magazine = null;\n    public ?\\DateTimeImmutable $since = null;\n}\n"
  },
  {
    "path": "src/DTO/SearchResponseDto.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\DTO;\n\nuse OpenApi\\Attributes as OA;\n\n/**\n * This class is just used to have a single return type in case of an array that can contain multiple content types.\n */\n#[OA\\Schema()]\nclass SearchResponseDto\n{\n    public function __construct(\n        public ?EntryResponseDto $entry = null,\n        public ?EntryCommentResponseDto $entryComment = null,\n        public ?PostResponseDto $post = null,\n        public ?PostCommentResponseDto $postComment = null,\n        public ?MagazineResponseDto $magazine = null,\n        public ?UserResponseDto $user = null,\n    ) {\n    }\n}\n"
  },
  {
    "path": "src/DTO/SettingsDto.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\DTO;\n\nuse OpenApi\\Attributes as OA;\n\n#[OA\\Schema()]\nclass SettingsDto implements \\JsonSerializable\n{\n    public function __construct(\n        public string $KBIN_DOMAIN,\n        public string $KBIN_TITLE,\n        public string $KBIN_META_TITLE,\n        public string $KBIN_META_KEYWORDS,\n        public string $KBIN_META_DESCRIPTION,\n        public string $KBIN_DEFAULT_LANG,\n        public string $KBIN_CONTACT_EMAIL,\n        public string $KBIN_SENDER_EMAIL,\n        public string $MBIN_DEFAULT_THEME,\n        public bool $KBIN_JS_ENABLED,\n        public bool $KBIN_FEDERATION_ENABLED,\n        public bool $KBIN_REGISTRATIONS_ENABLED,\n        public bool $KBIN_HEADER_LOGO,\n        public bool $KBIN_CAPTCHA_ENABLED,\n        public bool $KBIN_MERCURE_ENABLED,\n        public bool $KBIN_FEDERATION_PAGE_ENABLED,\n        public bool $KBIN_ADMIN_ONLY_OAUTH_CLIENTS,\n        public bool $MBIN_SSO_ONLY_MODE,\n        public bool $MBIN_PRIVATE_INSTANCE,\n        public bool $KBIN_FEDERATED_SEARCH_ONLY_LOGGEDIN,\n        public bool $MBIN_SIDEBAR_SECTIONS_RANDOM_LOCAL_ONLY,\n        public bool $MBIN_SIDEBAR_SECTIONS_USERS_LOCAL_ONLY,\n        public bool $MBIN_SSO_REGISTRATIONS_ENABLED,\n        public bool $MBIN_RESTRICT_MAGAZINE_CREATION,\n        public bool $MBIN_SSO_SHOW_FIRST,\n        public string $MBIN_DOWNVOTES_MODE,\n        public bool $MBIN_NEW_USERS_NEED_APPROVAL,\n        public bool $MBIN_USE_FEDERATION_ALLOW_LIST,\n    ) {\n    }\n\n    public function mergeIntoDto(SettingsDto $dto): SettingsDto\n    {\n        $dto->KBIN_DOMAIN = $this->KBIN_DOMAIN ?? $dto->KBIN_DOMAIN;\n        $dto->KBIN_TITLE = $this->KBIN_TITLE ?? $dto->KBIN_TITLE;\n        $dto->KBIN_META_TITLE = $this->KBIN_META_TITLE ?? $dto->KBIN_META_TITLE;\n        $dto->KBIN_META_KEYWORDS = $this->KBIN_META_KEYWORDS ?? $dto->KBIN_META_KEYWORDS;\n        $dto->KBIN_META_DESCRIPTION = $this->KBIN_META_DESCRIPTION ?? $dto->KBIN_META_DESCRIPTION;\n        $dto->KBIN_DEFAULT_LANG = $this->KBIN_DEFAULT_LANG ?? $dto->KBIN_DEFAULT_LANG;\n        $dto->KBIN_CONTACT_EMAIL = $this->KBIN_CONTACT_EMAIL ?? $dto->KBIN_CONTACT_EMAIL;\n        $dto->KBIN_SENDER_EMAIL = $this->KBIN_SENDER_EMAIL ?? $dto->KBIN_SENDER_EMAIL;\n        $dto->MBIN_DEFAULT_THEME = $this->MBIN_DEFAULT_THEME ?? $dto->MBIN_DEFAULT_THEME;\n        $dto->KBIN_JS_ENABLED = $this->KBIN_JS_ENABLED ?? $dto->KBIN_JS_ENABLED;\n        $dto->KBIN_FEDERATION_ENABLED = $this->KBIN_FEDERATION_ENABLED ?? $dto->KBIN_FEDERATION_ENABLED;\n        $dto->KBIN_REGISTRATIONS_ENABLED = $this->KBIN_REGISTRATIONS_ENABLED ?? $dto->KBIN_REGISTRATIONS_ENABLED;\n        $dto->KBIN_HEADER_LOGO = $this->KBIN_HEADER_LOGO ?? $dto->KBIN_HEADER_LOGO;\n        $dto->KBIN_CAPTCHA_ENABLED = $this->KBIN_CAPTCHA_ENABLED ?? $dto->KBIN_CAPTCHA_ENABLED;\n        $dto->KBIN_MERCURE_ENABLED = $this->KBIN_MERCURE_ENABLED ?? $dto->KBIN_MERCURE_ENABLED;\n        $dto->KBIN_FEDERATION_PAGE_ENABLED = $this->KBIN_FEDERATION_PAGE_ENABLED ?? $dto->KBIN_FEDERATION_PAGE_ENABLED;\n        $dto->KBIN_ADMIN_ONLY_OAUTH_CLIENTS = $this->KBIN_ADMIN_ONLY_OAUTH_CLIENTS ?? $dto->KBIN_ADMIN_ONLY_OAUTH_CLIENTS;\n        $dto->MBIN_SSO_ONLY_MODE = $this->MBIN_SSO_ONLY_MODE ?? $dto->MBIN_SSO_ONLY_MODE;\n        $dto->MBIN_PRIVATE_INSTANCE = $this->MBIN_PRIVATE_INSTANCE ?? $dto->MBIN_PRIVATE_INSTANCE;\n        $dto->KBIN_FEDERATED_SEARCH_ONLY_LOGGEDIN = $this->KBIN_FEDERATED_SEARCH_ONLY_LOGGEDIN ?? $dto->KBIN_FEDERATED_SEARCH_ONLY_LOGGEDIN;\n        $dto->MBIN_SIDEBAR_SECTIONS_RANDOM_LOCAL_ONLY = $this->MBIN_SIDEBAR_SECTIONS_RANDOM_LOCAL_ONLY ?? $dto->MBIN_SIDEBAR_SECTIONS_RANDOM_LOCAL_ONLY;\n        $dto->MBIN_SIDEBAR_SECTIONS_USERS_LOCAL_ONLY = $this->MBIN_SIDEBAR_SECTIONS_USERS_LOCAL_ONLY ?? $dto->MBIN_SIDEBAR_SECTIONS_USERS_LOCAL_ONLY;\n        $dto->MBIN_SSO_REGISTRATIONS_ENABLED = $this->MBIN_SSO_REGISTRATIONS_ENABLED ?? $dto->MBIN_SSO_REGISTRATIONS_ENABLED;\n        $dto->MBIN_RESTRICT_MAGAZINE_CREATION = $this->MBIN_RESTRICT_MAGAZINE_CREATION ?? $dto->MBIN_RESTRICT_MAGAZINE_CREATION;\n        $dto->MBIN_SSO_SHOW_FIRST = $this->MBIN_SSO_SHOW_FIRST ?? $dto->MBIN_SSO_SHOW_FIRST;\n        $dto->MBIN_DOWNVOTES_MODE = $this->MBIN_DOWNVOTES_MODE ?? $dto->MBIN_DOWNVOTES_MODE;\n        $dto->MBIN_NEW_USERS_NEED_APPROVAL = $this->MBIN_NEW_USERS_NEED_APPROVAL ?? $dto->MBIN_NEW_USERS_NEED_APPROVAL;\n        $dto->MBIN_USE_FEDERATION_ALLOW_LIST = $this->MBIN_USE_FEDERATION_ALLOW_LIST ?? $dto->MBIN_USE_FEDERATION_ALLOW_LIST;\n\n        return $dto;\n    }\n\n    public function jsonSerialize(): mixed\n    {\n        return [\n            'KBIN_DOMAIN' => $this->KBIN_DOMAIN,\n            'KBIN_TITLE' => $this->KBIN_TITLE,\n            'KBIN_META_TITLE' => $this->KBIN_META_TITLE,\n            'KBIN_META_KEYWORDS' => $this->KBIN_META_KEYWORDS,\n            'KBIN_META_DESCRIPTION' => $this->KBIN_META_DESCRIPTION,\n            'KBIN_DEFAULT_LANG' => $this->KBIN_DEFAULT_LANG,\n            'KBIN_CONTACT_EMAIL' => $this->KBIN_CONTACT_EMAIL,\n            'KBIN_SENDER_EMAIL' => $this->KBIN_SENDER_EMAIL,\n            'MBIN_DEFAULT_THEME' => $this->MBIN_DEFAULT_THEME,\n            'KBIN_JS_ENABLED' => $this->KBIN_JS_ENABLED,\n            'KBIN_FEDERATION_ENABLED' => $this->KBIN_FEDERATION_ENABLED,\n            'KBIN_REGISTRATIONS_ENABLED' => $this->KBIN_REGISTRATIONS_ENABLED,\n            'KBIN_HEADER_LOGO' => $this->KBIN_HEADER_LOGO,\n            'KBIN_CAPTCHA_ENABLED' => $this->KBIN_CAPTCHA_ENABLED,\n            'KBIN_MERCURE_ENABLED' => $this->KBIN_MERCURE_ENABLED,\n            'KBIN_FEDERATION_PAGE_ENABLED' => $this->KBIN_FEDERATION_PAGE_ENABLED,\n            'KBIN_ADMIN_ONLY_OAUTH_CLIENTS' => $this->KBIN_ADMIN_ONLY_OAUTH_CLIENTS,\n            'MBIN_SSO_ONLY_MODE' => $this->MBIN_SSO_ONLY_MODE,\n            'MBIN_PRIVATE_INSTANCE' => $this->MBIN_PRIVATE_INSTANCE,\n            'KBIN_FEDERATED_SEARCH_ONLY_LOGGEDIN' => $this->KBIN_FEDERATED_SEARCH_ONLY_LOGGEDIN,\n            'MBIN_SIDEBAR_SECTIONS_RANDOM_LOCAL_ONLY' => $this->MBIN_SIDEBAR_SECTIONS_RANDOM_LOCAL_ONLY,\n            'MBIN_SIDEBAR_SECTIONS_USERS_LOCAL_ONLY' => $this->MBIN_SIDEBAR_SECTIONS_USERS_LOCAL_ONLY,\n            'MBIN_SSO_REGISTRATIONS_ENABLED' => $this->MBIN_SSO_REGISTRATIONS_ENABLED,\n            'MBIN_RESTRICT_MAGAZINE_CREATION' => $this->MBIN_RESTRICT_MAGAZINE_CREATION,\n            'MBIN_SSO_SHOW_FIRST' => $this->MBIN_SSO_SHOW_FIRST,\n            'MBIN_DOWNVOTES_MODE' => $this->MBIN_DOWNVOTES_MODE,\n            'MBIN_NEW_USERS_NEED_APPROVAL' => $this->MBIN_NEW_USERS_NEED_APPROVAL,\n            'MBIN_USE_FEDERATION_ALLOW_LIST' => $this->MBIN_USE_FEDERATION_ALLOW_LIST,\n        ];\n    }\n}\n"
  },
  {
    "path": "src/DTO/SiteResponseDto.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\DTO;\n\nuse App\\Entity\\Site;\nuse App\\Utils\\DownvotesMode;\nuse OpenApi\\Attributes as OA;\n\n#[OA\\Schema()]\nclass SiteResponseDto implements \\JsonSerializable\n{\n    public const PAGES = [\n        'about',\n        'contact',\n        'faq',\n        'privacyPolicy',\n        'terms',\n    ];\n\n    public ?string $about = null;\n    public ?string $contact = null;\n    public ?string $faq = null;\n    public ?string $privacyPolicy = null;\n    public ?string $terms = null;\n    public DownvotesMode $downvotesMode = DownvotesMode::Enabled;\n\n    public function __construct(?Site $site, DownvotesMode $downvotesMode)\n    {\n        $this->terms = $site?->terms;\n        $this->privacyPolicy = $site?->privacyPolicy;\n        $this->faq = $site?->faq;\n        $this->about = $site?->about;\n        $this->contact = $site?->contact;\n        $this->downvotesMode = $downvotesMode;\n    }\n\n    public function jsonSerialize(): mixed\n    {\n        return [\n            'about' => $this->about,\n            'contact' => $this->contact,\n            'faq' => $this->faq,\n            'privacyPolicy' => $this->privacyPolicy,\n            'terms' => $this->terms,\n        ];\n    }\n}\n"
  },
  {
    "path": "src/DTO/Temp2FADto.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\DTO;\n\nuse Scheb\\TwoFactorBundle\\Model\\Totp\\TotpConfiguration;\nuse Scheb\\TwoFactorBundle\\Model\\Totp\\TotpConfigurationInterface;\nuse Scheb\\TwoFactorBundle\\Model\\Totp\\TwoFactorInterface;\n\nclass Temp2FADto implements TwoFactorInterface\n{\n    public function __construct(\n        public string $forUsername,\n        public string $secret,\n    ) {\n    }\n\n    public function isTotpAuthenticationEnabled(): bool\n    {\n        return null !== $this->secret;\n    }\n\n    public function getTotpAuthenticationUsername(): string\n    {\n        return $this->forUsername;\n    }\n\n    /**\n     * Has to match User::getTotpAuthenticationConfiguration.\n     *\n     * @see User::getTotpAuthenticationConfiguration()\n     */\n    public function getTotpAuthenticationConfiguration(): ?TotpConfigurationInterface\n    {\n        return new TotpConfiguration($this->secret, TotpConfiguration::ALGORITHM_SHA1, 30, 6);\n    }\n}\n"
  },
  {
    "path": "src/DTO/ToggleCreatedDto.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\DTO;\n\nuse OpenApi\\Attributes as OA;\n\n#[OA\\Schema()]\nclass ToggleCreatedDto implements \\JsonSerializable\n{\n    #[OA\\Property(description: 'if true the resource was created, if false it was deleted')]\n    public bool $created;\n\n    public function __construct(bool $created)\n    {\n        $this->created = $created;\n    }\n\n    public function jsonSerialize(): mixed\n    {\n        return ['created' => $this->created];\n    }\n}\n"
  },
  {
    "path": "src/DTO/UserBanResponseDto.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\DTO;\n\nuse OpenApi\\Attributes as OA;\n\n#[OA\\Schema()]\nclass UserBanResponseDto extends UserResponseDto implements \\JsonSerializable\n{\n    public ?bool $isBanned = null;\n\n    public function __construct(UserDto $dto, bool $isBanned)\n    {\n        parent::__construct($dto);\n        $this->isBanned = $isBanned;\n    }\n\n    public function jsonSerialize(): mixed\n    {\n        $response = parent::jsonSerialize();\n        $response['isBanned'] = $this->isBanned;\n\n        return $response;\n    }\n}\n"
  },
  {
    "path": "src/DTO/UserDto.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\DTO;\n\nuse App\\DTO\\Contracts\\UserDtoInterface;\nuse App\\Entity\\User;\nuse App\\Utils\\RegPatterns;\nuse App\\Validator\\NoSurroundingWhitespace;\nuse App\\Validator\\Unique;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\Validator\\Constraints as Assert;\nuse Symfony\\Component\\Validator\\Context\\ExecutionContextInterface;\n\n#[Unique(User::class, errorPath: 'email', fields: ['email'], idFields: ['id'])]\n#[Unique(User::class, errorPath: 'username', fields: ['username'], idFields: ['id'])]\nclass UserDto implements UserDtoInterface\n{\n    public const MAX_USERNAME_LENGTH = 30;\n    public const MAX_ABOUT_LENGTH = 512;\n\n    #[Assert\\NotBlank]\n    #[Assert\\Length(min: 2, max: self::MAX_USERNAME_LENGTH)]\n    #[Assert\\Regex(pattern: RegPatterns::USERNAME, match: true)]\n    public ?string $username = null;\n    #[Assert\\Length(min: 2, max: self::MAX_USERNAME_LENGTH)]\n    #[NoSurroundingWhitespace]\n    public ?string $title = null;\n    #[Assert\\NotBlank]\n    #[Assert\\Email]\n    public ?string $email = null;\n    #[Assert\\Length(min: 6, max: 4096)]\n    public ?string $plainPassword = null; // @todo move password and agreeTerms to RegisterDto\n    #[Assert\\Length(min: 2, max: self::MAX_ABOUT_LENGTH, countUnit: Assert\\Length::COUNT_GRAPHEMES)]\n    public ?string $about = null;\n    public ?\\DateTimeImmutable $createdAt = null;\n    public ?string $fields = null;\n    public ?ImageDto $avatar = null;\n    public ?ImageDto $cover = null;\n    public bool $agreeTerms = false;\n    public ?string $ip = null;\n    public ?string $apId = null;\n    public ?string $apProfileId = null;\n    public ?int $id = null;\n    public ?int $followersCount = 0;\n    public ?bool $isBot = null;\n    public ?bool $isAdmin = null;\n    public ?bool $isGlobalModerator = null;\n    public ?bool $isFollowedByUser = null;\n    public ?bool $isFollowerOfUser = null;\n    public ?bool $isBlockedByUser = null;\n    public ?string $totpSecret = null;\n    public ?string $serverSoftware = null;\n    public ?string $serverSoftwareVersion = null;\n    public ?string $applicationText = null;\n    public ?int $reputationPoints = null;\n    public ?bool $discoverable = null;\n    public ?bool $indexable = null;\n\n    #[Assert\\Callback]\n    public function validate(\n        ExecutionContextInterface $context,\n        $payload,\n    ) {\n        if (!Request::createFromGlobals()->request->has('user_register')) {\n            return;\n        }\n\n        if (false === $this->agreeTerms) {\n            $this->buildViolation($context, 'agreeTerms');\n        }\n    }\n\n    private function buildViolation(ExecutionContextInterface $context, $path)\n    {\n        $context->buildViolation('This value should not be blank.')\n            ->atPath($path)\n            ->addViolation();\n    }\n\n    public function getId(): ?int\n    {\n        return $this->id;\n    }\n\n    public static function create(\n        string $username,\n        ?string $email = null,\n        ?ImageDto $avatar = null,\n        ?ImageDto $cover = null,\n        ?string $about = null,\n        ?\\DateTimeImmutable $createdAt = null,\n        ?array $fields = null,\n        ?string $apId = null,\n        ?string $apProfileId = null,\n        ?int $id = null,\n        ?int $followersCount = 0,\n        ?bool $isBot = null,\n        ?bool $isAdmin = null,\n        ?bool $isGlobalModerator = null,\n        ?string $applicationText = null,\n        ?int $reputationPoints = null,\n        ?bool $discoverable = null,\n        ?bool $indexable = null,\n        ?string $title = null,\n    ): self {\n        $dto = new UserDto();\n        $dto->id = $id;\n        $dto->username = $username;\n        $dto->email = $email;\n        $dto->avatar = $avatar;\n        $dto->cover = $cover;\n        $dto->about = $about;\n        $dto->createdAt = $createdAt;\n        $dto->fields = $fields;\n        $dto->apId = $apId;\n        $dto->apProfileId = $apProfileId;\n        $dto->followersCount = $followersCount;\n        $dto->isBot = $isBot;\n        $dto->isAdmin = $isAdmin;\n        $dto->isGlobalModerator = $isGlobalModerator;\n        $dto->applicationText = $applicationText;\n        $dto->reputationPoints = $reputationPoints;\n        $dto->discoverable = $discoverable;\n        $dto->indexable = $indexable;\n        $dto->title = $title;\n\n        return $dto;\n    }\n}\n"
  },
  {
    "path": "src/DTO/UserFilterListDto.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\DTO;\n\nuse App\\Entity\\UserFilterList;\n\nclass UserFilterListDto\n{\n    public ?int $id = null;\n    public string $name;\n\n    public ?\\DateTimeImmutable $expirationDate;\n\n    public bool $feeds;\n    public bool $comments;\n    public bool $profile;\n\n    /** @var UserFilterWordDto[] */\n    public array $words = [];\n\n    public function wordsToArray(): array\n    {\n        $nonEmptyWords = array_filter($this->words, fn (?UserFilterWordDto $word) => null !== $word?->word && '' !== trim($word->word));\n\n        return array_map(fn (UserFilterWordDto $word) => [\n            'word' => $word->word,\n            'exactMatch' => $word->exactMatch,\n        ], $nonEmptyWords);\n    }\n\n    public function addEmptyWords(): void\n    {\n        $wordsToAdd = 5 - \\sizeof($this->words);\n        if ($wordsToAdd <= 0) {\n            $wordsToAdd = 1;\n        }\n\n        for ($i = 0; $i < $wordsToAdd; ++$i) {\n            $this->words[] = new UserFilterWordDto();\n        }\n    }\n\n    public static function fromList(UserFilterList $list): self\n    {\n        $dto = new self();\n        $dto->id = $list->getId();\n        $dto->name = $list->name;\n        $dto->expirationDate = $list->expirationDate;\n        $dto->feeds = $list->feeds;\n        $dto->comments = $list->comments;\n        $dto->profile = $list->profile;\n        foreach ($list->words as $word) {\n            $dto2 = new UserFilterWordDto();\n            $dto2->word = $word['word'];\n            $dto2->exactMatch = $word['exactMatch'];\n            $dto->words[] = $dto2;\n        }\n\n        return $dto;\n    }\n}\n"
  },
  {
    "path": "src/DTO/UserFilterListResponseDto.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\DTO;\n\nuse App\\Entity\\UserFilterList;\nuse OpenApi\\Attributes as OA;\n\n#[OA\\Schema]\nclass UserFilterListResponseDto implements \\JsonSerializable\n{\n    public ?int $id = null;\n    public string $name;\n\n    public ?string $expirationDate;\n\n    public bool $feeds;\n    public bool $comments;\n    public bool $profile;\n\n    /**\n     * @var array<array{word: string, exactMatch: bool}>\n     */\n    public array $words = [];\n\n    public static function fromList(UserFilterList $list): self\n    {\n        $dto = new self();\n        $dto->id = $list->getId();\n        $dto->name = $list->name;\n        $dto->expirationDate = $list->expirationDate?->format(DATE_ATOM);\n        $dto->feeds = $list->feeds;\n        $dto->comments = $list->comments;\n        $dto->profile = $list->profile;\n        $dto->words = $list->words;\n\n        return $dto;\n    }\n\n    public function jsonSerialize(): mixed\n    {\n        return [\n            'id' => $this->id,\n            'name' => $this->name,\n            'expirationDate' => $this->expirationDate,\n            'feeds' => $this->feeds,\n            'comments' => $this->comments,\n            'profile' => $this->profile,\n            'words' => $this->words,\n        ];\n    }\n}\n"
  },
  {
    "path": "src/DTO/UserFilterWordDto.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\DTO;\n\nclass UserFilterWordDto\n{\n    public ?string $word = null;\n\n    public bool $exactMatch = false;\n}\n"
  },
  {
    "path": "src/DTO/UserNoteDto.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\DTO;\n\nuse App\\Entity\\User;\nuse Symfony\\Component\\Validator\\Constraints as Assert;\n\nclass UserNoteDto\n{\n    #[Assert\\NotBlank]\n    public ?User $target;\n    #[Assert\\Length(min: 0, max: 255)]\n    public ?string $body = null;\n}\n"
  },
  {
    "path": "src/DTO/UserProfileRequestDto.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\DTO;\n\nuse App\\Validator\\NoSurroundingWhitespace;\nuse OpenApi\\Attributes as OA;\nuse Symfony\\Component\\Validator\\Constraints as Assert;\n\n#[OA\\Schema()]\nclass UserProfileRequestDto\n{\n    #[Assert\\Length(min: 2, max: UserDto::MAX_ABOUT_LENGTH, countUnit: Assert\\Length::COUNT_GRAPHEMES)]\n    public ?string $about = null;\n\n    #[Assert\\Length(min: 2, max: UserDto::MAX_USERNAME_LENGTH)]\n    #[NoSurroundingWhitespace]\n    public ?string $title = null;\n}\n"
  },
  {
    "path": "src/DTO/UserResponseDto.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\DTO;\n\nuse App\\Enums\\ENotificationStatus;\nuse OpenApi\\Attributes as OA;\n\n#[OA\\Schema()]\nclass UserResponseDto implements \\JsonSerializable\n{\n    public ?ImageDto $avatar = null;\n    public ?ImageDto $cover = null;\n    public string $username;\n    public ?string $title;\n    public int $followersCount = 0;\n    public ?string $about = null;\n    public ?\\DateTimeImmutable $createdAt = null;\n    public ?string $apProfileId = null;\n    public ?string $apId = null;\n    public ?bool $isBot = null;\n    public ?bool $isFollowedByUser = null;\n    public ?bool $isFollowerOfUser = null;\n    public ?bool $isBlockedByUser = null;\n    public ?bool $isAdmin = null;\n    public ?bool $isGlobalModerator = null;\n    public ?int $userId = null;\n    public ?string $serverSoftware = null;\n    public ?string $serverSoftwareVersion = null;\n    public ?ENotificationStatus $notificationStatus = null;\n    public ?bool $indexable = null;\n\n    /**\n     * @var int|null this will only be populated on single user retrieves, not on batch ones,\n     *               because it is a costly operation\n     */\n    public ?int $reputationPoints = null;\n    public ?bool $discoverable = null;\n\n    public function __construct(UserDto $dto)\n    {\n        $this->userId = $dto->getId();\n        $this->username = $dto->username;\n        $this->title = $dto->title;\n        $this->about = $dto->about;\n        $this->avatar = $dto->avatar;\n        $this->cover = $dto->cover;\n        $this->createdAt = $dto->createdAt;\n        $this->apId = $dto->apId;\n        $this->apProfileId = $dto->apProfileId;\n        $this->followersCount = $dto->followersCount;\n        $this->isBot = true === $dto->isBot;\n        $this->isFollowedByUser = $dto->isFollowedByUser;\n        $this->isFollowerOfUser = $dto->isFollowerOfUser;\n        $this->isBlockedByUser = $dto->isBlockedByUser;\n        $this->serverSoftware = $dto->serverSoftware;\n        $this->serverSoftwareVersion = $dto->serverSoftwareVersion;\n        $this->isAdmin = $dto->isAdmin;\n        $this->isGlobalModerator = $dto->isGlobalModerator;\n        $this->reputationPoints = $dto->reputationPoints;\n        $this->discoverable = $dto->discoverable;\n        $this->indexable = $dto->indexable;\n    }\n\n    public function jsonSerialize(): mixed\n    {\n        return [\n            'userId' => $this->userId,\n            'username' => $this->username,\n            'title' => $this->title,\n            'about' => $this->about,\n            'avatar' => $this->avatar?->jsonSerialize(),\n            'cover' => $this->cover?->jsonSerialize(),\n            'createdAt' => $this->createdAt?->format(\\DateTimeInterface::ATOM),\n            'followersCount' => $this->followersCount,\n            'apId' => $this->apId,\n            'apProfileId' => $this->apProfileId,\n            'isBot' => $this->isBot,\n            'isAdmin' => $this->isAdmin,\n            'isGlobalModerator' => $this->isGlobalModerator,\n            'isFollowedByUser' => $this->isFollowedByUser,\n            'isFollowerOfUser' => $this->isFollowerOfUser,\n            'isBlockedByUser' => $this->isBlockedByUser,\n            'serverSoftware' => $this->serverSoftware,\n            'serverSoftwareVersion' => $this->serverSoftwareVersion,\n            'notificationStatus' => $this->notificationStatus,\n            'reputationPoints' => $this->reputationPoints,\n            'discoverable' => $this->discoverable,\n            'indexable' => $this->indexable,\n        ];\n    }\n}\n"
  },
  {
    "path": "src/DTO/UserSettingsDto.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\DTO;\n\nuse App\\Entity\\User;\nuse App\\Enums\\EDirectMessageSettings;\nuse App\\Enums\\EFrontContentOptions;\nuse App\\PageView\\EntryCommentPageView;\nuse App\\PageView\\EntryPageView;\nuse OpenApi\\Attributes as OA;\n\n#[OA\\Schema()]\nclass UserSettingsDto implements \\JsonSerializable\n{\n    public function __construct(\n        public ?bool $notifyOnNewEntry = null,\n        public ?bool $notifyOnNewEntryReply = null,\n        public ?bool $notifyOnNewEntryCommentReply = null,\n        public ?bool $notifyOnNewPost = null,\n        public ?bool $notifyOnNewPostReply = null,\n        public ?bool $notifyOnNewPostCommentReply = null,\n        public ?bool $hideAdult = null,\n        public ?bool $showProfileSubscriptions = null,\n        public ?bool $showProfileFollowings = null,\n        public ?bool $addMentionsEntries = null,\n        public ?bool $addMentionsPosts = null,\n        #[OA\\Property(type: 'string', enum: User::HOMEPAGE_OPTIONS)]\n        public ?string $homepage = null,\n        #[OA\\Property(type: 'string', enum: EntryPageView::SORT_OPTIONS)]\n        public ?string $frontDefaultSort = null,\n        #[OA\\Property(type: 'string', enum: EntryCommentPageView::SORT_OPTIONS)]\n        public ?string $commentDefaultSort = null,\n        public ?bool $showFollowingBoosts = null,\n        #[OA\\Property(type: 'array', items: new OA\\Items(type: 'string'))]\n        public ?array $featuredMagazines = null,\n        #[OA\\Property(type: 'array', items: new OA\\Items(type: 'string'))]\n        public ?array $preferredLanguages = null,\n        public ?string $customCss = null,\n        public ?bool $ignoreMagazinesCustomCss = null,\n        public ?bool $notifyOnUserSignup = null,\n        #[OA\\Property(type: 'string', enum: EDirectMessageSettings::OPTIONS)]\n        public ?string $directMessageSetting = null,\n        #[OA\\Property(type: 'string', enum: EFrontContentOptions::OPTIONS)]\n        public ?string $frontDefaultContent = null,\n        public ?bool $discoverable = null,\n        public ?bool $indexable = null,\n    ) {\n    }\n\n    public function jsonSerialize(): mixed\n    {\n        return [\n            'notifyOnNewEntry' => $this->notifyOnNewEntry,\n            'notifyOnNewEntryReply' => $this->notifyOnNewEntryReply,\n            'notifyOnNewEntryCommentReply' => $this->notifyOnNewEntryCommentReply,\n            'notifyOnNewPost' => $this->notifyOnNewPost,\n            'notifyOnNewPostReply' => $this->notifyOnNewPostReply,\n            'notifyOnNewPostCommentReply' => $this->notifyOnNewPostCommentReply,\n            'hideAdult' => $this->hideAdult,\n            'showProfileSubscriptions' => $this->showProfileSubscriptions,\n            'showProfileFollowings' => $this->showProfileFollowings,\n            'addMentionsEntries' => $this->addMentionsEntries,\n            'addMentionsPosts' => $this->addMentionsPosts,\n            'homepage' => $this->homepage,\n            'frontDefaultSort' => $this->frontDefaultSort,\n            'frontDefaultContent' => $this->frontDefaultContent,\n            'commentDefaultSort' => $this->commentDefaultSort,\n            'featuredMagazines' => $this->featuredMagazines,\n            'preferredLanguages' => $this->preferredLanguages,\n            'customCss' => $this->customCss,\n            'ignoreMagazinesCustomCss' => $this->ignoreMagazinesCustomCss,\n            'notifyOnUserSignup' => $this->notifyOnUserSignup,\n            'directMessageSetting' => $this->directMessageSetting,\n            'discoverable' => $this->discoverable,\n            'indexable' => $this->indexable,\n        ];\n    }\n\n    public function mergeIntoDto(UserSettingsDto $dto): UserSettingsDto\n    {\n        $dto->notifyOnNewEntry = $this->notifyOnNewEntry ?? $dto->notifyOnNewEntry;\n        $dto->notifyOnNewEntryReply = $this->notifyOnNewEntryReply ?? $dto->notifyOnNewEntryReply;\n        $dto->notifyOnNewEntryCommentReply = $this->notifyOnNewEntryCommentReply ?? $dto->notifyOnNewEntryCommentReply;\n        $dto->notifyOnNewPost = $this->notifyOnNewPost ?? $dto->notifyOnNewPost;\n        $dto->notifyOnNewPostReply = $this->notifyOnNewPostReply ?? $dto->notifyOnNewPostReply;\n        $dto->notifyOnNewPostCommentReply = $this->notifyOnNewPostCommentReply ?? $dto->notifyOnNewPostCommentReply;\n        $dto->hideAdult = $this->hideAdult ?? $dto->hideAdult;\n        $dto->showProfileSubscriptions = $this->showProfileSubscriptions ?? $dto->showProfileSubscriptions;\n        $dto->showProfileFollowings = $this->showProfileFollowings ?? $dto->showProfileFollowings;\n        $dto->addMentionsEntries = $this->addMentionsEntries ?? $dto->addMentionsEntries;\n        $dto->addMentionsPosts = $this->addMentionsPosts ?? $dto->addMentionsPosts;\n        $dto->homepage = $this->homepage ?? $dto->homepage;\n        $dto->frontDefaultSort = $this->frontDefaultSort ?? $dto->frontDefaultSort;\n        $dto->commentDefaultSort = $this->commentDefaultSort ?? $dto->commentDefaultSort;\n        $dto->featuredMagazines = $this->featuredMagazines ?? $dto->featuredMagazines;\n        $dto->preferredLanguages = $this->preferredLanguages ?? $dto->preferredLanguages;\n        $dto->customCss = $this->customCss ?? $dto->customCss;\n        $dto->ignoreMagazinesCustomCss = $this->ignoreMagazinesCustomCss ?? $dto->ignoreMagazinesCustomCss;\n        $dto->directMessageSetting = $this->directMessageSetting ?? $dto->directMessageSetting;\n        $dto->frontDefaultContent = $this->frontDefaultContent ?? $dto->frontDefaultContent;\n        $dto->discoverable = $this->discoverable ?? $dto->discoverable;\n        $dto->indexable = $this->indexable ?? $dto->indexable;\n\n        return $dto;\n    }\n}\n"
  },
  {
    "path": "src/DTO/UserSignupResponseDto.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\DTO;\n\nuse OpenApi\\Attributes as OA;\n\n#[OA\\Schema()]\nclass UserSignupResponseDto implements \\JsonSerializable\n{\n    public int $userId = 0;\n    public string $username = '';\n    public bool $isBot = false;\n    public ?\\DateTimeImmutable $createdAt = null;\n    public ?string $email = null;\n    public ?string $applicationText = null;\n\n    public function __construct(UserDto $dto)\n    {\n        $this->userId = $dto->getId();\n        $this->username = $dto->username;\n        $this->isBot = $dto->isBot;\n        $this->createdAt = $dto->createdAt;\n        $this->email = $dto->email;\n        $this->applicationText = $dto->applicationText;\n    }\n\n    public function jsonSerialize(): mixed\n    {\n        return [\n            'userId' => $this->userId,\n            'username' => $this->username,\n            'isBot' => $this->isBot,\n            'createdAt' => $this->createdAt?->format(\\DateTimeInterface::ATOM),\n            'email' => $this->email,\n            'applicationText' => $this->applicationText,\n        ];\n    }\n}\n"
  },
  {
    "path": "src/DTO/UserSmallResponseDto.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\DTO;\n\nuse OpenApi\\Attributes as OA;\n\n#[OA\\Schema()]\nclass UserSmallResponseDto implements \\JsonSerializable\n{\n    public ?int $userId = null;\n    public ?string $username = null;\n    public ?string $title = null;\n    public ?bool $isBot = null;\n    public ?bool $isFollowedByUser = null;\n    public ?bool $isFollowerOfUser = null;\n    public ?bool $isBlockedByUser = null;\n    public ?bool $isAdmin = null;\n    public ?bool $isGlobalModerator = null;\n    public ?ImageDto $avatar = null;\n    public ?string $apId = null;\n    public ?string $apProfileId = null;\n    public ?\\DateTimeImmutable $createdAt = null;\n    public ?bool $discoverable = null;\n    public ?bool $indexable = null;\n\n    public function __construct(UserDto $dto)\n    {\n        $this->userId = $dto->getId();\n        $this->username = $dto->username;\n        $this->title = $dto->title;\n        $this->isBot = $dto->isBot;\n        $this->isFollowedByUser = $dto->isFollowedByUser;\n        $this->isFollowerOfUser = $dto->isFollowerOfUser;\n        $this->isBlockedByUser = $dto->isBlockedByUser;\n        $this->avatar = $dto->avatar;\n        $this->apId = $dto->apId;\n        $this->apProfileId = $dto->apProfileId;\n        $this->createdAt = $dto->createdAt;\n        $this->isAdmin = $dto->isAdmin;\n        $this->isGlobalModerator = $dto->isGlobalModerator;\n        $this->discoverable = $dto->discoverable;\n        $this->indexable = $dto->indexable;\n    }\n\n    public function jsonSerialize(): mixed\n    {\n        return [\n            'userId' => $this->userId,\n            'username' => $this->username,\n            'title' => $this->title,\n            'isBot' => $this->isBot,\n            'isFollowedByUser' => $this->isFollowedByUser,\n            'isFollowerOfUser' => $this->isFollowerOfUser,\n            'isBlockedByUser' => $this->isBlockedByUser,\n            'isAdmin' => $this->isAdmin,\n            'isGlobalModerator' => $this->isGlobalModerator,\n            'avatar' => $this->avatar,\n            'apId' => $this->apId,\n            'apProfileId' => $this->apProfileId,\n            'createdAt' => $this->createdAt?->format(\\DateTimeImmutable::ATOM),\n            'discoverable' => $this->discoverable,\n            'indexable' => $this->indexable,\n        ];\n    }\n}\n"
  },
  {
    "path": "src/DTO/VoteStatsResponseDto.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\DTO;\n\nuse OpenApi\\Attributes as OA;\n\n#[OA\\Schema()]\nclass VoteStatsResponseDto\n{\n    public ?string $datetime = null;\n    public ?int $boost = null;\n    public ?int $down = null;\n    public ?int $up = null;\n}\n"
  },
  {
    "path": "src/DataFixtures/BaseFixture.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\DataFixtures;\n\nuse App\\Utils\\Slugger;\nuse Doctrine\\Bundle\\FixturesBundle\\Fixture;\nuse Doctrine\\Bundle\\FixturesBundle\\FixtureGroupInterface;\nuse Doctrine\\Persistence\\ObjectManager;\nuse Faker\\Factory;\nuse Faker\\Generator;\n\nabstract class BaseFixture extends Fixture implements FixtureGroupInterface\n{\n    protected Generator $faker;\n    protected ObjectManager $manager;\n    protected Slugger $slugger;\n\n    public static function getGroups(): array\n    {\n        return ['dev'];\n    }\n\n    public function load(ObjectManager $manager): void\n    {\n        $this->manager = $manager;\n        $this->faker = Factory::create();\n\n        $this->loadData($manager);\n    }\n\n    abstract protected function loadData(ObjectManager $manager): void;\n\n    protected function camelCase(string $value): string\n    {\n        return Slugger::camelCase($value);\n    }\n\n    protected function getRandomTime(?\\DateTimeImmutable $from = null): \\DateTimeImmutable\n    {\n        return new \\DateTimeImmutable(\n            $this->faker->dateTimeBetween(\n                $from ? $from->format('Y-m-d H:i:s') : '-1 month',\n                'now'\n            )\n                ->format('Y-m-d H:i:s')\n        );\n    }\n}\n"
  },
  {
    "path": "src/DataFixtures/EntryCommentFixtures.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\DataFixtures;\n\nuse App\\DTO\\EntryCommentDto;\nuse App\\Entity\\Entry;\nuse App\\Entity\\EntryComment;\nuse App\\Entity\\User;\nuse App\\Repository\\ImageRepository;\nuse App\\Service\\EntryCommentManager;\nuse App\\Service\\ImageManagerInterface;\nuse Doctrine\\Common\\DataFixtures\\DependentFixtureInterface;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Doctrine\\Persistence\\ObjectManager;\n\nclass EntryCommentFixtures extends BaseFixture implements DependentFixtureInterface\n{\n    public const COMMENTS_COUNT = EntryFixtures::ENTRIES_COUNT * 3;\n\n    public function __construct(\n        private readonly EntryCommentManager $commentManager,\n        private readonly ImageManagerInterface $imageManager,\n        private readonly ImageRepository $imageRepository,\n        private readonly EntityManagerInterface $entityManager,\n    ) {\n    }\n\n    public function getDependencies(): array\n    {\n        return [\n            EntryFixtures::class,\n        ];\n    }\n\n    public function loadData(ObjectManager $manager): void\n    {\n        foreach ($this->provideRandomComments(self::COMMENTS_COUNT) as $index => $comment) {\n            $dto = new EntryCommentDto();\n            $dto->entry = $comment['entry'];\n            $dto->body = $comment['body'];\n            $dto->lang = 'en';\n\n            $entity = $this->commentManager->create($dto, $comment['user']);\n\n            $manager->persist($entity);\n\n            $this->addReference('entry_comment_'.$index, $entity);\n\n            $manager->flush();\n\n            $roll = rand(0, 4);\n            $children = [$entity];\n            if ($roll) {\n                for ($i = 1; $i <= rand(0, 20); ++$i) {\n                    $children[] = $this->createChildren($children[array_rand($children, 1)], $manager);\n                }\n            }\n\n            $entity->createdAt = $this->getRandomTime($entity->entry->createdAt);\n            $entity->updateLastActive();\n        }\n\n        $manager->flush();\n    }\n\n    /**\n     * @return array<string, mixed>[]\n     */\n    private function provideRandomComments(int $count = 1): iterable\n    {\n        for ($i = 0; $i <= $count; ++$i) {\n            yield [\n                'body' => $this->faker->paragraphs($this->faker->numberBetween(1, 3), true),\n                'entry' => $this->getReference('entry_'.rand(1, EntryFixtures::ENTRIES_COUNT), Entry::class),\n                'user' => $this->getReference('user_'.rand(1, UserFixtures::USERS_COUNT), User::class),\n            ];\n        }\n    }\n\n    private function createChildren(EntryComment $parent, ObjectManager $manager): EntryComment\n    {\n        $dto = (new EntryCommentDto())->createWithParent(\n            $parent->entry,\n            $parent,\n            null,\n            $this->faker->paragraphs($this->faker->numberBetween(1, 3), true)\n        );\n        $dto->lang = 'en';\n\n        $entity = $this->commentManager->create($dto, $this->getReference('user_'.rand(1, UserFixtures::USERS_COUNT), User::class));\n\n        $roll = rand(1, 400);\n        if ($roll % 10) {\n            try {\n                $tempFile = $this->imageManager->download(\"https://picsum.photos/300/$roll?hash=$roll\");\n            } catch (\\Exception $e) {\n                $tempFile = null;\n            }\n\n            if ($tempFile) {\n                $image = $this->imageRepository->findOrCreateFromPath($tempFile);\n\n                $entity->image = $image;\n                $this->entityManager->flush();\n            }\n        }\n\n        $entity->createdAt = $this->getRandomTime($parent->createdAt);\n        $entity->updateLastActive();\n\n        $manager->flush();\n\n        return $entity;\n    }\n}\n"
  },
  {
    "path": "src/DataFixtures/EntryFixtures.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\DataFixtures;\n\nuse App\\DTO\\EntryDto;\nuse App\\Entity\\Magazine;\nuse App\\Entity\\User;\nuse App\\Repository\\ImageRepository;\nuse App\\Service\\EntryManager;\nuse App\\Service\\ImageManagerInterface;\nuse Doctrine\\Common\\DataFixtures\\DependentFixtureInterface;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Doctrine\\Persistence\\ObjectManager;\n\nclass EntryFixtures extends BaseFixture implements DependentFixtureInterface\n{\n    public const ENTRIES_COUNT = MagazineFixtures::MAGAZINES_COUNT * 15;\n\n    public function __construct(\n        private readonly EntryManager $entryManager,\n        private readonly ImageManagerInterface $imageManager,\n        private readonly ImageRepository $imageRepository,\n        private readonly EntityManagerInterface $entityManager,\n    ) {\n    }\n\n    public function getDependencies(): array\n    {\n        return [\n            MagazineFixtures::class,\n        ];\n    }\n\n    public function loadData(ObjectManager $manager): void\n    {\n        foreach ($this->provideRandomEntries(self::ENTRIES_COUNT) as $index => $entry) {\n            $dto = new EntryDto();\n            $dto->magazine = $entry['magazine'];\n            $dto->user = $entry['user'];\n            $dto->title = $entry['title'];\n            $dto->url = $entry['url'];\n            $dto->body = $entry['body'];\n            $dto->ip = $entry['ip'];\n            $dto->lang = 'en';\n\n            $entity = $this->entryManager->create($dto, $entry['user']);\n\n            $roll = rand(1, 400);\n            if ($roll % 5) {\n                try {\n                    $tempFile = $this->imageManager->download(\"https://picsum.photos/300/$roll?hash=$roll\");\n                } catch (\\Exception $e) {\n                    $tempFile = null;\n                }\n\n                if ($tempFile) {\n                    $image = $this->imageRepository->findOrCreateFromPath($tempFile);\n\n                    $entity->image = $image;\n                    $this->entityManager->flush();\n                }\n            }\n\n            $entity->createdAt = $this->getRandomTime();\n\n            $entity->updateCounts();\n            $entity->updateLastActive();\n            $entity->updateRanking();\n\n            $this->addReference('entry_'.$index, $entity);\n        }\n\n        $manager->flush();\n    }\n\n    /**\n     * @return array<string, mixed>[]\n     */\n    private function provideRandomEntries(int $count = 1): iterable\n    {\n        for ($i = 0; $i <= $count; ++$i) {\n            $isUrl = $this->faker->numberBetween(0, 1);\n            $body = $isUrl ? null : $this->faker->paragraphs($this->faker->numberBetween(1, 10), true);\n\n            yield [\n                'title' => $this->faker->realText($this->faker->numberBetween(10, 255)),\n                'url' => $isUrl ? $this->faker->url() : null,\n                'body' => $body,\n                'magazine' => $this->getReference('magazine_'.rand(1, (int) MagazineFixtures::MAGAZINES_COUNT), Magazine::class),\n                'user' => $this->getReference('user_'.rand(1, UserFixtures::USERS_COUNT), User::class),\n                'ip' => $this->faker->ipv4(),\n            ];\n        }\n    }\n}\n"
  },
  {
    "path": "src/DataFixtures/MagazineFixtures.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\DataFixtures;\n\nuse App\\DTO\\MagazineDto;\nuse App\\Entity\\User;\nuse App\\Repository\\ImageRepository;\nuse App\\Service\\ImageManagerInterface;\nuse App\\Service\\MagazineManager;\nuse Doctrine\\Common\\Collections\\ArrayCollection;\nuse Doctrine\\Common\\DataFixtures\\DependentFixtureInterface;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Doctrine\\Persistence\\ObjectManager;\n\nclass MagazineFixtures extends BaseFixture implements DependentFixtureInterface\n{\n    public const MAGAZINES_COUNT = UserFixtures::USERS_COUNT / 3;\n\n    public function __construct(\n        private readonly MagazineManager $magazineManager,\n        private readonly ImageManagerInterface $imageManager,\n        private readonly ImageRepository $imageRepository,\n        private readonly EntityManagerInterface $entityManager,\n    ) {\n    }\n\n    public function loadData(ObjectManager $manager): void\n    {\n        foreach ($this->provideRandomMagazines(self::MAGAZINES_COUNT) as $index => $magazine) {\n            $image = null;\n            $width = rand(100, 400);\n\n            try {\n                $tempFile = $this->imageManager->download(\"https://picsum.photos/{$width}/?hash=$width\");\n            } catch (\\Exception $e) {\n                $tempFile = null;\n            }\n\n            if ($tempFile) {\n                $image = $this->imageRepository->findOrCreateFromPath($tempFile);\n                $this->entityManager->flush();\n            }\n\n            $dto = new MagazineDto();\n            $dto->name = $magazine['name'];\n            $dto->title = $magazine['title'];\n            $dto->description = $magazine['description'];\n            $dto->rules = $magazine['rules'];\n            $dto->badges = $magazine['badges'];\n            $dto->icon = $image;\n\n            $entity = $this->magazineManager->create($dto, $magazine['user']);\n\n            $this->addReference('magazine_'.$index, $entity);\n        }\n\n        $manager->flush();\n    }\n\n    /**\n     * @return array<string, mixed>[]\n     */\n    private function provideRandomMagazines(int $count = 1): iterable\n    {\n        $titles = [];\n        for ($i = 0; $i <= $count; ++$i) {\n            $title = substr($this->faker->words($this->faker->numberBetween(1, 5), true), 0, 50);\n\n            if (\\in_array($title, $titles)) {\n                $title = $title.bin2hex(random_bytes(5));\n            }\n\n            $titles[] = $title;\n\n            yield [\n                'name' => substr($this->camelCase($title), 0, 24),\n                'title' => $title,\n                'user' => $this->getReference('user_'.rand(1, UserFixtures::USERS_COUNT), User::class),\n                'description' => rand(0, 3) ? null : $this->faker->realText($this->faker->numberBetween(10, 550)),\n                'rules' => rand(0, 3) ? null : $this->faker->realText($this->faker->numberBetween(10, 550)),\n                'badges' => new ArrayCollection(),\n            ];\n        }\n    }\n\n    public function getDependencies(): array\n    {\n        return [\n            UserFixtures::class,\n        ];\n    }\n}\n"
  },
  {
    "path": "src/DataFixtures/PostCommentFixtures.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\DataFixtures;\n\nuse App\\DTO\\PostCommentDto;\nuse App\\Entity\\Post;\nuse App\\Entity\\PostComment;\nuse App\\Entity\\User;\nuse App\\Repository\\ImageRepository;\nuse App\\Service\\ImageManagerInterface;\nuse App\\Service\\PostCommentManager;\nuse Doctrine\\Common\\DataFixtures\\DependentFixtureInterface;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Doctrine\\Persistence\\ObjectManager;\n\nclass PostCommentFixtures extends BaseFixture implements DependentFixtureInterface\n{\n    public const COMMENTS_COUNT = EntryFixtures::ENTRIES_COUNT * 3;\n\n    private PostCommentManager $postCommentManager;\n\n    public function __construct(\n        PostCommentManager $postCommentManager,\n        private readonly ImageManagerInterface $imageManager,\n        private readonly ImageRepository $imageRepository,\n        private readonly EntityManagerInterface $entityManager,\n    ) {\n        $this->postCommentManager = $postCommentManager;\n    }\n\n    public function getDependencies(): array\n    {\n        return [\n            PostFixtures::class,\n        ];\n    }\n\n    public function loadData(ObjectManager $manager): void\n    {\n        foreach ($this->provideRandomComments(self::COMMENTS_COUNT) as $index => $comment) {\n            $dto = new PostCommentDto();\n            $dto->post = $comment['post'];\n            $dto->body = $comment['body'];\n            $dto->lang = 'en';\n\n            $entity = $this->postCommentManager->create($dto, $comment['user']);\n\n            $manager->persist($entity);\n\n            $this->addReference('post_comment_'.$index, $entity);\n            $manager->flush();\n\n            $roll = rand(0, 4);\n            $children = [$entity];\n            if ($roll) {\n                for ($i = 1; $i <= rand(0, 20); ++$i) {\n                    $children[] = $this->createChildren($children[array_rand($children, 1)], $manager);\n                }\n            }\n\n            $entity->createdAt = $this->getRandomTime($entity->post->createdAt);\n            $entity->updateLastActive();\n        }\n\n        $manager->flush();\n    }\n\n    /**\n     * @return array<string, mixed>[]\n     */\n    private function provideRandomComments(int $count = 1): iterable\n    {\n        for ($i = 0; $i <= $count; ++$i) {\n            yield [\n                'body' => $this->faker->realText($this->faker->numberBetween(10, 1024)),\n                'post' => $this->getReference('post_'.rand(1, EntryFixtures::ENTRIES_COUNT), Post::class),\n                'user' => $this->getReference('user_'.rand(1, UserFixtures::USERS_COUNT), User::class),\n            ];\n        }\n    }\n\n    private function createChildren(PostComment $parent, ObjectManager $manager): PostComment\n    {\n        $dto = (new PostCommentDto())->createWithParent(\n            $parent->post,\n            $parent,\n            null,\n            $this->faker->realText($this->faker->numberBetween(10, 1024))\n        );\n        $dto->lang = 'en';\n\n        $entity = $this->postCommentManager->create(\n            $dto,\n            $this->getReference('user_'.rand(1, UserFixtures::USERS_COUNT), User::class)\n        );\n\n        $roll = rand(1, 400);\n        if ($roll % 10) {\n            try {\n                $tempFile = $this->imageManager->download(\"https://picsum.photos/300/$roll?hash=$roll\");\n            } catch (\\Exception $e) {\n                $tempFile = null;\n            }\n\n            if ($tempFile) {\n                $image = $this->imageRepository->findOrCreateFromPath($tempFile);\n\n                $entity->image = $image;\n                $this->entityManager->flush();\n            }\n        }\n\n        $entity->createdAt = $this->getRandomTime($parent->createdAt);\n        $entity->updateLastActive();\n\n        $manager->flush();\n\n        return $entity;\n    }\n}\n"
  },
  {
    "path": "src/DataFixtures/PostFixtures.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\DataFixtures;\n\nuse App\\DTO\\PostDto;\nuse App\\Entity\\Magazine;\nuse App\\Entity\\User;\nuse App\\Repository\\ImageRepository;\nuse App\\Service\\ImageManagerInterface;\nuse App\\Service\\PostManager;\nuse Doctrine\\Common\\DataFixtures\\DependentFixtureInterface;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Doctrine\\Persistence\\ObjectManager;\n\nclass PostFixtures extends BaseFixture implements DependentFixtureInterface\n{\n    public const ENTRIES_COUNT = MagazineFixtures::MAGAZINES_COUNT * 15;\n\n    public function __construct(\n        private readonly PostManager $postManager,\n        private readonly ImageManagerInterface $imageManager,\n        private readonly ImageRepository $imageRepository,\n        private readonly EntityManagerInterface $entityManager,\n    ) {\n    }\n\n    public function getDependencies(): array\n    {\n        return [\n            MagazineFixtures::class,\n        ];\n    }\n\n    public function loadData(ObjectManager $manager): void\n    {\n        foreach ($this->provideRandomPosts(self::ENTRIES_COUNT) as $index => $post) {\n            $dto = new PostDto();\n            $dto->magazine = $post['magazine'];\n            $dto->user = $post['user'];\n            $dto->body = $post['body'];\n            $dto->ip = $post['ip'];\n            $dto->lang = 'en';\n\n            $entity = $this->postManager->create($dto, $post['user']);\n\n            $roll = rand(1, 400);\n            if ($roll % 7) {\n                try {\n                    $tempFile = $this->imageManager->download(\"https://picsum.photos/300/$roll?hash=$roll\");\n                } catch (\\Exception $e) {\n                    $tempFile = null;\n                }\n\n                if ($tempFile) {\n                    $image = $this->imageRepository->findOrCreateFromPath($tempFile);\n\n                    $entity->image = $image;\n                    $this->entityManager->flush();\n                }\n            }\n\n            $entity->createdAt = $this->getRandomTime();\n            $entity->updateCounts();\n            $entity->updateLastActive();\n            $entity->updateRanking();\n\n            $this->addReference('post_'.$index, $entity);\n        }\n\n        $manager->flush();\n    }\n\n    /**\n     * @return array<string, mixed>[]\n     */\n    private function provideRandomPosts(int $count = 1): iterable\n    {\n        for ($i = 0; $i <= $count; ++$i) {\n            yield [\n                'body' => $this->faker->realText($this->faker->numberBetween(10, 1024)),\n                'magazine' => $this->getReference('magazine_'.rand(1, \\intval(MagazineFixtures::MAGAZINES_COUNT)), Magazine::class),\n                'user' => $this->getReference('user_'.rand(1, UserFixtures::USERS_COUNT), User::class),\n                'ip' => $this->faker->ipv4(),\n            ];\n        }\n    }\n}\n"
  },
  {
    "path": "src/DataFixtures/ReportFixtures.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\DataFixtures;\n\nuse App\\Entity\\Entry;\nuse App\\Entity\\EntryComment;\nuse App\\Entity\\EntryCommentReport;\nuse App\\Entity\\EntryReport;\nuse App\\Entity\\Post;\nuse App\\Entity\\PostComment;\nuse App\\Entity\\PostCommentReport;\nuse App\\Entity\\PostReport;\nuse App\\Entity\\User;\nuse App\\Event\\Report\\SubjectReportedEvent;\nuse Doctrine\\Common\\DataFixtures\\DependentFixtureInterface;\nuse Doctrine\\Persistence\\ObjectManager;\nuse Psr\\EventDispatcher\\EventDispatcherInterface;\n\nclass ReportFixtures extends BaseFixture implements DependentFixtureInterface\n{\n    public function __construct(private readonly EventDispatcherInterface $dispatcher)\n    {\n    }\n\n    public function loadData(ObjectManager $manager): void\n    {\n        $this->entries();\n        $this->entryComments();\n        $this->posts();\n        $this->postComments();\n\n        $this->manager->flush();\n    }\n\n    private function entries(): void\n    {\n        $randomNb = $this->getUniqueNb(\n            EntryFixtures::ENTRIES_COUNT,\n            \\intval(EntryFixtures::ENTRIES_COUNT / rand(2, 5))\n        );\n\n        foreach ($randomNb as $e) {\n            $roll = rand(0, 2);\n\n            if (0 === $roll) {\n                continue;\n            }\n\n            $r = new EntryReport(\n                $this->getReference('user_'.$this->getRandomNumber(UserFixtures::USERS_COUNT), User::class),\n                $this->getReference('entry_'.$e, Entry::class)\n            );\n\n            $this->manager->persist($r);\n\n            $this->dispatcher->dispatch(new SubjectReportedEvent($r));\n        }\n    }\n\n    /**\n     * @return int[]\n     */\n    private function getUniqueNb(int $max, int $quantity): array\n    {\n        $numbers = range(1, $max);\n        shuffle($numbers);\n\n        return \\array_slice($numbers, 0, $quantity);\n    }\n\n    public function getRandomNumber(int $max): int\n    {\n        $numbers = range(1, $max);\n        shuffle($numbers);\n\n        return $numbers[0];\n    }\n\n    private function entryComments(): void\n    {\n        $randomNb = $this->getUniqueNb(\n            EntryCommentFixtures::COMMENTS_COUNT,\n            \\intval(EntryCommentFixtures::COMMENTS_COUNT / rand(2, 5))\n        );\n\n        foreach ($randomNb as $c) {\n            $roll = rand(0, 2);\n\n            if (0 === $roll) {\n                continue;\n            }\n\n            $r = new EntryCommentReport(\n                $this->getReference('user_'.$this->getRandomNumber(UserFixtures::USERS_COUNT), User::class),\n                $this->getReference('entry_comment_'.$c, EntryComment::class)\n            );\n\n            $this->manager->persist($r);\n\n            $this->dispatcher->dispatch(new SubjectReportedEvent($r));\n        }\n    }\n\n    private function posts(): void\n    {\n        $randomNb = $this->getUniqueNb(\n            PostFixtures::ENTRIES_COUNT,\n            \\intval(PostFixtures::ENTRIES_COUNT / rand(2, 5))\n        );\n\n        foreach ($randomNb as $e) {\n            $roll = rand(0, 2);\n\n            if (0 === $roll) {\n                continue;\n            }\n\n            $r = new PostReport(\n                $this->getReference('user_'.$this->getRandomNumber(UserFixtures::USERS_COUNT), User::class),\n                $this->getReference('post_'.$e, Post::class)\n            );\n\n            $this->manager->persist($r);\n\n            $this->dispatcher->dispatch(new SubjectReportedEvent($r));\n        }\n    }\n\n    private function postComments(): void\n    {\n        $randomNb = $this->getUniqueNb(\n            PostCommentFixtures::COMMENTS_COUNT,\n            \\intval(PostCommentFixtures::COMMENTS_COUNT / rand(2, 5))\n        );\n\n        foreach ($randomNb as $c) {\n            $roll = rand(0, 2);\n\n            if (0 === $roll) {\n                continue;\n            }\n\n            $r = new PostCommentReport(\n                $this->getReference('user_'.$this->getRandomNumber(UserFixtures::USERS_COUNT), User::class),\n                $this->getReference('post_comment_'.$c, PostComment::class)\n            );\n\n            $this->manager->persist($r);\n\n            $this->dispatcher->dispatch(new SubjectReportedEvent($r));\n        }\n    }\n\n    public function getDependencies(): array\n    {\n        return [\n            EntryCommentFixtures::class,\n            PostCommentFixtures::class,\n        ];\n    }\n}\n"
  },
  {
    "path": "src/DataFixtures/SubFixtures.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\DataFixtures;\n\nuse App\\Entity\\Magazine;\nuse App\\Entity\\User;\nuse App\\Service\\MagazineManager;\nuse App\\Service\\UserManager;\nuse Doctrine\\Common\\DataFixtures\\DependentFixtureInterface;\nuse Doctrine\\Persistence\\ObjectManager;\n\nclass SubFixtures extends BaseFixture implements DependentFixtureInterface\n{\n    public function __construct(\n        private readonly MagazineManager $magazineManager,\n        private readonly UserManager $userManager,\n    ) {\n    }\n\n    public function loadData(ObjectManager $manager): void\n    {\n        for ($u = 1; $u <= UserFixtures::USERS_COUNT; ++$u) {\n            $this->magazines($u);\n            $this->users($u);\n        }\n    }\n\n    private function magazines(int $u): void\n    {\n        $randomNb = $this->getUniqueNb(\n            MagazineFixtures::MAGAZINES_COUNT,\n            \\intval(MagazineFixtures::MAGAZINES_COUNT / rand(2, 5))\n        );\n\n        foreach ($randomNb as $m) {\n            $roll = rand(0, 2);\n\n            if (0 === $roll) {\n                $this->magazineManager->block(\n                    $this->getReference('magazine_'.$m, Magazine::class),\n                    $this->getReference('user_'.$u, User::class)\n                );\n                continue;\n            }\n\n            $this->magazineManager->subscribe(\n                $this->getReference('magazine_'.$m, Magazine::class),\n                $this->getReference('user_'.$u, User::class)\n            );\n        }\n    }\n\n    /**\n     * @return int[]\n     */\n    private function getUniqueNb(int $max, int $quantity): array\n    {\n        $numbers = range(1, $max);\n        shuffle($numbers);\n\n        return \\array_slice($numbers, 0, $quantity);\n    }\n\n    private function users(int $u): void\n    {\n        $randomNb = $this->getUniqueNb(\n            UserFixtures::USERS_COUNT,\n            \\intval(UserFixtures::USERS_COUNT / rand(2, 5))\n        );\n\n        foreach ($randomNb as $f) {\n            $roll = rand(0, 2);\n\n            if (0 === $roll) {\n                $this->userManager->block(\n                    $this->getReference('user_'.$f, User::class),\n                    $this->getReference('user_'.$u, User::class)\n                );\n                continue;\n            }\n\n            $this->userManager->follow(\n                $this->getReference('user_'.$f, User::class),\n                $this->getReference('user_'.$u, User::class)\n            );\n        }\n    }\n\n    public function getDependencies(): array\n    {\n        return [\n            UserFixtures::class,\n            MagazineFixtures::class,\n        ];\n    }\n}\n"
  },
  {
    "path": "src/DataFixtures/UserFixtures.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\DataFixtures;\n\nuse App\\Entity\\User;\nuse App\\Repository\\ImageRepository;\nuse App\\Repository\\UserRepository;\nuse App\\Service\\ImageManagerInterface;\nuse Doctrine\\Persistence\\ObjectManager;\nuse Symfony\\Component\\PasswordHasher\\Hasher\\UserPasswordHasherInterface;\n\nclass UserFixtures extends BaseFixture\n{\n    public const USERS_COUNT = 9;\n\n    public function __construct(\n        private readonly UserPasswordHasherInterface $hasher,\n        private readonly ImageManagerInterface $imageManager,\n        private readonly ImageRepository $imageRepository,\n        private readonly UserRepository $userRepository,\n    ) {\n    }\n\n    public function loadData(ObjectManager $manager): void\n    {\n        foreach ($this->provideRandomUsers(self::USERS_COUNT) as $index => $user) {\n            $newUser = new User(\n                $user['email'],\n                $user['username'],\n                $user['password'],\n                $user['type']\n            );\n\n            $newUser->setPassword(\n                $this->hasher->hashPassword($newUser, $user['password'])\n            );\n\n            $newUser->notifyOnNewEntry = true;\n            $newUser->notifyOnNewEntryReply = true;\n            $newUser->notifyOnNewEntryCommentReply = true;\n            $newUser->notifyOnNewPostReply = true;\n            $newUser->notifyOnNewPostCommentReply = true;\n            $newUser->isVerified = true;\n\n            $manager->persist($newUser);\n\n            $this->addReference('user_'.$index, $newUser);\n\n            $manager->flush();\n\n            if ('demo' !== $user['username']) {\n                $rand = rand(1, 500);\n\n                try {\n                    $tempFile = $this->imageManager->download(\"https://picsum.photos/500/500?hash={$rand}\");\n                } catch (\\Exception $e) {\n                    $tempFile = null;\n                }\n\n                if ($tempFile) {\n                    $image = $this->imageRepository->findOrCreateFromPath($tempFile);\n                    $newUser->avatar = $image;\n                    $manager->flush();\n                }\n            }\n        }\n    }\n\n    /**\n     * @return array<string, string>[]\n     */\n    private function provideRandomUsers(int $count = 1): iterable\n    {\n        if (!$this->userRepository->findOneByUsername('demo')) {\n            yield [\n                'email' => 'demo@karab.in',\n                'username' => 'demo',\n                'password' => 'demo',\n                'type' => 'Person',\n            ];\n        }\n\n        for ($i = 0; $i <= $count; ++$i) {\n            yield [\n                'email' => $this->faker->email(),\n                'username' => str_replace('.', '_', $this->faker->userName()),\n                'password' => 'secret',\n                'type' => 'Person',\n            ];\n        }\n    }\n}\n"
  },
  {
    "path": "src/DataFixtures/VoteFixtures.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\DataFixtures;\n\nuse App\\Entity\\Entry;\nuse App\\Entity\\EntryComment;\nuse App\\Entity\\Post;\nuse App\\Entity\\PostComment;\nuse App\\Entity\\User;\nuse App\\Service\\VoteManager;\nuse Doctrine\\Common\\DataFixtures\\DependentFixtureInterface;\nuse Doctrine\\Persistence\\ObjectManager;\n\nclass VoteFixtures extends BaseFixture implements DependentFixtureInterface\n{\n    public function __construct(private readonly VoteManager $voteManager)\n    {\n    }\n\n    public function loadData(ObjectManager $manager): void\n    {\n        for ($u = 0; $u <= UserFixtures::USERS_COUNT; ++$u) {\n            $this->entries($u);\n            $this->entryComments($u);\n            $this->posts($u);\n            $this->postComments($u);\n        }\n    }\n\n    private function entries(int $u): void\n    {\n        $randomNb = $this->getUniqueNb(\n            EntryFixtures::ENTRIES_COUNT,\n            rand(0, 155),\n        );\n\n        foreach ($randomNb as $e) {\n            $roll = rand(0, 2);\n\n            if (0 === $roll) {\n                continue;\n            }\n\n            $this->voteManager->vote(\n                rand(0, 4) > 0 ? 1 : -1,\n                $this->getReference('entry_'.$e, Entry::class),\n                $this->getReference('user_'.$u, User::class)\n            );\n        }\n    }\n\n    /**\n     * @return int[]\n     */\n    private function getUniqueNb(int $max, int $quantity): array\n    {\n        $numbers = range(1, $max);\n        shuffle($numbers);\n\n        return \\array_slice($numbers, 0, $quantity);\n    }\n\n    private function entryComments(int $u): void\n    {\n        $randomNb = $this->getUniqueNb(\n            EntryCommentFixtures::COMMENTS_COUNT,\n            rand(0, 155),\n        );\n\n        foreach ($randomNb as $c) {\n            $roll = rand(0, 2);\n\n            if (0 === $roll) {\n                continue;\n            }\n\n            $this->voteManager->vote(\n                rand(0, 4) > 0 ? 1 : -1,\n                $this->getReference('entry_comment_'.$c, EntryComment::class),\n                $this->getReference('user_'.$u, User::class)\n            );\n        }\n    }\n\n    private function posts(int $u): void\n    {\n        $randomNb = $this->getUniqueNb(\n            PostFixtures::ENTRIES_COUNT,\n            rand(0, 155),\n        );\n\n        foreach ($randomNb as $e) {\n            $roll = rand(0, 2);\n\n            if (0 === $roll) {\n                continue;\n            }\n\n            $this->voteManager->vote(\n                rand(0, 4) > 0 ? 1 : -1,\n                $this->getReference('post_'.$e, Post::class),\n                $this->getReference('user_'.$u, User::class)\n            );\n        }\n    }\n\n    private function postComments(int $u): void\n    {\n        $randomNb = $this->getUniqueNb(\n            PostCommentFixtures::COMMENTS_COUNT,\n            rand(0, 155),\n        );\n\n        foreach ($randomNb as $c) {\n            $roll = rand(0, 2);\n\n            if (0 === $roll) {\n                continue;\n            }\n\n            $this->voteManager->vote(\n                rand(0, 4) > 0 ? 1 : -1,\n                $this->getReference('post_comment_'.$c, PostComment::class),\n                $this->getReference('user_'.$u, User::class)\n            );\n        }\n    }\n\n    public function getDependencies(): array\n    {\n        return [\n            EntryFixtures::class,\n            EntryCommentFixtures::class,\n            PostFixtures::class,\n            PostCommentFixtures::class,\n        ];\n    }\n}\n"
  },
  {
    "path": "src/DoctrineExtensions/DBAL/Types/Citext.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\DoctrineExtensions\\DBAL\\Types;\n\nuse Doctrine\\DBAL\\Platforms\\AbstractPlatform;\nuse Doctrine\\DBAL\\Types\\TextType;\n\n/**\n * original src: https://github.com/shiftonelabs/laravel-nomad/issues/2#issuecomment-463388050.\n */\nfinal class Citext extends TextType\n{\n    public const CITEXT = 'citext';\n\n    public function getName(): string\n    {\n        return self::CITEXT;\n    }\n\n    public function getSQLDeclaration(array $column, AbstractPlatform $platform): string\n    {\n        return $platform->getDoctrineTypeMapping(self::CITEXT);\n    }\n}\n"
  },
  {
    "path": "src/DoctrineExtensions/DBAL/Types/EnumApplicationStatus.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\DoctrineExtensions\\DBAL\\Types;\n\nuse App\\Enums\\EApplicationStatus;\n\nclass EnumApplicationStatus extends EnumType\n{\n    public function getName(): string\n    {\n        return 'EnumApplicationStatus';\n    }\n\n    public function getValues(): array\n    {\n        return EApplicationStatus::getValues();\n    }\n}\n"
  },
  {
    "path": "src/DoctrineExtensions/DBAL/Types/EnumDirectMessageSettings.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\DoctrineExtensions\\DBAL\\Types;\n\nuse App\\Enums\\EDirectMessageSettings;\n\nclass EnumDirectMessageSettings extends EnumType\n{\n    public function getName(): string\n    {\n        return 'EnumDirectMessageSettings';\n    }\n\n    public function getValues(): array\n    {\n        return EDirectMessageSettings::getValues();\n    }\n}\n"
  },
  {
    "path": "src/DoctrineExtensions/DBAL/Types/EnumFrontContentOptions.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\DoctrineExtensions\\DBAL\\Types;\n\nuse App\\Enums\\EFrontContentOptions;\n\nclass EnumFrontContentOptions extends EnumType\n{\n    public function getName(): string\n    {\n        return 'EnumFrontContentOptions';\n    }\n\n    public function getValues(): array\n    {\n        return EFrontContentOptions::getValues();\n    }\n}\n"
  },
  {
    "path": "src/DoctrineExtensions/DBAL/Types/EnumNotificationStatus.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\DoctrineExtensions\\DBAL\\Types;\n\nuse App\\Enums\\ENotificationStatus;\n\nclass EnumNotificationStatus extends EnumType\n{\n    public function getName(): string\n    {\n        return 'EnumNotificationStatus';\n    }\n\n    public function getValues(): array\n    {\n        return ENotificationStatus::getValues();\n    }\n}\n"
  },
  {
    "path": "src/DoctrineExtensions/DBAL/Types/EnumSortOptions.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\DoctrineExtensions\\DBAL\\Types;\n\nuse App\\Enums\\ESortOptions;\n\nclass EnumSortOptions extends EnumType\n{\n    public function getName(): string\n    {\n        return 'EnumSortOptions';\n    }\n\n    public function getValues(): array\n    {\n        return ESortOptions::getValues();\n    }\n}\n"
  },
  {
    "path": "src/DoctrineExtensions/DBAL/Types/EnumType.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\DoctrineExtensions\\DBAL\\Types;\n\nuse Doctrine\\DBAL\\Platforms\\AbstractPlatform;\nuse Doctrine\\DBAL\\Types\\Type;\n\n/**\n * See example by doctrine: https://www.doctrine-project.org/projects/doctrine-orm/en/2.20/cookbook/mysql-enums.html#solution-2-defining-a-type.\n */\nabstract class EnumType extends Type\n{\n    abstract public function getName(): string;\n\n    /**\n     * @return string[]\n     */\n    abstract public function getValues(): array;\n\n    public function getSQLDeclaration(array $column, AbstractPlatform $platform): string\n    {\n        $values = array_map(function ($val) { return \"'\".$val.\"'\"; }, $this->getValues());\n\n        return 'ENUM('.implode(', ', $values).')';\n    }\n\n    public function convertToPHPValue($value, AbstractPlatform $platform): mixed\n    {\n        return $value;\n    }\n\n    public function convertToDatabaseValue($value, AbstractPlatform $platform): mixed\n    {\n        if (!\\in_array($value, $this->getValues())) {\n            throw new \\InvalidArgumentException(\"Invalid '\".$this->getName().\"' value.\");\n        }\n\n        return $value;\n    }\n\n    public function requiresSQLCommentHint(AbstractPlatform $platform): bool\n    {\n        return true;\n    }\n}\n"
  },
  {
    "path": "src/Document/.gitignore",
    "content": ""
  },
  {
    "path": "src/Entity/.gitignore",
    "content": ""
  },
  {
    "path": "src/Entity/Activity.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity;\n\nuse App\\Controller\\ActivityPub\\ObjectController;\nuse App\\Entity\\Contracts\\ActivityPubActivityInterface;\nuse App\\Entity\\Contracts\\ActivityPubActorInterface;\nuse App\\Entity\\Traits\\CreatedAtTrait;\nuse Doctrine\\ORM\\Mapping\\Column;\nuse Doctrine\\ORM\\Mapping\\CustomIdGenerator;\nuse Doctrine\\ORM\\Mapping\\Entity;\nuse Doctrine\\ORM\\Mapping\\GeneratedValue;\nuse Doctrine\\ORM\\Mapping\\Id;\nuse Doctrine\\ORM\\Mapping\\JoinColumn;\nuse Doctrine\\ORM\\Mapping\\ManyToOne;\nuse Symfony\\Component\\Uid\\Uuid;\n\n#[Entity]\nclass Activity\n{\n    use CreatedAtTrait {\n        CreatedAtTrait::__construct as createdAtTraitConstruct;\n    }\n\n    #[Column(type: 'uuid'), Id, GeneratedValue(strategy: 'CUSTOM')]\n    #[CustomIdGenerator(class: 'doctrine.uuid_generator')]\n    public Uuid $uuid;\n\n    #[Column]\n    public string $type;\n\n    /**\n     * If the activity is a remote activity then we will not return it through the @see ObjectController.\n     */\n    #[Column(nullable: false, options: ['default' => false])]\n    public bool $isRemote = false;\n\n    #[ManyToOne, JoinColumn(nullable: true, onDelete: 'CASCADE')]\n    public ?User $userActor;\n\n    #[ManyToOne, JoinColumn(nullable: true, onDelete: 'CASCADE')]\n    public ?Magazine $magazineActor;\n\n    #[ManyToOne, JoinColumn(nullable: true, onDelete: 'CASCADE')]\n    public ?Magazine $audience = null;\n\n    #[ManyToOne, JoinColumn(referencedColumnName: 'uuid', nullable: true, onDelete: 'CASCADE', options: ['default' => null])]\n    public ?Activity $innerActivity = null;\n\n    #[Column(type: 'text', nullable: true)]\n    public ?string $innerActivityUrl = null;\n\n    #[ManyToOne, JoinColumn(nullable: true, onDelete: 'CASCADE')]\n    public ?Entry $objectEntry = null;\n\n    #[ManyToOne, JoinColumn(nullable: true, onDelete: 'CASCADE')]\n    public ?EntryComment $objectEntryComment = null;\n\n    #[ManyToOne, JoinColumn(nullable: true, onDelete: 'CASCADE')]\n    public ?Post $objectPost = null;\n\n    #[ManyToOne, JoinColumn(nullable: true, onDelete: 'CASCADE')]\n    public ?PostComment $objectPostComment = null;\n\n    #[ManyToOne, JoinColumn(nullable: true, onDelete: 'CASCADE')]\n    public ?Message $objectMessage = null;\n\n    #[ManyToOne, JoinColumn(nullable: true, onDelete: 'CASCADE')]\n    public ?User $objectUser = null;\n\n    #[ManyToOne, JoinColumn(nullable: true, onDelete: 'CASCADE')]\n    public ?Magazine $objectMagazine = null;\n\n    #[ManyToOne, JoinColumn(nullable: true, onDelete: 'CASCADE')]\n    public ?MagazineBan $objectMagazineBan = null;\n\n    #[Column(type: 'text', nullable: true)]\n    public ?string $objectGeneric = null;\n\n    #[Column(type: 'text', nullable: true)]\n    public ?string $targetString = null;\n\n    #[Column(type: 'text', nullable: true)]\n    public ?string $contentString = null;\n\n    /**\n     * This should only be set when the json should not get compiled.\n     */\n    #[Column(type: 'text', nullable: true)]\n    public ?string $activityJson = null;\n\n    public function __construct(string $type)\n    {\n        $this->type = $type;\n        $this->createdAtTraitConstruct();\n    }\n\n    public function setObject(ActivityPubActivityInterface|Entry|EntryComment|Post|PostComment|ActivityPubActorInterface|User|Magazine|MagazineBan|Activity|array|string $object): void\n    {\n        if ($object instanceof Entry) {\n            $this->objectEntry = $object;\n        } elseif ($object instanceof EntryComment) {\n            $this->objectEntryComment = $object;\n        } elseif ($object instanceof Post) {\n            $this->objectPost = $object;\n        } elseif ($object instanceof PostComment) {\n            $this->objectPostComment = $object;\n        } elseif ($object instanceof Message) {\n            $this->objectMessage = $object;\n        } elseif ($object instanceof User) {\n            $this->objectUser = $object;\n        } elseif ($object instanceof Magazine) {\n            $this->objectMagazine = $object;\n        } elseif ($object instanceof MagazineBan) {\n            $this->objectMagazineBan = $object;\n        } elseif ($object instanceof Activity) {\n            $this->innerActivity = $object;\n        } elseif (\\is_array($object)) {\n            if (isset($object['@context'])) {\n                unset($object['@context']);\n            }\n            $this->objectGeneric = json_encode($object);\n        } elseif (\\is_string($object)) {\n            $this->objectGeneric = $object;\n        } else {\n            throw new \\LogicException(\\get_class($object));\n        }\n    }\n\n    public function getObject(): Post|EntryComment|PostComment|Entry|Message|User|Magazine|MagazineBan|array|string|null\n    {\n        $o = $this->objectEntry ?? $this->objectEntryComment ?? $this->objectPost ?? $this->objectPostComment ?? $this->objectMessage ?? $this->objectUser ?? $this->objectMagazine ?? $this->objectMagazineBan;\n        if (null !== $o) {\n            return $o;\n        }\n        $o = json_decode($this->objectGeneric ?? '', associative: true);\n        if (JSON_ERROR_NONE === json_last_error()) {\n            return $o;\n        }\n\n        return $this->objectGeneric;\n    }\n\n    public function setActor(Magazine|User $actor): void\n    {\n        if ($actor instanceof User) {\n            $this->userActor = $actor;\n        } else {\n            $this->magazineActor = $actor;\n        }\n    }\n\n    public function getActor(): Magazine|User|null\n    {\n        return $this->userActor ?? $this->magazineActor;\n    }\n}\n"
  },
  {
    "path": "src/Entity/ApActivity.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity;\n\nuse App\\Entity\\Traits\\CreatedAtTrait;\nuse App\\Repository\\ApActivityRepository;\nuse Doctrine\\DBAL\\Types\\Types;\nuse Doctrine\\ORM\\Mapping\\Column;\nuse Doctrine\\ORM\\Mapping\\Entity;\nuse Doctrine\\ORM\\Mapping\\GeneratedValue;\nuse Doctrine\\ORM\\Mapping\\Id;\nuse Doctrine\\ORM\\Mapping\\JoinColumn;\nuse Doctrine\\ORM\\Mapping\\ManyToOne;\n\n#[Entity(repositoryClass: ApActivityRepository::class)]\nclass ApActivity\n{\n    use CreatedAtTrait {\n        CreatedAtTrait::__construct as createdAtTraitConstruct;\n    }\n\n    #[ManyToOne(targetEntity: User::class)]\n    #[JoinColumn(nullable: false, onDelete: 'CASCADE')]\n    public User $user;\n    #[ManyToOne(targetEntity: Magazine::class)]\n    #[JoinColumn(nullable: true, onDelete: 'CASCADE')]\n    public ?Magazine $magazine;\n    #[Column(type: 'string', nullable: false)]\n    public int $subjectId;\n    #[Column(type: 'string', nullable: false)]\n    public string $type;\n    #[Column(type: Types::JSONB, nullable: true)]\n    public string $body;\n    #[Id]\n    #[GeneratedValue]\n    #[Column(type: 'integer')]\n    private int $id;\n}\n"
  },
  {
    "path": "src/Entity/Badge.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity;\n\nuse Doctrine\\Common\\Collections\\Collection;\nuse Doctrine\\ORM\\Mapping\\Column;\nuse Doctrine\\ORM\\Mapping\\Entity;\nuse Doctrine\\ORM\\Mapping\\GeneratedValue;\nuse Doctrine\\ORM\\Mapping\\Id;\nuse Doctrine\\ORM\\Mapping\\JoinColumn;\nuse Doctrine\\ORM\\Mapping\\ManyToOne;\nuse Doctrine\\ORM\\Mapping\\OneToMany;\nuse Doctrine\\ORM\\Mapping\\UniqueConstraint;\n\n#[Entity]\n#[UniqueConstraint(name: 'badge_magazine_name_idx', columns: ['name', 'magazine_id'])]\nclass Badge\n{\n    #[ManyToOne(targetEntity: Magazine::class, inversedBy: 'badges')]\n    #[JoinColumn(onDelete: 'CASCADE')]\n    public Magazine $magazine;\n    #[Column(type: 'string', nullable: false)]\n    public ?string $name;\n    #[OneToMany(mappedBy: 'badge', targetEntity: EntryBadge::class, cascade: ['persist', 'remove'], orphanRemoval: true)]\n    public Collection $badges;\n    #[Id]\n    #[GeneratedValue]\n    #[Column(type: 'integer')]\n    private int $id;\n\n    public function __construct(Magazine $magazine, string $name)\n    {\n        $this->magazine = $magazine;\n        $this->name = $name;\n    }\n\n    public function getId(): ?int\n    {\n        return $this->id;\n    }\n\n    public function countBadges(): int\n    {\n        return $this->badges->count();\n    }\n}\n"
  },
  {
    "path": "src/Entity/Bookmark.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity;\n\nuse App\\Entity\\Traits\\CreatedAtTrait;\nuse Doctrine\\ORM\\Mapping\\Column;\nuse Doctrine\\ORM\\Mapping\\Entity;\nuse Doctrine\\ORM\\Mapping\\GeneratedValue;\nuse Doctrine\\ORM\\Mapping\\Id;\nuse Doctrine\\ORM\\Mapping\\JoinColumn;\nuse Doctrine\\ORM\\Mapping\\ManyToOne;\nuse Doctrine\\ORM\\Mapping\\UniqueConstraint;\n\n#[Entity]\n#[UniqueConstraint(name: 'bookmark_list_entry_entryComment_post_postComment_idx', columns: ['list_id', 'entry_id', 'entry_comment_id', 'post_id', 'post_comment_id'])]\nclass Bookmark\n{\n    use CreatedAtTrait {\n        CreatedAtTrait::__construct as createdAtTraitConstruct;\n    }\n\n    #[Column, Id, GeneratedValue]\n    private int $id;\n\n    #[ManyToOne(targetEntity: BookmarkList::class, inversedBy: 'entities')]\n    #[JoinColumn(nullable: false, onDelete: 'CASCADE')]\n    public BookmarkList $list;\n\n    #[ManyToOne, JoinColumn(nullable: false, onDelete: 'CASCADE')]\n    public User $user;\n\n    #[ManyToOne, JoinColumn(onDelete: 'CASCADE')]\n    public ?Entry $entry = null;\n\n    #[ManyToOne, JoinColumn(onDelete: 'CASCADE')]\n    public ?EntryComment $entryComment = null;\n\n    #[ManyToOne, JoinColumn(onDelete: 'CASCADE')]\n    public ?Post $post = null;\n\n    #[ManyToOne, JoinColumn(onDelete: 'CASCADE')]\n    public ?PostComment $postComment = null;\n\n    public function __construct(User $user, BookmarkList $list)\n    {\n        $this->user = $user;\n        $this->list = $list;\n        $this->createdAtTraitConstruct();\n    }\n\n    public function setContent(Post|EntryComment|PostComment|Entry $content): void\n    {\n        if ($content instanceof Entry) {\n            $this->entry = $content;\n        } elseif ($content instanceof EntryComment) {\n            $this->entryComment = $content;\n        } elseif ($content instanceof Post) {\n            $this->post = $content;\n        } elseif ($content instanceof PostComment) {\n            $this->postComment = $content;\n        }\n    }\n\n    public function getContent(): Entry|EntryComment|Post|PostComment\n    {\n        return $this->entry ?? $this->entryComment ?? $this->post ?? $this->postComment;\n    }\n}\n"
  },
  {
    "path": "src/Entity/BookmarkList.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity;\n\nuse Doctrine\\Common\\Collections\\ArrayCollection;\nuse Doctrine\\Common\\Collections\\Collection;\nuse Doctrine\\ORM\\Mapping\\Column;\nuse Doctrine\\ORM\\Mapping\\Entity;\nuse Doctrine\\ORM\\Mapping\\GeneratedValue;\nuse Doctrine\\ORM\\Mapping\\Id;\nuse Doctrine\\ORM\\Mapping\\JoinColumn;\nuse Doctrine\\ORM\\Mapping\\ManyToOne;\nuse Doctrine\\ORM\\Mapping\\OneToMany;\nuse Doctrine\\ORM\\Mapping\\UniqueConstraint;\n\n#[Entity]\n#[UniqueConstraint(columns: ['user_id', 'name'])]\nclass BookmarkList\n{\n    #[Column, Id, GeneratedValue]\n    private int $id;\n\n    #[OneToMany(mappedBy: 'list', targetEntity: Bookmark::class, orphanRemoval: true)]\n    #[JoinColumn(onDelete: 'CASCADE')]\n    public Collection $entities;\n\n    #[ManyToOne(inversedBy: 'bookmarkLists')]\n    #[JoinColumn(nullable: false, onDelete: 'CASCADE')]\n    public User $user;\n\n    #[Column(nullable: false)]\n    public string $name;\n\n    #[Column]\n    public bool $isDefault = false;\n\n    public function __construct(User $user, string $name, bool $isDefault = false)\n    {\n        $this->user = $user;\n        $this->name = $name;\n        $this->isDefault = $isDefault;\n        $this->entities = new ArrayCollection();\n    }\n\n    public function getId(): int\n    {\n        return $this->id;\n    }\n}\n"
  },
  {
    "path": "src/Entity/Client.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity;\n\nuse App\\Entity\\Traits\\CreatedAtTrait;\nuse Doctrine\\Common\\Collections\\ArrayCollection;\nuse Doctrine\\Common\\Collections\\Collection;\nuse Doctrine\\ORM\\Mapping\\Column;\nuse Doctrine\\ORM\\Mapping\\Entity;\nuse Doctrine\\ORM\\Mapping\\GeneratedValue;\nuse Doctrine\\ORM\\Mapping\\Id;\nuse Doctrine\\ORM\\Mapping\\JoinColumn;\nuse Doctrine\\ORM\\Mapping\\OneToMany;\nuse Doctrine\\ORM\\Mapping\\OneToOne;\nuse Doctrine\\ORM\\Mapping\\Table;\nuse League\\Bundle\\OAuth2ServerBundle\\Model\\AbstractClient;\nuse League\\OAuth2\\Server\\Entities\\ClientEntityInterface;\n\n#[Entity]\n#[Table(name: '`oauth2_client`')]\nclass Client extends AbstractClient implements ClientEntityInterface\n{\n    use CreatedAtTrait {\n        CreatedAtTrait::__construct as createdAtTraitConstruct;\n    }\n\n    #[Id]\n    #[GeneratedValue(strategy: 'NONE')]\n    #[Column(type: 'string', length: 32, unique: true)]\n    protected string $identifier;\n\n    #[Column(type: 'text', nullable: true)]\n    private ?string $description = null;\n\n    #[OneToOne(targetEntity: User::class)]\n    #[JoinColumn(nullable: true, onDelete: 'CASCADE')]\n    private ?User $user = null;\n\n    #[OneToOne(targetEntity: Image::class, cascade: ['persist'])]\n    #[JoinColumn(nullable: true)]\n    private ?Image $image = null;\n\n    #[Column(type: 'string', nullable: false)]\n    private ?string $contactEmail = null;\n\n    #[OneToMany(mappedBy: 'client', targetEntity: OAuth2UserConsent::class, cascade: ['persist', 'remove'], orphanRemoval: true)]\n    private ?Collection $oAuth2UserConsents = null;\n\n    #[OneToMany(mappedBy: 'client', targetEntity: OAuth2ClientAccess::class, cascade: ['persist', 'remove'], orphanRemoval: true)]\n    private Collection $oAuth2ClientAccesses;\n\n    public function __construct(string $name, string $identifier, ?string $secret)\n    {\n        parent::__construct($name, $identifier, $secret);\n        $this->oAuth2UserConsents = new ArrayCollection();\n        $this->oAuth2ClientAccesses = new ArrayCollection();\n        $this->createdAtTraitConstruct();\n    }\n\n    public function getDescription(): ?string\n    {\n        return $this->description;\n    }\n\n    public function setDescription(?string $description): self\n    {\n        $this->description = $description;\n\n        return $this;\n    }\n\n    public function getUser(): ?User\n    {\n        return $this->user;\n    }\n\n    public function setUser(User $user): self\n    {\n        $this->user = $user;\n\n        return $this;\n    }\n\n    public function getContactEmail(): ?string\n    {\n        return $this->contactEmail;\n    }\n\n    public function setContactEmail(string $contactEmail): self\n    {\n        $this->contactEmail = $contactEmail;\n\n        return $this;\n    }\n\n    public function getImage(): ?Image\n    {\n        return $this->image;\n    }\n\n    public function setImage(Image $image): self\n    {\n        $this->image = $image;\n\n        return $this;\n    }\n\n    /**\n     * @return Collection<int, OAuth2UserConsent>\n     */\n    public function getOAuth2UserConsents(): Collection\n    {\n        return $this->oAuth2UserConsents;\n    }\n\n    public function addOAuth2UserConsent(OAuth2UserConsent $oAuth2UserConsent): self\n    {\n        if (!$this->oAuth2UserConsents->contains($oAuth2UserConsent)) {\n            $this->oAuth2UserConsents->add($oAuth2UserConsent);\n            $oAuth2UserConsent->setClient($this);\n        }\n\n        return $this;\n    }\n\n    public function removeOAuth2UserConsent(OAuth2UserConsent $oAuth2UserConsent): self\n    {\n        if ($this->oAuth2UserConsents->removeElement($oAuth2UserConsent)) {\n            // set the owning side to null (unless already changed)\n            if ($oAuth2UserConsent->getClient() === $this) {\n                $oAuth2UserConsent->setClient(null);\n            }\n        }\n\n        return $this;\n    }\n\n    public function getRedirectUri(): string|array\n    {\n        return $this->getRedirectUris();\n    }\n\n    /**\n     * @return Collection<int, OAuth2ClientAccess>\n     */\n    public function getOAuth2ClientAccesses(): Collection\n    {\n        return $this->oAuth2ClientAccesses;\n    }\n\n    public function addOAuth2ClientAccess(OAuth2ClientAccess $oAuth2ClientAccess): self\n    {\n        if (!$this->oAuth2ClientAccesses->contains($oAuth2ClientAccess)) {\n            $this->oAuth2ClientAccesses->add($oAuth2ClientAccess);\n            $oAuth2ClientAccess->setClient($this);\n        }\n\n        return $this;\n    }\n\n    public function removeOAuth2ClientAccess(OAuth2ClientAccess $oAuth2ClientAccess): self\n    {\n        if ($this->oAuth2ClientAccesses->removeElement($oAuth2ClientAccess)) {\n            // set the owning side to null (unless already changed)\n            if ($oAuth2ClientAccess->getClient() === $this) {\n                $oAuth2ClientAccess->setClient(null);\n            }\n        }\n\n        return $this;\n    }\n}\n"
  },
  {
    "path": "src/Entity/Contracts/ActivityPubActivityInterface.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity\\Contracts;\n\nuse App\\Entity\\User;\n\ninterface ActivityPubActivityInterface\n{\n    public const FOLLOWERS = 'followers';\n    public const FOLLOWING = 'following';\n    public const INBOX = 'inbox';\n    public const OUTBOX = 'outbox';\n    public const CONTEXT = 'context';\n    public const CONTEXT_URL = 'https://www.w3.org/ns/activitystreams';\n    public const SECURITY_URL = 'https://w3id.org/security/v1';\n    public const PUBLIC_URL = 'https://www.w3.org/ns/activitystreams#Public';\n\n    public const ADDITIONAL_CONTEXTS = [\n        // namespaces\n        'ostatus' => 'http://ostatus.org#',\n        'schema' => 'http://schema.org#',\n        'toot' => 'http://joinmastodon.org/ns#',\n        'pt' => 'https://joinpeertube.org/ns#',\n        'lemmy' => 'https://join-lemmy.org/ns#',\n        // objects\n        'Hashtag' => 'as:Hashtag',\n        'PropertyValue' => 'schema:PropertyValue',\n        // properties\n        'manuallyApprovesFollowers' => 'as:manuallyApprovesFollowers',\n        'sensitive' => 'as:sensitive',\n        'value' => 'schema:value',\n        'blurhash' => 'toot:blurhash',\n        'focalPoint' => 'toot:focalPoint',\n        'votersCount' => 'toot:votersCount',\n        'featured' => 'toot:featured',\n        'commentsEnabled' => 'pt:commentsEnabled',\n        'postingRestrictedToMods' => 'lemmy:postingRestrictedToMods',\n        'stickied' => 'lemmy:stickied',\n    ];\n\n    public function getUser(): ?User;\n}\n"
  },
  {
    "path": "src/Entity/Contracts/ActivityPubActorInterface.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity\\Contracts;\n\ninterface ActivityPubActorInterface\n{\n    public function getApName(): string;\n\n    public function getPrivateKey(): ?string;\n\n    public function getPublicKey(): ?string;\n}\n"
  },
  {
    "path": "src/Entity/Contracts/ApiResourceInterface.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity\\Contracts;\n\ninterface ApiResourceInterface\n{\n    public function getId(): ?int;\n\n    public function getApId(): ?string;\n}\n"
  },
  {
    "path": "src/Entity/Contracts/CommentInterface.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity\\Contracts;\n\ninterface CommentInterface\n{\n}\n"
  },
  {
    "path": "src/Entity/Contracts/ContentInterface.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity\\Contracts;\n\nuse App\\Entity\\Magazine;\nuse App\\Entity\\User;\n\ninterface ContentInterface extends ApiResourceInterface\n{\n    public function getMagazine(): ?Magazine;\n\n    public function getUser(): ?User;\n}\n"
  },
  {
    "path": "src/Entity/Contracts/ContentVisibilityInterface.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity\\Contracts;\n\ninterface ContentVisibilityInterface extends ContentInterface\n{\n    public function getVisibility(): string;\n\n    public function isVisible(): bool;\n\n    public function isTrashed(): bool;\n\n    public function isPrivate(): bool;\n\n    public function isSoftDeleted(): bool;\n}\n"
  },
  {
    "path": "src/Entity/Contracts/DomainInterface.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity\\Contracts;\n\nuse App\\Entity\\Domain;\n\ninterface DomainInterface\n{\n    public function getUrl();\n\n    public function setDomain(Domain $domain): self;\n}\n"
  },
  {
    "path": "src/Entity/Contracts/FavouriteInterface.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity\\Contracts;\n\nuse App\\Entity\\User;\n\ninterface FavouriteInterface extends ContentInterface\n{\n    public function getId(): ?int;\n\n    public function getUser(): ?User;\n\n    public function updateCounts(): self;\n\n    public function isFavored(User $user): bool;\n\n    public function updateRanking(): void;\n\n    public function updateScore(): self;\n}\n"
  },
  {
    "path": "src/Entity/Contracts/NotificationInterface.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity\\Contracts;\n\nuse App\\Entity\\Magazine;\nuse App\\Entity\\User;\n\ninterface NotificationInterface extends ContentInterface\n{\n    public function getId(): ?int;\n\n    public function getMagazine(): ?Magazine;\n\n    public function getUser(): ?User;\n\n    public function getSubjectClassName(): string;\n}\n"
  },
  {
    "path": "src/Entity/Contracts/RankingInterface.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity\\Contracts;\n\ninterface RankingInterface\n{\n    public const DOWNVOTED_CUTOFF = -5;\n    public const NETSCORE_MULTIPLIER = 4500;\n    public const COMMENT_MULTIPLIER = 1500;\n    public const COMMENT_UNIQUE_MULTIPLIER = 5000;\n    public const COMMENT_DOWNVOTED_MULTIPLIER = 500;\n    public const MAX_ADVANTAGE = 86400;\n    public const MAX_PENALTY = 43200;\n\n    public function updateRanking(): void;\n\n    public function setRanking(int $ranking): void;\n\n    public function getRanking(): int;\n\n    public function getCommentCount(): int;\n\n    public function getUniqueCommentCount(): int;\n\n    public function getScore(): int;\n\n    public function getCreatedAt(): \\DateTimeImmutable;\n}\n"
  },
  {
    "path": "src/Entity/Contracts/ReportInterface.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity\\Contracts;\n\nuse App\\Entity\\User;\n\ninterface ReportInterface extends ContentInterface\n{\n    public function getId(): ?int;\n\n    public function getUser(): ?User;\n}\n"
  },
  {
    "path": "src/Entity/Contracts/VisibilityInterface.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity\\Contracts;\n\ninterface VisibilityInterface\n{\n    public const VISIBILITY_VISIBLE = 'visible';\n    public const VISIBILITY_SOFT_DELETED = 'soft_deleted';\n    public const VISIBILITY_TRASHED = 'trashed';\n    public const VISIBILITY_PRIVATE = 'private';\n\n    public function getVisibility(): string;\n\n    public function isVisible(): bool;\n\n    public function isTrashed(): bool;\n\n    public function isPrivate(): bool;\n\n    public function isSoftDeleted(): bool;\n\n    public function softDelete(): void;\n\n    public function trash(): void;\n\n    public function restore(): void;\n}\n"
  },
  {
    "path": "src/Entity/Contracts/VotableInterface.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity\\Contracts;\n\nuse App\\Entity\\User;\nuse App\\Entity\\Vote;\nuse Doctrine\\Common\\Collections\\Collection;\n\ninterface VotableInterface\n{\n    public const VOTE_UP = 1;\n    public const VOTE_NONE = 0;\n    public const VOTE_DOWN = -1;\n    public const VOTE_CHOICES = [\n        self::VOTE_DOWN,\n        self::VOTE_NONE,\n        self::VOTE_UP,\n    ];\n\n    public function getId(): int;\n\n    public function addVote(Vote $votable): self;\n\n    public function removeVote(Vote $votable): self;\n\n    public function getUpVotes(): Collection;\n\n    public function getDownVotes(): Collection;\n\n    public function countUpVotes(): int;\n\n    public function countDownVotes(): int;\n\n    public function countVotes(): int;\n\n    public function getUserChoice(User $user): int;\n\n    public function getUserVote(User $user): ?Vote;\n}\n"
  },
  {
    "path": "src/Entity/Contracts/VoteInterface.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity\\Contracts;\n\ninterface VoteInterface\n{\n    public function getSubject();\n}\n"
  },
  {
    "path": "src/Entity/Domain.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity;\n\nuse App\\Entity\\Contracts\\DomainInterface;\nuse App\\Repository\\DomainRepository;\nuse App\\Service\\DomainManager;\nuse Doctrine\\Common\\Collections\\ArrayCollection;\nuse Doctrine\\Common\\Collections\\Collection;\nuse Doctrine\\Common\\Collections\\Criteria;\nuse Doctrine\\ORM\\Mapping as ORM;\nuse Doctrine\\ORM\\Mapping\\Column;\nuse Doctrine\\ORM\\Mapping\\Entity;\nuse Doctrine\\ORM\\Mapping\\GeneratedValue;\nuse Doctrine\\ORM\\Mapping\\Id;\nuse Doctrine\\ORM\\Mapping\\OneToMany;\nuse Doctrine\\ORM\\Mapping\\Table;\n\n#[Entity(repositoryClass: DomainRepository::class)]\n#[Table]\n#[ORM\\UniqueConstraint(name: 'domain_name_idx', columns: ['name'])]\nclass Domain\n{\n    #[OneToMany(mappedBy: 'domain', targetEntity: Entry::class)]\n    public Collection $entries;\n    #[Column(type: 'string', nullable: false)]\n    public string $name;\n    #[Column(type: 'integer', nullable: false)]\n    public int $entryCount = 0;\n    #[Column(type: 'integer', options: ['default' => 0])]\n    public int $subscriptionsCount = 0;\n    #[OneToMany(mappedBy: 'domain', targetEntity: DomainSubscription::class, cascade: [\n        'persist',\n        'remove',\n    ], orphanRemoval: true)]\n    public Collection $subscriptions;\n    #[Id]\n    #[GeneratedValue]\n    #[Column(type: 'integer')]\n    private int $id;\n\n    public function __construct(DomainInterface $entry, string $name)\n    {\n        $this->name = $name;\n        $this->entries = new ArrayCollection();\n        $this->subscriptions = new ArrayCollection();\n\n        $this->addEntry($entry);\n    }\n\n    public function addEntry(DomainInterface $subject): self\n    {\n        if (!$this->entries->contains($subject)) {\n            $this->entries->add($subject);\n            $subject->setDomain($this);\n        }\n\n        $this->updateCounts();\n\n        return $this;\n    }\n\n    public function updateCounts()\n    {\n        $this->entryCount = $this->entries->count();\n    }\n\n    public function getId(): ?int\n    {\n        return $this->id;\n    }\n\n    public function removeEntry(DomainInterface $subject): self\n    {\n        if ($this->entries->removeElement($subject)) {\n            if ($subject->getDomain() === $this) {\n                $subject->setDomain(null);\n            }\n        }\n\n        $this->updateCounts();\n\n        return $this;\n    }\n\n    public function subscribe(User $user): self\n    {\n        if (!$this->isSubscribed($user)) {\n            $this->subscriptions->add($sub = new DomainSubscription($user, $this));\n            $sub->domain = $this;\n        }\n\n        $this->updateSubscriptionsCount();\n\n        return $this;\n    }\n\n    public function isSubscribed(User $user): bool\n    {\n        $criteria = Criteria::create()\n            ->where(Criteria::expr()->eq('user', $user));\n\n        return $this->subscriptions->matching($criteria)->count() > 0;\n    }\n\n    private function updateSubscriptionsCount(): void\n    {\n        $this->subscriptionsCount = $this->subscriptions->count();\n    }\n\n    public function unsubscribe(User $user): void\n    {\n        $criteria = Criteria::create()\n            ->where(Criteria::expr()->eq('user', $user));\n\n        $subscription = $this->subscriptions->matching($criteria)->first();\n\n        if ($this->subscriptions->removeElement($subscription)) {\n            if ($subscription->domain === $this) {\n                $subscription->domain = null;\n            }\n        }\n\n        $this->updateSubscriptionsCount();\n    }\n\n    public function shouldRatio(): bool\n    {\n        return DomainManager::shouldRatio($this->name);\n    }\n\n    public function __sleep()\n    {\n        return [];\n    }\n}\n"
  },
  {
    "path": "src/Entity/DomainBlock.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity;\n\nuse App\\Entity\\Traits\\CreatedAtTrait;\nuse Doctrine\\ORM\\Mapping\\Cache;\nuse Doctrine\\ORM\\Mapping\\Column;\nuse Doctrine\\ORM\\Mapping\\Entity;\nuse Doctrine\\ORM\\Mapping\\GeneratedValue;\nuse Doctrine\\ORM\\Mapping\\Id;\nuse Doctrine\\ORM\\Mapping\\JoinColumn;\nuse Doctrine\\ORM\\Mapping\\ManyToOne;\nuse Doctrine\\ORM\\Mapping\\Table;\nuse Doctrine\\ORM\\Mapping\\UniqueConstraint;\n\n#[Entity]\n#[Table]\n#[UniqueConstraint(name: 'domain_block_idx', columns: ['user_id', 'domain_id'])]\n#[Cache(usage: 'NONSTRICT_READ_WRITE')]\nclass DomainBlock\n{\n    use CreatedAtTrait {\n        CreatedAtTrait::__construct as createdAtTraitConstruct;\n    }\n\n    #[ManyToOne(targetEntity: User::class, inversedBy: 'blockedDomains')]\n    #[JoinColumn(nullable: false, onDelete: 'CASCADE')]\n    public ?User $user;\n    #[ManyToOne(targetEntity: Domain::class)]\n    #[JoinColumn(nullable: false, onDelete: 'CASCADE')]\n    public ?Domain $domain;\n    #[Id]\n    #[GeneratedValue]\n    #[Column(type: 'integer')]\n    private int $id;\n\n    public function __construct(User $user, Domain $domain)\n    {\n        $this->createdAtTraitConstruct();\n\n        $this->user = $user;\n        $this->domain = $domain;\n    }\n\n    public function getId(): ?int\n    {\n        return $this->id;\n    }\n\n    public function __sleep()\n    {\n        return [];\n    }\n}\n"
  },
  {
    "path": "src/Entity/DomainSubscription.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity;\n\nuse App\\Entity\\Traits\\CreatedAtTrait;\nuse App\\Repository\\DomainSubscriptionRepository;\nuse Doctrine\\ORM\\Mapping\\Column;\nuse Doctrine\\ORM\\Mapping\\Entity;\nuse Doctrine\\ORM\\Mapping\\GeneratedValue;\nuse Doctrine\\ORM\\Mapping\\Id;\nuse Doctrine\\ORM\\Mapping\\JoinColumn;\nuse Doctrine\\ORM\\Mapping\\ManyToOne;\nuse Doctrine\\ORM\\Mapping\\Table;\nuse Doctrine\\ORM\\Mapping\\UniqueConstraint;\n\n#[Entity(repositoryClass: DomainSubscriptionRepository::class)]\n#[Table]\n#[UniqueConstraint(name: 'domain_subscription_idx', columns: ['user_id', 'domain_id'])]\nclass DomainSubscription\n{\n    use CreatedAtTrait {\n        CreatedAtTrait::__construct as createdAtTraitConstruct;\n    }\n\n    #[ManyToOne(targetEntity: User::class, inversedBy: 'subscribedDomains')]\n    #[JoinColumn(nullable: false)]\n    public ?User $user;\n    #[ManyToOne(targetEntity: Domain::class, inversedBy: 'subscriptions')]\n    #[JoinColumn(nullable: false)]\n    public ?Domain $domain;\n    #[Id]\n    #[GeneratedValue]\n    #[Column(type: 'integer')]\n    private int $id;\n\n    public function __construct(User $user, Domain $domain)\n    {\n        $this->createdAtTraitConstruct();\n        $this->user = $user;\n        $this->domain = $domain;\n    }\n\n    public function getId(): ?int\n    {\n        return $this->id;\n    }\n\n    public function __sleep()\n    {\n        return [];\n    }\n}\n"
  },
  {
    "path": "src/Entity/Embed.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity;\n\nuse App\\Entity\\Traits\\CreatedAtTrait;\nuse App\\Repository\\EmbedRepository;\nuse Doctrine\\ORM\\Mapping\\Column;\nuse Doctrine\\ORM\\Mapping\\Entity;\nuse Doctrine\\ORM\\Mapping\\GeneratedValue;\nuse Doctrine\\ORM\\Mapping\\Id;\nuse Doctrine\\ORM\\Mapping\\Table;\nuse Doctrine\\ORM\\Mapping\\UniqueConstraint;\n\n#[Entity(repositoryClass: EmbedRepository::class)]\n#[Table]\n#[UniqueConstraint(name: 'url_idx', columns: ['url'])]\nclass Embed\n{\n    use CreatedAtTrait {\n        CreatedAtTrait::__construct as createdAtTraitConstruct;\n    }\n\n    #[Column(type: 'string', nullable: false)]\n    public string $url;\n    #[Column(type: 'boolean', nullable: false)]\n    public bool $hasEmbed = false;\n    #[Id]\n    #[GeneratedValue]\n    #[Column(type: 'integer')]\n    private int $id;\n\n    public function __construct(string $url, bool $embed)\n    {\n        $this->url = $url;\n        $this->hasEmbed = $embed;\n\n        $this->createdAtTraitConstruct();\n    }\n\n    public function getId(): ?int\n    {\n        return $this->id;\n    }\n}\n"
  },
  {
    "path": "src/Entity/Entry.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity;\n\nuse App\\Entity\\Contracts\\ActivityPubActivityInterface;\nuse App\\Entity\\Contracts\\CommentInterface;\nuse App\\Entity\\Contracts\\ContentVisibilityInterface;\nuse App\\Entity\\Contracts\\DomainInterface;\nuse App\\Entity\\Contracts\\FavouriteInterface;\nuse App\\Entity\\Contracts\\RankingInterface;\nuse App\\Entity\\Contracts\\ReportInterface;\nuse App\\Entity\\Contracts\\VisibilityInterface;\nuse App\\Entity\\Contracts\\VotableInterface;\nuse App\\Entity\\Traits\\ActivityPubActivityTrait;\nuse App\\Entity\\Traits\\CreatedAtTrait;\nuse App\\Entity\\Traits\\EditedAtTrait;\nuse App\\Entity\\Traits\\RankingTrait;\nuse App\\Entity\\Traits\\VisibilityTrait;\nuse App\\Entity\\Traits\\VotableTrait;\nuse App\\Repository\\EntryRepository;\nuse Doctrine\\Common\\Collections\\ArrayCollection;\nuse Doctrine\\Common\\Collections\\Collection;\nuse Doctrine\\Common\\Collections\\Criteria;\nuse Doctrine\\DBAL\\Types\\Types;\nuse Doctrine\\ORM\\Mapping\\Column;\nuse Doctrine\\ORM\\Mapping\\Entity;\nuse Doctrine\\ORM\\Mapping\\GeneratedValue;\nuse Doctrine\\ORM\\Mapping\\Id;\nuse Doctrine\\ORM\\Mapping\\Index;\nuse Doctrine\\ORM\\Mapping\\JoinColumn;\nuse Doctrine\\ORM\\Mapping\\ManyToOne;\nuse Doctrine\\ORM\\Mapping\\OneToMany;\nuse Doctrine\\ORM\\Mapping\\OrderBy;\nuse Webmozart\\Assert\\Assert;\n\n#[Entity(repositoryClass: EntryRepository::class)]\n#[Index(columns: ['visibility', 'is_adult'], name: 'entry_visibility_adult_idx')]\n#[Index(columns: ['is_adult'], name: 'entry_adult_idx')]\n#[Index(columns: ['ranking'], name: 'entry_ranking_idx')]\n#[Index(columns: ['score'], name: 'entry_score_idx')]\n#[Index(columns: ['comment_count'], name: 'entry_comment_count_idx')]\n#[Index(columns: ['created_at'], name: 'entry_created_at_idx')]\n#[Index(columns: ['last_active'], name: 'entry_last_active_at_idx')]\n#[Index(columns: ['body_ts'], name: 'entry_body_ts_idx')]\n#[Index(columns: ['title_ts'], name: 'entry_title_ts_idx')]\nclass Entry implements VotableInterface, CommentInterface, DomainInterface, VisibilityInterface, RankingInterface, ReportInterface, FavouriteInterface, ActivityPubActivityInterface, ContentVisibilityInterface\n{\n    use VotableTrait;\n    use RankingTrait;\n    use VisibilityTrait;\n    use ActivityPubActivityTrait;\n    use EditedAtTrait;\n    use CreatedAtTrait {\n        CreatedAtTrait::__construct as createdAtTraitConstruct;\n    }\n\n    public const ENTRY_TYPE_ARTICLE = 'article';\n    public const ENTRY_TYPE_LINK = 'link';\n    public const ENTRY_TYPE_IMAGE = 'image';\n    public const ENTRY_TYPE_VIDEO = 'video';\n    public const ENTRY_TYPE_OPTIONS = [\n        self::ENTRY_TYPE_ARTICLE,\n        self::ENTRY_TYPE_LINK,\n        self::ENTRY_TYPE_IMAGE,\n        self::ENTRY_TYPE_VIDEO,\n    ];\n    public const MAX_TITLE_LENGTH = 255;\n    public const MAX_BODY_LENGTH = 35000;\n\n    #[ManyToOne(targetEntity: User::class, inversedBy: 'entries')]\n    #[JoinColumn(nullable: false, onDelete: 'CASCADE')]\n    public User $user;\n    #[ManyToOne(targetEntity: Magazine::class, inversedBy: 'entries')]\n    #[JoinColumn(nullable: false, onDelete: 'CASCADE')]\n    public ?Magazine $magazine;\n    #[ManyToOne(targetEntity: Image::class, cascade: ['persist'])]\n    #[JoinColumn(nullable: true)]\n    public ?Image $image = null;\n    #[ManyToOne(targetEntity: Domain::class, inversedBy: 'entries')]\n    #[JoinColumn(nullable: true)]\n    public ?Domain $domain = null;\n    #[Column(type: 'string', nullable: true)]\n    public ?string $slug = null;\n    #[Column(type: 'string', length: self::MAX_TITLE_LENGTH, nullable: false)]\n    public string $title;\n    #[Column(type: 'string', length: 2048, nullable: true)]\n    public ?string $url = null;\n    #[Column(type: 'text', length: self::MAX_BODY_LENGTH, nullable: true)]\n    public ?string $body = null;\n    #[Column(type: 'string', nullable: false)]\n    public string $type = self::ENTRY_TYPE_ARTICLE;\n    #[Column(type: 'string', nullable: false)]\n    public string $lang = 'en';\n    #[Column(type: 'boolean', options: ['default' => false])]\n    public bool $isOc = false;\n    #[Column(type: 'boolean', nullable: false)]\n    public bool $hasEmbed = false;\n    #[Column(type: 'integer', nullable: false)]\n    public int $commentCount = 0;\n    #[Column(type: 'integer', options: ['default' => 0])]\n    public int $favouriteCount = 0;\n    #[Column(type: 'integer', nullable: false)]\n    public int $score = 0;\n    #[Column(type: 'boolean', nullable: false)]\n    public bool $isAdult = false;\n    #[Column(type: 'boolean', nullable: false)]\n    public bool $sticky = false;\n    #[Column(type: 'boolean', nullable: false, options: ['default' => false])]\n    public bool $isLocked = false;\n    #[Column(type: 'datetimetz')]\n    public ?\\DateTime $lastActive = null;\n    #[Column(type: 'string', nullable: true)]\n    public ?string $ip = null;\n    #[Column(type: Types::JSONB, nullable: true)]\n    public ?array $mentions = null;\n    #[OneToMany(mappedBy: 'entry', targetEntity: EntryComment::class, cascade: ['persist', 'remove'], fetch: 'EXTRA_LAZY', orphanRemoval: true)]\n    public Collection $comments;\n    #[OneToMany(mappedBy: 'entry', targetEntity: EntryVote::class, cascade: ['persist', 'remove'], fetch: 'EXTRA_LAZY', orphanRemoval: true)]\n    #[OrderBy(['createdAt' => 'DESC'])]\n    public Collection $votes;\n    #[OneToMany(mappedBy: 'entry', targetEntity: EntryReport::class, cascade: ['persist', 'remove'], fetch: 'EXTRA_LAZY', orphanRemoval: true)]\n    public Collection $reports;\n    #[OneToMany(mappedBy: 'entry', targetEntity: EntryFavourite::class, cascade: ['persist', 'remove'], fetch: 'EXTRA_LAZY', orphanRemoval: true)]\n    #[OrderBy(['createdAt' => 'DESC'])]\n    public Collection $favourites;\n    #[OneToMany(mappedBy: 'entry', targetEntity: EntryCreatedNotification::class, cascade: ['persist', 'remove'], fetch: 'EXTRA_LAZY', orphanRemoval: true)]\n    public Collection $notifications;\n    #[OneToMany(mappedBy: 'entry', targetEntity: HashtagLink::class, cascade: ['persist', 'remove'], fetch: 'EXTRA_LAZY', orphanRemoval: true)]\n    public Collection $hashtags;\n    #[OneToMany(mappedBy: 'entry', targetEntity: EntryBadge::class, cascade: ['remove', 'persist'], fetch: 'EXTRA_LAZY', orphanRemoval: true)]\n    public Collection $badges;\n    public array $children = [];\n    #[Id]\n    #[GeneratedValue]\n    #[Column(type: 'integer')]\n    private int $id;\n    #[Column(type: 'text', nullable: true, insertable: false, updatable: false, options: ['default' => 'english'])]\n    private string $titleTs;\n    #[Column(type: 'text', nullable: true, insertable: false, updatable: false, options: ['default' => 'english'])]\n    private ?string $bodyTs = null;\n    public bool $cross = false;\n\n    public function __construct(\n        string $title,\n        ?string $url,\n        ?string $body,\n        Magazine $magazine,\n        User $user,\n        bool $isAdult,\n        ?bool $isOc,\n        ?string $lang,\n        ?string $ip = null,\n    ) {\n        $this->title = $title;\n        $this->url = $url;\n        $this->body = $body;\n        $this->magazine = $magazine;\n        $this->user = $user;\n        $this->isAdult = $isAdult;\n        $this->isOc = $isOc;\n        $this->lang = $lang;\n        $this->ip = $ip;\n        $this->comments = new ArrayCollection();\n        $this->votes = new ArrayCollection();\n        $this->reports = new ArrayCollection();\n        $this->favourites = new ArrayCollection();\n        $this->notifications = new ArrayCollection();\n        $this->badges = new ArrayCollection();\n        $this->hashtags = new ArrayCollection();\n\n        $user->addEntry($this);\n\n        $this->createdAtTraitConstruct();\n\n        $this->updateLastActive();\n    }\n\n    public function updateLastActive(): void\n    {\n        $this->comments->get(-1);\n\n        $criteria = Criteria::create()\n            ->andWhere(Criteria::expr()->eq('visibility', VisibilityInterface::VISIBILITY_VISIBLE))\n            ->orderBy(['createdAt' => 'DESC'])\n            ->setMaxResults(1);\n\n        $lastComment = $this->comments->matching($criteria)->first();\n\n        if ($lastComment) {\n            $this->lastActive = \\DateTime::createFromImmutable($lastComment->createdAt);\n        } else {\n            $this->lastActive = \\DateTime::createFromImmutable($this->createdAt);\n        }\n    }\n\n    public function getId(): int\n    {\n        return $this->id;\n    }\n\n    public function getApId(): ?string\n    {\n        return $this->apId;\n    }\n\n    public function setBadges(Badge ...$badges)\n    {\n        $this->badges->clear();\n\n        foreach ($badges as $badge) {\n            $this->badges->add(new EntryBadge($this, $badge));\n        }\n    }\n\n    public function softDelete(): void\n    {\n        $this->visibility = VisibilityInterface::VISIBILITY_SOFT_DELETED;\n    }\n\n    public function trash(): void\n    {\n        $this->visibility = VisibilityInterface::VISIBILITY_TRASHED;\n    }\n\n    public function restore(): void\n    {\n        $this->visibility = VisibilityInterface::VISIBILITY_VISIBLE;\n    }\n\n    public function addComment(EntryComment $comment): self\n    {\n        if (!$this->comments->contains($comment)) {\n            $this->comments->add($comment);\n            $comment->entry = $this;\n        }\n\n        $this->updateCounts();\n        $this->updateRanking();\n        $this->updateLastActive();\n\n        return $this;\n    }\n\n    public function updateCounts(): self\n    {\n        $criteria = Criteria::create()\n            ->andWhere(Criteria::expr()->eq('visibility', VisibilityInterface::VISIBILITY_VISIBLE));\n\n        $this->commentCount = $this->comments->matching($criteria)->count();\n        $this->favouriteCount = $this->favourites->count();\n\n        return $this;\n    }\n\n    public function removeComment(EntryComment $comment): self\n    {\n        if ($this->comments->removeElement($comment)) {\n            if ($comment->entry === $this) {\n                $comment->entry = null;\n            }\n        }\n\n        $this->updateCounts();\n        $this->updateRanking();\n        $this->updateLastActive();\n\n        return $this;\n    }\n\n    public function addVote(Vote $vote): self\n    {\n        Assert::isInstanceOf($vote, EntryVote::class);\n\n        if (!$this->votes->contains($vote)) {\n            $this->votes->add($vote);\n            $vote->entry = $this;\n        }\n\n        $this->updateScore();\n        $this->updateRanking();\n\n        return $this;\n    }\n\n    public function updateScore(): self\n    {\n        $this->score = ($this->apShareCount ?? $this->getUpVotes()->count()) + ($this->apLikeCount ?? $this->favouriteCount) - ($this->apDislikeCount ?? $this->getDownVotes()->count());\n\n        return $this;\n    }\n\n    public function removeVote(Vote $vote): self\n    {\n        Assert::isInstanceOf($vote, EntryVote::class);\n\n        if ($this->votes->removeElement($vote)) {\n            if ($vote->entry === $this) {\n                $vote->entry = null;\n            }\n        }\n\n        $this->updateScore();\n        $this->updateRanking();\n\n        return $this;\n    }\n\n    public function isAuthor(User $user): bool\n    {\n        return $user === $this->user;\n    }\n\n    public function getShortTitle(?int $length = 60): string\n    {\n        $body = wordwrap($this->title, $length);\n        $body = explode(\"\\n\", $body);\n\n        return trim($body[0]).(isset($body[1]) ? '...' : '');\n    }\n\n    public function getShortDesc(?int $length = 330): string\n    {\n        $body = wordwrap($this->body ?? '', $length);\n        $body = explode(\"\\n\", $body);\n\n        return trim($body[0]).(isset($body[1]) ? '...' : '');\n    }\n\n    public function getUrl(): ?string\n    {\n        return $this->url;\n    }\n\n    public function setDomain(Domain $domain): DomainInterface\n    {\n        $this->domain = $domain;\n\n        return $this;\n    }\n\n    public function getCommentCount(): int\n    {\n        return $this->commentCount;\n    }\n\n    public function getUniqueCommentCount(): int\n    {\n        $users = [];\n        $count = 0;\n        foreach ($this->comments as $comment) {\n            if (!\\in_array($comment->user, $users)) {\n                $users[] = $comment->user;\n                ++$count;\n            }\n        }\n\n        return $count;\n    }\n\n    public function getScore(): int\n    {\n        return $this->score;\n    }\n\n    public function getMagazine(): ?Magazine\n    {\n        return $this->magazine;\n    }\n\n    public function getUser(): ?User\n    {\n        return $this->user;\n    }\n\n    public function isAdult(): bool\n    {\n        return $this->isAdult || $this->magazine->isAdult;\n    }\n\n    public function isFavored(User $user): bool\n    {\n        $criteria = Criteria::create()\n            ->where(Criteria::expr()->eq('user', $user));\n\n        return $this->favourites->matching($criteria)->count() > 0;\n    }\n\n    public function getAuthorComment(): ?string\n    {\n        return null;\n    }\n\n    public function getDescription(): string\n    {\n        return ''; // @todo get first author comment\n    }\n\n    public function __sleep()\n    {\n        return [];\n    }\n}\n"
  },
  {
    "path": "src/Entity/EntryBadge.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity;\n\nuse Doctrine\\ORM\\Mapping\\Column;\nuse Doctrine\\ORM\\Mapping\\Entity;\nuse Doctrine\\ORM\\Mapping\\GeneratedValue;\nuse Doctrine\\ORM\\Mapping\\Id;\nuse Doctrine\\ORM\\Mapping\\JoinColumn;\nuse Doctrine\\ORM\\Mapping\\ManyToOne;\n\n#[Entity]\nclass EntryBadge\n{\n    #[ManyToOne(targetEntity: Badge::class, inversedBy: 'badges')]\n    #[JoinColumn]\n    public Badge $badge;\n    #[ManyToOne(targetEntity: Entry::class, inversedBy: 'badges')]\n    #[JoinColumn]\n    public Entry $entry;\n    #[Id]\n    #[GeneratedValue]\n    #[Column(type: 'integer')]\n    private int $id;\n\n    public function __construct(Entry $entry, Badge $badge)\n    {\n        $this->entry = $entry;\n        $this->badge = $badge;\n    }\n\n    public function getId(): ?int\n    {\n        return $this->id;\n    }\n\n    public function __toString(): string\n    {\n        return $this->badge->name;\n    }\n}\n"
  },
  {
    "path": "src/Entity/EntryComment.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity;\n\nuse App\\Entity\\Contracts\\ActivityPubActivityInterface;\nuse App\\Entity\\Contracts\\ContentInterface;\nuse App\\Entity\\Contracts\\FavouriteInterface;\nuse App\\Entity\\Contracts\\ReportInterface;\nuse App\\Entity\\Contracts\\VisibilityInterface;\nuse App\\Entity\\Contracts\\VotableInterface;\nuse App\\Entity\\Traits\\ActivityPubActivityTrait;\nuse App\\Entity\\Traits\\CreatedAtTrait;\nuse App\\Entity\\Traits\\EditedAtTrait;\nuse App\\Entity\\Traits\\VisibilityTrait;\nuse App\\Entity\\Traits\\VotableTrait;\nuse App\\Repository\\Criteria as MbinCriteria;\nuse App\\Repository\\EntryCommentRepository;\nuse App\\Utils\\ArrayUtils;\nuse App\\Utils\\DownvotesMode;\nuse Doctrine\\Common\\Collections\\ArrayCollection;\nuse Doctrine\\Common\\Collections\\Collection;\nuse Doctrine\\Common\\Collections\\Criteria;\nuse Doctrine\\ORM\\Mapping\\Column;\nuse Doctrine\\ORM\\Mapping\\Entity;\nuse Doctrine\\ORM\\Mapping\\GeneratedValue;\nuse Doctrine\\ORM\\Mapping\\Id;\nuse Doctrine\\ORM\\Mapping\\Index;\nuse Doctrine\\ORM\\Mapping\\JoinColumn;\nuse Doctrine\\ORM\\Mapping\\ManyToOne;\nuse Doctrine\\ORM\\Mapping\\OneToMany;\nuse Doctrine\\ORM\\Mapping\\OrderBy;\nuse Webmozart\\Assert\\Assert;\n\n#[Entity(repositoryClass: EntryCommentRepository::class)]\n#[Index(columns: ['up_votes'], name: 'entry_comment_up_votes_idx')]\n#[Index(columns: ['last_active'], name: 'entry_comment_last_active_at_idx')]\n#[Index(columns: ['created_at'], name: 'entry_comment_created_at_idx')]\n#[Index(columns: ['body_ts'], name: 'entry_comment_body_ts_idx')]\nclass EntryComment implements VotableInterface, VisibilityInterface, ReportInterface, FavouriteInterface, ActivityPubActivityInterface\n{\n    use VotableTrait;\n    use VisibilityTrait;\n    use ActivityPubActivityTrait;\n    use EditedAtTrait;\n    use CreatedAtTrait {\n        CreatedAtTrait::__construct as createdAtTraitConstruct;\n    }\n\n    #[ManyToOne(targetEntity: User::class, inversedBy: 'entryComments')]\n    #[JoinColumn(nullable: false, onDelete: 'CASCADE')]\n    public User $user;\n    #[ManyToOne(targetEntity: Entry::class, inversedBy: 'comments')]\n    #[JoinColumn(nullable: false, onDelete: 'CASCADE')]\n    public ?Entry $entry;\n    #[ManyToOne(targetEntity: Magazine::class)]\n    #[JoinColumn(nullable: false, onDelete: 'CASCADE')]\n    public ?Magazine $magazine;\n    #[ManyToOne(targetEntity: Image::class, cascade: ['persist'])]\n    #[JoinColumn(nullable: true)]\n    public ?Image $image = null;\n    #[ManyToOne(targetEntity: EntryComment::class, inversedBy: 'children')]\n    #[JoinColumn(nullable: true, onDelete: 'CASCADE')]\n    public ?EntryComment $parent = null;\n    #[ManyToOne(targetEntity: EntryComment::class, inversedBy: 'nested')]\n    #[JoinColumn(nullable: true, onDelete: 'CASCADE')]\n    public ?EntryComment $root = null;\n    #[Column(type: 'text', length: 4500)]\n    public ?string $body = null;\n    #[Column(type: 'string', nullable: false)]\n    public string $lang = 'en';\n    #[Column(type: 'boolean', nullable: false)]\n    public bool $isAdult = false;\n    #[Column(type: 'integer', options: ['default' => 0])]\n    public int $favouriteCount = 0;\n    #[Column(type: 'datetimetz')]\n    public ?\\DateTime $lastActive = null;\n    #[Column(type: 'string', nullable: true)]\n    public ?string $ip = null;\n    #[Column(type: 'json', nullable: true)]\n    public ?array $mentions = null;\n    #[OneToMany(mappedBy: 'parent', targetEntity: EntryComment::class, cascade: ['persist', 'remove'], orphanRemoval: true)]\n    #[OrderBy(['createdAt' => 'ASC'])]\n    public Collection $children;\n    #[OneToMany(mappedBy: 'root', targetEntity: EntryComment::class, cascade: ['persist', 'remove'], orphanRemoval: true)]\n    #[OrderBy(['createdAt' => 'ASC'])]\n    public Collection $nested;\n    #[OneToMany(mappedBy: 'comment', targetEntity: EntryCommentVote::class, cascade: ['persist', 'remove'], fetch: 'EXTRA_LAZY', orphanRemoval: true)]\n    public Collection $votes;\n    #[OneToMany(mappedBy: 'entryComment', targetEntity: EntryCommentReport::class, cascade: ['remove'], fetch: 'EXTRA_LAZY', orphanRemoval: true)]\n    public Collection $reports;\n    #[OneToMany(mappedBy: 'entryComment', targetEntity: EntryCommentFavourite::class, cascade: ['remove'], fetch: 'EXTRA_LAZY', orphanRemoval: true)]\n    public Collection $favourites;\n    #[OneToMany(mappedBy: 'entryComment', targetEntity: EntryCommentCreatedNotification::class, cascade: ['remove'], fetch: 'EXTRA_LAZY', orphanRemoval: true)]\n    public Collection $notifications;\n    #[OneToMany(mappedBy: 'entryComment', targetEntity: HashtagLink::class, cascade: ['persist', 'remove'], fetch: 'EXTRA_LAZY', orphanRemoval: true)]\n    public Collection $hashtags;\n    #[Id]\n    #[GeneratedValue]\n    #[Column(type: 'integer')]\n    private int $id;\n    #[Column(type: 'text', nullable: true, insertable: false, updatable: false, options: ['default' => 'english'])]\n    private $bodyTs;\n\n    public function __construct(\n        string $body,\n        ?Entry $entry,\n        User $user,\n        ?EntryComment $parent = null,\n        ?string $ip = null,\n    ) {\n        $this->body = $body;\n        $this->entry = $entry;\n        $this->user = $user;\n        $this->parent = $parent;\n        $this->ip = $ip;\n        $this->votes = new ArrayCollection();\n        $this->children = new ArrayCollection();\n        $this->reports = new ArrayCollection();\n        $this->favourites = new ArrayCollection();\n        $this->notifications = new ArrayCollection();\n\n        if ($parent) {\n            $this->root = $parent->root ?? $parent;\n        }\n\n        $this->createdAtTraitConstruct();\n        $this->updateLastActive();\n    }\n\n    public function updateLastActive(): void\n    {\n        $this->lastActive = \\DateTime::createFromImmutable($this->createdAt);\n\n        if (!$this->root) {\n            return;\n        }\n\n        $this->root->lastActive = \\DateTime::createFromImmutable($this->createdAt);\n    }\n\n    public function getId(): int\n    {\n        return $this->id;\n    }\n\n    public function getApId(): ?string\n    {\n        return $this->apId;\n    }\n\n    public function addVote(Vote $vote): self\n    {\n        Assert::isInstanceOf($vote, EntryCommentVote::class);\n\n        if (!$this->votes->contains($vote)) {\n            $this->votes->add($vote);\n            $vote->setComment($this);\n        }\n\n        return $this;\n    }\n\n    public function removeVote(Vote $vote): self\n    {\n        Assert::isInstanceOf($vote, EntryCommentVote::class);\n\n        if ($this->votes->removeElement($vote)) {\n            // set the owning side to null (unless already changed)\n            if ($vote->comment === $this) {\n                $vote->setComment(null);\n            }\n        }\n\n        return $this;\n    }\n\n    public function getChildrenRecursive(int &$startIndex = 0): \\Traversable\n    {\n        foreach ($this->children as $child) {\n            yield $startIndex++ => $child;\n            yield from $child->getChildrenRecursive($startIndex);\n        }\n    }\n\n    public function softDelete(): void\n    {\n        $this->visibility = VisibilityInterface::VISIBILITY_SOFT_DELETED;\n    }\n\n    public function trash(): void\n    {\n        $this->visibility = VisibilityInterface::VISIBILITY_TRASHED;\n    }\n\n    public function restore(): void\n    {\n        $this->visibility = VisibilityInterface::VISIBILITY_VISIBLE;\n    }\n\n    public function isAuthor(User $user): bool\n    {\n        return $user === $this->user;\n    }\n\n    public function getShortTitle(?int $length = 60): string\n    {\n        $body = wordwrap($this->body ?? '', $length);\n        $body = explode(\"\\n\", $body);\n\n        return trim($body[0]).(isset($body[1]) ? '...' : '');\n    }\n\n    public function getMagazine(): ?Magazine\n    {\n        return $this->magazine;\n    }\n\n    public function getUser(): ?User\n    {\n        return $this->user;\n    }\n\n    public function updateCounts(): self\n    {\n        $this->favouriteCount = $this->favourites->count();\n\n        return $this;\n    }\n\n    public function isFavored(User $user): bool\n    {\n        $criteria = Criteria::create()\n            ->where(Criteria::expr()->eq('user', $user));\n\n        return $this->favourites->matching($criteria)->count() > 0;\n    }\n\n    public function __sleep()\n    {\n        return [];\n    }\n\n    public function updateRanking(): void\n    {\n    }\n\n    public function updateScore(): self\n    {\n        return $this;\n    }\n\n    public function getParentSubject(): ?ContentInterface\n    {\n        return $this->entry;\n    }\n\n    public function containsBannedHashtags(): bool\n    {\n        foreach ($this->hashtags as /** @var HashtagLink $hashtag */ $hashtag) {\n            if ($hashtag->hashtag->banned) {\n                return true;\n            }\n        }\n\n        return false;\n    }\n\n    /**\n     * @param 'profile'|'comments' $filterRealm\n     */\n    public function containsFilteredWords(User $loggedInUser, string $filterRealm): bool\n    {\n        foreach ($loggedInUser->getCurrentFilterLists() as $list) {\n            if (!$list->$filterRealm) {\n                continue;\n            }\n\n            foreach ($list->words as $word) {\n                if ($word['exactMatch']) {\n                    if (false !== mb_strpos($this->body, $word['word'])) {\n                        return true;\n                    }\n                } else {\n                    if (false !== mb_stripos($this->body, $word['word'])) {\n                        return true;\n                    }\n                }\n            }\n        }\n\n        return false;\n    }\n\n    /**\n     * @param 'profile'|'comments' $filterRealm\n     */\n    public function getChildrenByCriteria(MbinCriteria $entryCommentCriteria, DownvotesMode $downvoteMode, ?User $loggedInUser, string $filterRealm): array\n    {\n        $criteria = Criteria::create();\n\n        if ($entryCommentCriteria->languages) {\n            $criteria->andwhere(Criteria::expr()->in('lang', $entryCommentCriteria->languages));\n        }\n\n        if (MbinCriteria::AP_LOCAL === $entryCommentCriteria->federation) {\n            $criteria->andWhere(Criteria::expr()->isNull('apId'));\n        } elseif (MbinCriteria::AP_FEDERATED === $entryCommentCriteria->federation) {\n            $criteria->andWhere(Criteria::expr()->isNotNull('apId'));\n        }\n\n        if (MbinCriteria::TIME_ALL !== $entryCommentCriteria->time) {\n            $criteria->andWhere(Criteria::expr()->gte('createdAt', $entryCommentCriteria->getSince()));\n        }\n\n        $children = $this->children\n            ->matching($criteria)\n            ->filter(fn (EntryComment $comment) => !$comment->containsBannedHashtags() && (!$loggedInUser || !$comment->containsFilteredWords($loggedInUser, $filterRealm)))\n            ->toArray();\n\n        switch ($entryCommentCriteria->sortOption) {\n            case MbinCriteria::SORT_TOP:\n                if (DownvotesMode::Disabled === $downvoteMode) {\n                    uasort($children, fn (EntryComment $a, EntryComment $b) => ArrayUtils::numCompareDescending($a->favouriteCount + $a->upVotes, $b->favouriteCount + $b->upVotes));\n                } else {\n                    uasort($children, fn (EntryComment $a, EntryComment $b) => ArrayUtils::numCompareDescending($a->favouriteCount + $a->upVotes - $a->downVotes, $b->favouriteCount + $b->upVotes - $b->downVotes));\n                }\n                break;\n            case MbinCriteria::SORT_HOT:\n                uasort($children, fn (EntryComment $a, EntryComment $b) => ArrayUtils::numCompareDescending($a->favouriteCount, $b->favouriteCount));\n\n                break;\n            case MbinCriteria::SORT_ACTIVE:\n                uasort($children, fn (EntryComment $a, EntryComment $b) => ArrayUtils::numCompareDescending($a->lastActive->getTimestamp(), $b->lastActive->getTimestamp()));\n\n                break;\n            case MbinCriteria::SORT_OLD:\n                uasort($children, fn (EntryComment $a, EntryComment $b) => ArrayUtils::numCompareAscending($a->createdAt->getTimestamp(), $b->createdAt->getTimestamp()));\n\n                break;\n            case MbinCriteria::SORT_NEW:\n                uasort($children, fn (EntryComment $a, EntryComment $b) => ArrayUtils::numCompareDescending($a->createdAt->getTimestamp(), $b->createdAt->getTimestamp()));\n\n                break;\n            default:\n        }\n\n        return $children;\n    }\n}\n"
  },
  {
    "path": "src/Entity/EntryCommentCreatedNotification.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity;\n\nuse App\\Payloads\\PushNotification;\nuse Doctrine\\ORM\\Mapping\\Entity;\nuse Doctrine\\ORM\\Mapping\\JoinColumn;\nuse Doctrine\\ORM\\Mapping\\ManyToOne;\nuse Symfony\\Component\\Routing\\Generator\\UrlGeneratorInterface;\nuse Symfony\\Contracts\\Translation\\TranslatorInterface;\n\n#[Entity]\nclass EntryCommentCreatedNotification extends Notification\n{\n    #[ManyToOne(targetEntity: EntryComment::class, inversedBy: 'notifications')]\n    #[JoinColumn(nullable: true, onDelete: 'CASCADE')]\n    public ?EntryComment $entryComment = null;\n\n    public function __construct(User $receiver, EntryComment $comment)\n    {\n        parent::__construct($receiver);\n\n        $this->entryComment = $comment;\n    }\n\n    public function getSubject(): EntryComment\n    {\n        return $this->entryComment;\n    }\n\n    public function getComment(): EntryComment\n    {\n        return $this->entryComment;\n    }\n\n    public function getType(): string\n    {\n        return 'entry_comment_created_notification';\n    }\n\n    public function getMessage(TranslatorInterface $trans, string $locale, UrlGeneratorInterface $urlGenerator): PushNotification\n    {\n        $message = \\sprintf('%s %s: %s', $this->entryComment->user->username, $trans->trans('added_new_comment'), $this->entryComment->getShortTitle());\n        $slash = $this->entryComment->user->avatar && !str_starts_with('/', $this->entryComment->user->avatar->filePath) ? '/' : '';\n        $avatarUrl = $this->entryComment->user->avatar ? '/media/cache/resolve/avatar_thumb'.$slash.$this->entryComment->user->avatar->filePath : null;\n        $url = $urlGenerator->generate('entry_comment_view', [\n            'entry_id' => $this->entryComment->entry->getId(),\n            'magazine_name' => $this->entryComment->magazine->name,\n            'slug' => $this->entryComment->entry->slug ?? '-',\n            'comment_id' => $this->entryComment->getId(),\n        ]);\n\n        return new PushNotification($this->getId(), $message, $trans->trans('notification_title_new_comment', locale: $locale), actionUrl: $url, avatarUrl: $avatarUrl);\n    }\n}\n"
  },
  {
    "path": "src/Entity/EntryCommentDeletedNotification.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity;\n\nuse App\\Payloads\\PushNotification;\nuse Doctrine\\ORM\\Mapping\\Entity;\nuse Doctrine\\ORM\\Mapping\\JoinColumn;\nuse Doctrine\\ORM\\Mapping\\ManyToOne;\nuse Symfony\\Component\\Routing\\Generator\\UrlGeneratorInterface;\nuse Symfony\\Contracts\\Translation\\TranslatorInterface;\n\n#[Entity]\nclass EntryCommentDeletedNotification extends Notification\n{\n    #[ManyToOne(targetEntity: EntryComment::class, inversedBy: 'notifications')]\n    #[JoinColumn(nullable: true)]\n    public ?EntryComment $entryComment = null;\n\n    public function __construct(User $receiver, EntryComment $comment)\n    {\n        parent::__construct($receiver);\n\n        $this->entryComment = $comment;\n    }\n\n    public function getSubject(): EntryComment\n    {\n        return $this->entryComment;\n    }\n\n    public function getComment(): EntryComment\n    {\n        return $this->entryComment;\n    }\n\n    public function getType(): string\n    {\n        return 'entry_comment_deleted_notification';\n    }\n\n    public function getMessage(TranslatorInterface $trans, string $locale, UrlGeneratorInterface $urlGenerator): PushNotification\n    {\n        $message = \\sprintf('%s %s - %s', $trans->trans('comment'), $this->entryComment->getShortTitle(), $this->entryComment->isTrashed() ? $trans->trans('removed') : $trans->trans('deleted'));\n        $slash = $this->entryComment->user->avatar && !str_starts_with('/', $this->entryComment->user->avatar->filePath) ? '/' : '';\n        $avatarUrl = $this->entryComment->user->avatar ? '/media/cache/resolve/avatar_thumb'.$slash.$this->entryComment->user->avatar->filePath : null;\n        $url = $urlGenerator->generate('entry_comment_view', [\n            'entry_id' => $this->entryComment->entry->getId(),\n            'magazine_name' => $this->entryComment->magazine->name,\n            'slug' => $this->entryComment->entry->slug ?? '-',\n            'comment_id' => $this->entryComment->getId(),\n        ]);\n\n        return new PushNotification($this->getId(), $message, $trans->trans('notification_title_removed_comment', locale: $locale), actionUrl: $url, avatarUrl: $avatarUrl);\n    }\n}\n"
  },
  {
    "path": "src/Entity/EntryCommentEditedNotification.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity;\n\nuse App\\Payloads\\PushNotification;\nuse Doctrine\\ORM\\Mapping\\Entity;\nuse Doctrine\\ORM\\Mapping\\JoinColumn;\nuse Doctrine\\ORM\\Mapping\\ManyToOne;\nuse Symfony\\Component\\Routing\\Generator\\UrlGeneratorInterface;\nuse Symfony\\Contracts\\Translation\\TranslatorInterface;\n\n#[Entity]\nclass EntryCommentEditedNotification extends Notification\n{\n    #[ManyToOne(targetEntity: EntryComment::class, inversedBy: 'notifications')]\n    #[JoinColumn(nullable: true)]\n    public ?EntryComment $entryComment = null;\n\n    public function __construct(User $receiver, EntryComment $comment)\n    {\n        parent::__construct($receiver);\n\n        $this->entryComment = $comment;\n    }\n\n    public function getSubject(): EntryComment\n    {\n        return $this->entryComment;\n    }\n\n    public function getComment(): EntryComment\n    {\n        return $this->entryComment;\n    }\n\n    public function getType(): string\n    {\n        return 'entry_comment_edited_notification';\n    }\n\n    public function getMessage(TranslatorInterface $trans, string $locale, UrlGeneratorInterface $urlGenerator): PushNotification\n    {\n        $message = \\sprintf('%s %s - %s', $this->entryComment->user->username, $trans->trans('edited_comment'), $this->entryComment->getShortTitle());\n        $slash = $this->entryComment->user->avatar && !str_starts_with('/', $this->entryComment->user->avatar->filePath) ? '/' : '';\n        $avatarUrl = $this->entryComment->user->avatar ? '/media/cache/resolve/avatar_thumb'.$slash.$this->entryComment->user->avatar->filePath : null;\n        $url = $urlGenerator->generate('entry_comment_view', [\n            'entry_id' => $this->entryComment->entry->getId(),\n            'magazine_name' => $this->entryComment->magazine->name,\n            'slug' => $this->entryComment->entry->slug ?? '-',\n            'comment_id' => $this->entryComment->getId(),\n        ]);\n\n        return new PushNotification($this->getId(), $message, $trans->trans('notification_title_edited_comment', locale: $locale), actionUrl: $url, avatarUrl: $avatarUrl);\n    }\n}\n"
  },
  {
    "path": "src/Entity/EntryCommentFavourite.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity;\n\nuse Doctrine\\ORM\\Mapping\\Cache;\nuse Doctrine\\ORM\\Mapping\\Entity;\nuse Doctrine\\ORM\\Mapping\\JoinColumn;\nuse Doctrine\\ORM\\Mapping\\ManyToOne;\n\n#[Entity]\n#[Cache(usage: 'NONSTRICT_READ_WRITE')]\nclass EntryCommentFavourite extends Favourite\n{\n    #[ManyToOne(targetEntity: EntryComment::class, inversedBy: 'favourites')]\n    #[JoinColumn(nullable: true, onDelete: 'CASCADE')]\n    public ?EntryComment $entryComment = null;\n\n    public function __construct(User $user, EntryComment $comment)\n    {\n        parent::__construct($user);\n\n        $this->magazine = $comment->magazine;\n        $this->entryComment = $comment;\n    }\n\n    public function getSubject(): EntryComment\n    {\n        return $this->entryComment;\n    }\n\n    public function clearSubject(): Favourite\n    {\n        $this->entryComment = null;\n\n        return $this;\n    }\n\n    public function getType(): string\n    {\n        return 'entry_comment';\n    }\n}\n"
  },
  {
    "path": "src/Entity/EntryCommentMentionedNotification.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity;\n\nuse App\\Payloads\\PushNotification;\nuse Doctrine\\ORM\\Mapping\\Entity;\nuse Doctrine\\ORM\\Mapping\\ManyToOne;\nuse Symfony\\Component\\Routing\\Generator\\UrlGeneratorInterface;\nuse Symfony\\Contracts\\Translation\\TranslatorInterface;\n\n#[Entity]\nclass EntryCommentMentionedNotification extends Notification\n{\n    #[ManyToOne(targetEntity: EntryComment::class, inversedBy: 'notifications')]\n    public ?EntryComment $entryComment;\n\n    public function __construct(User $receiver, EntryComment $comment)\n    {\n        parent::__construct($receiver);\n\n        $this->entryComment = $comment;\n    }\n\n    public function getSubject(): EntryComment\n    {\n        return $this->entryComment;\n    }\n\n    public function getComment(): EntryComment\n    {\n        return $this->entryComment;\n    }\n\n    public function getType(): string\n    {\n        return 'entry_comment_mentioned_notification';\n    }\n\n    public function getMessage(TranslatorInterface $trans, string $locale, UrlGeneratorInterface $urlGenerator): PushNotification\n    {\n        $message = \\sprintf('%s %s - %s', $this->entryComment->user->username, $trans->trans('mentioned_you'), $this->entryComment->getShortTitle());\n        $slash = $this->entryComment->user->avatar && !str_starts_with('/', $this->entryComment->user->avatar->filePath) ? '/' : '';\n        $avatarUrl = $this->entryComment->user->avatar ? '/media/cache/resolve/avatar_thumb'.$slash.$this->entryComment->user->avatar->filePath : null;\n        $url = $urlGenerator->generate('entry_comment_view', [\n            'entry_id' => $this->entryComment->entry->getId(),\n            'magazine_name' => $this->entryComment->magazine->name,\n            'slug' => $this->entryComment->entry->slug ?? '-',\n            'comment_id' => $this->entryComment->getId(),\n        ]);\n\n        return new PushNotification($this->getId(), $message, $trans->trans('notification_title_mention', locale: $locale), actionUrl: $url, avatarUrl: $avatarUrl);\n    }\n}\n"
  },
  {
    "path": "src/Entity/EntryCommentReplyNotification.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity;\n\nuse App\\Payloads\\PushNotification;\nuse Doctrine\\ORM\\Mapping\\Entity;\nuse Doctrine\\ORM\\Mapping\\JoinColumn;\nuse Doctrine\\ORM\\Mapping\\ManyToOne;\nuse Symfony\\Component\\Routing\\Generator\\UrlGeneratorInterface;\nuse Symfony\\Contracts\\Translation\\TranslatorInterface;\n\n#[Entity]\nclass EntryCommentReplyNotification extends Notification\n{\n    #[ManyToOne(targetEntity: EntryComment::class, inversedBy: 'notifications')]\n    #[JoinColumn(nullable: true)]\n    public ?EntryComment $entryComment = null;\n\n    public function __construct(User $receiver, EntryComment $comment)\n    {\n        parent::__construct($receiver);\n\n        $this->entryComment = $comment;\n    }\n\n    public function getSubject(): EntryComment\n    {\n        return $this->entryComment;\n    }\n\n    public function getComment(): EntryComment\n    {\n        return $this->entryComment;\n    }\n\n    public function getType(): string\n    {\n        return 'entry_comment_reply_notification';\n    }\n\n    public function getMessage(TranslatorInterface $trans, string $locale, UrlGeneratorInterface $urlGenerator): PushNotification\n    {\n        $message = \\sprintf('%s %s - %s', $this->entryComment->user->username, $trans->trans('replied_to_your_comment'), $this->entryComment->getShortTitle());\n        $slash = $this->entryComment->user->avatar && !str_starts_with('/', $this->entryComment->user->avatar->filePath) ? '/' : '';\n        $avatarUrl = $this->entryComment->user->avatar ? '/media/cache/resolve/avatar_thumb'.$slash.$this->entryComment->user->avatar->filePath : null;\n        $url = $urlGenerator->generate('entry_comment_view', [\n            'entry_id' => $this->entryComment->entry->getId(),\n            'magazine_name' => $this->entryComment->magazine->name,\n            'slug' => $this->entryComment->entry->slug ?? '-',\n            'comment_id' => $this->entryComment->getId(),\n        ]);\n\n        return new PushNotification($this->getId(), $message, $trans->trans('notification_title_new_reply', locale: $locale), actionUrl: $url, avatarUrl: $avatarUrl);\n    }\n}\n"
  },
  {
    "path": "src/Entity/EntryCommentReport.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity;\n\nuse Doctrine\\ORM\\Mapping\\Entity;\nuse Doctrine\\ORM\\Mapping\\JoinColumn;\nuse Doctrine\\ORM\\Mapping\\ManyToOne;\n\n#[Entity]\nclass EntryCommentReport extends Report\n{\n    #[ManyToOne(targetEntity: EntryComment::class, inversedBy: 'reports')]\n    #[JoinColumn(nullable: true, onDelete: 'CASCADE')]\n    public ?EntryComment $entryComment = null;\n\n    public function __construct(User $reporting, EntryComment $comment, ?string $reason = null)\n    {\n        parent::__construct($reporting, $comment->user, $comment->magazine, $reason);\n\n        $this->entryComment = $comment;\n    }\n\n    public function getSubject(): EntryComment\n    {\n        return $this->entryComment;\n    }\n\n    public function clearSubject(): Report\n    {\n        $this->entryComment = null;\n\n        return $this;\n    }\n\n    public function getType(): string\n    {\n        return 'entry_comment';\n    }\n}\n"
  },
  {
    "path": "src/Entity/EntryCommentVote.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity;\n\nuse App\\Entity\\Contracts\\VotableInterface;\nuse Doctrine\\ORM\\Mapping\\AssociationOverride;\nuse Doctrine\\ORM\\Mapping\\AssociationOverrides;\nuse Doctrine\\ORM\\Mapping\\Cache;\nuse Doctrine\\ORM\\Mapping\\Entity;\nuse Doctrine\\ORM\\Mapping\\JoinColumn;\nuse Doctrine\\ORM\\Mapping\\ManyToOne;\nuse Doctrine\\ORM\\Mapping\\Table;\nuse Doctrine\\ORM\\Mapping\\UniqueConstraint;\n\n#[Entity]\n#[Table]\n#[UniqueConstraint(name: 'user_entry_comment_vote_idx', columns: ['user_id', 'comment_id'])]\n#[AssociationOverrides([\n    new AssociationOverride(name: 'user', inversedBy: 'entryCommentVotes'),\n])]\n#[Cache(usage: 'NONSTRICT_READ_WRITE')]\nclass EntryCommentVote extends Vote\n{\n    #[ManyToOne(targetEntity: EntryComment::class, inversedBy: 'votes')]\n    #[JoinColumn(name: 'comment_id', nullable: false, onDelete: 'CASCADE')]\n    public ?EntryComment $comment;\n\n    public function __construct(int $choice, User $user, EntryComment $comment)\n    {\n        parent::__construct($choice, $user, $comment->user);\n\n        $this->comment = $comment;\n    }\n\n    public function getComment(): EntryComment\n    {\n        return $this->comment;\n    }\n\n    public function setComment(?EntryComment $comment): self\n    {\n        $this->comment = $comment;\n\n        return $this;\n    }\n\n    public function getSubject(): VotableInterface\n    {\n        return $this->comment;\n    }\n}\n"
  },
  {
    "path": "src/Entity/EntryCreatedNotification.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity;\n\nuse App\\Payloads\\PushNotification;\nuse Doctrine\\ORM\\Mapping\\Entity;\nuse Doctrine\\ORM\\Mapping\\JoinColumn;\nuse Doctrine\\ORM\\Mapping\\ManyToOne;\nuse Symfony\\Component\\Routing\\Generator\\UrlGeneratorInterface;\nuse Symfony\\Contracts\\Translation\\TranslatorInterface;\n\n#[Entity]\nclass EntryCreatedNotification extends Notification\n{\n    #[ManyToOne(targetEntity: Entry::class, inversedBy: 'notifications')]\n    #[JoinColumn(nullable: true, onDelete: 'CASCADE')]\n    public ?Entry $entry = null;\n\n    public function __construct(User $receiver, Entry $entry)\n    {\n        parent::__construct($receiver);\n\n        $this->entry = $entry;\n    }\n\n    public function getSubject(): Entry\n    {\n        return $this->entry;\n    }\n\n    public function getType(): string\n    {\n        return 'entry_created_notification';\n    }\n\n    public function getMessage(TranslatorInterface $trans, string $locale, UrlGeneratorInterface $urlGenerator): PushNotification\n    {\n        $message = \\sprintf('%s %s - %s', $this->entry->user->username, $trans->trans('added_new_thread'), $this->entry->getShortTitle());\n        $slash = $this->entry->user->avatar && !str_starts_with('/', $this->entry->user->avatar->filePath) ? '/' : '';\n        $avatarUrl = $this->entry->user->avatar ? '/media/cache/resolve/avatar_thumb'.$slash.$this->entry->user->avatar->filePath : null;\n        $url = $urlGenerator->generate('entry_single', [\n            'entry_id' => $this->entry->getId(),\n            'magazine_name' => $this->entry->magazine->name,\n            'slug' => $this->entry->slug ?? '-',\n        ]);\n\n        return new PushNotification($this->getId(), $message, $trans->trans('notification_title_new_thread', locale: $locale), actionUrl: $url, avatarUrl: $avatarUrl);\n    }\n}\n"
  },
  {
    "path": "src/Entity/EntryDeletedNotification.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity;\n\nuse App\\Payloads\\PushNotification;\nuse Doctrine\\ORM\\Mapping\\Entity;\nuse Doctrine\\ORM\\Mapping\\JoinColumn;\nuse Doctrine\\ORM\\Mapping\\ManyToOne;\nuse Symfony\\Component\\Routing\\Generator\\UrlGeneratorInterface;\nuse Symfony\\Contracts\\Translation\\TranslatorInterface;\n\n#[Entity]\nclass EntryDeletedNotification extends Notification\n{\n    #[ManyToOne(targetEntity: Entry::class, inversedBy: 'notifications')]\n    #[JoinColumn(nullable: true, onDelete: 'CASCADE')]\n    public ?Entry $entry = null;\n\n    public function __construct(User $receiver, Entry $entry)\n    {\n        parent::__construct($receiver);\n\n        $this->entry = $entry;\n    }\n\n    public function getSubject(): Entry\n    {\n        return $this->entry;\n    }\n\n    public function getType(): string\n    {\n        return 'entry_deleted_notification';\n    }\n\n    public function getMessage(TranslatorInterface $trans, string $locale, UrlGeneratorInterface $urlGenerator): PushNotification\n    {\n        $message = \\sprintf('%s %s', $this->entry->getShortTitle(), $this->entry->isTrashed() ? $trans->trans('removed') : $trans->trans('deleted'));\n        $slash = $this->entry->user->avatar && !str_starts_with('/', $this->entry->user->avatar->filePath) ? '/' : '';\n        $avatarUrl = $this->entry->user->avatar ? '/media/cache/resolve/avatar_thumb'.$slash.$this->entry->user->avatar->filePath : null;\n        $url = $urlGenerator->generate('entry_single', [\n            'entry_id' => $this->entry->getId(),\n            'magazine_name' => $this->entry->magazine->name,\n            'slug' => $this->entry->slug ?? '-',\n        ]);\n\n        return new PushNotification($this->getId(), $message, $trans->trans('notification_title_removed_thread', locale: $locale), actionUrl: $url, avatarUrl: $avatarUrl);\n    }\n}\n"
  },
  {
    "path": "src/Entity/EntryEditedNotification.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity;\n\nuse App\\Payloads\\PushNotification;\nuse Doctrine\\ORM\\Mapping\\Entity;\nuse Doctrine\\ORM\\Mapping\\JoinColumn;\nuse Doctrine\\ORM\\Mapping\\ManyToOne;\nuse Symfony\\Component\\Routing\\Generator\\UrlGeneratorInterface;\nuse Symfony\\Contracts\\Translation\\TranslatorInterface;\n\n#[Entity]\nclass EntryEditedNotification extends Notification\n{\n    #[ManyToOne(targetEntity: Entry::class, inversedBy: 'notifications')]\n    #[JoinColumn(nullable: true, onDelete: 'CASCADE')]\n    public ?Entry $entry = null;\n\n    public function __construct(User $receiver, Entry $entry)\n    {\n        parent::__construct($receiver);\n\n        $this->entry = $entry;\n    }\n\n    public function getSubject(): Entry\n    {\n        return $this->entry;\n    }\n\n    public function getType(): string\n    {\n        return 'entry_edited_notification';\n    }\n\n    public function getMessage(TranslatorInterface $trans, string $locale, UrlGeneratorInterface $urlGenerator): PushNotification\n    {\n        $message = \\sprintf('%s %s - %s', $this->entry->user->username, $trans->trans('edited_thread'), $this->entry->getShortTitle());\n        $slash = $this->entry->user->avatar && !str_starts_with('/', $this->entry->user->avatar->filePath) ? '/' : '';\n        $avatarUrl = $this->entry->user->avatar ? '/media/cache/resolve/avatar_thumb'.$slash.$this->entry->user->avatar->filePath : null;\n        $url = $urlGenerator->generate('entry_single', [\n            'entry_id' => $this->entry->getId(),\n            'magazine_name' => $this->entry->magazine->name,\n            'slug' => $this->entry->slug ?? '-',\n        ]);\n\n        return new PushNotification($this->getId(), $message, $trans->trans('notification_title_edited_thread', locale: $locale), actionUrl: $url, avatarUrl: $avatarUrl);\n    }\n}\n"
  },
  {
    "path": "src/Entity/EntryFavourite.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity;\n\nuse Doctrine\\ORM\\Mapping\\Cache;\nuse Doctrine\\ORM\\Mapping\\Entity;\nuse Doctrine\\ORM\\Mapping\\JoinColumn;\nuse Doctrine\\ORM\\Mapping\\ManyToOne;\n\n#[Entity]\n#[Cache(usage: 'NONSTRICT_READ_WRITE')]\nclass EntryFavourite extends Favourite\n{\n    #[ManyToOne(targetEntity: Entry::class, inversedBy: 'favourites')]\n    #[JoinColumn(nullable: true, onDelete: 'CASCADE')]\n    public ?Entry $entry = null;\n\n    public function __construct(User $user, Entry $entry)\n    {\n        parent::__construct($user);\n\n        $this->magazine = $entry->magazine;\n        $this->entry = $entry;\n    }\n\n    public function getSubject(): Entry\n    {\n        return $this->entry;\n    }\n\n    public function clearSubject(): Favourite\n    {\n        $this->entry = null;\n\n        return $this;\n    }\n\n    public function getType(): string\n    {\n        return 'entry';\n    }\n}\n"
  },
  {
    "path": "src/Entity/EntryMentionedNotification.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity;\n\nuse App\\Payloads\\PushNotification;\nuse Doctrine\\ORM\\Mapping\\Entity;\nuse Doctrine\\ORM\\Mapping\\ManyToOne;\nuse Symfony\\Component\\Routing\\Generator\\UrlGeneratorInterface;\nuse Symfony\\Contracts\\Translation\\TranslatorInterface;\n\n#[Entity]\nclass EntryMentionedNotification extends Notification\n{\n    #[ManyToOne(targetEntity: Entry::class, inversedBy: 'notifications')]\n    public ?Entry $entry;\n\n    public function __construct(User $receiver, Entry $entry)\n    {\n        parent::__construct($receiver);\n\n        $this->entry = $entry;\n    }\n\n    public function getSubject(): Entry\n    {\n        return $this->entry;\n    }\n\n    public function getType(): string\n    {\n        return 'entry_mentioned_notification';\n    }\n\n    public function getMessage(TranslatorInterface $trans, string $locale, UrlGeneratorInterface $urlGenerator): PushNotification\n    {\n        $message = \\sprintf('%s %s - %s', $this->entry->user->username, $trans->trans('mentioned_you'), $this->entry->getShortTitle());\n        $slash = $this->entry->user->avatar && !str_starts_with('/', $this->entry->user->avatar->filePath) ? '/' : '';\n        $avatarUrl = $this->entry->user->avatar ? '/media/cache/resolve/avatar_thumb'.$slash.$this->entry->user->avatar->filePath : null;\n        $url = $urlGenerator->generate('entry_single', [\n            'entry_id' => $this->entry->getId(),\n            'magazine_name' => $this->entry->magazine->name,\n            'slug' => $this->entry->slug ?? '-',\n        ]);\n\n        return new PushNotification($this->getId(), $message, $trans->trans('notification_title_mention', locale: $locale), actionUrl: $url, avatarUrl: $avatarUrl);\n    }\n}\n"
  },
  {
    "path": "src/Entity/EntryReport.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity;\n\nuse Doctrine\\ORM\\Mapping\\Entity;\nuse Doctrine\\ORM\\Mapping\\JoinColumn;\nuse Doctrine\\ORM\\Mapping\\ManyToOne;\n\n#[Entity]\nclass EntryReport extends Report\n{\n    #[ManyToOne(targetEntity: Entry::class, inversedBy: 'reports')]\n    #[JoinColumn(nullable: true, onDelete: 'CASCADE')]\n    public ?Entry $entry = null;\n\n    public function __construct(User $reporting, Entry $entry, ?string $reason = null)\n    {\n        parent::__construct($reporting, $entry->user, $entry->magazine, $reason);\n\n        $this->entry = $entry;\n    }\n\n    public function getSubject(): Entry\n    {\n        return $this->entry;\n    }\n\n    public function clearSubject(): Report\n    {\n        $this->entry = null;\n\n        return $this;\n    }\n\n    public function getType(): string\n    {\n        return 'entry';\n    }\n}\n"
  },
  {
    "path": "src/Entity/EntryVote.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity;\n\nuse App\\Entity\\Contracts\\VotableInterface;\nuse Doctrine\\ORM\\Mapping\\AssociationOverride;\nuse Doctrine\\ORM\\Mapping\\AssociationOverrides;\nuse Doctrine\\ORM\\Mapping\\Cache;\nuse Doctrine\\ORM\\Mapping\\Entity;\nuse Doctrine\\ORM\\Mapping\\JoinColumn;\nuse Doctrine\\ORM\\Mapping\\ManyToOne;\nuse Doctrine\\ORM\\Mapping\\Table;\nuse Doctrine\\ORM\\Mapping\\UniqueConstraint;\n\n#[Entity]\n#[Table]\n#[UniqueConstraint(name: 'user_entry_vote_idx', columns: ['user_id', 'entry_id'])]\n#[AssociationOverrides([\n    new AssociationOverride(name: 'user', inversedBy: 'entryVotes'),\n])]\n#[Cache(usage: 'NONSTRICT_READ_WRITE')]\nclass EntryVote extends Vote\n{\n    #[ManyToOne(targetEntity: Entry::class, inversedBy: 'votes')]\n    #[JoinColumn(name: 'entry_id', nullable: false, onDelete: 'CASCADE')]\n    public ?Entry $entry = null;\n\n    public function __construct(int $choice, User $user, ?Entry $entry)\n    {\n        parent::__construct($choice, $user, $entry->user);\n\n        $this->entry = $entry;\n    }\n\n    public function getSubject(): VotableInterface\n    {\n        return $this->entry;\n    }\n}\n"
  },
  {
    "path": "src/Entity/Favourite.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity;\n\nuse App\\Entity\\Contracts\\FavouriteInterface;\nuse App\\Entity\\Traits\\CreatedAtTrait;\nuse App\\Repository\\FavouriteRepository;\nuse Doctrine\\ORM\\Mapping\\Column;\nuse Doctrine\\ORM\\Mapping\\DiscriminatorColumn;\nuse Doctrine\\ORM\\Mapping\\DiscriminatorMap;\nuse Doctrine\\ORM\\Mapping\\Entity;\nuse Doctrine\\ORM\\Mapping\\GeneratedValue;\nuse Doctrine\\ORM\\Mapping\\Id;\nuse Doctrine\\ORM\\Mapping\\InheritanceType;\nuse Doctrine\\ORM\\Mapping\\JoinColumn;\nuse Doctrine\\ORM\\Mapping\\ManyToOne;\nuse Doctrine\\ORM\\Mapping\\UniqueConstraint;\n\n#[Entity(repositoryClass: FavouriteRepository::class)]\n#[InheritanceType('SINGLE_TABLE')]\n#[DiscriminatorColumn(name: 'favourite_type', type: 'text')]\n#[DiscriminatorMap([\n    'entry' => 'EntryFavourite',\n    'entry_comment' => 'EntryCommentFavourite',\n    'post' => 'PostFavourite',\n    'post_comment' => 'PostCommentFavourite',\n])]\n#[UniqueConstraint(name: 'favourite_user_entry_unique_idx', columns: ['entry_id', 'user_id'])]\n#[UniqueConstraint(name: 'favourite_user_entry_comment_unique_idx', columns: ['entry_comment_id', 'user_id'])]\n#[UniqueConstraint(name: 'favourite_user_post_unique_idx', columns: ['post_id', 'user_id'])]\n#[UniqueConstraint(name: 'favourite_user_post_comment_unique_idx', columns: ['post_comment_id', 'user_id'])]\nabstract class Favourite\n{\n    use CreatedAtTrait {\n        CreatedAtTrait::__construct as createdAtTraitConstruct;\n    }\n\n    #[ManyToOne(targetEntity: Magazine::class)]\n    #[JoinColumn(nullable: false, onDelete: 'CASCADE')]\n    public Magazine $magazine;\n    #[ManyToOne(targetEntity: User::class, inversedBy: 'favourites')]\n    #[JoinColumn(nullable: false, onDelete: 'CASCADE')]\n    public User $user;\n    #[Id]\n    #[GeneratedValue]\n    #[Column(type: 'integer')]\n    private int $id;\n\n    public function __construct(User $user)\n    {\n        $this->user = $user;\n        $this->createdAtTraitConstruct();\n    }\n\n    public function getId(): int\n    {\n        return $this->id;\n    }\n\n    abstract public function getType(): string;\n\n    abstract public function getSubject(): FavouriteInterface;\n\n    abstract public function clearSubject(): Favourite;\n}\n"
  },
  {
    "path": "src/Entity/Hashtag.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity;\n\nuse App\\Repository\\TagRepository;\nuse Doctrine\\Common\\Collections\\Collection;\nuse Doctrine\\ORM\\Mapping\\Column;\nuse Doctrine\\ORM\\Mapping\\Entity;\nuse Doctrine\\ORM\\Mapping\\GeneratedValue;\nuse Doctrine\\ORM\\Mapping\\Id;\nuse Doctrine\\ORM\\Mapping\\OneToMany;\n\n#[Entity(repositoryClass: TagRepository::class)]\nclass Hashtag\n{\n    #[Id, GeneratedValue]\n    #[Column(type: 'integer')]\n    private int $id;\n\n    #[Column(type: 'citext', unique: true)]\n    public string $tag;\n\n    #[Column(type: 'boolean', options: ['default' => false])]\n    public bool $banned = false;\n\n    #[OneToMany(mappedBy: 'hashtag', targetEntity: HashtagLink::class, fetch: 'EXTRA_LAZY', orphanRemoval: true)]\n    public Collection $linkedPosts;\n}\n"
  },
  {
    "path": "src/Entity/HashtagLink.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity;\n\nuse App\\Repository\\TagLinkRepository;\nuse Doctrine\\ORM\\Mapping\\Column;\nuse Doctrine\\ORM\\Mapping\\Entity;\nuse Doctrine\\ORM\\Mapping\\GeneratedValue;\nuse Doctrine\\ORM\\Mapping\\Id;\nuse Doctrine\\ORM\\Mapping\\JoinColumn;\nuse Doctrine\\ORM\\Mapping\\ManyToOne;\n\n#[Entity(repositoryClass: TagLinkRepository::class)]\nclass HashtagLink\n{\n    #[Id, GeneratedValue]\n    #[Column(type: 'integer')]\n    private int $id;\n\n    #[ManyToOne(targetEntity: Hashtag::class, inversedBy: 'linkedPosts')]\n    #[JoinColumn(name: 'hashtag_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]\n    public Hashtag $hashtag;\n\n    #[ManyToOne(targetEntity: Entry::class, inversedBy: 'hashtags')]\n    #[JoinColumn(name: 'entry_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]\n    public ?Entry $entry;\n\n    #[ManyToOne(targetEntity: EntryComment::class, inversedBy: 'hashtags')]\n    #[JoinColumn(name: 'entry_comment_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]\n    public ?EntryComment $entryComment;\n\n    #[ManyToOne(targetEntity: Post::class, inversedBy: 'hashtags')]\n    #[JoinColumn(name: 'post_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]\n    public ?Post $post;\n\n    #[ManyToOne(targetEntity: PostComment::class, inversedBy: 'hashtags')]\n    #[JoinColumn(name: 'post_comment_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]\n    public ?PostComment $postComment;\n}\n"
  },
  {
    "path": "src/Entity/Image.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity;\n\nuse App\\Entity\\Traits\\CreatedAtTrait;\nuse App\\Repository\\ImageRepository;\nuse Doctrine\\ORM\\Mapping\\Cache;\nuse Doctrine\\ORM\\Mapping\\Column;\nuse Doctrine\\ORM\\Mapping\\Entity;\nuse Doctrine\\ORM\\Mapping\\GeneratedValue;\nuse Doctrine\\ORM\\Mapping\\Id;\nuse Doctrine\\ORM\\Mapping\\Table;\nuse Doctrine\\ORM\\Mapping\\UniqueConstraint;\n\n#[Entity(repositoryClass: ImageRepository::class)]\n#[Table]\n#[UniqueConstraint(name: 'images_file_name_idx', columns: ['file_name'])]\n#[UniqueConstraint(name: 'images_sha256_idx', columns: ['sha256'])]\n#[Cache(usage: 'NONSTRICT_READ_WRITE')]\nclass Image\n{\n    use CreatedAtTrait {\n        CreatedAtTrait::__construct as createdAtTraitConstruct;\n    }\n    #[Column(type: 'string', nullable: true)]\n    /**\n     * If this is NULL it is only a remote image, probably because the image was too big.\n     */\n    public ?string $filePath;\n    #[Column(type: 'string', nullable: false)]\n    public string $fileName;\n    #[Column(type: 'binary', length: 32, nullable: false)]\n    public $sha256;\n    #[Column(type: 'integer', nullable: true)]\n    public ?int $width = null;\n    #[Column(type: 'integer', nullable: true)]\n    public ?int $height;\n    #[Column(type: 'string', nullable: true)]\n    public ?string $blurhash = null;\n    #[Column(type: 'text', nullable: true)]\n    public ?string $altText = null;\n    #[Column(type: 'text', nullable: true)]\n    public ?string $sourceUrl = null;\n    #[Column(nullable: false, options: ['default' => false])]\n    public bool $isCompressed = false;\n    #[Column(nullable: false, options: ['default' => false])]\n    public bool $sourceTooBig = false;\n    #[Column(type: 'datetimetz_immutable', nullable: true, options: ['default' => null])]\n    public ?\\DateTimeImmutable $downloadedAt;\n    #[Column(type: 'bigint', options: ['default' => 0])]\n    public int $localSize = 0;\n    #[Column(type: 'bigint', options: ['default' => 0])]\n    public int $originalSize = 0;\n\n    #[Id]\n    #[GeneratedValue]\n    #[Column(type: 'integer')]\n    private int $id;\n\n    public function __construct(\n        string $fileName,\n        string $filePath,\n        string $sha256,\n        ?int $width,\n        ?int $height,\n        ?string $blurhash,\n    ) {\n        $this->createdAtTraitConstruct();\n        $this->filePath = $filePath;\n        $this->fileName = $fileName;\n        $this->blurhash = $blurhash;\n\n        error_clear_last();\n        if (64 === \\strlen($sha256)) {\n            $sha256 = @hex2bin($sha256);\n\n            if (false === $sha256) {\n                throw new \\InvalidArgumentException(error_get_last()['message']);\n            }\n        } elseif (32 !== \\strlen($sha256)) {\n            throw new \\InvalidArgumentException('$sha256 must be a SHA256 hash in raw or binary form');\n        }\n\n        $this->sha256 = $sha256;\n        $this->setDimensions($width, $height);\n    }\n\n    public function setDimensions(?int $width, ?int $height): void\n    {\n        if (null !== $width && $width <= 0) {\n            throw new \\InvalidArgumentException('$width must be NULL or >0');\n        }\n\n        if (null !== $height && $height <= 0) {\n            throw new \\InvalidArgumentException('$height must be NULL or >0');\n        }\n\n        if (($width && $height) || (!$width && !$height)) {\n            $this->width = $width;\n            $this->height = $height;\n        } else {\n            throw new \\InvalidArgumentException('$width and $height must both be set or NULL');\n        }\n    }\n\n    public function getId(): int\n    {\n        return $this->id;\n    }\n\n    public function __toString(): string\n    {\n        return $this->fileName;\n    }\n\n    //    public function getSha256(): string\n    //    {\n    //        return bin2hex($this->sha256);\n    //    }\n\n    public function __sleep()\n    {\n        return [];\n    }\n}\n"
  },
  {
    "path": "src/Entity/Instance.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity;\n\nuse App\\Entity\\Traits\\CreatedAtTrait;\nuse App\\Entity\\Traits\\UpdatedAtTrait;\nuse App\\Repository\\InstanceRepository;\nuse Doctrine\\ORM\\Mapping\\Column;\nuse Doctrine\\ORM\\Mapping\\Entity;\nuse Doctrine\\ORM\\Mapping\\GeneratedValue;\nuse Doctrine\\ORM\\Mapping\\Id;\n\n#[Entity(repositoryClass: InstanceRepository::class)]\nclass Instance\n{\n    use CreatedAtTrait {\n        CreatedAtTrait::__construct as createdAtTraitConstruct;\n    }\n    use UpdatedAtTrait;\n    public const int NUMBER_OF_FAILED_DELIVERS_UNTIL_DEAD = 10;\n\n    public static function getDateBeforeDead(): \\DateTimeImmutable\n    {\n        return new \\DateTimeImmutable('now - 7 days');\n    }\n\n    #[Column(nullable: true)]\n    public ?string $software = null;\n\n    #[Column(nullable: true)]\n    public ?string $version = null;\n\n    #[Column(unique: true)]\n    public string $domain;\n\n    #[Column(type: 'datetimetz_immutable', nullable: true)]\n    private ?\\DateTimeImmutable $lastSuccessfulDeliver = null;\n\n    #[Column(type: 'datetimetz_immutable', nullable: true)]\n    private ?\\DateTimeImmutable $lastFailedDeliver = null;\n\n    #[Column(type: 'datetimetz_immutable', nullable: true)]\n    private ?\\DateTimeImmutable $lastSuccessfulReceive = null;\n\n    #[Column]\n    private int $failedDelivers = 0;\n\n    #[Column(options: ['default' => false])]\n    public bool $isBanned = false;\n\n    #[Column(options: ['default' => false])]\n    public bool $isExplicitlyAllowed = false;\n\n    #[Column, Id, GeneratedValue]\n    private int $id;\n\n    public function __construct(string $domain)\n    {\n        $this->domain = $domain;\n        $this->createdAtTraitConstruct();\n    }\n\n    public function getId(): int\n    {\n        return $this->id;\n    }\n\n    public function setLastSuccessfulDeliver(): void\n    {\n        $this->lastSuccessfulDeliver = new \\DateTimeImmutable();\n        $this->failedDelivers = 0;\n    }\n\n    public function getLastSuccessfulDeliver(): ?\\DateTimeImmutable\n    {\n        return $this->lastSuccessfulDeliver;\n    }\n\n    public function setLastFailedDeliver(): void\n    {\n        $this->lastFailedDeliver = new \\DateTimeImmutable();\n        ++$this->failedDelivers;\n    }\n\n    public function getLastFailedDeliver(): ?\\DateTimeImmutable\n    {\n        return $this->lastFailedDeliver;\n    }\n\n    public function setLastSuccessfulReceive(): void\n    {\n        $this->lastSuccessfulReceive = new \\DateTimeImmutable();\n    }\n\n    public function getLastSuccessfulReceive(): ?\\DateTimeImmutable\n    {\n        return $this->lastSuccessfulReceive;\n    }\n\n    public function getFailedDelivers(): int\n    {\n        return $this->failedDelivers;\n    }\n\n    public function isDead(): bool\n    {\n        return ($this->getLastSuccessfulDeliver() < self::getDateBeforeDead() || null === $this->getLastSuccessfulDeliver())\n            && ($this->getLastSuccessfulReceive() < self::getDateBeforeDead() || null === $this->getLastSuccessfulReceive())\n            && $this->getFailedDelivers() >= self::NUMBER_OF_FAILED_DELIVERS_UNTIL_DEAD;\n    }\n}\n"
  },
  {
    "path": "src/Entity/Magazine.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity;\n\nuse App\\Entity\\Contracts\\ActivityPubActorInterface;\nuse App\\Entity\\Contracts\\ApiResourceInterface;\nuse App\\Entity\\Contracts\\VisibilityInterface;\nuse App\\Entity\\Traits\\ActivityPubActorTrait;\nuse App\\Entity\\Traits\\CreatedAtTrait;\nuse App\\Entity\\Traits\\VisibilityTrait;\nuse App\\Repository\\MagazineRepository;\nuse App\\Service\\MagazineManager;\nuse Doctrine\\Common\\Collections\\ArrayCollection;\nuse Doctrine\\Common\\Collections\\Collection;\nuse Doctrine\\Common\\Collections\\Criteria;\nuse Doctrine\\DBAL\\Types\\Types;\nuse Doctrine\\ORM\\Mapping\\Column;\nuse Doctrine\\ORM\\Mapping\\Entity;\nuse Doctrine\\ORM\\Mapping\\GeneratedValue;\nuse Doctrine\\ORM\\Mapping\\Id;\nuse Doctrine\\ORM\\Mapping\\Index;\nuse Doctrine\\ORM\\Mapping\\JoinColumn;\nuse Doctrine\\ORM\\Mapping\\ManyToOne;\nuse Doctrine\\ORM\\Mapping\\OneToMany;\nuse Doctrine\\ORM\\Mapping\\OrderBy;\nuse Doctrine\\ORM\\Mapping\\UniqueConstraint;\n\n#[Entity(repositoryClass: MagazineRepository::class)]\n#[Index(columns: ['visibility', 'is_adult'], name: 'magazine_visibility_adult_idx')]\n#[Index(columns: ['is_adult'], name: 'magazine_adult_idx')]\n#[Index(columns: ['name_ts'], name: 'magazine_name_ts')]\n#[Index(columns: ['title_ts'], name: 'magazine_title_ts')]\n#[Index(columns: ['description_ts'], name: 'magazine_description_ts')]\n#[UniqueConstraint(name: 'magazine_name_idx', columns: ['name'])]\n#[UniqueConstraint(name: 'magazine_ap_id_idx', columns: ['ap_id'])]\n#[UniqueConstraint(name: 'magazine_ap_profile_id_idx', columns: ['ap_profile_id'])]\n#[UniqueConstraint(name: 'magazine_ap_public_url_idx', columns: ['ap_public_url'])]\nclass Magazine implements VisibilityInterface, ActivityPubActorInterface, ApiResourceInterface\n{\n    use ActivityPubActorTrait;\n    use VisibilityTrait;\n    use CreatedAtTrait {\n        CreatedAtTrait::__construct as createdAtTraitConstruct;\n    }\n\n    public const MAX_DESCRIPTION_LENGTH = 10000;\n    public const MAX_RULES_LENGTH = 10000;\n\n    #[ManyToOne(targetEntity: Image::class, cascade: ['persist'])]\n    #[JoinColumn(nullable: true)]\n    public ?Image $icon = null;\n    #[ManyToOne(targetEntity: Image::class, cascade: ['persist'])]\n    #[JoinColumn(nullable: true)]\n    public ?Image $banner = null;\n    #[Column(type: 'string', nullable: false)]\n    public string $name;\n    #[Column(type: 'text', length: self::MAX_DESCRIPTION_LENGTH, nullable: true)]\n    public ?string $description = null;\n    #[Column(type: 'text', length: self::MAX_RULES_LENGTH, nullable: true)]\n    public ?string $rules = null;\n    #[Column(type: 'boolean', nullable: false, options: ['default' => false])]\n    public bool $postingRestrictedToMods = false;\n    #[Column(type: 'integer', nullable: false)]\n    public int $subscriptionsCount = 0;\n    #[Column(type: 'integer', nullable: false)]\n    public int $entryCount = 0;\n    #[Column(type: 'integer', nullable: false)]\n    public int $entryCommentCount = 0;\n    #[Column(type: 'integer', nullable: false)]\n    public int $postCount = 0;\n    #[Column(type: 'integer', nullable: false)]\n    public int $postCommentCount = 0;\n    #[Column(type: 'boolean', nullable: false)]\n    public bool $isAdult = false;\n    #[Column(type: 'text', nullable: true)]\n    public ?string $customCss = null;\n    #[Column(type: 'datetimetz', nullable: true)]\n    public ?\\DateTime $lastActive = null;\n    /**\n     * @var \\DateTime|null this is set if this is a remote magazine.\n     *                     This is the last time we had an update from the origin of the magazine\n     */\n    #[Column(type: 'datetimetz', nullable: true)]\n    public ?\\DateTime $lastOriginUpdate = null;\n    #[Column(type: 'datetimetz', nullable: true)]\n    public ?\\DateTime $markedForDeletionAt = null;\n    #[Column(type: Types::JSONB, nullable: true)]\n    public ?array $tags = null;\n    #[OneToMany(mappedBy: 'magazine', targetEntity: Moderator::class, cascade: ['persist', 'remove'], orphanRemoval: true)]\n    public Collection $moderators;\n    #[OneToMany(mappedBy: 'magazine', targetEntity: MagazineOwnershipRequest::class, cascade: ['persist', 'remove'], orphanRemoval: true)]\n    public Collection $ownershipRequests;\n    #[OneToMany(mappedBy: 'magazine', targetEntity: ModeratorRequest::class, cascade: ['persist', 'remove'], orphanRemoval: true)]\n    public Collection $moderatorRequests;\n    #[OneToMany(mappedBy: 'magazine', targetEntity: Entry::class, cascade: ['persist', 'remove'], orphanRemoval: true)]\n    public Collection $entries;\n    #[OneToMany(mappedBy: 'magazine', targetEntity: Post::class, cascade: ['persist', 'remove'], orphanRemoval: true)]\n    public Collection $posts;\n    #[OneToMany(mappedBy: 'magazine', targetEntity: MagazineSubscription::class, cascade: ['persist', 'remove'], orphanRemoval: true)]\n    public Collection $subscriptions;\n    #[OneToMany(mappedBy: 'magazine', targetEntity: MagazineBan::class, cascade: ['persist', 'remove'], orphanRemoval: true)]\n    public Collection $bans;\n    #[OneToMany(mappedBy: 'magazine', targetEntity: Report::class, cascade: ['persist', 'remove'], fetch: 'EXTRA_LAZY', orphanRemoval: true)]\n    #[OrderBy(['createdAt' => 'DESC'])]\n    public Collection $reports;\n    #[OneToMany(mappedBy: 'magazine', targetEntity: Badge::class, cascade: ['persist', 'remove'], fetch: 'EXTRA_LAZY', orphanRemoval: true)]\n    #[OrderBy(['id' => 'DESC'])]\n    public Collection $badges;\n    #[OneToMany(mappedBy: 'magazine', targetEntity: MagazineLog::class, cascade: ['persist', 'remove'], fetch: 'EXTRA_LAZY', orphanRemoval: true)]\n    #[OrderBy(['createdAt' => 'DESC'])]\n    public Collection $logs;\n\n    #[Column(type: 'text', nullable: true, insertable: false, updatable: false, options: ['default' => null])]\n    private ?string $nameTs;\n    #[Column(type: 'text', nullable: true, insertable: false, updatable: false, options: ['default' => null])]\n    private ?string $titleTs;\n    #[Column(type: 'text', nullable: true, insertable: false, updatable: false, options: ['default' => null])]\n    private ?string $descriptionTs;\n\n    #[Id]\n    #[GeneratedValue]\n    #[Column(type: 'integer')]\n    private int $id;\n\n    public function __construct(\n        string $name,\n        string $title,\n        ?User $user,\n        ?string $description,\n        ?string $rules,\n        bool $isAdult,\n        bool $postingRestrictedToMods,\n        ?Image $icon,\n        ?Image $banner = null,\n    ) {\n        $this->name = $name;\n        $this->title = $title;\n        $this->description = $description;\n        $this->rules = $rules;\n        $this->isAdult = $isAdult;\n        $this->postingRestrictedToMods = $postingRestrictedToMods;\n        $this->icon = $icon;\n        $this->banner = $banner;\n        $this->moderators = new ArrayCollection();\n        $this->entries = new ArrayCollection();\n        $this->posts = new ArrayCollection();\n        $this->subscriptions = new ArrayCollection();\n        $this->bans = new ArrayCollection();\n        $this->reports = new ArrayCollection();\n        $this->badges = new ArrayCollection();\n        $this->logs = new ArrayCollection();\n        $this->moderatorRequests = new ArrayCollection();\n        $this->ownershipRequests = new ArrayCollection();\n\n        if (null !== $user) {\n            $this->addModerator(new Moderator($this, $user, null, true, true));\n        }\n\n        $this->createdAtTraitConstruct();\n    }\n\n    /**\n     * Only use this to add a moderator if you don't want that action to be federated.\n     * If you want this action to be federated, use @see MagazineManager::addModerator().\n     *\n     * @return $this\n     */\n    public function addModerator(Moderator $moderator): self\n    {\n        if (!$this->moderators->contains($moderator)) {\n            $this->moderators->add($moderator);\n            $moderator->magazine = $this;\n        }\n\n        return $this;\n    }\n\n    public function getId(): int\n    {\n        return $this->id;\n    }\n\n    public function getApId(): ?string\n    {\n        return $this->apId;\n    }\n\n    public function userIsModerator(User $user): bool\n    {\n        $user->moderatorTokens->get(-1);\n\n        $criteria = Criteria::create()\n            ->where(Criteria::expr()->eq('magazine', $this))\n            ->andWhere(Criteria::expr()->eq('isConfirmed', true));\n\n        return !$user->moderatorTokens->matching($criteria)->isEmpty();\n    }\n\n    public function getUserAsModeratorOrNull(User $user): ?Moderator\n    {\n        $user->moderatorTokens->get(-1);\n\n        $criteria = Criteria::create()\n            ->where(Criteria::expr()->eq('magazine', $this))\n            ->andWhere(Criteria::expr()->eq('isConfirmed', true));\n\n        $col = $user->moderatorTokens->matching($criteria);\n        if (!$col->isEmpty()) {\n            return $col->first();\n        }\n\n        return null;\n    }\n\n    public function userIsOwner(User $user): bool\n    {\n        $user->moderatorTokens->get(-1);\n\n        $criteria = Criteria::create()\n            ->where(Criteria::expr()->eq('magazine', $this))\n            ->andWhere(Criteria::expr()->eq('isOwner', true));\n\n        return !$user->moderatorTokens->matching($criteria)->isEmpty();\n    }\n\n    public function isAbandoned(): bool\n    {\n        return !$this->apId and (null === $this->getOwner() || $this->getOwner()->lastActive < new \\DateTime('-1 month'));\n    }\n\n    public function getOwnerModerator(): ?Moderator\n    {\n        $criteria = Criteria::create()\n            ->where(Criteria::expr()->eq('isOwner', true));\n\n        $res = $this->moderators->matching($criteria)->first();\n        if (false !== $res) {\n            return $res;\n        }\n\n        return null;\n    }\n\n    public function getOwner(): ?User\n    {\n        $criteria = Criteria::create()\n            ->where(Criteria::expr()->eq('isOwner', true));\n\n        $res = $this->moderators->matching($criteria)->first();\n        if (false !== $res) {\n            return $res->user;\n        }\n\n        return null;\n    }\n\n    public function getModeratorCount(): int\n    {\n        return $this->moderators->count();\n    }\n\n    public function addEntry(Entry $entry): self\n    {\n        if (!$this->entries->contains($entry)) {\n            $this->entries->add($entry);\n            $entry->magazine = $this;\n        }\n\n        $this->updateEntryCounts();\n\n        return $this;\n    }\n\n    public function updateEntryCounts(): self\n    {\n        $criteria = Criteria::create()\n            ->andWhere(Criteria::expr()->eq('visibility', Entry::VISIBILITY_VISIBLE));\n\n        $this->entryCount = $this->entries->matching($criteria)->count();\n\n        return $this;\n    }\n\n    public function removeEntry(Entry $entry): self\n    {\n        if ($this->entries->removeElement($entry)) {\n            if ($entry->magazine === $this) {\n                $entry->magazine = null;\n            }\n        }\n\n        $this->updateEntryCounts();\n\n        return $this;\n    }\n\n    public function getPosts(): Collection\n    {\n        return $this->posts;\n    }\n\n    public function addPost(Post $post): self\n    {\n        if (!$this->posts->contains($post)) {\n            $this->posts->add($post);\n            $post->magazine = $this;\n        }\n\n        $this->updatePostCounts();\n\n        return $this;\n    }\n\n    public function updatePostCounts(): self\n    {\n        $criteria = Criteria::create()\n            ->andWhere(Criteria::expr()->eq('visibility', Entry::VISIBILITY_VISIBLE));\n\n        $this->postCount = $this->posts->matching($criteria)->count();\n\n        return $this;\n    }\n\n    public function removePost(Post $post): self\n    {\n        if ($this->posts->removeElement($post)) {\n            if ($post->magazine === $this) {\n                $post->magazine = null;\n            }\n        }\n        $this->updatePostCounts();\n\n        return $this;\n    }\n\n    public function subscribe(User $user): self\n    {\n        if (!$this->isSubscribed($user)) {\n            $this->subscriptions->add($sub = new MagazineSubscription($user, $this));\n            $sub->magazine = $this;\n        }\n\n        $this->updateSubscriptionsCount();\n\n        return $this;\n    }\n\n    public function isSubscribed(User $user): bool\n    {\n        $criteria = Criteria::create()\n            ->where(Criteria::expr()->eq('user', $user));\n\n        return $this->subscriptions->matching($criteria)->count() > 0;\n    }\n\n    public function updateSubscriptionsCount(): void\n    {\n        if (null !== $this->apFollowersCount) {\n            $criteria = Criteria::create()\n                ->where(Criteria::expr()->gt('createdAt', \\DateTimeImmutable::createFromMutable($this->apFetchedAt)));\n\n            $newSubscribers = $this->subscriptions->matching($criteria)->count();\n            $this->subscriptionsCount = $this->apFollowersCount + $newSubscribers;\n        } else {\n            $this->subscriptionsCount = $this->subscriptions->count();\n        }\n    }\n\n    public function unsubscribe(User $user): void\n    {\n        $criteria = Criteria::create()\n            ->where(Criteria::expr()->eq('user', $user));\n\n        $subscription = $this->subscriptions->matching($criteria)->first();\n\n        if ($this->subscriptions->removeElement($subscription)) {\n            if ($subscription->magazine === $this) {\n                $subscription->magazine = null;\n            }\n        }\n\n        $this->updateSubscriptionsCount();\n    }\n\n    public function softDelete(): void\n    {\n        $this->markedForDeletionAt = new \\DateTime('now + 30days');\n        $this->visibility = VisibilityInterface::VISIBILITY_SOFT_DELETED;\n    }\n\n    public function trash(): void\n    {\n        $this->visibility = VisibilityInterface::VISIBILITY_TRASHED;\n    }\n\n    public function restore(): void\n    {\n        $this->markedForDeletionAt = null;\n        $this->visibility = VisibilityInterface::VISIBILITY_VISIBLE;\n    }\n\n    public function addBan(User $user, User $bannedBy, ?string $reason, ?\\DateTimeImmutable $expiredAt): ?MagazineBan\n    {\n        $ban = $this->isBanned($user);\n\n        if (!$ban) {\n            $this->bans->add($ban = new MagazineBan($this, $user, $bannedBy, $reason, $expiredAt));\n            $ban->magazine = $this;\n        } else {\n            return null;\n        }\n\n        return $ban;\n    }\n\n    public function isBanned(User $user): bool\n    {\n        $criteria = Criteria::create()\n            ->andWhere(Criteria::expr()->gt('expiredAt', new \\DateTimeImmutable()))\n            ->orWhere(Criteria::expr()->isNull('expiredAt'))\n            ->andWhere(Criteria::expr()->eq('user', $user));\n\n        return $this->bans->matching($criteria)->count() > 0;\n    }\n\n    public function removeBan(MagazineBan $ban): self\n    {\n        if ($this->bans->removeElement($ban)) {\n            if ($ban->magazine === $this) {\n                $ban->magazine = null;\n            }\n        }\n\n        return $this;\n    }\n\n    public function unban(User $user): MagazineBan\n    {\n        $criteria = Criteria::create()\n            ->andWhere(Criteria::expr()->gt('expiredAt', new \\DateTimeImmutable()))\n            ->orWhere(Criteria::expr()->isNull('expiredAt'))\n            ->andWhere(Criteria::expr()->eq('user', $user));\n\n        /**\n         * @var MagazineBan $ban\n         */\n        $ban = $this->bans->matching($criteria)->first();\n        $ban->expiredAt = new \\DateTimeImmutable('-10 seconds');\n\n        return $ban;\n    }\n\n    public function addBadge(Badge ...$badges): self\n    {\n        foreach ($badges as $badge) {\n            if (!$this->badges->contains($badge)) {\n                $this->badges->add($badge);\n            }\n        }\n\n        return $this;\n    }\n\n    public function removeBadge(Badge $badge): self\n    {\n        $this->badges->removeElement($badge);\n\n        return $this;\n    }\n\n    public function addLog(MagazineLog $log): void\n    {\n        if (!$this->logs->contains($log)) {\n            $this->logs->add($log);\n        }\n    }\n\n    public function __sleep()\n    {\n        return [];\n    }\n\n    public function getApName(): string\n    {\n        return $this->name;\n    }\n\n    public function hasSameHostAsUser(User $actor): bool\n    {\n        if (!$actor->apId and !$this->apId) {\n            return true;\n        }\n\n        if ($actor->apId and $this->apId) {\n            return parse_url($actor->apId, PHP_URL_HOST) === parse_url($this->apId, PHP_URL_HOST);\n        }\n\n        return false;\n    }\n\n    public function canUpdateMagazine(User $actor): bool\n    {\n        if (null === $this->apId) {\n            return $actor->isAdmin() || $actor->isModerator() || $this->userIsModerator($actor);\n        } else {\n            return $this->apDomain === $actor->apDomain || $this->userIsModerator($actor);\n        }\n    }\n\n    /**\n     * @param Magazine|User $actor the actor trying to create an Entry\n     *\n     * @return bool false if the user is not restricted, true if the user is restricted\n     */\n    public function isActorPostingRestricted(Magazine|User $actor): bool\n    {\n        if (!$this->postingRestrictedToMods) {\n            return false;\n        }\n        if ($actor instanceof User) {\n            if (null !== $this->apId && $this->apDomain === $actor->apDomain) {\n                return false;\n            }\n\n            if ((null === $this->apId && ($actor->isAdmin() || $actor->isModerator())) || $this->userIsModerator($actor)) {\n                return false;\n            }\n        }\n\n        return true;\n    }\n\n    public function getContentCount(): int\n    {\n        return $this->entryCount + $this->entryCommentCount + $this->postCount + $this->postCommentCount;\n    }\n}\n"
  },
  {
    "path": "src/Entity/MagazineBan.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity;\n\nuse App\\Entity\\Traits\\CreatedAtTrait;\nuse App\\Repository\\MagazineBanRepository;\nuse Doctrine\\ORM\\Mapping\\Column;\nuse Doctrine\\ORM\\Mapping\\Entity;\nuse Doctrine\\ORM\\Mapping\\GeneratedValue;\nuse Doctrine\\ORM\\Mapping\\Id;\nuse Doctrine\\ORM\\Mapping\\JoinColumn;\nuse Doctrine\\ORM\\Mapping\\ManyToOne;\n\n#[Entity(repositoryClass: MagazineBanRepository::class)]\nclass MagazineBan\n{\n    use CreatedAtTrait {\n        CreatedAtTrait::__construct as createdAtTraitConstruct;\n    }\n\n    #[ManyToOne(targetEntity: Magazine::class, inversedBy: 'bans')]\n    #[JoinColumn(nullable: false, onDelete: 'CASCADE')]\n    public ?Magazine $magazine;\n    #[ManyToOne(targetEntity: User::class)]\n    #[JoinColumn(nullable: false, onDelete: 'CASCADE')]\n    public ?User $user;\n    #[ManyToOne(targetEntity: User::class)]\n    #[JoinColumn(nullable: false, onDelete: 'CASCADE')]\n    public ?User $bannedBy;\n    #[Column(type: 'text', length: 2048, nullable: true)]\n    public ?string $reason = null;\n    #[Column(type: 'datetimetz_immutable', nullable: true)]\n    public ?\\DateTimeImmutable $expiredAt = null;\n    #[Id]\n    #[GeneratedValue]\n    #[Column(type: 'integer')]\n    private int $id;\n\n    public function __construct(\n        Magazine $magazine,\n        User $user,\n        User $bannedBy,\n        ?string $reason = null,\n        ?\\DateTimeImmutable $expiredAt = null,\n    ) {\n        $this->magazine = $magazine;\n        $this->user = $user;\n        $this->bannedBy = $bannedBy;\n        $this->reason = $reason;\n        $this->expiredAt = $expiredAt;\n\n        $this->createdAtTraitConstruct();\n    }\n\n    public function getId(): ?int\n    {\n        return $this->id;\n    }\n\n    public function __sleep()\n    {\n        return [];\n    }\n}\n"
  },
  {
    "path": "src/Entity/MagazineBanNotification.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity;\n\nuse App\\Payloads\\PushNotification;\nuse Doctrine\\ORM\\Mapping\\Entity;\nuse Doctrine\\ORM\\Mapping\\JoinColumn;\nuse Doctrine\\ORM\\Mapping\\ManyToOne;\nuse Symfony\\Component\\Routing\\Generator\\UrlGeneratorInterface;\nuse Symfony\\Contracts\\Translation\\TranslatorInterface;\n\n#[Entity]\nclass MagazineBanNotification extends Notification\n{\n    #[ManyToOne(targetEntity: MagazineBan::class)]\n    #[JoinColumn(nullable: true, onDelete: 'CASCADE')]\n    public ?MagazineBan $ban = null;\n\n    public function __construct(User $receiver, MagazineBan $ban)\n    {\n        parent::__construct($receiver);\n\n        $this->ban = $ban;\n    }\n\n    public function getSubject(): MagazineBan\n    {\n        return $this->ban;\n    }\n\n    public function getType(): string\n    {\n        return 'magazine_ban_notification';\n    }\n\n    public function getMessage(TranslatorInterface $trans, string $locale, UrlGeneratorInterface $urlGenerator): PushNotification\n    {\n        $intl = new \\IntlDateFormatter($locale, \\IntlDateFormatter::SHORT, \\IntlDateFormatter::SHORT, calendar: \\IntlDateFormatter::GREGORIAN);\n\n        if ($this->ban->expiredAt) {\n            $message = \\sprintf('%s %s: %s. %s: %s',\n                $trans->trans('you_have_been_banned_from_magazine', ['%m' => $this->ban->magazine->name], locale: $locale),\n                new \\DateTimeImmutable() > $this->ban->expiredAt ? $trans->trans('ban_expired', locale: $locale) : $trans->trans('ban_expires', locale: $locale),\n                $intl->format($this->ban->expiredAt),\n                $trans->trans('reason', locale: $locale),\n                $this->ban->reason\n            );\n        } else {\n            $message = \\sprintf('%s %s: %s',\n                $trans->trans('you_have_been_banned_from_magazine_permanently', ['%m' => $this->ban->magazine->name], locale: $locale),\n                $trans->trans('reason', locale: $locale),\n                $this->ban->reason\n            );\n        }\n        $slash = $this->ban->magazine->icon && !str_starts_with('/', $this->ban->magazine->icon->filePath) ? '/' : '';\n        $avatarUrl = $this->ban->magazine->icon ? '/media/cache/resolve/avatar_thumb'.$slash.$this->ban->magazine->icon->filePath : null;\n\n        return new PushNotification($this->getId(), $message, $trans->trans('notification_title_ban', locale: $locale), avatarUrl: $avatarUrl);\n    }\n}\n"
  },
  {
    "path": "src/Entity/MagazineBlock.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity;\n\nuse App\\Entity\\Traits\\CreatedAtTrait;\nuse Doctrine\\ORM\\Mapping\\Cache;\nuse Doctrine\\ORM\\Mapping\\Column;\nuse Doctrine\\ORM\\Mapping\\Entity;\nuse Doctrine\\ORM\\Mapping\\GeneratedValue;\nuse Doctrine\\ORM\\Mapping\\Id;\nuse Doctrine\\ORM\\Mapping\\JoinColumn;\nuse Doctrine\\ORM\\Mapping\\ManyToOne;\nuse Doctrine\\ORM\\Mapping\\Table;\nuse Doctrine\\ORM\\Mapping\\UniqueConstraint;\n\n#[Entity(repositoryClass: 'App\\Repository\\MagazineBlockRepository')]\n#[Table]\n#[UniqueConstraint(name: 'magazine_block_idx', columns: ['user_id', 'magazine_id'])]\n#[Cache(usage: 'NONSTRICT_READ_WRITE')]\nclass MagazineBlock\n{\n    use CreatedAtTrait {\n        CreatedAtTrait::__construct as createdAtTraitConstruct;\n    }\n\n    #[ManyToOne(targetEntity: User::class, inversedBy: 'blockedMagazines')]\n    #[JoinColumn(nullable: false, onDelete: 'CASCADE')]\n    public ?User $user;\n    #[ManyToOne(targetEntity: Magazine::class)]\n    #[JoinColumn(nullable: false, onDelete: 'CASCADE')]\n    public ?Magazine $magazine;\n    #[Id]\n    #[GeneratedValue]\n    #[Column(type: 'integer')]\n    private int $id;\n\n    public function __construct(User $user, Magazine $magazine)\n    {\n        $this->createdAtTraitConstruct();\n\n        $this->user = $user;\n        $this->magazine = $magazine;\n    }\n\n    public function getId(): ?int\n    {\n        return $this->id;\n    }\n\n    public function __sleep()\n    {\n        return [];\n    }\n}\n"
  },
  {
    "path": "src/Entity/MagazineLog.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity;\n\nuse App\\Entity\\Contracts\\ContentInterface;\nuse App\\Entity\\Traits\\CreatedAtTrait;\nuse App\\Repository\\NotificationRepository;\nuse Doctrine\\ORM\\Mapping\\Column;\nuse Doctrine\\ORM\\Mapping\\DiscriminatorColumn;\nuse Doctrine\\ORM\\Mapping\\DiscriminatorMap;\nuse Doctrine\\ORM\\Mapping\\Entity;\nuse Doctrine\\ORM\\Mapping\\GeneratedValue;\nuse Doctrine\\ORM\\Mapping\\Id;\nuse Doctrine\\ORM\\Mapping\\InheritanceType;\nuse Doctrine\\ORM\\Mapping\\JoinColumn;\nuse Doctrine\\ORM\\Mapping\\ManyToOne;\n\n#[Entity(repositoryClass: NotificationRepository::class)]\n#[InheritanceType('SINGLE_TABLE')]\n#[DiscriminatorColumn(name: 'log_type', type: 'text')]\n#[DiscriminatorMap(self::DISCRIMINATOR_MAP)]\nabstract class MagazineLog\n{\n    use CreatedAtTrait {\n        CreatedAtTrait::__construct as createdAtTraitConstruct;\n    }\n\n    public const DISCRIMINATOR_MAP = [\n        'entry_deleted' => MagazineLogEntryDeleted::class,\n        'entry_restored' => MagazineLogEntryRestored::class,\n        'entry_comment_deleted' => MagazineLogEntryCommentDeleted::class,\n        'entry_comment_restored' => MagazineLogEntryCommentRestored::class,\n        'entry_pinned' => MagazineLogEntryPinned::class,\n        'entry_unpinned' => MagazineLogEntryUnpinned::class,\n        'post_deleted' => MagazineLogPostDeleted::class,\n        'post_restored' => MagazineLogPostRestored::class,\n        'post_comment_deleted' => MagazineLogPostCommentDeleted::class,\n        'post_comment_restored' => MagazineLogPostCommentRestored::class,\n        'ban' => MagazineLogBan::class,\n        'moderator_add' => MagazineLogModeratorAdd::class,\n        'moderator_remove' => MagazineLogModeratorRemove::class,\n        'entry_locked' => MagazineLogEntryLocked::class,\n        'entry_unlocked' => MagazineLogEntryUnlocked::class,\n        'post_locked' => MagazineLogPostLocked::class,\n        'post_unlocked' => MagazineLogPostUnlocked::class,\n    ];\n\n    public const CHOICES = [\n        'entry_deleted',\n        'entry_restored',\n        'entry_comment_deleted',\n        'entry_comment_restored',\n        'entry_pinned',\n        'entry_unpinned',\n        'post_deleted',\n        'post_restored',\n        'post_comment_deleted',\n        'post_comment_restored',\n        'ban',\n        'moderator_add',\n        'moderator_remove',\n        'entry_locked',\n        'entry_unlocked',\n        'post_locked',\n        'post_unlocked',\n    ];\n\n    #[ManyToOne(targetEntity: Magazine::class, inversedBy: 'logs')]\n    #[JoinColumn(nullable: false, onDelete: 'CASCADE')]\n    public Magazine $magazine;\n    #[ManyToOne(targetEntity: User::class)]\n    #[JoinColumn(nullable: false, onDelete: 'CASCADE')]\n    /**\n     * Usually the acting moderator. There are 2 exceptions MagazineLogModeratorAdd and MagazineLogModeratorRemove;\n     * in that case this is the moderator being added or removed, because the acting moderator can be null.\n     *\n     * @see MagazineLogModeratorAdd\n     * @see MagazineLogModeratorRemove\n     */\n    public User $user;\n    #[Id]\n    #[GeneratedValue]\n    #[Column(type: 'integer')]\n    private int $id;\n\n    public function __construct(Magazine $magazine, User $user)\n    {\n        $this->magazine = $magazine;\n        $this->user = $user;\n\n        $this->createdAtTraitConstruct();\n    }\n\n    abstract public function getSubject(): ?ContentInterface;\n\n    abstract public function clearSubject(): MagazineLog;\n\n    abstract public function getType(): string;\n}\n"
  },
  {
    "path": "src/Entity/MagazineLogBan.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity;\n\nuse App\\Entity\\Contracts\\ContentInterface;\nuse Doctrine\\ORM\\Mapping\\Column;\nuse Doctrine\\ORM\\Mapping\\Entity;\nuse Doctrine\\ORM\\Mapping\\JoinColumn;\nuse Doctrine\\ORM\\Mapping\\ManyToOne;\n\n#[Entity]\nclass MagazineLogBan extends MagazineLog\n{\n    #[ManyToOne(targetEntity: MagazineBan::class)]\n    #[JoinColumn(nullable: true, onDelete: 'CASCADE')]\n    public ?MagazineBan $ban = null;\n\n    #[Column(type: 'string')]\n    public string $meta = 'ban';\n\n    public function __construct(MagazineBan $ban)\n    {\n        parent::__construct($ban->magazine, $ban->bannedBy);\n\n        $this->ban = $ban;\n\n        if (null !== $ban->expiredAt && $ban->expiredAt < new \\DateTime()) {\n            $this->meta = 'unban';\n        }\n    }\n\n    public function getType(): string\n    {\n        return 'log_ban';\n    }\n\n    public function getSubject(): ?ContentInterface\n    {\n        return null;\n    }\n\n    public function clearSubject(): MagazineLog\n    {\n        $this->ban = null;\n\n        return $this;\n    }\n}\n"
  },
  {
    "path": "src/Entity/MagazineLogEntryCommentDeleted.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity;\n\nuse App\\Entity\\Contracts\\ContentInterface;\nuse Doctrine\\ORM\\Mapping\\Entity;\nuse Doctrine\\ORM\\Mapping\\JoinColumn;\nuse Doctrine\\ORM\\Mapping\\ManyToOne;\n\n#[Entity]\nclass MagazineLogEntryCommentDeleted extends MagazineLog\n{\n    #[ManyToOne(targetEntity: EntryComment::class)]\n    #[JoinColumn(nullable: true, onDelete: 'CASCADE')]\n    public ?EntryComment $entryComment = null;\n\n    public function __construct(EntryComment $comment, User $user)\n    {\n        parent::__construct($comment->magazine, $user);\n\n        $this->entryComment = $comment;\n    }\n\n    public function getType(): string\n    {\n        return 'log_entry_comment_deleted';\n    }\n\n    public function getComment(): EntryComment\n    {\n        return $this->entryComment;\n    }\n\n    public function getSubject(): ContentInterface\n    {\n        return $this->entryComment;\n    }\n\n    public function clearSubject(): MagazineLog\n    {\n        $this->entryComment = null;\n\n        return $this;\n    }\n}\n"
  },
  {
    "path": "src/Entity/MagazineLogEntryCommentRestored.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity;\n\nuse App\\Entity\\Contracts\\ContentInterface;\nuse Doctrine\\ORM\\Mapping\\Entity;\nuse Doctrine\\ORM\\Mapping\\JoinColumn;\nuse Doctrine\\ORM\\Mapping\\ManyToOne;\n\n#[Entity]\nclass MagazineLogEntryCommentRestored extends MagazineLog\n{\n    #[ManyToOne(targetEntity: EntryComment::class)]\n    #[JoinColumn(nullable: true, onDelete: 'CASCADE')]\n    public ?EntryComment $entryComment = null;\n\n    public function __construct(EntryComment $comment, User $user)\n    {\n        parent::__construct($comment->magazine, $user);\n\n        $this->entryComment = $comment;\n    }\n\n    public function getType(): string\n    {\n        return 'log_entry_comment_restored';\n    }\n\n    public function getComment(): EntryComment\n    {\n        return $this->entryComment;\n    }\n\n    public function getSubject(): ContentInterface\n    {\n        return $this->entryComment;\n    }\n\n    public function clearSubject(): MagazineLog\n    {\n        $this->entryComment = null;\n\n        return $this;\n    }\n}\n"
  },
  {
    "path": "src/Entity/MagazineLogEntryDeleted.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity;\n\nuse App\\Entity\\Contracts\\ContentInterface;\nuse Doctrine\\ORM\\Mapping\\Entity;\nuse Doctrine\\ORM\\Mapping\\JoinColumn;\nuse Doctrine\\ORM\\Mapping\\ManyToOne;\n\n#[Entity]\nclass MagazineLogEntryDeleted extends MagazineLog\n{\n    #[ManyToOne(targetEntity: Entry::class)]\n    #[JoinColumn(nullable: true, onDelete: 'CASCADE')]\n    public ?Entry $entry = null;\n\n    public function __construct(Entry $entry, User $user)\n    {\n        parent::__construct($entry->magazine, $user);\n\n        $this->entry = $entry;\n    }\n\n    public function getType(): string\n    {\n        return 'log_entry_deleted';\n    }\n\n    public function getSubject(): ContentInterface\n    {\n        return $this->entry;\n    }\n\n    public function clearSubject(): MagazineLog\n    {\n        $this->entry = null;\n\n        return $this;\n    }\n}\n"
  },
  {
    "path": "src/Entity/MagazineLogEntryLocked.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity;\n\nuse App\\Entity\\Contracts\\ContentInterface;\nuse Doctrine\\ORM\\Mapping\\Entity;\nuse Doctrine\\ORM\\Mapping\\JoinColumn;\nuse Doctrine\\ORM\\Mapping\\ManyToOne;\n\n#[Entity]\nclass MagazineLogEntryLocked extends MagazineLog\n{\n    #[ManyToOne(targetEntity: Entry::class)]\n    #[JoinColumn(nullable: true, onDelete: 'CASCADE')]\n    public ?Entry $entry = null;\n\n    public function __construct(Entry $entry, User $user)\n    {\n        parent::__construct($entry->magazine, $user);\n\n        $this->entry = $entry;\n    }\n\n    public function getSubject(): ?ContentInterface\n    {\n        return $this->entry;\n    }\n\n    public function clearSubject(): MagazineLog\n    {\n        $this->entry = null;\n\n        return $this;\n    }\n\n    public function getType(): string\n    {\n        return 'log_entry_locked';\n    }\n}\n"
  },
  {
    "path": "src/Entity/MagazineLogEntryPinned.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity;\n\nuse App\\Entity\\Contracts\\ContentInterface;\nuse Doctrine\\ORM\\Mapping\\Entity;\nuse Doctrine\\ORM\\Mapping\\JoinColumn;\nuse Doctrine\\ORM\\Mapping\\ManyToOne;\n\n#[Entity]\nclass MagazineLogEntryPinned extends MagazineLog\n{\n    #[ManyToOne(targetEntity: Entry::class)]\n    #[JoinColumn(nullable: true, onDelete: 'CASCADE')]\n    public ?Entry $entry = null;\n\n    #[ManyToOne(targetEntity: User::class)]\n    #[JoinColumn(nullable: true, onDelete: 'CASCADE')]\n    public ?User $actingUser;\n\n    public function __construct(Magazine $magazine, ?User $actingUser, Entry $unpinnedEntry)\n    {\n        parent::__construct($magazine, $unpinnedEntry->user);\n        $this->entry = $unpinnedEntry;\n        $this->actingUser = $actingUser;\n    }\n\n    public function getSubject(): ?ContentInterface\n    {\n        return $this->entry;\n    }\n\n    public function clearSubject(): MagazineLog\n    {\n        $this->entry = null;\n\n        return $this;\n    }\n\n    public function getType(): string\n    {\n        return 'log_entry_pinned';\n    }\n}\n"
  },
  {
    "path": "src/Entity/MagazineLogEntryRestored.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity;\n\nuse App\\Entity\\Contracts\\ContentInterface;\nuse Doctrine\\ORM\\Mapping\\Entity;\nuse Doctrine\\ORM\\Mapping\\JoinColumn;\nuse Doctrine\\ORM\\Mapping\\ManyToOne;\n\n#[Entity]\nclass MagazineLogEntryRestored extends MagazineLog\n{\n    #[ManyToOne(targetEntity: Entry::class)]\n    #[JoinColumn(nullable: true, onDelete: 'CASCADE')]\n    public ?Entry $entry;\n\n    public function __construct(Entry $entry, User $user)\n    {\n        parent::__construct($entry->magazine, $user);\n\n        $this->entry = $entry;\n    }\n\n    public function getType(): string\n    {\n        return 'log_entry_restored';\n    }\n\n    public function getSubject(): ContentInterface\n    {\n        return $this->entry;\n    }\n\n    public function clearSubject(): MagazineLog\n    {\n        $this->entry = null;\n\n        return $this;\n    }\n}\n"
  },
  {
    "path": "src/Entity/MagazineLogEntryUnlocked.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity;\n\nuse App\\Entity\\Contracts\\ContentInterface;\nuse Doctrine\\ORM\\Mapping\\Entity;\nuse Doctrine\\ORM\\Mapping\\JoinColumn;\nuse Doctrine\\ORM\\Mapping\\ManyToOne;\n\n#[Entity]\nclass MagazineLogEntryUnlocked extends MagazineLog\n{\n    #[ManyToOne(targetEntity: Entry::class)]\n    #[JoinColumn(nullable: true, onDelete: 'CASCADE')]\n    public ?Entry $entry = null;\n\n    public function __construct(Entry $entry, User $user)\n    {\n        parent::__construct($entry->magazine, $user);\n\n        $this->entry = $entry;\n    }\n\n    public function getSubject(): ?ContentInterface\n    {\n        return $this->entry;\n    }\n\n    public function clearSubject(): MagazineLog\n    {\n        $this->entry = null;\n\n        return $this;\n    }\n\n    public function getType(): string\n    {\n        return 'log_entry_unlocked';\n    }\n}\n"
  },
  {
    "path": "src/Entity/MagazineLogEntryUnpinned.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity;\n\nuse App\\Entity\\Contracts\\ContentInterface;\nuse Doctrine\\ORM\\Mapping\\Entity;\nuse Doctrine\\ORM\\Mapping\\JoinColumn;\nuse Doctrine\\ORM\\Mapping\\ManyToOne;\n\n#[Entity]\nclass MagazineLogEntryUnpinned extends MagazineLog\n{\n    #[ManyToOne(targetEntity: Entry::class)]\n    #[JoinColumn(nullable: true, onDelete: 'CASCADE')]\n    public ?Entry $entry = null;\n\n    #[ManyToOne(targetEntity: User::class)]\n    #[JoinColumn(nullable: true, onDelete: 'CASCADE')]\n    public ?User $actingUser;\n\n    public function __construct(Magazine $magazine, ?User $actingUser, Entry $unpinnedEntry)\n    {\n        parent::__construct($magazine, $unpinnedEntry->user);\n        $this->entry = $unpinnedEntry;\n        $this->actingUser = $actingUser;\n    }\n\n    public function getSubject(): ?ContentInterface\n    {\n        return $this->entry;\n    }\n\n    public function clearSubject(): MagazineLog\n    {\n        $this->entry = null;\n\n        return $this;\n    }\n\n    public function getType(): string\n    {\n        return 'log_entry_unpinned';\n    }\n}\n"
  },
  {
    "path": "src/Entity/MagazineLogModeratorAdd.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity;\n\nuse App\\Entity\\Contracts\\ContentInterface;\nuse Doctrine\\ORM\\Mapping\\Entity;\nuse Doctrine\\ORM\\Mapping\\JoinColumn;\nuse Doctrine\\ORM\\Mapping\\ManyToOne;\n\n#[Entity]\nclass MagazineLogModeratorAdd extends MagazineLog\n{\n    #[ManyToOne(targetEntity: User::class)]\n    #[JoinColumn(nullable: true, onDelete: 'CASCADE')]\n    public ?User $actingUser;\n\n    public function __construct(Magazine $magazine, User $addedMod, ?User $actingUser)\n    {\n        parent::__construct($magazine, $addedMod);\n        $this->actingUser = $actingUser;\n    }\n\n    public function getSubject(): ?ContentInterface\n    {\n        return null;\n    }\n\n    public function clearSubject(): MagazineLog\n    {\n        return $this;\n    }\n\n    public function getType(): string\n    {\n        return 'log_moderator_add';\n    }\n}\n"
  },
  {
    "path": "src/Entity/MagazineLogModeratorRemove.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity;\n\nuse App\\Entity\\Contracts\\ContentInterface;\nuse Doctrine\\ORM\\Mapping\\Entity;\nuse Doctrine\\ORM\\Mapping\\JoinColumn;\nuse Doctrine\\ORM\\Mapping\\ManyToOne;\n\n#[Entity]\nclass MagazineLogModeratorRemove extends MagazineLog\n{\n    #[ManyToOne(targetEntity: User::class)]\n    #[JoinColumn(nullable: true, onDelete: 'CASCADE')]\n    public ?User $actingUser;\n\n    public function __construct(Magazine $magazine, User $addedMod, ?User $actingUser)\n    {\n        parent::__construct($magazine, $addedMod);\n        $this->actingUser = $actingUser;\n    }\n\n    public function getSubject(): ?ContentInterface\n    {\n        return null;\n    }\n\n    public function clearSubject(): MagazineLog\n    {\n        return $this;\n    }\n\n    public function getType(): string\n    {\n        return 'log_moderator_remove';\n    }\n}\n"
  },
  {
    "path": "src/Entity/MagazineLogPostCommentDeleted.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity;\n\nuse App\\Entity\\Contracts\\ContentInterface;\nuse Doctrine\\ORM\\Mapping\\Entity;\nuse Doctrine\\ORM\\Mapping\\JoinColumn;\nuse Doctrine\\ORM\\Mapping\\ManyToOne;\n\n#[Entity]\nclass MagazineLogPostCommentDeleted extends MagazineLog\n{\n    #[ManyToOne(targetEntity: PostComment::class)]\n    #[JoinColumn(nullable: true, onDelete: 'CASCADE')]\n    public ?PostComment $postComment = null;\n\n    public function __construct(PostComment $comment, User $user)\n    {\n        parent::__construct($comment->magazine, $user);\n\n        $this->postComment = $comment;\n    }\n\n    public function getType(): string\n    {\n        return 'log_post_comment_deleted';\n    }\n\n    public function getComment(): PostComment\n    {\n        return $this->postComment;\n    }\n\n    public function getSubject(): ContentInterface\n    {\n        return $this->postComment;\n    }\n\n    public function clearSubject(): MagazineLog\n    {\n        $this->postComment = null;\n\n        return $this;\n    }\n}\n"
  },
  {
    "path": "src/Entity/MagazineLogPostCommentRestored.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity;\n\nuse App\\Entity\\Contracts\\ContentInterface;\nuse Doctrine\\ORM\\Mapping\\Entity;\nuse Doctrine\\ORM\\Mapping\\JoinColumn;\nuse Doctrine\\ORM\\Mapping\\ManyToOne;\n\n#[Entity]\nclass MagazineLogPostCommentRestored extends MagazineLog\n{\n    #[ManyToOne(targetEntity: PostComment::class)]\n    #[JoinColumn(nullable: true, onDelete: 'CASCADE')]\n    public ?PostComment $postComment;\n\n    public function __construct(PostComment $comment, User $user)\n    {\n        parent::__construct($comment->magazine, $user);\n\n        $this->postComment = $comment;\n    }\n\n    public function getType(): string\n    {\n        return 'log_post_comment_restored';\n    }\n\n    public function getComment(): PostComment\n    {\n        return $this->postComment;\n    }\n\n    public function getSubject(): ContentInterface\n    {\n        return $this->postComment;\n    }\n\n    public function clearSubject(): MagazineLog\n    {\n        $this->postComment = null;\n\n        return $this;\n    }\n}\n"
  },
  {
    "path": "src/Entity/MagazineLogPostDeleted.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity;\n\nuse App\\Entity\\Contracts\\ContentInterface;\nuse Doctrine\\ORM\\Mapping\\Entity;\nuse Doctrine\\ORM\\Mapping\\JoinColumn;\nuse Doctrine\\ORM\\Mapping\\ManyToOne;\n\n#[Entity]\nclass MagazineLogPostDeleted extends MagazineLog\n{\n    #[ManyToOne(targetEntity: Post::class)]\n    #[JoinColumn(nullable: true, onDelete: 'CASCADE')]\n    public ?Post $post;\n\n    public function __construct(Post $post, User $user)\n    {\n        parent::__construct($post->magazine, $user);\n\n        $this->post = $post;\n    }\n\n    public function getType(): string\n    {\n        return 'log_post_deleted';\n    }\n\n    public function getSubject(): ContentInterface\n    {\n        return $this->post;\n    }\n\n    public function clearSubject(): MagazineLog\n    {\n        $this->post = null;\n\n        return $this;\n    }\n}\n"
  },
  {
    "path": "src/Entity/MagazineLogPostLocked.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity;\n\nuse App\\Entity\\Contracts\\ContentInterface;\nuse Doctrine\\ORM\\Mapping\\Entity;\nuse Doctrine\\ORM\\Mapping\\JoinColumn;\nuse Doctrine\\ORM\\Mapping\\ManyToOne;\n\n#[Entity]\nclass MagazineLogPostLocked extends MagazineLog\n{\n    #[ManyToOne(targetEntity: Post::class)]\n    #[JoinColumn(nullable: true, onDelete: 'CASCADE')]\n    public ?Post $post = null;\n\n    public function __construct(Post $post, User $user)\n    {\n        parent::__construct($post->magazine, $user);\n\n        $this->post = $post;\n    }\n\n    public function getSubject(): ?ContentInterface\n    {\n        return $this->post;\n    }\n\n    public function clearSubject(): MagazineLog\n    {\n        $this->post = null;\n\n        return $this;\n    }\n\n    public function getType(): string\n    {\n        return 'log_post_locked';\n    }\n}\n"
  },
  {
    "path": "src/Entity/MagazineLogPostRestored.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity;\n\nuse App\\Entity\\Contracts\\ContentInterface;\nuse Doctrine\\ORM\\Mapping\\Entity;\nuse Doctrine\\ORM\\Mapping\\JoinColumn;\nuse Doctrine\\ORM\\Mapping\\ManyToOne;\n\n#[Entity]\nclass MagazineLogPostRestored extends MagazineLog\n{\n    #[ManyToOne(targetEntity: Post::class)]\n    #[JoinColumn(nullable: true, onDelete: 'CASCADE')]\n    public ?Post $post;\n\n    public function __construct(Post $post, User $user)\n    {\n        parent::__construct($post->magazine, $user);\n\n        $this->post = $post;\n    }\n\n    public function getType(): string\n    {\n        return 'log_post_restored';\n    }\n\n    public function getSubject(): ContentInterface\n    {\n        return $this->post;\n    }\n\n    public function clearSubject(): MagazineLog\n    {\n        $this->post = null;\n\n        return $this;\n    }\n}\n"
  },
  {
    "path": "src/Entity/MagazineLogPostUnlocked.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity;\n\nuse App\\Entity\\Contracts\\ContentInterface;\nuse Doctrine\\ORM\\Mapping\\Entity;\nuse Doctrine\\ORM\\Mapping\\JoinColumn;\nuse Doctrine\\ORM\\Mapping\\ManyToOne;\n\n#[Entity]\nclass MagazineLogPostUnlocked extends MagazineLog\n{\n    #[ManyToOne(targetEntity: Post::class)]\n    #[JoinColumn(nullable: true, onDelete: 'CASCADE')]\n    public ?Post $post = null;\n\n    public function __construct(Post $post, User $user)\n    {\n        parent::__construct($post->magazine, $user);\n\n        $this->post = $post;\n    }\n\n    public function getSubject(): ?ContentInterface\n    {\n        return $this->post;\n    }\n\n    public function clearSubject(): MagazineLog\n    {\n        $this->post = null;\n\n        return $this;\n    }\n\n    public function getType(): string\n    {\n        return 'log_post_unlocked';\n    }\n}\n"
  },
  {
    "path": "src/Entity/MagazineOwnershipRequest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity;\n\nuse App\\Entity\\Traits\\CreatedAtTrait;\nuse Doctrine\\ORM\\Mapping\\Column;\nuse Doctrine\\ORM\\Mapping\\Entity;\nuse Doctrine\\ORM\\Mapping\\GeneratedValue;\nuse Doctrine\\ORM\\Mapping\\Id;\nuse Doctrine\\ORM\\Mapping\\JoinColumn;\nuse Doctrine\\ORM\\Mapping\\ManyToOne;\nuse Doctrine\\ORM\\Mapping\\Table;\nuse Doctrine\\ORM\\Mapping\\UniqueConstraint;\n\n#[Entity]\n#[Table]\n#[UniqueConstraint(name: 'magazine_ownership_magazine_user_idx', columns: ['magazine_id', 'user_id'])]\nclass MagazineOwnershipRequest\n{\n    use CreatedAtTrait {\n        CreatedAtTrait::__construct as createdAtTraitConstruct;\n    }\n\n    #[ManyToOne(targetEntity: User::class, inversedBy: 'magazineOwnershipRequests')]\n    #[JoinColumn(nullable: false, onDelete: 'CASCADE')]\n    public User $user;\n    #[ManyToOne(targetEntity: Magazine::class, inversedBy: 'ownershipRequests')]\n    #[JoinColumn(nullable: false, onDelete: 'CASCADE')]\n    public Magazine $magazine;\n    #[Id]\n    #[GeneratedValue]\n    #[Column(type: 'integer')]\n    private int $id;\n\n    public function __construct(Magazine $magazine, User $user)\n    {\n        $this->magazine = $magazine;\n        $this->user = $user;\n\n        $this->createdAtTraitConstruct();\n    }\n\n    public function getId(): int\n    {\n        return $this->id;\n    }\n\n    public function __sleep()\n    {\n        return [];\n    }\n}\n"
  },
  {
    "path": "src/Entity/MagazineSubscription.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity;\n\nuse App\\Entity\\Traits\\CreatedAtTrait;\nuse App\\Repository\\MagazineSubscriptionRepository;\nuse Doctrine\\ORM\\Mapping\\Column;\nuse Doctrine\\ORM\\Mapping\\Entity;\nuse Doctrine\\ORM\\Mapping\\GeneratedValue;\nuse Doctrine\\ORM\\Mapping\\Id;\nuse Doctrine\\ORM\\Mapping\\JoinColumn;\nuse Doctrine\\ORM\\Mapping\\ManyToOne;\nuse Doctrine\\ORM\\Mapping\\Table;\nuse Doctrine\\ORM\\Mapping\\UniqueConstraint;\n\n#[Entity(repositoryClass: MagazineSubscriptionRepository::class)]\n#[Table]\n#[UniqueConstraint(name: 'magazine_subsription_idx', columns: ['user_id', 'magazine_id'])]\nclass MagazineSubscription\n{\n    use CreatedAtTrait {\n        CreatedAtTrait::__construct as createdAtTraitConstruct;\n    }\n\n    #[ManyToOne(targetEntity: User::class, inversedBy: 'subscriptions')]\n    #[JoinColumn(nullable: false, onDelete: 'CASCADE')]\n    public ?User $user;\n    #[ManyToOne(targetEntity: Magazine::class, inversedBy: 'subscriptions')]\n    #[JoinColumn(nullable: false, onDelete: 'CASCADE')]\n    public ?Magazine $magazine;\n    #[Id]\n    #[GeneratedValue]\n    #[Column(type: 'integer')]\n    private int $id;\n\n    public function __construct(User $user, Magazine $magazine)\n    {\n        $this->createdAtTraitConstruct();\n        $this->user = $user;\n        $this->magazine = $magazine;\n    }\n\n    public function getId(): ?int\n    {\n        return $this->id;\n    }\n\n    public function __sleep()\n    {\n        return [];\n    }\n}\n"
  },
  {
    "path": "src/Entity/MagazineSubscriptionRequest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity;\n\nuse App\\Entity\\Traits\\CreatedAtTrait;\nuse Doctrine\\ORM\\Mapping\\Column;\nuse Doctrine\\ORM\\Mapping\\Entity;\nuse Doctrine\\ORM\\Mapping\\GeneratedValue;\nuse Doctrine\\ORM\\Mapping\\Id;\nuse Doctrine\\ORM\\Mapping\\JoinColumn;\nuse Doctrine\\ORM\\Mapping\\ManyToOne;\nuse Doctrine\\ORM\\Mapping\\Table;\nuse Doctrine\\ORM\\Mapping\\UniqueConstraint;\n\n#[Entity(repositoryClass: MagazineSubscription::class)]\n#[Table]\n#[UniqueConstraint(name: 'magazine_subscription_requests_idx', columns: ['user_id', 'magazine_id'])]\nclass MagazineSubscriptionRequest\n{\n    use CreatedAtTrait {\n        CreatedAtTrait::__construct as createdAtTraitConstruct;\n    }\n\n    #[ManyToOne(targetEntity: User::class)]\n    #[JoinColumn(nullable: false, onDelete: 'CASCADE')]\n    public ?User $user;\n    #[ManyToOne(targetEntity: Magazine::class)]\n    #[JoinColumn(nullable: false, onDelete: 'CASCADE')]\n    public ?Magazine $magazine;\n    #[Id]\n    #[GeneratedValue]\n    #[Column(type: 'integer')]\n    private int $id;\n\n    public function __construct(User $user, Magazine $magazine)\n    {\n        $this->createdAtTraitConstruct();\n\n        $this->user = $user;\n        $this->magazine = $magazine;\n    }\n\n    public function getId(): ?int\n    {\n        return $this->id;\n    }\n\n    public function __sleep()\n    {\n        return [];\n    }\n}\n"
  },
  {
    "path": "src/Entity/MagazineUnBanNotification.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity;\n\nuse App\\Payloads\\PushNotification;\nuse Doctrine\\ORM\\Mapping\\Entity;\nuse Doctrine\\ORM\\Mapping\\JoinColumn;\nuse Doctrine\\ORM\\Mapping\\ManyToOne;\nuse Symfony\\Component\\Routing\\Generator\\UrlGeneratorInterface;\nuse Symfony\\Contracts\\Translation\\TranslatorInterface;\n\n#[Entity]\nclass MagazineUnBanNotification extends Notification\n{\n    #[ManyToOne(targetEntity: MagazineBan::class)]\n    #[JoinColumn(nullable: true, onDelete: 'CASCADE')]\n    public ?MagazineBan $ban = null;\n\n    public function __construct(User $receiver, MagazineBan $ban)\n    {\n        parent::__construct($receiver);\n\n        $this->ban = $ban;\n    }\n\n    public function getSubject(): MagazineBan\n    {\n        return $this->ban;\n    }\n\n    public function getType(): string\n    {\n        return 'magazine_unban_notification';\n    }\n\n    public function getMessage(TranslatorInterface $trans, string $locale, UrlGeneratorInterface $urlGenerator): PushNotification\n    {\n        $message = $trans->trans('you_are_no_longer_banned_from_magazine', ['%m' => $this->ban->magazine->name], locale: $locale);\n        $slash = $this->ban->magazine->icon && !str_starts_with('/', $this->ban->magazine->icon->filePath) ? '/' : '';\n        $avatarUrl = $this->ban->magazine->icon ? '/media/cache/resolve/avatar_thumb'.$slash.$this->ban->magazine->icon->filePath : null;\n\n        return new PushNotification($this->getId(), $message, $trans->trans('notification_title_ban', locale: $locale), avatarUrl: $avatarUrl);\n    }\n}\n"
  },
  {
    "path": "src/Entity/Message.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity;\n\nuse App\\Entity\\Contracts\\ActivityPubActivityInterface;\nuse App\\Entity\\Traits\\ActivityPubActivityTrait;\nuse App\\Entity\\Traits\\CreatedAtTrait;\nuse App\\Entity\\Traits\\EditedAtTrait;\nuse Doctrine\\Common\\Collections\\ArrayCollection;\nuse Doctrine\\Common\\Collections\\Collection;\nuse Doctrine\\ORM\\Mapping\\Column;\nuse Doctrine\\ORM\\Mapping\\Entity;\nuse Doctrine\\ORM\\Mapping\\GeneratedValue;\nuse Doctrine\\ORM\\Mapping\\Id;\nuse Doctrine\\ORM\\Mapping\\JoinColumn;\nuse Doctrine\\ORM\\Mapping\\ManyToOne;\nuse Doctrine\\ORM\\Mapping\\OneToMany;\nuse Symfony\\Component\\Uid\\Uuid;\n\n#[Entity]\nclass Message implements ActivityPubActivityInterface\n{\n    use ActivityPubActivityTrait;\n    use CreatedAtTrait {\n        CreatedAtTrait::__construct as createdAtTraitConstruct;\n    }\n    use EditedAtTrait;\n    public const STATUS_NEW = 'new';\n    public const STATUS_READ = 'read';\n    public const STATUS_OPTIONS = [\n        self::STATUS_NEW,\n        self::STATUS_READ,\n    ];\n\n    #[ManyToOne(targetEntity: MessageThread::class, inversedBy: 'messages')]\n    #[JoinColumn(nullable: false, onDelete: 'CASCADE')]\n    public MessageThread $thread;\n    #[ManyToOne(targetEntity: User::class)]\n    #[JoinColumn(nullable: false, onDelete: 'CASCADE')]\n    public User $sender;\n    #[Column(type: 'text', nullable: false)]\n    public string $body;\n    #[Column(type: 'string', nullable: false)]\n    public string $status = self::STATUS_NEW;\n    #[Column(type: 'uuid', unique: true, nullable: false)]\n    public string $uuid;\n    #[Id]\n    #[GeneratedValue]\n    #[Column(type: 'integer')]\n    private int $id;\n    #[OneToMany(mappedBy: 'message', targetEntity: MessageNotification::class, cascade: ['remove'], orphanRemoval: true)]\n    private Collection $notifications;\n\n    public function __construct(MessageThread $thread, User $sender, string $body, ?string $apId)\n    {\n        $this->thread = $thread;\n        $this->sender = $sender;\n        $this->body = $body;\n        $this->notifications = new ArrayCollection();\n        $this->uuid = Uuid::v4()->toRfc4122();\n        $this->apId = $apId;\n\n        $thread->addMessage($this);\n\n        $this->createdAtTraitConstruct();\n    }\n\n    public function getId(): int\n    {\n        return $this->id;\n    }\n\n    public function getTitle(): string\n    {\n        $firstLine = preg_replace('/^# |\\R.*/', '', $this->body);\n\n        if (grapheme_strlen($firstLine) <= 80) {\n            return $firstLine;\n        }\n\n        return grapheme_substr($firstLine, 0, 80).'…';\n    }\n\n    public function getUser(): User\n    {\n        return $this->sender;\n    }\n}\n"
  },
  {
    "path": "src/Entity/MessageNotification.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity;\n\nuse App\\Enums\\EPushNotificationType;\nuse App\\Payloads\\PushNotification;\nuse Doctrine\\ORM\\Mapping\\Entity;\nuse Doctrine\\ORM\\Mapping\\JoinColumn;\nuse Doctrine\\ORM\\Mapping\\ManyToOne;\nuse Symfony\\Component\\Routing\\Generator\\UrlGeneratorInterface;\nuse Symfony\\Contracts\\Translation\\TranslatorInterface;\n\n#[Entity]\nclass MessageNotification extends Notification\n{\n    #[ManyToOne(targetEntity: Message::class, inversedBy: 'notifications')]\n    #[JoinColumn(nullable: true, onDelete: 'CASCADE')]\n    public ?Message $message = null;\n\n    public function __construct(\n        User $receiver,\n        Message $message,\n    ) {\n        parent::__construct($receiver);\n\n        $this->message = $message;\n    }\n\n    public function getSubject(): Message\n    {\n        return $this->message;\n    }\n\n    public function getType(): string\n    {\n        return 'message_notification';\n    }\n\n    public function getMessage(TranslatorInterface $trans, string $locale, UrlGeneratorInterface $urlGenerator): PushNotification\n    {\n        $message = \\sprintf('%s %s: %s', $this->message->sender->username, $trans->trans('wrote_message'), $this->message->body);\n        $slash = $this->message->sender->avatar && !str_starts_with('/', $this->message->sender->avatar->filePath) ? '/' : '';\n        $avatarUrl = $this->message->sender->avatar ? '/media/cache/resolve/avatar_thumb'.$slash.$this->message->sender->avatar->filePath : null;\n        $url = $urlGenerator->generate('messages_single', ['id' => $this->message->thread->getId()]);\n\n        return new PushNotification($this->getId(), $message, $trans->trans('notification_title_message', locale: $locale), actionUrl: $url, avatarUrl: $avatarUrl, category: EPushNotificationType::Message);\n    }\n}\n"
  },
  {
    "path": "src/Entity/MessageThread.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity;\n\nuse App\\Entity\\Traits\\UpdatedAtTrait;\nuse App\\Repository\\MessageThreadRepository;\nuse Doctrine\\Common\\Collections\\ArrayCollection;\nuse Doctrine\\Common\\Collections\\Collection;\nuse Doctrine\\Common\\Collections\\Criteria;\nuse Doctrine\\ORM\\Mapping\\Column;\nuse Doctrine\\ORM\\Mapping\\Entity;\nuse Doctrine\\ORM\\Mapping\\GeneratedValue;\nuse Doctrine\\ORM\\Mapping\\Id;\nuse Doctrine\\ORM\\Mapping\\JoinColumn;\nuse Doctrine\\ORM\\Mapping\\JoinTable;\nuse Doctrine\\ORM\\Mapping\\ManyToMany;\nuse Doctrine\\ORM\\Mapping\\OneToMany;\nuse Doctrine\\ORM\\Mapping\\OrderBy;\nuse JetBrains\\PhpStorm\\Pure;\n\n#[Entity(repositoryClass: MessageThreadRepository::class)]\nclass MessageThread\n{\n    use UpdatedAtTrait;\n\n    #[JoinTable(\n        name: 'message_thread_participants',\n        joinColumns: [\n            new JoinColumn(name: 'message_thread_id', referencedColumnName: 'id', onDelete: 'CASCADE'),\n        ],\n        inverseJoinColumns: [\n            new JoinColumn(name: 'user_id', referencedColumnName: 'id', onDelete: 'CASCADE'),\n        ]\n    )]\n    #[ManyToMany(targetEntity: User::class, cascade: ['persist'], orphanRemoval: true)]\n    public Collection $participants;\n    #[OneToMany(mappedBy: 'thread', targetEntity: Message::class, cascade: ['persist', 'remove'], orphanRemoval: true)]\n    #[OrderBy(['createdAt' => 'ASC'])]\n    public Collection $messages;\n    #[Id]\n    #[GeneratedValue]\n    #[Column(type: 'integer')]\n    private int $id;\n\n    #[Pure]\n    public function __construct(User ...$participants)\n    {\n        $this->participants = new ArrayCollection($participants);\n        $this->messages = new ArrayCollection();\n    }\n\n    public function getId(): ?int\n    {\n        return $this->id;\n    }\n\n    public function getOtherParticipants(User $self): array\n    {\n        return $this->participants->filter(\n            static function (User $user) use ($self) {\n                return $user !== $self;\n            }\n        )->getValues();\n    }\n\n    public function getNewMessages(User $user): Collection\n    {\n        $criteria = Criteria::create()\n            ->where(Criteria::expr()->eq('status', Message::STATUS_NEW))\n            ->andWhere(Criteria::expr()->neq('sender', $user));\n\n        return $this->messages->matching($criteria);\n    }\n\n    public function countNewMessages(User $user): int\n    {\n        $criteria = Criteria::create()\n            ->where(Criteria::expr()->eq('status', Message::STATUS_NEW))\n            ->andWhere(Criteria::expr()->neq('sender', $user));\n\n        return $this->messages->matching($criteria)->count();\n    }\n\n    public function addMessage(Message $message): void\n    {\n        if (!$this->messages->contains($message)) {\n            if (!$this->userIsParticipant($message->sender)) {\n                throw new \\DomainException('Sender is not allowed to participate');\n            }\n\n            $this->messages->add($message);\n        }\n    }\n\n    public function userIsParticipant($user): bool\n    {\n        return $this->participants->contains($user);\n    }\n\n    public function removeMessage(Message $message): void\n    {\n        $this->messages->removeElement($message);\n    }\n\n    public function getLastMessage(): ?Message\n    {\n        if (0 === $this->messages->count()) {\n            return null;\n        }\n\n        return $this->messages[$this->messages->count() - 1];\n    }\n\n    public function getTitle(): string\n    {\n        $body = $this->messages[0]->body;\n        $firstLine = preg_replace('/^# |\\R.*/', '', $body);\n\n        if (grapheme_strlen($firstLine) <= 80) {\n            return $firstLine;\n        }\n\n        return grapheme_substr($firstLine, 0, 80).'…';\n    }\n}\n"
  },
  {
    "path": "src/Entity/Moderator.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity;\n\nuse App\\Entity\\Traits\\CreatedAtTrait;\nuse Doctrine\\ORM\\Mapping\\Column;\nuse Doctrine\\ORM\\Mapping\\Entity;\nuse Doctrine\\ORM\\Mapping\\GeneratedValue;\nuse Doctrine\\ORM\\Mapping\\Id;\nuse Doctrine\\ORM\\Mapping\\JoinColumn;\nuse Doctrine\\ORM\\Mapping\\ManyToOne;\nuse Doctrine\\ORM\\Mapping\\Table;\nuse Doctrine\\ORM\\Mapping\\UniqueConstraint;\n\n#[Entity]\n#[Table]\n#[UniqueConstraint(name: 'moderator_magazine_user_idx', columns: ['magazine_id', 'user_id'])]\nclass Moderator\n{\n    use CreatedAtTrait {\n        CreatedAtTrait::__construct as createdAtTraitConstruct;\n    }\n\n    #[ManyToOne(targetEntity: User::class, inversedBy: 'moderatorTokens')]\n    #[JoinColumn(nullable: false, onDelete: 'CASCADE')]\n    public User $user;\n    #[ManyToOne(targetEntity: Magazine::class, inversedBy: 'moderators')]\n    #[JoinColumn(nullable: false, onDelete: 'CASCADE')]\n    public Magazine $magazine;\n    #[ManyToOne(targetEntity: User::class)]\n    #[JoinColumn(nullable: true, onDelete: 'SET NULL')]\n    public ?User $addedByUser;\n    #[Column(type: 'boolean', nullable: false)]\n    public bool $isOwner = false;\n    #[Column(type: 'boolean', nullable: false)]\n    public bool $isConfirmed = false;\n    #[Id]\n    #[GeneratedValue]\n    #[Column(type: 'integer')]\n    private int $id;\n\n    public function __construct(Magazine $magazine, User $user, ?User $addedByUser = null, $isOwner = false, $isConfirmed = false)\n    {\n        $this->magazine = $magazine;\n        $this->user = $user;\n        $this->addedByUser = $addedByUser;\n        $this->isOwner = $isOwner;\n        $this->isConfirmed = $isConfirmed;\n\n        $magazine->moderators->add($this);\n        $user->moderatorTokens->add($this);\n\n        $this->createdAtTraitConstruct();\n    }\n\n    public function getId(): int\n    {\n        return $this->id;\n    }\n\n    public function __sleep()\n    {\n        return [];\n    }\n}\n"
  },
  {
    "path": "src/Entity/ModeratorRequest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity;\n\nuse App\\Entity\\Traits\\CreatedAtTrait;\nuse Doctrine\\ORM\\Mapping\\Column;\nuse Doctrine\\ORM\\Mapping\\Entity;\nuse Doctrine\\ORM\\Mapping\\GeneratedValue;\nuse Doctrine\\ORM\\Mapping\\Id;\nuse Doctrine\\ORM\\Mapping\\JoinColumn;\nuse Doctrine\\ORM\\Mapping\\ManyToOne;\nuse Doctrine\\ORM\\Mapping\\Table;\nuse Doctrine\\ORM\\Mapping\\UniqueConstraint;\n\n#[Entity]\n#[Table]\n#[UniqueConstraint(name: 'moderator_request_magazine_user_idx', columns: ['magazine_id', 'user_id'])]\nclass ModeratorRequest\n{\n    use CreatedAtTrait {\n        CreatedAtTrait::__construct as createdAtTraitConstruct;\n    }\n\n    #[ManyToOne(targetEntity: User::class, inversedBy: 'moderatorRequests')]\n    #[JoinColumn(nullable: false, onDelete: 'CASCADE')]\n    public User $user;\n    #[ManyToOne(targetEntity: Magazine::class, inversedBy: 'moderatorRequests')]\n    #[JoinColumn(nullable: false, onDelete: 'CASCADE')]\n    public Magazine $magazine;\n    #[Id]\n    #[GeneratedValue]\n    #[Column(type: 'integer')]\n    private int $id;\n\n    public function __construct(Magazine $magazine, User $user)\n    {\n        $this->magazine = $magazine;\n        $this->user = $user;\n\n        $this->createdAtTraitConstruct();\n    }\n\n    public function getId(): int\n    {\n        return $this->id;\n    }\n\n    public function __sleep()\n    {\n        return [];\n    }\n}\n"
  },
  {
    "path": "src/Entity/MonitoringCurlRequest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity;\n\nuse App\\Entity\\Traits\\CreatedAtTrait;\nuse App\\Entity\\Traits\\MonitoringPerformanceTrait;\nuse Doctrine\\ORM\\Mapping\\Column;\nuse Doctrine\\ORM\\Mapping\\Entity;\nuse Doctrine\\ORM\\Mapping\\GeneratedValue;\nuse Doctrine\\ORM\\Mapping\\Id;\nuse Doctrine\\ORM\\Mapping\\JoinColumn;\nuse Doctrine\\ORM\\Mapping\\ManyToOne;\n\n#[Entity]\nclass MonitoringCurlRequest\n{\n    use CreatedAtTrait {\n        CreatedAtTrait::__construct as createdAtTraitConstruct;\n    }\n    use MonitoringPerformanceTrait;\n\n    #[Id]\n    #[GeneratedValue]\n    #[Column(type: 'integer')]\n    private int $id;\n\n    #[Column]\n    public string $url;\n\n    #[Column]\n    public string $method;\n\n    #[Column]\n    public bool $wasSuccessful;\n\n    #[Column(nullable: true)]\n    public ?string $exception = null;\n\n    #[ManyToOne(targetEntity: MonitoringExecutionContext::class, inversedBy: 'curlRequests')]\n    #[JoinColumn(referencedColumnName: 'uuid', onDelete: 'CASCADE')]\n    public MonitoringExecutionContext $context;\n\n    public function __construct()\n    {\n        $this->createdAtTraitConstruct();\n    }\n}\n"
  },
  {
    "path": "src/Entity/MonitoringExecutionContext.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity;\n\nuse App\\DTO\\GroupedMonitoringQueryDto;\nuse App\\Entity\\Traits\\CreatedAtTrait;\nuse App\\Entity\\Traits\\MonitoringPerformanceTrait;\nuse Doctrine\\Common\\Collections\\Collection;\nuse Doctrine\\Common\\Collections\\Criteria;\nuse Doctrine\\ORM\\Mapping\\Column;\nuse Doctrine\\ORM\\Mapping\\CustomIdGenerator;\nuse Doctrine\\ORM\\Mapping\\Entity;\nuse Doctrine\\ORM\\Mapping\\GeneratedValue;\nuse Doctrine\\ORM\\Mapping\\Id;\nuse Doctrine\\ORM\\Mapping\\OneToMany;\nuse Symfony\\Component\\Uid\\Uuid;\n\n#[Entity]\nclass MonitoringExecutionContext\n{\n    use CreatedAtTrait {\n        CreatedAtTrait::__construct as createdAtTraitConstruct;\n    }\n    use MonitoringPerformanceTrait;\n\n    #[Column(type: 'uuid'), Id, GeneratedValue(strategy: 'CUSTOM')]\n    #[CustomIdGenerator(class: 'doctrine.uuid_generator')]\n    public Uuid $uuid;\n\n    /**\n     * @var string 'request'|'messenger'\n     */\n    #[Column]\n    public string $executionType;\n\n    /**\n     * @var string the path or the message class\n     */\n    #[Column]\n    public string $path;\n\n    /**\n     * @var string the controller or the message transport\n     */\n    #[Column]\n    public string $handler;\n\n    /**\n     * @var string 'anonymous'|'user'|'activity_pub'|'ajax'\n     */\n    #[Column]\n    public string $userType;\n\n    #[Column(nullable: true)]\n    public ?int $statusCode = null;\n\n    #[Column(nullable: true)]\n    public ?string $exception = null;\n\n    #[Column(nullable: true)]\n    public ?string $stacktrace = null;\n\n    #[Column(nullable: true)]\n    public ?float $responseSendingDurationMilliseconds = null;\n\n    #[Column]\n    public float $queryDurationMilliseconds;\n\n    #[Column]\n    public float $twigRenderDurationMilliseconds;\n\n    #[Column]\n    public float $curlRequestDurationMilliseconds;\n\n    /**\n     * @var Collection<MonitoringQuery>\n     */\n    #[OneToMany(mappedBy: 'context', targetEntity: MonitoringQuery::class, fetch: 'EXTRA_LAZY', orphanRemoval: true)]\n    public Collection $queries;\n\n    /**\n     * @var Collection<MonitoringCurlRequest>\n     */\n    #[OneToMany(mappedBy: 'context', targetEntity: MonitoringCurlRequest::class, fetch: 'EXTRA_LAZY', orphanRemoval: true)]\n    public Collection $curlRequests;\n\n    /**\n     * @var Collection<MonitoringTwigRender>\n     */\n    #[OneToMany(mappedBy: 'context', targetEntity: MonitoringTwigRender::class, fetch: 'EXTRA_LAZY', orphanRemoval: true)]\n    public Collection $twigRenders;\n\n    public function __construct()\n    {\n        $this->createdAtTraitConstruct();\n    }\n\n    /**\n     * @return Collection<MonitoringQuery>\n     */\n    public function getQueriesSorted(string $sortBy = 'durationMilliseconds', string $sortDirection = 'DESC'): Collection\n    {\n        $criteria = new Criteria(orderings: [$sortBy => $sortDirection]);\n\n        return $this->queries->matching($criteria);\n    }\n\n    /**\n     * @return GroupedMonitoringQueryDto[]\n     */\n    public function getGroupedQueries(): array\n    {\n        /** @var array<string, MonitoringQuery[]> $groupedQueries */\n        $groupedQueries = [];\n        foreach ($this->getQueriesSorted() as $query) {\n            $hash = $query->queryString->queryHash;\n            if (!\\array_key_exists($hash, $groupedQueries)) {\n                $groupedQueries[$hash] = [];\n            }\n            $groupedQueries[$hash][] = $query;\n        }\n        $dtos = [];\n        foreach ($groupedQueries as $hash => $queries) {\n            $dto = new GroupedMonitoringQueryDto();\n            $dto->query = $queries[0]->queryString->query;\n            $minTime = 10000000000;\n            $maxTime = 0;\n            $addedTime = 0;\n            $queryCount = 0;\n            foreach ($queries as $query) {\n                $duration = $query->getDuration();\n                if ($minTime > $duration) {\n                    $minTime = $duration;\n                }\n                if ($maxTime < $duration) {\n                    $maxTime = $duration;\n                }\n                $addedTime += $duration;\n                ++$queryCount;\n            }\n            $dto->count = $queryCount;\n            $dto->maxExecutionTime = $maxTime;\n            $dto->minExecutionTime = $minTime;\n            $dto->meanExecutionTime = $addedTime / $queryCount;\n            $dto->totalExecutionTime = $addedTime;\n            $dtos[] = $dto;\n        }\n        usort($dtos, function (GroupedMonitoringQueryDto $a, GroupedMonitoringQueryDto $b) {\n            if ($a->totalExecutionTime === $b->totalExecutionTime) {\n                return 0;\n            }\n\n            return $b->totalExecutionTime < $a->totalExecutionTime ? -1 : 1;\n        });\n\n        return $dtos;\n    }\n\n    /**\n     * @return Collection<MonitoringTwigRender>\n     */\n    public function getRootTwigRenders(): Collection\n    {\n        $criteria = new Criteria(Criteria::expr()->isNull('parent'));\n\n        return $this->twigRenders->matching($criteria);\n    }\n\n    /**\n     * @return Collection<MonitoringCurlRequest>\n     */\n    public function getRequestsSorted(string $sortBy = 'durationMilliseconds', string $sortDirection = 'DESC'): Collection\n    {\n        $criteria = new Criteria(orderings: [$sortBy => $sortDirection]);\n\n        return $this->curlRequests->matching($criteria);\n    }\n}\n"
  },
  {
    "path": "src/Entity/MonitoringQuery.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity;\n\nuse App\\Entity\\Traits\\CreatedAtTrait;\nuse App\\Entity\\Traits\\MonitoringPerformanceTrait;\nuse Doctrine\\DBAL\\Types\\Types;\nuse Doctrine\\ORM\\Mapping\\Column;\nuse Doctrine\\ORM\\Mapping\\Entity;\nuse Doctrine\\ORM\\Mapping\\GeneratedValue;\nuse Doctrine\\ORM\\Mapping\\Id;\nuse Doctrine\\ORM\\Mapping\\JoinColumn;\nuse Doctrine\\ORM\\Mapping\\ManyToOne;\n\n#[Entity]\nclass MonitoringQuery\n{\n    use CreatedAtTrait {\n        CreatedAtTrait::__construct as createdAtTraitConstruct;\n    }\n    use MonitoringPerformanceTrait;\n\n    #[Id]\n    #[GeneratedValue]\n    #[Column(type: 'integer')]\n    private int $id;\n\n    #[Column(type: Types::JSONB, nullable: true)]\n    public ?array $parameters = null;\n\n    #[ManyToOne(targetEntity: MonitoringExecutionContext::class, inversedBy: 'queries')]\n    #[JoinColumn(referencedColumnName: 'uuid', onDelete: 'CASCADE')]\n    public MonitoringExecutionContext $context;\n\n    #[ManyToOne(targetEntity: MonitoringQueryString::class, fetch: 'EAGER', inversedBy: 'queryInstances')]\n    #[JoinColumn(referencedColumnName: 'query_hash', onDelete: 'CASCADE')]\n    public MonitoringQueryString $queryString;\n\n    public function __construct()\n    {\n        $this->createdAtTraitConstruct();\n    }\n\n    public function cleanParameterArray(): void\n    {\n        if (null !== $this->parameters) {\n            $json = json_encode($this->parameters, JSON_INVALID_UTF8_IGNORE | JSON_UNESCAPED_UNICODE);\n            $newParameters = json_decode($json, true);\n            $newParameters2 = [];\n            foreach ($newParameters as $newParameter) {\n                if (\\is_string($newParameter)) {\n                    $newParameter = preg_replace('/[[:cntrl:]]/', '', $newParameter);\n                }\n                $newParameters2[] = $newParameter;\n            }\n            $this->parameters = $newParameters2;\n        }\n    }\n}\n"
  },
  {
    "path": "src/Entity/MonitoringQueryString.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity;\n\nuse Doctrine\\Common\\Collections\\Collection;\nuse Doctrine\\ORM\\Mapping\\Column;\nuse Doctrine\\ORM\\Mapping\\Entity;\nuse Doctrine\\ORM\\Mapping\\Id;\nuse Doctrine\\ORM\\Mapping\\OneToMany;\n\n#[Entity]\nclass MonitoringQueryString\n{\n    #[Column(type: 'string', length: 40)]\n    #[Id]\n    public string $queryHash;\n\n    #[Column(type: 'text')]\n    public string $query;\n\n    #[OneToMany(mappedBy: 'queryString', targetEntity: MonitoringQuery::class, orphanRemoval: true)]\n    public Collection $queryInstances;\n}\n"
  },
  {
    "path": "src/Entity/MonitoringTwigRender.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity;\n\nuse App\\Entity\\Traits\\CreatedAtTrait;\nuse App\\Entity\\Traits\\MonitoringPerformanceTrait;\nuse Doctrine\\Common\\Collections\\Collection;\nuse Doctrine\\ORM\\Mapping\\Column;\nuse Doctrine\\ORM\\Mapping\\Entity;\nuse Doctrine\\ORM\\Mapping\\GeneratedValue;\nuse Doctrine\\ORM\\Mapping\\Id;\nuse Doctrine\\ORM\\Mapping\\JoinColumn;\nuse Doctrine\\ORM\\Mapping\\ManyToOne;\nuse Doctrine\\ORM\\Mapping\\OneToMany;\n\n#[Entity]\nclass MonitoringTwigRender\n{\n    use CreatedAtTrait {\n        CreatedAtTrait::__construct as createdAtTraitConstruct;\n    }\n    use MonitoringPerformanceTrait;\n\n    #[Id]\n    #[GeneratedValue]\n    #[Column(type: 'integer')]\n    private int $id;\n\n    #[ManyToOne(targetEntity: MonitoringExecutionContext::class, inversedBy: 'twigRenders')]\n    #[JoinColumn(referencedColumnName: 'uuid', onDelete: 'CASCADE')]\n    public MonitoringExecutionContext $context;\n\n    #[Column(type: 'text')]\n    public string $shortDescription;\n\n    #[Column(nullable: true)]\n    public ?string $templateName = null;\n\n    #[Column(nullable: true)]\n    public ?string $name = null;\n\n    #[Column(nullable: true)]\n    public ?string $type = null;\n\n    #[Column(nullable: true)]\n    public ?int $memoryUsage = null;\n\n    #[Column(nullable: true)]\n    public ?int $peakMemoryUsage = null;\n\n    #[Column(nullable: true)]\n    public ?float $profilerDuration = null;\n\n    #[ManyToOne(targetEntity: MonitoringTwigRender::class, inversedBy: 'children')]\n    #[JoinColumn(nullable: true, onDelete: 'CASCADE')]\n    public ?MonitoringTwigRender $parent = null;\n\n    #[OneToMany(mappedBy: 'parent', targetEntity: MonitoringTwigRender::class, orphanRemoval: true)]\n    public Collection $children;\n\n    public function __construct()\n    {\n        $this->createdAtTraitConstruct();\n    }\n\n    public function getPercentageOfParentDuration(): float\n    {\n        if (null === $this->parent) {\n            return 100 / $this->context->twigRenderDurationMilliseconds * $this->durationMilliseconds;\n        }\n\n        return 100 / $this->parent->durationMilliseconds * $this->durationMilliseconds;\n    }\n\n    public function getPercentageOfTotalDuration(): float\n    {\n        return 100 / $this->context->twigRenderDurationMilliseconds * $this->durationMilliseconds;\n    }\n\n    public function getColorBasedOnPercentageDuration(bool $compareToParent = true): string\n    {\n        if ($compareToParent) {\n            $percentage = $this->getPercentageOfParentDuration() / 100;\n        } else {\n            $percentage = $this->getPercentageOfTotalDuration() / 100;\n        }\n        $baseline = 50;\n        $rFactor = 1;\n        $gFactor = 0.25;\n        $bFactor = 0.1;\n\n        $valueR = ($rFactor * (255 - $baseline) * $percentage) + $baseline;\n        $valueG = ($gFactor * (255 - $baseline) * $percentage) + $baseline;\n        $valueB = ($bFactor * (255 - $baseline) * $percentage) + $baseline;\n\n        return \"rgb($valueR, $valueG, $valueB)\";\n    }\n}\n"
  },
  {
    "path": "src/Entity/NewSignupNotification.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity;\n\nuse App\\Payloads\\PushNotification;\nuse Doctrine\\ORM\\Mapping\\Entity;\nuse Doctrine\\ORM\\Mapping\\JoinColumn;\nuse Doctrine\\ORM\\Mapping\\ManyToOne;\nuse Symfony\\Component\\Routing\\Generator\\UrlGeneratorInterface;\nuse Symfony\\Contracts\\Translation\\TranslatorInterface;\n\n#[Entity]\nclass NewSignupNotification extends Notification\n{\n    #[ManyToOne(targetEntity: User::class)]\n    #[JoinColumn(nullable: true, onDelete: 'CASCADE')]\n    public ?User $newUser;\n\n    public function getType(): string\n    {\n        return 'new_signup';\n    }\n\n    public function getSubject(): ?User\n    {\n        return $this->newUser;\n    }\n\n    public function getMessage(TranslatorInterface $trans, string $locale, UrlGeneratorInterface $urlGenerator): PushNotification\n    {\n        $message = str_replace('%u%', $this->newUser->username, $trans->trans('notification_body_new_signup', locale: $locale));\n        $title = $trans->trans('notification_title_new_signup', locale: $locale);\n        $url = $urlGenerator->generate('user_overview', ['username' => $this->newUser->username]);\n        $slash = $this->newUser->avatar && !str_starts_with('/', $this->newUser->avatar->filePath) ? '/' : '';\n        $avatarUrl = $this->newUser->avatar ? '/media/cache/resolve/avatar_thumb'.$slash.$this->newUser->avatar->filePath : null;\n\n        return new PushNotification($this->getId(), $message, $title, actionUrl: $url, avatarUrl: $avatarUrl);\n    }\n}\n"
  },
  {
    "path": "src/Entity/Notification.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity;\n\nuse App\\Entity\\Traits\\CreatedAtTrait;\nuse App\\Payloads\\PushNotification;\nuse Doctrine\\ORM\\Mapping\\Column;\nuse Doctrine\\ORM\\Mapping\\DiscriminatorColumn;\nuse Doctrine\\ORM\\Mapping\\DiscriminatorMap;\nuse Doctrine\\ORM\\Mapping\\Entity;\nuse Doctrine\\ORM\\Mapping\\GeneratedValue;\nuse Doctrine\\ORM\\Mapping\\Id;\nuse Doctrine\\ORM\\Mapping\\InheritanceType;\nuse Doctrine\\ORM\\Mapping\\JoinColumn;\nuse Doctrine\\ORM\\Mapping\\ManyToOne;\nuse Symfony\\Component\\Routing\\Generator\\UrlGeneratorInterface;\nuse Symfony\\Contracts\\Translation\\TranslatorInterface;\n\n#[Entity]\n#[InheritanceType('SINGLE_TABLE')]\n#[DiscriminatorColumn(name: 'notification_type', type: 'text')]\n#[DiscriminatorMap([\n    'entry_created' => 'EntryCreatedNotification',\n    'entry_edited' => 'EntryEditedNotification',\n    'entry_deleted' => 'EntryDeletedNotification',\n    'entry_mentioned' => 'EntryMentionedNotification',\n    'entry_comment_created' => 'EntryCommentCreatedNotification',\n    'entry_comment_edited' => 'EntryCommentEditedNotification',\n    'entry_comment_reply' => 'EntryCommentReplyNotification',\n    'entry_comment_deleted' => 'EntryCommentDeletedNotification',\n    'entry_comment_mentioned' => 'EntryCommentMentionedNotification',\n    'post_created' => 'PostCreatedNotification',\n    'post_edited' => 'PostEditedNotification',\n    'post_deleted' => 'PostDeletedNotification',\n    'post_mentioned' => 'PostMentionedNotification',\n    'post_comment_created' => 'PostCommentCreatedNotification',\n    'post_comment_edited' => 'PostCommentEditedNotification',\n    'post_comment_reply' => 'PostCommentReplyNotification',\n    'post_comment_deleted' => 'PostCommentDeletedNotification',\n    'post_comment_mentioned' => 'PostCommentMentionedNotification',\n    'message' => 'MessageNotification',\n    'ban' => 'MagazineBanNotification',\n    'unban' => 'MagazineUnBanNotification',\n    'report_created' => 'ReportCreatedNotification',\n    'report_approved' => 'ReportApprovedNotification',\n    'report_rejected' => 'ReportRejectedNotification',\n    'new_signup' => 'NewSignupNotification',\n])]\nabstract class Notification\n{\n    use CreatedAtTrait {\n        CreatedAtTrait::__construct as createdAtTraitConstruct;\n    }\n    public const STATUS_NEW = 'new';\n    public const STATUS_READ = 'read';\n\n    #[ManyToOne(targetEntity: User::class, inversedBy: 'notifications')]\n    #[JoinColumn(nullable: false, onDelete: 'CASCADE')]\n    public User $user;\n    #[Column(type: 'string')]\n    public string $status = self::STATUS_NEW;\n    #[Id]\n    #[GeneratedValue]\n    #[Column(type: 'integer')]\n    private int $id;\n\n    public function __construct(User $receiver)\n    {\n        $this->user = $receiver;\n\n        $this->createdAtTraitConstruct();\n    }\n\n    public function getId(): int\n    {\n        return $this->id;\n    }\n\n    abstract public function getType(): string;\n\n    abstract public function getMessage(TranslatorInterface $trans, string $locale, UrlGeneratorInterface $urlGenerator): PushNotification;\n}\n"
  },
  {
    "path": "src/Entity/NotificationSettings.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity;\n\nuse App\\Enums\\ENotificationStatus;\nuse Doctrine\\ORM\\Mapping\\Column;\nuse Doctrine\\ORM\\Mapping\\Entity;\nuse Doctrine\\ORM\\Mapping\\GeneratedValue;\nuse Doctrine\\ORM\\Mapping\\Id;\nuse Doctrine\\ORM\\Mapping\\JoinColumn;\nuse Doctrine\\ORM\\Mapping\\ManyToOne;\nuse Doctrine\\ORM\\Mapping\\UniqueConstraint;\n\n#[Entity]\n#[UniqueConstraint(name: 'notification_settings_user_target', columns: ['user_id', 'entry_id', 'post_id', 'magazine_id', 'target_user_id'])]\nclass NotificationSettings\n{\n    #[Id, GeneratedValue, Column(type: 'integer')]\n    private int $id;\n\n    #[ManyToOne(targetEntity: User::class)]\n    #[JoinColumn(nullable: false, onDelete: 'CASCADE')]\n    public User $user;\n\n    #[ManyToOne(targetEntity: Entry::class)]\n    #[JoinColumn(nullable: true, onDelete: 'CASCADE')]\n    public ?Entry $entry = null;\n\n    #[ManyToOne(targetEntity: Post::class)]\n    #[JoinColumn(nullable: true, onDelete: 'CASCADE')]\n    public ?Post $post = null;\n\n    #[ManyToOne(targetEntity: Magazine::class)]\n    #[JoinColumn(nullable: true, onDelete: 'CASCADE')]\n    public ?Magazine $magazine = null;\n\n    #[ManyToOne(targetEntity: User::class)]\n    #[JoinColumn(nullable: true, onDelete: 'CASCADE')]\n    public ?User $targetUser = null;\n\n    #[Column(type: 'enumNotificationStatus', nullable: false, options: ['default' => ENotificationStatus::Default->value])]\n    private string $notificationStatus = ENotificationStatus::Default->value;\n\n    public function __construct(User $user, Entry|Post|User|Magazine $target, ENotificationStatus $status)\n    {\n        $this->user = $user;\n        $this->setStatus($status);\n        if ($target instanceof User) {\n            $this->targetUser = $target;\n        } elseif ($target instanceof Magazine) {\n            $this->magazine = $target;\n        } elseif ($target instanceof Entry) {\n            $this->entry = $target;\n        } elseif ($target instanceof Post) {\n            $this->post = $target;\n        }\n    }\n\n    public function setStatus(ENotificationStatus $status): void\n    {\n        $this->notificationStatus = $status->value;\n    }\n\n    public function getStatus(): ENotificationStatus\n    {\n        return ENotificationStatus::getFromString($this->notificationStatus);\n    }\n}\n"
  },
  {
    "path": "src/Entity/OAuth2ClientAccess.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity;\n\nuse App\\Repository\\OAuth2ClientAccessRepository;\nuse Doctrine\\ORM\\Mapping as ORM;\n\n#[ORM\\Entity(repositoryClass: OAuth2ClientAccessRepository::class)]\nclass OAuth2ClientAccess\n{\n    #[ORM\\Id]\n    #[ORM\\GeneratedValue]\n    #[ORM\\Column]\n    private ?int $id = null;\n\n    #[ORM\\ManyToOne(inversedBy: 'oAuth2ClientAccesses')]\n    #[ORM\\JoinColumn(nullable: false, referencedColumnName: 'identifier')]\n    private ?Client $client = null;\n\n    #[ORM\\Column]\n    private ?\\DateTimeImmutable $createdAt = null;\n\n    #[ORM\\Column(length: 255)]\n    private ?string $path = null;\n\n    public function getId(): ?int\n    {\n        return $this->id;\n    }\n\n    public function getClient(): ?Client\n    {\n        return $this->client;\n    }\n\n    public function setClient(?Client $client): self\n    {\n        $this->client = $client;\n\n        return $this;\n    }\n\n    public function getCreatedAt(): ?\\DateTimeImmutable\n    {\n        return $this->createdAt;\n    }\n\n    public function setCreatedAt(\\DateTimeImmutable $createdAt): self\n    {\n        $this->createdAt = $createdAt;\n\n        return $this;\n    }\n\n    public function getPath(): ?string\n    {\n        return $this->path;\n    }\n\n    public function setPath(string $path): self\n    {\n        $this->path = $path;\n\n        return $this;\n    }\n}\n"
  },
  {
    "path": "src/Entity/OAuth2UserConsent.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity;\n\nuse App\\Repository\\OAuth2UserConsentRepository;\nuse Doctrine\\DBAL\\Types\\Types;\nuse Doctrine\\ORM\\Mapping as ORM;\nuse Doctrine\\ORM\\Mapping\\Column;\nuse Doctrine\\ORM\\Mapping\\GeneratedValue;\nuse Doctrine\\ORM\\Mapping\\Id;\nuse Doctrine\\ORM\\Mapping\\JoinColumn;\nuse Doctrine\\ORM\\Mapping\\ManyToOne;\n\n#[ORM\\Entity(repositoryClass: OAuth2UserConsentRepository::class)]\nclass OAuth2UserConsent\n{\n    /**\n     * An associative array with translation keys for each available oauth2 grant.\n     */\n    public const SCOPE_DESCRIPTIONS = [\n        // Grants read permissions on all public resources - pretty much everything will use this\n        'read' => 'oauth2.grant.read.general',\n        // Grants all content create and edit permissions\n        'write' => 'oauth2.grant.write.general',\n        // Grants all content delete permissions\n        'delete' => 'oauth2.grant.delete.general',\n        // Grants all report permissions\n        'report' => 'oauth2.grant.report.general',\n        // Grants all vote/boost permissions\n        'vote' => 'oauth2.grant.vote.general',\n        // Grants all subscription/follow permissions\n        'subscribe' => 'oauth2.grant.subscribe.general',\n        // Grants all block permissions\n        'block' => 'oauth2.grant.block.general',\n        // Grants allowing applications to (un)subscribe or (un)block domains on behalf of the user\n        'domain' => 'oauth2.grant.domain.all',\n        'domain:subscribe' => 'oauth2.grant.domain.subscribe',\n        'domain:block' => 'oauth2.grant.domain.block',\n        // Grants allowing the application to create, edit, delete, (up/down)vote, boost, or report entries on behalf of the user\n        'entry' => 'oauth2.grant.entry.all',\n        'entry:create' => 'oauth2.grant.entry.create',\n        'entry:edit' => 'oauth2.grant.entry.edit',\n        'entry:delete' => 'oauth2.grant.entry.delete',\n        'entry:vote' => 'oauth2.grant.entry.vote',\n        'entry:report' => 'oauth2.grant.entry.report',\n        // Grants allowing the application to create, edit, delete, (up/down)vote, boost, or report entry comments on behalf of the user\n        'entry_comment' => 'oauth2.grant.entry_comment.all',\n        'entry_comment:create' => 'oauth2.grant.entry_comment.create',\n        'entry_comment:edit' => 'oauth2.grant.entry_comment.edit',\n        'entry_comment:delete' => 'oauth2.grant.entry_comment.delete',\n        'entry_comment:vote' => 'oauth2.grant.entry_comment.vote',\n        'entry_comment:report' => 'oauth2.grant.entry_comment.report',\n        // Grants allowing the application to (un)subscribe or (un)block magazines on behalf of the user\n        'magazine' => 'oauth2.grant.magazine.all',\n        'magazine:subscribe' => 'oauth2.grant.magazine.subscribe',\n        'magazine:block' => 'oauth2.grant.magazine.block',\n        // Grants allowing the application to create, edit, delete, (up/down)vote, boost, or report posts on behalf of the user\n        'post' => 'oauth2.grant.post.all',\n        'post:create' => 'oauth2.grant.post.create',\n        'post:edit' => 'oauth2.grant.post.edit',\n        'post:delete' => 'oauth2.grant.post.delete',\n        'post:vote' => 'oauth2.grant.post.vote',\n        'post:report' => 'oauth2.grant.post.report',\n        // Grants allowing the application to create, edit, delete, (up/down)vote, boost, or report post comments on behalf of the user\n        'post_comment' => 'oauth2.grant.post_comment.all',\n        'post_comment:create' => 'oauth2.grant.post_comment.create',\n        'post_comment:edit' => 'oauth2.grant.post_comment.edit',\n        'post_comment:delete' => 'oauth2.grant.post_comment.delete',\n        'post_comment:vote' => 'oauth2.grant.post_comment.vote',\n        'post_comment:report' => 'oauth2.grant.post_comment.report',\n        // Various grants related to reading and writing information about the current user,\n        //   messages they've sent and received, notifications they have, who they follow, and who they block\n        'user' => 'oauth2.grant.user.all',\n        'bookmark' => 'oauth2.grant.bookmark',\n        'bookmark:add' => 'oauth2.grant.bookmark.add',\n        'bookmark:remove' => 'oauth2.grant.bookmark.remove',\n        'bookmark_list' => 'oauth2.grant.bookmark_list',\n        'bookmark_list:read' => 'oauth2.grant.bookmark_list.read',\n        'bookmark_list:edit' => 'oauth2.grant.bookmark_list.edit',\n        'bookmark_list:delete' => 'oauth2.grant.bookmark_list.delete',\n        'user:profile' => 'oauth2.grant.user.profile.all',\n        'user:profile:read' => 'oauth2.grant.user.profile.read',\n        'user:profile:edit' => 'oauth2.grant.user.profile.edit',\n        'user:message' => 'oauth2.grant.user.message.all',\n        'user:message:read' => 'oauth2.grant.user.message.read',\n        'user:message:create' => 'oauth2.grant.user.message.create',\n        'user:notification' => 'oauth2.grant.user.notification.all',\n        'user:notification:read' => 'oauth2.grant.user.notification.read',\n        'user:notification:delete' => 'oauth2.grant.user.notification.delete',\n        'user:notification:edit' => 'oauth2.grant.user.notification.edit',\n        'user:oauth_clients' => 'oauth2.grant.user.oauth_clients.all',\n        'user:oauth_clients:read' => 'oauth2.grant.user.oauth_clients.read',\n        'user:oauth_clients:edit' => 'oauth2.grant.user.oauth_clients.edit',\n        'user:follow' => 'oauth2.grant.user.follow',\n        'user:block' => 'oauth2.grant.user.block',\n        // Moderation grants\n        'moderate' => 'oauth2.grant.moderate.all',\n        // Entry moderation grants\n        'moderate:entry' => 'oauth2.grant.moderate.entry.all',\n        'moderate:entry:language' => 'oauth2.grant.moderate.entry.change_language',\n        'moderate:entry:pin' => 'oauth2.grant.moderate.entry.pin',\n        'moderate:entry:lock' => 'oauth2.grant.moderate.entry.lock',\n        'moderate:entry:set_adult' => 'oauth2.grant.moderate.entry.set_adult',\n        'moderate:entry:trash' => 'oauth2.grant.moderate.entry.trash',\n        // Entry comment moderation grants\n        'moderate:entry_comment' => 'oauth2.grant.moderate.entry_comment.all',\n        'moderate:entry_comment:language' => 'oauth2.grant.moderate.entry_comment.change_language',\n        'moderate:entry_comment:set_adult' => 'oauth2.grant.moderate.entry_comment.set_adult',\n        'moderate:entry_comment:trash' => 'oauth2.grant.moderate.entry_comment.trash',\n        // Post moderation grants\n        'moderate:post' => 'oauth2.grant.moderate.post.all',\n        'moderate:post:language' => 'oauth2.grant.moderate.post.change_language',\n        'moderate:post:pin' => 'oauth2.grant.moderate.post.pin',\n        'moderate:post:lock' => 'oauth2.grant.moderate.post.lock',\n        'moderate:post:set_adult' => 'oauth2.grant.moderate.post.set_adult',\n        'moderate:post:trash' => 'oauth2.grant.moderate.post.trash',\n        // Post comment moderation grants\n        'moderate:post_comment' => 'oauth2.grant.moderate.post_comment.all',\n        'moderate:post_comment:language' => 'oauth2.grant.moderate.post_comment.change_language',\n        'moderate:post_comment:set_adult' => 'oauth2.grant.moderate.post_comment.set_adult',\n        'moderate:post_comment:trash' => 'oauth2.grant.moderate.post_comment.trash',\n        // Magazine moderation grants\n        'moderate:magazine' => 'oauth2.grant.moderate.magazine.all',\n        'moderate:magazine:ban' => 'oauth2.grant.moderate.magazine.ban.all',\n        'moderate:magazine:ban:read' => 'oauth2.grant.moderate.magazine.ban.read',\n        'moderate:magazine:ban:create' => 'oauth2.grant.moderate.magazine.ban.create',\n        'moderate:magazine:ban:delete' => 'oauth2.grant.moderate.magazine.ban.delete',\n        'moderate:magazine:list' => 'oauth2.grant.moderate.magazine.list',\n        'moderate:magazine:reports' => 'oauth2.grant.moderate.magazine.reports.all',\n        'moderate:magazine:reports:read' => 'oauth2.grant.moderate.magazine.reports.read',\n        'moderate:magazine:reports:action' => 'oauth2.grant.moderate.magazine.reports.action',\n        'moderate:magazine:trash:read' => 'oauth2.grant.moderate.magazine.trash.read',\n        // Magazine owner moderation grants\n        'moderate:magazine_admin' => 'oauth2.grant.moderate.magazine_admin.all',\n        'moderate:magazine_admin:create' => 'oauth2.grant.moderate.magazine_admin.create',\n        'moderate:magazine_admin:delete' => 'oauth2.grant.moderate.magazine_admin.delete',\n        'moderate:magazine_admin:update' => 'oauth2.grant.moderate.magazine_admin.update',\n        'moderate:magazine_admin:theme' => 'oauth2.grant.moderate.magazine_admin.edit_theme',\n        'moderate:magazine_admin:moderators' => 'oauth2.grant.moderate.magazine_admin.moderators',\n        'moderate:magazine_admin:badges' => 'oauth2.grant.moderate.magazine_admin.badges',\n        'moderate:magazine_admin:tags' => 'oauth2.grant.moderate.magazine_admin.tags',\n        'moderate:magazine_admin:stats' => 'oauth2.grant.moderate.magazine_admin.stats',\n        // Admin grants\n        'admin' => 'oauth2.grant.admin.all',\n        // Purge content entirely from the instance\n        'admin:entry:purge' => 'oauth2.grant.admin.entry.purge',\n        'admin:entry_comment:purge' => 'oauth2.grant.admin.entry_comment.purge',\n        'admin:post:purge' => 'oauth2.grant.admin.post.purge',\n        'admin:post_comment:purge' => 'oauth2.grant.admin.post_comment.purge',\n        // Administrate magazines\n        'admin:magazine' => 'oauth2.grant.admin.magazine.all',\n        'admin:magazine:move_entry' => 'oauth2.grant.admin.magazine.move_entry',\n        'admin:magazine:purge' => 'oauth2.grant.admin.magazine.purge',\n        'admin:magazine:moderate' => 'oauth2.grant.admin.magazine.moderate',\n        // Administrate users\n        'admin:user' => 'oauth2.grant.admin.user.all',\n        'admin:user:ban' => 'oauth2.grant.admin.user.ban',\n        'admin:user:verify' => 'oauth2.grant.admin.user.verify',\n        'admin:user:delete' => 'oauth2.grant.admin.user.delete',\n        'admin:user:purge' => 'oauth2.grant.admin.user.purge',\n        // Administrate site information\n        'admin:instance' => 'oauth2.grant.admin.instance.all',\n        'admin:instance:stats' => 'oauth2.grant.admin.instance.stats',\n        'admin:instance:settings' => 'oauth2.grant.admin.instance.settings.all',\n        'admin:instance:settings:read' => 'oauth2.grant.admin.instance.settings.read',\n        'admin:instance:settings:edit' => 'oauth2.grant.admin.instance.settings.edit',\n        // Update About, FAQ, Contact, ToS, and Privacy Policy\n        'admin:instance:information:edit' => 'oauth2.grant.admin.instance.information.edit',\n        // Administrate federation with other instances\n        'admin:federation' => 'oauth2.grant.admin.federation.all',\n        'admin:federation:read' => 'oauth2.grant.admin.federation.read',\n        'admin:federation:update' => 'oauth2.grant.admin.federation.update',\n        // Administrate oauth applications\n        'admin:oauth_clients' => 'oauth2.grant.admin.oauth_clients.all',\n        'admin:oauth_clients:read' => 'oauth2.grant.admin.oauth_clients.read',\n        'admin:oauth_clients:revoke' => 'oauth2.grant.admin.oauth_clients.revoke',\n    ];\n    #[Id]\n    #[GeneratedValue]\n    #[Column]\n    private ?int $id = null;\n\n    #[ManyToOne(inversedBy: 'oAuth2UserConsents')]\n    #[JoinColumn(name: 'user_id', nullable: false, onDelete: 'CASCADE')]\n    private ?User $user = null;\n\n    #[ManyToOne(inversedBy: 'oAuth2UserConsents')]\n    #[JoinColumn(name: 'client_identifier', referencedColumnName: 'identifier', nullable: false)]\n    private ?Client $client = null;\n\n    #[Column]\n    private ?\\DateTimeImmutable $createdAt = null;\n\n    #[Column]\n    private ?\\DateTimeImmutable $expiresAt = null;\n\n    #[Column(type: Types::JSON)]\n    private array $scopes = [];\n\n    #[Column]\n    private ?string $ipAddress = null;\n\n    public function getId(): ?int\n    {\n        return $this->id;\n    }\n\n    public function getUser(): ?User\n    {\n        return $this->user;\n    }\n\n    public function setUser(?User $user): self\n    {\n        $this->user = $user;\n\n        return $this;\n    }\n\n    public function getClient(): ?Client\n    {\n        return $this->client;\n    }\n\n    public function setClient(?Client $client): self\n    {\n        $this->client = $client;\n\n        return $this;\n    }\n\n    public function getCreatedAt(): ?\\DateTimeImmutable\n    {\n        return $this->createdAt;\n    }\n\n    public function setCreatedAt(\\DateTimeImmutable $createdAt): self\n    {\n        $this->createdAt = $createdAt;\n\n        return $this;\n    }\n\n    public function getExpiresAt(): ?\\DateTimeImmutable\n    {\n        return $this->expiresAt;\n    }\n\n    public function setExpiresAt(\\DateTimeImmutable $expiresAt): self\n    {\n        $this->expiresAt = $expiresAt;\n\n        return $this;\n    }\n\n    public function getScopes(): array\n    {\n        return $this->scopes;\n    }\n\n    public function setScopes(array $scopes): self\n    {\n        $this->scopes = $scopes;\n\n        return $this;\n    }\n\n    public function getIpAddress(): string\n    {\n        return $this->ipAddress;\n    }\n\n    public function setIpAddress(string $ipAddress): self\n    {\n        $this->ipAddress = $ipAddress;\n\n        return $this;\n    }\n}\n"
  },
  {
    "path": "src/Entity/Post.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity;\n\nuse App\\Entity\\Contracts\\ActivityPubActivityInterface;\nuse App\\Entity\\Contracts\\CommentInterface;\nuse App\\Entity\\Contracts\\FavouriteInterface;\nuse App\\Entity\\Contracts\\RankingInterface;\nuse App\\Entity\\Contracts\\ReportInterface;\nuse App\\Entity\\Contracts\\VisibilityInterface;\nuse App\\Entity\\Contracts\\VotableInterface;\nuse App\\Entity\\Traits\\ActivityPubActivityTrait;\nuse App\\Entity\\Traits\\CreatedAtTrait;\nuse App\\Entity\\Traits\\EditedAtTrait;\nuse App\\Entity\\Traits\\RankingTrait;\nuse App\\Entity\\Traits\\VisibilityTrait;\nuse App\\Entity\\Traits\\VotableTrait;\nuse App\\Repository\\PostRepository;\nuse Doctrine\\Common\\Collections\\ArrayCollection;\nuse Doctrine\\Common\\Collections\\Collection;\nuse Doctrine\\Common\\Collections\\Criteria;\nuse Doctrine\\DBAL\\Types\\Types;\nuse Doctrine\\ORM\\Mapping\\Column;\nuse Doctrine\\ORM\\Mapping\\Entity;\nuse Doctrine\\ORM\\Mapping\\GeneratedValue;\nuse Doctrine\\ORM\\Mapping\\Id;\nuse Doctrine\\ORM\\Mapping\\Index;\nuse Doctrine\\ORM\\Mapping\\JoinColumn;\nuse Doctrine\\ORM\\Mapping\\ManyToOne;\nuse Doctrine\\ORM\\Mapping\\OneToMany;\nuse Webmozart\\Assert\\Assert;\n\n#[Entity(repositoryClass: PostRepository::class)]\n#[Index(columns: ['visibility', 'is_adult'], name: 'post_visibility_adult_idx')]\n#[Index(columns: ['is_adult'], name: 'post_adult_idx')]\n#[Index(columns: ['ranking'], name: 'post_ranking_idx')]\n#[Index(columns: ['score'], name: 'post_score_idx')]\n#[Index(columns: ['comment_count'], name: 'post_comment_count_idx')]\n#[Index(columns: ['created_at'], name: 'post_created_at_idx')]\n#[Index(columns: ['last_active'], name: 'post_last_active_at_idx')]\n#[Index(columns: ['body_ts'], name: 'post_body_ts_idx')]\nclass Post implements VotableInterface, CommentInterface, VisibilityInterface, RankingInterface, ReportInterface, FavouriteInterface, ActivityPubActivityInterface\n{\n    use VotableTrait;\n    use RankingTrait;\n    use VisibilityTrait;\n    use ActivityPubActivityTrait;\n    use EditedAtTrait;\n    use CreatedAtTrait {\n        CreatedAtTrait::__construct as createdAtTraitConstruct;\n    }\n\n    #[ManyToOne(targetEntity: User::class, inversedBy: 'posts')]\n    #[JoinColumn(nullable: false, onDelete: 'CASCADE')]\n    public User $user;\n    #[ManyToOne(targetEntity: Magazine::class, inversedBy: 'posts')]\n    #[JoinColumn(nullable: false, onDelete: 'CASCADE')]\n    public ?Magazine $magazine;\n    #[ManyToOne(targetEntity: Image::class, cascade: ['persist'])]\n    #[JoinColumn(nullable: true)]\n    public ?Image $image = null;\n    #[Column(type: 'string', length: 255, nullable: true)]\n    public string $slug;\n    #[Column(type: 'text', length: 15000, nullable: true)]\n    public ?string $body = null;\n    #[Column(type: 'string', nullable: false)]\n    public string $lang = 'en';\n    #[Column(type: 'integer', nullable: false)]\n    public int $commentCount = 0;\n    #[Column(type: 'integer', options: ['default' => 0])]\n    public int $favouriteCount = 0;\n    #[Column(type: 'integer', nullable: false)]\n    public int $score = 0;\n    #[Column(type: 'boolean', nullable: false)]\n    public bool $isAdult = false;\n    #[Column(type: 'boolean', nullable: false, options: ['default' => false])]\n    public bool $sticky = false;\n    #[Column(type: 'boolean', nullable: false, options: ['default' => false])]\n    public bool $isLocked = false;\n    #[Column(type: 'datetimetz')]\n    public ?\\DateTime $lastActive;\n    #[Column(type: 'string', nullable: true)]\n    public ?string $ip = null;\n    #[Column(type: Types::JSONB, nullable: true)]\n    public ?array $mentions = null;\n    #[OneToMany(mappedBy: 'post', targetEntity: PostComment::class, cascade: ['persist', 'remove'], orphanRemoval: true)]\n    public Collection $comments;\n    #[OneToMany(mappedBy: 'post', targetEntity: PostVote::class, cascade: ['persist', 'remove'], fetch: 'EXTRA_LAZY', orphanRemoval: true)]\n    public Collection $votes;\n    #[OneToMany(mappedBy: 'post', targetEntity: PostReport::class, cascade: ['persist', 'remove'], fetch: 'EXTRA_LAZY', orphanRemoval: true)]\n    public Collection $reports;\n    #[OneToMany(mappedBy: 'post', targetEntity: PostFavourite::class, cascade: ['persist', 'remove'], fetch: 'EXTRA_LAZY', orphanRemoval: true)]\n    public Collection $favourites;\n    #[OneToMany(mappedBy: 'post', targetEntity: PostCreatedNotification::class, cascade: ['persist', 'remove'], fetch: 'EXTRA_LAZY', orphanRemoval: true)]\n    public Collection $notifications;\n    #[OneToMany(mappedBy: 'post', targetEntity: HashtagLink::class, cascade: ['persist', 'remove'], fetch: 'EXTRA_LAZY', orphanRemoval: true)]\n    public Collection $hashtags;\n    public array $children = [];\n    #[Id]\n    #[GeneratedValue]\n    #[Column(type: 'integer')]\n    private int $id;\n    #[Column(type: 'text', nullable: true, insertable: false, updatable: false, options: ['default' => 'english'])]\n    private $bodyTs;\n\n    public function __construct(\n        ?string $body,\n        Magazine $magazine,\n        User $user,\n        bool $isAdult,\n        ?string $ip = null,\n    ) {\n        $this->body = $body;\n        $this->magazine = $magazine;\n        $this->user = $user;\n        $this->isAdult = $isAdult;\n        $this->ip = $ip;\n        $this->comments = new ArrayCollection();\n        $this->votes = new ArrayCollection();\n        $this->reports = new ArrayCollection();\n        $this->favourites = new ArrayCollection();\n        $this->notifications = new ArrayCollection();\n\n        $user->addPost($this);\n\n        $this->createdAtTraitConstruct();\n        $this->updateLastActive();\n    }\n\n    public function updateLastActive(): void\n    {\n        $this->comments->get(-1);\n\n        $criteria = Criteria::create()\n            ->orderBy(['createdAt' => 'DESC'])\n            ->setMaxResults(1);\n\n        $lastComment = $this->comments->matching($criteria)->first();\n\n        if ($lastComment) {\n            $this->lastActive = \\DateTime::createFromImmutable($lastComment->createdAt);\n        } else {\n            $this->lastActive = \\DateTime::createFromImmutable($this->getCreatedAt());\n        }\n    }\n\n    public function getId(): int\n    {\n        return $this->id;\n    }\n\n    public function getApId(): ?string\n    {\n        return $this->apId;\n    }\n\n    public function getBestComments(?User $user = null): Collection\n    {\n        $criteria = Criteria::create()\n            ->orderBy(['upVotes' => 'DESC', 'createdAt' => 'ASC']);\n\n        $comments = $this->comments->matching($criteria);\n        $comments = $this->handlePrivateComments($comments, $user);\n        $comments = new ArrayCollection($comments->slice(0, 2));\n\n        if (!\\count(array_filter($comments->toArray(), fn ($comment) => $comment->countUpVotes() > 0))) {\n            return $this->getLastComments();\n        }\n\n        $iterator = $comments->getIterator();\n        $iterator->uasort(function ($a, $b) {\n            return ($a->createdAt < $b->createdAt) ? -1 : 1;\n        });\n\n        return new ArrayCollection(iterator_to_array($iterator));\n    }\n\n    private function handlePrivateComments(ArrayCollection $comments, ?User $user): ArrayCollection\n    {\n        return $comments->filter(function (PostComment $val) use ($user) {\n            if ($user && VisibilityInterface::VISIBILITY_PRIVATE === $val->visibility) {\n                return $user->isFollower($val->user);\n            }\n\n            return VisibilityInterface::VISIBILITY_VISIBLE === $val->visibility;\n        });\n    }\n\n    public function getLastComments(?User $user = null): Collection\n    {\n        $criteria = Criteria::create()\n            ->orderBy(['createdAt' => 'ASC']);\n\n        $comments = $this->comments->matching($criteria);\n\n        $comments = $this->handlePrivateComments($comments, $user);\n\n        return new ArrayCollection($comments->slice(-2, 2));\n    }\n\n    public function addComment(PostComment $comment): self\n    {\n        if (!$this->comments->contains($comment)) {\n            $this->comments->add($comment);\n            $comment->post = $this;\n        }\n\n        $this->updateCounts();\n        $this->updateRanking();\n        $this->updateLastActive();\n\n        return $this;\n    }\n\n    public function updateCounts(): self\n    {\n        $criteria = Criteria::create()\n            ->andWhere(Criteria::expr()->eq('visibility', VisibilityInterface::VISIBILITY_VISIBLE));\n\n        $this->commentCount = $this->comments->matching($criteria)->count();\n        $this->favouriteCount = $this->favourites->count();\n\n        return $this;\n    }\n\n    public function removeComment(PostComment $comment): self\n    {\n        if ($this->comments->removeElement($comment)) {\n            if ($comment->post === $this) {\n                $comment->post = null;\n            }\n        }\n\n        $this->updateCounts();\n        $this->updateRanking();\n        $this->updateLastActive();\n\n        return $this;\n    }\n\n    public function softDelete(): void\n    {\n        $this->visibility = VisibilityInterface::VISIBILITY_SOFT_DELETED;\n    }\n\n    public function trash(): void\n    {\n        $this->visibility = VisibilityInterface::VISIBILITY_TRASHED;\n    }\n\n    public function restore(): void\n    {\n        $this->visibility = VisibilityInterface::VISIBILITY_VISIBLE;\n    }\n\n    public function updateScore(): self\n    {\n        $this->score = $this->favouriteCount + $this->getUpVotes()->count() - $this->getDownVotes()->count();\n\n        return $this;\n    }\n\n    public function addVote(Vote $vote): self\n    {\n        Assert::isInstanceOf($vote, PostVote::class);\n\n        if (!$this->votes->contains($vote)) {\n            $this->votes->add($vote);\n            $vote->post = $this;\n        }\n\n        $this->updateScore();\n        $this->updateRanking();\n\n        return $this;\n    }\n\n    public function removeVote(Vote $vote): self\n    {\n        Assert::isInstanceOf($vote, PostVote::class);\n\n        if ($this->votes->removeElement($vote)) {\n            if ($vote->getPost() === $this) {\n                $vote->setPost(null);\n            }\n        }\n\n        $this->updateScore();\n        $this->updateRanking();\n\n        return $this;\n    }\n\n    public function isAuthor(User $user): bool\n    {\n        return $user === $this->user;\n    }\n\n    public function getShortTitle(?int $length = 60): string\n    {\n        $body = wordwrap($this->body ?? '', $length);\n        $body = explode(\"\\n\", $body);\n\n        return trim($body[0]).(isset($body[1]) ? '...' : '');\n    }\n\n    public function getCommentCount(): int\n    {\n        return $this->commentCount;\n    }\n\n    public function getUniqueCommentCount(): int\n    {\n        $users = [];\n        $count = 0;\n        foreach ($this->comments as $comment) {\n            if (!\\in_array($comment->user, $users)) {\n                $users[] = $comment->user;\n                ++$count;\n            }\n        }\n\n        return $count;\n    }\n\n    public function getScore(): int\n    {\n        return $this->score;\n    }\n\n    public function getMagazine(): ?Magazine\n    {\n        return $this->magazine;\n    }\n\n    public function getUser(): ?User\n    {\n        return $this->user;\n    }\n\n    public function isFavored(User $user): bool\n    {\n        $criteria = Criteria::create()\n            ->where(Criteria::expr()->eq('user', $user));\n\n        return $this->favourites->matching($criteria)->count() > 0;\n    }\n\n    public function isAdult(): bool\n    {\n        return $this->isAdult || $this->magazine->isAdult;\n    }\n\n    public function __sleep()\n    {\n        return [];\n    }\n}\n"
  },
  {
    "path": "src/Entity/PostComment.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity;\n\nuse App\\Entity\\Contracts\\ActivityPubActivityInterface;\nuse App\\Entity\\Contracts\\ContentInterface;\nuse App\\Entity\\Contracts\\FavouriteInterface;\nuse App\\Entity\\Contracts\\ReportInterface;\nuse App\\Entity\\Contracts\\VisibilityInterface;\nuse App\\Entity\\Contracts\\VotableInterface;\nuse App\\Entity\\Traits\\ActivityPubActivityTrait;\nuse App\\Entity\\Traits\\CreatedAtTrait;\nuse App\\Entity\\Traits\\EditedAtTrait;\nuse App\\Entity\\Traits\\VisibilityTrait;\nuse App\\Entity\\Traits\\VotableTrait;\nuse App\\Repository\\Criteria as MbinCriteria;\nuse App\\Repository\\PostCommentRepository;\nuse App\\Utils\\ArrayUtils;\nuse Doctrine\\Common\\Collections\\ArrayCollection;\nuse Doctrine\\Common\\Collections\\Collection;\nuse Doctrine\\Common\\Collections\\Criteria;\nuse Doctrine\\DBAL\\Types\\Types;\nuse Doctrine\\ORM\\Mapping\\Column;\nuse Doctrine\\ORM\\Mapping\\Entity;\nuse Doctrine\\ORM\\Mapping\\GeneratedValue;\nuse Doctrine\\ORM\\Mapping\\Id;\nuse Doctrine\\ORM\\Mapping\\Index;\nuse Doctrine\\ORM\\Mapping\\JoinColumn;\nuse Doctrine\\ORM\\Mapping\\ManyToOne;\nuse Doctrine\\ORM\\Mapping\\OneToMany;\nuse Doctrine\\ORM\\Mapping\\OrderBy;\nuse Webmozart\\Assert\\Assert;\n\n#[Entity(repositoryClass: PostCommentRepository::class)]\n#[Index(columns: ['up_votes'], name: 'post_comment_up_votes_idx')]\n#[Index(columns: ['last_active'], name: 'post_comment_last_active_at_idx')]\n#[Index(columns: ['created_at'], name: 'post_comment_created_at_idx')]\n#[Index(columns: ['body_ts'], name: 'post_comment_body_ts_idx')]\nclass PostComment implements VotableInterface, VisibilityInterface, ReportInterface, FavouriteInterface, ActivityPubActivityInterface\n{\n    use VotableTrait;\n    use VisibilityTrait;\n    use ActivityPubActivityTrait;\n    use EditedAtTrait;\n    use CreatedAtTrait {\n        CreatedAtTrait::__construct as createdAtTraitConstruct;\n    }\n\n    #[ManyToOne(targetEntity: User::class, inversedBy: 'postComments')]\n    #[JoinColumn(nullable: false, onDelete: 'CASCADE')]\n    public User $user;\n    #[ManyToOne(targetEntity: Post::class, inversedBy: 'comments')]\n    #[JoinColumn(nullable: false, onDelete: 'CASCADE')]\n    public ?Post $post;\n    #[ManyToOne(targetEntity: Magazine::class)]\n    #[JoinColumn(nullable: false, onDelete: 'CASCADE')]\n    public ?Magazine $magazine;\n    #[ManyToOne(targetEntity: PostComment::class, inversedBy: 'children')]\n    #[JoinColumn(nullable: true, onDelete: 'CASCADE')]\n    public ?PostComment $parent;\n    #[ManyToOne(targetEntity: PostComment::class, inversedBy: 'nested')]\n    #[JoinColumn(nullable: true, onDelete: 'CASCADE')]\n    public ?PostComment $root = null;\n    #[ManyToOne(targetEntity: Image::class, cascade: ['persist'])]\n    #[JoinColumn(nullable: true)]\n    public ?Image $image = null;\n    #[Column(type: 'text', length: 4500)]\n    public ?string $body;\n    #[Column(type: 'string', nullable: false)]\n    public string $lang = 'en';\n    #[Column(type: 'integer', options: ['default' => 0])]\n    public int $favouriteCount = 0;\n    #[Column(type: 'datetimetz')]\n    public ?\\DateTime $lastActive;\n    #[Column(type: 'string', nullable: true)]\n    public ?string $ip = null;\n    #[Column(type: Types::JSONB, nullable: true)]\n    public ?array $mentions = null;\n    #[Column(type: 'boolean', nullable: false)]\n    public bool $isAdult = false;\n    #[Column(type: 'boolean', nullable: false, options: ['default' => false])]\n    public ?bool $updateMark = false;\n    #[OneToMany(mappedBy: 'parent', targetEntity: PostComment::class, orphanRemoval: true)]\n    #[OrderBy(['createdAt' => 'ASC'])]\n    public Collection $children;\n    #[OneToMany(mappedBy: 'root', targetEntity: PostComment::class, orphanRemoval: true)]\n    #[OrderBy(['createdAt' => 'ASC'])]\n    public Collection $nested;\n    #[OneToMany(mappedBy: 'comment', targetEntity: PostCommentVote::class, cascade: ['persist', 'remove'], fetch: 'EXTRA_LAZY', orphanRemoval: true)]\n    public Collection $votes;\n    #[OneToMany(mappedBy: 'postComment', targetEntity: PostCommentReport::class, cascade: ['remove'], fetch: 'EXTRA_LAZY', orphanRemoval: true)]\n    public Collection $reports;\n    #[OneToMany(mappedBy: 'postComment', targetEntity: PostCommentFavourite::class, cascade: ['remove'], fetch: 'EXTRA_LAZY', orphanRemoval: true)]\n    public Collection $favourites;\n    #[OneToMany(mappedBy: 'postComment', targetEntity: PostCommentCreatedNotification::class, cascade: ['remove'], fetch: 'EXTRA_LAZY', orphanRemoval: true)]\n    public Collection $notifications;\n    #[OneToMany(mappedBy: 'postComment', targetEntity: HashtagLink::class, cascade: ['persist', 'remove'], fetch: 'EXTRA_LAZY', orphanRemoval: true)]\n    public Collection $hashtags;\n    #[Id]\n    #[GeneratedValue]\n    #[Column(type: 'integer')]\n    private int $id;\n    #[Column(type: 'text', nullable: true, insertable: false, updatable: false, options: ['default' => 'english'])]\n    private $bodyTs;\n\n    public function __construct(string $body, ?Post $post, User $user, ?PostComment $parent = null, ?string $ip = null)\n    {\n        $this->body = $body;\n        $this->post = $post;\n        $this->user = $user;\n        $this->parent = $parent;\n        $this->ip = $ip;\n        $this->votes = new ArrayCollection();\n        $this->children = new ArrayCollection();\n        $this->reports = new ArrayCollection();\n        $this->favourites = new ArrayCollection();\n\n        if ($parent) {\n            $this->root = $parent->root ?? $parent;\n        }\n\n        $this->createdAtTraitConstruct();\n        $this->updateLastActive();\n    }\n\n    public function updateLastActive(): void\n    {\n        $this->lastActive = \\DateTime::createFromImmutable($this->createdAt);\n\n        $this->post->lastActive = \\DateTime::createFromImmutable($this->createdAt);\n    }\n\n    public function getId(): int\n    {\n        return $this->id;\n    }\n\n    public function getApId(): ?string\n    {\n        return $this->apId;\n    }\n\n    public function addVote(Vote $vote): self\n    {\n        Assert::isInstanceOf($vote, PostCommentVote::class);\n\n        if (!$this->votes->contains($vote)) {\n            $this->votes->add($vote);\n            $vote->setComment($this);\n        }\n\n        return $this;\n    }\n\n    public function removeVote(Vote $vote): self\n    {\n        Assert::isInstanceOf($vote, PostCommentVote::class);\n\n        if ($this->votes->removeElement($vote)) {\n            // set the owning side to null (unless already changed)\n            if ($vote->getComment() === $this) {\n                $vote->setComment(null);\n            }\n        }\n\n        return $this;\n    }\n\n    public function getChildrenRecursive(int &$startIndex = 0): \\Traversable\n    {\n        foreach ($this->children as $child) {\n            yield $startIndex++ => $child;\n            yield from $child->getChildrenRecursive($startIndex);\n        }\n    }\n\n    public function softDelete(): void\n    {\n        $this->visibility = VisibilityInterface::VISIBILITY_SOFT_DELETED;\n    }\n\n    public function trash(): void\n    {\n        $this->visibility = VisibilityInterface::VISIBILITY_TRASHED;\n    }\n\n    public function restore(): void\n    {\n        $this->visibility = VisibilityInterface::VISIBILITY_VISIBLE;\n    }\n\n    public function isAuthor(User $user): bool\n    {\n        return $user === $this->user;\n    }\n\n    public function getShortTitle(?int $length = 60): string\n    {\n        $body = wordwrap($this->body ?? '', $length);\n        $body = explode(\"\\n\", $body);\n\n        return trim($body[0]).(isset($body[1]) ? '...' : '');\n    }\n\n    public function getMagazine(): ?Magazine\n    {\n        return $this->magazine;\n    }\n\n    public function getUser(): ?User\n    {\n        return $this->user;\n    }\n\n    public function updateCounts(): self\n    {\n        $this->favouriteCount = $this->favourites->count();\n\n        return $this;\n    }\n\n    public function isFavored(User $user): bool\n    {\n        $criteria = Criteria::create()\n            ->where(Criteria::expr()->eq('user', $user));\n\n        return $this->favourites->matching($criteria)->count() > 0;\n    }\n\n    public function getTags(): array\n    {\n        return array_values($this->tags ?? []);\n    }\n\n    public function __sleep()\n    {\n        return [];\n    }\n\n    public function updateRanking(): void\n    {\n    }\n\n    public function updateScore(): self\n    {\n        return $this;\n    }\n\n    public function getParentSubject(): ?ContentInterface\n    {\n        return $this->post;\n    }\n\n    public function containsBannedHashtags(): bool\n    {\n        foreach ($this->hashtags as /** @var HashtagLink $hashtag */ $hashtag) {\n            if ($hashtag->hashtag->banned) {\n                return true;\n            }\n        }\n\n        return false;\n    }\n\n    /**\n     * @param 'profile'|'comments' $filterRealm\n     */\n    public function containsFilteredWords(User $loggedInUser, string $filterRealm): bool\n    {\n        foreach ($loggedInUser->getCurrentFilterLists() as $list) {\n            if (!$list->$filterRealm) {\n                continue;\n            }\n\n            foreach ($list->words as $word) {\n                if ($word['exactMatch']) {\n                    if (str_contains($this->body, $word['word'])) {\n                        return true;\n                    }\n                } else {\n                    if (str_contains(strtolower($this->body), strtolower($word['word']))) {\n                        return true;\n                    }\n                }\n            }\n        }\n\n        return false;\n    }\n\n    /**\n     * @param 'profile'|'comments' $filterRealm\n     */\n    public function getChildrenByCriteria(MbinCriteria $postCommentCriteria, ?User $loggedInUser, string $filterRealm): array\n    {\n        $criteria = Criteria::create();\n\n        if ($postCommentCriteria->languages) {\n            $criteria->andwhere(Criteria::expr()->in('lang', $postCommentCriteria->languages));\n        }\n\n        if (MbinCriteria::AP_LOCAL === $postCommentCriteria->federation) {\n            $criteria->andWhere(Criteria::expr()->isNull('apId'));\n        } elseif (MbinCriteria::AP_FEDERATED === $postCommentCriteria->federation) {\n            $criteria->andWhere(Criteria::expr()->isNotNull('apId'));\n        }\n\n        if (MbinCriteria::TIME_ALL !== $postCommentCriteria->time) {\n            $criteria->andWhere(Criteria::expr()->gte('createdAt', $postCommentCriteria->getSince()));\n        }\n\n        $children = $this->children\n            ->matching($criteria)\n            ->filter(fn (PostComment $comment) => !$comment->containsBannedHashtags() && (!$loggedInUser || !$comment->containsFilteredWords($loggedInUser, $filterRealm)))\n            ->toArray();\n\n        // id sort\n        uasort($children, fn ($a, $b) => ArrayUtils::numCompareAscending($a->id, $b->id));\n\n        switch ($postCommentCriteria->sortOption) {\n            case MbinCriteria::SORT_TOP:\n            case MbinCriteria::SORT_HOT:\n                uasort($children, fn (PostComment $a, PostComment $b) => ArrayUtils::numCompareDescending($a->upVotes + $a->favouriteCount, $b->upVotes + $b->favouriteCount));\n\n                break;\n            case MbinCriteria::SORT_ACTIVE:\n                uasort($children, fn (PostComment $a, PostComment $b) => ArrayUtils::numCompareDescending($a->lastActive->getTimestamp(), $b->lastActive->getTimestamp()));\n\n                break;\n            case MbinCriteria::SORT_OLD:\n                uasort($children, fn (PostComment $a, PostComment $b) => ArrayUtils::numCompareDescending($a->createdAt->getTimestamp(), $b->createdAt->getTimestamp()));\n\n                break;\n            case MbinCriteria::SORT_NEW:\n                uasort($children, fn (PostComment $a, PostComment $b) => ArrayUtils::numCompareAscending($a->createdAt->getTimestamp(), $b->createdAt->getTimestamp()));\n\n                break;\n            default:\n        }\n\n        return $children;\n    }\n}\n"
  },
  {
    "path": "src/Entity/PostCommentCreatedNotification.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity;\n\nuse App\\Payloads\\PushNotification;\nuse Doctrine\\ORM\\Mapping\\Entity;\nuse Doctrine\\ORM\\Mapping\\JoinColumn;\nuse Doctrine\\ORM\\Mapping\\ManyToOne;\nuse Symfony\\Component\\Routing\\Generator\\UrlGeneratorInterface;\nuse Symfony\\Contracts\\Translation\\TranslatorInterface;\n\n#[Entity]\nclass PostCommentCreatedNotification extends Notification\n{\n    #[ManyToOne(targetEntity: PostComment::class, inversedBy: 'notifications')]\n    #[JoinColumn(nullable: true, onDelete: 'CASCADE')]\n    public ?PostComment $postComment = null;\n\n    public function __construct(User $receiver, PostComment $comment)\n    {\n        parent::__construct($receiver);\n\n        $this->postComment = $comment;\n    }\n\n    public function getSubject(): PostComment\n    {\n        return $this->postComment;\n    }\n\n    public function getComment(): PostComment\n    {\n        return $this->postComment;\n    }\n\n    public function getType(): string\n    {\n        return 'post_comment_created_notification';\n    }\n\n    public function getMessage(TranslatorInterface $trans, string $locale, UrlGeneratorInterface $urlGenerator): PushNotification\n    {\n        $message = \\sprintf('%s %s - %s', $this->postComment->user->username, $trans->trans('added_new_comment'), $this->postComment->getShortTitle());\n        $slash = $this->postComment->user->avatar && !str_starts_with('/', $this->postComment->user->avatar->filePath) ? '/' : '';\n        $avatarUrl = $this->postComment->user->avatar ? '/media/cache/resolve/avatar_thumb'.$slash.$this->postComment->user->avatar->filePath : null;\n        $url = $urlGenerator->generate('post_single', [\n            'magazine_name' => $this->postComment->post->magazine->name,\n            'post_id' => $this->postComment->post->getId(),\n            'slug' => empty($this->postComment->post->slug) ? '-' : $this->postComment->post->slug,\n        ]).'#post-comment-'.$this->postComment->getId();\n\n        return new PushNotification($this->getId(), $message, $trans->trans('notification_title_new_comment', locale: $locale), actionUrl: $url, avatarUrl: $avatarUrl);\n    }\n}\n"
  },
  {
    "path": "src/Entity/PostCommentDeletedNotification.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity;\n\nuse App\\Payloads\\PushNotification;\nuse Doctrine\\ORM\\Mapping\\Entity;\nuse Doctrine\\ORM\\Mapping\\JoinColumn;\nuse Doctrine\\ORM\\Mapping\\ManyToOne;\nuse Symfony\\Component\\Routing\\Generator\\UrlGeneratorInterface;\nuse Symfony\\Contracts\\Translation\\TranslatorInterface;\n\n#[Entity]\nclass PostCommentDeletedNotification extends Notification\n{\n    #[ManyToOne(targetEntity: PostComment::class, inversedBy: 'notifications')]\n    #[JoinColumn(nullable: true, onDelete: 'CASCADE')]\n    public ?PostComment $postComment = null;\n\n    public function __construct(User $receiver, PostComment $comment)\n    {\n        parent::__construct($receiver);\n\n        $this->postComment = $comment;\n    }\n\n    public function getSubject(): PostComment\n    {\n        return $this->postComment;\n    }\n\n    public function getComment(): PostComment\n    {\n        return $this->postComment;\n    }\n\n    public function getType(): string\n    {\n        return 'post_comment_deleted_notification';\n    }\n\n    public function getMessage(TranslatorInterface $trans, string $locale, UrlGeneratorInterface $urlGenerator): PushNotification\n    {\n        $message = \\sprintf('%s %s - %s', $trans->trans('comment'), $this->postComment->getShortTitle(), $this->postComment->isTrashed() ? $trans->trans('removed') : $trans->trans('deleted'));\n        $slash = $this->postComment->user->avatar && !str_starts_with('/', $this->postComment->user->avatar->filePath) ? '/' : '';\n        $avatarUrl = $this->postComment->user->avatar ? '/media/cache/resolve/avatar_thumb'.$this->postComment->user->avatar->filePath : null;\n        $url = $urlGenerator->generate('post_single', [\n            'magazine_name' => $this->postComment->post->magazine->name,\n            'post_id' => $this->postComment->post->getId(),\n            'slug' => empty($this->postComment->post->slug) ? '-' : $this->postComment->post->slug,\n        ]).'#post-comment-'.$this->postComment->getId();\n\n        return new PushNotification($this->getId(), $message, $trans->trans('notification_title_removed_comment', locale: $locale), actionUrl: $url, avatarUrl: $avatarUrl);\n    }\n}\n"
  },
  {
    "path": "src/Entity/PostCommentEditedNotification.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity;\n\nuse App\\Payloads\\PushNotification;\nuse Doctrine\\ORM\\Mapping\\Entity;\nuse Doctrine\\ORM\\Mapping\\JoinColumn;\nuse Doctrine\\ORM\\Mapping\\ManyToOne;\nuse Symfony\\Component\\Routing\\Generator\\UrlGeneratorInterface;\nuse Symfony\\Contracts\\Translation\\TranslatorInterface;\n\n#[Entity]\nclass PostCommentEditedNotification extends Notification\n{\n    #[ManyToOne(targetEntity: PostComment::class, inversedBy: 'notifications')]\n    #[JoinColumn(nullable: true, onDelete: 'CASCADE')]\n    public ?PostComment $postComment = null;\n\n    public function __construct(User $receiver, PostComment $comment)\n    {\n        parent::__construct($receiver);\n\n        $this->postComment = $comment;\n    }\n\n    public function getSubject(): PostComment\n    {\n        return $this->postComment;\n    }\n\n    public function getComment(): PostComment\n    {\n        return $this->postComment;\n    }\n\n    public function getType(): string\n    {\n        return 'post_comment_edited_notification';\n    }\n\n    public function getMessage(TranslatorInterface $trans, string $locale, UrlGeneratorInterface $urlGenerator): PushNotification\n    {\n        $message = \\sprintf('%s %s - %s', $this->postComment->user->username, $trans->trans('edited_comment'), $this->postComment->getShortTitle());\n        $slash = $this->postComment->user->avatar && !str_starts_with('/', $this->postComment->user->avatar->filePath) ? '/' : '';\n        $avatarUrl = $this->postComment->user->avatar ? '/media/cache/resolve/avatar_thumb'.$slash.$this->postComment->user->avatar->filePath : null;\n        $url = $urlGenerator->generate('post_single', [\n            'magazine_name' => $this->postComment->post->magazine->name,\n            'post_id' => $this->postComment->post->getId(),\n            'slug' => empty($this->postComment->post->slug) ? '-' : $this->postComment->post->slug,\n        ]).'#post-comment-'.$this->postComment->getId();\n\n        return new PushNotification($this->getId(), $message, $trans->trans('notification_title_edited_comment', locale: $locale), actionUrl: $url, avatarUrl: $avatarUrl);\n    }\n}\n"
  },
  {
    "path": "src/Entity/PostCommentFavourite.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity;\n\nuse Doctrine\\ORM\\Mapping\\Cache;\nuse Doctrine\\ORM\\Mapping\\Entity;\nuse Doctrine\\ORM\\Mapping\\JoinColumn;\nuse Doctrine\\ORM\\Mapping\\ManyToOne;\n\n#[Entity]\n#[Cache(usage: 'NONSTRICT_READ_WRITE')]\nclass PostCommentFavourite extends Favourite\n{\n    #[ManyToOne(targetEntity: PostComment::class, inversedBy: 'favourites')]\n    #[JoinColumn(nullable: true, onDelete: 'CASCADE')]\n    public ?PostComment $postComment = null;\n\n    public function __construct(User $user, PostComment $comment)\n    {\n        parent::__construct($user);\n\n        $this->magazine = $comment->magazine;\n        $this->postComment = $comment;\n    }\n\n    public function getSubject(): PostComment\n    {\n        return $this->postComment;\n    }\n\n    public function clearSubject(): Favourite\n    {\n        $this->postComment = null;\n\n        return $this;\n    }\n\n    public function getType(): string\n    {\n        return 'post_comment';\n    }\n}\n"
  },
  {
    "path": "src/Entity/PostCommentMentionedNotification.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity;\n\nuse App\\Payloads\\PushNotification;\nuse Doctrine\\ORM\\Mapping\\Entity;\nuse Doctrine\\ORM\\Mapping\\JoinColumn;\nuse Doctrine\\ORM\\Mapping\\ManyToOne;\nuse Symfony\\Component\\Routing\\Generator\\UrlGeneratorInterface;\nuse Symfony\\Contracts\\Translation\\TranslatorInterface;\n\n#[Entity]\nclass PostCommentMentionedNotification extends Notification\n{\n    #[ManyToOne(targetEntity: PostComment::class, inversedBy: 'notifications')]\n    #[JoinColumn(nullable: true, onDelete: 'CASCADE')]\n    public ?PostComment $postComment;\n\n    public function __construct(User $receiver, PostComment $comment)\n    {\n        parent::__construct($receiver);\n\n        $this->postComment = $comment;\n    }\n\n    public function getSubject(): PostComment\n    {\n        return $this->postComment;\n    }\n\n    public function getComment(): PostComment\n    {\n        return $this->postComment;\n    }\n\n    public function getType(): string\n    {\n        return 'post_comment_mentioned_notification';\n    }\n\n    public function getMessage(TranslatorInterface $trans, string $locale, UrlGeneratorInterface $urlGenerator): PushNotification\n    {\n        $message = \\sprintf('%s %s - %s', $this->postComment->user->username, $trans->trans('mentioned_you'), $this->postComment->getShortTitle());\n        $slash = $this->postComment->user->avatar && !str_starts_with('/', $this->postComment->user->avatar->filePath) ? '/' : '';\n        $avatarUrl = $this->postComment->user->avatar ? '/media/cache/resolve/avatar_thumb'.$slash.$this->postComment->user->avatar->filePath : null;\n        $url = $urlGenerator->generate('post_single', [\n            'magazine_name' => $this->postComment->post->magazine->name,\n            'post_id' => $this->postComment->post->getId(),\n            'slug' => empty($this->postComment->post->slug) ? '-' : $this->postComment->post->slug,\n        ]).'#post-comment-'.$this->postComment->getId();\n\n        return new PushNotification($this->getId(), $message, $trans->trans('notification_title_mention', locale: $locale), actionUrl: $url, avatarUrl: $avatarUrl);\n    }\n}\n"
  },
  {
    "path": "src/Entity/PostCommentReplyNotification.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity;\n\nuse App\\Payloads\\PushNotification;\nuse Doctrine\\ORM\\Mapping\\Entity;\nuse Doctrine\\ORM\\Mapping\\JoinColumn;\nuse Doctrine\\ORM\\Mapping\\ManyToOne;\nuse Symfony\\Component\\Routing\\Generator\\UrlGeneratorInterface;\nuse Symfony\\Contracts\\Translation\\TranslatorInterface;\n\n#[Entity]\nclass PostCommentReplyNotification extends Notification\n{\n    #[ManyToOne(targetEntity: PostComment::class, inversedBy: 'notifications')]\n    #[JoinColumn(nullable: true, onDelete: 'CASCADE')]\n    public ?PostComment $postComment = null;\n\n    public function __construct(User $receiver, PostComment $comment)\n    {\n        parent::__construct($receiver);\n\n        $this->postComment = $comment;\n    }\n\n    public function getSubject(): PostComment\n    {\n        return $this->postComment;\n    }\n\n    public function getComment(): PostComment\n    {\n        return $this->postComment;\n    }\n\n    public function getType(): string\n    {\n        return 'post_comment_reply_notification';\n    }\n\n    public function getMessage(TranslatorInterface $trans, string $locale, UrlGeneratorInterface $urlGenerator): PushNotification\n    {\n        $message = \\sprintf('%s %s - %s', $this->postComment->user->username, $trans->trans('replied_to_your_comment'), $this->postComment->getShortTitle());\n        $slash = $this->postComment->user->avatar && !str_starts_with('/', $this->postComment->user->avatar->filePath) ? '/' : '';\n        $avatarUrl = $this->postComment->user->avatar ? '/media/cache/resolve/avatar_thumb'.$slash.$this->postComment->user->avatar->filePath : null;\n        $url = $urlGenerator->generate('post_single', [\n            'magazine_name' => $this->postComment->post->magazine->name,\n            'post_id' => $this->postComment->post->getId(),\n            'slug' => empty($this->postComment->post->slug) ? '-' : $this->postComment->post->slug,\n        ]).'#post-comment-'.$this->postComment->getId();\n\n        return new PushNotification($this->getId(), $message, $trans->trans('notification_title_new_reply', locale: $locale), actionUrl: $url, avatarUrl: $avatarUrl);\n    }\n}\n"
  },
  {
    "path": "src/Entity/PostCommentReport.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity;\n\nuse Doctrine\\ORM\\Mapping\\Entity;\nuse Doctrine\\ORM\\Mapping\\JoinColumn;\nuse Doctrine\\ORM\\Mapping\\ManyToOne;\n\n#[Entity]\nclass PostCommentReport extends Report\n{\n    #[ManyToOne(targetEntity: PostComment::class, inversedBy: 'reports')]\n    #[JoinColumn(nullable: true, onDelete: 'CASCADE')]\n    public ?PostComment $postComment = null;\n\n    public function __construct(User $reporting, PostComment $comment, ?string $reason = null)\n    {\n        parent::__construct($reporting, $comment->user, $comment->magazine, $reason);\n\n        $this->postComment = $comment;\n    }\n\n    public function getSubject(): PostComment\n    {\n        return $this->postComment;\n    }\n\n    public function clearSubject(): Report\n    {\n        $this->postComment = null;\n\n        return $this;\n    }\n\n    public function getType(): string\n    {\n        return 'post_comment';\n    }\n}\n"
  },
  {
    "path": "src/Entity/PostCommentVote.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity;\n\nuse App\\Entity\\Contracts\\VotableInterface;\nuse Doctrine\\ORM\\Mapping\\AssociationOverride;\nuse Doctrine\\ORM\\Mapping\\AssociationOverrides;\nuse Doctrine\\ORM\\Mapping\\Cache;\nuse Doctrine\\ORM\\Mapping\\Entity;\nuse Doctrine\\ORM\\Mapping\\JoinColumn;\nuse Doctrine\\ORM\\Mapping\\ManyToOne;\nuse Doctrine\\ORM\\Mapping\\Table;\nuse Doctrine\\ORM\\Mapping\\UniqueConstraint;\n\n#[Entity]\n#[Table]\n#[UniqueConstraint(name: 'user_post_comment_vote_idx', columns: ['user_id', 'comment_id'])]\n#[AssociationOverrides([\n    new AssociationOverride(name: 'user', inversedBy: 'postCommentVotes'),\n])]\n#[Cache(usage: 'NONSTRICT_READ_WRITE')]\nclass PostCommentVote extends Vote\n{\n    #[ManyToOne(targetEntity: PostComment::class, inversedBy: 'votes')]\n    #[JoinColumn(name: 'comment_id', nullable: false, onDelete: 'CASCADE')]\n    public ?PostComment $comment = null;\n\n    public function __construct(int $choice, User $user, PostComment $comment)\n    {\n        parent::__construct($choice, $user, $comment->user);\n\n        $this->comment = $comment;\n    }\n\n    public function getComment(): PostComment\n    {\n        return $this->comment;\n    }\n\n    public function setComment(?PostComment $comment): self\n    {\n        $this->comment = $comment;\n\n        return $this;\n    }\n\n    public function getSubject(): VotableInterface\n    {\n        return $this->comment;\n    }\n}\n"
  },
  {
    "path": "src/Entity/PostCreatedNotification.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity;\n\nuse App\\Payloads\\PushNotification;\nuse Doctrine\\ORM\\Mapping\\Entity;\nuse Doctrine\\ORM\\Mapping\\JoinColumn;\nuse Doctrine\\ORM\\Mapping\\ManyToOne;\nuse Symfony\\Component\\Routing\\Generator\\UrlGeneratorInterface;\nuse Symfony\\Contracts\\Translation\\TranslatorInterface;\n\n#[Entity]\nclass PostCreatedNotification extends Notification\n{\n    #[ManyToOne(targetEntity: Post::class, inversedBy: 'notifications')]\n    #[JoinColumn(nullable: true, onDelete: 'CASCADE')]\n    public ?Post $post = null;\n\n    public function __construct(User $receiver, Post $post)\n    {\n        parent::__construct($receiver);\n\n        $this->post = $post;\n    }\n\n    public function getSubject(): Post\n    {\n        return $this->post;\n    }\n\n    public function getType(): string\n    {\n        return 'post_created_notification';\n    }\n\n    public function getMessage(TranslatorInterface $trans, string $locale, UrlGeneratorInterface $urlGenerator): PushNotification\n    {\n        $message = \\sprintf('%s %s - %s', $this->post->user->username, $trans->trans('added_new_post'), $this->post->getShortTitle());\n        $slash = $this->post->user->avatar && !str_starts_with('/', $this->post->user->avatar->filePath) ? '/' : '';\n        $avatarUrl = $this->post->user->avatar ? '/media/cache/resolve/avatar_thumb'.$slash.$this->post->user->avatar->filePath : null;\n        $url = $urlGenerator->generate('post_single', [\n            'magazine_name' => $this->post->magazine->name,\n            'post_id' => $this->post->getId(),\n            'slug' => empty($this->postComment->post->slug) ? '-' : $this->postComment->post->slug,\n        ]);\n\n        return new PushNotification($this->getId(), $message, $trans->trans('notification_title_new_post', locale: $locale), actionUrl: $url, avatarUrl: $avatarUrl);\n    }\n}\n"
  },
  {
    "path": "src/Entity/PostDeletedNotification.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity;\n\nuse App\\Payloads\\PushNotification;\nuse Doctrine\\ORM\\Mapping\\Entity;\nuse Doctrine\\ORM\\Mapping\\JoinColumn;\nuse Doctrine\\ORM\\Mapping\\ManyToOne;\nuse Symfony\\Component\\Routing\\Generator\\UrlGeneratorInterface;\nuse Symfony\\Contracts\\Translation\\TranslatorInterface;\n\n#[Entity]\nclass PostDeletedNotification extends Notification\n{\n    #[ManyToOne(targetEntity: Post::class, inversedBy: 'notifications')]\n    #[JoinColumn(nullable: true, onDelete: 'CASCADE')]\n    public ?Post $post = null;\n\n    public function __construct(User $receiver, Post $post)\n    {\n        parent::__construct($receiver);\n\n        $this->post = $post;\n    }\n\n    public function getSubject(): Post\n    {\n        return $this->post;\n    }\n\n    public function getType(): string\n    {\n        return 'post_deleted_notification';\n    }\n\n    public function getMessage(TranslatorInterface $trans, string $locale, UrlGeneratorInterface $urlGenerator): PushNotification\n    {\n        $message = \\sprintf('%s %s - %s', $trans->trans('post'), $this->post->getShortTitle(), $this->post->isTrashed() ? $trans->trans('removed') : $trans->trans('deleted'));\n        $slash = $this->post->user->avatar && !str_starts_with('/', $this->post->user->avatar->filePath) ? '/' : '';\n        $avatarUrl = $this->post->user->avatar ? '/media/cache/resolve/avatar_thumb'.$slash.$this->post->user->avatar->filePath : null;\n        $url = $urlGenerator->generate('post_single', [\n            'magazine_name' => $this->post->magazine->name,\n            'post_id' => $this->post->getId(),\n            'slug' => empty($this->postComment->post->slug) ? '-' : $this->postComment->post->slug,\n        ]);\n\n        return new PushNotification($this->getId(), $message, $trans->trans('notification_title_removed_post', locale: $locale), actionUrl: $url, avatarUrl: $avatarUrl);\n    }\n}\n"
  },
  {
    "path": "src/Entity/PostEditedNotification.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity;\n\nuse App\\Payloads\\PushNotification;\nuse Doctrine\\ORM\\Mapping\\Entity;\nuse Doctrine\\ORM\\Mapping\\JoinColumn;\nuse Doctrine\\ORM\\Mapping\\ManyToOne;\nuse Symfony\\Component\\Routing\\Generator\\UrlGeneratorInterface;\nuse Symfony\\Contracts\\Translation\\TranslatorInterface;\n\n#[Entity]\nclass PostEditedNotification extends Notification\n{\n    #[ManyToOne(targetEntity: Post::class, inversedBy: 'notifications')]\n    #[JoinColumn(nullable: true, onDelete: 'CASCADE')]\n    public ?Post $post = null;\n\n    public function __construct(User $receiver, Post $post)\n    {\n        parent::__construct($receiver);\n\n        $this->post = $post;\n    }\n\n    public function getSubject(): Post\n    {\n        return $this->post;\n    }\n\n    public function getType(): string\n    {\n        return 'post_edited_notification';\n    }\n\n    public function getMessage(TranslatorInterface $trans, string $locale, UrlGeneratorInterface $urlGenerator): PushNotification\n    {\n        $message = \\sprintf('%s %s - %s', $this->post->user->username, $trans->trans('edited_post'), $this->post->getShortTitle());\n        $slash = $this->post->user->avatar && !str_starts_with('/', $this->post->user->avatar->filePath) ? '/' : '';\n        $avatarUrl = $this->post->user->avatar ? '/media/cache/resolve/avatar_thumb'.$slash.$this->post->user->avatar->filePath : null;\n        $url = $urlGenerator->generate('post_single', [\n            'magazine_name' => $this->post->magazine->name,\n            'post_id' => $this->post->getId(),\n            'slug' => empty($this->postComment->post->slug) ? '-' : $this->postComment->post->slug,\n        ]);\n\n        return new PushNotification($this->getId(), $message, $trans->trans('notification_title_edited_post', locale: $locale), actionUrl: $url, avatarUrl: $avatarUrl);\n    }\n}\n"
  },
  {
    "path": "src/Entity/PostFavourite.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity;\n\nuse Doctrine\\ORM\\Mapping\\Cache;\nuse Doctrine\\ORM\\Mapping\\Entity;\nuse Doctrine\\ORM\\Mapping\\JoinColumn;\nuse Doctrine\\ORM\\Mapping\\ManyToOne;\n\n#[Entity]\n#[Cache(usage: 'NONSTRICT_READ_WRITE')]\nclass PostFavourite extends Favourite\n{\n    #[ManyToOne(targetEntity: Post::class, inversedBy: 'favourites')]\n    #[JoinColumn(nullable: true, onDelete: 'CASCADE')]\n    public ?Post $post = null;\n\n    public function __construct(User $user, Post $post)\n    {\n        parent::__construct($user);\n\n        $this->magazine = $post->magazine;\n        $this->post = $post;\n    }\n\n    public function getSubject(): Post\n    {\n        return $this->post;\n    }\n\n    public function clearSubject(): Favourite\n    {\n        $this->post = null;\n\n        return $this;\n    }\n\n    public function getType(): string\n    {\n        return 'post';\n    }\n}\n"
  },
  {
    "path": "src/Entity/PostMentionedNotification.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity;\n\nuse App\\Payloads\\PushNotification;\nuse Doctrine\\ORM\\Mapping\\Entity;\nuse Doctrine\\ORM\\Mapping\\JoinColumn;\nuse Doctrine\\ORM\\Mapping\\ManyToOne;\nuse Symfony\\Component\\Routing\\Generator\\UrlGeneratorInterface;\nuse Symfony\\Contracts\\Translation\\TranslatorInterface;\n\n#[Entity]\nclass PostMentionedNotification extends Notification\n{\n    #[ManyToOne(targetEntity: Post::class, inversedBy: 'notifications')]\n    #[JoinColumn(nullable: true, onDelete: 'CASCADE')]\n    public ?Post $post;\n\n    public function __construct(User $receiver, Post $post)\n    {\n        parent::__construct($receiver);\n\n        $this->post = $post;\n    }\n\n    public function getSubject(): Post\n    {\n        return $this->post;\n    }\n\n    public function getType(): string\n    {\n        return 'post_mentioned_notification';\n    }\n\n    public function getMessage(TranslatorInterface $trans, string $locale, UrlGeneratorInterface $urlGenerator): PushNotification\n    {\n        $message = \\sprintf('%s %s - %s', $this->post->user->username, $trans->trans('mentioned_you'), $this->post->getShortTitle());\n        $slash = $this->post->user->avatar && !str_starts_with('/', $this->post->user->avatar->filePath) ? '/' : '';\n        $avatarUrl = $this->post->user->avatar ? '/media/cache/resolve/avatar_thumb'.$slash.$this->post->user->avatar->filePath : null;\n        $url = $urlGenerator->generate('post_single', [\n            'magazine_name' => $this->post->magazine->name,\n            'post_id' => $this->post->getId(),\n            'slug' => empty($this->postComment->post->slug) ? '-' : $this->postComment->post->slug,\n        ]);\n\n        return new PushNotification($this->getId(), $message, $trans->trans('notification_title_mention', locale: $locale), actionUrl: $url, avatarUrl: $avatarUrl);\n    }\n}\n"
  },
  {
    "path": "src/Entity/PostReport.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity;\n\nuse Doctrine\\ORM\\Mapping\\Entity;\nuse Doctrine\\ORM\\Mapping\\JoinColumn;\nuse Doctrine\\ORM\\Mapping\\ManyToOne;\n\n#[Entity]\nclass PostReport extends Report\n{\n    #[ManyToOne(targetEntity: Post::class, inversedBy: 'reports')]\n    #[JoinColumn(nullable: true, onDelete: 'CASCADE')]\n    public ?Post $post = null;\n\n    public function __construct(User $reporting, Post $post, ?string $reason = null)\n    {\n        parent::__construct($reporting, $post->user, $post->magazine, $reason);\n\n        $this->post = $post;\n    }\n\n    public function getSubject(): Post\n    {\n        return $this->post;\n    }\n\n    public function clearSubject(): Report\n    {\n        $this->post = null;\n\n        return $this;\n    }\n\n    public function getType(): string\n    {\n        return 'post';\n    }\n}\n"
  },
  {
    "path": "src/Entity/PostVote.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity;\n\nuse App\\Entity\\Contracts\\VotableInterface;\nuse Doctrine\\ORM\\Mapping\\AssociationOverride;\nuse Doctrine\\ORM\\Mapping\\AssociationOverrides;\nuse Doctrine\\ORM\\Mapping\\Cache;\nuse Doctrine\\ORM\\Mapping\\Entity;\nuse Doctrine\\ORM\\Mapping\\JoinColumn;\nuse Doctrine\\ORM\\Mapping\\ManyToOne;\nuse Doctrine\\ORM\\Mapping\\Table;\nuse Doctrine\\ORM\\Mapping\\UniqueConstraint;\n\n#[Entity]\n#[Table]\n#[UniqueConstraint(name: 'user_post_vote_idx', columns: ['user_id', 'post_id'])]\n#[AssociationOverrides([\n    new AssociationOverride(name: 'user', inversedBy: 'postVotes'),\n])]\n#[Cache(usage: 'NONSTRICT_READ_WRITE')]\nclass PostVote extends Vote\n{\n    #[ManyToOne(targetEntity: Post::class, inversedBy: 'votes')]\n    #[JoinColumn(name: 'post_id', nullable: false, onDelete: 'CASCADE')]\n    public ?Post $post = null;\n\n    public function __construct(int $choice, User $user, ?Post $post)\n    {\n        parent::__construct($choice, $user, $post->user);\n\n        $this->post = $post;\n    }\n\n    public function getSubject(): VotableInterface\n    {\n        return $this->post;\n    }\n}\n"
  },
  {
    "path": "src/Entity/Report.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity;\n\nuse App\\Entity\\Contracts\\ReportInterface;\nuse App\\Entity\\Traits\\ConsideredAtTrait;\nuse App\\Entity\\Traits\\CreatedAtTrait;\nuse App\\Repository\\ReportRepository;\nuse Doctrine\\ORM\\Mapping\\Column;\nuse Doctrine\\ORM\\Mapping\\DiscriminatorColumn;\nuse Doctrine\\ORM\\Mapping\\DiscriminatorMap;\nuse Doctrine\\ORM\\Mapping\\Entity;\nuse Doctrine\\ORM\\Mapping\\GeneratedValue;\nuse Doctrine\\ORM\\Mapping\\Id;\nuse Doctrine\\ORM\\Mapping\\InheritanceType;\nuse Doctrine\\ORM\\Mapping\\JoinColumn;\nuse Doctrine\\ORM\\Mapping\\ManyToOne;\nuse Doctrine\\ORM\\Mapping\\UniqueConstraint;\nuse Symfony\\Component\\Uid\\Uuid;\n\n#[Entity(repositoryClass: ReportRepository::class)]\n#[InheritanceType('SINGLE_TABLE')]\n#[DiscriminatorColumn(name: 'report_type', type: 'text')]\n#[DiscriminatorMap([\n    'entry' => 'EntryReport',\n    'entry_comment' => 'EntryCommentReport',\n    'post' => 'PostReport',\n    'post_comment' => 'PostCommentReport',\n])]\n#[UniqueConstraint(name: 'report_uuid_idx', columns: ['uuid'])]\nabstract class Report\n{\n    use CreatedAtTrait {\n        CreatedAtTrait::__construct as createdAtTraitConstruct;\n    }\n    use ConsideredAtTrait;\n\n    public const STATUS_PENDING = 'pending';\n    public const STATUS_APPROVED = 'approved';\n    public const STATUS_REJECTED = 'rejected';\n    public const STATUS_APPEAL = 'appeal';\n    public const STATUS_CLOSED = 'closed';\n    public const STATUS_ANY = 'any';\n\n    public const STATUS_OPTIONS = [\n        self::STATUS_ANY,\n        self::STATUS_APPEAL,\n        self::STATUS_APPROVED,\n        self::STATUS_CLOSED,\n        self::STATUS_PENDING,\n        self::STATUS_REJECTED,\n    ];\n\n    #[ManyToOne(targetEntity: Magazine::class, inversedBy: 'reports')]\n    #[JoinColumn(nullable: false, onDelete: 'CASCADE')]\n    public Magazine $magazine;\n    #[ManyToOne(targetEntity: User::class, inversedBy: 'reports')]\n    #[JoinColumn(nullable: false, onDelete: 'CASCADE')]\n    public User $reporting;\n    #[ManyToOne(targetEntity: User::class, inversedBy: 'violations')]\n    #[JoinColumn(nullable: false, onDelete: 'CASCADE')]\n    public User $reported;\n    #[ManyToOne(targetEntity: User::class)]\n    #[JoinColumn(nullable: true, onDelete: 'CASCADE')]\n    public ?User $consideredBy = null;\n    #[Column(type: 'string', nullable: true)]\n    public ?string $reason = null;\n    #[Column(type: 'integer', nullable: false)]\n    public int $weight = 1;\n    #[Column(type: 'string', nullable: false)]\n    public string $status = self::STATUS_PENDING;\n\n    // this is nullable to be compatible with previous versions\n    #[Column(type: 'string', unique: true, nullable: true)]\n    public string $uuid;\n    #[Id]\n    #[GeneratedValue]\n    #[Column(type: 'integer')]\n    private int $id;\n\n    public function __construct(User $reporting, User $reported, Magazine $magazine, ?string $reason = null)\n    {\n        $this->reporting = $reporting;\n        $this->reported = $reported;\n        $this->magazine = $magazine;\n        $this->reason = $reason;\n        $this->uuid = Uuid::v4()->toRfc4122();\n        $this->createdAtTraitConstruct();\n    }\n\n    public function getId(): int\n    {\n        return $this->id;\n    }\n\n    public function increaseWeight(): self\n    {\n        ++$this->weight;\n\n        return $this;\n    }\n\n    abstract public function getType(): string;\n\n    abstract public function getSubject(): ?ReportInterface;\n\n    abstract public function clearSubject(): Report;\n}\n"
  },
  {
    "path": "src/Entity/ReportApprovedNotification.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity;\n\nuse App\\Entity\\Contracts\\ReportInterface;\nuse App\\Payloads\\PushNotification;\nuse Doctrine\\ORM\\Mapping\\Entity;\nuse Doctrine\\ORM\\Mapping\\JoinColumn;\nuse Doctrine\\ORM\\Mapping\\ManyToOne;\nuse Symfony\\Component\\Routing\\Generator\\UrlGeneratorInterface;\nuse Symfony\\Contracts\\Translation\\TranslatorInterface;\n\n#[Entity]\nclass ReportApprovedNotification extends Notification\n{\n    #[ManyToOne(targetEntity: Report::class)]\n    #[JoinColumn(nullable: true, onDelete: 'CASCADE')]\n    public ?Report $report = null;\n\n    public function __construct(User $receiver, Report $report)\n    {\n        parent::__construct($receiver);\n\n        $this->report = $report;\n    }\n\n    public function getType(): string\n    {\n        return 'report_approved_notification';\n    }\n\n    public function getMessage(TranslatorInterface $trans, string $locale, UrlGeneratorInterface $urlGenerator): PushNotification\n    {\n        /** @var Entry|EntryComment|Post|PostComment $subject */\n        $subject = $this->report->getSubject();\n        $linkToSubject = $this->getSubjectLink($this->report->getSubject(), $urlGenerator);\n        $linkToReport = $urlGenerator->generate('magazine_panel_reports', ['name' => $this->report->magazine->name, 'status' => Report::STATUS_APPROVED]);\n        if ($this->report->reporting->getId() === $this->user->getId()) {\n            $title = $trans->trans('own_report_accepted', locale: $locale);\n            $message = \\sprintf('%s: %s', $trans->trans('report_subject', locale: $locale), $subject->getShortTitle());\n            $actionUrl = $linkToSubject;\n        } elseif ($this->report->reported->getId() === $this->user->getId()) {\n            $title = $trans->trans('own_content_reported_accepted', locale: $locale);\n            $message = \\sprintf('%s: %s', $trans->trans('report_subject', locale: $locale), $subject->getShortTitle());\n            $actionUrl = $linkToSubject;\n        } else {\n            $title = $trans->trans('report_accepted', locale: $locale);\n            $message = \\sprintf('%s: %s\\n%s: %s\\n%s: %s - %s',\n                $trans->trans('reported_user', locale: $locale), $this->report->reported->username,\n                $trans->trans('reporting_user', locale: $locale), $this->report->reporting->username,\n                $trans->trans('report_subject', locale: $locale), $subject->getShortTitle(), $linkToSubject\n            );\n            $actionUrl = $linkToReport;\n        }\n\n        return new PushNotification($this->getId(), $message, $title, actionUrl: $actionUrl);\n    }\n\n    private function getSubjectLink(ReportInterface $subject, UrlGeneratorInterface $urlGenerator): string\n    {\n        if ($subject instanceof Entry) {\n            return $urlGenerator->generate('entry_single', ['magazine_name' => $subject->magazine->name, 'entry_id' => $subject->getId(), 'slug' => $subject->slug]);\n        } elseif ($subject instanceof EntryComment) {\n            return $urlGenerator->generate('entry_comment_view', ['magazine_name' => $subject->magazine->name, 'entry_id' => $subject->entry->getId(), 'slug' => $subject->entry->slug, 'comment_id' => $subject->getId()]);\n        } elseif ($subject instanceof Post) {\n            return $urlGenerator->generate('post_single', ['magazine_name' => $subject->magazine->name, 'post_id' => $subject->getId(), 'slug' => $subject->slug]);\n        } elseif ($subject instanceof PostComment) {\n            return $urlGenerator->generate('post_single', ['magazine_name' => $subject->magazine->name, 'post_id' => $subject->post->getId(), 'slug' => $subject->post->slug]).'#post-comment-'.$subject->getId();\n        }\n\n        return '';\n    }\n}\n"
  },
  {
    "path": "src/Entity/ReportCreatedNotification.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity;\n\nuse App\\Payloads\\PushNotification;\nuse Doctrine\\ORM\\Mapping\\Entity;\nuse Doctrine\\ORM\\Mapping\\JoinColumn;\nuse Doctrine\\ORM\\Mapping\\ManyToOne;\nuse Symfony\\Component\\Routing\\Generator\\UrlGeneratorInterface;\nuse Symfony\\Contracts\\Translation\\TranslatorInterface;\n\n#[Entity]\nclass ReportCreatedNotification extends Notification\n{\n    #[ManyToOne(targetEntity: Report::class)]\n    #[JoinColumn(nullable: true, onDelete: 'CASCADE')]\n    public ?Report $report = null;\n\n    public function __construct(User $receiver, Report $report)\n    {\n        parent::__construct($receiver);\n\n        $this->report = $report;\n    }\n\n    public function getType(): string\n    {\n        return 'report_created_notification';\n    }\n\n    public function getMessage(TranslatorInterface $trans, string $locale, UrlGeneratorInterface $urlGenerator): PushNotification\n    {\n        /** @var Entry|EntryComment|Post|PostComment $subject */\n        $subject = $this->report->getSubject();\n        $reportLink = $urlGenerator->generate('magazine_panel_reports', ['name' => $this->report->magazine->name, 'status' => Report::STATUS_PENDING]).'#report-id-'.$this->report->getId();\n        $message = \\sprintf('%s %s %s\\n%s: %s', $this->report->reporting->username, $trans->trans('reported', locale: $locale), $this->report->reported->username,\n            $trans->trans('report_subject', locale: $locale), $subject->getShortTitle());\n\n        return new PushNotification($this->getId(), $message, $trans->trans('notification_title_new_report'), actionUrl: $reportLink);\n    }\n}\n"
  },
  {
    "path": "src/Entity/ReportRejectedNotification.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity;\n\nuse App\\Entity\\Contracts\\ReportInterface;\nuse App\\Payloads\\PushNotification;\nuse Doctrine\\ORM\\Mapping\\Entity;\nuse Doctrine\\ORM\\Mapping\\JoinColumn;\nuse Doctrine\\ORM\\Mapping\\ManyToOne;\nuse Symfony\\Component\\Routing\\Generator\\UrlGeneratorInterface;\nuse Symfony\\Contracts\\Translation\\TranslatorInterface;\n\n#[Entity]\nclass ReportRejectedNotification extends Notification\n{\n    #[ManyToOne(targetEntity: Report::class)]\n    #[JoinColumn(nullable: true, onDelete: 'CASCADE')]\n    public ?Report $report = null;\n\n    public function __construct(User $receiver, Report $report)\n    {\n        parent::__construct($receiver);\n\n        $this->report = $report;\n    }\n\n    public function getType(): string\n    {\n        return 'report_rejected_notification';\n    }\n\n    public function getMessage(TranslatorInterface $trans, string $locale, UrlGeneratorInterface $urlGenerator): PushNotification\n    {\n        /** @var Entry|EntryComment|Post|PostComment $subject */\n        $subject = $this->report->getSubject();\n        $message = \\sprintf('%s: %s\\n%s: %s',\n            $trans->trans('reported_user', locale: $locale), $this->report->reported->username,\n            $trans->trans('report_subject', locale: $locale), $subject->getShortTitle()\n        );\n\n        return new PushNotification($this->getId(), $message, $trans->trans('own_report_rejected', locale: $locale), actionUrl: $this->getSubjectLink($subject, $urlGenerator));\n    }\n\n    private function getSubjectLink(ReportInterface $subject, UrlGeneratorInterface $urlGenerator): string\n    {\n        if ($subject instanceof Entry) {\n            return $urlGenerator->generate('entry_single', ['magazine_name' => $subject->magazine->name, 'entry_id' => $subject->getId(), 'slug' => $subject->slug]);\n        } elseif ($subject instanceof EntryComment) {\n            return $urlGenerator->generate('entry_comment_view', ['magazine_name' => $subject->magazine->name, 'entry_id' => $subject->entry->getId(), 'slug' => $subject->entry->slug, 'comment_id' => $subject->getId()]);\n        } elseif ($subject instanceof Post) {\n            return $urlGenerator->generate('post_single', ['magazine_name' => $subject->magazine->name, 'post_id' => $subject->getId(), 'slug' => $subject->slug]);\n        } elseif ($subject instanceof PostComment) {\n            return $urlGenerator->generate('post_single', ['magazine_name' => $subject->magazine->name, 'post_id' => $subject->post->getId(), 'slug' => $subject->post->slug]).'#post-comment-'.$subject->getId();\n        }\n\n        return '';\n    }\n}\n"
  },
  {
    "path": "src/Entity/ResetPasswordRequest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity;\n\nuse App\\Repository\\ResetPasswordRequestRepository;\nuse Doctrine\\ORM\\Mapping\\Column;\nuse Doctrine\\ORM\\Mapping\\Entity;\nuse Doctrine\\ORM\\Mapping\\GeneratedValue;\nuse Doctrine\\ORM\\Mapping\\Id;\nuse Doctrine\\ORM\\Mapping\\JoinColumn;\nuse Doctrine\\ORM\\Mapping\\ManyToOne;\nuse SymfonyCasts\\Bundle\\ResetPassword\\Model\\ResetPasswordRequestInterface;\nuse SymfonyCasts\\Bundle\\ResetPassword\\Model\\ResetPasswordRequestTrait;\n\n#[Entity(repositoryClass: ResetPasswordRequestRepository::class)]\nclass ResetPasswordRequest implements ResetPasswordRequestInterface\n{\n    use ResetPasswordRequestTrait;\n\n    #[Id]\n    #[GeneratedValue]\n    #[Column(type: 'integer')]\n    private int $id;\n\n    #[ManyToOne(targetEntity: User::class)]\n    #[JoinColumn(nullable: false, onDelete: 'CASCADE')]\n    private object $user;\n\n    public function __construct(object $user, \\DateTimeInterface $expiresAt, string $selector, string $hashedToken)\n    {\n        $this->user = $user;\n        $this->initialize($expiresAt, $selector, $hashedToken);\n    }\n\n    public function getId(): ?int\n    {\n        return $this->id;\n    }\n\n    public function getUser(): object\n    {\n        return $this->user;\n    }\n}\n"
  },
  {
    "path": "src/Entity/Settings.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity;\n\nuse App\\Repository\\SettingsRepository;\nuse Doctrine\\DBAL\\Types\\Types;\nuse Doctrine\\ORM\\Mapping\\Cache;\nuse Doctrine\\ORM\\Mapping\\Column;\nuse Doctrine\\ORM\\Mapping\\Entity;\nuse Doctrine\\ORM\\Mapping\\GeneratedValue;\nuse Doctrine\\ORM\\Mapping\\Id;\n\n#[Entity(repositoryClass: SettingsRepository::class)]\n#[Cache(usage: 'NONSTRICT_READ_WRITE')]\nclass Settings\n{\n    #[Column(type: 'string', nullable: false)]\n    public string $name;\n    #[Column(type: 'string', nullable: true)]\n    public ?string $value = null;\n    #[Column(type: Types::JSONB, nullable: true)]\n    public ?array $json = null;\n    #[Id]\n    #[GeneratedValue]\n    #[Column(type: 'integer')]\n    private int $id;\n\n    public function __construct(string $name, string|array $value)\n    {\n        $this->name = $name;\n\n        if (\\is_array($value)) {\n            $this->json = $value;\n        } else {\n            $this->value = $value;\n        }\n    }\n\n    public function getId(): int\n    {\n        return $this->id;\n    }\n}\n"
  },
  {
    "path": "src/Entity/Site.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity;\n\nuse Doctrine\\ORM\\Mapping\\Column;\nuse Doctrine\\ORM\\Mapping\\Entity;\nuse Doctrine\\ORM\\Mapping\\GeneratedValue;\nuse Doctrine\\ORM\\Mapping\\Id;\n\n#[Entity]\nclass Site\n{\n    #[Column(type: 'text', nullable: true)]\n    public ?string $terms = null;\n    #[Column(type: 'text', nullable: true)]\n    public ?string $privacyPolicy = null;\n    #[Column(type: 'text', nullable: true)]\n    public ?string $faq = null;\n    #[Column(type: 'text', nullable: true)]\n    public ?string $about = null;\n    #[Column(type: 'text', nullable: true)]\n    public ?string $announcement = null;\n    #[Column(type: 'text', nullable: true)]\n    public ?string $contact = null;\n    #[Column(type: 'text', nullable: true)]\n    public ?string $privateKey = null;\n    #[Column(type: 'text', nullable: true)]\n    public ?string $publicKey = null;\n    #[Column(type: 'text', nullable: true)]\n    public ?string $pushPrivateKey = null;\n    #[Column(type: 'text', nullable: true)]\n    public ?string $pushPublicKey = null;\n    #[Id]\n    #[GeneratedValue]\n    #[Column(type: 'integer')]\n    private int $id;\n\n    public function getId(): ?int\n    {\n        return $this->id;\n    }\n}\n"
  },
  {
    "path": "src/Entity/Traits/ActivityPubActivityTrait.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity\\Traits;\n\nuse Doctrine\\ORM\\Mapping\\Column;\n\ntrait ActivityPubActivityTrait\n{\n    #[Column(type: 'string', unique: true, nullable: true)]\n    public ?string $apId = null;\n}\n"
  },
  {
    "path": "src/Entity/Traits/ActivityPubActorTrait.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity\\Traits;\n\nuse App\\Service\\ActivityPub\\KeysGenerator;\nuse Doctrine\\ORM\\Mapping\\Column;\n\ntrait ActivityPubActorTrait\n{\n    #[Column(type: 'string', unique: true, nullable: true)]\n    public ?string $apId = null;\n\n    #[Column(type: 'string', nullable: true)]\n    public ?string $apProfileId = null;\n\n    #[Column(type: 'string', nullable: true)]\n    public ?string $apPublicUrl = null;\n\n    #[Column(type: 'string', nullable: true)]\n    public ?string $apFollowersUrl = null;\n\n    #[Column(type: 'string', nullable: true)]\n    public ?string $apAttributedToUrl = null;\n\n    #[Column(type: 'string', nullable: true)]\n    public ?string $apFeaturedUrl = null;\n\n    #[Column(type: 'integer', nullable: true)]\n    public ?int $apFollowersCount = null;\n\n    #[Column(type: 'string', nullable: true)]\n    public ?string $apInboxUrl = null;\n\n    #[Column(type: 'string', nullable: true)]\n    public ?string $apDomain = null;\n\n    #[Column(type: 'string', nullable: true)]\n    public ?string $apPreferredUsername = null;\n\n    #[Column(type: 'string')]\n    public ?string $title = null;\n\n    #[Column(type: 'boolean', nullable: true)]\n    public ?bool $apDiscoverable = null;\n\n    #[Column(type: 'boolean', nullable: true)]\n    public ?bool $apIndexable = null;\n\n    #[Column(type: 'boolean', nullable: true)]\n    public ?bool $apManuallyApprovesFollowers = null;\n\n    #[Column(type: 'text', nullable: true)]\n    public ?string $privateKey = null;\n\n    #[Column(type: 'text', nullable: true)]\n    public ?string $publicKey = null;\n\n    #[Column(type: 'text', nullable: true)]\n    public ?string $oldPrivateKey = null;\n\n    #[Column(type: 'text', nullable: true)]\n    public ?string $oldPublicKey = null;\n\n    #[Column(type: 'datetimetz', nullable: true)]\n    public ?\\DateTime $apFetchedAt = null;\n\n    #[Column(type: 'datetimetz', nullable: true)]\n    public ?\\DateTime $apDeletedAt = null;\n\n    #[Column(type: 'datetimetz', nullable: true)]\n    public ?\\DateTime $apTimeoutAt = null;\n\n    #[Column(type: 'datetimetz', nullable: true)]\n    public ?\\DateTime $lastKeyRotationDate = null;\n\n    public function getPrivateKey(): ?string\n    {\n        return $this->privateKey;\n    }\n\n    public function getPublicKey(): ?string\n    {\n        return $this->publicKey;\n    }\n\n    public function rotatePrivateKey(bool $revert = false): void\n    {\n        if (!$revert) {\n            $this->oldPrivateKey = $this->privateKey;\n            $this->oldPublicKey = $this->publicKey;\n            // set new private and public key\n            KeysGenerator::generate($this);\n        } else {\n            if (null === $this->oldPrivateKey || null === $this->oldPublicKey) {\n                throw new \\InvalidArgumentException('you cannot revert if there is no old key');\n            }\n            $newerPrivateKey = $this->privateKey;\n            $newerPublicKey = $this->publicKey;\n            $this->privateKey = $this->oldPrivateKey;\n            $this->publicKey = $this->oldPublicKey;\n            $this->oldPrivateKey = $newerPrivateKey;\n            $this->oldPublicKey = $newerPublicKey;\n        }\n        $this->lastKeyRotationDate = new \\DateTime();\n    }\n}\n"
  },
  {
    "path": "src/Entity/Traits/ConsideredAtTrait.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity\\Traits;\n\nuse Doctrine\\ORM\\Mapping as ORM;\n\ntrait ConsideredAtTrait\n{\n    #[ORM\\Column(type: 'datetimetz_immutable', nullable: true)]\n    public ?\\DateTimeImmutable $consideredAt = null;\n\n    public function getConsideredAt(): ?\\DateTimeImmutable\n    {\n        return $this->consideredAt;\n    }\n\n    public function setConsideredAt(): \\DateTimeImmutable\n    {\n        $this->consideredAt = new \\DateTimeImmutable('@'.time());\n\n        return $this->consideredAt;\n    }\n}\n"
  },
  {
    "path": "src/Entity/Traits/CreatedAtTrait.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity\\Traits;\n\nuse Doctrine\\ORM\\Mapping as ORM;\n\ntrait CreatedAtTrait\n{\n    public const NEW_FOR_DAYS = 30;\n\n    #[ORM\\Column(type: 'datetimetz_immutable')]\n    public \\DateTimeImmutable $createdAt;\n\n    public function __construct()\n    {\n        $this->createdAt = new \\DateTimeImmutable('@'.time());\n    }\n\n    public function getCreatedAt(): \\DateTimeImmutable\n    {\n        return $this->createdAt;\n    }\n\n    public function isNew(): bool\n    {\n        $days = self::NEW_FOR_DAYS;\n\n        return $this->getCreatedAt() >= new \\DateTime(\"now -$days days\");\n    }\n\n    public function isCakeDay(): bool\n    {\n        $now = new \\DateTime();\n\n        return $this->getCreatedAt()->format('d') === $now->format('d')\n            && $this->getCreatedAt()->format('m') === $now->format('m');\n    }\n}\n"
  },
  {
    "path": "src/Entity/Traits/EditedAtTrait.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity\\Traits;\n\nuse Doctrine\\ORM\\Mapping as ORM;\n\ntrait EditedAtTrait\n{\n    #[ORM\\Column(type: 'datetimetz_immutable', nullable: true)]\n    public ?\\DateTimeImmutable $editedAt = null;\n\n    public function getEditedAt(): \\DateTimeImmutable\n    {\n        return $this->editedAt;\n    }\n}\n"
  },
  {
    "path": "src/Entity/Traits/MonitoringPerformanceTrait.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity\\Traits;\n\nuse Doctrine\\ORM\\Mapping\\Column;\n\ntrait MonitoringPerformanceTrait\n{\n    #[Column]\n    private \\DateTimeImmutable $startedAt;\n\n    #[Column]\n    private float $startedAtMicroseconds;\n\n    #[Column]\n    private \\DateTimeImmutable $endedAt;\n\n    #[Column]\n    private float $endedAtMicroseconds;\n\n    #[Column]\n    private float $durationMilliseconds;\n\n    public function getStartedAt(): \\DateTimeImmutable\n    {\n        return $this->startedAt;\n    }\n\n    public function setStartedAt(): void\n    {\n        $this->startedAt = new \\DateTimeImmutable();\n        $this->startedAtMicroseconds = microtime(true);\n    }\n\n    public function setEndedAt(): void\n    {\n        $this->endedAt = new \\DateTimeImmutable();\n        $this->endedAtMicroseconds = microtime(true);\n    }\n\n    public function getEndedAt(): \\DateTimeImmutable\n    {\n        return $this->endedAt;\n    }\n\n    public function setDuration(): void\n    {\n        $this->durationMilliseconds = ($this->endedAtMicroseconds - $this->startedAtMicroseconds) * 1000;\n    }\n\n    public function getDuration(): float\n    {\n        return $this->durationMilliseconds;\n    }\n}\n"
  },
  {
    "path": "src/Entity/Traits/RankingTrait.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity\\Traits;\n\nuse Doctrine\\ORM\\Mapping as ORM;\n\ntrait RankingTrait\n{\n    #[ORM\\Column(type: 'integer')]\n    public int $ranking = 0;\n\n    public function updateRanking(): void\n    {\n        $score = $this->getScore();\n        $scoreAdvantage = $score * self::NETSCORE_MULTIPLIER;\n\n        if ($score > self::DOWNVOTED_CUTOFF) {\n            $commentAdvantage = $this->getCommentCount() * self::COMMENT_MULTIPLIER;\n            $commentAdvantage += $this->getUniqueCommentCount() * self::COMMENT_UNIQUE_MULTIPLIER;\n        } else {\n            $commentAdvantage = $this->getCommentCount() * self::COMMENT_DOWNVOTED_MULTIPLIER;\n            $commentAdvantage += $this->getUniqueCommentCount() * self::COMMENT_DOWNVOTED_MULTIPLIER;\n        }\n\n        $advantage = max(min($scoreAdvantage + $commentAdvantage, self::MAX_ADVANTAGE), -self::MAX_PENALTY);\n\n        // cap max date advantage at the time of calculation to cope with posts\n        // that have funny dates (e.g. 4200-06-09)\n        // which can cause int overflow (int32?) on ranking score\n        $dateAdvantage = min($this->getCreatedAt()->getTimestamp(), (new \\DateTimeImmutable())->getTimestamp());\n\n        // also cap the final score to not exceed int32 size for the time being\n        $this->ranking = min($dateAdvantage + $advantage, 2 ** 31 - 1);\n    }\n\n    public function getRanking(): int\n    {\n        return $this->ranking;\n    }\n\n    public function setRanking(int $ranking): void\n    {\n        $this->ranking = $ranking;\n    }\n}\n"
  },
  {
    "path": "src/Entity/Traits/UpdatedAtTrait.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity\\Traits;\n\nuse Doctrine\\ORM\\Mapping as ORM;\n\ntrait UpdatedAtTrait\n{\n    #[ORM\\Column(type: 'datetimetz_immutable', nullable: true)]\n    public ?\\DateTimeImmutable $updatedAt = null;\n\n    public function getUpdatedAt(): ?\\DateTimeImmutable\n    {\n        return $this->updatedAt;\n    }\n\n    public function setUpdatedAt(): \\DateTimeImmutable\n    {\n        $this->updatedAt = new \\DateTimeImmutable('@'.time());\n\n        return $this->updatedAt;\n    }\n}\n"
  },
  {
    "path": "src/Entity/Traits/VisibilityTrait.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity\\Traits;\n\nuse App\\Entity\\Contracts\\VisibilityInterface;\nuse Doctrine\\ORM\\Mapping as ORM;\nuse JetBrains\\PhpStorm\\Pure;\n\ntrait VisibilityTrait\n{\n    #[ORM\\Column(type: 'text', options: ['default' => VisibilityInterface::VISIBILITY_VISIBLE])]\n    public string $visibility = VisibilityInterface::VISIBILITY_VISIBLE;\n\n    #[Pure]\n    public function isVisible(): bool\n    {\n        return VisibilityInterface::VISIBILITY_VISIBLE === $this->getVisibility();\n    }\n\n    public function getVisibility(): string\n    {\n        return $this->visibility;\n    }\n\n    #[Pure]\n    public function isSoftDeleted(): bool\n    {\n        return VisibilityInterface::VISIBILITY_SOFT_DELETED === $this->getVisibility();\n    }\n\n    #[Pure]\n    public function isTrashed(): bool\n    {\n        return VisibilityInterface::VISIBILITY_TRASHED === $this->getVisibility();\n    }\n\n    #[Pure]\n    public function isPrivate(): bool\n    {\n        return VisibilityInterface::VISIBILITY_PRIVATE === $this->getVisibility();\n    }\n}\n"
  },
  {
    "path": "src/Entity/Traits/VotableTrait.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity\\Traits;\n\nuse App\\Entity\\Contracts\\VotableInterface;\nuse App\\Entity\\User;\nuse App\\Entity\\Vote;\nuse Doctrine\\Common\\Collections\\Collection;\nuse Doctrine\\Common\\Collections\\Criteria;\nuse Doctrine\\ORM\\Mapping\\Column;\n\ntrait VotableTrait\n{\n    #[Column(type: 'integer')]\n    private int $upVotes = 0;\n\n    #[Column(type: 'integer')]\n    private int $downVotes = 0;\n\n    #[Column(type: 'integer', nullable: true)]\n    public ?int $apLikeCount = null;\n\n    #[Column(type: 'integer', nullable: true)]\n    public ?int $apDislikeCount = null;\n\n    #[Column(type: 'integer', nullable: true)]\n    public ?int $apShareCount = null;\n\n    public function countUpVotes(): int\n    {\n        return $this->apShareCount ?? $this->upVotes;\n    }\n\n    public function countDownVotes(): int\n    {\n        return $this->apDislikeCount ?? $this->downVotes;\n    }\n\n    public function countVotes(): int\n    {\n        return $this->countDownVotes() + $this->countUpVotes();\n    }\n\n    public function getUserChoice(User $user): int\n    {\n        $vote = $this->getUserVote($user);\n\n        return $vote ? $vote->choice : VotableInterface::VOTE_NONE;\n    }\n\n    public function getUserVote(User $user): ?Vote\n    {\n        $criteria = Criteria::create()\n            ->where(Criteria::expr()->eq('user', $user));\n\n        return $this->votes->matching($criteria)->first() ?: null;\n    }\n\n    public function updateVoteCounts(): self\n    {\n        $this->upVotes = $this->getUpVotes()->count();\n        $this->downVotes = $this->getDownVotes()->count();\n\n        return $this;\n    }\n\n    public function getUpVotes(): Collection\n    {\n        $this->votes->get(-1);\n\n        $criteria = Criteria::create()\n            ->where(Criteria::expr()->eq('choice', self::VOTE_UP));\n\n        return $this->votes->matching($criteria);\n    }\n\n    public function getDownVotes(): Collection\n    {\n        $this->votes->get(-1);\n\n        $criteria = Criteria::create()\n            ->where(Criteria::expr()->eq('choice', self::VOTE_DOWN));\n\n        return $this->votes->matching($criteria);\n    }\n}\n"
  },
  {
    "path": "src/Entity/User.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity;\n\nuse App\\Entity\\Contracts\\ActivityPubActorInterface;\nuse App\\Entity\\Contracts\\ApiResourceInterface;\nuse App\\Entity\\Contracts\\VisibilityInterface;\nuse App\\Entity\\Traits\\ActivityPubActorTrait;\nuse App\\Entity\\Traits\\CreatedAtTrait;\nuse App\\Entity\\Traits\\VisibilityTrait;\nuse App\\Enums\\EApplicationStatus;\nuse App\\Enums\\EDirectMessageSettings;\nuse App\\Enums\\ESortOptions;\nuse App\\Repository\\UserRepository;\nuse App\\Service\\ActivityPub\\ApHttpClientInterface;\nuse Doctrine\\Common\\Collections\\ArrayCollection;\nuse Doctrine\\Common\\Collections\\Collection;\nuse Doctrine\\Common\\Collections\\Criteria;\nuse Doctrine\\Common\\Collections\\Order;\nuse Doctrine\\DBAL\\Types\\Types;\nuse Doctrine\\ORM\\Mapping\\Column;\nuse Doctrine\\ORM\\Mapping\\Entity;\nuse Doctrine\\ORM\\Mapping\\GeneratedValue;\nuse Doctrine\\ORM\\Mapping\\Id;\nuse Doctrine\\ORM\\Mapping\\Index;\nuse Doctrine\\ORM\\Mapping\\JoinColumn;\nuse Doctrine\\ORM\\Mapping\\ManyToOne;\nuse Doctrine\\ORM\\Mapping\\OneToMany;\nuse Doctrine\\ORM\\Mapping\\OrderBy;\nuse Doctrine\\ORM\\Mapping\\Table;\nuse Doctrine\\ORM\\Mapping\\UniqueConstraint;\nuse Scheb\\TwoFactorBundle\\Model\\BackupCodeInterface;\nuse Scheb\\TwoFactorBundle\\Model\\Totp\\TotpConfiguration;\nuse Scheb\\TwoFactorBundle\\Model\\Totp\\TotpConfigurationInterface;\nuse Scheb\\TwoFactorBundle\\Model\\Totp\\TwoFactorInterface;\nuse Symfony\\Component\\PropertyAccess\\PropertyAccess;\nuse Symfony\\Component\\Routing\\Generator\\UrlGeneratorInterface;\nuse Symfony\\Component\\Security\\Core\\User\\EquatableInterface;\nuse Symfony\\Component\\Security\\Core\\User\\PasswordAuthenticatedUserInterface;\nuse Symfony\\Component\\Security\\Core\\User\\UserInterface;\n\n#[Entity(repositoryClass: UserRepository::class)]\n#[Table(name: '`user`')]\n#[Index(columns: ['visibility'], name: 'user_visibility_idx')]\n#[Index(columns: ['username_ts'], name: 'user_username_ts')]\n#[Index(columns: ['title_ts'], name: 'user_title_ts')]\n#[Index(columns: ['about_ts'], name: 'user_about_ts')]\n#[UniqueConstraint(name: 'user_email_idx', columns: ['email'])]\n#[UniqueConstraint(name: 'user_username_idx', columns: ['username'])]\n#[UniqueConstraint(name: 'user_ap_id_idx', columns: ['ap_id'])]\n#[UniqueConstraint(name: 'user_ap_profile_id_idx', columns: ['ap_profile_id'])]\n#[UniqueConstraint(name: 'user_ap_public_url_idx', columns: ['ap_public_url'])]\nclass User implements UserInterface, PasswordAuthenticatedUserInterface, VisibilityInterface, TwoFactorInterface, BackupCodeInterface, EquatableInterface, ActivityPubActorInterface, ApiResourceInterface\n{\n    use ActivityPubActorTrait;\n    use VisibilityTrait;\n    use CreatedAtTrait {\n        CreatedAtTrait::__construct as createdAtTraitConstruct;\n    }\n\n    public const THEME_LIGHT = 'light';\n    public const THEME_DARK = 'dark';\n    public const THEME_AUTO = 'auto';\n    public const THEME_OPTIONS = [\n        self::THEME_AUTO,\n        self::THEME_DARK,\n        self::THEME_LIGHT,\n    ];\n\n    public const MODE_NORMAL = 'normal';\n    public const MODE_TURBO = 'turbo';\n\n    public const HOMEPAGE_ALL = 'front';\n    public const HOMEPAGE_SUB = 'front_subscribed';\n    public const HOMEPAGE_MOD = 'front_moderated';\n    public const HOMEPAGE_FAV = 'front_favourite';\n    public const HOMEPAGE_OPTIONS = [\n        self::HOMEPAGE_ALL,\n        self::HOMEPAGE_SUB,\n        self::HOMEPAGE_MOD,\n        self::HOMEPAGE_FAV,\n    ];\n\n    public const USER_TYPE_PERSON = 'Person';\n    public const USER_TYPE_SERVICE = 'Service';\n    public const USER_TYPE_ORG = 'Organization';\n    public const USER_TYPE_APP = 'Application';\n    public const USER_TYPES = [\n        self::USER_TYPE_PERSON,\n        self::USER_TYPE_SERVICE,\n        self::USER_TYPE_ORG,\n        self::USER_TYPE_APP,\n    ];\n\n    #[ManyToOne(targetEntity: Image::class, cascade: ['persist'])]\n    #[JoinColumn(nullable: true)]\n    public ?Image $avatar = null;\n    #[ManyToOne(targetEntity: Image::class, cascade: ['persist'])]\n    #[JoinColumn(nullable: true)]\n    public ?Image $cover = null;\n    #[Column(type: 'string', unique: true, nullable: false)]\n    public string $email;\n    #[Column(type: 'string', unique: true, nullable: false)]\n    public string $username;\n    #[Column(type: Types::JSONB, nullable: false)]\n    public array $roles = [];\n    #[Column(type: 'integer', nullable: false)]\n    public int $followersCount = 0;\n    #[Column(type: 'string', nullable: false, options: ['default' => self::HOMEPAGE_ALL])]\n    public string $homepage = self::HOMEPAGE_ALL;\n    #[Column(type: 'boolean', nullable: false, options: ['default' => false])]\n    public bool $showBoostsOfFollowing = false;\n    #[Column(type: 'enumSortOptions', nullable: false, options: ['default' => ESortOptions::Hot->value])]\n    public string $frontDefaultSort = ESortOptions::Hot->value;\n    #[Column(type: 'enumFrontContentOptions', nullable: true)]\n    public ?string $frontDefaultContent = null;\n    #[Column(type: 'enumSortOptions', nullable: false, options: ['default' => ESortOptions::Hot->value])]\n    public string $commentDefaultSort = ESortOptions::Hot->value;\n    #[Column(type: 'enumDirectMessageSettings', nullable: false, options: ['default' => EDirectMessageSettings::Everyone->value])]\n    public string $directMessageSetting = EDirectMessageSettings::Everyone->value;\n    #[Column(type: 'text', nullable: true)]\n    public ?string $about = null;\n    #[Column(type: 'datetimetz')]\n    public ?\\DateTime $lastActive = null;\n    #[Column(type: 'datetimetz', nullable: true)]\n    public ?\\DateTime $markedForDeletionAt = null;\n    #[Column(type: Types::JSONB, nullable: true)]\n    public ?array $fields = null;\n    #[Column(type: 'string', nullable: true)]\n    public ?string $oauthAzureId = null;\n    #[Column(type: 'string', nullable: true)]\n    public ?string $oauthGithubId = null;\n    #[Column(type: 'string', nullable: true)]\n    public ?string $oauthGoogleId = null;\n    #[Column(type: 'string', nullable: true)]\n    public ?string $oauthFacebookId = null;\n    #[Column(name: 'oauth_privacyportal_id', type: 'string', nullable: true)]\n    public ?string $oauthPrivacyPortalId = null;\n    #[Column(type: 'string', nullable: true)]\n    public ?string $oauthKeycloakId = null;\n    #[Column(type: 'string', nullable: true)]\n    public ?string $oauthSimpleLoginId = null;\n    #[Column(type: 'string', nullable: true)]\n    public ?string $oauthDiscordId = null;\n    #[Column(type: 'string', nullable: true)]\n    public ?string $oauthZitadelId = null;\n    #[Column(type: 'string', nullable: true)]\n    public ?string $oauthAuthentikId = null;\n    #[Column(type: 'boolean', nullable: false, options: ['default' => true])]\n    public bool $hideAdult = true;\n    #[Column(type: Types::JSONB, nullable: false, options: ['default' => '[]'])]\n    public array $preferredLanguages = [];\n    #[Column(type: 'simple_array', nullable: true)]\n    public ?array $featuredMagazines = null;\n    #[Column(type: 'boolean', nullable: false, options: ['default' => true])]\n    public bool $showProfileSubscriptions = false;\n    #[Column(type: 'boolean', nullable: false, options: ['default' => true])]\n    public bool $showProfileFollowings = true;\n    #[Column(type: 'boolean', nullable: false)]\n    public bool $notifyOnNewEntry = false;\n    #[Column(type: 'boolean', nullable: false)]\n    public bool $notifyOnNewEntryReply = true;\n    #[Column(type: 'boolean', nullable: false)]\n    public bool $notifyOnNewEntryCommentReply = true;\n    #[Column(type: 'boolean', nullable: false)]\n    public bool $notifyOnNewPost = false;\n    #[Column(type: 'boolean', nullable: false)]\n    public bool $notifyOnNewPostReply = true;\n    #[Column(type: 'boolean', nullable: false)]\n    public bool $notifyOnNewPostCommentReply = true;\n    #[Column(type: 'boolean', nullable: false, options: ['default' => true])]\n    public bool $notifyOnUserSignup = true;\n    #[Column(type: 'boolean', nullable: false, options: ['default' => false])]\n    public bool $addMentionsEntries = false;\n    #[Column(type: 'boolean', nullable: false, options: ['default' => true])]\n    public bool $addMentionsPosts = true;\n    #[Column(type: 'boolean', nullable: false, options: ['default' => false])]\n    public bool $isBanned = false;\n    #[Column(type: 'string', nullable: true, options: ['default' => null])]\n    public ?string $banReason = null;\n    #[Column(type: 'boolean', nullable: false)]\n    public bool $isVerified = false;\n    #[Column(type: 'boolean', nullable: false, options: ['default' => false])]\n    public bool $isDeleted = false;\n    #[Column(type: 'text', nullable: true)]\n    public ?string $customCss = null;\n    #[Column(type: 'boolean', nullable: false, options: ['default' => false])]\n    public bool $ignoreMagazinesCustomCss = false;\n    #[OneToMany(mappedBy: 'user', targetEntity: Moderator::class, cascade: ['persist', 'remove'], orphanRemoval: true)]\n    public Collection $moderatorTokens;\n    #[OneToMany(mappedBy: 'user', targetEntity: MagazineOwnershipRequest::class, cascade: ['persist', 'remove'], orphanRemoval: true)]\n    public Collection $magazineOwnershipRequests;\n    #[OneToMany(mappedBy: 'user', targetEntity: ModeratorRequest::class, cascade: ['persist', 'remove'], orphanRemoval: true)]\n    public Collection $moderatorRequests;\n    #[OneToMany(mappedBy: 'user', targetEntity: Entry::class, cascade: ['persist', 'remove'], orphanRemoval: true)]\n    public Collection $entries;\n    #[OneToMany(mappedBy: 'user', targetEntity: EntryVote::class, cascade: ['persist', 'remove'], fetch: 'EXTRA_LAZY', orphanRemoval: true)]\n    public Collection $entryVotes;\n    #[OneToMany(mappedBy: 'user', targetEntity: EntryComment::class, cascade: ['persist', 'remove'], fetch: 'EXTRA_LAZY', orphanRemoval: true)]\n    public Collection $entryComments; // @todo\n    #[OneToMany(mappedBy: 'user', targetEntity: EntryCommentVote::class, cascade: ['persist', 'remove'], fetch: 'EXTRA_LAZY', orphanRemoval: true)]\n    public Collection $entryCommentVotes;\n    #[OneToMany(mappedBy: 'user', targetEntity: Post::class, cascade: ['persist', 'remove'], fetch: 'EXTRA_LAZY', orphanRemoval: true)]\n    public Collection $posts;\n    #[OneToMany(mappedBy: 'user', targetEntity: PostVote::class, cascade: ['persist', 'remove'], fetch: 'EXTRA_LAZY', orphanRemoval: true)]\n    public Collection $postVotes;\n    #[OneToMany(mappedBy: 'user', targetEntity: PostComment::class, cascade: ['persist', 'remove'], fetch: 'EXTRA_LAZY', orphanRemoval: true)]\n    public Collection $postComments;\n    #[OneToMany(mappedBy: 'user', targetEntity: PostCommentVote::class, cascade: ['persist', 'remove'], fetch: 'EXTRA_LAZY', orphanRemoval: true)]\n    public Collection $postCommentVotes;\n    #[OneToMany(mappedBy: 'user', targetEntity: MagazineSubscription::class, cascade: ['persist', 'remove'], orphanRemoval: true)]\n    public Collection $subscriptions;\n    #[OneToMany(mappedBy: 'user', targetEntity: DomainSubscription::class, cascade: ['persist', 'remove'], orphanRemoval: true)]\n    public Collection $subscribedDomains;\n    #[OneToMany(mappedBy: 'follower', targetEntity: UserFollow::class, cascade: ['persist', 'remove'], orphanRemoval: true)]\n    #[OrderBy(['createdAt' => 'DESC'])]\n    public Collection $follows;\n    #[OneToMany(mappedBy: 'following', targetEntity: UserFollow::class, cascade: ['persist', 'remove'], orphanRemoval: true)]\n    #[OrderBy(['createdAt' => 'DESC'])]\n    public Collection $followers;\n    #[OneToMany(mappedBy: 'blocker', targetEntity: UserBlock::class, cascade: ['persist', 'remove'], orphanRemoval: true)]\n    #[OrderBy(['createdAt' => 'DESC'])]\n    public Collection $blocks;\n    #[OneToMany(mappedBy: 'blocked', targetEntity: UserBlock::class, cascade: ['persist', 'remove'], orphanRemoval: true)]\n    #[OrderBy(['createdAt' => 'DESC'])]\n    public ?Collection $blockers;\n    #[OneToMany(mappedBy: 'user', targetEntity: MagazineBlock::class, cascade: ['persist', 'remove'], orphanRemoval: true)]\n    #[OrderBy(['createdAt' => 'DESC'])]\n    public Collection $blockedMagazines;\n    #[OneToMany(mappedBy: 'user', targetEntity: DomainBlock::class, cascade: ['persist', 'remove'], orphanRemoval: true)]\n    #[OrderBy(['createdAt' => 'DESC'])]\n    public Collection $blockedDomains;\n    #[OneToMany(mappedBy: 'reporting', targetEntity: Report::class, cascade: ['persist'], fetch: 'EXTRA_LAZY')]\n    #[OrderBy(['createdAt' => 'DESC'])]\n    public Collection $reports;\n    #[OneToMany(mappedBy: 'user', targetEntity: Favourite::class, cascade: ['persist', 'remove'], fetch: 'EXTRA_LAZY', orphanRemoval: true)]\n    #[OrderBy(['createdAt' => 'DESC'])]\n    public Collection $favourites;\n    #[OneToMany(mappedBy: 'reported', targetEntity: Report::class, cascade: ['persist', 'remove'], fetch: 'EXTRA_LAZY', orphanRemoval: true)]\n    #[OrderBy(['createdAt' => 'DESC'])]\n    public Collection $violations;\n    #[OneToMany(mappedBy: 'user', targetEntity: Notification::class, cascade: ['persist', 'remove'], fetch: 'EXTRA_LAZY', orphanRemoval: true)]\n    #[OrderBy(['createdAt' => 'DESC'])]\n    public Collection $notifications;\n    #[OneToMany(mappedBy: 'user', targetEntity: UserPushSubscription::class, fetch: 'EXTRA_LAZY')]\n    public Collection $pushSubscriptions;\n    #[OneToMany(mappedBy: 'user', targetEntity: BookmarkList::class, fetch: 'EXTRA_LAZY')]\n    public Collection $bookmarkLists;\n    #[OneToMany(targetEntity: UserFilterList::class, mappedBy: 'user', fetch: 'LAZY')]\n    public Collection $filterLists;\n    #[Id]\n    #[GeneratedValue]\n    #[Column(type: 'integer')]\n    private int $id;\n    #[Column(type: 'string', nullable: false)]\n    private string $password;\n    #[Column(type: 'string', nullable: true)]\n    private ?string $totpSecret = null;\n    #[Column(type: Types::JSONB, nullable: false, options: ['default' => '[]'])]\n    private array $totpBackupCodes = [];\n    #[OneToMany(mappedBy: 'user', targetEntity: OAuth2UserConsent::class, orphanRemoval: true)]\n    private Collection $oAuth2UserConsents;\n    #[Column(type: 'string', nullable: false, options: ['default' => self::USER_TYPE_PERSON])]\n    public string $type;\n\n    #[Column(type: 'text', nullable: true)]\n    public ?string $applicationText;\n\n    #[Column(type: 'text', nullable: true, insertable: false, updatable: false, options: ['default' => null])]\n    private ?string $usernameTs;\n    #[Column(type: 'text', nullable: true, insertable: false, updatable: false, options: ['default' => null])]\n    private ?string $titleTs;\n    #[Column(type: 'text', nullable: true, insertable: false, updatable: false, options: ['default' => null])]\n    private ?string $aboutTs;\n\n    #[Column(type: 'enumApplicationStatus', nullable: false, options: ['default' => EApplicationStatus::Approved->value])]\n    private string $applicationStatus;\n\n    public function __construct(\n        string $email,\n        string $username,\n        string $password,\n        string $type,\n        ?string $apProfileId = null,\n        ?string $apId = null,\n        EApplicationStatus $applicationStatus = EApplicationStatus::Approved,\n        ?string $applicationText = null,\n    ) {\n        $this->email = $email;\n        $this->password = $password;\n        $this->username = $username;\n        $this->type = $type;\n        $this->apProfileId = $apProfileId;\n        $this->apId = $apId;\n        $this->moderatorTokens = new ArrayCollection();\n        $this->magazineOwnershipRequests = new ArrayCollection();\n        $this->moderatorRequests = new ArrayCollection();\n        $this->entries = new ArrayCollection();\n        $this->entryVotes = new ArrayCollection();\n        $this->entryComments = new ArrayCollection();\n        $this->entryCommentVotes = new ArrayCollection();\n        $this->posts = new ArrayCollection();\n        $this->postVotes = new ArrayCollection();\n        $this->postComments = new ArrayCollection();\n        $this->postCommentVotes = new ArrayCollection();\n        $this->subscriptions = new ArrayCollection();\n        $this->subscribedDomains = new ArrayCollection();\n        $this->follows = new ArrayCollection();\n        $this->followers = new ArrayCollection();\n        $this->blocks = new ArrayCollection();\n        $this->blockers = new ArrayCollection();\n        $this->blockedMagazines = new ArrayCollection();\n        $this->blockedDomains = new ArrayCollection();\n        $this->reports = new ArrayCollection();\n        $this->favourites = new ArrayCollection();\n        $this->violations = new ArrayCollection();\n        $this->notifications = new ArrayCollection();\n        $this->lastActive = new \\DateTime();\n        $this->createdAtTraitConstruct();\n        $this->oAuth2UserConsents = new ArrayCollection();\n        $this->setApplicationStatus($applicationStatus);\n        $this->applicationText = $applicationText;\n        $this->filterLists = new ArrayCollection();\n    }\n\n    public function getId(): int\n    {\n        return $this->id;\n    }\n\n    public function getApId(): ?string\n    {\n        return $this->apId;\n    }\n\n    public function getUsername(): string\n    {\n        return $this->username;\n    }\n\n    public function getEmail(): string\n    {\n        return $this->email;\n    }\n\n    public function getTotpSecret(): ?string\n    {\n        return $this->totpSecret;\n    }\n\n    public function setOrRemoveAdminRole(bool $remove = false): self\n    {\n        $this->roles = ['ROLE_ADMIN'];\n\n        if ($remove) {\n            $this->roles = [];\n        }\n\n        return $this;\n    }\n\n    public function setOrRemoveModeratorRole(bool $remove = false): self\n    {\n        $this->roles = ['ROLE_MODERATOR'];\n\n        if ($remove) {\n            $this->roles = [];\n        }\n\n        return $this;\n    }\n\n    public function getPassword(): string\n    {\n        return (string) $this->password;\n    }\n\n    public function setPassword(string $password): self\n    {\n        $this->password = $password;\n\n        return $this;\n    }\n\n    public function getSalt(): ?string\n    {\n        // not needed when using the \"bcrypt\" algorithm in security.yaml\n        return null;\n    }\n\n    #[\\Deprecated]\n    public function eraseCredentials(): void\n    {\n        // If you store any temporary, sensitive data on the user, clear it here\n        //         $this->plainPassword = null;\n    }\n\n    public function getModeratedMagazines(): Collection\n    {\n        // Tokens\n        $this->moderatorTokens->get(-1);\n        $criteria = Criteria::create()\n            ->andWhere(Criteria::expr()->eq('isConfirmed', true));\n        $tokens = $this->moderatorTokens->matching($criteria);\n\n        // Magazines\n        $magazines = $tokens->map(fn ($token) => $token->magazine);\n        $criteria = Criteria::create()\n            ->orderBy(['lastActive' => Order::Descending]);\n\n        return $magazines->matching($criteria);\n    }\n\n    public function addEntry(Entry $entry): self\n    {\n        if ($entry->user !== $this) {\n            throw new \\DomainException('Entry must belong to user');\n        }\n\n        if (!$this->entries->contains($entry)) {\n            $this->entries->add($entry);\n        }\n\n        return $this;\n    }\n\n    public function addEntryComment(EntryComment $comment): self\n    {\n        if (!$this->entryComments->contains($comment)) {\n            $this->entryComments->add($comment);\n            $comment->user = $this;\n        }\n\n        return $this;\n    }\n\n    public function addPost(Post $post): self\n    {\n        if ($post->user !== $this) {\n            throw new \\DomainException('Post must belong to user');\n        }\n\n        if (!$this->posts->contains($post)) {\n            $this->posts->add($post);\n        }\n\n        return $this;\n    }\n\n    public function addPostComment(PostComment $comment): self\n    {\n        if (!$this->entryComments->contains($comment)) {\n            $this->entryComments->add($comment);\n            $comment->user = $this;\n        }\n\n        return $this;\n    }\n\n    public function addSubscription(MagazineSubscription $subscription): self\n    {\n        if (!$this->subscriptions->contains($subscription)) {\n            $this->subscriptions->add($subscription);\n            $subscription->setUser($this);\n        }\n\n        return $this;\n    }\n\n    public function removeSubscription(MagazineSubscription $subscription): self\n    {\n        if ($this->subscriptions->removeElement($subscription)) {\n            if ($subscription->user === $this) {\n                $subscription->user = null;\n            }\n        }\n\n        return $this;\n    }\n\n    public function isFollower(User $user): bool\n    {\n        $criteria = Criteria::create()\n            ->where(Criteria::expr()->eq('follower', $this));\n\n        return $user->followers->matching($criteria)->count() > 0;\n    }\n\n    public function follow(User $following): self\n    {\n        $this->unblock($following);\n\n        if (!$this->isFollowing($following)) {\n            $this->followers->add($follower = new UserFollow($this, $following));\n\n            if (!$following->followers->contains($follower)) {\n                $following->followers->add($follower);\n            }\n        }\n\n        $following->updateFollowCounts();\n\n        return $this;\n    }\n\n    public function unblock(User $blocked): void\n    {\n        $criteria = Criteria::create()\n            ->where(Criteria::expr()->eq('blocked', $blocked));\n\n        /**\n         * @var UserBlock $userBlock\n         */\n        $userBlock = $this->blocks->matching($criteria)->first();\n\n        if ($this->blocks->removeElement($userBlock)) {\n            if ($userBlock->blocker === $this) {\n                $blocked->blockers->removeElement($this);\n            }\n        }\n    }\n\n    public function isFollowing(User $user): bool\n    {\n        $criteria = Criteria::create()\n            ->where(Criteria::expr()->eq('following', $user));\n\n        return $this->follows->matching($criteria)->count() > 0;\n    }\n\n    public function updateFollowCounts(): void\n    {\n        if (null !== $this->apFollowersCount) {\n            $criteria = Criteria::create();\n            if ($this->apFetchedAt) {\n                $criteria->where(Criteria::expr()->gt('createdAt', \\DateTimeImmutable::createFromMutable($this->apFetchedAt)));\n            }\n\n            $newFollowers = $this->followers->matching($criteria)->count();\n            $this->followersCount = $this->apFollowersCount + $newFollowers;\n        } else {\n            $this->followersCount = $this->followers->count();\n        }\n    }\n\n    public function unfollow(User $following): void\n    {\n        $followingUser = $following;\n\n        $criteria = Criteria::create()\n            ->where(Criteria::expr()->eq('following', $following));\n\n        /**\n         * @var UserFollow $following\n         */\n        $following = $this->follows->matching($criteria)->first();\n\n        if ($this->follows->removeElement($following)) {\n            if ($following->follower === $this) {\n                $following->follower = null;\n                $followingUser->followers->removeElement($following);\n            }\n        }\n\n        $followingUser->updateFollowCounts();\n    }\n\n    public function toggleTheme(): self\n    {\n        $this->theme = self::THEME_LIGHT === $this->theme ? self::THEME_DARK : self::THEME_LIGHT;\n\n        return $this;\n    }\n\n    public function isBlocker(User $user): bool\n    {\n        $criteria = Criteria::create()\n            ->where(Criteria::expr()->eq('blocker', $user));\n\n        return $user->blockers->matching($criteria)->count() > 0;\n    }\n\n    public function block(User $blocked): self\n    {\n        if (!$this->isBlocked($blocked)) {\n            $this->blocks->add($userBlock = new UserBlock($this, $blocked));\n\n            if (!$blocked->blockers->contains($userBlock)) {\n                $blocked->blockers->add($userBlock);\n            }\n        }\n\n        return $this;\n    }\n\n    /**\n     * Returns whether or not the given user is blocked by the user this method is called on.\n     */\n    public function isBlocked(User $user): bool\n    {\n        $criteria = Criteria::create()\n            ->where(Criteria::expr()->eq('blocked', $user));\n\n        return $this->blocks->matching($criteria)->count() > 0;\n    }\n\n    public function blockMagazine(Magazine $magazine): self\n    {\n        if (!$this->isBlockedMagazine($magazine)) {\n            $this->blockedMagazines->add(new MagazineBlock($this, $magazine));\n        }\n\n        return $this;\n    }\n\n    public function isBlockedMagazine(Magazine $magazine): bool\n    {\n        $criteria = Criteria::create()\n            ->where(Criteria::expr()->eq('magazine', $magazine));\n\n        return $this->blockedMagazines->matching($criteria)->count() > 0;\n    }\n\n    public function unblockMagazine(Magazine $magazine): void\n    {\n        $criteria = Criteria::create()\n            ->where(Criteria::expr()->eq('magazine', $magazine));\n\n        /**\n         * @var MagazineBlock $magazineBlock\n         */\n        $magazineBlock = $this->blockedMagazines->matching($criteria)->first();\n\n        if ($this->blockedMagazines->removeElement($magazineBlock)) {\n            if ($magazineBlock->user === $this) {\n                $magazineBlock->magazine = null;\n                $this->blockedMagazines->removeElement($magazineBlock);\n            }\n        }\n    }\n\n    public function blockDomain(Domain $domain): self\n    {\n        if (!$this->isBlockedDomain($domain)) {\n            $this->blockedDomains->add(new DomainBlock($this, $domain));\n        }\n\n        return $this;\n    }\n\n    public function isBlockedDomain(Domain $domain): bool\n    {\n        $criteria = Criteria::create()\n            ->where(Criteria::expr()->eq('domain', $domain));\n\n        return $this->blockedDomains->matching($criteria)->count() > 0;\n    }\n\n    public function unblockDomain(Domain $domain): void\n    {\n        $criteria = Criteria::create()\n            ->where(Criteria::expr()->eq('domain', $domain));\n\n        /**\n         * @var DomainBlock $domainBlock\n         */\n        $domainBlock = $this->blockedDomains->matching($criteria)->first();\n\n        if ($this->blockedDomains->removeElement($domainBlock)) {\n            if ($domainBlock->user === $this) {\n                $domainBlock->domain = null;\n                $this->blockedMagazines->removeElement($domainBlock);\n            }\n        }\n    }\n\n    public function getNewNotifications(): Collection\n    {\n        return $this->notifications->matching($this->getNewNotificationsCriteria());\n    }\n\n    private function getNewNotificationsCriteria(): Criteria\n    {\n        return Criteria::create()\n            ->where(Criteria::expr()->eq('status', Notification::STATUS_NEW));\n    }\n\n    public function getNewEntryNotifications(User $user, Entry $entry): ?Notification\n    {\n        $criteria = $this->getNewNotificationsCriteria()\n            ->andWhere(Criteria::expr()->eq('user', $user))\n            ->andWhere(Criteria::expr()->eq('entry', $entry))\n            ->andWhere(Criteria::expr()->eq('type', 'new_entry'));\n\n        return $this->notifications->matching($criteria)->first();\n    }\n\n    public function countNewNotifications(): int\n    {\n        return $this->notifications\n            ->matching($this->getNewNotificationsCriteria())\n            ->filter(fn ($notification) => 'message_notification' !== $notification->getType())\n            ->count();\n    }\n\n    public function countNewMessages(): int\n    {\n        $criteria = Criteria::create()\n            ->where(Criteria::expr()->eq('status', Notification::STATUS_NEW));\n\n        return $this->notifications\n            ->matching($criteria)\n            ->filter(fn ($notification) => 'message_notification' === $notification->getType())\n            ->count();\n    }\n\n    public function isAdmin(): bool\n    {\n        return \\in_array('ROLE_ADMIN', $this->getRoles());\n    }\n\n    public function isModerator(): bool\n    {\n        return \\in_array('ROLE_MODERATOR', $this->getRoles());\n    }\n\n    public function getRoles(): array\n    {\n        $roles = $this->roles;\n        // guarantee every user at least has ROLE_USER\n        $roles[] = 'ROLE_USER';\n\n        return array_unique($roles);\n    }\n\n    public function isAccountDeleted(): bool\n    {\n        return $this->isDeleted;\n    }\n\n    public function getUserIdentifier(): string\n    {\n        return $this->username;\n    }\n\n    public function __call(string $name, array $arguments)\n    {\n        // TODO: Implement @method string getUserIdentifier()\n    }\n\n    /**\n     * This method is used by Symfony to determine whether a session needs to be refreshed.\n     * Every security relevant information needs to be in there.\n     * In order to check these parameters you need to add them to the __serialize function.\n     *\n     * @see User::__serialize()\n     */\n    public function isEqualTo(UserInterface $user): bool\n    {\n        $pa = PropertyAccess::createPropertyAccessor();\n        $theirTotpSecret = $pa->getValue($user, 'totpSecret') ?? '';\n\n        return $pa->getValue($user, 'isBanned') === $this->isBanned\n            && $pa->getValue($user, 'isDeleted') === $this->isDeleted\n            && $pa->getValue($user, 'markedForDeletionAt') === $this->markedForDeletionAt\n            && $pa->getValue($user, 'username') === $this->username\n            && $pa->getValue($user, 'password') === $this->password\n            && ($theirTotpSecret === $this->totpSecret || $theirTotpSecret === hash('sha256', $this->totpSecret) || hash('sha256', $theirTotpSecret) === $this->totpSecret);\n    }\n\n    public function getApName(): string\n    {\n        return $this->username;\n    }\n\n    public function isActiveNow(): bool\n    {\n        $delay = new \\DateTime('1 day ago');\n\n        return $this->lastActive > $delay;\n    }\n\n    public function getShowProfileFollowings(): bool\n    {\n        if ($this->apId) {\n            return true;\n        }\n\n        return $this->showProfileFollowings;\n    }\n\n    public function getShowProfileSubscriptions(): bool\n    {\n        if ($this->apId) {\n            return false;\n        }\n\n        return $this->showProfileSubscriptions;\n    }\n\n    /**\n     * @return Collection<int, OAuth2UserConsent>\n     */\n    public function getOAuth2UserConsents(): Collection\n    {\n        return $this->oAuth2UserConsents;\n    }\n\n    public function addOAuth2UserConsent(OAuth2UserConsent $oAuth2UserConsent): self\n    {\n        if (!$this->oAuth2UserConsents->contains($oAuth2UserConsent)) {\n            $this->oAuth2UserConsents->add($oAuth2UserConsent);\n            $oAuth2UserConsent->setUser($this);\n        }\n\n        return $this;\n    }\n\n    public function removeOAuth2UserConsent(OAuth2UserConsent $oAuth2UserConsent): self\n    {\n        if ($this->oAuth2UserConsents->removeElement($oAuth2UserConsent)) {\n            // set the owning side to null (unless already changed)\n            if ($oAuth2UserConsent->getUser() === $this) {\n                $oAuth2UserConsent->setUser(null);\n            }\n        }\n\n        return $this;\n    }\n\n    public function isSsoControlled(): bool\n    {\n        return $this->oauthAzureId || $this->oauthGithubId || $this->oauthGoogleId || $this->oauthDiscordId || $this->oauthFacebookId || $this->oauthKeycloakId || $this->oauthSimpleLoginId || $this->oauthZitadelId || $this->oauthAuthentikId || $this->oauthPrivacyPortalId;\n    }\n\n    public function getCustomCss(): ?string\n    {\n        return $this->customCss;\n    }\n\n    public function setCustomCss(?string $customCss): static\n    {\n        $this->customCss = $customCss;\n\n        return $this;\n    }\n\n    public function setTotpSecret(?string $totpSecret): void\n    {\n        $this->totpSecret = $totpSecret;\n    }\n\n    public function isTotpAuthenticationEnabled(): bool\n    {\n        return (bool) $this->totpSecret;\n    }\n\n    public function getTotpAuthenticationUsername(): string\n    {\n        return $this->username;\n    }\n\n    public function getTotpAuthenticationConfiguration(): ?TotpConfigurationInterface\n    {\n        return new TotpConfiguration($this->totpSecret, TotpConfiguration::ALGORITHM_SHA1, 30, 6);\n    }\n\n    /**\n     * @param string[]|null $codes\n     */\n    public function setBackupCodes(?array $codes): void\n    {\n        $this->totpBackupCodes = $codes;\n    }\n\n    public function isBackupCode(string $code): bool\n    {\n        return \\in_array($code, $this->totpBackupCodes);\n    }\n\n    public function invalidateBackupCode(string $code): void\n    {\n        $this->totpBackupCodes = array_values(\n            array_filter($this->totpBackupCodes, function ($existingCode) use ($code) {\n                return $code !== $existingCode;\n            })\n        );\n    }\n\n    public function softDelete(): void\n    {\n        $this->markedForDeletionAt = new \\DateTime('now + 30days');\n        $this->visibility = VisibilityInterface::VISIBILITY_SOFT_DELETED;\n        $this->isDeleted = true;\n    }\n\n    public function isSoftDeleted(): bool\n    {\n        return self::VISIBILITY_SOFT_DELETED === $this->visibility;\n    }\n\n    public function trash(): void\n    {\n        $this->visibility = self::VISIBILITY_TRASHED;\n    }\n\n    public function isTrashed(): bool\n    {\n        return self::VISIBILITY_TRASHED === $this->visibility;\n    }\n\n    public function restore(): void\n    {\n        $this->markedForDeletionAt = null;\n        $this->visibility = VisibilityInterface::VISIBILITY_VISIBLE;\n        $this->isDeleted = false;\n    }\n\n    public function hasModeratorRequest(Magazine $magazine): bool\n    {\n        $criteria = Criteria::create()\n            ->where(Criteria::expr()->eq('magazine', $magazine));\n\n        return $this->moderatorRequests->matching($criteria)->count() > 0;\n    }\n\n    public function hasMagazineOwnershipRequest(Magazine $magazine): bool\n    {\n        $criteria = Criteria::create()\n            ->where(Criteria::expr()->eq('magazine', $magazine));\n\n        return $this->magazineOwnershipRequests->matching($criteria)->count() > 0;\n    }\n\n    public function getFollowerUrl(ApHttpClientInterface $client, UrlGeneratorInterface $urlGenerator, bool $isRemote): ?string\n    {\n        if ($isRemote) {\n            $actorObject = $client->getActorObject($this->apProfileId);\n            if ($actorObject and isset($actorObject['followers']) and \\is_string($actorObject['followers'])) {\n                return $actorObject['followers'];\n            }\n\n            return null;\n        } else {\n            return $urlGenerator->generate(\n                'ap_user_followers',\n                ['username' => $this->username],\n                UrlGeneratorInterface::ABSOLUTE_URL\n            );\n        }\n    }\n\n    public function canUpdateUser(User $actor): bool\n    {\n        if (null === $this->apId) {\n            return null === $actor->apId && $actor->isAdmin();\n        } else {\n            return $this->apDomain === $actor->apDomain;\n        }\n    }\n\n    public function getApplicationStatus(): EApplicationStatus\n    {\n        return EApplicationStatus::getFromString($this->applicationStatus);\n    }\n\n    public function setApplicationStatus(EApplicationStatus $applicationStatus): void\n    {\n        $this->applicationStatus = $applicationStatus->value;\n    }\n\n    /**\n     * @param User $dmAuthor the author of the direct message\n     *\n     * @return bool whether the $dmAuthor is allowed to send this user a direct message\n     */\n    public function canReceiveDirectMessage(User $dmAuthor): bool\n    {\n        if (EDirectMessageSettings::Everyone->value === $this->directMessageSetting) {\n            return true;\n        } elseif (EDirectMessageSettings::FollowersOnly->value === $this->directMessageSetting) {\n            $criteria = Criteria::create()->where(Criteria::expr()->eq('follower', $dmAuthor));\n\n            return $this->followers->matching($criteria)->count() > 0;\n        } else {\n            return false;\n        }\n    }\n\n    /**\n     * @return UserFilterList[]\n     */\n    public function getCurrentFilterLists(): array\n    {\n        $criteria = Criteria::create()->where(Criteria::expr()->gte('expirationDate', new \\DateTimeImmutable()))\n            ->orWhere(Criteria::expr()->isNull('expirationDate'));\n\n        return $this->filterLists->matching($criteria)->toArray();\n    }\n\n    /**\n     * this is used to check whether the session of a user is valid\n     * if any of these values have changed the user needs to re-login\n     * it should be the same values as the remember-me cookie signature in the security.yaml\n     * also have a look at the isEqualTo function as this stuff needs to be checked there.\n     *\n     * @see User::isEqualTo()\n     */\n    public function __serialize(): array\n    {\n        return [\n            \"\\0\".self::class.\"\\0id\" => $this->id,\n            \"\\0\".self::class.\"\\0username\" => $this->username,\n            \"\\0\".self::class.\"\\0password\" => $this->password,\n            \"\\0\".self::class.\"\\0totpSecret\" => $this->totpSecret ? hash('sha256', $this->totpSecret) : '',\n            \"\\0\".self::class.\"\\0isBanned\" => $this->isBanned,\n            \"\\0\".self::class.\"\\0isDeleted\" => $this->isDeleted,\n            \"\\0\".self::class.\"\\0markedForDeletionAt\" => $this->markedForDeletionAt,\n        ];\n    }\n}\n"
  },
  {
    "path": "src/Entity/UserBlock.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity;\n\nuse App\\Entity\\Traits\\CreatedAtTrait;\nuse Doctrine\\ORM\\Mapping\\Cache;\nuse Doctrine\\ORM\\Mapping\\Column;\nuse Doctrine\\ORM\\Mapping\\Entity;\nuse Doctrine\\ORM\\Mapping\\GeneratedValue;\nuse Doctrine\\ORM\\Mapping\\Id;\nuse Doctrine\\ORM\\Mapping\\JoinColumn;\nuse Doctrine\\ORM\\Mapping\\ManyToOne;\nuse Doctrine\\ORM\\Mapping\\Table;\nuse Doctrine\\ORM\\Mapping\\UniqueConstraint;\n\n#[Entity(repositoryClass: 'App\\Repository\\UserBlockRepository')]\n#[Table]\n#[UniqueConstraint(name: 'user_block_idx', columns: ['blocker_id', 'blocked_id'])]\n#[Cache(usage: 'NONSTRICT_READ_WRITE')]\nclass UserBlock\n{\n    use CreatedAtTrait {\n        CreatedAtTrait::__construct as createdAtTraitConstruct;\n    }\n\n    #[ManyToOne(targetEntity: User::class, inversedBy: 'blocks')]\n    #[JoinColumn(nullable: false, onDelete: 'CASCADE')]\n    public ?User $blocker;\n    #[ManyToOne(targetEntity: User::class, inversedBy: 'blockers')]\n    #[JoinColumn(nullable: false, onDelete: 'CASCADE')]\n    public ?User $blocked;\n    #[Id]\n    #[GeneratedValue]\n    #[Column(type: 'integer')]\n    private int $id;\n\n    public function __construct(User $blocker, User $blocked)\n    {\n        $this->createdAtTraitConstruct();\n\n        $this->blocker = $blocker;\n        $this->blocked = $blocked;\n    }\n\n    public function getId(): ?int\n    {\n        return $this->id;\n    }\n\n    public function __sleep()\n    {\n        return [];\n    }\n}\n"
  },
  {
    "path": "src/Entity/UserFilterList.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity;\n\nuse App\\Entity\\Traits\\CreatedAtTrait;\nuse Doctrine\\DBAL\\Types\\Types;\nuse Doctrine\\ORM\\Mapping\\Column;\nuse Doctrine\\ORM\\Mapping\\Entity;\nuse Doctrine\\ORM\\Mapping\\GeneratedValue;\nuse Doctrine\\ORM\\Mapping\\Id;\nuse Doctrine\\ORM\\Mapping\\JoinColumn;\nuse Doctrine\\ORM\\Mapping\\ManyToOne;\n\n#[Entity]\nclass UserFilterList\n{\n    use CreatedAtTrait;\n\n    #[Column, Id, GeneratedValue]\n    private int $id;\n\n    #[JoinColumn(nullable: false, onDelete: 'CASCADE')]\n    #[ManyToOne(targetEntity: User::class, inversedBy: 'filterLists')]\n    public User $user;\n\n    #[Column]\n    public string $name;\n\n    #[Column(nullable: true)]\n    public ?\\DateTimeImmutable $expirationDate;\n\n    #[Column]\n    public bool $feeds = false;\n\n    #[Column]\n    public bool $profile = false;\n\n    #[Column]\n    public bool $comments = false;\n\n    /**\n     * @var array<array{word: string, exactMatch: bool}> $words\n     */\n    #[Column(type: Types::JSONB)]\n    public array $words = [];\n\n    public function getId(): int\n    {\n        return $this->id;\n    }\n\n    /**\n     * @return string[]\n     */\n    public function getRealmStrings(): array\n    {\n        $res = [];\n        if ($this->feeds) {\n            $res[] = 'feeds';\n        }\n        if ($this->profile) {\n            $res[] = 'profile';\n        }\n        if ($this->comments) {\n            $res[] = 'comments';\n        }\n\n        return $res;\n    }\n\n    public function isExpired(): bool\n    {\n        if (null !== $this->expirationDate) {\n            return $this->expirationDate <= new \\DateTimeImmutable();\n        }\n\n        return false;\n    }\n}\n"
  },
  {
    "path": "src/Entity/UserFollow.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity;\n\nuse App\\Entity\\Traits\\CreatedAtTrait;\nuse Doctrine\\ORM\\Mapping\\Cache;\nuse Doctrine\\ORM\\Mapping\\Column;\nuse Doctrine\\ORM\\Mapping\\Entity;\nuse Doctrine\\ORM\\Mapping\\GeneratedValue;\nuse Doctrine\\ORM\\Mapping\\Id;\nuse Doctrine\\ORM\\Mapping\\JoinColumn;\nuse Doctrine\\ORM\\Mapping\\ManyToOne;\nuse Doctrine\\ORM\\Mapping\\Table;\nuse Doctrine\\ORM\\Mapping\\UniqueConstraint;\n\n#[Entity(repositoryClass: 'App\\Repository\\UserFollowRepository')]\n#[Table]\n#[UniqueConstraint(name: 'user_follows_idx', columns: ['follower_id', 'following_id'])]\n#[Cache(usage: 'NONSTRICT_READ_WRITE')]\nclass UserFollow\n{\n    use CreatedAtTrait {\n        CreatedAtTrait::__construct as createdAtTraitConstruct;\n    }\n\n    #[ManyToOne(targetEntity: User::class, inversedBy: 'follows')]\n    #[JoinColumn(nullable: false, onDelete: 'CASCADE')]\n    public ?User $follower;\n    #[ManyToOne(targetEntity: User::class, inversedBy: 'followers')]\n    #[JoinColumn(nullable: false, onDelete: 'CASCADE')]\n    public ?User $following;\n    #[Id]\n    #[GeneratedValue]\n    #[Column(type: 'integer')]\n    private int $id;\n\n    public function __construct(User $follower, User $following)\n    {\n        $this->createdAtTraitConstruct();\n\n        $this->follower = $follower;\n        $this->following = $following;\n    }\n\n    public function getId(): ?int\n    {\n        return $this->id;\n    }\n\n    public function __sleep()\n    {\n        return [];\n    }\n}\n"
  },
  {
    "path": "src/Entity/UserFollowRequest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity;\n\nuse App\\Entity\\Traits\\CreatedAtTrait;\nuse App\\Repository\\UserFollowRepository;\nuse Doctrine\\ORM\\Mapping\\Column;\nuse Doctrine\\ORM\\Mapping\\Entity;\nuse Doctrine\\ORM\\Mapping\\GeneratedValue;\nuse Doctrine\\ORM\\Mapping\\Id;\nuse Doctrine\\ORM\\Mapping\\JoinColumn;\nuse Doctrine\\ORM\\Mapping\\ManyToOne;\nuse Doctrine\\ORM\\Mapping\\Table;\nuse Doctrine\\ORM\\Mapping\\UniqueConstraint;\n\n#[Entity(repositoryClass: UserFollowRepository::class)]\n#[Table]\n#[UniqueConstraint(name: 'user_follow_requests_idx', columns: ['follower_id', 'following_id'])]\nclass UserFollowRequest\n{\n    use CreatedAtTrait {\n        CreatedAtTrait::__construct as createdAtTraitConstruct;\n    }\n\n    #[ManyToOne(targetEntity: User::class)]\n    #[JoinColumn(nullable: false, onDelete: 'CASCADE')]\n    public ?User $follower;\n    #[ManyToOne(targetEntity: User::class)]\n    #[JoinColumn(nullable: false, onDelete: 'CASCADE')]\n    public ?User $following;\n    #[Id]\n    #[GeneratedValue]\n    #[Column(type: 'integer')]\n    private int $id;\n\n    public function __construct(User $follower, User $following)\n    {\n        $this->createdAtTraitConstruct();\n\n        $this->follower = $follower;\n        $this->following = $following;\n    }\n\n    public function getId(): ?int\n    {\n        return $this->id;\n    }\n\n    public function __sleep()\n    {\n        return [];\n    }\n}\n"
  },
  {
    "path": "src/Entity/UserNote.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity;\n\nuse App\\Entity\\Traits\\CreatedAtTrait;\nuse App\\Repository\\UserNoteRepository;\nuse Doctrine\\ORM\\Mapping\\Column;\nuse Doctrine\\ORM\\Mapping\\Entity;\nuse Doctrine\\ORM\\Mapping\\GeneratedValue;\nuse Doctrine\\ORM\\Mapping\\Id;\nuse Doctrine\\ORM\\Mapping\\JoinColumn;\nuse Doctrine\\ORM\\Mapping\\ManyToOne;\nuse Doctrine\\ORM\\Mapping\\Table;\nuse Doctrine\\ORM\\Mapping\\UniqueConstraint;\n\n#[Entity(repositoryClass: UserNoteRepository::class)]\n#[Table]\n#[UniqueConstraint(name: 'user_noted_idx', columns: ['user_id', 'target_id'])]\nclass UserNote\n{\n    use CreatedAtTrait {\n        CreatedAtTrait::__construct as createdAtTraitConstruct;\n    }\n\n    #[ManyToOne(targetEntity: User::class)]\n    #[JoinColumn(nullable: false, onDelete: 'CASCADE')]\n    public ?User $user;\n    #[ManyToOne(targetEntity: User::class)]\n    #[JoinColumn(nullable: false, onDelete: 'CASCADE')]\n    public ?User $target;\n    #[Column(type: 'text', nullable: false)]\n    public string $body;\n    #[Id]\n    #[GeneratedValue]\n    #[Column(type: 'integer')]\n    private int $id;\n\n    public function __construct(User $user, User $target, string $body)\n    {\n        $this->createdAtTraitConstruct();\n\n        $this->user = $user;\n        $this->target = $target;\n        $this->body = $body;\n    }\n\n    public function getId(): ?int\n    {\n        return $this->id;\n    }\n\n    public function __sleep()\n    {\n        return [];\n    }\n}\n"
  },
  {
    "path": "src/Entity/UserPushSubscription.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity;\n\nuse Doctrine\\ORM\\Mapping\\Column;\nuse Doctrine\\ORM\\Mapping\\Entity;\nuse Doctrine\\ORM\\Mapping\\GeneratedValue;\nuse Doctrine\\ORM\\Mapping\\Id;\nuse Doctrine\\ORM\\Mapping\\JoinColumn;\nuse Doctrine\\ORM\\Mapping\\ManyToOne;\nuse League\\Bundle\\OAuth2ServerBundle\\Model\\AccessToken;\n\n#[Entity]\nclass UserPushSubscription\n{\n    #[Id, GeneratedValue, Column(type: 'integer')]\n    public int $id;\n\n    #[ManyToOne(targetEntity: User::class, cascade: ['persist'], inversedBy: 'pushSubscriptions')]\n    #[JoinColumn(nullable: false, onDelete: 'CASCADE')]\n    public User $user;\n\n    #[Column(type: 'text')]\n    public string $endpoint;\n\n    #[Column(type: 'text')]\n    public string $contentEncryptionPublicKey;\n\n    /**\n     * @var AccessToken|null this is only null for the web interface push messages\n     */\n    #[ManyToOne(targetEntity: AccessToken::class, cascade: ['persist'])]\n    #[JoinColumn(name: 'api_token', referencedColumnName: 'identifier', unique: true, nullable: true, onDelete: 'CASCADE')]\n    public ?AccessToken $apiToken = null;\n\n    #[Column(type: 'string', nullable: true)]\n    public ?string $locale = null;\n\n    #[Column(type: 'uuid', nullable: true)]\n    public ?string $deviceKey = null;\n\n    #[Column(type: 'text')]\n    public string $serverAuthKey;\n\n    /**\n     * @var string[] the identifier of the notifications that this push subscription wants to receive\n     *\n     * @see Notification the discriminator map\n     */\n    #[Column(type: 'json')]\n    public array $notificationTypes = [];\n\n    /**\n     * @param string[] $notifications\n     */\n    public function __construct(User $user, string $endpoint, string $contentEncryptionPublicKey, string $serverAuthKey, array $notifications, ?AccessToken $apiToken = null)\n    {\n        $this->user = $user;\n        $this->endpoint = $endpoint;\n        $this->serverAuthKey = $serverAuthKey;\n        $this->contentEncryptionPublicKey = $contentEncryptionPublicKey;\n        $this->notificationTypes = $notifications;\n        $this->apiToken = $apiToken;\n    }\n}\n"
  },
  {
    "path": "src/Entity/Vote.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Entity;\n\nuse App\\Entity\\Contracts\\VotableInterface;\nuse App\\Entity\\Contracts\\VoteInterface;\nuse App\\Entity\\Traits\\CreatedAtTrait;\nuse Doctrine\\ORM\\Mapping\\Column;\nuse Doctrine\\ORM\\Mapping\\GeneratedValue;\nuse Doctrine\\ORM\\Mapping\\Id;\nuse Doctrine\\ORM\\Mapping\\JoinColumn;\nuse Doctrine\\ORM\\Mapping\\ManyToOne;\nuse Doctrine\\ORM\\Mapping\\MappedSuperclass;\n\n#[MappedSuperclass]\nclass Vote implements VoteInterface\n{\n    use CreatedAtTrait {\n        CreatedAtTrait::__construct as createdAtTraitConstruct;\n    }\n\n    #[Column(type: 'integer', nullable: false)]\n    public int $choice;\n    #[ManyToOne(targetEntity: User::class)]\n    #[JoinColumn(nullable: false, onDelete: 'CASCADE')]\n    public User $user;\n    #[ManyToOne(targetEntity: User::class)]\n    #[JoinColumn(nullable: false, onDelete: 'CASCADE')]\n    public User $author;\n    #[Id]\n    #[GeneratedValue]\n    #[Column(type: 'integer')]\n    protected int $id;\n\n    public function __construct(int $choice, User $user, User $author)\n    {\n        $this->choice = $choice;\n        $this->user = $user;\n        $this->author = $author;\n\n        $this->createdAtTraitConstruct();\n    }\n\n    public function getId(): ?int\n    {\n        return $this->id;\n    }\n\n    public function __sleep()\n    {\n        return [];\n    }\n\n    public function getSubject(): VotableInterface\n    {\n        throw new \\Exception('Not implemented');\n    }\n}\n"
  },
  {
    "path": "src/Enums/EApplicationStatus.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Enums;\n\nenum EApplicationStatus: string\n{\n    case Approved = 'Approved';\n    case Rejected = 'Rejected';\n    case Pending = 'Pending';\n\n    public static function getFromString(string $value): ?EApplicationStatus\n    {\n        return match ($value) {\n            self::Approved->value => self::Approved,\n            self::Rejected->value => self::Rejected,\n            self::Pending->value => self::Pending,\n            default => null,\n        };\n    }\n\n    /**\n     * @return string[]\n     */\n    public static function getValues(): array\n    {\n        return [\n            EApplicationStatus::Approved->value,\n            EApplicationStatus::Rejected->value,\n            EApplicationStatus::Pending->value,\n        ];\n    }\n}\n"
  },
  {
    "path": "src/Enums/EDirectMessageSettings.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Enums;\n\nenum EDirectMessageSettings: string\n{\n    case Everyone = 'everyone';\n    case FollowersOnly = 'followers_only';\n    case Nobody = 'nobody';\n\n    public const array OPTIONS = [\n        EDirectMessageSettings::Everyone->value,\n        EDirectMessageSettings::FollowersOnly->value,\n        EDirectMessageSettings::Nobody->value,\n    ];\n\n    public static function getFromString(string $value): ?EDirectMessageSettings\n    {\n        return match ($value) {\n            self::Everyone->value => self::Everyone,\n            self::FollowersOnly->value => self::FollowersOnly,\n            self::Nobody->value => self::Nobody,\n            default => null,\n        };\n    }\n\n    /**\n     * @return string[]\n     */\n    public static function getValues(): array\n    {\n        return self::OPTIONS;\n    }\n}\n"
  },
  {
    "path": "src/Enums/EFrontContentOptions.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Enums;\n\nenum EFrontContentOptions: string\n{\n    case Combined = 'combined';\n    case Threads = 'threads';\n    case Microblog = 'microblog';\n\n    public const array OPTIONS = [\n        EFrontContentOptions::Combined->value,\n        EFrontContentOptions::Threads->value,\n        EFrontContentOptions::Microblog->value,\n    ];\n\n    /**\n     * @return string[]\n     */\n    public static function getValues(): array\n    {\n        return [\n            ...self::OPTIONS,\n            null,\n        ];\n    }\n}\n"
  },
  {
    "path": "src/Enums/ENotificationStatus.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Enums;\n\nenum ENotificationStatus: string\n{\n    case Default = 'Default';\n    case Muted = 'Muted';\n    case Loud = 'Loud';\n\n    public static function getFromString(string $value): ?ENotificationStatus\n    {\n        return match ($value) {\n            self::Default->value => self::Default,\n            self::Muted->value => self::Muted,\n            self::Loud->value => self::Loud,\n            default => null,\n        };\n    }\n\n    public const Values = [\n        ENotificationStatus::Default->value,\n        ENotificationStatus::Muted->value,\n        ENotificationStatus::Loud->value,\n    ];\n\n    /**\n     * @return string[]\n     */\n    public static function getValues(): array\n    {\n        return self::Values;\n    }\n}\n"
  },
  {
    "path": "src/Enums/EPushNotificationType.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Enums;\n\nenum EPushNotificationType: string\n{\n    case Notification = 'notification';\n    case Message = 'message';\n}\n"
  },
  {
    "path": "src/Enums/ESortOptions.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Enums;\n\nenum ESortOptions: string\n{\n    case Hot = 'hot';\n    case Top = 'top';\n    case Newest = 'newest';\n    case Active = 'active';\n    case Oldest = 'oldest';\n    case Commented = 'commented';\n\n    public static function getFromString(string $value): ?ESortOptions\n    {\n        return match ($value) {\n            self::Hot->value => self::Hot,\n            self::Top->value => self::Top,\n            self::Newest->value => self::Newest,\n            self::Active->value => self::Active,\n            self::Oldest->value => self::Oldest,\n            self::Commented->value => self::Commented,\n            default => null,\n        };\n    }\n\n    /**\n     * @return string[]\n     */\n    public static function getValues(): array\n    {\n        return [\n            ESortOptions::Hot->value,\n            ESortOptions::Top->value,\n            ESortOptions::Newest->value,\n            ESortOptions::Active->value,\n            ESortOptions::Oldest->value,\n            ESortOptions::Commented->value,\n        ];\n    }\n}\n"
  },
  {
    "path": "src/Event/ActivityPub/CurlRequestBeginningEvent.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Event\\ActivityPub;\n\nclass CurlRequestBeginningEvent\n{\n    public function __construct(\n        public string $targetUrl,\n        public string $method = 'GET',\n        public ?string $body = null,\n    ) {\n    }\n}\n"
  },
  {
    "path": "src/Event/ActivityPub/CurlRequestFinishedEvent.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Event\\ActivityPub;\n\nclass CurlRequestFinishedEvent\n{\n    public function __construct(\n        public string $url,\n        public bool $wasSuccessful,\n        public ?string $responseContent = null,\n        public ?\\Throwable $exception = null,\n    ) {\n    }\n}\n"
  },
  {
    "path": "src/Event/ActivityPub/WebfingerResponseEvent.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Event\\ActivityPub;\n\nuse App\\ActivityPub\\JsonRd;\n\nclass WebfingerResponseEvent\n{\n    public function __construct(\n        public JsonRd $jsonRd,\n        public string $subject,\n        public array $params,\n    ) {\n    }\n}\n"
  },
  {
    "path": "src/Event/DomainBlockedEvent.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Event;\n\nuse App\\Entity\\Domain;\nuse App\\Entity\\User;\n\nclass DomainBlockedEvent\n{\n    public function __construct(public Domain $domain, public User $user)\n    {\n    }\n}\n"
  },
  {
    "path": "src/Event/DomainSubscribedEvent.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Event;\n\nuse App\\Entity\\Domain;\nuse App\\Entity\\User;\n\nclass DomainSubscribedEvent\n{\n    public function __construct(public Domain $domain, public User $user)\n    {\n    }\n}\n"
  },
  {
    "path": "src/Event/Entry/EntryBeforeDeletedEvent.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Event\\Entry;\n\nuse App\\Entity\\Entry;\nuse App\\Entity\\User;\n\nclass EntryBeforeDeletedEvent\n{\n    public function __construct(public Entry $entry, public ?User $user = null)\n    {\n    }\n}\n"
  },
  {
    "path": "src/Event/Entry/EntryBeforePurgeEvent.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Event\\Entry;\n\nuse App\\Entity\\Entry;\nuse App\\Entity\\User;\n\nclass EntryBeforePurgeEvent\n{\n    public function __construct(public Entry $entry, public User $user)\n    {\n    }\n}\n"
  },
  {
    "path": "src/Event/Entry/EntryCreatedEvent.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Event\\Entry;\n\nuse App\\Entity\\Entry;\n\nclass EntryCreatedEvent\n{\n    public function __construct(public Entry $entry)\n    {\n    }\n}\n"
  },
  {
    "path": "src/Event/Entry/EntryDeletedEvent.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Event\\Entry;\n\nuse App\\Entity\\Entry;\nuse App\\Entity\\User;\n\nclass EntryDeletedEvent\n{\n    public function __construct(public Entry $entry, public ?User $user = null)\n    {\n    }\n}\n"
  },
  {
    "path": "src/Event/Entry/EntryEditedEvent.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Event\\Entry;\n\nuse App\\Entity\\Entry;\nuse App\\Entity\\User;\n\nclass EntryEditedEvent\n{\n    public function __construct(public Entry $entry, public ?User $editedBy = null)\n    {\n    }\n}\n"
  },
  {
    "path": "src/Event/Entry/EntryHasBeenSeenEvent.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Event\\Entry;\n\nuse App\\Entity\\Entry;\n\nclass EntryHasBeenSeenEvent\n{\n    public function __construct(public Entry $entry)\n    {\n    }\n}\n"
  },
  {
    "path": "src/Event/Entry/EntryLockEvent.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Event\\Entry;\n\nuse App\\Entity\\Entry;\nuse App\\Entity\\User;\n\nclass EntryLockEvent\n{\n    public function __construct(public Entry $entry, public ?User $actor)\n    {\n    }\n}\n"
  },
  {
    "path": "src/Event/Entry/EntryPinEvent.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Event\\Entry;\n\nuse App\\Entity\\Entry;\nuse App\\Entity\\User;\n\nclass EntryPinEvent\n{\n    public function __construct(public Entry $entry, public ?User $actor)\n    {\n    }\n}\n"
  },
  {
    "path": "src/Event/Entry/EntryRestoredEvent.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Event\\Entry;\n\nuse App\\Entity\\Entry;\nuse App\\Entity\\User;\n\nclass EntryRestoredEvent\n{\n    public function __construct(public Entry $entry, public ?User $user = null)\n    {\n    }\n}\n"
  },
  {
    "path": "src/Event/Entry/PostLockEvent.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Event\\Entry;\n\nuse App\\Entity\\Post;\nuse App\\Entity\\User;\n\nclass PostLockEvent\n{\n    public function __construct(public Post $post, public ?User $actor)\n    {\n    }\n}\n"
  },
  {
    "path": "src/Event/EntryComment/EntryCommentBeforeDeletedEvent.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Event\\EntryComment;\n\nuse App\\Entity\\EntryComment;\nuse App\\Entity\\User;\n\nclass EntryCommentBeforeDeletedEvent\n{\n    public function __construct(public EntryComment $comment, public ?User $user = null)\n    {\n    }\n}\n"
  },
  {
    "path": "src/Event/EntryComment/EntryCommentBeforePurgeEvent.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Event\\EntryComment;\n\nuse App\\Entity\\EntryComment;\nuse App\\Entity\\User;\n\nclass EntryCommentBeforePurgeEvent\n{\n    public function __construct(public EntryComment $comment, public User $user)\n    {\n    }\n}\n"
  },
  {
    "path": "src/Event/EntryComment/EntryCommentCreatedEvent.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Event\\EntryComment;\n\nuse App\\Entity\\EntryComment;\n\nclass EntryCommentCreatedEvent\n{\n    public function __construct(public EntryComment $comment)\n    {\n    }\n}\n"
  },
  {
    "path": "src/Event/EntryComment/EntryCommentDeletedEvent.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Event\\EntryComment;\n\nuse App\\Entity\\EntryComment;\nuse App\\Entity\\User;\n\nclass EntryCommentDeletedEvent\n{\n    public function __construct(public EntryComment $comment, public ?User $user = null)\n    {\n    }\n}\n"
  },
  {
    "path": "src/Event/EntryComment/EntryCommentEditedEvent.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Event\\EntryComment;\n\nuse App\\Entity\\EntryComment;\nuse App\\Entity\\User;\n\nclass EntryCommentEditedEvent\n{\n    public function __construct(public EntryComment $comment, public ?User $editedByUser = null)\n    {\n    }\n}\n"
  },
  {
    "path": "src/Event/EntryComment/EntryCommentPurgedEvent.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Event\\EntryComment;\n\nuse App\\Entity\\Magazine;\n\nclass EntryCommentPurgedEvent\n{\n    public function __construct(public Magazine $magazine)\n    {\n    }\n}\n"
  },
  {
    "path": "src/Event/EntryComment/EntryCommentRestoredEvent.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Event\\EntryComment;\n\nuse App\\Entity\\EntryComment;\nuse App\\Entity\\User;\n\nclass EntryCommentRestoredEvent\n{\n    public function __construct(public EntryComment $comment, public ?User $user = null)\n    {\n    }\n}\n"
  },
  {
    "path": "src/Event/FavouriteEvent.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Event;\n\nuse App\\Entity\\Contracts\\FavouriteInterface;\nuse App\\Entity\\User;\n\nclass FavouriteEvent\n{\n    public function __construct(public FavouriteInterface $subject, public User $user, public bool $removeLike = false)\n    {\n    }\n}\n"
  },
  {
    "path": "src/Event/ImagePostProcessEvent.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Event;\n\nuse App\\Utils\\ImageOrigin;\nuse Symfony\\Contracts\\EventDispatcher\\Event;\n\nclass ImagePostProcessEvent extends Event\n{\n    public function __construct(\n        public string $source,\n        public string $targetFilePath,\n        public ImageOrigin $origin,\n    ) {\n    }\n}\n"
  },
  {
    "path": "src/Event/Instance/InstanceBanEvent.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Event\\Instance;\n\nuse App\\Entity\\User;\n\nclass InstanceBanEvent\n{\n    public function __construct(public User $bannedUser, public ?User $bannedByUser, public ?string $reason)\n    {\n    }\n}\n"
  },
  {
    "path": "src/Event/Magazine/MagazineBanEvent.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Event\\Magazine;\n\nuse App\\Entity\\MagazineBan;\n\nclass MagazineBanEvent\n{\n    public function __construct(public MagazineBan $ban)\n    {\n    }\n}\n"
  },
  {
    "path": "src/Event/Magazine/MagazineBlockedEvent.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Event\\Magazine;\n\nuse App\\Entity\\Magazine;\nuse App\\Entity\\User;\n\nclass MagazineBlockedEvent\n{\n    public function __construct(public Magazine $magazine, public User $user)\n    {\n    }\n}\n"
  },
  {
    "path": "src/Event/Magazine/MagazineModeratorAddedEvent.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Event\\Magazine;\n\nuse App\\Entity\\Magazine;\nuse App\\Entity\\User;\n\nclass MagazineModeratorAddedEvent\n{\n    public function __construct(public Magazine $magazine, public User $user, public ?User $addedBy)\n    {\n    }\n}\n"
  },
  {
    "path": "src/Event/Magazine/MagazineModeratorRemovedEvent.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Event\\Magazine;\n\nuse App\\Entity\\Magazine;\nuse App\\Entity\\User;\n\nclass MagazineModeratorRemovedEvent\n{\n    public function __construct(public Magazine $magazine, public User $user, public ?User $removedBy)\n    {\n    }\n}\n"
  },
  {
    "path": "src/Event/Magazine/MagazineSubscribedEvent.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Event\\Magazine;\n\nuse App\\Entity\\Magazine;\nuse App\\Entity\\User;\n\nclass MagazineSubscribedEvent\n{\n    public function __construct(public Magazine $magazine, public User $user, public $unfollow = false)\n    {\n    }\n}\n"
  },
  {
    "path": "src/Event/Magazine/MagazineUpdatedEvent.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Event\\Magazine;\n\nuse App\\Entity\\Magazine;\nuse App\\Entity\\User;\n\nclass MagazineUpdatedEvent\n{\n    public function __construct(\n        public Magazine $magazine,\n        public User $editedBy,\n    ) {\n    }\n}\n"
  },
  {
    "path": "src/Event/NotificationCreatedEvent.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Event;\n\nuse App\\Entity\\Notification;\nuse Symfony\\Contracts\\EventDispatcher\\Event;\n\nclass NotificationCreatedEvent extends Event\n{\n    public function __construct(\n        public Notification $notification,\n    ) {\n    }\n}\n"
  },
  {
    "path": "src/Event/Post/PostBeforeDeletedEvent.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Event\\Post;\n\nuse App\\Entity\\Post;\nuse App\\Entity\\User;\n\nclass PostBeforeDeletedEvent\n{\n    public function __construct(public Post $post, public ?User $user = null)\n    {\n    }\n}\n"
  },
  {
    "path": "src/Event/Post/PostBeforePurgeEvent.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Event\\Post;\n\nuse App\\Entity\\Post;\nuse App\\Entity\\User;\n\nclass PostBeforePurgeEvent\n{\n    public function __construct(public Post $post, public User $user)\n    {\n    }\n}\n"
  },
  {
    "path": "src/Event/Post/PostCreatedEvent.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Event\\Post;\n\nuse App\\Entity\\Post;\n\nclass PostCreatedEvent\n{\n    public function __construct(public Post $post)\n    {\n    }\n}\n"
  },
  {
    "path": "src/Event/Post/PostDeletedEvent.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Event\\Post;\n\nuse App\\Entity\\Post;\nuse App\\Entity\\User;\n\nclass PostDeletedEvent\n{\n    public function __construct(public Post $post, public ?User $user = null)\n    {\n    }\n}\n"
  },
  {
    "path": "src/Event/Post/PostEditedEvent.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Event\\Post;\n\nuse App\\Entity\\Post;\nuse App\\Entity\\User;\n\nclass PostEditedEvent\n{\n    public function __construct(public Post $post, public ?User $editedBy = null)\n    {\n    }\n}\n"
  },
  {
    "path": "src/Event/Post/PostHasBeenSeenEvent.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Event\\Post;\n\nuse App\\Entity\\Post;\n\nclass PostHasBeenSeenEvent\n{\n    public function __construct(public Post $post)\n    {\n    }\n}\n"
  },
  {
    "path": "src/Event/Post/PostRestoredEvent.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Event\\Post;\n\nuse App\\Entity\\Post;\nuse App\\Entity\\User;\n\nclass PostRestoredEvent\n{\n    public function __construct(public Post $post, public ?User $user = null)\n    {\n    }\n}\n"
  },
  {
    "path": "src/Event/PostComment/PostCommentBeforeDeletedEvent.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Event\\PostComment;\n\nuse App\\Entity\\PostComment;\nuse App\\Entity\\User;\n\nclass PostCommentBeforeDeletedEvent\n{\n    public function __construct(public PostComment $comment, public ?User $user = null)\n    {\n    }\n}\n"
  },
  {
    "path": "src/Event/PostComment/PostCommentBeforePurgeEvent.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Event\\PostComment;\n\nuse App\\Entity\\PostComment;\nuse App\\Entity\\User;\n\nclass PostCommentBeforePurgeEvent\n{\n    public function __construct(public PostComment $comment, public User $user)\n    {\n    }\n}\n"
  },
  {
    "path": "src/Event/PostComment/PostCommentCreatedEvent.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Event\\PostComment;\n\nuse App\\Entity\\PostComment;\n\nclass PostCommentCreatedEvent\n{\n    public function __construct(public PostComment $comment)\n    {\n    }\n}\n"
  },
  {
    "path": "src/Event/PostComment/PostCommentDeletedEvent.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Event\\PostComment;\n\nuse App\\Entity\\PostComment;\nuse App\\Entity\\User;\n\nclass PostCommentDeletedEvent\n{\n    public function __construct(public PostComment $comment, public ?User $user = null)\n    {\n    }\n}\n"
  },
  {
    "path": "src/Event/PostComment/PostCommentEditedEvent.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Event\\PostComment;\n\nuse App\\Entity\\PostComment;\nuse App\\Entity\\User;\n\nclass PostCommentEditedEvent\n{\n    public function __construct(public PostComment $comment, public ?User $editedBy = null)\n    {\n    }\n}\n"
  },
  {
    "path": "src/Event/PostComment/PostCommentPurgedEvent.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Event\\PostComment;\n\nuse App\\Entity\\Magazine;\n\nclass PostCommentPurgedEvent\n{\n    public function __construct(public Magazine $magazine)\n    {\n    }\n}\n"
  },
  {
    "path": "src/Event/PostComment/PostCommentRestoredEvent.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Event\\PostComment;\n\nuse App\\Entity\\PostComment;\nuse App\\Entity\\User;\n\nclass PostCommentRestoredEvent\n{\n    public function __construct(public PostComment $comment, public ?User $user = null)\n    {\n    }\n}\n"
  },
  {
    "path": "src/Event/Report/ReportApprovedEvent.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Event\\Report;\n\nuse App\\Entity\\Report;\n\nclass ReportApprovedEvent\n{\n    public function __construct(public Report $report)\n    {\n    }\n}\n"
  },
  {
    "path": "src/Event/Report/ReportRejectedEvent.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Event\\Report;\n\nuse App\\Entity\\Report;\n\nclass ReportRejectedEvent\n{\n    public function __construct(public Report $report)\n    {\n    }\n}\n"
  },
  {
    "path": "src/Event/Report/SubjectReportedEvent.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Event\\Report;\n\nuse App\\Entity\\Report;\n\nclass SubjectReportedEvent\n{\n    public function __construct(public Report $report)\n    {\n    }\n}\n"
  },
  {
    "path": "src/Event/User/UserApplicationApprovedEvent.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Event\\User;\n\nuse App\\Entity\\User;\n\nclass UserApplicationApprovedEvent\n{\n    public function __construct(\n        public readonly User $user,\n    ) {\n    }\n}\n"
  },
  {
    "path": "src/Event/User/UserApplicationRejectedEvent.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Event\\User;\n\nuse App\\Entity\\User;\n\nclass UserApplicationRejectedEvent\n{\n    public function __construct(\n        public readonly User $user,\n    ) {\n    }\n}\n"
  },
  {
    "path": "src/Event/User/UserBlockEvent.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Event\\User;\n\nuse App\\Entity\\User;\n\nclass UserBlockEvent\n{\n    public function __construct(public User $blocker, public User $blocked)\n    {\n    }\n}\n"
  },
  {
    "path": "src/Event/User/UserEditedEvent.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Event\\User;\n\nclass UserEditedEvent\n{\n    public function __construct(\n        public int $userId,\n    ) {\n    }\n}\n"
  },
  {
    "path": "src/Event/User/UserFollowEvent.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Event\\User;\n\nuse App\\Entity\\User;\n\nclass UserFollowEvent\n{\n    public function __construct(public User $follower, public User $following, public $unfollow = false)\n    {\n    }\n}\n"
  },
  {
    "path": "src/Event/VoteEvent.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Event;\n\nuse App\\Entity\\Contracts\\VotableInterface;\nuse App\\Entity\\Vote;\n\nclass VoteEvent\n{\n    public function __construct(\n        public VotableInterface $votable,\n        public Vote $vote,\n        public bool $votedAgain,\n        ?string $apId = null,\n    ) {\n    }\n}\n"
  },
  {
    "path": "src/EventListener/ContentNotificationPurgeListener.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\EventListener;\n\nuse App\\Entity\\Entry;\nuse App\\Entity\\EntryComment;\nuse App\\Entity\\Post;\nuse App\\Entity\\PostComment;\nuse App\\Service\\Notification\\EntryCommentNotificationManager;\nuse App\\Service\\Notification\\EntryNotificationManager;\nuse App\\Service\\Notification\\PostCommentNotificationManager;\nuse App\\Service\\Notification\\PostNotificationManager;\nuse Doctrine\\Persistence\\Event\\LifecycleEventArgs;\n\nreadonly class ContentNotificationPurgeListener\n{\n    public function __construct(\n        private EntryNotificationManager $entryManager,\n        private EntryCommentNotificationManager $entryCommentManager,\n        private PostNotificationManager $postManager,\n        private PostCommentNotificationManager $postCommentManager,\n    ) {\n    }\n\n    public function preRemove(LifecycleEventArgs $args): void\n    {\n        $object = $args->getObject();\n\n        switch ($object) {\n            case $object instanceof Entry:\n                $this->entryManager->purgeNotifications($object);\n                $this->entryManager->purgeMagazineLog($object);\n                break;\n            case $object instanceof EntryComment:\n                $this->entryCommentManager->purgeNotifications($object);\n                $this->entryCommentManager->purgeMagazineLog($object);\n                break;\n            case $object instanceof Post:\n                $this->postManager->purgeNotifications($object);\n                $this->postManager->purgeMagazineLog($object);\n                break;\n            case $object instanceof PostComment:\n                $this->postCommentManager->purgeNotifications($object);\n                $this->postCommentManager->purgeMagazineLog($object);\n                break;\n        }\n    }\n}\n"
  },
  {
    "path": "src/EventListener/FederationStatusListener.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\EventListener;\n\nuse App\\Service\\SettingsManager;\nuse Symfony\\Component\\HttpKernel\\Event\\ControllerEvent;\nuse Symfony\\Component\\HttpKernel\\Exception\\NotFoundHttpException;\n\nclass FederationStatusListener\n{\n    public function __construct(private readonly SettingsManager $settingsManager)\n    {\n    }\n\n    public function onKernelController(ControllerEvent $event)\n    {\n        if (!$event->isMainRequest() || $this->settingsManager->get('KBIN_FEDERATION_ENABLED')) {\n            return;\n        }\n\n        $route = $event->getRequest()->attributes->get('_route');\n\n        if (str_starts_with($route, 'ap_') && 'ap_node_info' !== $route && 'ap_node_info_v2' !== $route) {\n            throw new NotFoundHttpException();\n        }\n    }\n}\n"
  },
  {
    "path": "src/EventListener/LanguageListener.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\EventListener;\n\nuse Symfony\\Component\\HttpKernel\\Event\\RequestEvent;\n\nclass LanguageListener\n{\n    public function __construct(public string $lang)\n    {\n    }\n\n    public function onKernelRequest(RequestEvent $event): void\n    {\n        $request = $event->getRequest();\n\n        if ($request->cookies->has('mbin_lang')) {\n            $request->setLocale($request->cookies->get('mbin_lang'));\n\n            return;\n        }\n\n        if (!isset($_SERVER['HTTP_ACCEPT_LANGUAGE'])) {\n            $request->setLocale($this->lang);\n\n            return;\n        }\n\n        $lang = substr($_SERVER['HTTP_ACCEPT_LANGUAGE'], 0, 2);\n\n        $request->setLocale($lang);\n        $request->setDefaultLocale($lang);\n    }\n}\n"
  },
  {
    "path": "src/EventListener/MagazineVisibilityListener.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\EventListener;\n\nuse App\\Entity\\Contracts\\VisibilityInterface;\nuse App\\Entity\\Magazine;\nuse Symfony\\Bundle\\SecurityBundle\\Security;\nuse Symfony\\Component\\HttpKernel\\Event\\ControllerArgumentsEvent;\nuse Symfony\\Component\\HttpKernel\\Exception\\NotFoundHttpException;\n\nclass MagazineVisibilityListener\n{\n    public function __construct(private readonly Security $security)\n    {\n    }\n\n    public function onKernelControllerArguments(ControllerArgumentsEvent $event): void\n    {\n        $magazine = array_filter($event->getArguments(), fn ($argument) => $argument instanceof Magazine);\n\n        if (!$magazine) {\n            return;\n        }\n\n        $magazine = array_values($magazine)[0];\n\n        if (VisibilityInterface::VISIBILITY_VISIBLE !== $magazine->visibility) {\n            if (null === $this->security->getUser()\n                || (false === $magazine->userIsOwner($this->security->getUser())\n                && false === $this->security->isGranted('ROLE_ADMIN')\n                && false === $this->security->isGranted('ROLE_MODERATOR'))) {\n                throw new NotFoundHttpException();\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "src/EventListener/UserActivityListener.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\EventListener;\n\nuse App\\Entity\\User;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse JetBrains\\PhpStorm\\NoReturn;\nuse Symfony\\Bundle\\SecurityBundle\\Security;\nuse Symfony\\Component\\HttpKernel\\Event\\ControllerEvent;\nuse Symfony\\Component\\HttpKernel\\HttpKernelInterface;\n\nclass UserActivityListener\n{\n    public function __construct(private Security $security, private EntityManagerInterface $entityManager)\n    {\n    }\n\n    #[NoReturn]\n    public function onKernelController(ControllerEvent $event): void\n    {\n        if (HttpKernelInterface::MAIN_REQUEST !== $event->getRequestType()) {\n            return;\n        }\n\n        if ($this->security->getToken()) {\n            $user = $this->security->getToken()->getUser();\n\n            if (($user instanceof User) && !$user->isActiveNow()) {\n                $user->lastActive = new \\DateTime();\n                $this->entityManager->flush();\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "src/EventSubscriber/ActivityPub/GroupWebFingerProfileSubscriber.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\EventSubscriber\\ActivityPub;\n\nuse App\\ActivityPub\\JsonRdLink;\nuse App\\Entity\\Magazine;\nuse App\\Event\\ActivityPub\\WebfingerResponseEvent;\nuse App\\Repository\\MagazineRepository;\nuse App\\Service\\ActivityPub\\Webfinger\\WebFingerParameters;\nuse JetBrains\\PhpStorm\\ArrayShape;\nuse Symfony\\Component\\EventDispatcher\\EventSubscriberInterface;\nuse Symfony\\Component\\Routing\\Generator\\UrlGeneratorInterface;\n\nclass GroupWebFingerProfileSubscriber implements EventSubscriberInterface\n{\n    public function __construct(\n        private readonly MagazineRepository $magazineRepository,\n        private readonly UrlGeneratorInterface $urlGenerator,\n    ) {\n    }\n\n    #[ArrayShape([WebfingerResponseEvent::class => 'string'])]\n    public static function getSubscribedEvents(): array\n    {\n        return [\n            WebfingerResponseEvent::class => ['buildResponse', 999],\n        ];\n    }\n\n    public function buildResponse(WebfingerResponseEvent $event): void\n    {\n        $params = $event->params;\n        $jsonRd = $event->jsonRd;\n\n        if (\n            isset($params[WebFingerParameters::ACCOUNT_KEY_NAME])\n            && $actor = $this->getActor($params[WebFingerParameters::ACCOUNT_KEY_NAME])\n        ) {\n            $accountHref = $this->urlGenerator->generate(\n                'ap_magazine',\n                ['name' => $actor->name],\n                UrlGeneratorInterface::ABSOLUTE_URL\n            );\n\n            $link = new JsonRdLink();\n            $link->setRel('self')\n                ->setType('application/activity+json')\n                ->setHref($accountHref);\n            $jsonRd->addLink($link);\n        }\n    }\n\n    protected function getActor($name): ?Magazine\n    {\n        if ('random' === $name) {\n            return null;\n        }\n\n        return $this->magazineRepository->findOneByName($name);\n    }\n}\n"
  },
  {
    "path": "src/EventSubscriber/ActivityPub/GroupWebFingerSubscriber.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\EventSubscriber\\ActivityPub;\n\nuse App\\ActivityPub\\JsonRdLink;\nuse App\\Entity\\Magazine;\nuse App\\Event\\ActivityPub\\WebfingerResponseEvent;\nuse App\\Repository\\MagazineRepository;\nuse App\\Service\\ActivityPub\\Webfinger\\WebFingerParameters;\nuse JetBrains\\PhpStorm\\ArrayShape;\nuse Symfony\\Component\\EventDispatcher\\EventSubscriberInterface;\nuse Symfony\\Component\\Routing\\Generator\\UrlGeneratorInterface;\n\nclass GroupWebFingerSubscriber implements EventSubscriberInterface\n{\n    public function __construct(\n        private readonly MagazineRepository $magazineRepository,\n        private readonly UrlGeneratorInterface $urlGenerator,\n    ) {\n    }\n\n    #[ArrayShape([WebfingerResponseEvent::class => 'string'])]\n    public static function getSubscribedEvents(): array\n    {\n        return [\n            WebfingerResponseEvent::class => ['buildResponse', 195],\n        ];\n    }\n\n    public function buildResponse(WebfingerResponseEvent $event): void\n    {\n        $params = $event->params;\n        $jsonRd = $event->jsonRd;\n\n        $subject = $event->subject;\n        if (!empty($subject)) {\n            $jsonRd->setSubject($subject);\n        }\n\n        if (\n            isset($params[WebFingerParameters::ACCOUNT_KEY_NAME])\n            && $actor = $this->getActor($params[WebFingerParameters::ACCOUNT_KEY_NAME])\n        ) {\n            $accountHref = $this->urlGenerator->generate(\n                'ap_magazine',\n                ['name' => $actor->name],\n                UrlGeneratorInterface::ABSOLUTE_URL\n            );\n\n            $jsonRd->addAlias($accountHref);\n            $link = new JsonRdLink();\n            $link->setRel('https://webfinger.net/rel/profile-page')\n                ->setType('text/html')\n                ->setHref($accountHref);\n            $jsonRd->addLink($link);\n        }\n    }\n\n    protected function getActor($name): ?Magazine\n    {\n        if ('random' === $name) {\n            return null;\n        }\n\n        return $this->magazineRepository->findOneByName($name);\n    }\n}\n"
  },
  {
    "path": "src/EventSubscriber/ActivityPub/MagazineFollowSubscriber.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\EventSubscriber\\ActivityPub;\n\nuse App\\Event\\Magazine\\MagazineSubscribedEvent;\nuse App\\Message\\ActivityPub\\Outbox\\FollowMessage;\nuse App\\Utils\\SqlHelpers;\nuse JetBrains\\PhpStorm\\ArrayShape;\nuse Symfony\\Component\\EventDispatcher\\EventSubscriberInterface;\nuse Symfony\\Component\\Messenger\\MessageBusInterface;\n\nclass MagazineFollowSubscriber implements EventSubscriberInterface\n{\n    public function __construct(\n        private readonly MessageBusInterface $bus,\n        private readonly SqlHelpers $sqlHelpers,\n    ) {\n    }\n\n    #[ArrayShape([MagazineSubscribedEvent::class => 'string'])]\n    public static function getSubscribedEvents(): array\n    {\n        return [\n            MagazineSubscribedEvent::class => 'onMagazineFollow',\n        ];\n    }\n\n    public function onMagazineFollow(MagazineSubscribedEvent $event): void\n    {\n        $this->sqlHelpers->clearCachedUserSubscribedMagazines($event->user);\n\n        if ($event->magazine->apId && !$event->user->apId) {\n            $this->bus->dispatch(\n                new FollowMessage($event->user->getId(), $event->magazine->getId(), $event->unfollow, true)\n            );\n        }\n    }\n}\n"
  },
  {
    "path": "src/EventSubscriber/ActivityPub/MagazineModeratorAddedRemovedSubscriber.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\EventSubscriber\\ActivityPub;\n\nuse App\\Entity\\Magazine;\nuse App\\Entity\\MagazineLogModeratorAdd;\nuse App\\Entity\\MagazineLogModeratorRemove;\nuse App\\Event\\Magazine\\MagazineModeratorAddedEvent;\nuse App\\Event\\Magazine\\MagazineModeratorRemovedEvent;\nuse App\\Message\\ActivityPub\\Outbox\\AddMessage;\nuse App\\Message\\ActivityPub\\Outbox\\RemoveMessage;\nuse App\\Utils\\SqlHelpers;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Psr\\Cache\\InvalidArgumentException;\nuse Psr\\Log\\LoggerInterface;\nuse Symfony\\Component\\EventDispatcher\\EventSubscriberInterface;\nuse Symfony\\Component\\Messenger\\MessageBusInterface;\nuse Symfony\\Contracts\\Cache\\CacheInterface;\n\nclass MagazineModeratorAddedRemovedSubscriber implements EventSubscriberInterface\n{\n    public function __construct(\n        private readonly MessageBusInterface $bus,\n        private readonly CacheInterface $cache,\n        private readonly LoggerInterface $logger,\n        private readonly EntityManagerInterface $entityManager,\n        private readonly SqlHelpers $sqlHelpers,\n    ) {\n    }\n\n    public function onModeratorAdded(MagazineModeratorAddedEvent $event): void\n    {\n        $this->sqlHelpers->clearCachedUserModeratedMagazines($event->user);\n\n        // if the magazine is local then we have authority over it, otherwise the addedBy user has to be a local user\n        if (!$event->magazine->apId or (null !== $event->addedBy and !$event->addedBy->apId)) {\n            $this->bus->dispatch(new AddMessage($event->addedBy->getId(), $event->magazine->getId(), $event->user->getId()));\n        }\n        $log = new MagazineLogModeratorAdd($event->magazine, $event->user, $event->addedBy);\n        $this->entityManager->persist($log);\n        $this->entityManager->flush();\n        $this->deleteCache($event->magazine);\n    }\n\n    public function onModeratorRemoved(MagazineModeratorRemovedEvent $event): void\n    {\n        $this->sqlHelpers->clearCachedUserModeratedMagazines($event->user);\n\n        // if the magazine is local then we have authority over it, otherwise the removedBy user has to be a local user\n        if (!$event->magazine->apId or (null !== $event->removedBy and !$event->removedBy->apId)) {\n            $this->bus->dispatch(new RemoveMessage($event->removedBy->getId(), $event->magazine->getId(), $event->user->getId()));\n        }\n        $log = new MagazineLogModeratorRemove($event->magazine, $event->user, $event->removedBy);\n        $this->entityManager->persist($log);\n        $this->entityManager->flush();\n        $this->deleteCache($event->magazine);\n    }\n\n    public static function getSubscribedEvents(): array\n    {\n        return [\n            MagazineModeratorAddedEvent::class => 'onModeratorAdded',\n            MagazineModeratorRemovedEvent::class => 'onModeratorRemoved',\n        ];\n    }\n\n    private function deleteCache(Magazine $magazine): void\n    {\n        if (!$magazine->apId) {\n            return;\n        }\n\n        try {\n            $this->cache->delete('ap_'.hash('sha256', $magazine->apProfileId));\n            $this->cache->delete('ap_'.hash('sha256', $magazine->apId));\n            if (null !== $magazine->apAttributedToUrl) {\n                $this->cache->delete('ap_'.hash('sha256', $magazine->apAttributedToUrl));\n                $this->cache->delete('ap_collection'.hash('sha256', $magazine->apAttributedToUrl));\n            }\n        } catch (InvalidArgumentException $e) {\n            $this->logger->warning(\"There was an error while clearing the cache for magazine '{$magazine->name}' ({$magazine->getId()})\");\n        }\n    }\n}\n"
  },
  {
    "path": "src/EventSubscriber/ActivityPub/UserFollowSubscriber.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\EventSubscriber\\ActivityPub;\n\nuse App\\Event\\User\\UserFollowEvent;\nuse App\\Message\\ActivityPub\\Outbox\\FollowMessage;\nuse App\\Utils\\SqlHelpers;\nuse JetBrains\\PhpStorm\\ArrayShape;\nuse Symfony\\Component\\EventDispatcher\\EventSubscriberInterface;\nuse Symfony\\Component\\Messenger\\MessageBusInterface;\nuse Symfony\\Contracts\\Cache\\CacheInterface;\n\nclass UserFollowSubscriber implements EventSubscriberInterface\n{\n    public function __construct(\n        private readonly MessageBusInterface $bus,\n        private readonly CacheInterface $cache,\n        private readonly SqlHelpers $sqlHelpers,\n    ) {\n    }\n\n    #[ArrayShape([UserFollowEvent::class => 'string'])]\n    public static function getSubscribedEvents(): array\n    {\n        return [\n            UserFollowEvent::class => 'onUserFollow',\n        ];\n    }\n\n    public function onUserFollow(UserFollowEvent $event): void\n    {\n        $this->sqlHelpers->clearCachedUserFollows($event->follower);\n\n        if (!$event->follower->apId && $event->following->apId) {\n            $this->bus->dispatch(\n                new FollowMessage($event->follower->getId(), $event->following->getId(), $event->unfollow)\n            );\n        }\n\n        $this->cache->invalidateTags(['user_follow_'.$event->follower->getId()]);\n    }\n}\n"
  },
  {
    "path": "src/EventSubscriber/ActivityPub/UserWebFingerProfileSubscriber.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\EventSubscriber\\ActivityPub;\n\nuse App\\ActivityPub\\JsonRdLink;\nuse App\\Event\\ActivityPub\\WebfingerResponseEvent;\nuse App\\Repository\\UserRepository;\nuse App\\Service\\ActivityPub\\Webfinger\\WebFingerParameters;\nuse App\\Service\\ImageManagerInterface;\nuse App\\Service\\SettingsManager;\nuse JetBrains\\PhpStorm\\ArrayShape;\nuse Psr\\Log\\LoggerInterface;\nuse Symfony\\Component\\EventDispatcher\\EventSubscriberInterface;\nuse Symfony\\Component\\Routing\\Generator\\UrlGeneratorInterface;\nuse Symfony\\Component\\Security\\Core\\User\\UserInterface;\n\nclass UserWebFingerProfileSubscriber implements EventSubscriberInterface\n{\n    public function __construct(\n        private readonly UserRepository $userRepository,\n        private readonly UrlGeneratorInterface $urlGenerator,\n        private readonly SettingsManager $settingsManager,\n        private readonly LoggerInterface $logger,\n        private readonly ImageManagerInterface $imageManager,\n    ) {\n    }\n\n    #[ArrayShape([WebfingerResponseEvent::class => 'string'])]\n    public static function getSubscribedEvents(): array\n    {\n        return [\n            WebfingerResponseEvent::class => ['buildResponse', 999],\n        ];\n    }\n\n    public function buildResponse(WebfingerResponseEvent $event): void\n    {\n        $params = $event->params;\n        $jsonRd = $event->jsonRd;\n\n        if (isset($params[WebFingerParameters::ACCOUNT_KEY_NAME])) {\n            $query = $params[WebFingerParameters::ACCOUNT_KEY_NAME];\n            $this->logger->debug(\"got webfinger query for $query\");\n\n            $domain = $this->settingsManager->get('KBIN_DOMAIN');\n            if ($domain === $query) {\n                $accountHref = $this->urlGenerator->generate('ap_instance', [], UrlGeneratorInterface::ABSOLUTE_URL);\n                $link = new JsonRdLink();\n                $link->setRel('self')\n                    ->setType('application/activity+json')\n                    ->setHref($accountHref);\n                $jsonRd->addLink($link);\n\n                return;\n            }\n\n            $actor = $this->getActor($query);\n            if ($actor) {\n                $accountHref = $this->urlGenerator->generate(\n                    'ap_user',\n                    ['username' => $actor->getUserIdentifier()],\n                    UrlGeneratorInterface::ABSOLUTE_URL\n                );\n\n                $link = new JsonRdLink();\n                $link->setRel('self')\n                    ->setType('application/activity+json')\n                    ->setHref($accountHref);\n                $jsonRd->addLink($link);\n\n                if ($actor->avatar) {\n                    $link = new JsonRdLink();\n                    $link->setRel('https://webfinger.net/rel/avatar')\n                        ->setHref(\n                            $this->imageManager->getUrl($actor->avatar),\n                        ); // @todo media url\n                    $jsonRd->addLink($link);\n                }\n            }\n        }\n    }\n\n    protected function getActor($name): ?UserInterface\n    {\n        return $this->userRepository->findOneByUsername($name);\n    }\n}\n"
  },
  {
    "path": "src/EventSubscriber/ActivityPub/UserWebFingerSubscriber.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\EventSubscriber\\ActivityPub;\n\nuse App\\ActivityPub\\JsonRdLink;\nuse App\\Event\\ActivityPub\\WebfingerResponseEvent;\nuse App\\Repository\\UserRepository;\nuse App\\Service\\ActivityPub\\Webfinger\\WebFingerParameters;\nuse JetBrains\\PhpStorm\\ArrayShape;\nuse Symfony\\Component\\EventDispatcher\\EventSubscriberInterface;\nuse Symfony\\Component\\Routing\\Generator\\UrlGeneratorInterface;\nuse Symfony\\Component\\Security\\Core\\User\\UserInterface;\n\nclass UserWebFingerSubscriber implements EventSubscriberInterface\n{\n    public function __construct(\n        private readonly UserRepository $userRepository,\n        private readonly UrlGeneratorInterface $urlGenerator,\n    ) {\n    }\n\n    #[ArrayShape([WebfingerResponseEvent::class => 'string'])]\n    public static function getSubscribedEvents(): array\n    {\n        return [\n            WebfingerResponseEvent::class => ['buildResponse', 1000],\n        ];\n    }\n\n    public function buildResponse(WebfingerResponseEvent $event): void\n    {\n        $params = $event->params;\n        $jsonRd = $event->jsonRd;\n\n        $subject = $event->subject;\n        if (!empty($subject)) {\n            $jsonRd->setSubject($subject);\n        }\n\n        if (\n            isset($params[WebFingerParameters::ACCOUNT_KEY_NAME])\n            && $actor = $this->getActor($params[WebFingerParameters::ACCOUNT_KEY_NAME])\n        ) {\n            $accountHref = $this->urlGenerator->generate(\n                'ap_user',\n                ['username' => $actor->getUserIdentifier()],\n                UrlGeneratorInterface::ABSOLUTE_URL\n            );\n\n            $jsonRd->addAlias($accountHref);\n            $link = new JsonRdLink();\n            $link->setRel('https://webfinger.net/rel/profile-page')\n                ->setType('text/html')\n                ->setHref($accountHref);\n            $jsonRd->addLink($link);\n        }\n    }\n\n    protected function getActor($name): ?UserInterface\n    {\n        return $this->userRepository->findOneByUsername($name);\n    }\n}\n"
  },
  {
    "path": "src/EventSubscriber/AuthorizationCodeSubscriber.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\EventSubscriber;\n\nuse League\\Bundle\\OAuth2ServerBundle\\Event\\AuthorizationRequestResolveEvent;\nuse Symfony\\Bundle\\SecurityBundle\\Security;\nuse Symfony\\Component\\EventDispatcher\\EventSubscriberInterface;\nuse Symfony\\Component\\HttpFoundation\\RedirectResponse;\nuse Symfony\\Component\\HttpFoundation\\RequestStack;\nuse Symfony\\Component\\Routing\\Generator\\UrlGeneratorInterface;\nuse Symfony\\Component\\Security\\Core\\User\\UserInterface;\nuse Symfony\\Component\\Security\\Http\\Util\\TargetPathTrait;\n\nclass AuthorizationCodeSubscriber implements EventSubscriberInterface\n{\n    use TargetPathTrait;\n\n    private Security $security;\n    private UrlGeneratorInterface $urlGenerator;\n    private RequestStack $requestStack;\n\n    public function __construct(Security $security, UrlGeneratorInterface $urlGenerator, RequestStack $requestStack)\n    {\n        $this->security = $security;\n        $this->urlGenerator = $urlGenerator;\n        $this->requestStack = $requestStack;\n    }\n\n    public function onLeagueOauth2ServerEventAuthorizationRequestResolve(AuthorizationRequestResolveEvent $event): void\n    {\n        $request = $this->requestStack->getCurrentRequest();\n        $user = $this->security->getUser();\n        $firewallName = $this->security->getFirewallConfig($request)->getName();\n\n        $this->saveTargetPath($request->getSession(), $firewallName, $request->getUri());\n        $response = new RedirectResponse($this->urlGenerator->generate('app_login'), 307);\n        if ($user instanceof UserInterface) {\n            if (null !== $request->getSession()->get('consent_granted')) {\n                $event->resolveAuthorization($request->getSession()->get('consent_granted'));\n                $request->getSession()->remove('consent_granted');\n\n                return;\n            }\n            $response = new RedirectResponse($this->urlGenerator->generate('app_consent', $request->query->all()), 307);\n        }\n        $event->setResponse($response);\n    }\n\n    public static function getSubscribedEvents(): array\n    {\n        return [\n            'league.oauth2_server.event.authorization_request_resolve' => 'onLeagueOauth2ServerEventAuthorizationRequestResolve',\n        ];\n    }\n}\n"
  },
  {
    "path": "src/EventSubscriber/ContentCountSubscriber.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\EventSubscriber;\n\nuse App\\Event\\Entry\\EntryDeletedEvent;\nuse App\\Event\\EntryComment\\EntryCommentCreatedEvent;\nuse App\\Event\\EntryComment\\EntryCommentDeletedEvent;\nuse App\\Event\\EntryComment\\EntryCommentPurgedEvent;\nuse App\\Event\\PostComment\\PostCommentCreatedEvent;\nuse App\\Event\\PostComment\\PostCommentDeletedEvent;\nuse App\\Event\\PostComment\\PostCommentPurgedEvent;\nuse App\\Repository\\EntryRepository;\nuse App\\Repository\\PostRepository;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Symfony\\Component\\EventDispatcher\\EventSubscriberInterface;\n\nclass ContentCountSubscriber implements EventSubscriberInterface\n{\n    public function __construct(\n        private readonly EntryRepository $entryRepository,\n        private readonly PostRepository $postRepository,\n        private readonly EntityManagerInterface $entityManager,\n    ) {\n    }\n\n    public static function getSubscribedEvents(): array\n    {\n        return [\n            EntryDeletedEvent::class => 'onEntryDeleted',\n            EntryCommentCreatedEvent::class => 'onEntryCommentCreated',\n            EntryCommentDeletedEvent::class => 'onEntryCommentDeleted',\n            EntryCommentPurgedEvent::class => 'onEntryCommentPurged',\n            PostCommentCreatedEvent::class => 'onPostCommentCreated',\n            PostCommentDeletedEvent::class => 'onPostCommentDeleted',\n            PostCommentPurgedEvent::class => 'onPostCommentPurged',\n        ];\n    }\n\n    public function onEntryDeleted(EntryDeletedEvent $event): void\n    {\n        $event->entry->magazine->updateEntryCounts();\n\n        $this->entityManager->flush();\n    }\n\n    public function onEntryCommentCreated(EntryCommentCreatedEvent $event): void\n    {\n        $magazine = $event->comment->entry->magazine;\n        $magazine->entryCommentCount = $this->entryRepository->countEntryCommentsByMagazine($magazine);\n\n        $this->entityManager->flush();\n    }\n\n    public function onEntryCommentDeleted(EntryCommentDeletedEvent $event): void\n    {\n        $magazine = $event->comment->entry->magazine;\n        $magazine->entryCommentCount = $this->entryRepository->countEntryCommentsByMagazine($magazine) - 1;\n\n        $event->comment->entry->updateCounts();\n\n        $this->entityManager->flush();\n    }\n\n    public function onEntryCommentPurged(EntryCommentPurgedEvent $event): void\n    {\n        $event->magazine->entryCommentCount = $this->entryRepository->countEntryCommentsByMagazine($event->magazine);\n\n        $this->entityManager->flush();\n    }\n\n    public function onPostCommentCreated(PostCommentCreatedEvent $event): void\n    {\n        $magazine = $event->comment->post->magazine;\n        $magazine->postCommentCount = $this->postRepository->countPostCommentsByMagazine($magazine);\n\n        $this->entityManager->flush();\n    }\n\n    public function onPostCommentDeleted(PostCommentDeletedEvent $event): void\n    {\n        $magazine = $event->comment->post->magazine;\n        $magazine->postCommentCount = $this->postRepository->countPostCommentsByMagazine($magazine) - 1;\n\n        $event->comment->post->updateCounts();\n\n        $this->entityManager->flush();\n    }\n\n    public function onPostCommentPurged(PostCommentPurgedEvent $event): void\n    {\n        $event->magazine->postCommentCount = $this->postRepository->countPostCommentsByMagazine($event->magazine);\n\n        $this->entityManager->flush();\n    }\n}\n"
  },
  {
    "path": "src/EventSubscriber/Domain/DomainBlockSubscriber.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\EventSubscriber\\Domain;\n\nuse App\\Event\\DomainBlockedEvent;\nuse App\\Utils\\SqlHelpers;\nuse Symfony\\Component\\EventDispatcher\\EventSubscriberInterface;\n\nclass DomainBlockSubscriber implements EventSubscriberInterface\n{\n    public function __construct(\n        private readonly SqlHelpers $sqlHelpers,\n    ) {\n    }\n\n    public static function getSubscribedEvents(): array\n    {\n        return [DomainBlockedEvent::class => 'handleDomainBlockedEvent'];\n    }\n\n    public function handleDomainBlockedEvent(DomainBlockedEvent $event): void\n    {\n        $this->sqlHelpers->clearCachedUserDomainBlocks($event->user);\n    }\n}\n"
  },
  {
    "path": "src/EventSubscriber/Domain/DomainFollowSubscriber.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\EventSubscriber\\Domain;\n\nuse App\\Event\\DomainSubscribedEvent;\nuse App\\Utils\\SqlHelpers;\nuse Symfony\\Component\\EventDispatcher\\EventSubscriberInterface;\n\nclass DomainFollowSubscriber implements EventSubscriberInterface\n{\n    public function __construct(\n        private readonly SqlHelpers $sqlHelpers,\n    ) {\n    }\n\n    public static function getSubscribedEvents(): array\n    {\n        return [DomainSubscribedEvent::class => 'handleDomainSubscribedEvent'];\n    }\n\n    public function handleDomainSubscribedEvent(DomainSubscribedEvent $event): void\n    {\n        $this->sqlHelpers->clearCachedUserSubscribedDomains($event->user);\n    }\n}\n"
  },
  {
    "path": "src/EventSubscriber/Entry/EntryCreateSubscriber.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\EventSubscriber\\Entry;\n\nuse App\\Event\\Entry\\EntryCreatedEvent;\nuse App\\Message\\ActivityPub\\Outbox\\CreateMessage;\nuse App\\Message\\EntryEmbedMessage;\nuse App\\Message\\LinkEmbedMessage;\nuse App\\Message\\Notification\\EntryCreatedNotificationMessage;\nuse App\\Repository\\EntryRepository;\nuse App\\Service\\DomainManager;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Symfony\\Component\\EventDispatcher\\EventSubscriberInterface;\nuse Symfony\\Component\\Messenger\\MessageBusInterface;\n\nclass EntryCreateSubscriber implements EventSubscriberInterface\n{\n    public function __construct(\n        private readonly MessageBusInterface $bus,\n        private readonly DomainManager $manager,\n        private readonly EntryRepository $entryRepository,\n        private readonly EntityManagerInterface $entityManager,\n    ) {\n    }\n\n    public static function getSubscribedEvents(): array\n    {\n        return [\n            EntryCreatedEvent::class => 'onEntryCreated',\n        ];\n    }\n\n    public function onEntryCreated(EntryCreatedEvent $event): void\n    {\n        $event->entry->magazine->entryCount = $this->entryRepository->countEntriesByMagazine($event->entry->magazine);\n\n        $this->entityManager->flush();\n\n        $this->manager->extract($event->entry);\n        $this->bus->dispatch(new EntryEmbedMessage($event->entry->getId()));\n        $threshold = new \\DateTimeImmutable('now - 2 days');\n        if ($event->entry->createdAt > $threshold) {\n            $this->bus->dispatch(new EntryCreatedNotificationMessage($event->entry->getId()));\n        }\n\n        if ($event->entry->body) {\n            $this->bus->dispatch(new LinkEmbedMessage($event->entry->body));\n        }\n\n        if (!$event->entry->apId) {\n            $this->bus->dispatch(new CreateMessage($event->entry->getId(), \\get_class($event->entry)));\n        }\n    }\n}\n"
  },
  {
    "path": "src/EventSubscriber/Entry/EntryDeleteSubscriber.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\EventSubscriber\\Entry;\n\nuse App\\Entity\\Entry;\nuse App\\Entity\\User;\nuse App\\Event\\Entry\\EntryBeforeDeletedEvent;\nuse App\\Event\\Entry\\EntryBeforePurgeEvent;\nuse App\\Event\\Entry\\EntryDeletedEvent;\nuse App\\Message\\Notification\\EntryDeletedNotificationMessage;\nuse App\\Repository\\EntryRepository;\nuse App\\Service\\ActivityPub\\DeleteService;\nuse Symfony\\Component\\EventDispatcher\\EventSubscriberInterface;\nuse Symfony\\Component\\Messenger\\MessageBusInterface;\n\nclass EntryDeleteSubscriber implements EventSubscriberInterface\n{\n    public function __construct(\n        private readonly MessageBusInterface $bus,\n        private readonly EntryRepository $entryRepository,\n        private readonly DeleteService $deleteService,\n    ) {\n    }\n\n    public static function getSubscribedEvents(): array\n    {\n        return [\n            EntryDeletedEvent::class => 'onEntryDeleted',\n            EntryBeforePurgeEvent::class => 'onEntryBeforePurge',\n            EntryBeforeDeletedEvent::class => 'onEntryBeforeDelete',\n        ];\n    }\n\n    public function onEntryDeleted(EntryDeletedEvent $event): void\n    {\n        $this->bus->dispatch(new EntryDeletedNotificationMessage($event->entry->getId()));\n    }\n\n    public function onEntryBeforePurge(EntryBeforePurgeEvent $event): void\n    {\n        $event->entry->magazine->entryCount = $this->entryRepository->countEntriesByMagazine(\n            $event->entry->magazine\n        ) - 1;\n        $this->onEntryBeforeDeleteImpl($event->user, $event->entry);\n    }\n\n    public function onEntryBeforeDelete(EntryBeforeDeletedEvent $event): void\n    {\n        $this->onEntryBeforeDeleteImpl($event->user, $event->entry);\n    }\n\n    public function onEntryBeforeDeleteImpl(?User $user, Entry $entry): void\n    {\n        $this->bus->dispatch(new EntryDeletedNotificationMessage($entry->getId()));\n        $this->deleteService->announceIfNecessary($user, $entry);\n    }\n}\n"
  },
  {
    "path": "src/EventSubscriber/Entry/EntryEditSubscriber.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\EventSubscriber\\Entry;\n\nuse App\\Event\\Entry\\EntryEditedEvent;\nuse App\\Message\\ActivityPub\\Outbox\\UpdateMessage;\nuse App\\Message\\LinkEmbedMessage;\nuse App\\Message\\Notification\\EntryEditedNotificationMessage;\nuse Symfony\\Component\\EventDispatcher\\EventSubscriberInterface;\nuse Symfony\\Component\\Messenger\\MessageBusInterface;\n\nclass EntryEditSubscriber implements EventSubscriberInterface\n{\n    public function __construct(private readonly MessageBusInterface $bus)\n    {\n    }\n\n    public static function getSubscribedEvents(): array\n    {\n        return [\n            EntryEditedEvent::class => 'onEntryEdited',\n        ];\n    }\n\n    public function onEntryEdited(EntryEditedEvent $event): void\n    {\n        $this->bus->dispatch(new EntryEditedNotificationMessage($event->entry->getId()));\n        if ($event->entry->body) {\n            $this->bus->dispatch(new LinkEmbedMessage($event->entry->body));\n        }\n\n        if (!$event->entry->apId) {\n            $this->bus->dispatch(new UpdateMessage($event->entry->getId(), \\get_class($event->entry), $event->editedBy->getId()));\n        }\n    }\n}\n"
  },
  {
    "path": "src/EventSubscriber/Entry/EntryPinSubscriber.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\EventSubscriber\\Entry;\n\nuse App\\Event\\Entry\\EntryPinEvent;\nuse App\\Message\\ActivityPub\\Outbox\\EntryPinMessage;\nuse Psr\\Log\\LoggerInterface;\nuse Symfony\\Component\\EventDispatcher\\EventSubscriberInterface;\nuse Symfony\\Component\\Messenger\\MessageBusInterface;\n\nclass EntryPinSubscriber implements EventSubscriberInterface\n{\n    public function __construct(\n        private readonly MessageBusInterface $bus,\n        private readonly LoggerInterface $logger,\n    ) {\n    }\n\n    public static function getSubscribedEvents(): array\n    {\n        return [\n            EntryPinEvent::class => 'onEntryPin',\n        ];\n    }\n\n    public function onEntryPin(EntryPinEvent $event): void\n    {\n        if ($event->actor && null === $event->actor->apId && $event->entry->magazine->userIsModerator($event->actor)) {\n            $this->logger->debug('entry {e} got {p} by {u}, dispatching new EntryPinMessage', ['e' => $event->entry->title, 'p' => $event->entry->sticky ? 'pinned' : 'unpinned', 'u' => $event->actor?->username ?? 'system']);\n            $this->bus->dispatch(new EntryPinMessage($event->entry->getId(), $event->entry->sticky, $event->actor?->getId()));\n        } elseif (null === $event->entry->magazine->apId && $event->actor && $event->entry->magazine->userIsModerator($event->actor)) {\n            if (null !== $event->actor->apId) {\n                // do not do the announce of the pin here, but in the AddHandler instead\n            } else {\n                $this->logger->debug('entry {e} got {p} by {u}, dispatching new EntryPinMessage', ['e' => $event->entry->title, 'p' => $event->entry->sticky ? 'pinned' : 'unpinned', 'u' => $event->actor?->username ?? 'system']);\n                $this->bus->dispatch(new EntryPinMessage($event->entry->getId(), $event->entry->sticky, $event->actor?->getId()));\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "src/EventSubscriber/Entry/EntryShowSubscriber.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\EventSubscriber\\Entry;\n\nuse App\\Entity\\Entry;\nuse App\\Entity\\Notification;\nuse App\\Event\\Entry\\EntryHasBeenSeenEvent;\nuse App\\Repository\\NotificationRepository;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse JetBrains\\PhpStorm\\ArrayShape;\nuse Symfony\\Bundle\\SecurityBundle\\Security;\nuse Symfony\\Component\\EventDispatcher\\EventSubscriberInterface;\n\nclass EntryShowSubscriber implements EventSubscriberInterface\n{\n    public function __construct(\n        private readonly Security $security,\n        private readonly NotificationRepository $repository,\n        private readonly EntityManagerInterface $entityManager,\n    ) {\n    }\n\n    #[ArrayShape([EntryHasBeenSeenEvent::class => 'string'])]\n    public static function getSubscribedEvents(): array\n    {\n        return [\n            EntryHasBeenSeenEvent::class => 'onShowEntry',\n        ];\n    }\n\n    public function onShowEntry(EntryHasBeenSeenEvent $event): void\n    {\n        $this->readMessage($event->entry);\n    }\n\n    private function readMessage(Entry $entry): void\n    {\n        if (!$this->security->getUser()) {\n            return;\n        }\n\n        $notifications = $this->repository->findUnreadEntryNotifications($this->security->getUser(), $entry);\n\n        if (!\\count($notifications)) {\n            return;\n        }\n\n        array_map(fn ($notification) => $notification->status = Notification::STATUS_READ, $notifications);\n\n        $this->entityManager->flush();\n    }\n}\n"
  },
  {
    "path": "src/EventSubscriber/Entry/LockSubscriber.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\EventSubscriber\\Entry;\n\nuse App\\Event\\Entry\\EntryLockEvent;\nuse App\\Event\\Entry\\PostLockEvent;\nuse App\\Message\\ActivityPub\\Outbox\\LockMessage;\nuse Psr\\Log\\LoggerInterface;\nuse Symfony\\Component\\EventDispatcher\\EventSubscriberInterface;\nuse Symfony\\Component\\Messenger\\MessageBusInterface;\n\nclass LockSubscriber implements EventSubscriberInterface\n{\n    public function __construct(\n        private readonly MessageBusInterface $bus,\n        private readonly LoggerInterface $logger,\n    ) {\n    }\n\n    public static function getSubscribedEvents(): array\n    {\n        return [\n            EntryLockEvent::class => 'onEntryLock',\n            PostLockEvent::class => 'onPostLock',\n        ];\n    }\n\n    public function onEntryLock(EntryLockEvent $event): void\n    {\n        if ($event->actor && null === $event->actor->apId && ($event->entry->magazine->userIsModerator($event->actor) || $event->entry->user === $event->actor)) {\n            $this->logger->debug('entry {e} got {p} by {u}, dispatching new EntryLockMessage', ['e' => $event->entry->title, 'p' => $event->entry->isLocked ? 'locked' : 'unlocked', 'u' => $event->actor?->username ?? 'system']);\n            $this->bus->dispatch(new LockMessage($event->actor->getId(), $event->entry->getId(), null));\n        } elseif (null === $event->entry->magazine->apId && $event->actor && ($event->entry->magazine->userIsModerator($event->actor) || $event->entry->user === $event->actor)) {\n            if (null !== $event->actor->apId) {\n                // do not do the announce of the lock here, but in the LockHandler instead\n            } else {\n                $this->logger->debug('entry {e} got {p} by {u}, dispatching new EntryLockMessage', ['e' => $event->entry->title, 'p' => $event->entry->sticky ? 'locked' : 'unlocked', 'u' => $event->actor?->username ?? 'system']);\n                $this->bus->dispatch(new LockMessage($event->actor->getId(), $event->entry->getId(), null));\n            }\n        }\n    }\n\n    public function onPostLock(PostLockEvent $event): void\n    {\n        if ($event->actor && null === $event->actor->apId && ($event->post->magazine->userIsModerator($event->actor) || $event->post->user === $event->actor)) {\n            $this->logger->debug('post {e} got {p} by {u}, dispatching new EntryLockMessage', ['e' => $event->post->getShortTitle(), 'p' => $event->post->isLocked ? 'locked' : 'unlocked', 'u' => $event->actor?->username ?? 'system']);\n            $this->bus->dispatch(new LockMessage($event->actor->getId(), null, $event->post->getId()));\n        } elseif (null === $event->post->magazine->apId && $event->actor && ($event->post->magazine->userIsModerator($event->actor) || $event->post->user === $event->actor)) {\n            if (null !== $event->actor->apId) {\n                // do not do the announce of the lock here, but in the LockHandler instead\n            } else {\n                $this->logger->debug('post {e} got {p} by {u}, dispatching new EntryLockMessage', ['e' => $event->post->getShortTitle(), 'p' => $event->post->sticky ? 'locked' : 'unlocked', 'u' => $event->actor?->username ?? 'system']);\n                $this->bus->dispatch(new LockMessage($event->actor->getId(), null, $event->post->getId()));\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "src/EventSubscriber/EntryComment/EntryCommentCreateSubscriber.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\EventSubscriber\\EntryComment;\n\nuse App\\Event\\EntryComment\\EntryCommentCreatedEvent;\nuse App\\Message\\ActivityPub\\Outbox\\CreateMessage;\nuse App\\Message\\LinkEmbedMessage;\nuse App\\Message\\Notification\\EntryCommentCreatedNotificationMessage;\nuse Symfony\\Component\\EventDispatcher\\EventSubscriberInterface;\nuse Symfony\\Component\\Messenger\\MessageBusInterface;\nuse Symfony\\Contracts\\Cache\\CacheInterface;\n\nclass EntryCommentCreateSubscriber implements EventSubscriberInterface\n{\n    public function __construct(private readonly CacheInterface $cache, private readonly MessageBusInterface $bus)\n    {\n    }\n\n    public static function getSubscribedEvents(): array\n    {\n        return [\n            EntryCommentCreatedEvent::class => 'onEntryCommentCreated',\n        ];\n    }\n\n    public function onEntryCommentCreated(EntryCommentCreatedEvent $event): void\n    {\n        $this->cache->invalidateTags(['entry_comment_'.$event->comment->root?->getId() ?? $event->comment->getId()]);\n\n        $threshold = new \\DateTimeImmutable('now - 2 days');\n        if ($event->comment->createdAt > $threshold) {\n            $this->bus->dispatch(new EntryCommentCreatedNotificationMessage($event->comment->getId()));\n        }\n\n        if ($event->comment->body) {\n            $this->bus->dispatch(new LinkEmbedMessage($event->comment->body));\n        }\n\n        if (!$event->comment->apId) {\n            $this->bus->dispatch(new CreateMessage($event->comment->getId(), \\get_class($event->comment)));\n        }\n    }\n}\n"
  },
  {
    "path": "src/EventSubscriber/EntryComment/EntryCommentDeleteSubscriber.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\EventSubscriber\\EntryComment;\n\nuse App\\Entity\\EntryComment;\nuse App\\Entity\\User;\nuse App\\Event\\EntryComment\\EntryCommentBeforeDeletedEvent;\nuse App\\Event\\EntryComment\\EntryCommentBeforePurgeEvent;\nuse App\\Event\\EntryComment\\EntryCommentDeletedEvent;\nuse App\\Message\\Notification\\EntryCommentDeletedNotificationMessage;\nuse App\\Service\\ActivityPub\\DeleteService;\nuse Symfony\\Component\\EventDispatcher\\EventSubscriberInterface;\nuse Symfony\\Component\\Messenger\\MessageBusInterface;\nuse Symfony\\Contracts\\Cache\\CacheInterface;\n\nclass EntryCommentDeleteSubscriber implements EventSubscriberInterface\n{\n    public function __construct(\n        private readonly CacheInterface $cache,\n        private readonly MessageBusInterface $bus,\n        private readonly DeleteService $deleteService,\n    ) {\n    }\n\n    public static function getSubscribedEvents(): array\n    {\n        return [\n            EntryCommentDeletedEvent::class => 'onEntryCommentDeleted',\n            EntryCommentBeforePurgeEvent::class => 'onEntryCommentBeforePurge',\n            EntryCommentBeforeDeletedEvent::class => 'onEntryCommentBeforeDelete',\n        ];\n    }\n\n    public function onEntryCommentDeleted(EntryCommentDeletedEvent $event): void\n    {\n        $this->cache->invalidateTags(['entry_comment_'.$event->comment->root?->getId() ?? $event->comment->getId()]);\n\n        $this->bus->dispatch(new EntryCommentDeletedNotificationMessage($event->comment->getId()));\n    }\n\n    public function onEntryCommentBeforePurge(EntryCommentBeforePurgeEvent $event): void\n    {\n        $this->onEntryCommentBeforeDeleteImpl($event->user, $event->comment);\n    }\n\n    public function onEntryCommentBeforeDelete(EntryCommentBeforeDeletedEvent $event): void\n    {\n        $this->onEntryCommentBeforeDeleteImpl($event->user, $event->comment);\n    }\n\n    public function onEntryCommentBeforeDeleteImpl(?User $user, EntryComment $comment): void\n    {\n        $this->cache->invalidateTags(['entry_comment_'.$comment->root?->getId() ?? $comment->getId()]);\n\n        $this->bus->dispatch(new EntryCommentDeletedNotificationMessage($comment->getId()));\n        $this->deleteService->announceIfNecessary($user, $comment);\n    }\n}\n"
  },
  {
    "path": "src/EventSubscriber/EntryComment/EntryCommentEditSubscriber.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\EventSubscriber\\EntryComment;\n\nuse App\\Event\\EntryComment\\EntryCommentEditedEvent;\nuse App\\Message\\ActivityPub\\Outbox\\UpdateMessage;\nuse App\\Message\\LinkEmbedMessage;\nuse App\\Message\\Notification\\EntryCommentEditedNotificationMessage;\nuse Symfony\\Component\\EventDispatcher\\EventSubscriberInterface;\nuse Symfony\\Component\\Messenger\\MessageBusInterface;\nuse Symfony\\Contracts\\Cache\\CacheInterface;\n\nclass EntryCommentEditSubscriber implements EventSubscriberInterface\n{\n    public function __construct(private readonly CacheInterface $cache, private readonly MessageBusInterface $bus)\n    {\n    }\n\n    public static function getSubscribedEvents(): array\n    {\n        return [\n            EntryCommentEditedEvent::class => 'onEntryCommentEdited',\n        ];\n    }\n\n    public function onEntryCommentEdited(EntryCommentEditedEvent $event): void\n    {\n        $this->cache->invalidateTags(['entry_comment_'.$event->comment->root?->getId() ?? $event->comment->getId()]);\n\n        $this->bus->dispatch(new EntryCommentEditedNotificationMessage($event->comment->getId()));\n        if ($event->comment->body) {\n            $this->bus->dispatch(new LinkEmbedMessage($event->comment->body));\n        }\n\n        if (!$event->comment->apId) {\n            $this->bus->dispatch(new UpdateMessage($event->comment->getId(), \\get_class($event->comment), $event->editedByUser->getId()));\n        }\n    }\n}\n"
  },
  {
    "path": "src/EventSubscriber/FavouriteHandleSubscriber.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\EventSubscriber;\n\nuse App\\Entity\\Contracts\\FavouriteInterface;\nuse App\\Entity\\Contracts\\VotableInterface;\nuse App\\Entity\\EntryComment;\nuse App\\Entity\\PostComment;\nuse App\\Event\\FavouriteEvent;\nuse App\\Message\\ActivityPub\\Outbox\\LikeMessage;\nuse App\\Message\\Notification\\FavouriteNotificationMessage;\nuse App\\Service\\CacheService;\nuse App\\Service\\VoteManager;\nuse App\\Utils\\SqlHelpers;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse JetBrains\\PhpStorm\\ArrayShape;\nuse Symfony\\Component\\EventDispatcher\\EventSubscriberInterface;\nuse Symfony\\Component\\Messenger\\MessageBusInterface;\nuse Symfony\\Contracts\\Cache\\CacheInterface;\n\nclass FavouriteHandleSubscriber implements EventSubscriberInterface\n{\n    public function __construct(\n        private readonly MessageBusInterface $bus,\n        private readonly CacheInterface $cache,\n        private readonly CacheService $cacheService,\n        private readonly VoteManager $voteManager,\n        private readonly EntityManagerInterface $entityManager,\n    ) {\n    }\n\n    #[ArrayShape([FavouriteEvent::class => 'string'])]\n    public static function getSubscribedEvents(): array\n    {\n        return [\n            FavouriteEvent::class => 'onFavourite',\n        ];\n    }\n\n    public function onFavourite(FavouriteEvent $event): void\n    {\n        $subject = $event->subject;\n        $choice = $event->subject->getUserVote($event->user)?->choice;\n        if (VotableInterface::VOTE_DOWN === $choice && $subject->isFavored($event->user)) {\n            $this->voteManager->removeVote($subject, $event->user);\n        }\n\n        $this->bus->dispatch(\n            new FavouriteNotificationMessage(\n                $subject->getId(),\n                SqlHelpers::getRealClassName($this->entityManager, $subject),\n            )\n        );\n\n        $this->deleteFavouriteCache($subject);\n\n        match (\\get_class($subject)) {\n            EntryComment::class => $this->clearEntryCommentCache($subject),\n            PostComment::class => $this->clearPostCommentCache($subject),\n            default => null,\n        };\n\n        if (!$event->user->apId) {\n            $this->bus->dispatch(\n                new LikeMessage(\n                    $event->user->getId(),\n                    $subject->getId(),\n                    \\get_class($subject),\n                    $event->removeLike\n                ),\n            );\n        }\n    }\n\n    private function deleteFavouriteCache(FavouriteInterface $subject)\n    {\n        $this->cache->delete($this->cacheService->getFavouritesCacheKey($subject));\n    }\n\n    private function clearEntryCommentCache(EntryComment $comment): void\n    {\n        $this->cache->invalidateTags(['entry_comment_'.$comment->root?->getId() ?? $comment->getId()]);\n    }\n\n    private function clearPostCommentCache(PostComment $comment)\n    {\n        $this->cache->invalidateTags([\n            'post_'.$comment->post->getId(),\n            'post_comment_'.$comment->root?->getId() ?? $comment->getId(),\n        ]);\n    }\n}\n"
  },
  {
    "path": "src/EventSubscriber/Image/ExifCleanerSubscriber.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\EventSubscriber\\Image;\n\nuse App\\Event\\ImagePostProcessEvent;\nuse App\\Utils\\ExifCleaner;\nuse App\\Utils\\ExifCleanMode;\nuse App\\Utils\\ImageOrigin;\nuse Psr\\Log\\LoggerInterface;\nuse Symfony\\Component\\DependencyInjection\\ParameterBag\\ContainerBagInterface;\nuse Symfony\\Component\\EventDispatcher\\EventSubscriberInterface;\n\nclass ExifCleanerSubscriber implements EventSubscriberInterface\n{\n    private ExifCleanMode $uploadedCleanMode;\n    private ExifCleanMode $externalCleanMode;\n\n    public function __construct(\n        private readonly ExifCleaner $cleaner,\n        private readonly ContainerBagInterface $params,\n        private readonly LoggerInterface $logger,\n    ) {\n        $this->uploadedCleanMode = $params->get('exif_clean_mode_uploaded');\n        $this->externalCleanMode = $params->get('exif_clean_mode_external');\n    }\n\n    public static function getSubscribedEvents(): array\n    {\n        return [\n            ImagePostProcessEvent::class => ['cleanExif'],\n        ];\n    }\n\n    public function cleanExif(ImagePostProcessEvent $event)\n    {\n        $mode = $this->getCleanMode($event->origin);\n        $this->logger->debug(\n            'ImagePostProcessEvent:ExifCleanerSubscriber: cleaning image:',\n            [\n                'source' => $event->source,\n                'origin' => $event->origin,\n                'sha256' => hash_file('sha256', $event->source, false),\n                'mode' => $mode,\n            ]\n        );\n        $this->cleaner->cleanImage($event->source, $mode);\n    }\n\n    private function getCleanMode(ImageOrigin $origin): ExifCleanMode\n    {\n        return match ($origin) {\n            ImageOrigin::Uploaded => $this->uploadedCleanMode,\n            ImageOrigin::External => $this->externalCleanMode,\n        };\n    }\n}\n"
  },
  {
    "path": "src/EventSubscriber/Image/ImageCompressSubscriber.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\EventSubscriber\\Image;\n\nuse App\\Event\\ImagePostProcessEvent;\nuse App\\Service\\ImageManagerInterface;\nuse App\\Service\\SettingsManager;\nuse Psr\\Log\\LoggerInterface;\nuse Symfony\\Component\\EventDispatcher\\EventSubscriberInterface;\n\nreadonly class ImageCompressSubscriber implements EventSubscriberInterface\n{\n    public function __construct(\n        private ImageManagerInterface $imageManager,\n        private SettingsManager $settingsManager,\n        private LoggerInterface $logger,\n    ) {\n    }\n\n    public static function getSubscribedEvents(): array\n    {\n        return [\n            ImagePostProcessEvent::class => 'compressImage',\n        ];\n    }\n\n    public function compressImage(ImagePostProcessEvent $event): void\n    {\n        $extension = pathinfo($event->targetFilePath, PATHINFO_EXTENSION);\n        if (!$this->imageManager->compressUntilSize($event->source, $extension, $this->settingsManager->getMaxImageBytes())) {\n            if (filesize($event->source) > $this->settingsManager->getMaxImageBytes()) {\n                $this->logger->warning('Was not able to compress image {i} to size {b}', ['i' => $event->source, 'b' => $this->settingsManager->getMaxImageBytes()]);\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "src/EventSubscriber/Instance/InstanceBanSubscriber.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\EventSubscriber\\Instance;\n\nuse App\\Event\\Instance\\InstanceBanEvent;\nuse App\\Message\\ActivityPub\\Outbox\\BlockMessage;\nuse Symfony\\Component\\EventDispatcher\\EventSubscriberInterface;\nuse Symfony\\Component\\Messenger\\MessageBusInterface;\n\nclass InstanceBanSubscriber implements EventSubscriberInterface\n{\n    public function __construct(\n        private readonly MessageBusInterface $bus,\n    ) {\n    }\n\n    public static function getSubscribedEvents(): array\n    {\n        return [\n            InstanceBanEvent::class => 'onInstanceBan',\n        ];\n    }\n\n    public function onInstanceBan(InstanceBanEvent $event): void\n    {\n        if (!$event->bannedUser->apId && !$event->bannedByUser->apId) {\n            // local user banning another local user\n            $this->bus->dispatch(new BlockMessage(magazineBanId: null, bannedUserId: $event->bannedUser->getId(), actor: $event->bannedByUser->getId()));\n        }\n    }\n}\n"
  },
  {
    "path": "src/EventSubscriber/LogoutSubscriber.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\EventSubscriber;\n\nuse KnpU\\OAuth2ClientBundle\\Client\\ClientRegistry;\nuse Symfony\\Bundle\\SecurityBundle\\Security;\nuse Symfony\\Component\\EventDispatcher\\EventSubscriberInterface;\nuse Symfony\\Component\\HttpFoundation\\RedirectResponse;\nuse Symfony\\Component\\Routing\\Generator\\UrlGeneratorInterface;\nuse Symfony\\Component\\Security\\Http\\Event\\LogoutEvent;\n\nclass LogoutSubscriber implements EventSubscriberInterface\n{\n    public function __construct(\n        private UrlGeneratorInterface $urlGenerator,\n        private readonly ClientRegistry $clientRegistry,\n        private readonly Security $security,\n    ) {\n    }\n\n    public static function getSubscribedEvents(): array\n    {\n        return [LogoutEvent::class => 'onLogout'];\n    }\n\n    public function onLogout(LogoutEvent $event): void\n    {\n        $token = $event->getToken();\n        $user = $token->getUser();\n\n        if (null !== $user->oauthKeycloakId) {\n            $event->setResponse(\n                new RedirectResponse(\n                    $this->clientRegistry->getClient('keycloak')->getOAuth2Provider()->getLogoutUrl()\n                )\n            );\n        }\n    }\n}\n"
  },
  {
    "path": "src/EventSubscriber/Magazine/MagazineBanSubscriber.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\EventSubscriber\\Magazine;\n\nuse App\\Event\\Magazine\\MagazineBanEvent;\nuse App\\Message\\ActivityPub\\Outbox\\BlockMessage;\nuse App\\Message\\Notification\\MagazineBanNotificationMessage;\nuse Psr\\Log\\LoggerInterface;\nuse Symfony\\Component\\EventDispatcher\\EventSubscriberInterface;\nuse Symfony\\Component\\Messenger\\MessageBusInterface;\n\nclass MagazineBanSubscriber implements EventSubscriberInterface\n{\n    public function __construct(\n        private readonly MessageBusInterface $bus,\n        private readonly LoggerInterface $logger,\n    ) {\n    }\n\n    public static function getSubscribedEvents(): array\n    {\n        return [\n            MagazineBanEvent::class => 'onBan',\n        ];\n    }\n\n    public function onBan(MagazineBanEvent $event): void\n    {\n        $this->bus->dispatch(new MagazineBanNotificationMessage($event->ban->getId()));\n        $this->logger->debug('[MagazineBanSubscriber::onBan] got ban event: banned: {u}, magazine {m}, expires: {e}, bannedBy: {u2}', [\n            'u' => $event->ban->user->username,\n            'm' => $event->ban->magazine->name,\n            'e' => $event->ban->expiredAt,\n            'u2' => $event->ban->bannedBy->username,\n        ]);\n        if (null !== $event->ban->bannedBy && null === $event->ban->bannedBy->apId) {\n            // bannedBy not null and a local user\n            $this->bus->dispatch(new BlockMessage(magazineBanId: $event->ban->getId(), bannedUserId: null, actor: null));\n        }\n    }\n}\n"
  },
  {
    "path": "src/EventSubscriber/Magazine/MagazineBlockSubscriber.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\EventSubscriber\\Magazine;\n\nuse App\\Event\\Magazine\\MagazineBlockedEvent;\nuse App\\Utils\\SqlHelpers;\nuse Symfony\\Component\\EventDispatcher\\EventSubscriberInterface;\n\nclass MagazineBlockSubscriber implements EventSubscriberInterface\n{\n    public function __construct(\n        private readonly SqlHelpers $sqlHelpers,\n    ) {\n    }\n\n    public static function getSubscribedEvents(): array\n    {\n        return [MagazineBlockedEvent::class => 'handleMagazineBlockedEvent'];\n    }\n\n    public function handleMagazineBlockedEvent(MagazineBlockedEvent $event): void\n    {\n        $this->sqlHelpers->clearCachedUserMagazineBlocks($event->user);\n    }\n}\n"
  },
  {
    "path": "src/EventSubscriber/Magazine/MagazineLogSubscriber.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\EventSubscriber\\Magazine;\n\nuse App\\Entity\\MagazineLogBan;\nuse App\\Entity\\MagazineLogEntryCommentDeleted;\nuse App\\Entity\\MagazineLogEntryCommentRestored;\nuse App\\Entity\\MagazineLogEntryDeleted;\nuse App\\Entity\\MagazineLogEntryRestored;\nuse App\\Entity\\MagazineLogPostCommentDeleted;\nuse App\\Entity\\MagazineLogPostCommentRestored;\nuse App\\Entity\\MagazineLogPostDeleted;\nuse App\\Entity\\MagazineLogPostRestored;\nuse App\\Event\\Entry\\EntryDeletedEvent;\nuse App\\Event\\Entry\\EntryRestoredEvent;\nuse App\\Event\\EntryComment\\EntryCommentDeletedEvent;\nuse App\\Event\\EntryComment\\EntryCommentRestoredEvent;\nuse App\\Event\\Magazine\\MagazineBanEvent;\nuse App\\Event\\Post\\PostDeletedEvent;\nuse App\\Event\\Post\\PostRestoredEvent;\nuse App\\Event\\PostComment\\PostCommentDeletedEvent;\nuse App\\Event\\PostComment\\PostCommentRestoredEvent;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Symfony\\Component\\EventDispatcher\\EventSubscriberInterface;\n\nclass MagazineLogSubscriber implements EventSubscriberInterface\n{\n    public function __construct(private readonly EntityManagerInterface $entityManager)\n    {\n    }\n\n    public static function getSubscribedEvents(): array\n    {\n        return [\n            EntryDeletedEvent::class => 'onEntryDeleted',\n            EntryRestoredEvent::class => 'onEntryRestored',\n            EntryCommentDeletedEvent::class => 'onEntryCommentDeleted',\n            EntryCommentRestoredEvent::class => 'onEntryCommentRestored',\n            PostDeletedEvent::class => 'onPostDeleted',\n            PostRestoredEvent::class => 'onPostRestored',\n            PostCommentDeletedEvent::class => 'onPostCommentDeleted',\n            PostCommentRestoredEvent::class => 'onPostCommentRestored',\n            MagazineBanEvent::class => 'onBan',\n        ];\n    }\n\n    public function onEntryDeleted(EntryDeletedEvent $event): void\n    {\n        if (!$event->entry->isTrashed()) {\n            return;\n        }\n\n        if (!$event->user || $event->entry->isAuthor($event->user)) {\n            return;\n        }\n\n        $log = new MagazineLogEntryDeleted($event->entry, $event->user);\n\n        $this->entityManager->persist($log);\n        $this->entityManager->flush();\n    }\n\n    public function onEntryRestored(EntryRestoredEvent $event): void\n    {\n        if ($event->entry->isTrashed()) {\n            return;\n        }\n\n        if (!$event->user || $event->entry->isAuthor($event->user)) {\n            return;\n        }\n\n        $log = new MagazineLogEntryRestored($event->entry, $event->user);\n\n        $this->entityManager->persist($log);\n        $this->entityManager->flush();\n    }\n\n    public function onEntryCommentDeleted(EntryCommentDeletedEvent $event): void\n    {\n        if (!$event->comment->isTrashed()) {\n            return;\n        }\n\n        if (!$event->user || $event->comment->isAuthor($event->user)) {\n            return;\n        }\n\n        $log = new MagazineLogEntryCommentDeleted($event->comment, $event->user);\n\n        $this->entityManager->persist($log);\n        $this->entityManager->flush();\n    }\n\n    public function onEntryCommentRestored(EntryCommentRestoredEvent $event): void\n    {\n        if ($event->comment->isTrashed()) {\n            return;\n        }\n\n        if (!$event->user || $event->comment->isAuthor($event->user)) {\n            return;\n        }\n\n        $log = new MagazineLogEntryCommentRestored($event->comment, $event->user);\n\n        $this->entityManager->persist($log);\n        $this->entityManager->flush();\n    }\n\n    public function onPostDeleted(PostDeletedEvent $event): void\n    {\n        if (!$event->post->isTrashed()) {\n            return;\n        }\n\n        if (!$event->user || $event->post->isAuthor($event->user)) {\n            return;\n        }\n\n        $log = new MagazineLogPostDeleted($event->post, $event->user);\n\n        $this->entityManager->persist($log);\n        $this->entityManager->flush();\n    }\n\n    public function onPostRestored(PostRestoredEvent $event): void\n    {\n        if ($event->post->isTrashed()) {\n            return;\n        }\n\n        if (!$event->user || $event->post->isAuthor($event->user)) {\n            return;\n        }\n\n        $log = new MagazineLogPostRestored($event->post, $event->user);\n\n        $this->entityManager->persist($log);\n        $this->entityManager->flush();\n    }\n\n    public function onPostCommentDeleted(PostCommentDeletedEvent $event): void\n    {\n        if (!$event->comment->isTrashed()) {\n            return;\n        }\n\n        if (!$event->user || $event->comment->isAuthor($event->user)) {\n            return;\n        }\n\n        $log = new MagazineLogPostCommentDeleted($event->comment, $event->user);\n\n        $this->entityManager->persist($log);\n        $this->entityManager->flush();\n    }\n\n    public function onPostCommentRestored(PostCommentRestoredEvent $event): void\n    {\n        if ($event->comment->isTrashed()) {\n            return;\n        }\n\n        if (!$event->user || $event->comment->isAuthor($event->user)) {\n            return;\n        }\n\n        $log = new MagazineLogPostCommentRestored($event->comment, $event->user);\n\n        $this->entityManager->persist($log);\n        $this->entityManager->flush();\n    }\n\n    public function onBan(MagazineBanEvent $event): void\n    {\n        $log = new MagazineLogBan($event->ban);\n\n        $this->entityManager->persist($log);\n        $this->entityManager->flush();\n    }\n}\n"
  },
  {
    "path": "src/EventSubscriber/Magazine/MagazineUpdatedSubscriber.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\EventSubscriber\\Magazine;\n\nuse App\\Entity\\Magazine;\nuse App\\Event\\Magazine\\MagazineUpdatedEvent;\nuse App\\Message\\ActivityPub\\Outbox\\GenericAnnounceMessage;\nuse App\\Message\\ActivityPub\\Outbox\\UpdateMessage;\nuse App\\Service\\ActivityPub\\Wrapper\\UpdateWrapper;\nuse Symfony\\Component\\EventDispatcher\\EventSubscriberInterface;\nuse Symfony\\Component\\Messenger\\MessageBusInterface;\n\nclass MagazineUpdatedSubscriber implements EventSubscriberInterface\n{\n    public function __construct(\n        private readonly MessageBusInterface $bus,\n        private readonly UpdateWrapper $updateWrapper,\n    ) {\n    }\n\n    public static function getSubscribedEvents(): array\n    {\n        return [\n            MagazineUpdatedEvent::class => 'onMagazineUpdated',\n        ];\n    }\n\n    public function onMagazineUpdated(MagazineUpdatedEvent $event): void\n    {\n        $mag = $event->magazine;\n        if (null === $mag->apId) {\n            $activity = $this->updateWrapper->buildForActor($mag, $event->editedBy);\n            $this->bus->dispatch(new GenericAnnounceMessage($mag->getId(), null, $event->editedBy->apDomain, $activity->uuid->toString(), null));\n        } elseif (null !== $event->editedBy && null === $event->editedBy->apId) {\n            $this->bus->dispatch(new UpdateMessage($mag->getId(), Magazine::class, $event->editedBy->getId()));\n        }\n    }\n}\n"
  },
  {
    "path": "src/EventSubscriber/Monitoring/CurlRequestSubscriber.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\EventSubscriber\\Monitoring;\n\nuse App\\Event\\ActivityPub\\CurlRequestBeginningEvent;\nuse App\\Event\\ActivityPub\\CurlRequestFinishedEvent;\nuse App\\Service\\Monitor;\nuse Symfony\\Component\\EventDispatcher\\EventSubscriberInterface;\n\nreadonly class CurlRequestSubscriber implements EventSubscriberInterface\n{\n    public function __construct(\n        private Monitor $monitor,\n    ) {\n    }\n\n    public static function getSubscribedEvents(): array\n    {\n        return [\n            CurlRequestBeginningEvent::class => ['onCurlRequestBeginning'],\n            CurlRequestFinishedEvent::class => ['onCurlRequestFinished'],\n        ];\n    }\n\n    public function onCurlRequestBeginning(CurlRequestBeginningEvent $event): void\n    {\n        if (!$this->monitor->shouldRecordCurlRequests() || null === $this->monitor->currentContext) {\n            return;\n        }\n\n        $this->monitor->startCurlRequest($event->targetUrl, $event->method);\n    }\n\n    public function onCurlRequestFinished(CurlRequestFinishedEvent $event): void\n    {\n        if (!$this->monitor->shouldRecordCurlRequests() || null === $this->monitor->currentContext) {\n            return;\n        }\n\n        $this->monitor->endCurlRequest($event->url, $event->wasSuccessful, $event->exception);\n    }\n}\n"
  },
  {
    "path": "src/EventSubscriber/Monitoring/KernelEventsSubscriber.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\EventSubscriber\\Monitoring;\n\nuse App\\Service\\Monitor;\nuse Symfony\\Bundle\\SecurityBundle\\Security;\nuse Symfony\\Component\\EventDispatcher\\EventSubscriberInterface;\nuse Symfony\\Component\\HttpKernel\\Event\\ControllerEvent;\nuse Symfony\\Component\\HttpKernel\\Event\\ExceptionEvent;\nuse Symfony\\Component\\HttpKernel\\Event\\RequestEvent;\nuse Symfony\\Component\\HttpKernel\\Event\\ResponseEvent;\nuse Symfony\\Component\\HttpKernel\\Event\\TerminateEvent;\nuse Symfony\\Component\\HttpKernel\\KernelEvents;\nuse Symfony\\Component\\Routing\\RouterInterface;\n\n/**\n * Heavily inspired by https://github.com/inspector-apm/inspector-symfony/blob/master/src/Listeners/KernelEventsSubscriber.php.\n */\nreadonly class KernelEventsSubscriber implements EventSubscriberInterface\n{\n    public function __construct(\n        private Monitor $monitor,\n        private Security $security,\n        private RouterInterface $router,\n    ) {\n    }\n\n    public static function getSubscribedEvents(): array\n    {\n        return [\n            KernelEvents::REQUEST => ['onKernelRequest'],\n            KernelEvents::CONTROLLER => ['onKernelController'],\n            KernelEvents::EXCEPTION => ['onKernelException'],\n            KernelEvents::RESPONSE => ['onKernelResponse'],\n            KernelEvents::TERMINATE => ['onKernelResponseSent'],\n        ];\n    }\n\n    public const array ROUTES_TO_IGNORE = [\n        'ajax_fetch_user_notifications_count',\n        'liip_imagine_filter',\n        'custom_style',\n        'admin_monitoring',\n        'admin_monitoring_single_context',\n        '_wdt',\n        '_wdt_stylesheet',\n    ];\n\n    public function onKernelRequest(RequestEvent $event): void\n    {\n        if (!$this->monitor->shouldRecord()) {\n            return;\n        }\n        $request = $event->getRequest();\n        $acceptHeaders = $request->headers->all('Accept');\n        if (0 < \\sizeof($acceptHeaders) && (\\in_array('application/activity+json', $acceptHeaders) || \\in_array('application/ld+json', $acceptHeaders))) {\n            $user = 'activity_pub';\n        } elseif ($request->isXmlHttpRequest()) {\n            $user = 'ajax';\n        } elseif ($this->security->getUser()) {\n            $user = 'user';\n        } else {\n            $user = 'anonymous';\n        }\n\n        try {\n            $routeInfo = $this->router->matchRequest($request);\n            $routeName = $routeInfo['_route'];\n            if (\\in_array($routeName, self::ROUTES_TO_IGNORE)) {\n                return;\n            }\n\n            if (str_starts_with($routeName, 'ap_')) {\n                $user = 'activity_pub';\n            }\n        } catch (\\Exception) {\n        }\n\n        $this->monitor->startNewExecutionContext('request', $user, $routeName ?? $request->getRequestUri(), '');\n    }\n\n    public function onKernelController(ControllerEvent $event): void\n    {\n        if (!$this->monitor->shouldRecord() || null === $this->monitor->currentContext) {\n            return;\n        }\n        $controller = $event->getController();\n        if (\\is_array($controller)) {\n            $this->monitor->currentContext->handler = \\get_class($controller[0]).'->'.$controller[1];\n        } elseif (\\is_object($controller)) {\n            $this->monitor->currentContext->handler = \\get_class($controller).'->__invoke';\n        } elseif (\\is_string($controller)) {\n            $this->monitor->currentContext->handler = $controller;\n        }\n    }\n\n    public function onKernelException(ExceptionEvent $event): void\n    {\n        if (!$this->monitor->shouldRecord()) {\n            return;\n        }\n        if (null !== $this->monitor->currentContext) {\n            $this->monitor->currentContext->exception = \\get_class($event->getThrowable());\n            $this->monitor->currentContext->stacktrace = $event->getThrowable()->getTraceAsString();\n        }\n    }\n\n    public function onKernelResponse(ResponseEvent $event): void\n    {\n        if (!$this->monitor->shouldRecord() || null === $this->monitor->currentContext) {\n            return;\n        }\n        $this->monitor->startSendingResponse();\n    }\n\n    public function onKernelResponseSent(TerminateEvent $event): void\n    {\n        if (!$this->monitor->shouldRecord() || null === $this->monitor->currentContext) {\n            return;\n        }\n        $this->monitor->endSendingResponse();\n        $response = $event->getResponse();\n        $this->monitor->endCurrentExecutionContext($response->getStatusCode());\n    }\n}\n"
  },
  {
    "path": "src/EventSubscriber/Monitoring/MessengerEventsSubscriber.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\EventSubscriber\\Monitoring;\n\nuse App\\Service\\Monitor;\nuse Symfony\\Component\\EventDispatcher\\EventSubscriberInterface;\nuse Symfony\\Component\\Messenger\\Event\\WorkerMessageFailedEvent;\nuse Symfony\\Component\\Messenger\\Event\\WorkerMessageHandledEvent;\nuse Symfony\\Component\\Messenger\\Event\\WorkerMessageReceivedEvent;\n\n/**\n * Heavily inspired by https://github.com/inspector-apm/inspector-symfony/blob/master/src/Listeners/MessengerEventsSubscriber.php.\n */\nreadonly class MessengerEventsSubscriber implements EventSubscriberInterface\n{\n    public function __construct(\n        private Monitor $monitor,\n    ) {\n    }\n\n    public static function getSubscribedEvents(): array\n    {\n        return [\n            WorkerMessageReceivedEvent::class => 'onWorkerMessageReceived',\n            WorkerMessageFailedEvent::class => 'onWorkerMessageFailed',\n            WorkerMessageHandledEvent::class => 'onWorkerMessageHandled',\n        ];\n    }\n\n    public function onWorkerMessageReceived(WorkerMessageReceivedEvent $event): void\n    {\n        if (!$this->monitor->shouldRecord()) {\n            return;\n        }\n        $message = $event->getEnvelope()->getMessage();\n        $this->monitor->startNewExecutionContext('messenger', 'anonymous', \\get_class($message), $event->getReceiverName());\n    }\n\n    public function onWorkerMessageFailed(WorkerMessageFailedEvent $event): void\n    {\n        if (!$this->monitor->shouldRecord()) {\n            return;\n        }\n        $throwable = $event->getThrowable();\n        $this->monitor->currentContext->exception = \\get_class($throwable);\n        $this->monitor->currentContext->stacktrace = $throwable->getTraceAsString();\n    }\n\n    public function onWorkerMessageHandled(WorkerMessageHandledEvent $event): void\n    {\n        if (!$this->monitor->shouldRecord()) {\n            return;\n        }\n        $this->monitor->endCurrentExecutionContext();\n    }\n}\n"
  },
  {
    "path": "src/EventSubscriber/NotificationCreatedSubscriber.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\EventSubscriber;\n\nuse App\\Event\\NotificationCreatedEvent;\nuse App\\Service\\Notification\\UserPushSubscriptionManager;\nuse Psr\\Log\\LoggerInterface;\nuse Symfony\\Component\\EventDispatcher\\EventSubscriberInterface;\n\nclass NotificationCreatedSubscriber implements EventSubscriberInterface\n{\n    public function __construct(\n        private readonly UserPushSubscriptionManager $pushSubscriptionManager,\n        private readonly LoggerInterface $logger,\n    ) {\n    }\n\n    public static function getSubscribedEvents(): array\n    {\n        return [NotificationCreatedEvent::class => 'onNotificationCreated'];\n    }\n\n    public function onNotificationCreated(NotificationCreatedEvent $event): void\n    {\n        try {\n            $this->pushSubscriptionManager->sendTextToUser($event->notification->user, $event->notification);\n        } catch (\\ErrorException $e) {\n            $this->logger->error('there was an exception while sending a {t} to {u}. {e} - {m}', [\n                't' => \\get_class($event->notification),\n                'u' => $event->notification->user->username,\n                'e' => \\get_class($e),\n                'm' => $e->getMessage(),\n            ]);\n        }\n    }\n}\n"
  },
  {
    "path": "src/EventSubscriber/Post/PostCreateSubscriber.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\EventSubscriber\\Post;\n\nuse App\\Entity\\Post;\nuse App\\Event\\Post\\PostCreatedEvent;\nuse App\\Message\\ActivityPub\\Outbox\\CreateMessage;\nuse App\\Message\\LinkEmbedMessage;\nuse App\\Message\\Notification\\PostCreatedNotificationMessage;\nuse App\\Repository\\MagazineRepository;\nuse App\\Repository\\PostRepository;\nuse App\\Repository\\TagLinkRepository;\nuse App\\Service\\PostManager;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Symfony\\Component\\EventDispatcher\\EventSubscriberInterface;\nuse Symfony\\Component\\Messenger\\MessageBusInterface;\n\nclass PostCreateSubscriber implements EventSubscriberInterface\n{\n    public function __construct(\n        private readonly MessageBusInterface $bus,\n        private readonly MagazineRepository $magazineRepository,\n        private readonly TagLinkRepository $tagLinkRepository,\n        private readonly PostRepository $postRepository,\n        private readonly PostManager $postManager,\n        private readonly EntityManagerInterface $entityManager,\n    ) {\n    }\n\n    public static function getSubscribedEvents(): array\n    {\n        return [\n            PostCreatedEvent::class => 'onPostCreated',\n        ];\n    }\n\n    public function onPostCreated(PostCreatedEvent $event): void\n    {\n        $event->post->magazine->postCount = $this->postRepository->countPostsByMagazine($event->post->magazine);\n\n        $this->entityManager->flush();\n\n        if (!$event->post->apId) {\n            $this->bus->dispatch(new CreateMessage($event->post->getId(), \\get_class($event->post)));\n        } else {\n            $this->handleMagazine($event->post);\n        }\n\n        $threshold = new \\DateTimeImmutable('now - 2 days');\n        if ($event->post->createdAt > $threshold) {\n            $this->bus->dispatch(new PostCreatedNotificationMessage($event->post->getId()));\n        }\n\n        if ($event->post->body) {\n            $this->bus->dispatch(new LinkEmbedMessage($event->post->body));\n        }\n    }\n\n    private function handleMagazine(Post $post): void\n    {\n        if ('random' !== $post->magazine->name) {\n            // do not overwrite matched magazines\n            return;\n        }\n        $tags = $this->tagLinkRepository->getTagsOfContent($post);\n\n        foreach ($tags as $tag) {\n            if ($magazine = $this->magazineRepository->findByTag($tag)) {\n                $this->postManager->changeMagazine($post, $magazine);\n                break;\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "src/EventSubscriber/Post/PostDeleteSubscriber.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\EventSubscriber\\Post;\n\nuse App\\Entity\\Post;\nuse App\\Entity\\User;\nuse App\\Event\\Post\\PostBeforeDeletedEvent;\nuse App\\Event\\Post\\PostBeforePurgeEvent;\nuse App\\Event\\Post\\PostDeletedEvent;\nuse App\\Message\\Notification\\PostDeletedNotificationMessage;\nuse App\\Repository\\PostRepository;\nuse App\\Service\\ActivityPub\\DeleteService;\nuse Symfony\\Component\\EventDispatcher\\EventSubscriberInterface;\nuse Symfony\\Component\\Messenger\\MessageBusInterface;\n\nclass PostDeleteSubscriber implements EventSubscriberInterface\n{\n    public function __construct(\n        private readonly MessageBusInterface $bus,\n        private readonly PostRepository $postRepository,\n        private readonly DeleteService $deleteService,\n    ) {\n    }\n\n    public static function getSubscribedEvents(): array\n    {\n        return [\n            PostDeletedEvent::class => 'onPostDeleted',\n            PostBeforePurgeEvent::class => 'onPostBeforePurge',\n            PostBeforeDeletedEvent::class => 'onPostBeforeDelete',\n        ];\n    }\n\n    public function onPostDeleted(PostDeletedEvent $event)\n    {\n        $this->bus->dispatch(new PostDeletedNotificationMessage($event->post->getId()));\n    }\n\n    public function onPostBeforePurge(PostBeforePurgeEvent $event): void\n    {\n        $event->post->magazine->postCount = $this->postRepository->countPostsByMagazine($event->post->magazine) - 1;\n        $this->onPostBeforeDeleteImpl($event->user, $event->post);\n    }\n\n    public function onPostBeforeDelete(PostBeforeDeletedEvent $event): void\n    {\n        $this->onPostBeforeDeleteImpl($event->user, $event->post);\n    }\n\n    public function onPostBeforeDeleteImpl(?User $user, Post $post): void\n    {\n        $this->bus->dispatch(new PostDeletedNotificationMessage($post->getId()));\n        $this->deleteService->announceIfNecessary($user, $post);\n    }\n}\n"
  },
  {
    "path": "src/EventSubscriber/Post/PostEditSubscriber.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\EventSubscriber\\Post;\n\nuse App\\Event\\Post\\PostEditedEvent;\nuse App\\Message\\ActivityPub\\Outbox\\UpdateMessage;\nuse App\\Message\\LinkEmbedMessage;\nuse App\\Message\\Notification\\PostEditedNotificationMessage;\nuse Symfony\\Component\\EventDispatcher\\EventSubscriberInterface;\nuse Symfony\\Component\\Messenger\\MessageBusInterface;\n\nclass PostEditSubscriber implements EventSubscriberInterface\n{\n    public function __construct(private readonly MessageBusInterface $bus)\n    {\n    }\n\n    public static function getSubscribedEvents(): array\n    {\n        return [\n            PostEditedEvent::class => 'onPostEdited',\n        ];\n    }\n\n    public function onPostEdited(PostEditedEvent $event): void\n    {\n        $this->bus->dispatch(new PostEditedNotificationMessage($event->post->getId()));\n        if ($event->post->body) {\n            $this->bus->dispatch(new LinkEmbedMessage($event->post->body));\n        }\n\n        if (!$event->post->apId) {\n            $this->bus->dispatch(new UpdateMessage($event->post->getId(), \\get_class($event->post), $event->editedBy->getId()));\n        }\n    }\n}\n"
  },
  {
    "path": "src/EventSubscriber/Post/PostShowSubscriber.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\EventSubscriber\\Post;\n\nuse App\\Entity\\Notification;\nuse App\\Entity\\Post;\nuse App\\Event\\Post\\PostHasBeenSeenEvent;\nuse App\\Repository\\NotificationRepository;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse JetBrains\\PhpStorm\\ArrayShape;\nuse Symfony\\Bundle\\SecurityBundle\\Security;\nuse Symfony\\Component\\EventDispatcher\\EventSubscriberInterface;\n\nclass PostShowSubscriber implements EventSubscriberInterface\n{\n    public function __construct(\n        private readonly Security $security,\n        private readonly NotificationRepository $repository,\n        private readonly EntityManagerInterface $entityManager,\n    ) {\n    }\n\n    #[ArrayShape([PostHasBeenSeenEvent::class => 'string'])]\n    public static function getSubscribedEvents(): array\n    {\n        return [\n            PostHasBeenSeenEvent::class => 'onShowEntry',\n        ];\n    }\n\n    public function onShowEntry(PostHasBeenSeenEvent $event): void\n    {\n        $this->readMessage($event->post);\n    }\n\n    private function readMessage(Post $post): void\n    {\n        if (!$this->security->getUser()) {\n            return;\n        }\n\n        $notifications = $this->repository->findUnreadPostNotifications($this->security->getUser(), $post);\n\n        if (!$notifications) {\n            return;\n        }\n\n        array_map(fn ($notification) => $notification->status = Notification::STATUS_READ, $notifications);\n\n        $this->entityManager->flush();\n    }\n}\n"
  },
  {
    "path": "src/EventSubscriber/PostComment/PostCommentCreateSubscriber.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\EventSubscriber\\PostComment;\n\nuse App\\Event\\PostComment\\PostCommentCreatedEvent;\nuse App\\Message\\ActivityPub\\Outbox\\CreateMessage;\nuse App\\Message\\LinkEmbedMessage;\nuse App\\Message\\Notification\\PostCommentCreatedNotificationMessage;\nuse Symfony\\Component\\EventDispatcher\\EventSubscriberInterface;\nuse Symfony\\Component\\Messenger\\MessageBusInterface;\nuse Symfony\\Contracts\\Cache\\CacheInterface;\n\nclass PostCommentCreateSubscriber implements EventSubscriberInterface\n{\n    public function __construct(private readonly CacheInterface $cache, private readonly MessageBusInterface $bus)\n    {\n    }\n\n    public static function getSubscribedEvents(): array\n    {\n        return [\n            PostCommentCreatedEvent::class => 'onPostCommentCreated',\n        ];\n    }\n\n    public function onPostCommentCreated(PostCommentCreatedEvent $event)\n    {\n        $this->cache->invalidateTags([\n            'post_'.$event->comment->post->getId(),\n            'post_comment_'.$event->comment->root?->getId() ?? $event->comment->getId(),\n        ]);\n\n        $threshold = new \\DateTimeImmutable('now - 2 days');\n        if ($event->comment->createdAt > $threshold) {\n            $this->bus->dispatch(new PostCommentCreatedNotificationMessage($event->comment->getId()));\n        }\n\n        if ($event->comment->body) {\n            $this->bus->dispatch(new LinkEmbedMessage($event->comment->body));\n        }\n\n        if (!$event->comment->apId) {\n            $this->bus->dispatch(new CreateMessage($event->comment->getId(), \\get_class($event->comment)));\n        }\n    }\n}\n"
  },
  {
    "path": "src/EventSubscriber/PostComment/PostCommentDeleteSubscriber.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\EventSubscriber\\PostComment;\n\nuse App\\Entity\\PostComment;\nuse App\\Entity\\User;\nuse App\\Event\\PostComment\\PostCommentBeforeDeletedEvent;\nuse App\\Event\\PostComment\\PostCommentBeforePurgeEvent;\nuse App\\Event\\PostComment\\PostCommentDeletedEvent;\nuse App\\Message\\Notification\\PostCommentDeletedNotificationMessage;\nuse App\\Service\\ActivityPub\\DeleteService;\nuse Symfony\\Component\\EventDispatcher\\EventSubscriberInterface;\nuse Symfony\\Component\\Messenger\\MessageBusInterface;\nuse Symfony\\Contracts\\Cache\\CacheInterface;\n\nclass PostCommentDeleteSubscriber implements EventSubscriberInterface\n{\n    public function __construct(\n        private readonly CacheInterface $cache,\n        private readonly MessageBusInterface $bus,\n        private readonly DeleteService $deleteService,\n    ) {\n    }\n\n    public static function getSubscribedEvents(): array\n    {\n        return [\n            PostCommentDeletedEvent::class => 'onPostCommentDeleted',\n            PostCommentBeforePurgeEvent::class => 'onPostCommentBeforePurge',\n            PostCommentBeforeDeletedEvent::class => 'onPostBeforeDelete',\n        ];\n    }\n\n    public function onPostCommentDeleted(PostCommentDeletedEvent $event): void\n    {\n        $this->cache->invalidateTags([\n            'post_'.$event->comment->post->getId(),\n            'post_comment_'.$event->comment->root?->getId() ?? $event->comment->getId(),\n        ]);\n\n        $this->bus->dispatch(new PostCommentDeletedNotificationMessage($event->comment->getId()));\n    }\n\n    public function onPostBeforeDelete(PostCommentBeforeDeletedEvent $event): void\n    {\n        $this->onPostCommentBeforeDeleteImpl($event->user, $event->comment);\n    }\n\n    public function onPostCommentBeforePurge(PostCommentBeforePurgeEvent $event): void\n    {\n        $this->onPostCommentBeforeDeleteImpl($event->user, $event->comment);\n    }\n\n    public function onPostCommentBeforeDeleteImpl(?User $user, PostComment $comment): void\n    {\n        $this->cache->invalidateTags([\n            'post_'.$comment->post->getId(),\n            'post_comment_'.$comment->root?->getId() ?? $comment->getId(),\n        ]);\n\n        $this->bus->dispatch(new PostCommentDeletedNotificationMessage($comment->getId()));\n        $this->deleteService->announceIfNecessary($user, $comment);\n    }\n}\n"
  },
  {
    "path": "src/EventSubscriber/PostComment/PostCommentEditSubscriber.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\EventSubscriber\\PostComment;\n\nuse App\\Event\\PostComment\\PostCommentEditedEvent;\nuse App\\Message\\ActivityPub\\Outbox\\UpdateMessage;\nuse App\\Message\\LinkEmbedMessage;\nuse App\\Message\\Notification\\PostCommentEditedNotificationMessage;\nuse Symfony\\Component\\EventDispatcher\\EventSubscriberInterface;\nuse Symfony\\Component\\Messenger\\MessageBusInterface;\nuse Symfony\\Contracts\\Cache\\CacheInterface;\n\nclass PostCommentEditSubscriber implements EventSubscriberInterface\n{\n    public function __construct(\n        private readonly CacheInterface $cache,\n        private readonly MessageBusInterface $bus,\n    ) {\n    }\n\n    public static function getSubscribedEvents(): array\n    {\n        return [\n            PostCommentEditedEvent::class => 'onPostCommentEdited',\n        ];\n    }\n\n    public function onPostCommentEdited(PostCommentEditedEvent $event)\n    {\n        $this->cache->invalidateTags([\n            'post_'.$event->comment->post->getId(),\n            'post_comment_'.$event->comment->root?->getId() ?? $event->comment->getId(),\n        ]);\n\n        $this->bus->dispatch(new PostCommentEditedNotificationMessage($event->comment->getId()));\n        if ($event->comment->body) {\n            $this->bus->dispatch(new LinkEmbedMessage($event->comment->body));\n        }\n\n        if (!$event->comment->apId) {\n            $this->bus->dispatch(new UpdateMessage($event->comment->getId(), \\get_class($event->comment), $event->editedBy->getId()));\n        }\n    }\n}\n"
  },
  {
    "path": "src/EventSubscriber/ReportApprovedSubscriber.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\EventSubscriber;\n\nuse App\\Event\\Report\\ReportApprovedEvent;\nuse App\\Service\\Notification\\ReportNotificationManager;\nuse Symfony\\Component\\EventDispatcher\\EventSubscriberInterface;\n\nclass ReportApprovedSubscriber implements EventSubscriberInterface\n{\n    public function __construct(\n        private readonly ReportNotificationManager $notificationManager,\n    ) {\n    }\n\n    public function onReportApproved(ReportApprovedEvent $reportedEvent): void\n    {\n        $this->notificationManager->sendReportApprovedNotification($reportedEvent->report);\n    }\n\n    public static function getSubscribedEvents(): array\n    {\n        return [\n            ReportApprovedEvent::class => 'onReportApproved',\n        ];\n    }\n}\n"
  },
  {
    "path": "src/EventSubscriber/ReportHandleSubscriber.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\EventSubscriber;\n\nuse App\\Entity\\Contracts\\ReportInterface;\nuse App\\Entity\\Report;\nuse App\\Entity\\User;\nuse App\\Event\\Entry\\EntryDeletedEvent;\nuse App\\Event\\EntryComment\\EntryCommentDeletedEvent;\nuse App\\Event\\Post\\PostDeletedEvent;\nuse App\\Event\\PostComment\\PostCommentDeletedEvent;\nuse App\\Repository\\ReportRepository;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Symfony\\Component\\EventDispatcher\\EventSubscriberInterface;\n\nclass ReportHandleSubscriber implements EventSubscriberInterface\n{\n    public function __construct(\n        private readonly ReportRepository $repository,\n        private readonly EntityManagerInterface $entityManager,\n    ) {\n    }\n\n    public static function getSubscribedEvents(): array\n    {\n        return [\n            EntryDeletedEvent::class => 'onEntryDeleted',\n            EntryCommentDeletedEvent::class => 'onEntryCommentDeleted',\n            PostDeletedEvent::class => 'onPostDeleted',\n            PostCommentDeletedEvent::class => 'onPostCommentDeleted',\n        ];\n    }\n\n    public function onEntryDeleted(EntryDeletedEvent $event): void\n    {\n        $this->handleReport($event->entry, $event->user);\n        $this->entityManager->flush();\n    }\n\n    public function onEntryCommentDeleted(EntryCommentDeletedEvent $event): void\n    {\n        $this->handleReport($event->comment, $event->user);\n        $this->entityManager->flush();\n    }\n\n    public function onPostDeleted(PostDeletedEvent $event): void\n    {\n        $this->handleReport($event->post, $event->user);\n        $this->entityManager->flush();\n    }\n\n    public function onPostCommentDeleted(PostCommentDeletedEvent $event): void\n    {\n        $this->handleReport($event->comment, $event->user);\n        $this->entityManager->flush();\n    }\n\n    private function handleReport(ReportInterface $subject, ?User $user): void\n    {\n        $report = $this->repository->findBySubject($subject);\n\n        if (!$report) {\n            return;\n        }\n\n        // If the user deletes their own post when a report has been lodged against it\n        //    the report should not be considered approved\n        if ($user && $user->getId() === $subject->getUser()->getId()) {\n            $report->status = Report::STATUS_CLOSED;\n        } else {\n            $report->status = Report::STATUS_APPROVED;\n            $report->consideredBy = $user;\n            $report->consideredAt = new \\DateTimeImmutable();\n        }\n\n        // @todo Notification for reporting, reported user\n        // @todo Reputation points for reporting user\n    }\n}\n"
  },
  {
    "path": "src/EventSubscriber/ReportRejectedSubscriber.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\EventSubscriber;\n\nuse App\\Event\\Report\\ReportRejectedEvent;\nuse App\\Service\\Notification\\ReportNotificationManager;\nuse Symfony\\Component\\EventDispatcher\\EventSubscriberInterface;\n\nclass ReportRejectedSubscriber implements EventSubscriberInterface\n{\n    public function __construct(\n        private readonly ReportNotificationManager $notificationManager,\n    ) {\n    }\n\n    public function onReportRejected(ReportRejectedEvent $reportedEvent): void\n    {\n        $this->notificationManager->sendReportRejectedNotification($reportedEvent->report);\n    }\n\n    public static function getSubscribedEvents(): array\n    {\n        return [\n            ReportRejectedEvent::class => 'onReportRejected',\n        ];\n    }\n}\n"
  },
  {
    "path": "src/EventSubscriber/SubjectReportedSubscriber.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\EventSubscriber;\n\nuse App\\Event\\Report\\SubjectReportedEvent;\nuse App\\Message\\ActivityPub\\Outbox\\FlagMessage;\nuse App\\Service\\Notification\\ReportNotificationManager;\nuse Psr\\Log\\LoggerInterface;\nuse Symfony\\Component\\EventDispatcher\\EventSubscriberInterface;\nuse Symfony\\Component\\Messenger\\MessageBusInterface;\n\nclass SubjectReportedSubscriber implements EventSubscriberInterface\n{\n    public function __construct(\n        private readonly MessageBusInterface $bus,\n        private readonly LoggerInterface $logger,\n        private readonly ReportNotificationManager $notificationManager,\n    ) {\n    }\n\n    public function onSubjectReported(SubjectReportedEvent $reportedEvent): void\n    {\n        $this->logger->debug($reportedEvent->report->reported->username.' was reported for '.$reportedEvent->report->reason);\n        $this->notificationManager->sendReportCreatedNotification($reportedEvent->report);\n        if (!$reportedEvent->report->magazine->apId and 'random' !== $reportedEvent->report->magazine->name) {\n            return;\n        }\n\n        if ($reportedEvent->report->magazine->apId) {\n            $this->logger->debug('was on a remote magazine, dispatching a new FlagMessage');\n        } elseif ('random' === $reportedEvent->report->magazine->name) {\n            $this->logger->debug('was on the random magazine, dispatching a new FlagMessage');\n        }\n        $this->bus->dispatch(new FlagMessage($reportedEvent->report->getId()));\n    }\n\n    public static function getSubscribedEvents(): array\n    {\n        return [\n            SubjectReportedEvent::class => 'onSubjectReported',\n        ];\n    }\n}\n"
  },
  {
    "path": "src/EventSubscriber/TwigGlobalSubscriber.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\EventSubscriber;\n\nuse App\\Controller\\User\\ThemeSettingsController;\nuse Symfony\\Component\\EventDispatcher\\EventSubscriberInterface;\nuse Symfony\\Component\\HttpKernel\\Event\\RequestEvent;\nuse Twig\\Environment;\n\nclass TwigGlobalSubscriber implements EventSubscriberInterface\n{\n    public function __construct(\n        private readonly Environment $twig,\n    ) {\n    }\n\n    public static function getSubscribedEvents(): array\n    {\n        return [\n            'kernel.request'::class => 'registerTwigGlobalUserSettings',\n        ];\n    }\n\n    public function registerTwigGlobalUserSettings(RequestEvent $request)\n    {\n        // determine the comment reply position, factoring in the infinite scroll setting (comment reply always on top when infinite scroll enabled)\n        $infiniteScroll = $request->getRequest()->cookies->get(ThemeSettingsController::KBIN_GENERAL_INFINITE_SCROLL, ThemeSettingsController::FALSE);\n        $commentReplyPosition = $request->getRequest()->cookies->get(ThemeSettingsController::KBIN_COMMENTS_REPLY_POSITION, ThemeSettingsController::TOP);\n        if (ThemeSettingsController::TRUE === $infiniteScroll) {\n            $commentReplyPosition = ThemeSettingsController::TOP;\n        }\n\n        $userSettings = [\n            'comment_reply_position' => $commentReplyPosition,\n        ];\n\n        $this->twig->addGlobal('user_settings', $userSettings);\n    }\n}\n"
  },
  {
    "path": "src/EventSubscriber/User/UserApplicationSubscriber.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\EventSubscriber\\User;\n\nuse App\\Event\\User\\UserApplicationApprovedEvent;\nuse App\\Event\\User\\UserApplicationRejectedEvent;\nuse App\\Message\\UserApplicationAnswerMessage;\nuse Psr\\Log\\LoggerInterface;\nuse Symfony\\Component\\EventDispatcher\\EventSubscriberInterface;\nuse Symfony\\Component\\Messenger\\MessageBusInterface;\n\nclass UserApplicationSubscriber implements EventSubscriberInterface\n{\n    public function __construct(\n        private readonly MessageBusInterface $bus,\n        private readonly LoggerInterface $logger,\n    ) {\n    }\n\n    public static function getSubscribedEvents(): array\n    {\n        return [\n            UserApplicationRejectedEvent::class => 'onUserApplicationRejected',\n            UserApplicationApprovedEvent::class => 'onUserApplicationApproved',\n        ];\n    }\n\n    public function onUserApplicationApproved(UserApplicationApprovedEvent $event): void\n    {\n        $this->logger->debug('Got a UserApplicationApprovedEvent for {u}', ['u' => $event->user->username]);\n        $this->bus->dispatch(new UserApplicationAnswerMessage($event->user->getId(), approved: true));\n    }\n\n    public function onUserApplicationRejected(UserApplicationRejectedEvent $event): void\n    {\n        $this->logger->debug('Got a UserApplicationRejectedEvent for {u}', ['u' => $event->user->username]);\n        $this->bus->dispatch(new UserApplicationAnswerMessage($event->user->getId(), approved: false));\n    }\n}\n"
  },
  {
    "path": "src/EventSubscriber/User/UserBlockSubscriber.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\EventSubscriber\\User;\n\nuse App\\Event\\User\\UserBlockEvent;\nuse App\\Utils\\SqlHelpers;\nuse Symfony\\Component\\EventDispatcher\\EventSubscriberInterface;\n\nclass UserBlockSubscriber implements EventSubscriberInterface\n{\n    public function __construct(\n        private readonly SqlHelpers $sqlHelpers,\n    ) {\n    }\n\n    public static function getSubscribedEvents(): array\n    {\n        return [\n            UserBlockEvent::class => 'onUserBlock',\n        ];\n    }\n\n    public function onUserBlock(UserBlockEvent $event): void\n    {\n        $this->sqlHelpers->clearCachedUserBlocks($event->blocker);\n    }\n}\n"
  },
  {
    "path": "src/EventSubscriber/User/UserEditedSubscriber.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\EventSubscriber\\User;\n\nuse App\\Entity\\User;\nuse App\\Event\\User\\UserEditedEvent;\nuse App\\Message\\ActivityPub\\Outbox\\UpdateMessage;\nuse App\\Repository\\UserRepository;\nuse Symfony\\Component\\EventDispatcher\\EventSubscriberInterface;\nuse Symfony\\Component\\Messenger\\MessageBusInterface;\n\nclass UserEditedSubscriber implements EventSubscriberInterface\n{\n    public function __construct(\n        private readonly UserRepository $userRepository,\n        private readonly MessageBusInterface $bus,\n    ) {\n    }\n\n    public static function getSubscribedEvents(): array\n    {\n        return [\n            UserEditedEvent::class => 'onUserEdited',\n        ];\n    }\n\n    public function onUserEdited(UserEditedEvent $event): void\n    {\n        $user = $this->userRepository->findOneBy(['id' => $event->userId]);\n        if (null === $user->apId) {\n            $this->bus->dispatch(new UpdateMessage($user->getId(), User::class, $user->getId()));\n        }\n    }\n}\n"
  },
  {
    "path": "src/EventSubscriber/VoteHandleSubscriber.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\EventSubscriber;\n\nuse App\\Entity\\Contracts\\VotableInterface;\nuse App\\Entity\\Entry;\nuse App\\Entity\\EntryComment;\nuse App\\Entity\\PostComment;\nuse App\\Event\\VoteEvent;\nuse App\\Message\\ActivityPub\\Outbox\\AnnounceMessage;\nuse App\\Message\\Notification\\VoteNotificationMessage;\nuse App\\Service\\CacheService;\nuse App\\Service\\FavouriteManager;\nuse App\\Service\\SettingsManager;\nuse App\\Utils\\DownvotesMode;\nuse App\\Utils\\SqlHelpers;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse JetBrains\\PhpStorm\\ArrayShape;\nuse Symfony\\Component\\EventDispatcher\\EventSubscriberInterface;\nuse Symfony\\Component\\Messenger\\MessageBusInterface;\nuse Symfony\\Contracts\\Cache\\CacheInterface;\n\nclass VoteHandleSubscriber implements EventSubscriberInterface\n{\n    public function __construct(\n        private readonly MessageBusInterface $bus,\n        private readonly CacheService $cacheService,\n        private readonly CacheInterface $cache,\n        private readonly FavouriteManager $favouriteManager,\n        private readonly SettingsManager $settingsManager,\n        private readonly EntityManagerInterface $entityManager,\n    ) {\n    }\n\n    #[ArrayShape([VoteEvent::class => 'string'])]\n    public static function getSubscribedEvents(): array\n    {\n        return [\n            VoteEvent::class => 'onVote',\n        ];\n    }\n\n    public function onVote(VoteEvent $event): void\n    {\n        if (VotableInterface::VOTE_DOWN === $event->vote->choice) {\n            $this->favouriteManager->toggle($event->vote->user, $event->votable, DownvotesMode::Disabled !== $this->settingsManager->getDownvotesMode() ? FavouriteManager::TYPE_UNLIKE : null);\n        }\n\n        $this->clearCache($event->votable);\n\n        $this->bus->dispatch(\n            new VoteNotificationMessage(\n                $event->votable->getId(),\n                SqlHelpers::getRealClassName($this->entityManager, $event->votable)\n            )\n        );\n\n        if (!$event->vote->user->apId && VotableInterface::VOTE_UP === $event->vote->choice && !$event->votedAgain) {\n            $this->bus->dispatch(\n                new AnnounceMessage(\n                    $event->vote->user->getId(),\n                    null,\n                    $event->votable->getId(),\n                    \\get_class($event->votable),\n                ),\n            );\n        }\n    }\n\n    public function clearCache(VotableInterface $votable)\n    {\n        $this->cache->delete($this->cacheService->getVotersCacheKey($votable));\n\n        if ($votable instanceof Entry) {\n            $this->cache->invalidateTags([\n                'entry_'.$votable->getId(),\n            ]);\n        }\n\n        if ($votable instanceof PostComment) {\n            $this->cache->invalidateTags([\n                'post_'.$votable->post->getId(),\n                'post_comment_'.$votable?->root?->getId() ?? $votable->getId(),\n            ]);\n        }\n\n        if ($votable instanceof EntryComment && $votable->root) {\n            $this->cache->invalidateTags(['entry_comment_'.$votable?->root?->getId() ?? $votable->getId()]);\n        }\n    }\n}\n"
  },
  {
    "path": "src/Exception/BadRequestDtoException.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Exception;\n\nuse JetBrains\\PhpStorm\\Pure;\nuse Symfony\\Component\\Validator\\ConstraintViolationList;\n\nclass BadRequestDtoException extends \\Exception\n{\n    private ConstraintViolationList $errors;\n\n    #[Pure]\n    public function __construct($message, $errors)\n    {\n        $this->errors = $errors;\n\n        parent::__construct($message);\n    }\n\n    public function getErrors(): ConstraintViolationList\n    {\n        return $this->errors;\n    }\n}\n"
  },
  {
    "path": "src/Exception/BadUrlException.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Exception;\n\nfinal class BadUrlException extends \\Exception\n{\n}\n"
  },
  {
    "path": "src/Exception/CorruptedFileException.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Exception;\n\nfinal class CorruptedFileException extends \\Exception\n{\n}\n"
  },
  {
    "path": "src/Exception/EntityNotFoundException.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Exception;\n\nclass EntityNotFoundException extends \\Exception\n{\n}\n"
  },
  {
    "path": "src/Exception/EntryLockedException.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Exception;\n\nfinal class EntryLockedException extends \\Exception\n{\n}\n"
  },
  {
    "path": "src/Exception/FavouritedAlreadyException.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Exception;\n\nfinal class FavouritedAlreadyException extends \\Exception\n{\n}\n"
  },
  {
    "path": "src/Exception/ImageDownloadTooLargeException.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Exception;\n\nfinal class ImageDownloadTooLargeException extends \\RuntimeException\n{\n}\n"
  },
  {
    "path": "src/Exception/InboxForwardingException.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Exception;\n\nuse Exception;\n\n/**\n * This exception is thrown when a remote instance A sends an activity message with an actor from instance B\n * and the activity has an audience from instance A as a target audience. @see https://www.w3.org/TR/activitypub/#inbox-forwarding\n * It indicates that the post should not be added from the activity, but fetched from the original instance.\n */\nclass InboxForwardingException extends \\Exception\n{\n    /**\n     * @param string $receivedFrom the domain from which the activity was received\n     * @param string $realOrigin   the original url where the activity can be found\n     */\n    public function __construct(public string $receivedFrom, public string $realOrigin, int $code = 0, ?\\Throwable $previous = null)\n    {\n        $message = \"Received a message from '$receivedFrom' which originated from '$realOrigin'. Though a audience on '$receivedFrom' was targeted and the post therefore forwarded.\";\n        parent::__construct($message, $code, $previous);\n    }\n}\n"
  },
  {
    "path": "src/Exception/InstanceBannedException.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Exception;\n\nfinal class InstanceBannedException extends \\Exception\n{\n}\n"
  },
  {
    "path": "src/Exception/InvalidApGetException.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Exception;\n\nfinal class InvalidApGetException extends \\Exception\n{\n}\n"
  },
  {
    "path": "src/Exception/InvalidApPostException.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Exception;\n\nfinal class InvalidApPostException extends \\Exception\n{\n    public function __construct(public ?string $messageStart = '', public ?string $url = null, public ?int $responseCode = null, public ?array $payload = null, int $code = 0, ?\\Throwable $previous = null)\n    {\n        $message = $this->messageStart;\n        $additions = [];\n        if ($url) {\n            $additions[] = $url;\n        }\n        if ($responseCode) {\n            $additions[] = \"status code: $responseCode\";\n        }\n        if ($payload) {\n            $jsonPayload = json_encode($this->payload);\n            $additions[] = $jsonPayload;\n        }\n        if (0 < \\sizeof($additions)) {\n            $message .= ': '.implode(', ', $additions);\n        }\n        parent::__construct($message, $code, $previous);\n    }\n}\n"
  },
  {
    "path": "src/Exception/InvalidApSignatureException.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Exception;\n\nfinal class InvalidApSignatureException extends \\Exception\n{\n}\n"
  },
  {
    "path": "src/Exception/InvalidUserPublicKeyException.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Exception;\n\nclass InvalidUserPublicKeyException extends \\Exception\n{\n    /**\n     * @param string $apProfileId the url of the user where the key cannot be extracted, is malformed, or does not exist\n     */\n    public function __construct(public string $apProfileId, int $code = 0, ?\\Throwable $previous = null)\n    {\n        $message = \"Unable to extract public key for '$apProfileId'.\";\n        parent::__construct($message, $code, $previous);\n    }\n}\n"
  },
  {
    "path": "src/Exception/InvalidWebfingerException.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Exception;\n\nfinal class InvalidWebfingerException extends \\Exception\n{\n}\n"
  },
  {
    "path": "src/Exception/PostLockedException.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Exception;\n\nfinal class PostLockedException extends \\Exception\n{\n}\n"
  },
  {
    "path": "src/Exception/PostingRestrictedException.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Exception;\n\nuse App\\Entity\\Magazine;\nuse App\\Entity\\User;\n\nfinal class PostingRestrictedException extends \\Exception\n{\n    public function __construct(public Magazine $magazine, public User|Magazine $actor)\n    {\n        if ($this->actor instanceof User) {\n            $username = $this->actor->getUsername();\n        } else {\n            $username = $this->actor->name;\n        }\n        $m = \\sprintf('Posting in magazine %s is restricted to mods and %s is not a mod', $this->magazine->apId ?? $this->magazine->name, $username);\n        parent::__construct($m, 0, null);\n    }\n}\n"
  },
  {
    "path": "src/Exception/SubjectHasBeenReportedException.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Exception;\n\nfinal class SubjectHasBeenReportedException extends \\Exception\n{\n}\n"
  },
  {
    "path": "src/Exception/TagBannedException.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Exception;\n\nfinal class TagBannedException extends \\Exception\n{\n}\n"
  },
  {
    "path": "src/Exception/UserBannedException.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Exception;\n\nfinal class UserBannedException extends \\Exception\n{\n}\n"
  },
  {
    "path": "src/Exception/UserBlockedException.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Exception;\n\nfinal class UserBlockedException extends \\Exception\n{\n}\n"
  },
  {
    "path": "src/Exception/UserCannotBeBanned.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Exception;\n\nfinal class UserCannotBeBanned extends \\Exception\n{\n}\n"
  },
  {
    "path": "src/Exception/UserCannotReceiveDirectMessage.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Exception;\n\nuse App\\Entity\\User;\n\nfinal class UserCannotReceiveDirectMessage extends \\Exception\n{\n    public function __construct(User $author, User $recipient, int $code = 0, ?\\Throwable $previous = null)\n    {\n        $message = \"$author->username tried to sent a direct message to $recipient->username but they cannot receive it\";\n        parent::__construct($message, $code, $previous);\n    }\n}\n"
  },
  {
    "path": "src/Exception/UserDeletedException.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Exception;\n\nfinal class UserDeletedException extends \\Exception\n{\n}\n"
  },
  {
    "path": "src/Factory/ActivityPub/ActivityFactory.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Factory\\ActivityPub;\n\nuse App\\Entity\\Contracts\\ActivityPubActivityInterface;\nuse App\\Entity\\Entry;\nuse App\\Entity\\EntryComment;\nuse App\\Entity\\Message;\nuse App\\Entity\\Post;\nuse App\\Entity\\PostComment;\nuse App\\Repository\\TagLinkRepository;\n\nclass ActivityFactory\n{\n    public function __construct(\n        private readonly TagLinkRepository $tagLinkRepository,\n        private readonly EntryPageFactory $pageFactory,\n        private readonly EntryCommentNoteFactory $entryNoteFactory,\n        private readonly PostNoteFactory $postNoteFactory,\n        private readonly PostCommentNoteFactory $postCommentNoteFactory,\n        private readonly MessageFactory $messageFactory,\n    ) {\n    }\n\n    public function create(ActivityPubActivityInterface $activity, bool $context = false): array\n    {\n        return match (true) {\n            $activity instanceof Entry => $this->pageFactory->create($activity, $this->tagLinkRepository->getTagsOfContent($activity), $context),\n            $activity instanceof EntryComment => $this->entryNoteFactory->create($activity, $this->tagLinkRepository->getTagsOfContent($activity), $context),\n            $activity instanceof Post => $this->postNoteFactory->create($activity, $this->tagLinkRepository->getTagsOfContent($activity), $context),\n            $activity instanceof PostComment => $this->postCommentNoteFactory->create($activity, $this->tagLinkRepository->getTagsOfContent($activity), $context),\n            $activity instanceof Message => $this->messageFactory->build($activity, $context),\n            default => throw new \\LogicException('Cannot handle activity of type '.\\get_class($activity)),\n        };\n    }\n}\n"
  },
  {
    "path": "src/Factory/ActivityPub/AddRemoveFactory.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Factory\\ActivityPub;\n\nuse App\\Entity\\Activity;\nuse App\\Entity\\Entry;\nuse App\\Entity\\Magazine;\nuse App\\Entity\\User;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Symfony\\Component\\Routing\\Generator\\UrlGeneratorInterface;\n\nclass AddRemoveFactory\n{\n    public function __construct(\n        private readonly UrlGeneratorInterface $urlGenerator,\n        private readonly EntityManagerInterface $entityManager,\n    ) {\n    }\n\n    public function buildAddModerator(User $actor, User $added, Magazine $magazine): Activity\n    {\n        $url = null !== $magazine->apId ? $magazine->apAttributedToUrl : $this->urlGenerator->generate(\n            'ap_magazine_moderators', ['name' => $magazine->name], UrlGeneratorInterface::ABSOLUTE_URL\n        );\n\n        return $this->build($actor, $added, $magazine, 'Add', $url);\n    }\n\n    public function buildRemoveModerator(User $actor, User $removed, Magazine $magazine): Activity\n    {\n        $url = null !== $magazine->apId ? $magazine->apAttributedToUrl : $this->urlGenerator->generate(\n            'ap_magazine_moderators', ['name' => $magazine->name], UrlGeneratorInterface::ABSOLUTE_URL\n        );\n\n        return $this->build($actor, $removed, $magazine, 'Remove', $url);\n    }\n\n    public function buildAddPinnedPost(User $actor, Entry $added): Activity\n    {\n        $url = null !== $added->magazine->apId ? $added->magazine->apFeaturedUrl : $this->urlGenerator->generate(\n            'ap_magazine_pinned', ['name' => $added->magazine->name], UrlGeneratorInterface::ABSOLUTE_URL\n        );\n\n        return $this->build($actor, $added, $added->magazine, 'Add', $url);\n    }\n\n    public function buildRemovePinnedPost(User $actor, Entry $removed): Activity\n    {\n        $url = null !== $removed->magazine->apId ? $removed->magazine->apFeaturedUrl : $this->urlGenerator->generate(\n            'ap_magazine_pinned', ['name' => $removed->magazine->name], UrlGeneratorInterface::ABSOLUTE_URL\n        );\n\n        return $this->build($actor, $removed, $removed->magazine, 'Remove', $url);\n    }\n\n    private function build(User $actor, User|Entry $targetObject, Magazine $magazine, string $type, string $collectionUrl): Activity\n    {\n        $activity = new Activity($type);\n        $activity->audience = $magazine;\n        $activity->setActor($actor);\n        $activity->setObject($targetObject);\n        $activity->targetString = $collectionUrl;\n\n        $this->entityManager->persist($activity);\n        $this->entityManager->flush();\n\n        return $activity;\n    }\n}\n"
  },
  {
    "path": "src/Factory/ActivityPub/BlockFactory.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Factory\\ActivityPub;\n\nuse App\\Entity\\Activity;\nuse App\\Entity\\MagazineBan;\nuse App\\Entity\\User;\nuse Doctrine\\ORM\\EntityManagerInterface;\n\nclass BlockFactory\n{\n    public function __construct(\n        private readonly EntityManagerInterface $entityManager,\n    ) {\n    }\n\n    public function createActivityFromMagazineBan(MagazineBan $magazineBan): Activity\n    {\n        $activity = new Activity('Block');\n        $activity->audience = $magazineBan->magazine;\n        $activity->setActor($magazineBan->bannedBy);\n        $activity->setObject($magazineBan);\n\n        $this->entityManager->persist($activity);\n\n        return $activity;\n    }\n\n    public function createActivityFromInstanceBan(User $bannedUser, User $actor): Activity\n    {\n        $activity = new Activity('Block');\n        $activity->setActor($actor);\n        $activity->setObject($bannedUser);\n\n        $this->entityManager->persist($activity);\n\n        return $activity;\n    }\n}\n"
  },
  {
    "path": "src/Factory/ActivityPub/CollectionFactory.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Factory\\ActivityPub;\n\nuse App\\Entity\\Magazine;\nuse App\\Entity\\Moderator;\nuse App\\Entity\\User;\nuse App\\Repository\\ActivityRepository;\nuse App\\Repository\\EntryRepository;\nuse App\\Repository\\MagazineRepository;\nuse App\\Repository\\TagLinkRepository;\nuse App\\Service\\ActivityPub\\ActivityJsonBuilder;\nuse App\\Service\\ActivityPub\\ContextsProvider;\nuse App\\Service\\ActivityPub\\Wrapper\\CollectionInfoWrapper;\nuse App\\Service\\ActivityPub\\Wrapper\\CollectionItemsWrapper;\nuse App\\Service\\ActivityPubManager;\nuse JetBrains\\PhpStorm\\ArrayShape;\nuse Symfony\\Component\\Routing\\Generator\\UrlGeneratorInterface;\n\nclass CollectionFactory\n{\n    public function __construct(\n        private readonly ContextsProvider $contextsProvider,\n        private readonly CollectionInfoWrapper $collectionInfoWrapper,\n        private readonly CollectionItemsWrapper $collectionItemsWrapper,\n        private readonly ActivityJsonBuilder $activityJsonBuilder,\n        private readonly ActivityRepository $activityRepository,\n        private readonly ActivityPubManager $manager,\n        private readonly MagazineRepository $magazineRepository,\n        private readonly UrlGeneratorInterface $urlGenerator,\n        private readonly EntryRepository $entryRepository,\n        private readonly TagLinkRepository $tagLinkRepository,\n        private readonly EntryPageFactory $entryFactory,\n    ) {\n    }\n\n    #[ArrayShape([\n        '@context' => 'string',\n        'type' => 'string',\n        'id' => 'string',\n        'first' => 'string',\n        'totalItems' => 'int',\n    ])]\n    public function getUserOutboxCollection(User $user, bool $includeContext = true): array\n    {\n        $fanta = $this->activityRepository->getOutboxActivitiesOfUser($user);\n\n        return $this->collectionInfoWrapper->build(\n            'ap_user_outbox',\n            ['username' => $user->username],\n            $fanta->count(),\n            $includeContext,\n        );\n    }\n\n    #[ArrayShape([\n        '@context' => 'string',\n        'type' => 'string',\n        'partOf' => 'string',\n        'id' => 'string',\n        'totalItems' => 'int',\n        'orderedItems' => 'array',\n    ])]\n    public function getUserOutboxCollectionItems(User $user, int $page, bool $includeContext = true): array\n    {\n        $activity = $this->activityRepository->getOutboxActivitiesOfUser($user);\n        $activity->setCurrentPage($page);\n        $activity->setMaxPerPage(10);\n\n        $items = [];\n        foreach ($activity as $item) {\n            $json = $this->activityJsonBuilder->buildActivityJson($item);\n            unset($json['@context']);\n            $items[] = $json;\n        }\n\n        return $this->collectionItemsWrapper->build(\n            'ap_user_outbox',\n            ['username' => $user->username],\n            $activity,\n            $items,\n            $page,\n            $includeContext,\n        );\n    }\n\n    #[ArrayShape([\n        '@context' => 'array',\n        'type' => 'string',\n        'id' => 'string',\n        'totalItems' => 'int',\n        'orderedItems' => 'array',\n    ])]\n    public function getMagazineModeratorCollection(Magazine $magazine, bool $includeContext = true): array\n    {\n        $moderators = $this->magazineRepository->findModerators($magazine, perPage: $magazine->moderators->count());\n\n        $items = [];\n        foreach ($moderators->getCurrentPageResults() as /* @var Moderator $mod */ $mod) {\n            $actor = $mod->user;\n            $items[] = $this->manager->getActorProfileId($actor);\n        }\n\n        $result = [\n            '@context' => $this->contextsProvider->referencedContexts(),\n            'type' => 'OrderedCollection',\n            'id' => $this->urlGenerator->generate('ap_magazine_moderators', ['name' => $magazine->name], UrlGeneratorInterface::ABSOLUTE_URL),\n            'totalItems' => \\sizeof($items),\n            'orderedItems' => $items,\n        ];\n\n        if (!$includeContext) {\n            unset($result['@context']);\n        }\n\n        return $result;\n    }\n\n    #[ArrayShape([\n        '@context' => 'array',\n        'type' => 'string',\n        'id' => 'string',\n        'totalItems' => 'int',\n        'orderedItems' => 'array',\n    ])]\n    public function getMagazinePinnedCollection(Magazine $magazine, bool $includeContext = true): array\n    {\n        $pinned = $this->entryRepository->findPinned($magazine);\n\n        $items = [];\n        foreach ($pinned as $entry) {\n            $items[] = $this->entryFactory->create($entry, $this->tagLinkRepository->getTagsOfContent($entry));\n        }\n\n        $result = [\n            '@context' => $this->contextsProvider->referencedContexts(),\n            'type' => 'OrderedCollection',\n            'id' => $this->urlGenerator->generate('ap_magazine_pinned', ['name' => $magazine->name], UrlGeneratorInterface::ABSOLUTE_URL),\n            'totalItems' => \\sizeof($items),\n            'orderedItems' => $items,\n        ];\n\n        if (!$includeContext) {\n            unset($result['@context']);\n        }\n\n        return $result;\n    }\n}\n"
  },
  {
    "path": "src/Factory/ActivityPub/EntryCommentNoteFactory.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Factory\\ActivityPub;\n\nuse App\\Entity\\Contracts\\ActivityPubActivityInterface;\nuse App\\Entity\\EntryComment;\nuse App\\Markdown\\MarkdownConverter;\nuse App\\Markdown\\RenderTarget;\nuse App\\Service\\ActivityPub\\ApHttpClientInterface;\nuse App\\Service\\ActivityPub\\ContextsProvider;\nuse App\\Service\\ActivityPub\\Wrapper\\ImageWrapper;\nuse App\\Service\\ActivityPub\\Wrapper\\MentionsWrapper;\nuse App\\Service\\ActivityPub\\Wrapper\\TagsWrapper;\nuse App\\Service\\ActivityPubManager;\nuse App\\Service\\MentionManager;\nuse Symfony\\Component\\Routing\\Generator\\UrlGeneratorInterface;\n\nclass EntryCommentNoteFactory\n{\n    public function __construct(\n        private readonly UrlGeneratorInterface $urlGenerator,\n        private readonly ContextsProvider $contextProvider,\n        private readonly GroupFactory $groupFactory,\n        private readonly ImageWrapper $imageWrapper,\n        private readonly TagsWrapper $tagsWrapper,\n        private readonly MentionsWrapper $mentionsWrapper,\n        private readonly MentionManager $mentionManager,\n        private readonly EntryPageFactory $pageFactory,\n        private readonly ApHttpClientInterface $client,\n        private readonly ActivityPubManager $activityPubManager,\n        private readonly MarkdownConverter $markdownConverter,\n    ) {\n    }\n\n    public function create(EntryComment $comment, array $tags, bool $context = false): array\n    {\n        if ($context) {\n            $note['@context'] = $this->contextProvider->referencedContexts();\n        }\n\n        if ('random' !== $comment->magazine->name && !$comment->magazine->apId) { // @todo\n            $tags[] = $comment->magazine->name;\n        }\n\n        $cc = [$this->groupFactory->getActivityPubId($comment->magazine)];\n        if ($followersUrl = $comment->user->getFollowerUrl($this->client, $this->urlGenerator, null !== $comment->apId)) {\n            $cc[] = $followersUrl;\n        }\n\n        $note = array_merge($note ?? [], [\n            'id' => $this->getActivityPubId($comment),\n            'type' => 'Note',\n            'attributedTo' => $this->activityPubManager->getActorProfileId($comment->user),\n            'inReplyTo' => $this->getReplyTo($comment),\n            'to' => [\n                ActivityPubActivityInterface::PUBLIC_URL,\n            ],\n            'cc' => $cc,\n            'audience' => $this->groupFactory->getActivityPubId($comment->magazine),\n            'sensitive' => $comment->entry->isAdult(),\n            'content' => $this->markdownConverter->convertToHtml(\n                $comment->body,\n                context: [MarkdownConverter::RENDER_TARGET => RenderTarget::ActivityPub]\n            ),\n            'mediaType' => 'text/html',\n            'source' => $comment->body ? [\n                'content' => $comment->body,\n                'mediaType' => 'text/markdown',\n            ] : null,\n            'url' => $this->getActivityPubId($comment),\n            'tag' => array_merge(\n                $this->tagsWrapper->build($tags),\n                $this->mentionsWrapper->build($comment->mentions ?? [], $comment->body)\n            ),\n            'published' => $comment->createdAt->format(DATE_ATOM),\n        ]);\n\n        $note['contentMap'] = [\n            $comment->lang => $note['content'],\n        ];\n\n        if ($comment->image) {\n            $note = $this->imageWrapper->build($note, $comment->image, $comment->getShortTitle());\n        }\n\n        $mentions = [];\n        foreach ($comment->mentions ?? [] as $mention) {\n            try {\n                $profileId = $this->activityPubManager->findActorOrCreate($mention)?->apProfileId;\n                if ($profileId) {\n                    $mentions[] = $profileId;\n                }\n            } catch (\\Exception $e) {\n                continue;\n            }\n        }\n\n        $note['to'] = array_values(\n            array_unique(\n                array_merge(\n                    $note['to'],\n                    $mentions,\n                    $this->activityPubManager->createCcFromBody($comment->body),\n                    [$this->getReplyToAuthor($comment)],\n                )\n            )\n        );\n\n        return $note;\n    }\n\n    public function getActivityPubId(EntryComment $comment): string\n    {\n        if ($comment->apId) {\n            return $comment->apId;\n        }\n\n        return $this->urlGenerator->generate(\n            'ap_entry_comment',\n            [\n                'magazine_name' => $comment->magazine->name,\n                'entry_id' => $comment->entry->getId(),\n                'comment_id' => $comment->getId(),\n            ],\n            UrlGeneratorInterface::ABSOLUTE_URL\n        );\n    }\n\n    private function getReplyTo(EntryComment $comment): string\n    {\n        return $comment->parent ? $this->getActivityPubId($comment->parent) : $this->pageFactory->getActivityPubId($comment->entry);\n    }\n\n    private function getReplyToAuthor(EntryComment $comment): string\n    {\n        return $comment->parent\n            ? $this->activityPubManager->getActorProfileId($comment->parent->user)\n            : $this->activityPubManager->getActorProfileId($comment->entry->user);\n    }\n}\n"
  },
  {
    "path": "src/Factory/ActivityPub/EntryPageFactory.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Factory\\ActivityPub;\n\nuse App\\Entity\\Contracts\\ActivityPubActivityInterface;\nuse App\\Entity\\Entry;\nuse App\\Markdown\\MarkdownConverter;\nuse App\\Markdown\\RenderTarget;\nuse App\\Service\\ActivityPub\\ApHttpClientInterface;\nuse App\\Service\\ActivityPub\\ContextsProvider;\nuse App\\Service\\ActivityPub\\Wrapper\\ImageWrapper;\nuse App\\Service\\ActivityPub\\Wrapper\\MentionsWrapper;\nuse App\\Service\\ActivityPub\\Wrapper\\TagsWrapper;\nuse App\\Service\\ActivityPubManager;\nuse Symfony\\Component\\Routing\\Generator\\UrlGeneratorInterface;\n\nclass EntryPageFactory\n{\n    public function __construct(\n        private readonly UrlGeneratorInterface $urlGenerator,\n        private readonly ContextsProvider $contextProvider,\n        private readonly GroupFactory $groupFactory,\n        private readonly ImageWrapper $imageWrapper,\n        private readonly TagsWrapper $tagsWrapper,\n        private readonly MentionsWrapper $mentionsWrapper,\n        private readonly ApHttpClientInterface $client,\n        private readonly ActivityPubManager $activityPubManager,\n        private readonly MarkdownConverter $markdownConverter,\n    ) {\n    }\n\n    public function create(Entry $entry, array $tags, bool $context = false): array\n    {\n        if ($context) {\n            $page['@context'] = $this->contextProvider->referencedContexts();\n        }\n\n        if ('random' !== $entry->magazine->name && !$entry->magazine->apId) { // @todo\n            $tags[] = $entry->magazine->name;\n        }\n\n        $cc = [];\n        if ($followersUrl = $entry->user->getFollowerUrl($this->client, $this->urlGenerator, null !== $entry->apId)) {\n            $cc[] = $followersUrl;\n        }\n\n        $page = array_merge($page ?? [], [\n            'id' => $this->getActivityPubId($entry),\n            'type' => 'Page',\n            'attributedTo' => $this->activityPubManager->getActorProfileId($entry->user),\n            'inReplyTo' => null,\n            'to' => [\n                $this->groupFactory->getActivityPubId($entry->magazine),\n                ActivityPubActivityInterface::PUBLIC_URL,\n            ],\n            'cc' => $cc,\n            'name' => $entry->title,\n            'audience' => $this->groupFactory->getActivityPubId($entry->magazine),\n            'content' => $entry->body ? $this->markdownConverter->convertToHtml(\n                $entry->body,\n                context: [MarkdownConverter::RENDER_TARGET => RenderTarget::ActivityPub]\n            ) : null,\n            'summary' => $entry->getShortTitle().' '.implode(\n                ' ',\n                array_map(fn ($val) => '#'.$val, $tags)\n            ),\n            'mediaType' => 'text/html',\n            'source' => $entry->body ? [\n                'content' => $entry->body,\n                'mediaType' => 'text/markdown',\n            ] : null,\n            'tag' => array_merge(\n                $this->tagsWrapper->build($tags),\n                $this->mentionsWrapper->build($entry->mentions ?? [], $entry->body)\n            ),\n            'commentsEnabled' => !$entry->isLocked,\n            'sensitive' => $entry->isAdult(),\n            'stickied' => $entry->sticky,\n            'published' => $entry->createdAt->format(DATE_ATOM),\n        ]);\n\n        $page['contentMap'] = [\n            $entry->lang => $page['content'],\n        ];\n\n        if ($entry->url) {\n            $page['source'] = $entry->url;\n            $page['attachment'][] = [\n                'href' => $entry->url,\n                'type' => 'Link',\n            ];\n        }\n\n        if ($entry->image) {\n            // We do not know whether the image comes from an embed.\n            // Even if $entry->hasEmbed is true that does not mean that the image is from the embed\n            $page = $this->imageWrapper->build($page, $entry->image, $entry->title);\n        }\n\n        if ($entry->body) {\n            $page['to'] = array_unique(\n                array_merge($page['to'], $this->activityPubManager->createCcFromBody($entry->body))\n            );\n        }\n\n        return $page;\n    }\n\n    public function getActivityPubId(Entry $entry): string\n    {\n        if ($entry->apId) {\n            return $entry->apId;\n        }\n\n        return $this->urlGenerator->generate(\n            'ap_entry',\n            ['magazine_name' => $entry->magazine->name, 'entry_id' => $entry->getId()],\n            UrlGeneratorInterface::ABSOLUTE_URL\n        );\n    }\n}\n"
  },
  {
    "path": "src/Factory/ActivityPub/FlagFactory.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Factory\\ActivityPub;\n\nuse App\\Entity\\Activity;\nuse App\\Entity\\Report;\nuse Doctrine\\ORM\\EntityManagerInterface;\n\nclass FlagFactory\n{\n    public function __construct(\n        private readonly EntityManagerInterface $entityManager,\n    ) {\n    }\n\n    public function build(Report $report): Activity\n    {\n        $activity = new Activity('Flag');\n        $activity->setObject($report->getSubject());\n        $activity->objectUser = $report->reported;\n        $activity->setActor($report->reporting);\n        $activity->contentString = $report->reason;\n        $activity->audience = $report->magazine;\n\n        $this->entityManager->persist($activity);\n        $this->entityManager->flush();\n\n        return $activity;\n    }\n}\n"
  },
  {
    "path": "src/Factory/ActivityPub/GroupFactory.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Factory\\ActivityPub;\n\nuse App\\Entity\\Magazine;\nuse App\\Markdown\\MarkdownConverter;\nuse App\\Markdown\\RenderTarget;\nuse App\\Service\\ActivityPub\\ContextsProvider;\nuse App\\Service\\ImageManagerInterface;\nuse Symfony\\Component\\Routing\\Generator\\UrlGeneratorInterface;\n\nclass GroupFactory\n{\n    public function __construct(\n        private readonly UrlGeneratorInterface $urlGenerator,\n        private readonly MarkdownConverter $markdownConverter,\n        private readonly ContextsProvider $contextProvider,\n        private readonly ImageManagerInterface $imageManager,\n    ) {\n    }\n\n    public function create(Magazine $magazine, bool $includeContext = true): array\n    {\n        $markdownSummary = $magazine->description ?? '';\n\n        if (!empty($magazine->rules)) {\n            $markdownSummary .= (!empty($markdownSummary) ? \"\\r\\n\\r\\n\" : '').\"### Rules\\r\\n\\r\\n\".$magazine->rules;\n        }\n\n        $summary = !empty($markdownSummary) ? $this->markdownConverter->convertToHtml(\n            $markdownSummary,\n            context: [MarkdownConverter::RENDER_TARGET => RenderTarget::ActivityPub],\n        ) : '';\n\n        $group = [\n            'type' => 'Group',\n            '@context' => $this->contextProvider->referencedContexts(),\n            'id' => $this->getActivityPubId($magazine),\n            'name' => $magazine->title, // lemmy\n            'preferredUsername' => $magazine->name,\n            'inbox' => $this->urlGenerator->generate(\n                'ap_magazine_inbox',\n                ['name' => $magazine->name],\n                UrlGeneratorInterface::ABSOLUTE_URL\n            ),\n            'outbox' => $this->urlGenerator->generate(\n                'ap_magazine_outbox',\n                ['name' => $magazine->name],\n                UrlGeneratorInterface::ABSOLUTE_URL\n            ),\n            'followers' => $this->getActivityPubFollowersId($magazine),\n            'featured' => $this->urlGenerator->generate(\n                'ap_magazine_pinned',\n                ['name' => $magazine->name],\n                UrlGeneratorInterface::ABSOLUTE_URL\n            ),\n            'url' => $this->getActivityPubId($magazine),\n            'publicKey' => [\n                'owner' => $this->getActivityPubId($magazine),\n                'id' => $this->getActivityPubId($magazine).'#main-key',\n                'publicKeyPem' => $magazine->publicKey,\n            ],\n            'summary' => $summary,\n            'source' => [\n                'content' => $markdownSummary,\n                'mediaType' => 'text/markdown',\n            ],\n            'sensitive' => $magazine->isAdult,\n            'attributedTo' => $this->urlGenerator->generate(\n                'ap_magazine_moderators',\n                ['name' => $magazine->name],\n                UrlGeneratorInterface::ABSOLUTE_URL\n            ),\n            'postingRestrictedToMods' => $magazine->postingRestrictedToMods,\n            'discoverable' => $magazine->apDiscoverable,\n            'indexable' => $magazine->apIndexable,\n            'endpoints' => [\n                'sharedInbox' => $this->urlGenerator->generate(\n                    'ap_shared_inbox',\n                    [],\n                    UrlGeneratorInterface::ABSOLUTE_URL\n                ),\n            ],\n            'published' => $magazine->createdAt->format(DATE_ATOM),\n            'updated' => $magazine->lastActive ?\n                $magazine->lastActive->format(DATE_ATOM)\n                : $magazine->createdAt->format(DATE_ATOM),\n        ];\n\n        if ($magazine->icon) {\n            $group['icon'] = [\n                'type' => 'Image',\n                'url' => $this->imageManager->getUrl($magazine->icon),\n            ];\n        }\n\n        if ($magazine->banner) {\n            $group['image'] = [\n                'type' => 'Image',\n                'url' => $this->imageManager->getUrl($magazine->banner),\n            ];\n        }\n\n        if (!$includeContext) {\n            unset($group['@context']);\n        }\n\n        return $group;\n    }\n\n    public function getActivityPubId(Magazine $magazine): string\n    {\n        if ($magazine->apId) {\n            return $magazine->apProfileId;\n        }\n\n        return $this->urlGenerator->generate(\n            'ap_magazine',\n            ['name' => $magazine->name],\n            UrlGeneratorInterface::ABSOLUTE_URL\n        );\n    }\n\n    public function getActivityPubFollowersId(Magazine $magazine): string\n    {\n        if ($magazine->apId) {\n            return $magazine->apFollowersUrl;\n        }\n\n        return $this->urlGenerator->generate(\n            'ap_magazine_followers',\n            ['name' => $magazine->name],\n            UrlGeneratorInterface::ABSOLUTE_URL\n        );\n    }\n}\n"
  },
  {
    "path": "src/Factory/ActivityPub/InstanceFactory.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Factory\\ActivityPub;\n\nuse App\\Repository\\UserRepository;\nuse App\\Service\\ActivityPub\\ApHttpClientInterface;\nuse App\\Service\\ActivityPub\\ContextsProvider;\nuse Symfony\\Component\\Routing\\Generator\\UrlGeneratorInterface;\n\nclass InstanceFactory\n{\n    public function __construct(\n        private string $kbinDomain,\n        private readonly ApHttpClientInterface $client,\n        private readonly UrlGeneratorInterface $urlGenerator,\n        private readonly ContextsProvider $contextProvider,\n        private readonly UserRepository $userRepository,\n    ) {\n    }\n\n    public function create(bool $includeContext = true): array\n    {\n        $actor = $this->urlGenerator->generate('ap_instance', [], UrlGeneratorInterface::ABSOLUTE_URL);\n\n        $result = [\n            '@context' => $this->contextProvider->referencedContexts(),\n            'id' => $actor,\n            'type' => 'Application',\n            'name' => 'Mbin',\n            'inbox' => $this->urlGenerator->generate('ap_instance_inbox', [], UrlGeneratorInterface::ABSOLUTE_URL),\n            'outbox' => $this->urlGenerator->generate('ap_instance_outbox', [], UrlGeneratorInterface::ABSOLUTE_URL),\n            'preferredUsername' => $this->kbinDomain,\n            'manuallyApprovesFollowers' => true,\n            'publicKey' => [\n                'id' => $actor.'#main-key',\n                'owner' => $actor,\n                'publicKeyPem' => $this->client->getInstancePublicKey(),\n            ],\n            'published' => ($this->userRepository->findOldestUser()?->createdAt ?? new \\DateTimeImmutable())->format(DATE_ATOM),\n        ];\n\n        if (!$includeContext) {\n            unset($result['@context']);\n        }\n\n        return $result;\n    }\n\n    public function getTargetUrl(): string\n    {\n        return 'https://'.$this->kbinDomain;\n    }\n}\n"
  },
  {
    "path": "src/Factory/ActivityPub/LockFactory.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Factory\\ActivityPub;\n\nuse App\\Entity\\Activity;\nuse App\\Entity\\Entry;\nuse App\\Entity\\Post;\nuse App\\Entity\\User;\nuse Doctrine\\ORM\\EntityManagerInterface;\n\nclass LockFactory\n{\n    public function __construct(\n        private readonly EntityManagerInterface $entityManager,\n    ) {\n    }\n\n    public function build(User $actor, Entry|Post $targetObject): Activity\n    {\n        $activity = new Activity('Lock');\n        $activity->audience = $targetObject->magazine;\n        $activity->setActor($actor);\n        $activity->setObject($targetObject);\n\n        $this->entityManager->persist($activity);\n        $this->entityManager->flush();\n\n        return $activity;\n    }\n}\n"
  },
  {
    "path": "src/Factory/ActivityPub/MessageFactory.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Factory\\ActivityPub;\n\nuse App\\Entity\\Message;\nuse App\\Entity\\User;\nuse App\\Markdown\\MarkdownConverter;\nuse App\\Markdown\\RenderTarget;\nuse App\\Service\\ActivityPub\\ContextsProvider;\nuse Symfony\\Component\\Routing\\Generator\\UrlGeneratorInterface;\n\nclass MessageFactory\n{\n    public function __construct(\n        private readonly UrlGeneratorInterface $urlGenerator,\n        private readonly MarkdownConverter $markdownConverter,\n        private readonly ContextsProvider $contextsProvider,\n    ) {\n    }\n\n    public function build(Message $message, bool $includeContext = true): array\n    {\n        $actorUrl = null === $message->sender->apId ? $this->urlGenerator->generate('ap_user', ['username' => $message->sender->username], UrlGeneratorInterface::ABSOLUTE_URL) : $message->sender->apPublicUrl;\n        $toUsers = array_values(array_filter($message->thread->participants->toArray(), fn (User $item) => $item->getId() !== $message->sender->getId()));\n        $to = array_map(fn (User $user) => !$user->apId ? $this->urlGenerator->generate('ap_user', ['username' => $user->username], UrlGeneratorInterface::ABSOLUTE_URL) : $user->apPublicUrl, $toUsers);\n\n        $result = [\n            '@context' => $this->contextsProvider->referencedContexts(),\n            'id' => $this->urlGenerator->generate('ap_message', ['uuid' => $message->uuid], UrlGeneratorInterface::ABSOLUTE_URL),\n            'attributedTo' => $actorUrl,\n            'to' => $to,\n            'cc' => [],\n            'type' => 'ChatMessage',\n            'published' => $message->createdAt->format(DATE_ATOM),\n            'content' => $this->markdownConverter->convertToHtml($message->body, context: [MarkdownConverter::RENDER_TARGET => RenderTarget::ActivityPub]),\n            'mediaType' => 'text/html',\n            'source' => [\n                'mediaType' => 'text/markdown',\n                'content' => $message->body,\n            ],\n        ];\n\n        if (!$includeContext) {\n            unset($result['@context']);\n        }\n\n        return $result;\n    }\n}\n"
  },
  {
    "path": "src/Factory/ActivityPub/NodeInfoFactory.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Factory\\ActivityPub;\n\nuse App\\Repository\\StatsContentRepository;\nuse App\\Service\\ProjectInfoService;\nuse App\\Service\\SettingsManager;\n\nclass NodeInfoFactory\n{\n    private const NODE_PROTOCOL = 'activitypub';\n\n    public function __construct(\n        private readonly StatsContentRepository $repository,\n        private readonly SettingsManager $settingsManager,\n        private readonly ProjectInfoService $projectInfo,\n    ) {\n    }\n\n    /**\n     * Create and return a NodeInfo PHP array depending on the version input.\n     *\n     * @param string $version NodeInfo version string (eg. \"2.0\")\n     */\n    public function create(string $version): array\n    {\n        switch ($version) {\n            case '2.0':\n                $software = [\n                    'name' => $this->projectInfo->getName(),\n                    'version' => $this->projectInfo->getVersion(),\n                ];\n                break;\n            case '2.1':\n            default:\n                // Used for 2.1 and as fallback\n                $software = [\n                    'name' => $this->projectInfo->getName(),\n                    'version' => $this->projectInfo->getVersion(),\n                    'repository' => $this->projectInfo->getRepositoryURL(),\n                ];\n                break;\n        }\n\n        return [\n            'version' => $version,\n            'software' => $software,\n            'protocols' => [\n                self::NODE_PROTOCOL,\n            ],\n            'services' => [\n                'outbound' => [],\n                'inbound' => [],\n            ],\n            'usage' => [\n                'users' => [\n                    'total' => $this->repository->countUsers(),\n                    'activeHalfyear' => $this->repository->countUsers((new \\DateTime('now'))->modify('-6 months')),\n                    'activeMonth' => $this->repository->countUsers((new \\DateTime('now'))->modify('-1 month')),\n                ],\n                'localPosts' => $this->repository->countLocalPosts(),\n                'localComments' => $this->repository->countLocalComments(),\n            ],\n            'openRegistrations' => $this->settingsManager->get('KBIN_REGISTRATIONS_ENABLED'),\n            'metadata' => [\n                'nodeName' => $this->settingsManager->get('KBIN_META_TITLE'),\n                'nodeDescription' => $this->settingsManager->get('KBIN_META_DESCRIPTION'),\n            ],\n        ];\n    }\n}\n"
  },
  {
    "path": "src/Factory/ActivityPub/PersonFactory.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Factory\\ActivityPub;\n\nuse App\\Entity\\User;\nuse App\\Markdown\\MarkdownConverter;\nuse App\\Markdown\\RenderTarget;\nuse App\\Service\\ActivityPub\\ContextsProvider;\nuse App\\Service\\ImageManagerInterface;\nuse Symfony\\Component\\Routing\\Generator\\UrlGeneratorInterface;\n\nclass PersonFactory\n{\n    public function __construct(\n        private readonly UrlGeneratorInterface $urlGenerator,\n        private readonly ContextsProvider $contextProvider,\n        private readonly ImageManagerInterface $imageManager,\n        private readonly MarkdownConverter $markdownConverter,\n    ) {\n    }\n\n    public function create(User $user, bool $context = true): array\n    {\n        if ($context) {\n            $person['@context'] = $this->contextProvider->referencedContexts();\n        }\n\n        $person = array_merge(\n            $person ?? [], [\n                'id' => $this->getActivityPubId($user),\n                'type' => $user->type,\n                'name' => $user->title ?? $user->getUsername(),\n                'preferredUsername' => $user->username,\n                'inbox' => $this->urlGenerator->generate(\n                    'ap_user_inbox',\n                    ['username' => $user->username],\n                    UrlGeneratorInterface::ABSOLUTE_URL\n                ),\n                'outbox' => $this->urlGenerator->generate(\n                    'ap_user_outbox',\n                    ['username' => $user->username],\n                    UrlGeneratorInterface::ABSOLUTE_URL\n                ),\n                'url' => $this->getActivityPubId($user),\n                'manuallyApprovesFollowers' => false,\n                'discoverable' => $user->apDiscoverable,\n                'indexable' => $user->apIndexable,\n                'published' => $user->createdAt->format(DATE_ATOM),\n                'following' => $this->urlGenerator->generate(\n                    'ap_user_following',\n                    ['username' => $user->username],\n                    UrlGeneratorInterface::ABSOLUTE_URL\n                ),\n                'followers' => $this->getActivityPubFollowersId($user),\n                'publicKey' => [\n                    'owner' => $this->getActivityPubId($user),\n                    'id' => $this->getActivityPubId($user).'#main-key',\n                    'publicKeyPem' => $user->publicKey,\n                ],\n                'endpoints' => [\n                    'sharedInbox' => $this->urlGenerator->generate(\n                        'ap_shared_inbox',\n                        [],\n                        UrlGeneratorInterface::ABSOLUTE_URL\n                    ),\n                ],\n            ]\n        );\n\n        if ($user->about) {\n            $person['summary'] = $this->markdownConverter->convertToHtml(\n                $user->about,\n                context: [MarkdownConverter::RENDER_TARGET => RenderTarget::ActivityPub],\n            );\n        }\n\n        if ($user->cover) {\n            $person['image'] = [\n                'type' => 'Image',\n                'url' => $this->imageManager->getUrl($user->cover),\n                // @todo media url\n            ];\n        }\n\n        if ($user->avatar) {\n            $person['icon'] = [\n                'type' => 'Image',\n                'url' => $this->imageManager->getUrl($user->avatar),\n                // @todo media url\n            ];\n        }\n\n        return $person;\n    }\n\n    public function getActivityPubId(User $user): string\n    {\n        if ($user->apId) {\n            return $user->apProfileId;\n        }\n\n        return $this->urlGenerator->generate(\n            'ap_user',\n            ['username' => $user->username],\n            UrlGeneratorInterface::ABSOLUTE_URL\n        );\n    }\n\n    public function getActivityPubFollowersId(User $user): string\n    {\n        if ($user->apId) {\n            return $user->apFollowersUrl;\n        }\n\n        return $this->urlGenerator->generate(\n            'ap_user_followers',\n            ['username' => $user->username],\n            UrlGeneratorInterface::ABSOLUTE_URL\n        );\n    }\n}\n"
  },
  {
    "path": "src/Factory/ActivityPub/PostCommentNoteFactory.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Factory\\ActivityPub;\n\nuse App\\Entity\\Contracts\\ActivityPubActivityInterface;\nuse App\\Entity\\PostComment;\nuse App\\Markdown\\MarkdownConverter;\nuse App\\Markdown\\RenderTarget;\nuse App\\Service\\ActivityPub\\ApHttpClientInterface;\nuse App\\Service\\ActivityPub\\ContextsProvider;\nuse App\\Service\\ActivityPub\\Wrapper\\ImageWrapper;\nuse App\\Service\\ActivityPub\\Wrapper\\MentionsWrapper;\nuse App\\Service\\ActivityPub\\Wrapper\\TagsWrapper;\nuse App\\Service\\ActivityPubManager;\nuse App\\Service\\MentionManager;\nuse Symfony\\Component\\Routing\\Generator\\UrlGeneratorInterface;\n\nclass PostCommentNoteFactory\n{\n    public function __construct(\n        private readonly UrlGeneratorInterface $urlGenerator,\n        private readonly ContextsProvider $contextProvider,\n        private readonly PostNoteFactory $postNoteFactory,\n        private readonly ImageWrapper $imageWrapper,\n        private readonly GroupFactory $groupFactory,\n        private readonly TagsWrapper $tagsWrapper,\n        private readonly MentionsWrapper $mentionsWrapper,\n        private readonly MentionManager $mentionManager,\n        private readonly ApHttpClientInterface $client,\n        private readonly ActivityPubManager $activityPubManager,\n        private readonly MarkdownConverter $markdownConverter,\n    ) {\n    }\n\n    public function create(PostComment $comment, array $tags, bool $context = false): array\n    {\n        if ($context) {\n            $note['@context'] = $this->contextProvider->referencedContexts();\n        }\n\n        if ('random' !== $comment->magazine->name && !$comment->magazine->apId) { // @todo\n            $tags[] = $comment->magazine->name;\n        }\n\n        $cc = [$this->groupFactory->getActivityPubId($comment->magazine)];\n        if ($followersUrl = $comment->user->getFollowerUrl($this->client, $this->urlGenerator, null !== $comment->apId)) {\n            $cc[] = $followersUrl;\n        }\n\n        $note = array_merge($note ?? [], [\n            'id' => $this->getActivityPubId($comment),\n            'type' => 'Note',\n            'attributedTo' => $this->activityPubManager->getActorProfileId($comment->user),\n            'inReplyTo' => $this->getReplyTo($comment),\n            'to' => [\n                ActivityPubActivityInterface::PUBLIC_URL,\n            ],\n            'cc' => $cc,\n            'audience' => $this->groupFactory->getActivityPubId($comment->magazine),\n            'sensitive' => $comment->post->isAdult(),\n            'content' => $this->markdownConverter->convertToHtml(\n                $comment->body,\n                context: [MarkdownConverter::RENDER_TARGET => RenderTarget::ActivityPub],\n            ),\n            'mediaType' => 'text/html',\n            'source' => $comment->body ? [\n                'content' => $comment->body,\n                'mediaType' => 'text/markdown',\n            ] : null,\n            'url' => $this->getActivityPubId($comment),\n            'tag' => array_merge(\n                $this->tagsWrapper->build($tags),\n                $this->mentionsWrapper->build($comment->mentions ?? [], $comment->body)\n            ),\n            'published' => $comment->createdAt->format(DATE_ATOM),\n        ]);\n\n        $note['contentMap'] = [\n            $comment->lang => $note['content'],\n        ];\n\n        if ($comment->image) {\n            $note = $this->imageWrapper->build($note, $comment->image, $comment->getShortTitle());\n        }\n\n        $mentions = [];\n        foreach ($comment->mentions ?? [] as $mention) {\n            try {\n                $profileId = $this->activityPubManager->findActorOrCreate($mention)?->apProfileId;\n                if ($profileId) {\n                    $mentions[] = $profileId;\n                }\n            } catch (\\Exception $e) {\n                continue;\n            }\n        }\n\n        $note['to'] = array_values(\n            array_unique(\n                array_merge(\n                    $note['to'],\n                    $mentions,\n                    $this->activityPubManager->createCcFromBody($comment->body),\n                    [$this->getReplyToAuthor($comment)],\n                )\n            )\n        );\n\n        return $note;\n    }\n\n    public function getActivityPubId(PostComment $comment): string\n    {\n        if ($comment->apId) {\n            return $comment->apId;\n        }\n\n        return $this->urlGenerator->generate(\n            'ap_post_comment',\n            [\n                'magazine_name' => $comment->magazine->name,\n                'post_id' => $comment->post->getId(),\n                'comment_id' => $comment->getId(),\n            ],\n            UrlGeneratorInterface::ABSOLUTE_URL\n        );\n    }\n\n    private function getReplyTo(PostComment $comment): string\n    {\n        return $comment->parent ? $this->getActivityPubId($comment->parent) : $this->postNoteFactory->getActivityPubId($comment->post);\n    }\n\n    private function getReplyToAuthor(PostComment $comment): string\n    {\n        return $comment->parent\n            ? $this->activityPubManager->getActorProfileId($comment->parent->user)\n            : $this->activityPubManager->getActorProfileId($comment->post->user);\n    }\n}\n"
  },
  {
    "path": "src/Factory/ActivityPub/PostNoteFactory.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Factory\\ActivityPub;\n\nuse App\\Entity\\Contracts\\ActivityPubActivityInterface;\nuse App\\Entity\\Post;\nuse App\\Markdown\\MarkdownConverter;\nuse App\\Markdown\\RenderTarget;\nuse App\\Service\\ActivityPub\\ApHttpClientInterface;\nuse App\\Service\\ActivityPub\\ContextsProvider;\nuse App\\Service\\ActivityPub\\Wrapper\\ImageWrapper;\nuse App\\Service\\ActivityPub\\Wrapper\\MentionsWrapper;\nuse App\\Service\\ActivityPub\\Wrapper\\TagsWrapper;\nuse App\\Service\\ActivityPubManager;\nuse App\\Service\\MentionManager;\nuse App\\Service\\TagExtractor;\nuse Symfony\\Component\\Routing\\Generator\\UrlGeneratorInterface;\n\nclass PostNoteFactory\n{\n    public function __construct(\n        private readonly UrlGeneratorInterface $urlGenerator,\n        private readonly ContextsProvider $contextProvider,\n        private readonly GroupFactory $groupFactory,\n        private readonly ImageWrapper $imageWrapper,\n        private readonly TagsWrapper $tagsWrapper,\n        private readonly MentionsWrapper $mentionsWrapper,\n        private readonly ApHttpClientInterface $client,\n        private readonly ActivityPubManager $activityPubManager,\n        private readonly MentionManager $mentionManager,\n        private readonly TagExtractor $tagExtractor,\n        private readonly MarkdownConverter $markdownConverter,\n    ) {\n    }\n\n    public function create(Post $post, array $tags, bool $context = false): array\n    {\n        if ($context) {\n            $note['@context'] = $this->contextProvider->referencedContexts();\n        }\n\n        if ('random' !== $post->magazine->name && !$post->magazine->apId) { // @todo\n            $tags[] = $post->magazine->name;\n        }\n\n        $body = $this->tagExtractor->joinTagsToBody(\n            $post->body,\n            $tags\n        );\n\n        $cc = [];\n        if ($followersUrl = $post->user->getFollowerUrl($this->client, $this->urlGenerator, null !== $post->apId)) {\n            $cc[] = $followersUrl;\n        }\n\n        $note = array_merge($note ?? [], [\n            'id' => $this->getActivityPubId($post),\n            'type' => 'Note',\n            'attributedTo' => $this->activityPubManager->getActorProfileId($post->user),\n            'inReplyTo' => null,\n            'to' => [\n                $this->groupFactory->getActivityPubId($post->magazine),\n                ActivityPubActivityInterface::PUBLIC_URL,\n            ],\n            'cc' => $cc,\n            'audience' => $this->groupFactory->getActivityPubId($post->magazine),\n            'sensitive' => $post->isAdult(),\n            'stickied' => $post->sticky,\n            'content' => $this->markdownConverter->convertToHtml(\n                $body,\n                context: [MarkdownConverter::RENDER_TARGET => RenderTarget::ActivityPub],\n            ),\n            'mediaType' => 'text/html',\n            'source' => $post->body ? [\n                'content' => $body,\n                'mediaType' => 'text/markdown',\n            ] : null,\n            'url' => $this->getActivityPubId($post),\n            'tag' => array_merge(\n                $this->tagsWrapper->build($tags),\n                $this->mentionsWrapper->build($post->mentions ?? [], $post->body)\n            ),\n            'commentsEnabled' => !$post->isLocked,\n            'published' => $post->createdAt->format(DATE_ATOM),\n        ]);\n\n        $note['contentMap'] = [\n            $post->lang => $note['content'],\n        ];\n\n        if ($post->image) {\n            $note = $this->imageWrapper->build($note, $post->image, $post->getShortTitle());\n        }\n\n        $note['to'] = array_unique(array_merge($note['to'], $this->activityPubManager->createCcFromBody($post->body)));\n\n        return $note;\n    }\n\n    public function getActivityPubId(Post $post): string\n    {\n        if ($post->apId) {\n            return $post->apId;\n        }\n\n        return $this->urlGenerator->generate(\n            'ap_post',\n            ['magazine_name' => $post->magazine->name, 'post_id' => $post->getId()],\n            UrlGeneratorInterface::ABSOLUTE_URL\n        );\n    }\n}\n"
  },
  {
    "path": "src/Factory/ActivityPub/TombstoneFactory.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Factory\\ActivityPub;\n\nuse App\\Entity\\User;\nuse Symfony\\Component\\Routing\\Generator\\UrlGeneratorInterface;\n\nclass TombstoneFactory\n{\n    public function __construct(\n        private readonly UrlGeneratorInterface $urlGenerator,\n    ) {\n    }\n\n    public function create(string $id): array\n    {\n        return [\n            'id' => $id,\n            'type' => 'Tombstone',\n        ];\n    }\n\n    public function createForUser(User $user): array\n    {\n        return $this->create($this->urlGenerator->generate('ap_user', ['username' => $user->username], UrlGeneratorInterface::ABSOLUTE_URL));\n    }\n}\n"
  },
  {
    "path": "src/Factory/BadgeFactory.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Factory;\n\nuse App\\DTO\\BadgeDto;\nuse App\\Entity\\Badge;\n\nclass BadgeFactory\n{\n    public function createDto(Badge $badge): BadgeDto\n    {\n        return BadgeDto::create(\n            $badge->magazine,\n            $badge->name,\n            $badge->getId(),\n        );\n    }\n}\n"
  },
  {
    "path": "src/Factory/ClientConsentsFactory.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Factory;\n\nuse App\\DTO\\ClientConsentsResponseDto;\nuse App\\Entity\\OAuth2UserConsent;\nuse League\\Bundle\\OAuth2ServerBundle\\ValueObject\\Scope;\n\nclass ClientConsentsFactory\n{\n    public function __construct(\n        private readonly ImageFactory $imageFactory,\n    ) {\n    }\n\n    public function createDto(OAuth2UserConsent $consent): ClientConsentsResponseDto\n    {\n        return ClientConsentsResponseDto::create(\n            $consent->getId(),\n            $consent->getClient()->getName(),\n            $consent->getClient()->getDescription(),\n            $consent->getClient()->getImage() ? $this->imageFactory->createDto($consent->getClient()->getImage()) : null,\n            $consent->getScopes(),\n            array_map(fn (Scope $scope) => (string) $scope, $consent->getClient()->getScopes()),\n        );\n    }\n}\n"
  },
  {
    "path": "src/Factory/ClientFactory.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Factory;\n\nuse App\\DTO\\OAuth2ClientDto;\nuse App\\Entity\\Client;\nuse League\\Bundle\\OAuth2ServerBundle\\ValueObject\\Grant;\nuse League\\Bundle\\OAuth2ServerBundle\\ValueObject\\RedirectUri;\nuse League\\Bundle\\OAuth2ServerBundle\\ValueObject\\Scope;\n\nclass ClientFactory\n{\n    public function __construct(\n        private readonly ImageFactory $imageFactory,\n        private readonly UserFactory $userFactory,\n    ) {\n    }\n\n    public function createDto(Client $client): OAuth2ClientDto\n    {\n        return OAuth2ClientDto::create(\n            $client->getIdentifier(),\n            $client->getSecret(),\n            $client->getName(),\n            $client->getUser() ? $this->userFactory->createSmallDto($client->getUser()) : null,\n            $client->getContactEmail(),\n            $client->getDescription(),\n            array_map(fn (RedirectUri $redirectUri) => (string) $redirectUri, $client->getRedirectUris()),\n            array_map(fn (Grant $grant) => (string) $grant, $client->getGrants()),\n            array_map(fn (Scope $scope) => (string) $scope, $client->getScopes()),\n            $client->getImage() ? $this->imageFactory->createDto($client->getImage()) : null,\n        );\n    }\n}\n"
  },
  {
    "path": "src/Factory/ContentActivityDtoFactory.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Factory;\n\nuse App\\DTO\\ActivitiesResponseDto;\nuse App\\Entity\\Contracts\\VotableInterface;\nuse App\\Entity\\Favourite;\nuse App\\Entity\\Vote;\n\nclass ContentActivityDtoFactory\n{\n    public function __construct(\n        private readonly UserFactory $userFactory,\n    ) {\n    }\n\n    public function createActivitiesDto(VotableInterface $subject): ActivitiesResponseDto\n    {\n        $dto = ActivitiesResponseDto::create([], [], null);\n        /* @var Vote $upvote */\n        foreach ($subject->getUpVotes() as $upvote) {\n            $dto->boosts[] = $this->userFactory->createSmallDto($upvote->user);\n        }\n\n        if (property_exists($subject, 'favourites')) {\n            /* @var Favourite $favourite */\n            foreach ($subject->favourites as $favourite) {\n                $dto->upvotes[] = $this->userFactory->createSmallDto($favourite->user);\n            }\n        } else {\n            $dto->upvotes = null;\n        }\n\n        return $dto;\n    }\n}\n"
  },
  {
    "path": "src/Factory/ContentManagerFactory.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Factory;\n\nuse App\\Entity\\Contracts\\ContentInterface;\nuse App\\Entity\\Entry;\nuse App\\Entity\\EntryComment;\nuse App\\Entity\\Post;\nuse App\\Entity\\PostComment;\nuse App\\Service\\Contracts\\ContentManagerInterface;\nuse App\\Service\\EntryCommentManager;\nuse App\\Service\\EntryManager;\nuse App\\Service\\PostCommentManager;\nuse App\\Service\\PostManager;\n\nclass ContentManagerFactory\n{\n    public function __construct(\n        private readonly EntryManager $entryManager,\n        private readonly EntryCommentManager $entryCommentManager,\n        private readonly PostManager $postManager,\n        private readonly PostCommentManager $postCommentManager,\n    ) {\n    }\n\n    public function createManager(ContentInterface $subject): ContentManagerInterface\n    {\n        if ($subject instanceof Entry) {\n            return $this->entryManager;\n        } elseif ($subject instanceof EntryComment) {\n            return $this->entryCommentManager;\n        } elseif ($subject instanceof Post) {\n            return $this->postManager;\n        } elseif ($subject instanceof PostComment) {\n            return $this->postCommentManager;\n        }\n        throw new \\LogicException(\"Unsupported subject type: '\".\\get_class($subject).\"'\");\n    }\n}\n"
  },
  {
    "path": "src/Factory/DomainFactory.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Factory;\n\nuse App\\DTO\\DomainDto;\nuse App\\Entity\\Domain;\nuse App\\Entity\\User;\nuse App\\Service\\DomainManager;\nuse Symfony\\Bundle\\SecurityBundle\\Security;\n\nclass DomainFactory\n{\n    public function __construct(\n        private readonly Security $security,\n        private readonly DomainManager $domainManager,\n    ) {\n    }\n\n    public function createDto(Domain $domain): DomainDto\n    {\n        $dto = DomainDto::create(\n            $domain->name,\n            $domain->entryCount,\n            $domain->subscriptionsCount,\n            $domain->getId(),\n        );\n\n        /** @var User $currentUser */\n        $currentUser = $this->security->getUser();\n        // Only return the user's vote if permission to control voting has been given\n        $dto->isUserSubscribed = $this->security->isGranted('ROLE_OAUTH2_DOMAIN:SUBSCRIBE') ? $domain->isSubscribed($currentUser) : null;\n        $dto->isBlockedByUser = $this->security->isGranted('ROLE_OAUTH2_DOMAIN:BLOCK') ? $currentUser->isBlockedDomain($domain) : null;\n\n        return $dto;\n    }\n}\n"
  },
  {
    "path": "src/Factory/EntryCommentFactory.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Factory;\n\nuse App\\DTO\\EntryCommentDto;\nuse App\\DTO\\EntryCommentResponseDto;\nuse App\\Entity\\EntryComment;\nuse App\\Entity\\User;\nuse App\\PageView\\EntryCommentPageView;\nuse App\\Repository\\BookmarkListRepository;\nuse App\\Repository\\TagLinkRepository;\nuse App\\Service\\SettingsManager;\nuse Symfony\\Bundle\\SecurityBundle\\Security;\n\nclass EntryCommentFactory\n{\n    public function __construct(\n        private readonly Security $security,\n        private readonly ImageFactory $imageFactory,\n        private readonly UserFactory $userFactory,\n        private readonly MagazineFactory $magazineFactory,\n        private readonly TagLinkRepository $tagLinkRepository,\n        private readonly BookmarkListRepository $bookmarkListRepository,\n        private readonly SettingsManager $settingsManager,\n    ) {\n    }\n\n    public function createFromDto(EntryCommentDto $dto, User $user): EntryComment\n    {\n        return new EntryComment(\n            $dto->body,\n            $dto->entry,\n            $user,\n            $dto->parent,\n            $dto->ip\n        );\n    }\n\n    public function createResponseDto(EntryCommentDto|EntryComment $comment, array $tags, int $childCount = 0): EntryCommentResponseDto\n    {\n        $dto = $comment instanceof EntryComment ? $this->createDto($comment) : $comment;\n\n        return EntryCommentResponseDto::create(\n            $dto->getId(),\n            $this->userFactory->createSmallDto($dto->user),\n            $this->magazineFactory->createSmallDto($dto->magazine),\n            $dto->entry->getId(),\n            $dto->parent?->getId(),\n            $dto->parent?->root?->getId() ?? $dto->parent?->getId(),\n            $dto->image,\n            $dto->body,\n            $dto->lang,\n            $dto->isAdult,\n            $dto->uv,\n            $dto->dv,\n            $dto->favouriteCount,\n            $dto->visibility,\n            $dto->apId,\n            $dto->mentions,\n            $tags,\n            $dto->createdAt,\n            $dto->editedAt,\n            $dto->lastActive,\n            $childCount,\n            bookmarks: $this->bookmarkListRepository->getBookmarksOfContentInterface($comment),\n            isAuthorModeratorInMagazine: $dto->magazine->userIsModerator($dto->user),\n        );\n    }\n\n    public function createResponseTree(EntryComment $comment, EntryCommentPageView $commentPageView, int $depth = -1, ?bool $canModerate = null): EntryCommentResponseDto\n    {\n        $commentDto = $this->createDto($comment);\n        $toReturn = $this->createResponseDto($commentDto, $this->tagLinkRepository->getTagsOfContent($comment), array_reduce($comment->children->toArray(), EntryCommentResponseDto::class.'::recursiveChildCount', 0));\n        $toReturn->isFavourited = $commentDto->isFavourited;\n        $toReturn->userVote = $commentDto->userVote;\n        $toReturn->canAuthUserModerate = $canModerate;\n\n        if (0 === $depth) {\n            return $toReturn;\n        }\n\n        $user = $this->security->getUser();\n        foreach ($comment->getChildrenByCriteria($commentPageView, $this->settingsManager->getDownvotesMode(), $user, 'comments') as $childComment) {\n            \\assert($childComment instanceof EntryComment);\n            if ($user instanceof User) {\n                if ($user->isBlocked($childComment->user)) {\n                    continue;\n                }\n            }\n            $child = $this->createResponseTree($childComment, $commentPageView, $depth > 0 ? $depth - 1 : -1, $canModerate);\n            $toReturn->children[] = $child;\n        }\n\n        return $toReturn;\n    }\n\n    public function createDto(EntryComment $comment): EntryCommentDto\n    {\n        $dto = new EntryCommentDto();\n        $dto->magazine = $comment->magazine;\n        $dto->entry = $comment->entry;\n        $dto->user = $comment->user;\n        $dto->body = $comment->body;\n        $dto->lang = $comment->lang;\n        $dto->parent = $comment->parent;\n        $dto->isAdult = $comment->isAdult;\n        $dto->image = $comment->image ? $this->imageFactory->createDto($comment->image) : null;\n        $dto->visibility = $comment->visibility;\n        $dto->uv = $comment->countUpVotes();\n        $dto->dv = $comment->countDownVotes();\n        $dto->favouriteCount = $comment->favouriteCount;\n        $dto->mentions = $comment->mentions;\n        $dto->createdAt = $comment->createdAt;\n        $dto->editedAt = $comment->editedAt;\n        $dto->lastActive = $comment->lastActive;\n        $dto->apId = $comment->apId;\n        $dto->apLikeCount = $comment->apLikeCount;\n        $dto->apDislikeCount = $comment->apDislikeCount;\n        $dto->apShareCount = $comment->apShareCount;\n        $dto->setId($comment->getId());\n\n        $currentUser = $this->security->getUser();\n        // Only return the user's vote if permission to control voting has been given\n        $dto->isFavourited = $this->security->isGranted('ROLE_OAUTH2_ENTRY_COMMENT:VOTE') ? $comment->isFavored($currentUser) : null;\n        $dto->userVote = $this->security->isGranted('ROLE_OAUTH2_ENTRY_COMMENT:VOTE') ? $comment->getUserChoice($currentUser) : null;\n\n        return $dto;\n    }\n}\n"
  },
  {
    "path": "src/Factory/EntryFactory.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Factory;\n\nuse App\\DTO\\EntryDto;\nuse App\\DTO\\EntryResponseDto;\nuse App\\Entity\\Badge;\nuse App\\Entity\\Entry;\nuse App\\Entity\\User;\nuse App\\Repository\\BookmarkListRepository;\nuse App\\Repository\\TagLinkRepository;\nuse Symfony\\Bundle\\SecurityBundle\\Security;\n\nclass EntryFactory\n{\n    public function __construct(\n        private readonly Security $security,\n        private readonly ImageFactory $imageFactory,\n        private readonly DomainFactory $domainFactory,\n        private readonly MagazineFactory $magazineFactory,\n        private readonly UserFactory $userFactory,\n        private readonly BadgeFactory $badgeFactory,\n        private readonly TagLinkRepository $tagLinkRepository,\n        private readonly BookmarkListRepository $bookmarkListRepository,\n    ) {\n    }\n\n    public function createFromDto(EntryDto $dto, User $user): Entry\n    {\n        return new Entry(\n            $dto->title,\n            $dto->url,\n            $dto->body,\n            $dto->magazine,\n            $user,\n            $dto->isAdult,\n            $dto->isOc,\n            $dto->lang,\n            $dto->ip,\n        );\n    }\n\n    public function createResponseDto(EntryDto|Entry $entry, array $tags, ?array $crosspostedEntries = null): EntryResponseDto\n    {\n        $dto = $entry instanceof Entry ? $this->createDto($entry) : $entry;\n        $badges = $dto->badges ? array_map(fn (Badge $badge) => $this->badgeFactory->createDto($badge), $dto->badges->toArray()) : null;\n\n        return EntryResponseDto::create(\n            $dto->getId(),\n            $this->magazineFactory->createSmallDto($dto->magazine),\n            $this->userFactory->createSmallDto($dto->user),\n            $dto->domain,\n            $dto->title,\n            $dto->url,\n            $dto->image,\n            $dto->body,\n            $dto->lang,\n            $tags,\n            $badges,\n            $dto->comments,\n            $dto->uv,\n            $dto->dv,\n            $dto->isPinned,\n            $dto->isLocked,\n            $dto->visibility,\n            $dto->favouriteCount,\n            $dto->isOc,\n            $dto->isAdult,\n            $dto->createdAt,\n            $dto->editedAt,\n            $dto->lastActive,\n            $dto->type,\n            $dto->slug,\n            $dto->apId,\n            bookmarks: $this->bookmarkListRepository->getBookmarksOfContentInterface($entry),\n            crosspostedEntries: $crosspostedEntries,\n            isAuthorModeratorInMagazine: $dto->magazine->userIsModerator($dto->user),\n        );\n    }\n\n    public function createDto(Entry $entry): EntryDto\n    {\n        $dto = new EntryDto();\n\n        $dto->magazine = $entry->magazine;\n        $dto->user = $entry->user;\n        $dto->image = $entry->image ? $this->imageFactory->createDto($entry->image) : null;\n        $dto->domain = $entry->domain ? $this->domainFactory->createDto($entry->domain) : null;\n        $dto->title = $entry->title;\n        $dto->url = $entry->url;\n        $dto->body = $entry->body;\n        $dto->comments = $entry->commentCount;\n        $dto->uv = $entry->countUpVotes();\n        $dto->dv = $entry->countDownVotes();\n        $dto->favouriteCount = $entry->favouriteCount;\n        $dto->isAdult = $entry->isAdult;\n        $dto->isLocked = $entry->isLocked;\n        $dto->isOc = $entry->isOc;\n        $dto->lang = $entry->lang;\n        $dto->badges = $entry->badges;\n        $dto->slug = $entry->slug;\n        $dto->score = $entry->score;\n        $dto->visibility = $entry->visibility;\n        $dto->ip = $entry->ip;\n        $dto->createdAt = $entry->createdAt;\n        $dto->editedAt = $entry->editedAt;\n        $dto->lastActive = $entry->lastActive;\n        $dto->setId($entry->getId());\n        $dto->isPinned = $entry->sticky;\n        $dto->type = $entry->type;\n        $dto->apId = $entry->apId;\n        $dto->apLikeCount = $entry->apLikeCount;\n        $dto->apDislikeCount = $entry->apDislikeCount;\n        $dto->apShareCount = $entry->apShareCount;\n        $dto->tags = $this->tagLinkRepository->getTagsOfContent($entry);\n\n        $currentUser = $this->security->getUser();\n        // Only return the user's vote if permission to control voting has been given\n        $dto->isFavourited = $this->security->isGranted('ROLE_OAUTH2_ENTRY:VOTE') ? $entry->isFavored($currentUser) : null;\n        $dto->userVote = $this->security->isGranted('ROLE_OAUTH2_ENTRY:VOTE') ? $entry->getUserChoice($currentUser) : null;\n\n        return $dto;\n    }\n}\n"
  },
  {
    "path": "src/Factory/FavouriteFactory.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Factory;\n\nuse App\\Entity\\Contracts\\FavouriteInterface;\nuse App\\Entity\\Favourite;\nuse App\\Entity\\User;\nuse Doctrine\\ORM\\EntityManagerInterface;\n\nclass FavouriteFactory\n{\n    public function __construct(private readonly EntityManagerInterface $entityManager)\n    {\n    }\n\n    public function createFromEntity(User $user, FavouriteInterface $subject): Favourite\n    {\n        $className = $this->entityManager->getClassMetadata(\\get_class($subject))->name.'Favourite';\n\n        return new $className($user, $subject);\n    }\n}\n"
  },
  {
    "path": "src/Factory/ImageFactory.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Factory;\n\nuse App\\DTO\\ImageDto;\nuse App\\Entity\\Image;\nuse App\\Service\\ImageManagerInterface;\nuse Doctrine\\ORM\\EntityManagerInterface;\n\nclass ImageFactory\n{\n    public function __construct(\n        private readonly ImageManagerInterface $imageManager,\n        private readonly EntityManagerInterface $entityManager,\n    ) {\n    }\n\n    public function createDto(Image $image): ImageDto\n    {\n        if (!$this->entityManager->contains($image)) {\n            $this->entityManager->persist($image);\n            $this->entityManager->flush();\n        }\n\n        return ImageDto::create(\n            $image->getId(),\n            $image->filePath,\n            $image->width,\n            $image->height,\n            $image->altText,\n            $image->sourceUrl,\n            $this->imageManager->getUrl($image),\n            $image->blurhash,\n        );\n    }\n}\n"
  },
  {
    "path": "src/Factory/MagazineFactory.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Factory;\n\nuse App\\DTO\\BadgeDto;\nuse App\\DTO\\BadgeResponseDto;\nuse App\\DTO\\MagazineBanResponseDto;\nuse App\\DTO\\MagazineDto;\nuse App\\DTO\\MagazineLogResponseDto;\nuse App\\DTO\\MagazineResponseDto;\nuse App\\DTO\\MagazineSmallResponseDto;\nuse App\\Entity\\Badge;\nuse App\\Entity\\Magazine;\nuse App\\Entity\\MagazineBan;\nuse App\\Entity\\MagazineLog;\nuse App\\Entity\\MagazineLogBan;\nuse App\\Entity\\MagazineLogModeratorAdd;\nuse App\\Entity\\MagazineLogModeratorRemove;\nuse App\\Entity\\Moderator;\nuse App\\Entity\\User;\nuse App\\Repository\\InstanceRepository;\nuse App\\Repository\\MagazineRepository;\nuse App\\Repository\\MagazineSubscriptionRepository;\nuse Symfony\\Bundle\\SecurityBundle\\Security;\nuse Symfony\\Component\\HttpKernel\\Exception\\NotFoundHttpException;\n\nclass MagazineFactory\n{\n    public function __construct(\n        private ImageFactory $imageFactory,\n        private InstanceRepository $instanceRepository,\n        private ModeratorFactory $moderatorFactory,\n        private UserFactory $userFactory,\n        private MagazineRepository $magazineRepository,\n        private MagazineSubscriptionRepository $magazineSubscriptionRepository,\n        private Security $security,\n    ) {\n    }\n\n    public function createFromDto(MagazineDto $dto, ?User $user): Magazine\n    {\n        return new Magazine(\n            $dto->name,\n            $dto->title,\n            $user,\n            $dto->description,\n            $dto->rules,\n            $dto->isAdult,\n            $dto->isPostingRestrictedToMods,\n            $dto->icon\n        );\n    }\n\n    public function createDto(Magazine $magazine): MagazineDto\n    {\n        $dto = new MagazineDto();\n        $dto->setOwner($magazine->getOwner());\n        $dto->icon = $magazine->icon ? $this->imageFactory->createDto($magazine->icon) : null;\n        $dto->banner = $magazine->banner ? $this->imageFactory->createDto($magazine->banner) : null;\n        $dto->name = $magazine->name;\n        $dto->title = $magazine->title;\n        $dto->description = $magazine->description;\n        $dto->rules = $magazine->rules;\n        $dto->subscriptionsCount = $magazine->subscriptionsCount;\n        $dto->entryCount = $magazine->entryCount;\n        $dto->entryCommentCount = $magazine->entryCommentCount;\n        $dto->postCount = $magazine->postCount;\n        $dto->postCommentCount = $magazine->postCommentCount;\n        $dto->isAdult = $magazine->isAdult;\n        $dto->isPostingRestrictedToMods = $magazine->postingRestrictedToMods;\n        $dto->discoverable = $magazine->apDiscoverable;\n        $dto->indexable = $magazine->apIndexable;\n        $dto->tags = $magazine->tags;\n        $dto->badges = $magazine->badges;\n        $dto->moderators = $magazine->moderators;\n        $dto->apId = $magazine->apId;\n        $dto->apProfileId = $magazine->apProfileId;\n        $dto->apFeaturedUrl = $magazine->apFeaturedUrl;\n        $dto->setId($magazine->getId());\n\n        /** @var User $currentUser */\n        $currentUser = $this->security->getUser();\n        // Only return the user's vote if permission to control voting has been given\n        $dto->isUserSubscribed = $this->security->isGranted('ROLE_OAUTH2_MAGAZINE:SUBSCRIBE') ? $magazine->isSubscribed($currentUser) : null;\n        $dto->isBlockedByUser = $this->security->isGranted('ROLE_OAUTH2_MAGAZINE:BLOCK') ? $currentUser->isBlockedMagazine($magazine) : null;\n\n        $subs = $this->magazineSubscriptionRepository->findMagazineSubscribers(1, $magazine)->count();\n        $dto->localSubscribers = $subs;\n\n        $instance = $this->instanceRepository->getInstanceOfMagazine($magazine);\n        if ($instance) {\n            $dto->serverSoftware = $instance->software;\n            $dto->serverSoftwareVersion = $instance->version;\n        }\n\n        return $dto;\n    }\n\n    public function createSmallDto(Magazine|MagazineDto $magazine): MagazineSmallResponseDto\n    {\n        $dto = $magazine instanceof Magazine ? $this->createDto($magazine) : $magazine;\n\n        return new MagazineSmallResponseDto($dto);\n    }\n\n    public function createBanDto(MagazineBan $ban): MagazineBanResponseDto\n    {\n        return MagazineBanResponseDto::create(\n            $ban->getId(),\n            $ban->reason,\n            $ban->expiredAt,\n            $this->createSmallDto($ban->magazine),\n            $this->userFactory->createSmallDto($ban->user),\n            $this->userFactory->createSmallDto($ban->bannedBy),\n        );\n    }\n\n    public function createLogDto(MagazineLog $log): MagazineLogResponseDto\n    {\n        $magazine = $this->createSmallDto($log->magazine);\n        $type = $log->getType();\n        $createdAt = $log->createdAt;\n\n        if ($log instanceof MagazineLogModeratorAdd || $log instanceof MagazineLogModeratorRemove) {\n            $moderator = $this->userFactory->createSmallDto($log->actingUser);\n            $moderatorSubject = $this->userFactory->createSmallDto($log->user);\n\n            return MagazineLogResponseDto::createModeratorAddRemove($magazine, $moderator, $createdAt, $type, $moderatorSubject);\n        } elseif ($log instanceof MagazineLogBan) {\n            $moderator = $this->userFactory->createSmallDto($log->user);\n            $banSubject = $this->createBanDto($log->ban);\n            if ('unban' === $log->meta) {\n                $type = 'log_unban';\n            }\n\n            return MagazineLogResponseDto::createBanUnban($magazine, $moderator, $createdAt, $type, $banSubject);\n        } else {\n            $moderator = $this->userFactory->createSmallDto($log->user);\n\n            return MagazineLogResponseDto::create($magazine, $moderator, $createdAt, $type);\n        }\n    }\n\n    public function createResponseDto(MagazineDto|Magazine $magazine): MagazineResponseDto\n    {\n        $dto = $magazine instanceof Magazine ? $this->createDto($magazine) : $magazine;\n        // Ensure that magazine is an actual magazine and not a DTO\n        $magazine = $this->magazineRepository->find($magazine->getId());\n        if (null === $magazine) {\n            throw new NotFoundHttpException('Magazine was not found!');\n        }\n\n        return MagazineResponseDto::create(\n            $dto->getOwner() ? $this->moderatorFactory->createDtoWithUser($dto->getOwner(), $magazine) : null,\n            $dto->icon,\n            $dto->banner,\n            $dto->name,\n            $dto->title,\n            $dto->description,\n            $dto->rules,\n            $dto->subscriptionsCount,\n            $dto->entryCount,\n            $dto->entryCommentCount,\n            $dto->postCount,\n            $dto->postCommentCount,\n            $dto->isAdult,\n            $dto->isUserSubscribed,\n            $dto->isBlockedByUser,\n            $dto->tags,\n            array_map(fn (Badge|BadgeDto $badge) => new BadgeResponseDto($badge), $dto->badges?->toArray() ?? []),\n            array_map(fn (Moderator $moderator) => $this->moderatorFactory->createDto($moderator), $dto->moderators?->toArray() ?? []),\n            $dto->apId,\n            $dto->apProfileId,\n            $dto->getId(),\n            $dto->serverSoftware,\n            $dto->serverSoftwareVersion,\n            $dto->isPostingRestrictedToMods,\n            $dto->localSubscribers,\n            $dto->discoverable,\n            $dto->indexable,\n        );\n    }\n\n    public function createDtoFromAp(string $actorUrl, ?string $apId): MagazineDto\n    {\n        $dto = new MagazineDto();\n        $dto->name = $apId;\n        $dto->title = $apId;\n        $dto->apId = $apId;\n        $dto->apProfileId = $actorUrl;\n\n        return $dto;\n    }\n}\n"
  },
  {
    "path": "src/Factory/MessageFactory.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Factory;\n\nuse App\\DTO\\MessageResponseDto;\nuse App\\DTO\\MessageThreadResponseDto;\nuse App\\DTO\\UserResponseDto;\nuse App\\Entity\\Message;\nuse App\\Entity\\MessageThread;\nuse App\\Entity\\User;\n\nclass MessageFactory\n{\n    public function __construct(\n        private readonly UserFactory $userFactory,\n    ) {\n    }\n\n    public function createResponseDto(Message $message): MessageResponseDto\n    {\n        return MessageResponseDto::create(\n            $this->userFactory->createSmallDto($message->sender),\n            $message->body,\n            $message->status,\n            $message->thread->getId(),\n            $message->createdAt,\n            $message->getId()\n        );\n    }\n\n    public function createThreadResponseDto(MessageThread $thread, int $depth): MessageThreadResponseDto\n    {\n        $participants = array_map(fn (User $participant) => new UserResponseDto($this->userFactory->createDto($participant)), $thread->participants->toArray());\n\n        $messages = $thread->messages->toArray();\n        usort($messages, fn (Message $a, Message $b) => $a->createdAt < $b->createdAt ? 1 : -1);\n\n        $messageResponses = array_map(fn (Message $message) => $this->createResponseDto($message), $messages);\n\n        return MessageThreadResponseDto::create(\n            $participants,\n            $thread->messages->count(),\n            $messageResponses,\n            $thread->getId()\n        );\n    }\n}\n"
  },
  {
    "path": "src/Factory/ModeratorFactory.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Factory;\n\nuse App\\DTO\\ModeratorResponseDto;\nuse App\\Entity\\Magazine;\nuse App\\Entity\\Moderator;\nuse App\\Entity\\User;\n\nclass ModeratorFactory\n{\n    public function __construct(\n        private readonly ImageFactory $imageFactory,\n    ) {\n    }\n\n    public function createDto(Moderator $moderator): ModeratorResponseDto\n    {\n        return ModeratorResponseDto::create(\n            $moderator->magazine->getId(),\n            $moderator->user->getId(),\n            $moderator->user->username,\n            $moderator->user->apId,\n            $moderator->user->avatar ? $this->imageFactory->createDto($moderator->user->avatar) : null,\n        );\n    }\n\n    public function createDtoWithUser(User $user, Magazine $magazine): ModeratorResponseDto\n    {\n        return ModeratorResponseDto::create(\n            $magazine->getId(),\n            $user->getId(),\n            $user->username,\n            $user->apId,\n            $user->avatar ? $this->imageFactory->createDto($user->avatar) : null,\n        );\n    }\n}\n"
  },
  {
    "path": "src/Factory/PostCommentFactory.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Factory;\n\nuse App\\DTO\\PostCommentDto;\nuse App\\DTO\\PostCommentResponseDto;\nuse App\\Entity\\PostComment;\nuse App\\Entity\\User;\nuse App\\PageView\\PostCommentPageView;\nuse App\\Repository\\BookmarkListRepository;\nuse App\\Repository\\PostRepository;\nuse App\\Repository\\TagLinkRepository;\nuse Symfony\\Bundle\\SecurityBundle\\Security;\n\nclass PostCommentFactory\n{\n    public function __construct(\n        private readonly Security $security,\n        private readonly UserFactory $userFactory,\n        private readonly MagazineFactory $magazineFactory,\n        private readonly ImageFactory $imageFactory,\n        private readonly PostRepository $postRepository,\n        private readonly TagLinkRepository $tagLinkRepository,\n        private readonly BookmarkListRepository $bookmarkListRepository,\n    ) {\n    }\n\n    public function createFromDto(PostCommentDto $dto, User $user): PostComment\n    {\n        return new PostComment(\n            $dto->body,\n            $dto->post,\n            $user,\n            $dto->parent,\n            $dto->ip\n        );\n    }\n\n    public function createResponseDto(PostCommentDto|PostComment $comment, array $tags, int $childCount = 0): PostCommentResponseDto\n    {\n        $dto = $comment instanceof PostComment ? $this->createDto($comment) : $comment;\n\n        return PostCommentResponseDto::create(\n            $dto->getId(),\n            $this->userFactory->createSmallDto($dto->user),\n            $this->magazineFactory->createSmallDto($dto->magazine),\n            $this->postRepository->find($dto->post->getId()),\n            $dto->parent,\n            $childCount,\n            $dto->image,\n            $dto->body,\n            $dto->lang,\n            $dto->isAdult,\n            $dto->uv,\n            $dto->dv,\n            $dto->favourites,\n            $dto->visibility,\n            $dto->apId,\n            $dto->mentions,\n            $tags,\n            $dto->createdAt,\n            $dto->editedAt,\n            $dto->lastActive,\n            bookmarks: $this->bookmarkListRepository->getBookmarksOfContentInterface($comment),\n            isAuthorModeratorInMagazine: $dto->magazine->userIsModerator($dto->user),\n        );\n    }\n\n    public function createResponseTree(PostComment $comment, PostCommentPageView $criteria, int $depth, ?bool $canModerate = null): PostCommentResponseDto\n    {\n        $commentDto = $this->createDto($comment);\n        $toReturn = $this->createResponseDto($commentDto, $this->tagLinkRepository->getTagsOfContent($comment), array_reduce($comment->children->toArray(), PostCommentResponseDto::class.'::recursiveChildCount', 0));\n        $toReturn->isFavourited = $commentDto->isFavourited;\n        $toReturn->userVote = $commentDto->userVote;\n        $toReturn->canAuthUserModerate = $canModerate;\n\n        if (0 === $depth) {\n            return $toReturn;\n        }\n\n        $user = $this->security->getUser();\n        foreach ($comment->getChildrenByCriteria($criteria, $user, 'comments') as /** @var PostComment $childComment */ $childComment) {\n            \\assert($childComment instanceof PostComment);\n            if ($user instanceof User) {\n                if ($user->isBlocked($childComment->user)) {\n                    continue;\n                }\n            }\n            $child = $this->createResponseTree($childComment, $criteria, $depth > 0 ? $depth - 1 : -1, $canModerate);\n            $toReturn->children[] = $child;\n        }\n\n        return $toReturn;\n    }\n\n    public function createDto(PostComment $comment): PostCommentDto\n    {\n        $dto = new PostCommentDto();\n        $dto->magazine = $comment->magazine;\n        $dto->post = $comment->post;\n        $dto->user = $comment->user;\n        $dto->body = $comment->body;\n        $dto->lang = $comment->lang;\n        $dto->image = $comment->image ? $this->imageFactory->createDto($comment->image) : null;\n        $dto->isAdult = $comment->isAdult;\n        $dto->uv = $comment->countUpVotes();\n        $dto->dv = $comment->countDownVotes();\n        $dto->favourites = $comment->favouriteCount;\n        $dto->visibility = $comment->visibility;\n        $dto->createdAt = $comment->createdAt;\n        $dto->editedAt = $comment->editedAt;\n        $dto->lastActive = $comment->lastActive;\n        $dto->setId($comment->getId());\n        $dto->parent = $comment->parent;\n        $dto->mentions = $comment->mentions;\n        $dto->apId = $comment->apId;\n        $dto->apLikeCount = $comment->apLikeCount;\n        $dto->apDislikeCount = $comment->apDislikeCount;\n        $dto->apShareCount = $comment->apShareCount;\n\n        $currentUser = $this->security->getUser();\n        // Only return the user's vote if permission to control voting has been given\n        $dto->isFavourited = $this->security->isGranted('ROLE_OAUTH2_POST_COMMENT:VOTE') ? $comment->isFavored($currentUser) : null;\n        $dto->userVote = $this->security->isGranted('ROLE_OAUTH2_POST_COMMENT:VOTE') ? $comment->getUserChoice($currentUser) : null;\n\n        return $dto;\n    }\n}\n"
  },
  {
    "path": "src/Factory/PostFactory.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Factory;\n\nuse App\\DTO\\PostDto;\nuse App\\DTO\\PostResponseDto;\nuse App\\Entity\\Post;\nuse App\\Entity\\User;\nuse App\\Repository\\BookmarkListRepository;\nuse Symfony\\Bundle\\SecurityBundle\\Security;\n\nclass PostFactory\n{\n    public function __construct(\n        private readonly Security $security,\n        private readonly UserFactory $userFactory,\n        private readonly MagazineFactory $magazineFactory,\n        private readonly ImageFactory $imageFactory,\n        private readonly BookmarkListRepository $bookmarkListRepository,\n    ) {\n    }\n\n    public function createFromDto(PostDto $dto, User $user): Post\n    {\n        return new Post(\n            $dto->body,\n            $dto->magazine,\n            $user,\n            $dto->isAdult,\n            $dto->ip\n        );\n    }\n\n    public function createResponseDto(PostDto|Post $post, array $tags): PostResponseDto\n    {\n        $dto = $post instanceof Post ? $this->createDto($post) : $post;\n\n        return PostResponseDto::create(\n            $dto->getId(),\n            $this->userFactory->createSmallDto($dto->user),\n            $this->magazineFactory->createSmallDto($dto->magazine),\n            $dto->image,\n            $dto->body,\n            $dto->lang,\n            $dto->isAdult,\n            $dto->isPinned,\n            $dto->isLocked,\n            $dto->comments,\n            $dto->uv,\n            $dto->dv,\n            $dto->favouriteCount,\n            $dto->visibility,\n            $tags,\n            $dto->mentions,\n            $dto->apId,\n            $dto->createdAt,\n            $dto->editedAt,\n            $dto->lastActive,\n            $dto->slug,\n            bookmarks: $this->bookmarkListRepository->getBookmarksOfContentInterface($post),\n            isAuthorModeratorInMagazine: $dto->magazine->userIsModerator($dto->user),\n        );\n    }\n\n    public function createDto(Post $post): PostDto\n    {\n        $dto = new PostDto();\n\n        $dto->magazine = $post->magazine;\n        $dto->user = $post->user;\n        $dto->image = $post->image ? $this->imageFactory->createDto($post->image) : null;\n        $dto->body = $post->body;\n        $dto->lang = $post->lang;\n        $dto->isAdult = $post->isAdult;\n        $dto->isPinned = $post->sticky;\n        $dto->isLocked = $post->isLocked;\n        $dto->slug = $post->slug;\n        $dto->comments = $post->commentCount;\n        $dto->uv = $post->countUpVotes();\n        $dto->dv = $post->countDownVotes();\n        $dto->favouriteCount = $post->favouriteCount;\n        $dto->visibility = $post->visibility;\n        $dto->createdAt = $post->createdAt;\n        $dto->editedAt = $post->editedAt;\n        $dto->lastActive = $post->lastActive;\n        $dto->ip = $post->ip;\n        $dto->mentions = $post->mentions;\n        $dto->apId = $post->apId;\n        $dto->apLikeCount = $post->apLikeCount;\n        $dto->apDislikeCount = $post->apDislikeCount;\n        $dto->apShareCount = $post->apShareCount;\n        $dto->setId($post->getId());\n\n        $currentUser = $this->security->getUser();\n        // Only return the user's vote if permission to control voting has been given\n        $dto->isFavourited = $this->security->isGranted('ROLE_OAUTH2_POST:VOTE') ? $post->isFavored($currentUser) : null;\n        $dto->userVote = $this->security->isGranted('ROLE_OAUTH2_POST:VOTE') ? $post->getUserChoice($currentUser) : null;\n\n        return $dto;\n    }\n}\n"
  },
  {
    "path": "src/Factory/ReportFactory.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Factory;\n\nuse App\\DTO\\ReportDto;\nuse App\\DTO\\ReportResponseDto;\nuse App\\Entity\\Entry;\nuse App\\Entity\\EntryComment;\nuse App\\Entity\\EntryCommentReport;\nuse App\\Entity\\EntryReport;\nuse App\\Entity\\Post;\nuse App\\Entity\\PostComment;\nuse App\\Entity\\PostCommentReport;\nuse App\\Entity\\PostReport;\nuse App\\Entity\\Report;\nuse App\\Repository\\TagLinkRepository;\nuse Doctrine\\ORM\\EntityManagerInterface;\n\nclass ReportFactory\n{\n    public function __construct(\n        private readonly EntityManagerInterface $entityManager,\n        private readonly UserFactory $userFactory,\n        private readonly MagazineFactory $magazineFactory,\n        private readonly EntryFactory $entryFactory,\n        private readonly PostFactory $postFactory,\n        private readonly EntryCommentFactory $entryCommentFactory,\n        private readonly PostCommentFactory $postCommentFactory,\n        private readonly TagLinkRepository $tagLinkRepository,\n    ) {\n    }\n\n    public function createFromDto(ReportDto $dto): Report\n    {\n        $className = $this->entityManager->getClassMetadata(\\get_class($dto->getSubject()))->name.'Report';\n\n        return new $className($dto->getSubject()->user, $dto->getSubject(), $dto->reason);\n    }\n\n    public function createResponseDto(Report $report): ReportResponseDto\n    {\n        $toReturn = ReportResponseDto::create(\n            $report->getId(),\n            $this->magazineFactory->createSmallDto($report->magazine),\n            $this->userFactory->createSmallDto($report->reported),\n            $this->userFactory->createSmallDto($report->reporting),\n            $report->reason,\n            $report->status,\n            $report->weight,\n            $report->createdAt,\n            $report->consideredAt,\n            $report->consideredBy ? $this->userFactory->createSmallDto($report->consideredBy) : null\n        );\n\n        $subject = $report->getSubject();\n        switch (\\get_class($report)) {\n            case EntryReport::class:\n                \\assert($subject instanceof Entry);\n                $toReturn->subject = $this->entryFactory->createResponseDto($subject, tags: $this->tagLinkRepository->getTagsOfContent($subject));\n                break;\n            case EntryCommentReport::class:\n                \\assert($subject instanceof EntryComment);\n                $toReturn->subject = $this->entryCommentFactory->createResponseDto($subject, tags: $this->tagLinkRepository->getTagsOfContent($subject));\n                break;\n            case PostReport::class:\n                \\assert($subject instanceof Post);\n                $toReturn->subject = $this->postFactory->createResponseDto($subject, tags: $this->tagLinkRepository->getTagsOfContent($subject));\n                break;\n            case PostCommentReport::class:\n                \\assert($subject instanceof PostComment);\n                $toReturn->subject = $this->postCommentFactory->createResponseDto($subject, tags: $this->tagLinkRepository->getTagsOfContent($subject));\n                break;\n            default:\n                throw new \\LogicException();\n        }\n\n        return $toReturn;\n    }\n}\n"
  },
  {
    "path": "src/Factory/UserFactory.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Factory;\n\nuse App\\DTO\\UserDto;\nuse App\\DTO\\UserSignupResponseDto;\nuse App\\DTO\\UserSmallResponseDto;\nuse App\\Entity\\User;\nuse App\\Repository\\InstanceRepository;\nuse Symfony\\Bundle\\SecurityBundle\\Security;\n\nclass UserFactory\n{\n    public function __construct(\n        private readonly ImageFactory $imageFactory,\n        private readonly InstanceRepository $instanceRepository,\n        private readonly Security $security,\n    ) {\n    }\n\n    public function createDto(User $user, ?int $reputationPoints = null): UserDto\n    {\n        /** @var User $currentUser */\n        $currentUser = $this->security->getUser();\n        $dto = UserDto::create(\n            $user->username,\n            $user->email,\n            $user->avatar ? $this->imageFactory->createDto($user->avatar) : null,\n            $user->cover ? $this->imageFactory->createDto($user->cover) : null,\n            $user->about,\n            $user->createdAt,\n            $user->fields,\n            $user->apId,\n            $user->apProfileId,\n            $user->getId(),\n            $user->followersCount,\n            'Service' === $user->type, // setting isBot\n            $user->isAdmin(),\n            $user->isModerator(),\n            $currentUser && ($currentUser->isAdmin() || $currentUser->isModerator()) ? $user->applicationText : null,\n            reputationPoints: $reputationPoints,\n            discoverable: $user->apDiscoverable,\n            indexable: $user->apIndexable,\n            title: $user->title,\n        );\n\n        // Only return the user's vote if permission to control voting has been given\n        $dto->isFollowedByUser = $this->security->isGranted('ROLE_OAUTH2_USER:FOLLOW') ? $currentUser->isFollowing($user) : null;\n        $dto->isFollowerOfUser = $this->security->isGranted('ROLE_OAUTH2_USER:FOLLOW') && $user->showProfileFollowings ? $user->isFollowing($currentUser) : null;\n        $dto->isBlockedByUser = $this->security->isGranted('ROLE_OAUTH2_USER:BLOCK') ? $currentUser->isBlocked($user) : null;\n\n        $instance = $this->instanceRepository->getInstanceOfUser($user);\n        if ($instance) {\n            $dto->serverSoftware = $instance->software;\n            $dto->serverSoftwareVersion = $instance->version;\n        }\n\n        return $dto;\n    }\n\n    public function createSmallDto(User|UserDto $user): UserSmallResponseDto\n    {\n        $dto = $user instanceof User ? $this->createDto($user) : $user;\n\n        return new UserSmallResponseDto($dto);\n    }\n\n    public function createSignupResponseDto(User|UserDto $user): UserSignupResponseDto\n    {\n        $dto = $user instanceof User ? $this->createDto($user) : $user;\n\n        return new UserSignupResponseDto($dto);\n    }\n\n    public function createDtoFromAp($apProfileId, $apId): UserDto\n    {\n        $dto = (new UserDto())->create('@'.$apId, $apId, null, null, null, null, null, $apId, $apProfileId);\n        $dto->plainPassword = bin2hex(random_bytes(20));\n\n        return $dto;\n    }\n}\n"
  },
  {
    "path": "src/Factory/VoteFactory.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Factory;\n\nuse App\\Entity\\Contracts\\VotableInterface;\nuse App\\Entity\\Entry;\nuse App\\Entity\\EntryComment;\nuse App\\Entity\\EntryCommentVote;\nuse App\\Entity\\EntryVote;\nuse App\\Entity\\Post;\nuse App\\Entity\\PostComment;\nuse App\\Entity\\PostCommentVote;\nuse App\\Entity\\PostVote;\nuse App\\Entity\\User;\nuse App\\Entity\\Vote;\n\nclass VoteFactory\n{\n    public function create(int $choice, VotableInterface $votable, User $user): Vote\n    {\n        $vote = match (true) {\n            $votable instanceof Entry => new EntryVote($choice, $user, $votable),\n            $votable instanceof EntryComment => new EntryCommentVote($choice, $user, $votable),\n            $votable instanceof Post => new PostVote($choice, $user, $votable),\n            $votable instanceof PostComment => new PostCommentVote($choice, $user, $votable),\n            default => throw new \\LogicException(),\n        };\n\n        $votable->addVote($vote);\n\n        return $vote;\n    }\n}\n"
  },
  {
    "path": "src/Feed/Provider.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Feed;\n\nuse App\\Service\\FeedManager;\nuse Debril\\RssAtomBundle\\Provider\\FeedProviderInterface;\nuse FeedIo\\FeedInterface;\nuse Symfony\\Component\\HttpFoundation\\Request;\n\nclass Provider implements FeedProviderInterface\n{\n    public function __construct(private readonly FeedManager $manager)\n    {\n    }\n\n    public function getFeed(Request $request): FeedInterface\n    {\n        return $this->manager->getFeed($request);\n    }\n\n    protected function getItems(): \\Generator\n    {\n        yield $this->manager->getItems();\n    }\n}\n"
  },
  {
    "path": "src/Form/BadgeType.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Form;\n\nuse App\\DTO\\BadgeDto;\nuse Symfony\\Component\\Form\\AbstractType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\SubmitType;\nuse Symfony\\Component\\Form\\FormBuilderInterface;\nuse Symfony\\Component\\OptionsResolver\\OptionsResolver;\n\nclass BadgeType extends AbstractType\n{\n    public function buildForm(FormBuilderInterface $builder, array $options): void\n    {\n        $builder\n            ->add('name')\n            ->add('submit', SubmitType::class);\n    }\n\n    public function configureOptions(OptionsResolver $resolver): void\n    {\n        $resolver->setDefaults(\n            [\n                'data_class' => BadgeDto::class,\n            ]\n        );\n    }\n}\n"
  },
  {
    "path": "src/Form/BookmarkListType.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Form;\n\nuse App\\DTO\\BookmarkListDto;\nuse Symfony\\Component\\Form\\AbstractType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\CheckboxType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\SubmitType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\TextType;\nuse Symfony\\Component\\Form\\FormBuilderInterface;\nuse Symfony\\Component\\OptionsResolver\\OptionsResolver;\n\nclass BookmarkListType extends AbstractType\n{\n    public function buildForm(FormBuilderInterface $builder, array $options): void\n    {\n        $builder\n            ->add('name', TextType::class)\n            ->add('isDefault', CheckboxType::class, [\n                'required' => false,\n            ])\n            ->add('submit', SubmitType::class);\n    }\n\n    public function configureOptions(OptionsResolver $resolver): void\n    {\n        $resolver->setDefaults(\n            [\n                'data_class' => BookmarkListDto::class,\n            ]\n        );\n    }\n}\n"
  },
  {
    "path": "src/Form/ChangePasswordFormType.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Form;\n\nuse Symfony\\Component\\Form\\AbstractType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\PasswordType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\RepeatedType;\nuse Symfony\\Component\\Form\\FormBuilderInterface;\nuse Symfony\\Component\\OptionsResolver\\OptionsResolver;\nuse Symfony\\Component\\Validator\\Constraints\\Length;\nuse Symfony\\Component\\Validator\\Constraints\\NotBlank;\n\nclass ChangePasswordFormType extends AbstractType\n{\n    public function buildForm(FormBuilderInterface $builder, array $options): void\n    {\n        $builder\n            ->add('plainPassword', RepeatedType::class, [\n                'type' => PasswordType::class,\n                'first_options' => [\n                    'attr' => ['autocomplete' => 'new-password'],\n                    'constraints' => [\n                        new NotBlank([\n                            'message' => 'Please enter a password',\n                        ]),\n                        new Length([\n                            'min' => 6,\n                            'minMessage' => 'Your password should be at least {{ limit }} characters',\n                            // max length allowed by Symfony for security reasons\n                            'max' => 4096,\n                        ]),\n                    ],\n                    'label' => 'New password',\n                ],\n                'second_options' => [\n                    'attr' => ['autocomplete' => 'new-password'],\n                    'label' => 'Repeat Password',\n                ],\n                'invalid_message' => 'The password fields must match.',\n                // Instead of being set onto the object directly,\n                // this is read and encoded in the controller\n                'mapped' => false,\n            ]);\n    }\n\n    public function configureOptions(OptionsResolver $resolver): void\n    {\n        $resolver->setDefaults([]);\n    }\n}\n"
  },
  {
    "path": "src/Form/ConfirmDefederationType.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Form;\n\nuse App\\DTO\\ConfirmDefederationDto;\nuse Symfony\\Component\\Form\\AbstractType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\CheckboxType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\SubmitType;\nuse Symfony\\Component\\Form\\FormBuilderInterface;\nuse Symfony\\Component\\OptionsResolver\\OptionsResolver;\n\nclass ConfirmDefederationType extends AbstractType\n{\n    public function buildForm(FormBuilderInterface $builder, array $options)\n    {\n        $builder\n            ->add('confirm', CheckboxType::class)\n            ->add('submit', SubmitType::class)\n        ;\n    }\n\n    public function configureOptions(OptionsResolver $resolver): void\n    {\n        $resolver->setDefaults(['data_class' => ConfirmDefederationDto::class]);\n    }\n}\n"
  },
  {
    "path": "src/Form/Constraint/ImageConstraint.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Form\\Constraint;\n\nuse App\\Service\\ImageManager;\nuse Symfony\\Component\\Validator\\Constraints\\Image;\n\nclass ImageConstraint\n{\n    public static function default(): Image\n    {\n        return new Image(\n            maxSize: '12M',\n            mimeTypes: ImageManager::IMAGE_MIMETYPES,\n            detectCorrupted: true,\n            groups: ['upload'],\n        );\n    }\n}\n"
  },
  {
    "path": "src/Form/ContactType.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Form;\n\nuse App\\DTO\\ContactDto;\nuse App\\Form\\EventListener\\CaptchaListener;\nuse Symfony\\Component\\Form\\AbstractType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\EmailType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\SubmitType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\TextareaType;\nuse Symfony\\Component\\Form\\FormBuilderInterface;\nuse Symfony\\Component\\OptionsResolver\\OptionsResolver;\n\nclass ContactType extends AbstractType\n{\n    public function __construct(\n        private readonly CaptchaListener $captchaListener,\n    ) {\n    }\n\n    public function buildForm(FormBuilderInterface $builder, array $options): void\n    {\n        $builder\n            ->add('name')\n            ->add('surname')\n            ->add('email', EmailType::class)\n            ->add('message', TextareaType::class)\n            ->add('submit', SubmitType::class);\n\n        $builder->addEventSubscriber($this->captchaListener);\n    }\n\n    public function configureOptions(OptionsResolver $resolver): void\n    {\n        $resolver->setDefaults(\n            [\n                'data_class' => ContactDto::class,\n            ]\n        );\n    }\n}\n"
  },
  {
    "path": "src/Form/DataTransformer/BadgeCollectionToStringTransformer.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Form\\DataTransformer;\n\nuse Doctrine\\Common\\Collections\\ArrayCollection;\nuse Doctrine\\Common\\Collections\\Collection;\nuse Symfony\\Component\\Form\\DataTransformerInterface;\n\nclass BadgeCollectionToStringTransformer implements DataTransformerInterface\n{\n    public function transform($value): string\n    {\n        if ($value instanceof Collection) {\n            $value = $value->toArray();\n            natcasesort($value);\n        } elseif (null !== $value) {\n            throw new \\TypeError(\\sprintf('$value must be array or NULL, %s given', get_debug_type($value)));\n        }\n\n        return implode(', ', $value ?? []);\n    }\n\n    public function reverseTransform($value): ArrayCollection\n    {\n        if (\\is_string($value)) {\n            return new ArrayCollection(preg_split('/\\s*,\\s*/', trim($value), -1, PREG_SPLIT_NO_EMPTY));\n        }\n\n        if (null !== $value) {\n            throw new \\TypeError(\\sprintf('$value must be string or NULL, %s given', get_debug_type($value)));\n        }\n\n        return new ArrayCollection();\n    }\n}\n"
  },
  {
    "path": "src/Form/DataTransformer/FeaturedMagazinesBarTransformer.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Form\\DataTransformer;\n\nuse Symfony\\Component\\Form\\DataTransformerInterface;\n\nclass FeaturedMagazinesBarTransformer implements DataTransformerInterface\n{\n    public function transform($value): ?string\n    {\n        return $value ? implode(',', $value) : null;\n    }\n\n    public function reverseTransform($value): ?array\n    {\n        if (null === $value || '' === $value) {\n            return null;\n        }\n\n        return explode(',', $value);\n    }\n}\n"
  },
  {
    "path": "src/Form/DataTransformer/TagTransformer.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Form\\DataTransformer;\n\nuse Symfony\\Component\\Form\\DataTransformerInterface;\n\nclass TagTransformer implements DataTransformerInterface\n{\n    public function transform($value): ?string\n    {\n        return $value ? implode(' ', $value) : null;\n    }\n\n    public function reverseTransform($value): ?array\n    {\n        if (null === $value || '' === $value) {\n            return null;\n        }\n\n        return explode(' ', strtolower($value));\n    }\n}\n"
  },
  {
    "path": "src/Form/DataTransformer/UserTransformer.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Form\\DataTransformer;\n\nuse App\\Entity\\User;\nuse App\\Repository\\UserRepository;\nuse Symfony\\Component\\Form\\DataTransformerInterface;\n\nclass UserTransformer implements DataTransformerInterface\n{\n    public function __construct(private readonly UserRepository $repository)\n    {\n    }\n\n    public function transform($value): ?string\n    {\n        if ($value instanceof User) {\n            return $value->getUsername();\n        }\n\n        if (null !== $value) {\n            throw new \\InvalidArgumentException('$value must be '.User::class.' or null');\n        }\n\n        return null;\n    }\n\n    public function reverseTransform($value): ?User\n    {\n        if (null === $value || '' === $value) {\n            return null;\n        }\n\n        return $this->repository->findOneByUsername($value);\n    }\n}\n"
  },
  {
    "path": "src/Form/EntryCommentType.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Form;\n\nuse App\\DTO\\EntryCommentDto;\nuse App\\Form\\Constraint\\ImageConstraint;\nuse App\\Form\\EventListener\\DefaultLanguage;\nuse App\\Form\\EventListener\\ImageListener;\nuse App\\Form\\Type\\LanguageType;\nuse App\\Service\\SettingsManager;\nuse Symfony\\Component\\Form\\AbstractType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\FileType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\SubmitType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\TextareaType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\UrlType;\nuse Symfony\\Component\\Form\\FormBuilderInterface;\nuse Symfony\\Component\\Form\\FormInterface;\nuse Symfony\\Component\\Form\\FormView;\nuse Symfony\\Component\\OptionsResolver\\OptionsResolver;\n\nclass EntryCommentType extends AbstractType\n{\n    public function __construct(\n        private readonly ImageListener $imageListener,\n        private readonly DefaultLanguage $defaultLanguage,\n        private readonly SettingsManager $settingsManager,\n    ) {\n    }\n\n    public function buildForm(FormBuilderInterface $builder, array $options): void\n    {\n        $builder\n            ->add('body', TextareaType::class, ['required' => false, 'empty_data' => ''])\n            ->add('lang', LanguageType::class, ['priorityLanguage' => $options['parentLanguage']])\n            ->add(\n                'image',\n                FileType::class,\n                [\n                    'constraints' => ImageConstraint::default(),\n                    'mapped' => false,\n                    'required' => false,\n                ]\n            )\n            ->add('imageUrl', UrlType::class, ['required' => false, 'default_protocol' => 'https'])\n            ->add('imageAlt', TextareaType::class, ['required' => false])\n            ->add('submit', SubmitType::class);\n\n        $builder->addEventSubscriber($this->defaultLanguage);\n        $builder->addEventSubscriber($this->imageListener);\n    }\n\n    public function configureOptions(OptionsResolver $resolver): void\n    {\n        $resolver->setDefaults(\n            [\n                'data_class' => EntryCommentDto::class,\n                'parentLanguage' => $this->settingsManager->get('KBIN_DEFAULT_LANG'),\n            ]\n        );\n\n        $resolver->addAllowedTypes('parentLanguage', 'string');\n    }\n\n    public function buildView(FormView $view, FormInterface $form, array $options): void\n    {\n        parent::buildView($view, $form, $options);\n\n        $view->vars['id'] .= '_'.uniqid('', true);\n    }\n}\n"
  },
  {
    "path": "src/Form/EntryEditType.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Form;\n\nuse App\\DTO\\EntryDto;\nuse App\\Form\\Constraint\\ImageConstraint;\nuse App\\Form\\DataTransformer\\TagTransformer;\nuse App\\Form\\EventListener\\DefaultLanguage;\nuse App\\Form\\EventListener\\DisableFieldsOnEntryEdit;\nuse App\\Form\\EventListener\\ImageListener;\n// use App\\Form\\Type\\BadgesType;\nuse App\\Form\\Type\\LanguageType;\nuse App\\Form\\Type\\MagazineAutocompleteType;\nuse Symfony\\Component\\Form\\AbstractType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\CheckboxType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\FileType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\SubmitType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\TextareaType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\TextType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\UrlType;\nuse Symfony\\Component\\Form\\FormBuilderInterface;\nuse Symfony\\Component\\OptionsResolver\\OptionsResolver;\n\nclass EntryEditType extends AbstractType\n{\n    public function __construct(\n        private readonly ImageListener $imageListener,\n        private readonly DefaultLanguage $defaultLanguage,\n        private readonly DisableFieldsOnEntryEdit $disableFieldsOnEntryEdit,\n    ) {\n    }\n\n    public function buildForm(FormBuilderInterface $builder, array $options): void\n    {\n        $builder\n            ->add('url', UrlType::class, [\n                'required' => false,\n                'default_protocol' => 'https',\n            ])\n            ->add('title', TextareaType::class, [\n                'required' => true,\n            ])\n            ->add('body', TextareaType::class, [\n                'required' => false,\n            ])\n            ->add('magazine', MagazineAutocompleteType::class)\n            ->add('tags', TextType::class, [\n                'required' => false,\n                'autocomplete' => true,\n                'tom_select_options' => [\n                    'create' => true,\n                    'createOnBlur' => true,\n                    'delimiter' => ' ',\n                ],\n            ])\n            // ->add(\n            //     'badges',\n            //     BadgesType::class,\n            //     [\n            //         'required' => false,\n            //     ]\n            // )\n            ->add(\n                'image',\n                FileType::class,\n                [\n                    'constraints' => ImageConstraint::default(),\n                    'mapped' => false,\n                    'required' => false,\n                ]\n            )\n            ->add('imageUrl', UrlType::class, [\n                'required' => false,\n                'default_protocol' => 'https',\n            ])\n            ->add('imageAlt', TextType::class, [\n                'required' => false,\n            ])\n            ->add('isAdult', CheckboxType::class, [\n                'required' => false,\n            ])\n            ->add('lang', LanguageType::class)\n            ->add('isOc', CheckboxType::class, [\n                'required' => false,\n            ])\n            ->add('submit', SubmitType::class);\n\n        $builder->get('tags')->addModelTransformer(\n            new TagTransformer()\n        );\n\n        $builder->addEventSubscriber($this->defaultLanguage);\n        $builder->addEventSubscriber($this->disableFieldsOnEntryEdit);\n        $builder->addEventSubscriber($this->imageListener);\n    }\n\n    public function configureOptions(OptionsResolver $resolver): void\n    {\n        $resolver->setDefaults(\n            [\n                'data_class' => EntryDto::class,\n            ]\n        );\n    }\n}\n"
  },
  {
    "path": "src/Form/EntryType.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Form;\n\nuse App\\DTO\\EntryDto;\nuse App\\Form\\Constraint\\ImageConstraint;\nuse App\\Form\\DataTransformer\\TagTransformer;\nuse App\\Form\\EventListener\\DefaultLanguage;\nuse App\\Form\\EventListener\\DisableFieldsOnEntryEdit;\nuse App\\Form\\EventListener\\ImageListener;\n// use App\\Form\\Type\\BadgesType;\nuse App\\Form\\Type\\LanguageType;\nuse App\\Form\\Type\\MagazineAutocompleteType;\nuse Symfony\\Component\\Form\\AbstractType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\CheckboxType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\FileType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\SubmitType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\TextareaType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\TextType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\UrlType;\nuse Symfony\\Component\\Form\\FormBuilderInterface;\nuse Symfony\\Component\\OptionsResolver\\OptionsResolver;\n\nclass EntryType extends AbstractType\n{\n    public function __construct(\n        private readonly ImageListener $imageListener,\n        private readonly DefaultLanguage $defaultLanguage,\n        private readonly DisableFieldsOnEntryEdit $disableFieldsOnEntryEdit,\n    ) {\n    }\n\n    public function buildForm(FormBuilderInterface $builder, array $options): void\n    {\n        $builder\n            ->add('url', UrlType::class, [\n                'required' => false,\n                'default_protocol' => 'https',\n            ])\n            ->add('title', TextareaType::class, [\n                'required' => true,\n            ])\n            ->add('body', TextareaType::class, [\n                'required' => false,\n            ])\n            ->add('magazine', MagazineAutocompleteType::class)\n            ->add('tags', TextType::class, [\n                'required' => false,\n                'autocomplete' => true,\n                'tom_select_options' => [\n                    'create' => true,\n                    'createOnBlur' => true,\n                    'delimiter' => ' ',\n                ],\n            ])\n            // ->add(\n            //     'badges',\n            //     BadgesType::class,\n            //     [\n            //         'required' => false,\n            //     ]\n            // )\n            ->add(\n                'image',\n                FileType::class,\n                [\n                    'required' => false,\n                    'constraints' => ImageConstraint::default(),\n                    'mapped' => false,\n                ]\n            )\n            ->add('imageUrl', UrlType::class, [\n                'required' => false,\n                'default_protocol' => 'https',\n            ])\n            ->add('imageAlt', TextType::class, [\n                'required' => false,\n            ])\n            ->add('isAdult', CheckboxType::class, [\n                'required' => false,\n            ])\n            ->add('lang', LanguageType::class)\n            ->add('isOc', CheckboxType::class, [\n                'required' => false,\n            ])\n            ->add('submit', SubmitType::class);\n\n        $builder->get('tags')->addModelTransformer(\n            new TagTransformer()\n        );\n\n        $builder->addEventSubscriber($this->defaultLanguage);\n        $builder->addEventSubscriber($this->disableFieldsOnEntryEdit);\n        $builder->addEventSubscriber($this->imageListener);\n    }\n\n    public function configureOptions(OptionsResolver $resolver): void\n    {\n        $resolver->setDefaults(\n            [\n                'data_class' => EntryDto::class,\n            ]\n        );\n    }\n}\n"
  },
  {
    "path": "src/Form/EventListener/AddFieldsOnUserEdit.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Form\\EventListener;\n\nuse App\\Service\\ImageManager;\nuse Symfony\\Component\\EventDispatcher\\EventSubscriberInterface;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\FileType;\nuse Symfony\\Component\\Form\\FormEvent;\nuse Symfony\\Component\\Form\\FormEvents;\nuse Symfony\\Component\\Validator\\Constraints\\Image as ImageConstraint;\n\nfinal class AddFieldsOnUserEdit implements EventSubscriberInterface\n{\n    public static function getSubscribedEvents(): array\n    {\n        return [FormEvents::PRE_SET_DATA => 'preSetData'];\n    }\n\n    public function preSetData(FormEvent $event): void\n    {\n        $user = $event->getData();\n        $form = $event->getForm();\n\n        if (!$user || null === $user->getId()) {\n            return;\n        }\n\n        $form->add(\n            'avatar',\n            FileType::class,\n            [\n                'required' => false,\n                'constraints' => $this->getConstraint(),\n                'mapped' => false,\n            ]\n        );\n\n        $form->add(\n            'cover',\n            FileType::class,\n            [\n                'required' => false,\n                'constraints' => $this->getConstraint('10M'),\n                'mapped' => false,\n            ]\n        );\n    }\n\n    private function getConstraint(string $maxSize = '2M'): ImageConstraint\n    {\n        return new ImageConstraint(\n            [\n                'detectCorrupted' => true,\n                'groups' => ['upload'],\n                'maxSize' => $maxSize,\n                'mimeTypes' => ImageManager::IMAGE_MIMETYPES,\n            ]\n        );\n    }\n}\n"
  },
  {
    "path": "src/Form/EventListener/AvatarListener.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Form\\EventListener;\n\nuse App\\Factory\\ImageFactory;\nuse App\\Repository\\ImageRepository;\nuse Symfony\\Component\\EventDispatcher\\EventSubscriberInterface;\nuse Symfony\\Component\\Form\\Event\\PostSubmitEvent;\nuse Symfony\\Component\\Form\\FormEvents;\n\nfinal class AvatarListener implements EventSubscriberInterface\n{\n    private string $fieldName;\n\n    public function __construct(\n        private readonly ImageRepository $images,\n        private readonly ImageFactory $factory,\n    ) {\n    }\n\n    public static function getSubscribedEvents(): array\n    {\n        return [\n            FormEvents::POST_SUBMIT => ['onPostSubmit', -200],\n        ];\n    }\n\n    public function onPostSubmit(PostSubmitEvent $event): void\n    {\n        if (!$event->getForm()->isValid()) {\n            return;\n        }\n\n        $data = $event->getData();\n\n        $fieldName = $this->fieldName ?? 'avatar';\n\n        if (!$event->getForm()->has($fieldName)) {\n            return;\n        }\n\n        $upload = $event->getForm()->get($fieldName)->getData();\n\n        if ($upload) {\n            // This could throw an error (be sure to catch it in your controller)\n            $image = $this->images->findOrCreateFromUpload($upload);\n            if ($image) {\n                $data->$fieldName = $this->factory->createDto($image);\n            }\n        }\n    }\n\n    public function setFieldName(string $fieldName): self\n    {\n        $this->fieldName = $fieldName;\n\n        return $this;\n    }\n}\n"
  },
  {
    "path": "src/Form/EventListener/CaptchaListener.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Form\\EventListener;\n\nuse App\\Service\\SettingsManager;\nuse MeteoConcept\\HCaptchaBundle\\Form\\HCaptchaType;\nuse Symfony\\Component\\EventDispatcher\\EventSubscriberInterface;\nuse Symfony\\Component\\Form\\FormEvent;\nuse Symfony\\Component\\Form\\FormEvents;\n\nfinal class CaptchaListener implements EventSubscriberInterface\n{\n    public function __construct(private readonly SettingsManager $settingsManager)\n    {\n    }\n\n    public static function getSubscribedEvents(): array\n    {\n        return [FormEvents::PRE_SET_DATA => 'preSetData'];\n    }\n\n    public function preSetData(FormEvent $event): void\n    {\n        if (!$this->settingsManager->get('KBIN_CAPTCHA_ENABLED')) {\n            return;\n        }\n\n        $form = $event->getForm();\n\n        $form->add('captcha', HCaptchaType::class, [\n            'label' => 'Captcha',\n        ]);\n    }\n}\n"
  },
  {
    "path": "src/Form/EventListener/DefaultLanguage.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Form\\EventListener;\n\nuse Symfony\\Component\\EventDispatcher\\EventSubscriberInterface;\nuse Symfony\\Component\\Form\\FormEvent;\nuse Symfony\\Component\\Form\\FormEvents;\nuse Symfony\\Component\\HttpFoundation\\RequestStack;\n\nfinal class DefaultLanguage implements EventSubscriberInterface\n{\n    public function __construct(private readonly RequestStack $requestStack)\n    {\n    }\n\n    public static function getSubscribedEvents(): array\n    {\n        return [FormEvents::PRE_SET_DATA => 'preSetData'];\n    }\n\n    public function preSetData(FormEvent $event): void\n    {\n        $dto = $event->getData();\n\n        if (null !== $dto && null === $dto->lang) {\n            $dto->lang = $event->getForm()->getConfig()->getOption(\n                'parentLanguage',\n                $this->requestStack->getCurrentRequest()?->getLocale(),\n            );\n\n            $event->setData($dto);\n        }\n    }\n}\n"
  },
  {
    "path": "src/Form/EventListener/DisableFieldsOnEntryEdit.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Form\\EventListener;\n\nuse Symfony\\Component\\EventDispatcher\\EventSubscriberInterface;\nuse Symfony\\Component\\Form\\FormEvent;\nuse Symfony\\Component\\Form\\FormEvents;\n\nfinal class DisableFieldsOnEntryEdit implements EventSubscriberInterface\n{\n    public static function getSubscribedEvents(): array\n    {\n        return [FormEvents::PRE_SET_DATA => 'preSetData'];\n    }\n\n    public function preSetData(FormEvent $event): void\n    {\n        $entry = $event->getData();\n        $form = $event->getForm();\n\n        if (!$entry || null === $entry->getId()) {\n            return;\n        }\n\n        $field = $form->get('magazine');\n        $attrs = $field->getConfig()->getOptions();\n        $attrs['disabled'] = 'disabled';\n\n        $form->remove($field->getName());\n        $form->add(\n            $field->getName(),\n            \\get_class($field->getConfig()->getType()->getInnerType()),\n            $attrs\n        );\n    }\n}\n"
  },
  {
    "path": "src/Form/EventListener/DisableFieldsOnMagazineEdit.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Form\\EventListener;\n\nuse Symfony\\Component\\EventDispatcher\\EventSubscriberInterface;\nuse Symfony\\Component\\Form\\FormEvent;\nuse Symfony\\Component\\Form\\FormEvents;\n\nfinal class DisableFieldsOnMagazineEdit implements EventSubscriberInterface\n{\n    public static function getSubscribedEvents(): array\n    {\n        return [FormEvents::PRE_SET_DATA => 'preSetData'];\n    }\n\n    public function preSetData(FormEvent $event): void\n    {\n        $magazine = $event->getData();\n        $form = $event->getForm();\n\n        if (!$magazine || null === $magazine->getId()) {\n            return;\n        }\n\n        $field = $form->get('name');\n        $attrs = $field->getConfig()->getOptions();\n        $attrs['disabled'] = 'disabled';\n\n        $form->remove($field->getName());\n        $form->add(\n            $field->getName(),\n            \\get_class($field->getConfig()->getType()->getInnerType()),\n            $attrs\n        );\n\n        $form->remove($form->get('nameAsTag')->getName());\n    }\n}\n"
  },
  {
    "path": "src/Form/EventListener/DisableFieldsOnUserEdit.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Form\\EventListener;\n\nuse App\\Repository\\UserRepository;\nuse Symfony\\Bundle\\SecurityBundle\\Security;\nuse Symfony\\Component\\EventDispatcher\\EventSubscriberInterface;\nuse Symfony\\Component\\Form\\FormEvent;\nuse Symfony\\Component\\Form\\FormEvents;\n\nfinal class DisableFieldsOnUserEdit implements EventSubscriberInterface\n{\n    public function __construct(private readonly UserRepository $repository, private readonly Security $security)\n    {\n    }\n\n    public static function getSubscribedEvents(): array\n    {\n        return [FormEvents::PRE_SET_DATA => 'preSetData'];\n    }\n\n    public function preSetData(FormEvent $event): void\n    {\n        $user = $event->getData();\n        $form = $event->getForm();\n\n        if (!$user || null === $user->getId()) {\n            return;\n        }\n\n        if ($this->security->isGranted('edit_username', $this->repository->find($user->id))) {\n            return;\n        }\n\n        $field = $form->get('username');\n        $attrs = $field->getConfig()->getOptions();\n        $attrs['disabled'] = 'disabled';\n\n        $form->remove($field->getName());\n        $form->add(\n            $field->getName(),\n            \\get_class($field->getConfig()->getType()->getInnerType()),\n            $attrs\n        );\n    }\n}\n"
  },
  {
    "path": "src/Form/EventListener/ImageListener.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Form\\EventListener;\n\nuse App\\Factory\\ImageFactory;\nuse App\\Repository\\ImageRepository;\nuse Symfony\\Component\\EventDispatcher\\EventSubscriberInterface;\nuse Symfony\\Component\\Form\\Event\\PostSubmitEvent;\nuse Symfony\\Component\\Form\\FormEvents;\n\nfinal class ImageListener implements EventSubscriberInterface\n{\n    private string $fieldName;\n\n    public function __construct(\n        private readonly ImageRepository $images,\n        private readonly ImageFactory $factory,\n    ) {\n    }\n\n    public static function getSubscribedEvents(): array\n    {\n        return [\n            FormEvents::POST_SUBMIT => ['onPostSubmit', -200],\n        ];\n    }\n\n    public function onPostSubmit(PostSubmitEvent $event): void\n    {\n        if (!$event->getForm()->isValid()) {\n            return;\n        }\n\n        $data = $event->getData();\n\n        $fieldName = $this->fieldName ?? 'image';\n\n        if (!$event->getForm()->has($fieldName)) {\n            return;\n        }\n\n        $upload = $event->getForm()->get($fieldName)->getData();\n\n        if ($upload) {\n            // This could throw an error (be sure to catch it in your controller)\n            $image = $this->images->findOrCreateFromUpload($upload);\n            if ($image) {\n                $data->$fieldName = $this->factory->createDto($image);\n            }\n        }\n    }\n\n    public function setFieldName(string $fieldName): self\n    {\n        $this->fieldName = $fieldName;\n\n        return $this;\n    }\n}\n"
  },
  {
    "path": "src/Form/EventListener/RemoveFieldsOnEntryImageEdit.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Form\\EventListener;\n\nuse Symfony\\Component\\EventDispatcher\\EventSubscriberInterface;\nuse Symfony\\Component\\Form\\FormEvent;\nuse Symfony\\Component\\Form\\FormEvents;\n\nfinal class RemoveFieldsOnEntryImageEdit implements EventSubscriberInterface\n{\n    public static function getSubscribedEvents(): array\n    {\n        return [FormEvents::PRE_SET_DATA => 'preSetData'];\n    }\n\n    public function preSetData(FormEvent $event): void\n    {\n        $entry = $event->getData();\n        $form = $event->getForm();\n\n        if (!$entry || null === $entry->getId()) {\n            return;\n        }\n\n        $form->remove($form->get('image')->getName());\n    }\n}\n"
  },
  {
    "path": "src/Form/EventListener/RemoveFieldsOnEntryLinkCreate.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Form\\EventListener;\n\nuse Symfony\\Component\\EventDispatcher\\EventSubscriberInterface;\nuse Symfony\\Component\\Form\\FormEvent;\nuse Symfony\\Component\\Form\\FormEvents;\n\nfinal class RemoveFieldsOnEntryLinkCreate implements EventSubscriberInterface\n{\n    public static function getSubscribedEvents(): array\n    {\n        return [FormEvents::PRE_SET_DATA => 'preSetData'];\n    }\n\n    public function preSetData(FormEvent $event): void\n    {\n        $entry = $event->getData();\n        $form = $event->getForm();\n\n        if ($entry && $entry->getId()) {\n            return;\n        }\n\n        $form->remove($form->get('image')->getName());\n    }\n}\n"
  },
  {
    "path": "src/Form/EventListener/RemoveRulesFieldIfEmpty.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Form\\EventListener;\n\nuse App\\Entity\\Magazine;\nuse Symfony\\Component\\EventDispatcher\\EventSubscriberInterface;\nuse Symfony\\Component\\Form\\FormEvent;\nuse Symfony\\Component\\Form\\FormEvents;\n\nfinal class RemoveRulesFieldIfEmpty implements EventSubscriberInterface\n{\n    public static function getSubscribedEvents(): array\n    {\n        return [FormEvents::PRE_SET_DATA => 'preSetData'];\n    }\n\n    public function preSetData(FormEvent $event): void\n    {\n        /** @var Magazine $magazine */\n        $magazine = $event->getData();\n        $form = $event->getForm();\n\n        $field = $form->get('rules');\n\n        if (!$field->isEmpty() || !empty($magazine?->rules)) {\n            return;\n        }\n        $form->remove($field->getName());\n    }\n}\n"
  },
  {
    "path": "src/Form/Extension/NoValidateExtension.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Form\\Extension;\n\nuse Symfony\\Component\\Form\\AbstractTypeExtension;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\FormType;\nuse Symfony\\Component\\Form\\FormInterface;\nuse Symfony\\Component\\Form\\FormView;\n\nfinal class NoValidateExtension extends AbstractTypeExtension\n{\n    public function __construct(private readonly bool $html5Validation)\n    {\n    }\n\n    public static function getExtendedTypes(): iterable\n    {\n        return [FormType::class];\n    }\n\n    /**\n     * @param array<string,mixed> $options\n     */\n    public function buildView(FormView $view, FormInterface $form, array $options): void\n    {\n        $attr = !$this->html5Validation ? ['novalidate' => 'novalidate'] : [];\n        $view->vars['attr'] = array_merge($view->vars['attr'], $attr);\n    }\n}\n"
  },
  {
    "path": "src/Form/FederationSettingsType.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Form;\n\nuse Symfony\\Component\\Form\\AbstractType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\CheckboxType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\SubmitType;\nuse Symfony\\Component\\Form\\FormBuilderInterface;\n\nclass FederationSettingsType extends AbstractType\n{\n    public function buildForm(FormBuilderInterface $builder, array $options): void\n    {\n        $builder\n            ->add('federationEnabled', CheckboxType::class, ['required' => false])\n            ->add('federationUsesAllowList',\n                CheckboxType::class,\n                [\n                    'required' => false,\n                    'help' => 'federation_page_use_allowlist_help',\n                ],\n            )\n            ->add('federationPageEnabled', CheckboxType::class, ['required' => false])\n            ->add('submit', SubmitType::class)\n        ;\n    }\n}\n"
  },
  {
    "path": "src/Form/LangType.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Form;\n\nuse App\\Form\\Type\\LanguageType;\nuse Symfony\\Component\\Form\\AbstractType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\SubmitType;\nuse Symfony\\Component\\Form\\FormBuilderInterface;\nuse Symfony\\Component\\OptionsResolver\\OptionsResolver;\n\nclass LangType extends AbstractType\n{\n    public function buildForm(FormBuilderInterface $builder, array $options): void\n    {\n        $builder\n            ->add('lang', LanguageType::class)\n            ->add('submit', SubmitType::class);\n    }\n\n    public function configureOptions(OptionsResolver $resolver): void\n    {\n        $resolver->setDefaults([]);\n    }\n}\n"
  },
  {
    "path": "src/Form/MagazineBanType.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Form;\n\nuse App\\DTO\\MagazineBanDto;\nuse Symfony\\Component\\Form\\AbstractType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\DateTimeType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\SubmitType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\TextareaType;\nuse Symfony\\Component\\Form\\FormBuilderInterface;\nuse Symfony\\Component\\OptionsResolver\\OptionsResolver;\n\nclass MagazineBanType extends AbstractType\n{\n    public function buildForm(FormBuilderInterface $builder, array $options): void\n    {\n        $builder\n            ->add('reason', TextareaType::class)\n            ->add(\n                'expiredAt',\n                DateTimeType::class,\n                ['widget' => 'single_text', 'input' => 'datetime_immutable', 'required' => false]\n            )\n            ->add('submit', SubmitType::class);\n    }\n\n    public function configureOptions(OptionsResolver $resolver): void\n    {\n        $resolver->setDefaults(\n            [\n                'data_class' => MagazineBanDto::class,\n            ]\n        );\n    }\n}\n"
  },
  {
    "path": "src/Form/MagazinePageViewType.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Form;\n\nuse App\\PageView\\MagazinePageView;\nuse App\\Repository\\Criteria;\nuse Symfony\\Component\\Form\\AbstractType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\ChoiceType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\TextType;\nuse Symfony\\Component\\Form\\FormBuilderInterface;\nuse Symfony\\Component\\OptionsResolver\\OptionsResolver;\n\nclass MagazinePageViewType extends AbstractType\n{\n    public function buildForm(FormBuilderInterface $builder, array $options): void\n    {\n        $builder\n            ->add('query', TextType::class, [\n                'attr' => [\n                    'placeholder' => 'type_search_term',\n                ],\n                'required' => false,\n            ])\n            ->add('fields', ChoiceType::class, [\n                'choices' => [\n                    'filter.fields.only_names' => MagazinePageView::FIELDS_NAMES,\n                    'filter.fields.names_and_descriptions' => MagazinePageView::FIELDS_NAMES_DESCRIPTIONS,\n                ],\n            ])\n            ->add('federation', ChoiceType::class, [\n                'choices' => [\n                    'local_and_federated' => Criteria::AP_ALL,\n                    'local' => Criteria::AP_LOCAL,\n                ],\n            ])\n            ->add('adult', ChoiceType::class, [\n                'choices' => [\n                    'filter.adult.hide' => MagazinePageView::ADULT_HIDE,\n                    'filter.adult.show' => MagazinePageView::ADULT_SHOW,\n                    'filter.adult.only' => MagazinePageView::ADULT_ONLY,\n                ],\n            ]);\n    }\n\n    public function configureOptions(OptionsResolver $resolver): void\n    {\n        $resolver->setDefaults(\n            [\n                'data_class' => MagazinePageView::class,\n                'csrf_protection' => false,\n                'method' => 'GET',\n            ]\n        );\n    }\n\n    public function getBlockPrefix(): string\n    {\n        return '';\n    }\n}\n"
  },
  {
    "path": "src/Form/MagazineTagsType.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Form;\n\nuse App\\Entity\\Magazine;\nuse App\\Form\\DataTransformer\\TagTransformer;\nuse Symfony\\Component\\Form\\AbstractType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\SubmitType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\TextType;\nuse Symfony\\Component\\Form\\FormBuilderInterface;\nuse Symfony\\Component\\OptionsResolver\\OptionsResolver;\n\nclass MagazineTagsType extends AbstractType\n{\n    public function buildForm(FormBuilderInterface $builder, array $options): void\n    {\n        $builder\n            ->add('tags')\n            ->add('submit', SubmitType::class)\n            ->add('tags', TextType::class, [\n                'required' => false,\n                'autocomplete' => true,\n                'tom_select_options' => [\n                    'create' => true,\n                    'createOnBlur' => true,\n                    'delimiter' => ' ',\n                ],\n            ]);\n\n        $builder->get('tags')->addModelTransformer(\n            new TagTransformer()\n        );\n    }\n\n    public function configureOptions(OptionsResolver $resolver): void\n    {\n        $resolver->setDefaults(\n            [\n                'data_class' => Magazine::class,\n            ]\n        );\n    }\n}\n"
  },
  {
    "path": "src/Form/MagazineThemeType.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Form;\n\nuse App\\DTO\\MagazineThemeDto;\nuse App\\Form\\Constraint\\ImageConstraint;\nuse App\\Form\\EventListener\\AvatarListener;\nuse App\\Form\\EventListener\\ImageListener;\nuse Symfony\\Component\\Form\\AbstractType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\ChoiceType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\FileType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\SubmitType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\TextareaType;\nuse Symfony\\Component\\Form\\FormBuilderInterface;\nuse Symfony\\Component\\OptionsResolver\\OptionsResolver;\n\nclass MagazineThemeType extends AbstractType\n{\n    public function __construct(\n        private readonly ImageListener $imageListener,\n        private readonly AvatarListener $avatarListener,\n    ) {\n    }\n\n    public function buildForm(FormBuilderInterface $builder, array $options): void\n    {\n        $builder\n            ->add(\n                'icon',\n                FileType::class,\n                [\n                    'constraints' => ImageConstraint::default(),\n                    'mapped' => false,\n                    'required' => false,\n                    'help' => 'magazine_theme_appearance_icon',\n                ]\n            )\n            ->add(\n                'banner',\n                FileType::class,\n                [\n                    'constraints' => ImageConstraint::default(),\n                    'mapped' => false,\n                    'required' => false,\n                    'help' => 'magazine_theme_appearance_banner',\n                ]\n            )\n            ->add('customCss', TextareaType::class, [\n                'required' => false,\n                'help' => 'magazine_theme_appearance_custom_css',\n            ]\n            )\n            ->add('backgroundImage', ChoiceType::class, [\n                'multiple' => false,\n                'expanded' => true,\n                'data' => 'none',\n                'choices' => [\n                    'none' => 'none',\n                    'shape 1' => 'shape1',\n                    'shape 2' => 'shape2',\n                ],\n                'help' => 'magazine_theme_appearance_background_image',\n            ])\n            ->add('submit', SubmitType::class);\n\n        $builder->addEventSubscriber($this->avatarListener->setFieldName('icon'));\n        $builder->addEventSubscriber($this->imageListener->setFieldName('banner'));\n    }\n\n    public function configureOptions(OptionsResolver $resolver): void\n    {\n        $resolver->setDefaults(\n            [\n                'data_class' => MagazineThemeDto::class,\n            ]\n        );\n    }\n}\n"
  },
  {
    "path": "src/Form/MagazineType.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Form;\n\nuse App\\DTO\\MagazineDto;\nuse App\\Form\\EventListener\\DisableFieldsOnMagazineEdit;\nuse App\\Form\\EventListener\\RemoveRulesFieldIfEmpty;\nuse Symfony\\Component\\Form\\AbstractType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\CheckboxType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\SubmitType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\TextareaType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\TextType;\nuse Symfony\\Component\\Form\\FormBuilderInterface;\nuse Symfony\\Component\\OptionsResolver\\OptionsResolver;\n\nclass MagazineType extends AbstractType\n{\n    public function buildForm(FormBuilderInterface $builder, array $options): void\n    {\n        $builder\n            ->add('name', TextType::class, ['required' => true])\n            ->add('title', TextType::class, ['required' => true])\n            ->add('description', TextareaType::class, ['required' => false])\n            ->add('rules', TextareaType::class, [\n                'required' => false,\n                'help' => 'magazine_rules_deprecated',\n            ])\n            ->add('isAdult', CheckboxType::class, ['required' => false])\n            ->add('isPostingRestrictedToMods', CheckboxType::class, ['required' => false])\n            ->add('discoverable', CheckboxType::class, [\n                'required' => false,\n                'help' => 'magazine_discoverable_help',\n            ])\n            ->add('indexable', CheckboxType::class, [\n                'required' => false,\n                'help' => 'magazine_indexable_by_search_engines_help',\n            ])\n            // this is removed through the event subscriber below on magazine edit\n            ->add('nameAsTag', CheckboxType::class, [\n                'required' => false,\n                'help' => 'magazine_name_as_tag_help',\n            ])\n            ->add('submit', SubmitType::class);\n\n        $builder->addEventSubscriber(new DisableFieldsOnMagazineEdit());\n        $builder->addEventSubscriber(new RemoveRulesFieldIfEmpty());\n    }\n\n    public function configureOptions(OptionsResolver $resolver): void\n    {\n        $resolver->setDefaults(\n            [\n                'data_class' => MagazineDto::class,\n            ]\n        );\n    }\n}\n"
  },
  {
    "path": "src/Form/MessageType.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Form;\n\nuse App\\DTO\\MessageDto;\nuse Symfony\\Component\\Form\\AbstractType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\SubmitType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\TextareaType;\nuse Symfony\\Component\\Form\\FormBuilderInterface;\nuse Symfony\\Component\\OptionsResolver\\OptionsResolver;\n\nclass MessageType extends AbstractType\n{\n    public function buildForm(FormBuilderInterface $builder, array $options): void\n    {\n        $builder\n            ->add('body', TextareaType::class)\n            ->add('submit', SubmitType::class);\n    }\n\n    public function configureOptions(OptionsResolver $resolver): void\n    {\n        $resolver->setDefaults(\n            [\n                'data_class' => MessageDto::class,\n            ]\n        );\n    }\n}\n"
  },
  {
    "path": "src/Form/ModeratorType.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Form;\n\nuse App\\DTO\\ModeratorDto;\nuse App\\Form\\DataTransformer\\UserTransformer;\nuse App\\Repository\\UserRepository;\nuse Symfony\\Component\\Form\\AbstractType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\SubmitType;\nuse Symfony\\Component\\Form\\FormBuilderInterface;\nuse Symfony\\Component\\OptionsResolver\\OptionsResolver;\n\nclass ModeratorType extends AbstractType\n{\n    public function __construct(private readonly UserRepository $userRepository)\n    {\n    }\n\n    public function buildForm(FormBuilderInterface $builder, array $options): void\n    {\n        $builder\n            ->add('user', options: [\n                'attr' => ['autocomplete' => 'new-password'],\n            ])\n            ->add('submit', SubmitType::class);\n\n        $builder->get('user')->addModelTransformer(\n            new UserTransformer($this->userRepository)\n        );\n    }\n\n    public function configureOptions(OptionsResolver $resolver): void\n    {\n        $resolver->setDefaults(\n            [\n                'data_class' => ModeratorDto::class,\n            ]\n        );\n    }\n}\n"
  },
  {
    "path": "src/Form/ModlogFilterType.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Form;\n\nuse App\\DTO\\ModlogFilterDto;\nuse App\\Form\\Type\\MagazineAutocompleteType;\nuse Symfony\\Component\\Form\\AbstractType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\ChoiceType;\nuse Symfony\\Component\\Form\\FormBuilderInterface;\nuse Symfony\\Component\\OptionsResolver\\OptionsResolver;\nuse Symfony\\Contracts\\Translation\\TranslatorInterface;\n\nclass ModlogFilterType extends AbstractType\n{\n    public function __construct(\n        private readonly TranslatorInterface $translator,\n    ) {\n    }\n\n    public function buildForm(FormBuilderInterface $builder, array $options): void\n    {\n        $builder\n            ->add('types', ChoiceType::class, [\n                'choices' => [\n                    $this->translator->trans('modlog_type_entry_deleted') => 'entry_deleted',\n                    $this->translator->trans('modlog_type_entry_restored') => 'entry_restored',\n                    $this->translator->trans('modlog_type_entry_comment_deleted') => 'entry_comment_deleted',\n                    $this->translator->trans('modlog_type_entry_comment_restored') => 'entry_comment_restored',\n                    $this->translator->trans('modlog_type_entry_pinned') => 'entry_pinned',\n                    $this->translator->trans('modlog_type_entry_unpinned') => 'entry_unpinned',\n                    $this->translator->trans('modlog_type_post_deleted') => 'post_deleted',\n                    $this->translator->trans('modlog_type_post_restored') => 'post_restored',\n                    $this->translator->trans('modlog_type_post_comment_deleted') => 'post_comment_deleted',\n                    $this->translator->trans('modlog_type_post_comment_restored') => 'post_comment_restored',\n                    $this->translator->trans('modlog_type_ban') => 'ban',\n                    $this->translator->trans('modlog_type_moderator_add') => 'moderator_add',\n                    $this->translator->trans('modlog_type_moderator_remove') => 'moderator_remove',\n                    $this->translator->trans('modlog_type_entry_lock') => 'entry_locked',\n                    $this->translator->trans('modlog_type_entry_unlock') => 'entry_unlocked',\n                    $this->translator->trans('modlog_type_post_lock') => 'post_locked',\n                    $this->translator->trans('modlog_type_post_unlock') => 'post_unlocked',\n                ],\n                'multiple' => true,\n                'required' => false,\n                'autocomplete' => true,\n            ])\n            ->add('magazine', MagazineAutocompleteType::class, ['required' => false]);\n    }\n\n    public function configureOptions(OptionsResolver $resolver): void\n    {\n        $resolver->setDefaults(\n            [\n                'data_class' => ModlogFilterDto::class,\n            ]\n        );\n    }\n}\n"
  },
  {
    "path": "src/Form/MonitoringExecutionContextFilterType.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Form;\n\nuse App\\DTO\\MonitoringExecutionContextFilterDto;\nuse Symfony\\Component\\Form\\AbstractType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\ChoiceType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\DateTimeType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\NumberType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\SubmitType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\TextType;\nuse Symfony\\Component\\Form\\FormBuilderInterface;\nuse Symfony\\Component\\OptionsResolver\\OptionsResolver;\nuse Symfony\\Contracts\\Translation\\TranslatorInterface;\n\nclass MonitoringExecutionContextFilterType extends AbstractType\n{\n    public function __construct(\n        private readonly TranslatorInterface $translator,\n    ) {\n    }\n\n    public function buildForm(FormBuilderInterface $builder, array $options): void\n    {\n        $builder\n            ->add('executionType', ChoiceType::class, [\n                'label' => $this->translator->trans('monitoring_execution_type'),\n                'required' => false,\n                'choices' => [\n                    $this->translator->trans('monitoring_request') => 'request',\n                    $this->translator->trans('monitoring_messenger') => 'messenger',\n                ],\n            ])\n            ->add('userType', ChoiceType::class, [\n                'label' => $this->translator->trans('monitoring_user_type'),\n                'required' => false,\n                'choices' => [\n                    $this->translator->trans('monitoring_anonymous') => 'anonymous',\n                    $this->translator->trans('monitoring_user') => 'user',\n                    $this->translator->trans('monitoring_activity_pub') => 'activity_pub',\n                    $this->translator->trans('monitoring_ajax') => 'ajax',\n                ],\n            ])\n            ->add('path', TextType::class, [\n                'label' => $this->translator->trans('monitoring_path'),\n                'required' => false,\n            ])\n            ->add('handler', TextType::class, [\n                'label' => $this->translator->trans('monitoring_handler'),\n                'required' => false,\n            ])\n            ->add('createdFrom', DateTimeType::class, [\n                'label' => $this->translator->trans('monitoring_created_from'),\n                'required' => false,\n            ])\n            ->add('createdTo', DateTimeType::class, [\n                'label' => $this->translator->trans('monitoring_created_to'),\n                'required' => false,\n            ])\n            ->add('durationMinimum', NumberType::class, [\n                'label' => $this->translator->trans('monitoring_duration_minimum'),\n                'required' => false,\n            ])\n            ->add('hasException', ChoiceType::class, [\n                'label' => $this->translator->trans('monitoring_has_exception'),\n                'required' => false,\n                'choices' => [\n                    $this->translator->trans('yes') => true,\n                    $this->translator->trans('no') => false,\n                ],\n            ])\n            ->add('chartOrdering', ChoiceType::class, [\n                'label' => $this->translator->trans('monitoring_chart_ordering'),\n                'required' => false,\n                'choices' => [\n                    $this->translator->trans('monitoring_total_duration') => 'total',\n                    $this->translator->trans('monitoring_mean_duration') => 'mean',\n                ],\n            ])\n            ->add('submit', SubmitType::class, ['label' => $this->translator->trans('monitoring_submit')])\n        ;\n    }\n\n    public function configureOptions(OptionsResolver $resolver): void\n    {\n        $resolver->setDefaults(\n            [\n                'data_class' => MonitoringExecutionContextFilterDto::class,\n                'csrf_protection' => false,\n            ]\n        );\n    }\n}\n"
  },
  {
    "path": "src/Form/PageType.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Form;\n\nuse App\\DTO\\PageDto;\nuse Symfony\\Component\\Form\\AbstractType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\SubmitType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\TextareaType;\nuse Symfony\\Component\\Form\\FormBuilderInterface;\nuse Symfony\\Component\\OptionsResolver\\OptionsResolver;\n\nclass PageType extends AbstractType\n{\n    public function buildForm(FormBuilderInterface $builder, array $options): void\n    {\n        $builder\n            ->add('body', TextareaType::class, ['required' => false])\n            ->add('submit', SubmitType::class);\n    }\n\n    public function configureOptions(OptionsResolver $resolver): void\n    {\n        $resolver->setDefaults(\n            [\n                'data_class' => PageDto::class,\n            ]\n        );\n    }\n}\n"
  },
  {
    "path": "src/Form/PostCommentType.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Form;\n\nuse App\\DTO\\PostCommentDto;\nuse App\\Form\\Constraint\\ImageConstraint;\nuse App\\Form\\EventListener\\DefaultLanguage;\nuse App\\Form\\EventListener\\ImageListener;\nuse App\\Form\\Type\\LanguageType;\nuse App\\Service\\SettingsManager;\nuse Symfony\\Component\\Form\\AbstractType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\FileType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\SubmitType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\TextareaType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\UrlType;\nuse Symfony\\Component\\Form\\FormBuilderInterface;\nuse Symfony\\Component\\Form\\FormInterface;\nuse Symfony\\Component\\Form\\FormView;\nuse Symfony\\Component\\OptionsResolver\\OptionsResolver;\n\nclass PostCommentType extends AbstractType\n{\n    public function __construct(\n        private readonly ImageListener $imageListener,\n        private readonly DefaultLanguage $defaultLanguage,\n        private readonly SettingsManager $settingsManager,\n    ) {\n    }\n\n    public function buildForm(FormBuilderInterface $builder, array $options): void\n    {\n        $builder\n            ->add('body', TextareaType::class, ['required' => false, 'empty_data' => ''])\n            ->add('lang', LanguageType::class, ['priorityLanguage' => $options['parentLanguage']])\n            ->add(\n                'image',\n                FileType::class,\n                [\n                    'constraints' => ImageConstraint::default(),\n                    'mapped' => false,\n                    'required' => false,\n                ]\n            )\n            ->add('imageUrl', UrlType::class, ['required' => false, 'default_protocol' => 'https'])\n            ->add('imageAlt', TextareaType::class, ['required' => false])\n            ->add('submit', SubmitType::class);\n\n        $builder->addEventSubscriber($this->defaultLanguage);\n        $builder->addEventSubscriber($this->imageListener);\n    }\n\n    public function configureOptions(OptionsResolver $resolver): void\n    {\n        $resolver->setDefaults(\n            [\n                'data_class' => PostCommentDto::class,\n                'parentLanguage' => $this->settingsManager->get('KBIN_DEFAULT_LANG'),\n            ]\n        );\n\n        $resolver->addAllowedTypes('parentLanguage', 'string');\n    }\n\n    public function buildView(FormView $view, FormInterface $form, array $options): void\n    {\n        parent::buildView($view, $form, $options);\n\n        $view->vars['id'] .= '_'.uniqid('', true);\n    }\n}\n"
  },
  {
    "path": "src/Form/PostType.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Form;\n\nuse App\\DTO\\PostDto;\nuse App\\Form\\Constraint\\ImageConstraint;\nuse App\\Form\\EventListener\\DefaultLanguage;\nuse App\\Form\\EventListener\\ImageListener;\nuse App\\Form\\Type\\LanguageType;\nuse App\\Form\\Type\\MagazineAutocompleteType;\nuse App\\Service\\SettingsManager;\nuse Symfony\\Component\\Form\\AbstractType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\CheckboxType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\FileType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\SubmitType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\TextareaType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\UrlType;\nuse Symfony\\Component\\Form\\FormBuilderInterface;\nuse Symfony\\Component\\OptionsResolver\\OptionsResolver;\n\nclass PostType extends AbstractType\n{\n    public function __construct(\n        private readonly ImageListener $imageListener,\n        private readonly DefaultLanguage $defaultLanguage,\n        private readonly SettingsManager $settingsManager,\n    ) {\n    }\n\n    public function buildForm(FormBuilderInterface $builder, array $options): void\n    {\n        $builder\n            ->add('body', TextareaType::class, ['required' => false, 'empty_data' => ''])\n            ->add(\n                'image',\n                FileType::class,\n                [\n                    'constraints' => ImageConstraint::default(),\n                    'mapped' => false,\n                    'required' => false,\n                ]\n            )\n            ->add('magazine', MagazineAutocompleteType::class)\n            ->add('lang', LanguageType::class)\n            ->add('imageUrl', UrlType::class, ['required' => false, 'default_protocol' => 'https'])\n            ->add('imageAlt', TextareaType::class, ['required' => false])\n            ->add('isAdult', CheckboxType::class, ['required' => false])\n            ->add('submit', SubmitType::class);\n\n        $builder->addEventSubscriber($this->defaultLanguage);\n        $builder->addEventSubscriber($this->imageListener);\n    }\n\n    public function configureOptions(OptionsResolver $resolver): void\n    {\n        $resolver->setDefaults(\n            [\n                'data_class' => PostDto::class,\n            ]\n        );\n    }\n}\n"
  },
  {
    "path": "src/Form/ReportType.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Form;\n\nuse App\\DTO\\ReportDto;\nuse Symfony\\Component\\Form\\AbstractType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\SubmitType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\TextareaType;\nuse Symfony\\Component\\Form\\FormBuilderInterface;\nuse Symfony\\Component\\OptionsResolver\\OptionsResolver;\n\nclass ReportType extends AbstractType\n{\n    public function buildForm(FormBuilderInterface $builder, array $options): void\n    {\n        $builder\n            ->add('reason', TextareaType::class)\n            ->add('submit', SubmitType::class);\n    }\n\n    public function configureOptions(OptionsResolver $resolver): void\n    {\n        $resolver->setDefaults(\n            [\n                'data_class' => ReportDto::class,\n            ]\n        );\n    }\n}\n"
  },
  {
    "path": "src/Form/ResendEmailActivationFormType.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Form;\n\nuse Symfony\\Component\\Form\\AbstractType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\EmailType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\SubmitType;\nuse Symfony\\Component\\Form\\FormBuilderInterface;\nuse Symfony\\Component\\OptionsResolver\\OptionsResolver;\n\nclass ResendEmailActivationFormType extends AbstractType\n{\n    public function buildForm(FormBuilderInterface $builder, array $options): void\n    {\n        $builder\n            ->add('email', EmailType::class, [\n                'required' => true,\n            ])\n            ->add('submit', SubmitType::class, [\n                'label' => 'resend_account_activation_email',\n                'attr' => [\n                    'class' => 'btn btn__primary',\n                ],\n            ]);\n    }\n\n    public function configureOptions(OptionsResolver $resolver): void\n    {\n        $resolver->setDefaults([]);\n    }\n}\n"
  },
  {
    "path": "src/Form/ResetPasswordRequestFormType.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Form;\n\nuse Symfony\\Component\\Form\\AbstractType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\EmailType;\nuse Symfony\\Component\\Form\\FormBuilderInterface;\nuse Symfony\\Component\\OptionsResolver\\OptionsResolver;\nuse Symfony\\Component\\Validator\\Constraints\\NotBlank;\n\nclass ResetPasswordRequestFormType extends AbstractType\n{\n    public function buildForm(FormBuilderInterface $builder, array $options): void\n    {\n        $builder\n            ->add('email', EmailType::class, [\n                'attr' => ['autocomplete' => 'email'],\n                'constraints' => [\n                    new NotBlank([\n                        'message' => 'Please enter your email',\n                    ]),\n                ],\n            ]);\n    }\n\n    public function configureOptions(OptionsResolver $resolver): void\n    {\n        $resolver->setDefaults([]);\n    }\n}\n"
  },
  {
    "path": "src/Form/SearchType.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Form;\n\nuse App\\Form\\Type\\MagazineAutocompleteType;\nuse App\\Form\\Type\\UserAutocompleteType;\nuse Symfony\\Component\\Form\\AbstractType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\ChoiceType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\DateTimeType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\TextType;\nuse Symfony\\Component\\Form\\FormBuilderInterface;\n\nclass SearchType extends AbstractType\n{\n    public function buildForm(FormBuilderInterface $builder, array $options): void\n    {\n        $builder\n            ->setMethod('GET')\n            ->add('q', TextType::class, [\n                'required' => true,\n                'attr' => [\n                    'placeholder' => 'type_search_term_url_handle',\n                ],\n            ])\n            ->add('magazine', MagazineAutocompleteType::class, ['required' => false, 'placeholder' => 'type_search_magazine'])\n            ->add('user', UserAutocompleteType::class, ['required' => false, 'placeholder' => 'type_search_user'])\n            ->add('type', ChoiceType::class, [\n                'choices' => [\n                    'search_type_all' => null,\n                    'search_type_entry' => 'entry',\n                    'search_type_post' => 'post',\n                    'search_type_magazine' => 'magazine',\n                    'search_type_user' => 'user',\n                    'search_type_actors' => 'users+magazines',\n                    'search_type_content' => 'entry+post',\n                ],\n            ])\n            ->add('since', DateTimeType::class, ['required' => false])\n        ;\n    }\n}\n"
  },
  {
    "path": "src/Form/SettingsType.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Form;\n\nuse App\\DTO\\SettingsDto;\nuse App\\Repository\\Criteria;\nuse App\\Service\\SettingsManager;\nuse App\\Utils\\DownvotesMode;\nuse Psr\\Log\\LoggerInterface;\nuse Symfony\\Component\\Form\\AbstractType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\CheckboxType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\ChoiceType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\EmailType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\SubmitType;\nuse Symfony\\Component\\Form\\FormBuilderInterface;\nuse Symfony\\Component\\OptionsResolver\\OptionsResolver;\n\nclass SettingsType extends AbstractType\n{\n    public function __construct(\n        private readonly LoggerInterface $logger,\n        private readonly SettingsManager $settingsManager,\n    ) {\n    }\n\n    public function buildForm(FormBuilderInterface $builder, array $options): void\n    {\n        $dto = $this->settingsManager->getDto();\n        $this->logger->debug('downvotes mode is: {mode}', ['mode' => $dto->MBIN_DOWNVOTES_MODE]);\n        $builder\n            ->add('KBIN_DOMAIN')\n            ->add('KBIN_CONTACT_EMAIL', EmailType::class)\n            ->add('KBIN_TITLE')\n            ->add('KBIN_META_TITLE')\n            ->add('KBIN_META_DESCRIPTION')\n            ->add('KBIN_META_KEYWORDS')\n            ->add('MBIN_DEFAULT_THEME', ChoiceType::class, [\n                'choices' => Criteria::THEME_OPTIONS,\n            ])\n            ->add('KBIN_HEADER_LOGO', CheckboxType::class, ['required' => false])\n            ->add('KBIN_REGISTRATIONS_ENABLED', CheckboxType::class, ['required' => false])\n            ->add('MBIN_SSO_REGISTRATIONS_ENABLED', CheckboxType::class, ['required' => false])\n            ->add('KBIN_CAPTCHA_ENABLED', CheckboxType::class, ['required' => false])\n            ->add('KBIN_MERCURE_ENABLED', CheckboxType::class, ['required' => false])\n            ->add('KBIN_ADMIN_ONLY_OAUTH_CLIENTS', CheckboxType::class, ['required' => false])\n            ->add('MBIN_SSO_ONLY_MODE', CheckboxType::class, ['required' => false])\n            ->add('MBIN_PRIVATE_INSTANCE', CheckboxType::class, ['required' => false])\n            ->add('KBIN_FEDERATED_SEARCH_ONLY_LOGGEDIN', CheckboxType::class, ['required' => false])\n            ->add('MBIN_SIDEBAR_SECTIONS_RANDOM_LOCAL_ONLY', CheckboxType::class, ['required' => false])\n            ->add('MBIN_SIDEBAR_SECTIONS_USERS_LOCAL_ONLY', CheckboxType::class, ['required' => false])\n            ->add('MBIN_RESTRICT_MAGAZINE_CREATION', CheckboxType::class, ['required' => false])\n            ->add('MBIN_SSO_SHOW_FIRST', CheckboxType::class, ['required' => false])\n            ->add('MBIN_DOWNVOTES_MODE', ChoiceType::class, [\n                'choices' => DownvotesMode::GetChoices(),\n                'choice_attr' => [\n                    $dto->MBIN_DOWNVOTES_MODE => ['checked' => true],\n                ],\n            ])\n            ->add('MBIN_NEW_USERS_NEED_APPROVAL', CheckboxType::class, ['required' => false])\n            ->add('submit', SubmitType::class);\n    }\n\n    public function configureOptions(OptionsResolver $resolver): void\n    {\n        $resolver->setDefaults(\n            [\n                'data_class' => SettingsDto::class,\n            ]\n        );\n    }\n}\n"
  },
  {
    "path": "src/Form/Type/BadgesType.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Form\\Type;\n\nuse App\\Form\\DataTransformer\\BadgeCollectionToStringTransformer;\nuse Symfony\\Component\\Form\\AbstractType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\TextType;\nuse Symfony\\Component\\Form\\FormBuilderInterface;\n\nfinal class BadgesType extends AbstractType\n{\n    public function __construct(private readonly BadgeCollectionToStringTransformer $badgeArrayToStringTransformer)\n    {\n    }\n\n    public function buildForm(FormBuilderInterface $builder, array $options): void\n    {\n        $builder->addModelTransformer($this->badgeArrayToStringTransformer);\n    }\n\n    public function getParent(): string\n    {\n        return TextType::class;\n    }\n}\n"
  },
  {
    "path": "src/Form/Type/LanguageType.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Form\\Type;\n\nuse Symfony\\Bundle\\SecurityBundle\\Security;\nuse Symfony\\Component\\Form\\AbstractType;\nuse Symfony\\Component\\Form\\ChoiceList\\ChoiceList;\nuse Symfony\\Component\\Form\\ChoiceList\\Loader\\CallbackChoiceLoader;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\ChoiceType;\nuse Symfony\\Component\\HttpFoundation\\RequestStack;\nuse Symfony\\Component\\Intl\\Exception\\MissingResourceException;\nuse Symfony\\Component\\Intl\\Languages;\nuse Symfony\\Component\\OptionsResolver\\Options;\nuse Symfony\\Component\\OptionsResolver\\OptionsResolver;\nuse Symfony\\UX\\Autocomplete\\Form\\AsEntityAutocompleteField;\n\n#[AsEntityAutocompleteField]\nclass LanguageType extends AbstractType\n{\n    private string $priorityLanguage;\n    private array $preferredLanguages;\n\n    public function __construct(\n        private readonly Security $security,\n        private readonly RequestStack $requestStack,\n    ) {\n    }\n\n    public function configureOptions(OptionsResolver $resolver): void\n    {\n        $resolver->setDefaults(\n            [\n                'choice_loader' => function (Options $options) {\n                    $this->preferredLanguages = $this->security->getUser()?->preferredLanguages ?? [];\n                    $this->priorityLanguage = $options['priorityLanguage'];\n\n                    if (0 === \\count($this->preferredLanguages)) {\n                        $this->preferredLanguages = [$this->requestStack->getCurrentRequest()?->getLocale()];\n                    }\n\n                    return ChoiceList::loader($this, new CallbackChoiceLoader(function () {\n                        foreach (Languages::getLanguageCodes() as $languageCode) {\n                            try {\n                                $choices[$languageCode] = Languages::getName($languageCode, $languageCode);\n                            } catch (MissingResourceException) {\n                            }\n                        }\n\n                        natcasesort($choices);\n\n                        return array_flip($choices);\n                    }), [$this->preferredLanguages, $this->priorityLanguage]);\n                },\n                'preferred_choices' => ChoiceList::preferred($this, function (string $choice): bool {\n                    if (\\in_array($choice, $this->preferredLanguages) || $this->priorityLanguage === $choice) {\n                        return true;\n                    }\n\n                    return false;\n                }),\n                'required' => true,\n                'autocomplete' => false,\n                'priorityLanguage' => '',\n            ]\n        );\n\n        $resolver->addAllowedTypes('priorityLanguage', 'string');\n    }\n\n    public function getParent(): string\n    {\n        return ChoiceType::class;\n    }\n}\n"
  },
  {
    "path": "src/Form/Type/MagazineAutocompleteType.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Form\\Type;\n\nuse App\\Entity\\Contracts\\VisibilityInterface;\nuse App\\Entity\\Magazine;\nuse App\\Entity\\MagazineBlock;\nuse Doctrine\\ORM\\QueryBuilder;\nuse Symfony\\Bundle\\SecurityBundle\\Security;\nuse Symfony\\Component\\Form\\AbstractType;\nuse Symfony\\Component\\OptionsResolver\\OptionsResolver;\nuse Symfony\\UX\\Autocomplete\\Form\\AsEntityAutocompleteField;\nuse Symfony\\UX\\Autocomplete\\Form\\BaseEntityAutocompleteType;\n\n#[AsEntityAutocompleteField]\nclass MagazineAutocompleteType extends AbstractType\n{\n    public function __construct(private readonly Security $security)\n    {\n    }\n\n    public function configureOptions(OptionsResolver $resolver): void\n    {\n        $resolver->setDefaults([\n            'class' => Magazine::class,\n            'choice_label' => 'name',\n            'placeholder' => 'select_magazine',\n            'filter_query' => function (QueryBuilder $qb, string $query) {\n                if ($currentUser = $this->security->getUser()) {\n                    $qb\n                        ->andWhere(\n                            \\sprintf(\n                                'entity.id NOT IN (SELECT IDENTITY(mb.magazine) FROM %s mb WHERE mb.user = :user)',\n                                MagazineBlock::class,\n                            )\n                        )\n                        ->setParameter('user', $currentUser);\n                }\n\n                if (!$query) {\n                    return;\n                }\n\n                $qb->andWhere('entity.name LIKE :filter OR entity.title LIKE :filter')\n                    ->andWhere('entity.visibility = :visibility')\n                    ->setParameter('filter', '%'.$query.'%')\n                    ->setParameter('visibility', VisibilityInterface::VISIBILITY_VISIBLE)\n                ;\n            },\n        ]);\n    }\n\n    public function getParent(): string\n    {\n        return BaseEntityAutocompleteType::class;\n    }\n}\n"
  },
  {
    "path": "src/Form/Type/UserAutocompleteType.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Form\\Type;\n\nuse App\\Entity\\Contracts\\VisibilityInterface;\nuse App\\Entity\\User;\nuse App\\Entity\\UserBlock;\nuse Doctrine\\ORM\\QueryBuilder;\nuse Symfony\\Bundle\\SecurityBundle\\Security;\nuse Symfony\\Component\\Form\\AbstractType;\nuse Symfony\\Component\\OptionsResolver\\OptionsResolver;\nuse Symfony\\UX\\Autocomplete\\Form\\AsEntityAutocompleteField;\nuse Symfony\\UX\\Autocomplete\\Form\\BaseEntityAutocompleteType;\n\n#[AsEntityAutocompleteField]\nclass UserAutocompleteType extends AbstractType\n{\n    public function __construct(private readonly Security $security)\n    {\n    }\n\n    public function configureOptions(OptionsResolver $resolver): void\n    {\n        $resolver->setDefaults([\n            'class' => User::class,\n            'choice_label' => 'username',\n            'placeholder' => 'select_user',\n            'filter_query' => function (QueryBuilder $qb, string $query) {\n                if ($currentUser = $this->security->getUser()) {\n                    $qb\n                        ->andWhere(\n                            \\sprintf(\n                                'entity.id NOT IN (SELECT IDENTITY(ub.blocked) FROM %s ub WHERE ub.blocker = :user)',\n                                UserBlock::class,\n                            )\n                        )\n                        ->setParameter('user', $currentUser);\n                }\n\n                if (!$query) {\n                    return;\n                }\n\n                $qb->andWhere('entity.username LIKE :filter')\n                    ->andWhere('entity.visibility = :visibility')\n                    ->setParameter('filter', '%'.$query.'%')\n                    ->setParameter('visibility', VisibilityInterface::VISIBILITY_VISIBLE)\n                ;\n            },\n        ]);\n    }\n\n    public function getParent(): string\n    {\n        return BaseEntityAutocompleteType::class;\n    }\n}\n"
  },
  {
    "path": "src/Form/UserAccountDeletionType.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Form;\n\nuse Symfony\\Component\\Form\\AbstractType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\CheckboxType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\PasswordType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\SubmitType;\nuse Symfony\\Component\\Form\\FormBuilderInterface;\n\nclass UserAccountDeletionType extends AbstractType\n{\n    public function __construct()\n    {\n    }\n\n    public function buildForm(FormBuilderInterface $builder, array $options): void\n    {\n        $builder\n            ->add('currentPassword', PasswordType::class, [\n                'mapped' => false,\n                'row_attr' => [\n                    'class' => 'password-preview',\n                    'data-controller' => 'password-preview',\n                ],\n            ])\n            ->add('instantDelete', CheckboxType::class, ['required' => false])\n            ->add('submit', SubmitType::class);\n    }\n}\n"
  },
  {
    "path": "src/Form/UserBasicType.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Form;\n\nuse App\\DTO\\UserDto;\nuse App\\Form\\EventListener\\AddFieldsOnUserEdit;\nuse App\\Form\\EventListener\\AvatarListener;\nuse App\\Form\\EventListener\\DisableFieldsOnUserEdit;\nuse App\\Form\\EventListener\\ImageListener;\nuse Symfony\\Component\\Form\\AbstractType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\SubmitType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\TextareaType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\TextType;\nuse Symfony\\Component\\Form\\FormBuilderInterface;\nuse Symfony\\Component\\OptionsResolver\\OptionsResolver;\n\nclass UserBasicType extends AbstractType\n{\n    public function __construct(\n        private readonly ImageListener $imageListener,\n        private readonly AvatarListener $avatarListener,\n        private readonly AddFieldsOnUserEdit $addAvatarFieldOnUserEdit,\n        private readonly DisableFieldsOnUserEdit $disableUsernameFieldOnUserEdit,\n    ) {\n    }\n\n    public function buildForm(FormBuilderInterface $builder, array $options): void\n    {\n        $builder\n            ->add('username', TextType::class, ['required' => false])\n            ->add('title', TextType::class, ['required' => false])\n            ->add('about', TextareaType::class, ['required' => false])\n            ->add('submit', SubmitType::class);\n\n        $builder->addEventSubscriber($this->disableUsernameFieldOnUserEdit);\n        $builder->addEventSubscriber($this->addAvatarFieldOnUserEdit);\n        $builder->addEventSubscriber($this->avatarListener->setFieldName('avatar'));\n        $builder->addEventSubscriber($this->imageListener->setFieldName('cover'));\n    }\n\n    public function configureOptions(OptionsResolver $resolver): void\n    {\n        $resolver->setDefaults(\n            [\n                'data_class' => UserDto::class,\n            ]\n        );\n    }\n}\n"
  },
  {
    "path": "src/Form/UserDisable2FAType.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Form;\n\nuse App\\DTO\\UserDto;\nuse Symfony\\Component\\Form\\AbstractType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\PasswordType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\SubmitType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\TextType;\nuse Symfony\\Component\\Form\\FormBuilderInterface;\nuse Symfony\\Component\\OptionsResolver\\OptionsResolver;\n\nclass UserDisable2FAType extends AbstractType\n{\n    public function buildForm(FormBuilderInterface $builder, array $options): void\n    {\n        $builder\n            ->add('currentPassword', PasswordType::class, [\n                'label' => 'current_password',\n                'mapped' => false,\n                'row_attr' => [\n                    'class' => 'password-preview',\n                    'data-controller' => 'password-preview',\n                ],\n            ])\n            ->add('totpCode',\n                TextType::class,\n                [\n                    'label' => '2fa.authentication_code.label',\n                    'mapped' => false,\n                    'attr' => [\n                        'autocomplete' => 'one-time-code',\n                        'inputmode' => 'numeric',\n                        'pattern' => '[0-9]*',\n                    ],\n                ],\n            )\n            ->add('submit', SubmitType::class);\n    }\n\n    public function configureOptions(OptionsResolver $resolver): void\n    {\n        $resolver->setDefaults(\n            [\n                'data_class' => UserDto::class,\n            ]\n        );\n    }\n}\n"
  },
  {
    "path": "src/Form/UserEmailType.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Form;\n\nuse App\\DTO\\UserDto;\nuse Symfony\\Bundle\\SecurityBundle\\Security;\nuse Symfony\\Component\\Form\\AbstractType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\EmailType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\PasswordType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\RepeatedType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\SubmitType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\TextType;\nuse Symfony\\Component\\Form\\FormBuilderInterface;\nuse Symfony\\Component\\OptionsResolver\\OptionsResolver;\n\nclass UserEmailType extends AbstractType\n{\n    public function __construct(private readonly Security $security)\n    {\n    }\n\n    public function buildForm(FormBuilderInterface $builder, array $options): void\n    {\n        $builder\n            ->add(\n                'email',\n                TextType::class,\n                ['mapped' => false]\n            )\n            ->add('newEmail', RepeatedType::class, [\n                'type' => EmailType::class,\n                'mapped' => false,\n                'required' => true,\n                'first_options' => ['label' => 'new_email'],\n                'second_options' => ['label' => 'new_email_repeat'],\n            ])\n            ->add('currentPassword', PasswordType::class, [\n                'mapped' => false,\n                'row_attr' => [\n                    'class' => 'password-preview',\n                    'data-controller' => 'password-preview',\n                ],\n            ])\n            ->add('submit', SubmitType::class);\n    }\n\n    public function configureOptions(OptionsResolver $resolver): void\n    {\n        $resolver->setDefaults(\n            [\n                'data_class' => UserDto::class,\n            ]\n        );\n    }\n}\n"
  },
  {
    "path": "src/Form/UserFilterListType.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Form;\n\nuse App\\DTO\\UserFilterListDto;\nuse Symfony\\Component\\Form\\AbstractType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\CheckboxType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\CollectionType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\DateType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\SubmitType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\TextType;\nuse Symfony\\Component\\Form\\FormBuilderInterface;\nuse Symfony\\Component\\OptionsResolver\\OptionsResolver;\n\nclass UserFilterListType extends AbstractType\n{\n    public function buildForm(FormBuilderInterface $builder, array $options)\n    {\n        $builder\n            ->add('name', TextType::class)\n            ->add('expirationDate', DateType::class, [\n                'required' => false,\n                'row_attr' => [\n                    'class' => 'checkbox',\n                ],\n            ])\n            ->add('feeds', CheckboxType::class, [\n                'required' => false,\n                'row_attr' => [\n                    'class' => 'checkbox',\n                ],\n                'help' => 'filter_lists_feeds_help',\n            ])\n            ->add('comments', CheckboxType::class, [\n                'required' => false,\n                'row_attr' => [\n                    'class' => 'checkbox',\n                ],\n                'help' => 'filter_lists_comments_help',\n            ])\n            ->add('profile', CheckboxType::class, [\n                'required' => false,\n                'row_attr' => [\n                    'class' => 'checkbox',\n                ],\n                'help' => 'filter_lists_profile_help',\n            ])\n            ->add('words', CollectionType::class, [\n                'entry_type' => UserFilterWordType::class,\n                'allow_add' => true,\n                'allow_delete' => true,\n                'attr' => [\n                    'class' => 'existing-words',\n                ],\n                'label' => 'filter_lists_filter_words',\n            ])\n            ->add('submit', SubmitType::class);\n    }\n\n    public function configureOptions(OptionsResolver $resolver): void\n    {\n        $resolver->setDefaults(\n            [\n                'data_class' => UserFilterListDto::class,\n            ]\n        );\n    }\n}\n"
  },
  {
    "path": "src/Form/UserFilterWordType.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Form;\n\nuse App\\DTO\\UserFilterWordDto;\nuse Symfony\\Component\\Form\\AbstractType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\CheckboxType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\TextType;\nuse Symfony\\Component\\Form\\FormBuilderInterface;\nuse Symfony\\Component\\OptionsResolver\\OptionsResolver;\n\nclass UserFilterWordType extends AbstractType\n{\n    public function buildForm(FormBuilderInterface $builder, array $options)\n    {\n        $builder\n            ->add('word', TextType::class, [\n                'label' => false,\n                'required' => false,\n            ])\n            ->add('exactMatch', CheckboxType::class, [\n                'required' => false,\n                'label' => 'filter_lists_word_exact_match',\n                'row_attr' => [\n                    'class' => 'checkbox',\n                ],\n            ]);\n    }\n\n    public function configureOptions(OptionsResolver $resolver): void\n    {\n        $resolver->setDefaults(\n            [\n                'data_class' => UserFilterWordDto::class,\n            ]\n        );\n    }\n}\n"
  },
  {
    "path": "src/Form/UserNoteType.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Form;\n\nuse App\\DTO\\UserNoteDto;\nuse Symfony\\Component\\Form\\AbstractType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\SubmitType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\TextareaType;\nuse Symfony\\Component\\Form\\FormBuilderInterface;\nuse Symfony\\Component\\OptionsResolver\\OptionsResolver;\n\nclass UserNoteType extends AbstractType\n{\n    public function buildForm(FormBuilderInterface $builder, array $options): void\n    {\n        $builder\n            ->add('body', TextareaType::class)\n            ->add('submit', SubmitType::class);\n    }\n\n    public function configureOptions(OptionsResolver $resolver): void\n    {\n        $resolver->setDefaults(\n            [\n                'data_class' => UserNoteDto::class,\n            ]\n        );\n    }\n}\n"
  },
  {
    "path": "src/Form/UserPasswordType.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Form;\n\nuse App\\DTO\\UserDto;\nuse Symfony\\Component\\Form\\AbstractType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\PasswordType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\RepeatedType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\SubmitType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\TextType;\nuse Symfony\\Component\\Form\\FormBuilderInterface;\nuse Symfony\\Component\\OptionsResolver\\OptionsResolver;\n\nclass UserPasswordType extends AbstractType\n{\n    public function buildForm(FormBuilderInterface $builder, array $options): void\n    {\n        $builder\n            ->add('currentPassword', PasswordType::class, [\n                'label' => 'current_password',\n                'mapped' => false,\n                'row_attr' => [\n                    'class' => 'password-preview',\n                    'data-controller' => 'password-preview',\n                ],\n            ])\n            ->add('totpCode',\n                TextType::class,\n                [\n                    'label' => '2fa.authentication_code.label',\n                    'mapped' => false,\n                    'attr' => [\n                        'autocomplete' => 'one-time-code',\n                        'inputmode' => 'numeric',\n                        'pattern' => '[0-9]*',\n                    ],\n                ],\n            )\n            ->add(\n                'plainPassword',\n                RepeatedType::class,\n                [\n                    'type' => PasswordType::class,\n                    'required' => true,\n                    'first_options' => [\n                        'label' => 'new_password',\n                        'row_attr' => [\n                            'class' => 'password-preview',\n                            'data-controller' => 'password-preview',\n                        ],\n                    ],\n                    'second_options' => [\n                        'label' => 'new_password_repeat',\n                        'row_attr' => [\n                            'class' => 'password-preview',\n                            'data-controller' => 'password-preview',\n                        ],\n                    ],\n                ]\n            )\n            ->add('submit', SubmitType::class);\n    }\n\n    public function configureOptions(OptionsResolver $resolver): void\n    {\n        $resolver->setDefaults(\n            [\n                'data_class' => UserDto::class,\n            ]\n        );\n    }\n}\n"
  },
  {
    "path": "src/Form/UserRegenerate2FABackupType.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Form;\n\nuse App\\DTO\\UserDto;\nuse Symfony\\Component\\Form\\AbstractType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\PasswordType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\SubmitType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\TextType;\nuse Symfony\\Component\\Form\\FormBuilderInterface;\nuse Symfony\\Component\\OptionsResolver\\OptionsResolver;\n\nclass UserRegenerate2FABackupType extends AbstractType\n{\n    public function buildForm(FormBuilderInterface $builder, array $options): void\n    {\n        $builder\n            ->add('currentPassword', PasswordType::class, [\n                'label' => 'current_password',\n                'mapped' => false,\n                'row_attr' => [\n                    'class' => 'password-preview',\n                    'data-controller' => 'password-preview',\n                ],\n            ])\n            ->add('totpCode',\n                TextType::class,\n                [\n                    'label' => '2fa.authentication_code.label',\n                    'mapped' => false,\n                    'attr' => [\n                        'autocomplete' => 'one-time-code',\n                        'inputmode' => 'numeric',\n                        'pattern' => '[0-9]*',\n                    ],\n                ],\n            )\n            ->add('submit', SubmitType::class);\n    }\n\n    public function configureOptions(OptionsResolver $resolver): void\n    {\n        $resolver->setDefaults(\n            [\n                'data_class' => UserDto::class,\n            ]\n        );\n    }\n}\n"
  },
  {
    "path": "src/Form/UserRegisterType.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Form;\n\nuse App\\DTO\\UserDto;\nuse App\\Form\\EventListener\\AddFieldsOnUserEdit;\nuse App\\Form\\EventListener\\CaptchaListener;\nuse App\\Form\\EventListener\\DisableFieldsOnUserEdit;\nuse App\\Form\\EventListener\\ImageListener;\nuse App\\Service\\SettingsManager;\nuse Symfony\\Component\\Form\\AbstractType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\CheckboxType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\EmailType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\PasswordType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\RepeatedType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\SubmitType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\TextareaType;\nuse Symfony\\Component\\Form\\FormBuilderInterface;\nuse Symfony\\Component\\OptionsResolver\\OptionsResolver;\n\nclass UserRegisterType extends AbstractType\n{\n    public function __construct(\n        private readonly ImageListener $imageListener,\n        private readonly AddFieldsOnUserEdit $addAvatarFieldOnUserEdit,\n        private readonly DisableFieldsOnUserEdit $disableUsernameFieldOnUserEdit,\n        private readonly CaptchaListener $captchaListener,\n        private readonly SettingsManager $settingsManager,\n    ) {\n    }\n\n    public function buildForm(FormBuilderInterface $builder, array $options): void\n    {\n        $builder\n            ->add('username')\n            ->add('email', EmailType::class)\n            ->add(\n                'plainPassword',\n                RepeatedType::class,\n                [\n                    'type' => PasswordType::class,\n                    'required' => true,\n                    'first_options' => [\n                        'label' => 'password',\n                        'row_attr' => [\n                            'class' => 'password-preview',\n                            'data-controller' => 'password-preview',\n                        ],\n                    ],\n                    'second_options' => [\n                        'label' => 'repeat_password',\n                        'row_attr' => [\n                            'class' => 'password-preview',\n                            'data-controller' => 'password-preview',\n                        ],\n                    ],\n                ]\n            )\n            ->add(\n                'agreeTerms',\n                CheckboxType::class,\n                [\n                    'label_html' => true,\n                ]\n            )\n            ->add('submit', SubmitType::class);\n\n        if ($this->settingsManager->getNewUsersNeedApproval()) {\n            $builder\n                ->add('applicationText', TextareaType::class, ['required' => true]);\n        }\n\n        $builder->addEventSubscriber($this->disableUsernameFieldOnUserEdit);\n        $builder->addEventSubscriber($this->captchaListener);\n        $builder->addEventSubscriber($this->addAvatarFieldOnUserEdit);\n        $builder->addEventSubscriber($this->imageListener->setFieldName('avatar'));\n    }\n\n    public function configureOptions(OptionsResolver $resolver): void\n    {\n        $resolver->setDefaults(\n            [\n                'data_class' => UserDto::class,\n            ]\n        );\n    }\n}\n"
  },
  {
    "path": "src/Form/UserSettingsType.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Form;\n\nuse App\\DTO\\UserSettingsDto;\nuse App\\Entity\\User;\nuse App\\Enums\\EDirectMessageSettings;\nuse App\\Enums\\EFrontContentOptions;\nuse App\\Form\\DataTransformer\\FeaturedMagazinesBarTransformer;\nuse App\\PageView\\EntryCommentPageView;\nuse App\\PageView\\EntryPageView;\nuse Symfony\\Bundle\\SecurityBundle\\Security;\nuse Symfony\\Component\\Form\\AbstractType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\CheckboxType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\ChoiceType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\LanguageType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\SubmitType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\TextareaType;\nuse Symfony\\Component\\Form\\FormBuilderInterface;\nuse Symfony\\Component\\OptionsResolver\\OptionsResolver;\nuse Symfony\\Contracts\\Translation\\TranslatorInterface;\n\nclass UserSettingsType extends AbstractType\n{\n    public function __construct(\n        private readonly TranslatorInterface $translator,\n        private readonly Security $security,\n    ) {\n    }\n\n    public function buildForm(FormBuilderInterface $builder, array $options): void\n    {\n        $frontDefaultSortChoices = [];\n        foreach (EntryPageView::SORT_OPTIONS as $option) {\n            $frontDefaultSortChoices[$this->translator->trans($option)] = $option;\n        }\n        $commentDefaultSortChoices = [];\n        foreach (EntryCommentPageView::SORT_OPTIONS as $option) {\n            $commentDefaultSortChoices[$this->translator->trans($option)] = $option;\n        }\n        $directMessageSettingChoices = [];\n        foreach (EDirectMessageSettings::getValues() as $option) {\n            $directMessageSettingChoices[$this->translator->trans($option)] = $option;\n        }\n        $frontDefaultContentChoices = [\n            $this->translator->trans('default_content_default') => null,\n        ];\n        foreach (EFrontContentOptions::OPTIONS as $option) {\n            $frontDefaultContentChoices[$this->translator->trans('default_content_'.$option)] = $option;\n        }\n        $builder\n            ->add(\n                'hideAdult',\n                CheckboxType::class,\n                ['required' => false]\n            )\n            ->add('homepage', ChoiceType::class, [\n                'autocomplete' => true,\n                'choices' => [\n                    $this->translator->trans('all') => User::HOMEPAGE_ALL,\n                    $this->translator->trans('subscriptions') => User::HOMEPAGE_SUB,\n                    $this->translator->trans('favourites') => User::HOMEPAGE_FAV,\n                    $this->translator->trans('moderated') => User::HOMEPAGE_MOD,\n                ],\n            ]\n            )\n            ->add('frontDefaultSort', ChoiceType::class, [\n                'autocomplete' => true,\n                'choices' => $frontDefaultSortChoices,\n            ])\n            ->add('frontDefaultContent', ChoiceType::class, [\n                'autocomplete' => true,\n                'choices' => $frontDefaultContentChoices,\n            ])\n            ->add('commentDefaultSort', ChoiceType::class, [\n                'autocomplete' => true,\n                'choices' => $commentDefaultSortChoices,\n            ])\n            ->add('directMessageSetting', ChoiceType::class, [\n                'autocomplete' => true,\n                'choices' => $directMessageSettingChoices,\n            ])\n            ->add('showFollowingBoosts', CheckboxType::class, [\n                'required' => false,\n                'help' => 'show_boost_following_help',\n            ])\n            ->add('discoverable', CheckboxType::class, [\n                'required' => false,\n                'help' => 'user_discoverable_help',\n            ])\n            ->add('indexable', CheckboxType::class, [\n                'required' => false,\n                'help' => 'user_indexable_by_search_engines_help',\n            ])\n            ->add('featuredMagazines', TextareaType::class, ['required' => false])\n            ->add('preferredLanguages', LanguageType::class, [\n                'required' => false,\n                'preferred_choices' => [$this->translator->getLocale()],\n                'autocomplete' => true,\n                'multiple' => true,\n                'choice_self_translation' => true,\n            ])\n            ->add('customCss', TextareaType::class, [\n                'required' => false,\n            ])\n            ->add(\n                'ignoreMagazinesCustomCss',\n                CheckboxType::class,\n                ['required' => false]\n            )\n            ->add(\n                'showProfileSubscriptions',\n                CheckboxType::class,\n                ['required' => false]\n            )\n            ->add(\n                'showProfileFollowings',\n                CheckboxType::class,\n                ['required' => false]\n            )\n            ->add(\n                'notifyOnNewEntry',\n                CheckboxType::class,\n                ['required' => false]\n            )\n            ->add(\n                'notifyOnNewEntryReply',\n                CheckboxType::class,\n                ['required' => false]\n            )\n            ->add(\n                'notifyOnNewEntryCommentReply',\n                CheckboxType::class,\n                ['required' => false]\n            )\n            ->add(\n                'notifyOnNewPost',\n                CheckboxType::class,\n                ['required' => false]\n            )\n            ->add(\n                'notifyOnNewPostReply',\n                CheckboxType::class,\n                ['required' => false]\n            )\n            ->add(\n                'notifyOnNewPostCommentReply',\n                CheckboxType::class,\n                ['required' => false]\n            )\n            ->add(\n                'addMentionsEntries',\n                CheckboxType::class,\n                ['required' => false]\n            )\n            ->add(\n                'addMentionsPosts',\n                CheckboxType::class,\n                ['required' => false]\n            )\n            ->add('submit', SubmitType::class);\n\n        /** @var User $user */\n        $user = $this->security->getUser();\n        if ($user->isAdmin() or $user->isModerator()) {\n            $builder->add('notifyOnUserSignup', CheckboxType::class, ['required' => false]);\n        }\n\n        $builder->get('featuredMagazines')->addModelTransformer(\n            new FeaturedMagazinesBarTransformer()\n        );\n    }\n\n    public function configureOptions(OptionsResolver $resolver): void\n    {\n        $resolver->setDefaults(\n            [\n                'data_class' => UserSettingsDto::class,\n            ]\n        );\n    }\n}\n"
  },
  {
    "path": "src/Form/UserTwoFactorType.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Form;\n\nuse App\\DTO\\UserDto;\nuse Symfony\\Component\\Form\\AbstractType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\PasswordType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\SubmitType;\nuse Symfony\\Component\\Form\\Extension\\Core\\Type\\TextType;\nuse Symfony\\Component\\Form\\FormBuilderInterface;\nuse Symfony\\Component\\OptionsResolver\\OptionsResolver;\n\nclass UserTwoFactorType extends AbstractType\n{\n    public function buildForm(FormBuilderInterface $builder, array $options): void\n    {\n        $builder\n            ->add(\n                'totpCode',\n                TextType::class,\n                [\n                    'label' => '2fa.verify_authentication_code.label',\n                    'mapped' => false,\n                    'attr' => [\n                        'autocomplete' => 'one-time-code',\n                        'inputmode' => 'numeric',\n                        'pattern' => '[0-9]*',\n                    ],\n                ],\n            )\n            ->add('currentPassword', PasswordType::class, [\n                'label' => 'current_password',\n                'mapped' => false,\n                'row_attr' => [\n                    'class' => 'password-preview',\n                    'data-controller' => 'password-preview',\n                ],\n            ])\n            ->add('submit', SubmitType::class);\n    }\n\n    public function configureOptions(OptionsResolver $resolver): void\n    {\n        $resolver->setDefaults(\n            [\n                'data_class' => UserDto::class,\n            ]\n        );\n    }\n}\n"
  },
  {
    "path": "src/Kernel.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App;\n\nuse Doctrine\\DBAL\\Platforms\\PostgreSQLPlatform;\nuse Doctrine\\ORM\\Mapping\\ClassMetadata;\nuse Symfony\\Bundle\\FrameworkBundle\\Kernel\\MicroKernelTrait;\nuse Symfony\\Component\\DependencyInjection\\Compiler\\CompilerPassInterface;\nuse Symfony\\Component\\DependencyInjection\\ContainerBuilder;\nuse Symfony\\Component\\HttpKernel\\Kernel as BaseKernel;\nuse Symfony\\Component\\Routing\\Loader\\Configurator\\RoutingConfigurator;\n\nclass Kernel extends BaseKernel\n{\n    use MicroKernelTrait;\n\n    // Kernel can be empty according to: https://github.com/symfony/recipes/pull/1006\n    // But this will break your routing, so we keep configureRoutes()\n    protected function configureRoutes(RoutingConfigurator $routes): void\n    {\n        $projectDir = $this->getProjectDir();\n        $routes->import($projectDir.'/config/{routes}/'.$this->environment.'/*.yaml');\n        $routes->import($projectDir.'/config/{mbin_routes}/*.yaml');\n        $routes->import($projectDir.'/config/{routes}/*.yaml');\n\n        if (is_file($projectDir.'/config/routes.yaml')) {\n            $routes->import($projectDir.'/config/routes.yaml');\n        } else {\n            $routes->import($projectDir.'/config/{routes}.php');\n        }\n    }\n\n    #[Override]\n    protected function build(ContainerBuilder $container): void\n    {\n        $container->addCompilerPass(new class implements CompilerPassInterface {\n            public function process(ContainerBuilder $container): void\n            {\n                $container->getDefinition('doctrine.orm.default_configuration')\n                    ->addMethodCall(\n                        'setIdentityGenerationPreferences',\n                        [\n                            [\n                                PostgreSQLPlatform::class => ClassMetadata::GENERATOR_TYPE_SEQUENCE,\n                            ],\n                        ]\n                    );\n            }\n        });\n    }\n}\n"
  },
  {
    "path": "src/Markdown/CommonMark/CommunityLinkParser.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Markdown\\CommonMark;\n\nuse App\\Markdown\\CommonMark\\Node\\ActorSearchLink;\nuse App\\Markdown\\CommonMark\\Node\\CommunityLink;\nuse App\\Markdown\\CommonMark\\Node\\UnresolvableLink;\nuse App\\Repository\\MagazineRepository;\nuse App\\Service\\SettingsManager;\nuse App\\Utils\\RegPatterns;\nuse League\\CommonMark\\Parser\\Inline\\InlineParserInterface;\nuse League\\CommonMark\\Parser\\Inline\\InlineParserMatch;\nuse League\\CommonMark\\Parser\\InlineParserContext;\nuse Symfony\\Component\\Routing\\Generator\\UrlGeneratorInterface;\n\nclass CommunityLinkParser implements InlineParserInterface\n{\n    public function __construct(\n        private readonly MagazineRepository $magazineRepository,\n        private readonly SettingsManager $settingsManager,\n        private readonly UrlGeneratorInterface $urlGenerator,\n    ) {\n    }\n\n    public function getMatchDefinition(): InlineParserMatch\n    {\n        return InlineParserMatch::regex(RegPatterns::COMMUNITY_REGEX);\n    }\n\n    public function parse(InlineParserContext $ctx): bool\n    {\n        $cursor = $ctx->getCursor();\n        $cursor->advanceBy($ctx->getFullMatchLength());\n\n        $matches = $ctx->getSubMatches();\n        $handle = $matches['0'];\n        $domain = $matches['1'] ?? $this->settingsManager->get('KBIN_DOMAIN');\n\n        $fullHandle = $handle.'@'.$domain;\n        $isRemote = $this->isRemoteCommunity($domain);\n        $magazine = $this->magazineRepository->findOneByName($isRemote ? $fullHandle : $handle);\n\n        $this->removeSurroundingLink($ctx, $handle, $domain);\n\n        if ($magazine) {\n            $ctx->getContainer()->appendChild(\n                new CommunityLink(\n                    $this->urlGenerator->generate('front_magazine', ['name' => $magazine->name]),\n                    '!'.$handle,\n                    '!'.($isRemote ? $magazine->apId : $magazine->name),\n                    $isRemote ? $magazine->apId : $magazine->name,\n                    $isRemote ? MentionType::RemoteMagazine : MentionType::Magazine,\n                ),\n            );\n\n            return true;\n        }\n\n        if ($isRemote) {\n            $ctx->getContainer()->appendChild(\n                new ActorSearchLink(\n                    $this->urlGenerator->generate('search', ['search[q]' => $fullHandle], UrlGeneratorInterface::ABSOLUTE_URL),\n                    '!'.$fullHandle,\n                    '!'.$fullHandle,\n                )\n            );\n\n            return true;\n        }\n\n        // unable to resolve a local '!' link so don't even try.\n        $ctx->getContainer()->appendChild(new UnresolvableLink('!'.$handle));\n\n        return true;\n    }\n\n    private function isRemoteCommunity(?string $domain): bool\n    {\n        return $domain !== $this->settingsManager->get('KBIN_DOMAIN');\n    }\n\n    /**\n     * Removes a surrounding link from the parsing container if the link contains $handle and $domain.\n     *\n     * @param string $handle the user handle in [!@]handle@domain\n     * @param string $domain the domain in [!@]handle@domain\n     */\n    public static function removeSurroundingLink(InlineParserContext $ctx, string $handle, string $domain): void\n    {\n        $cursor = $ctx->getCursor();\n        $prev = $cursor->peek(-1 - $ctx->getFullMatchLength());\n        $next = $cursor->peek(0);\n        $nextNext = $cursor->peek(1);\n        if ('[' === $prev && ']' === $next && '(' === $nextNext) {\n            $closing = null;\n            $link = '';\n            for ($i = 2; null !== ($char = $cursor->peek($i)); ++$i) {\n                if (')' === $char) {\n                    $closing = $i;\n                    break;\n                }\n                $link .= $char;\n            }\n            if (null !== $closing && str_contains($link, $handle) && str_contains($link, $domain)) {\n                // this is probably a lemmy community link a lá [!magazine@domain.tld](https://domain.tld/c/magazine]\n                $container = $ctx->getContainer();\n                $prev = $container->lastChild();\n                if ('[' === $prev->getLiteral()) {\n                    $prev->detach();\n                }\n                $ctx->getDelimiterStack()->removeBracket();\n                $cursor->advanceBy($closing + 1);\n                $current = $cursor->peek(0);\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "src/Markdown/CommonMark/DetailsBlockParser.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Markdown\\CommonMark;\n\nuse App\\Markdown\\CommonMark\\Node\\DetailsBlock;\nuse League\\CommonMark\\Node\\Block\\AbstractBlock;\nuse League\\CommonMark\\Node\\Block\\Paragraph;\nuse League\\CommonMark\\Parser\\Block\\AbstractBlockContinueParser;\nuse League\\CommonMark\\Parser\\Block\\BlockContinue;\nuse League\\CommonMark\\Parser\\Block\\BlockContinueParserInterface;\nuse League\\CommonMark\\Parser\\Block\\BlockContinueParserWithInlinesInterface;\nuse League\\CommonMark\\Parser\\Cursor;\nuse League\\CommonMark\\Parser\\InlineParserEngineInterface;\nuse League\\CommonMark\\Util\\RegexHelper;\n\nfinal class DetailsBlockParser extends AbstractBlockContinueParser implements BlockContinueParserWithInlinesInterface\n{\n    public const FENCE_END_PATTERN = '/^(?:\\:{3,})(?= *$)/';\n\n    private DetailsBlock $block;\n\n    public function __construct(string $title, int $fenceLength, int $fenceOffset)\n    {\n        $this->block = new DetailsBlock($title, $fenceLength, $fenceOffset);\n    }\n\n    public function tryContinue(Cursor $cursor, BlockContinueParserInterface $activeBlockParser): ?BlockContinue\n    {\n        // Check for closing fence\n        if (!$cursor->isIndented()\n            && DetailsBlock::FENCE_CHAR === $cursor->getNextNonSpaceCharacter()) {\n            $match = RegexHelper::matchFirst(\n                self::FENCE_END_PATTERN,\n                $cursor->getLine(),\n                $cursor->getNextNonSpacePosition()\n            );\n\n            if (null !== $match && \\strlen($match[0]) >= $this->block->getLength()) {\n                // closing fence found - finalize block\n                return BlockContinue::finished();\n            }\n        }\n\n        // Skip optional spaces of fence offset\n        // Optimization: don't attempt to match if we're at a non-space position\n        if ($cursor->getNextNonSpacePosition() > $cursor->getPosition()) {\n            $cursor->match('/^ {0,'.$this->block->getOffset().'}/');\n        }\n\n        return BlockContinue::at($cursor);\n    }\n\n    public function closeBlock(): void\n    {\n        $title = $this->block->getTitle();\n\n        if ($title && preg_match('/^spoiler\\b/', $title)) {\n            $this->block->setSpoiler(true);\n\n            $title = preg_replace('/^spoiler\\s*/', '', $title);\n            $this->block->setTitle($title);\n        }\n    }\n\n    public function parseInlines(InlineParserEngineInterface $inlineParser): void\n    {\n        $titleBlock = new Paragraph();\n        $inlineParser->parse($this->block->getTitle(), $titleBlock);\n        if ($titleBlock->hasChildren()) {\n            $titleBlock->data->set('section', 'summary');\n            $this->block->prependChild($titleBlock);\n        }\n    }\n\n    public function getBlock(): DetailsBlock\n    {\n        return $this->block;\n    }\n\n    public function isContainer(): bool\n    {\n        return true;\n    }\n\n    public function canContain(AbstractBlock $childBlock): bool\n    {\n        if ($childBlock instanceof DetailsBlock) {\n            return $this->block->getLength() > $childBlock->getLength();\n        }\n\n        return true;\n    }\n}\n"
  },
  {
    "path": "src/Markdown/CommonMark/DetailsBlockRenderer.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Markdown\\CommonMark;\n\nuse App\\Markdown\\CommonMark\\Node\\DetailsBlock;\nuse League\\CommonMark\\Node\\Node;\nuse League\\CommonMark\\Renderer\\ChildNodeRendererInterface;\nuse League\\CommonMark\\Renderer\\NodeRendererInterface;\nuse League\\CommonMark\\Util\\HtmlElement;\n\nfinal class DetailsBlockRenderer implements NodeRendererInterface\n{\n    /** @param DetailsBlock $node */\n    public function render(\n        Node $node,\n        ChildNodeRendererInterface $childRenderer,\n    ): HtmlElement {\n        DetailsBlock::assertInstanceOf($node);\n\n        $attrs = $node->data->get('attributes');\n\n        $summary = $node->getSummary();\n        $contents = $node->getContents();\n\n        return new HtmlElement(\n            'details',\n            $attrs,\n            [\n                new HtmlElement(\n                    'summary',\n                    [],\n                    $summary ? $childRenderer->renderNodes($summary->children()) : '',\n                ),\n                new HtmlElement(\n                    'div',\n                    [\n                        'class' => 'content',\n                    ],\n                    $childRenderer->renderNodes($contents),\n                ),\n            ]\n        );\n    }\n}\n"
  },
  {
    "path": "src/Markdown/CommonMark/DetailsBlockStartParser.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Markdown\\CommonMark;\n\nuse App\\Markdown\\CommonMark\\Node\\DetailsBlock;\nuse League\\CommonMark\\Parser\\Block\\BlockStart;\nuse League\\CommonMark\\Parser\\Block\\BlockStartParserInterface;\nuse League\\CommonMark\\Parser\\Cursor;\nuse League\\CommonMark\\Parser\\MarkdownParserStateInterface;\n\nfinal class DetailsBlockStartParser implements BlockStartParserInterface\n{\n    public const FENCE_START_PATTERN = '/^[ \\t]*(?:\\:{3,})(?!.*\\:\\:\\:)/';\n\n    public function tryStart(Cursor $cursor, MarkdownParserStateInterface $parserState): ?BlockStart\n    {\n        if ($cursor->isIndented() || DetailsBlock::FENCE_CHAR !== $cursor->getNextNonSpaceCharacter()) {\n            return BlockStart::none();\n        }\n\n        $indent = $cursor->getIndent();\n        $fence = $cursor->match(self::FENCE_START_PATTERN);\n        if (null === $fence) {\n            return BlockStart::none();\n        }\n\n        // todo: maybe move title parsing into DetailsBlockParser\n        $title = ltrim($cursor->getRemainder());\n        $cursor->advanceToEnd();\n\n        $fence = ltrim($fence, \" \\t\");\n\n        return BlockStart::of(new DetailsBlockParser($title, \\strlen($fence), $indent))->at($cursor);\n    }\n}\n"
  },
  {
    "path": "src/Markdown/CommonMark/EmbedElement.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Markdown\\CommonMark;\n\nuse App\\Service\\DomainManager;\nuse League\\CommonMark\\Util\\HtmlElement;\n\nclass EmbedElement\n{\n    public static function buildEmbed(string $url, ?string $label = null): HtmlElement\n    {\n        return new HtmlElement(\n            'span',\n            [\n                'class' => 'preview',\n                'data-controller' => 'preview',\n            ],\n            [\n                new HtmlElement(\n                    'button',\n                    [\n                        'class' => 'show-preview',\n                        'data-action' => 'preview#show',\n                        'data-preview-url-param' => $url,\n                        'data-preview-ratio-param' => DomainManager::shouldRatio($url) ? '1' : '0',\n                        'aria-label' => 'Show preview',\n                    ],\n                    new HtmlElement(\n                        'i',\n                        [\n                            'class' => 'fas fa-photo-video',\n                        ],\n                    ),\n                ),\n                new HtmlElement(\n                    'a',\n                    [\n                        'href' => $url,\n                        'rel' => 'nofollow noopener noreferrer',\n                        'target' => '_blank',\n                    ],\n                    $label\n                ),\n                new HtmlElement(\n                    'span',\n                    [\n                        'class' => 'preview-target hidden',\n                        'data-preview-target' => 'container',\n                    ]\n                ),\n            ]\n        );\n    }\n\n    public static function buildDestructed(string $url, ?string $label = null): HtmlElement\n    {\n        return new HtmlElement(\n            'span',\n            [],\n            [\n                new HtmlElement('i', ['class' => 'fas fa-photo-video']),\n                $label ? \\sprintf(' %s (%s)', $label, $url) : ' '.$url,\n            ]\n        );\n    }\n}\n"
  },
  {
    "path": "src/Markdown/CommonMark/ExternalImagesRenderer.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Markdown\\CommonMark;\n\nuse App\\Markdown\\CommonMark\\Node\\UnresolvableLink;\nuse App\\Markdown\\MarkdownConverter;\nuse App\\Markdown\\RenderTarget;\nuse League\\CommonMark\\Extension\\CommonMark\\Node\\Inline\\Image;\nuse League\\CommonMark\\Extension\\CommonMark\\Node\\Inline\\Link;\nuse League\\CommonMark\\Node\\Inline\\Newline;\nuse League\\CommonMark\\Node\\Inline\\Text;\nuse League\\CommonMark\\Node\\Node;\nuse League\\CommonMark\\Node\\NodeIterator;\nuse League\\CommonMark\\Node\\StringContainerInterface;\nuse League\\CommonMark\\Renderer\\ChildNodeRendererInterface;\nuse League\\CommonMark\\Renderer\\NodeRendererInterface;\nuse League\\CommonMark\\Util\\HtmlElement;\nuse League\\Config\\ConfigurationAwareInterface;\nuse League\\Config\\ConfigurationInterface;\n\nfinal class ExternalImagesRenderer implements NodeRendererInterface, ConfigurationAwareInterface\n{\n    private ConfigurationInterface $config;\n\n    public function setConfiguration(ConfigurationInterface $configuration): void\n    {\n        $this->config = $configuration;\n    }\n\n    /**\n     * @param Image $node\n     */\n    public function render(\n        Node $node,\n        ChildNodeRendererInterface $childRenderer,\n    ): HtmlElement {\n        Image::assertInstanceOf($node);\n\n        $renderTarget = $this->config->get('kbin')[MarkdownConverter::RENDER_TARGET];\n\n        $url = $node->getUrl();\n        $label = null;\n\n        if (RenderTarget::Page === $renderTarget) {\n            // skip rendering links inside the label (not allowed)\n            if ($node->hasChildren()) {\n                $cnodes = [];\n                foreach ($node->children() as $n) {\n                    if (\n                        ($n instanceof Link && $n instanceof StringContainerInterface)\n                        || $n instanceof UnresolvableLink\n                    ) {\n                        $cnodes[] = new Text($n->getLiteral());\n                    } else {\n                        $cnodes[] = $n;\n                    }\n                }\n                $label = $childRenderer->renderNodes($cnodes);\n            }\n\n            // self destructs rendering if parent is a link\n            // because while commonmark permits putting image inside link label,\n            // html does not allow nested interactive contents inside <a>\n            // see: https://spec.commonmark.org/0.30/#example-516\n            // and: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#technical_summary\n            if ($node->parent() && $node->parent() instanceof Link) {\n                return EmbedElement::buildDestructed($node->getUrl(), $this->getAltText($node));\n            }\n\n            return EmbedElement::buildEmbed($url, $label ?? $url);\n        }\n\n        return new HtmlElement(\n            'img',\n            [\n                'src' => $url,\n                'alt' => $node->hasChildren() ? $this->getAltText($node) : false,\n            ],\n            '',\n            true\n        );\n    }\n\n    // literally lifted from league/commonmark ImageRenderer\n    // see: https://github.com/thephpleague/commonmark/blob/7af3307679b2942d825562bfad202a52a03b4513/src/Extension/CommonMark/Renderer/Inline/ImageRenderer.php#L93\n    private function getAltText(Image $node): string\n    {\n        $altText = '';\n\n        foreach ((new NodeIterator($node)) as $n) {\n            if ($n instanceof StringContainerInterface) {\n                $altText .= $n->getLiteral();\n            } elseif ($n instanceof Newline) {\n                $altText .= ' ';\n            }\n        }\n\n        return $altText;\n    }\n}\n"
  },
  {
    "path": "src/Markdown/CommonMark/ExternalLinkRenderer.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Markdown\\CommonMark;\n\nuse App\\Controller\\User\\ThemeSettingsController;\nuse App\\Entity\\Entry;\nuse App\\Entity\\EntryComment;\nuse App\\Entity\\Magazine;\nuse App\\Entity\\Message;\nuse App\\Entity\\Post;\nuse App\\Entity\\PostComment;\nuse App\\Entity\\User;\nuse App\\Markdown\\CommonMark\\Node\\ActivityPubMentionLink;\nuse App\\Markdown\\CommonMark\\Node\\ActorSearchLink;\nuse App\\Markdown\\CommonMark\\Node\\CommunityLink;\nuse App\\Markdown\\CommonMark\\Node\\MentionLink;\nuse App\\Markdown\\CommonMark\\Node\\RoutedMentionLink;\nuse App\\Markdown\\CommonMark\\Node\\TagLink;\nuse App\\Markdown\\CommonMark\\Node\\UnresolvableLink;\nuse App\\Markdown\\MarkdownConverter;\nuse App\\Markdown\\RenderTarget;\nuse App\\Repository\\ApActivityRepository;\nuse App\\Repository\\EmbedRepository;\nuse App\\Repository\\MagazineRepository;\nuse App\\Repository\\UserRepository;\nuse App\\Service\\ImageManager;\nuse App\\Service\\SettingsManager;\nuse App\\Utils\\Embed;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse League\\CommonMark\\Extension\\CommonMark\\Node\\Inline\\Link;\nuse League\\CommonMark\\Node\\Inline\\Text;\nuse League\\CommonMark\\Node\\Node;\nuse League\\CommonMark\\Node\\StringContainerInterface;\nuse League\\CommonMark\\Renderer\\ChildNodeRendererInterface;\nuse League\\CommonMark\\Renderer\\NodeRendererInterface;\nuse League\\CommonMark\\Util\\HtmlElement;\nuse League\\CommonMark\\Util\\RegexHelper;\nuse League\\Config\\ConfigurationAwareInterface;\nuse League\\Config\\ConfigurationInterface;\nuse Psr\\Log\\LoggerInterface;\nuse Symfony\\Component\\HttpFoundation\\RequestStack;\nuse Symfony\\Component\\Routing\\Generator\\UrlGeneratorInterface;\nuse Twig\\Environment;\n\nfinal class ExternalLinkRenderer implements NodeRendererInterface, ConfigurationAwareInterface\n{\n    private ConfigurationInterface $config;\n\n    public function __construct(\n        private readonly Embed $embed,\n        private readonly EmbedRepository $embedRepository,\n        private readonly SettingsManager $settingsManager,\n        private readonly UrlGeneratorInterface $urlGenerator,\n        private readonly Environment $twig,\n        private readonly UserRepository $userRepository,\n        private readonly MagazineRepository $magazineRepository,\n        private readonly LoggerInterface $logger,\n        private readonly RequestStack $requestStack,\n        private readonly ApActivityRepository $activityRepository,\n        private readonly EntityManagerInterface $entityManager,\n    ) {\n    }\n\n    public function setConfiguration(ConfigurationInterface $configuration): void\n    {\n        $this->config = $configuration;\n        $this->logger->debug('[ExternalLinkRenderer] config initialized with: {v}', ['v' => $configuration->get('kbin')]);\n    }\n\n    private function getRenderTarget(): RenderTarget\n    {\n        $val = $this->getFromConfig(MarkdownConverter::RENDER_TARGET);\n        if ($val instanceof RenderTarget) {\n            return $val;\n        }\n\n        return RenderTarget::ActivityPub;\n    }\n\n    private function showRichMentions(): bool\n    {\n        return $this->getBoolFromConfig('richMention', true);\n    }\n\n    private function showRichMagazineMentions(): bool\n    {\n        return $this->getBoolFromConfig('richMagazineMention', true);\n    }\n\n    private function showRichAPLinks(): bool\n    {\n        return $this->getBoolFromConfig('richAPLink', true);\n    }\n\n    private function getBoolFromConfig(string $key, bool $default): bool\n    {\n        $value = $this->getFromConfig($key);\n\n        return null !== $value ? \\boolval($value) : $default;\n    }\n\n    private function getFromConfig(string $key): mixed\n    {\n        try {\n            $kbinConfig = $this->config->get('kbin');\n            if (\\array_key_exists($key, $kbinConfig)) {\n                return $kbinConfig[$key];\n            }\n        } catch (\\Throwable $e) {\n        }\n\n        return null;\n    }\n\n    public function render(Node $node, ChildNodeRendererInterface $childRenderer): HtmlElement|string\n    {\n        /* @var Link $node */\n        Link::assertInstanceOf($node);\n\n        $renderTarget = $this->getRenderTarget();\n        $isApRequest = RenderTarget::ActivityPub === $renderTarget;\n        if (!$isApRequest && $node instanceof MentionLink && $this->isExistingMentionType($node)) {\n            $this->logger->debug(\"Got node of class {c}: username: '{k}', title: '{t}', type: '{ty}', url: '{url}'\", [\n                'c' => \\get_class($node),\n                'k' => $node->getKbinUsername(),\n                't' => $node->getTitle(),\n                'ty' => $node->getType(),\n                'url' => $node->getUrl(),\n            ]);\n\n            return new HtmlElement('span', contents: $this->renderMentionType($node, $childRenderer));\n        } else {\n            $this->logger->debug(\"Got node of class {c}: title: '{t}', url: '{url}'\", [\n                'c' => \\get_class($node),\n                't' => $node->getTitle(),\n                'url' => $node->getUrl(),\n            ]);\n        }\n\n        // skip rendering links inside the label (not allowed)\n        $childContent = null;\n        if ($node->hasChildren()) {\n            $cnodes = [];\n            foreach ($node->children() as $n) {\n                if (\n                    ($n instanceof Link && $n instanceof StringContainerInterface)\n                    || $n instanceof UnresolvableLink\n                ) {\n                    $cnodes[] = new Text($n->getLiteral());\n                } else {\n                    $cnodes[] = $n;\n                }\n                $this->logger->debug('child node type {t}', ['t' => \\get_class($n)]);\n            }\n            $childContent = $childRenderer->renderNodes($cnodes);\n        }\n\n        $url = $node->getUrl();\n        $apLink = null;\n        if (filter_var($url, FILTER_VALIDATE_URL)) {\n            $apActivity = null;\n            try {\n                $apActivity = $this->activityRepository->findByObjectId($url);\n            } catch (\\Error|\\Exception $e) {\n                $this->logger->warning(\"There was an error finding the activity pub object for url '{q}': {e}\", ['q' => $url, 'e' => \\get_class($e).' - '.$e->getMessage()]);\n            }\n            if (!$isApRequest && null !== $apActivity && 0 !== $apActivity['id'] && Message::class !== $apActivity['type']) {\n                $this->logger->debug('Found activity with url {u}: {t} - {id}', [\n                    'u' => $node->getUrl(),\n                    't' => $apActivity['type'],\n                    'id' => $apActivity['id'],\n                ]);\n                /** @var Entry|EntryComment|Post|PostComment $entity */\n                $entity = $this->entityManager->getRepository($apActivity['type'])->find($apActivity['id']);\n\n                if (null !== $entity) {\n                    if (null === $node->getTitle() && (null === $childContent || $url === $childContent)) {\n                        return $this->renderInlineEntity($entity);\n                    } else {\n                        $apLink = $this->activityRepository->getLocalUrlOfEntity($entity);\n                    }\n                } else {\n                    $this->logger->warning('[ExternalLinkRenderer::render] Could not find an entity for type {t} with id {id} from url {url}', ['t' => $apActivity['type'], 'id' => $apActivity['id'], 'url' => $url]);\n\n                    return new HtmlElement('div');\n                }\n            }\n        } else {\n            $this->logger->debug('Got an invalid url {u}', ['u' => $url]);\n        }\n\n        $url = match ($node::class) {\n            RoutedMentionLink::class => $this->generateUrlForRoute($node, $renderTarget),\n            default => $apLink ?? $node->getUrl(),\n        };\n        $title = $childContent ?? $url;\n\n        if (RegexHelper::isLinkPotentiallyUnsafe($url)) {\n            return new HtmlElement(\n                'span',\n                ['class' => 'unsafe-link'],\n                $title\n            );\n        }\n\n        if (\n            !$this->isMentionType($node)\n            && (ImageManager::isImageUrl($url) || $this->isEmbed($url, $title))\n            && RenderTarget::Page === $renderTarget\n        ) {\n            return EmbedElement::buildEmbed($url, $title);\n        }\n\n        // create attributes for link\n        $attr = $this->generateAttr($node, $renderTarget);\n\n        // open non-local links in a new tab\n        if (false !== filter_var($url, FILTER_VALIDATE_URL)\n            && !$this->settingsManager->isLocalUrl($url)\n            && RenderTarget::ActivityPub !== $renderTarget\n        ) {\n            $attr['rel'] = 'noopener noreferrer nofollow';\n            $attr['target'] = '_blank';\n        }\n\n        return new HtmlElement(\n            'a',\n            ['href' => $url] + $attr,\n            $title\n        );\n    }\n\n    /**\n     * @return array{\n     *     class: string,\n     *     title?: string,\n     *     data-action?: string,\n     *     data-mentions-username-param?: string,\n     *     rel?: string,\n     * }\n     */\n    private function generateAttr(Link $node, RenderTarget $renderTarget): array\n    {\n        $attr = match ($node::class) {\n            ActivityPubMentionLink::class => $this->generateMentionLinkAttr($node),\n            ActorSearchLink::class => [],\n            CommunityLink::class => $this->generateCommunityLinkAttr($node),\n            RoutedMentionLink::class => $this->generateMentionLinkAttr($node),\n            TagLink::class => [\n                'class' => 'hashtag tag',\n                'rel' => 'tag',\n            ],\n            default => [\n                'class' => 'kbin-media-link',\n            ],\n        };\n\n        if (RenderTarget::ActivityPub === $renderTarget) {\n            $attr = array_intersect_key($attr, ['class', 'title', 'rel']);\n        }\n\n        return $attr;\n    }\n\n    /**\n     * @return array{\n     *     class: string,\n     *     title: string,\n     *     data-action: string,\n     *     data-mentions-username-param: string,\n     * }\n     */\n    private function generateMentionLinkAttr(MentionLink $link): array\n    {\n        $data = [\n            'class' => 'mention',\n            'title' => $link->getTitle(),\n            'data-mentions-username-param' => $link->getKbinUsername(),\n        ];\n\n        if (MentionType::Magazine === $link->getType() || MentionType::RemoteMagazine === $link->getType()) {\n            $data['class'] = $data['class'].' mention--magazine';\n            $data['data-action'] = 'mentions#navigateMagazine';\n        }\n\n        if (MentionType::User === $link->getType() || MentionType::RemoteUser === $link->getType()) {\n            $data['class'] = $data['class'].' u-url mention--user';\n            $data['data-action'] = 'mouseover->mentions#userPopup mouseout->mentions#userPopupOut mentions#navigateUser';\n        }\n\n        return $data;\n    }\n\n    /**\n     * @return array{\n     *     class: string,\n     *     title: string,\n     *     data-action: string,\n     *     data-mentions-username-param: string,\n     * }\n     */\n    private function generateCommunityLinkAttr(CommunityLink $link): array\n    {\n        $data = [\n            'class' => 'mention mention--magazine',\n            'title' => $link->getTitle(),\n            'data-mentions-username-param' => $link->getKbinUsername(),\n            'data-action' => 'mentions#navigateMagazine',\n        ];\n\n        return $data;\n    }\n\n    private function generateUrlForRoute(RoutedMentionLink $routedMentionLink, RenderTarget $renderTarget): string\n    {\n        return $this->urlGenerator->generate(\n            $routedMentionLink->getRoute(),\n            [$routedMentionLink->getParamName() => $routedMentionLink->getUrl()],\n            RenderTarget::ActivityPub === $renderTarget\n                ? UrlGeneratorInterface::ABSOLUTE_URL\n                : UrlGeneratorInterface::ABSOLUTE_PATH\n        );\n    }\n\n    private function isEmbed(string $url, string $title): bool\n    {\n        $embed = false;\n        if (filter_var($url, FILTER_VALIDATE_URL) && $entity = $this->embedRepository->findOneBy(['url' => $url])) {\n            $embed = $entity->hasEmbed;\n        }\n\n        return (bool) $embed;\n    }\n\n    private function isMentionType(Link $link): bool\n    {\n        $types = [\n            ActivityPubMentionLink::class,\n            ActorSearchLink::class,\n            CommunityLink::class,\n            RoutedMentionLink::class,\n            TagLink::class,\n        ];\n\n        foreach ($types as $type) {\n            if ($link instanceof $type) {\n                return true;\n            }\n        }\n\n        return false;\n    }\n\n    private function isExistingMentionType(Link $link): bool\n    {\n        if ($link instanceof CommunityLink || $link instanceof ActivityPubMentionLink || $link instanceof RoutedMentionLink) {\n            if (MentionType::Unresolvable !== $link->getType() && MentionType::Search !== $link->getType()) {\n                return true;\n            }\n        }\n\n        return false;\n    }\n\n    private function renderMentionType(MentionLink $node, ChildNodeRendererInterface $childRenderer): string\n    {\n        if (MentionType::User === $node->getType() || MentionType::RemoteUser === $node->getType()) {\n            return $this->renderUserNode($node, $childRenderer);\n        } elseif (MentionType::Magazine === $node->getType() || MentionType::RemoteMagazine === $node->getType()) {\n            return $this->renderMagazineNode($node, $childRenderer);\n        } else {\n            throw new \\LogicException('dont know type of '.\\get_class($node));\n        }\n    }\n\n    private function renderUserNode(MentionLink $node, ChildNodeRendererInterface $childRenderer): string\n    {\n        $username = $node->getKbinUsername();\n        $user = $this->userRepository->findOneBy(['username' => $username]);\n        if (!$user) {\n            $this->logger->error('cannot render {o}, couldn\\'t find user {u}', ['o' => $node, 'u' => $username]);\n\n            return '';\n        }\n\n        return $this->renderUser($user);\n    }\n\n    private function renderUser(?User $user): string\n    {\n        return $this->twig->render('components/user_inline_md.html.twig', [\n            'user' => $user,\n            'showAvatar' => true,\n            'showNewIcon' => true,\n            'fullName' => ThemeSettingsController::getShowUserFullName($this->requestStack->getCurrentRequest()),\n            'rich' => $this->showRichMentions(),\n        ]);\n    }\n\n    private function renderMagazineNode(MentionLink $node, ChildNodeRendererInterface $childRenderer): string\n    {\n        $magName = $node->getKbinUsername();\n        $magazine = $this->magazineRepository->findOneByName($magName);\n        if (!$magazine) {\n            $this->logger->error('cannot render {o}, couldn\\'t find magazine {m}', ['o' => $node, 'm' => $magName]);\n\n            return '';\n        }\n\n        return $this->renderMagazine($magazine);\n    }\n\n    private function renderMagazine(Magazine $magazine)\n    {\n        return $this->twig->render('components/magazine_inline_md.html.twig', [\n            'magazine' => $magazine,\n            'stretchedLink' => false,\n            'fullName' => ThemeSettingsController::getShowMagazineFullName($this->requestStack->getCurrentRequest()),\n            'showAvatar' => true,\n            'rich' => $this->showRichMagazineMentions(),\n        ]);\n    }\n\n    private function renderInlineEntity(Entry|EntryComment|Post|PostComment $entity): string\n    {\n        if ($entity instanceof Entry) {\n            return $this->twig->render('components/entry_inline_md.html.twig', [\n                'entry' => $entity,\n                'userFullName' => ThemeSettingsController::getShowUserFullName($this->requestStack->getCurrentRequest()),\n                'magazineFullName' => ThemeSettingsController::getShowMagazineFullName($this->requestStack->getCurrentRequest()),\n                'rich' => $this->showRichAPLinks(),\n            ]);\n        } elseif ($entity instanceof EntryComment) {\n            return $this->twig->render('components/entry_comment_inline_md.html.twig', [\n                'comment' => $entity,\n                'userFullName' => ThemeSettingsController::getShowUserFullName($this->requestStack->getCurrentRequest()),\n                'magazineFullName' => ThemeSettingsController::getShowMagazineFullName($this->requestStack->getCurrentRequest()),\n                'rich' => $this->showRichAPLinks(),\n            ]);\n        } elseif ($entity instanceof Post) {\n            return $this->twig->render('components/post_inline_md.html.twig', [\n                'post' => $entity,\n                'userFullName' => ThemeSettingsController::getShowUserFullName($this->requestStack->getCurrentRequest()),\n                'magazineFullName' => ThemeSettingsController::getShowMagazineFullName($this->requestStack->getCurrentRequest()),\n                'rich' => $this->showRichAPLinks(),\n            ]);\n        } elseif ($entity instanceof PostComment) {\n            return $this->twig->render('components/post_comment_inline_md.html.twig', [\n                'comment' => $entity,\n                'userFullName' => ThemeSettingsController::getShowUserFullName($this->requestStack->getCurrentRequest()),\n                'magazineFullName' => ThemeSettingsController::getShowMagazineFullName($this->requestStack->getCurrentRequest()),\n                'rich' => $this->showRichAPLinks(),\n            ]);\n        }\n        throw new \\LogicException('This code should be unreachable');\n    }\n}\n"
  },
  {
    "path": "src/Markdown/CommonMark/MentionLinkParser.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Markdown\\CommonMark;\n\nuse App\\Entity\\Magazine;\nuse App\\Entity\\User;\nuse App\\Markdown\\CommonMark\\Node\\ActivityPubMentionLink;\nuse App\\Markdown\\CommonMark\\Node\\RoutedMentionLink;\nuse App\\Markdown\\CommonMark\\Node\\UnresolvableLink;\nuse App\\Repository\\MagazineRepository;\nuse App\\Repository\\UserRepository;\nuse App\\Service\\SettingsManager;\nuse App\\Utils\\RegPatterns;\nuse League\\CommonMark\\Node\\Node;\nuse League\\CommonMark\\Parser\\Inline\\InlineParserInterface;\nuse League\\CommonMark\\Parser\\Inline\\InlineParserMatch;\nuse League\\CommonMark\\Parser\\InlineParserContext;\nuse Symfony\\Component\\Messenger\\MessageBusInterface;\n\nclass MentionLinkParser implements InlineParserInterface\n{\n    public function __construct(\n        private readonly MagazineRepository $magazineRepository,\n        private readonly MessageBusInterface $bus,\n        private readonly SettingsManager $settingsManager,\n        private readonly UserRepository $userRepository,\n    ) {\n    }\n\n    public function getMatchDefinition(): InlineParserMatch\n    {\n        // support for unicode international domains\n        return InlineParserMatch::regex(RegPatterns::MENTION_REGEX);\n    }\n\n    public function parse(InlineParserContext $ctx): bool\n    {\n        $cursor = $ctx->getCursor();\n        $cursor->advanceBy($ctx->getFullMatchLength());\n\n        $matches = $ctx->getSubMatches();\n        $username = $matches['0'];\n        $domain = $matches['1'] ?? $this->settingsManager->get('KBIN_DOMAIN');\n\n        $fullUsername = $username.'@'.$domain;\n\n        CommunityLinkParser::removeSurroundingLink($ctx, $username, $domain);\n\n        [$type, $data] = $this->resolveType($username, $domain);\n\n        if ($data instanceof User && $data->apPublicUrl) {\n            $ctx->getContainer()->appendChild(\n                new ActivityPubMentionLink(\n                    $data->apPublicUrl,\n                    '@'.$username,\n                    '@'.$data->apId,\n                    '@'.$data->apId,\n                    MentionType::RemoteUser,\n                )\n            );\n\n            return true;\n        }\n\n        [$routeDetails, $slug, $label, $title, $kbinUsername] = match ($type) {\n            MentionType::RemoteUser => [$this->resolveRouteDetails($type), '@'.$fullUsername, '@'.$username, '@'.$username, '@'.$fullUsername],\n            MentionType::RemoteMagazine => [$this->resolveRouteDetails($type), $fullUsername, '@'.$username, '@'.$fullUsername, $fullUsername],\n            MentionType::Magazine => [$this->resolveRouteDetails($type), $username, '@'.$username, '@'.$username, $username],\n            MentionType::Search => [$this->resolveRouteDetails($type), $fullUsername, '@'.$username, '@'.$fullUsername, $fullUsername],\n            MentionType::Unresolvable => [['route' => '', 'param' => ''], '', '@'.$username, '@'.$fullUsername, ''],\n            MentionType::User => [$this->resolveRouteDetails($type), $username, '@'.$username, '@'.$fullUsername, $username],\n        };\n\n        $ctx->getContainer()->appendChild(\n            $this->generateNode(\n                ...$routeDetails,\n                slug: $slug,\n                label: $label,\n                title: $title,\n                kbinUsername: $kbinUsername,\n                type: $type,\n            )\n        );\n\n        return true;\n    }\n\n    private function generateNode(string $route, string $param, string $slug, string $label, string $title, string $kbinUsername, MentionType $type): Node\n    {\n        if (MentionType::Unresolvable === $type) {\n            return new UnresolvableLink($label);\n        }\n\n        return new RoutedMentionLink($route, $param, $slug, $label, $title, $kbinUsername, $type);\n    }\n\n    private function isRemoteMention(?string $domain): bool\n    {\n        return $domain !== $this->settingsManager->get('KBIN_DOMAIN');\n    }\n\n    /**\n     * @return array{type: MentionType, data: User|Magazine|null}\n     */\n    private function resolveType(string $handle, ?string $domain): array\n    {\n        if ($this->isRemoteMention($domain)) {\n            return $this->resolveRemoteType($handle.'@'.$domain);\n        }\n\n        if (null !== $this->userRepository->findOneByUsername($handle)) {\n            return [MentionType::User, null];\n        }\n\n        if (null !== $this->magazineRepository->findOneByName($handle)) {\n            return [MentionType::Magazine, null];\n        }\n\n        return [MentionType::Unresolvable, null];\n    }\n\n    /**\n     * @return array{type: MentionType, data: User|Magazine|null}\n     */\n    private function resolveRemoteType($fullyQualifiedHandle): array\n    {\n        $user = $this->userRepository->findOneByUsername('@'.$fullyQualifiedHandle);\n        // we're aware of this account, link to it directly\n        if ($user && $user->apPublicUrl) {\n            return [MentionType::RemoteUser, $user];\n        }\n\n        $magazine = $this->magazineRepository->findOneByName($fullyQualifiedHandle);\n        // we're aware of this magazine, link to it directly\n        if ($magazine && $magazine->apPublicUrl) {\n            return [MentionType::RemoteMagazine, $magazine];\n        }\n\n        // take thee to search\n        return [MentionType::Search, null];\n    }\n\n    /**\n     * @return array{route: string, param: string}\n     */\n    private function resolveRouteDetails(MentionType $type): array\n    {\n        return match ($type) {\n            MentionType::Magazine => ['route' => 'front_magazine', 'param' => 'name'],\n            MentionType::RemoteMagazine => ['route' => 'front_magazine', 'param' => 'name'],\n            MentionType::RemoteUser => ['route' => 'user_overview',  'param' => 'username'],\n            MentionType::Search => ['route' => 'search',         'param' => 'search[q]'],\n            MentionType::User => ['route' => 'user_overview',  'param' => 'username'],\n        };\n    }\n}\n"
  },
  {
    "path": "src/Markdown/CommonMark/MentionType.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Markdown\\CommonMark;\n\nenum MentionType\n{\n    case Magazine;\n    case RemoteMagazine;\n    case RemoteUser;\n    case Search;\n    case Unresolvable;\n    case User;\n}\n"
  },
  {
    "path": "src/Markdown/CommonMark/Node/ActivityPubMentionLink.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Markdown\\CommonMark\\Node;\n\nuse App\\Markdown\\CommonMark\\MentionType;\nuse League\\CommonMark\\Extension\\CommonMark\\Node\\Inline\\Link;\n\nclass ActivityPubMentionLink extends Link implements MentionLink\n{\n    public function __construct(\n        string $activityPubUrl,\n        string $label,\n        string $title,\n        private string $kbinUsername,\n        private MentionType $type,\n    ) {\n        parent::__construct($activityPubUrl, $label, $title);\n    }\n\n    public function getKbinUsername(): string\n    {\n        return $this->kbinUsername;\n    }\n\n    public function getType(): MentionType\n    {\n        return $this->type;\n    }\n}\n"
  },
  {
    "path": "src/Markdown/CommonMark/Node/ActorSearchLink.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Markdown\\CommonMark\\Node;\n\nuse League\\CommonMark\\Extension\\CommonMark\\Node\\Inline\\Link;\n\nclass ActorSearchLink extends Link\n{\n}\n"
  },
  {
    "path": "src/Markdown/CommonMark/Node/CommunityLink.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Markdown\\CommonMark\\Node;\n\nuse App\\Markdown\\CommonMark\\MentionType;\n\nclass CommunityLink extends ActivityPubMentionLink\n{\n    public function __construct(\n        string $url,\n        string $label,\n        string $title,\n        private string $kbinUsername,\n        private MentionType $type,\n    ) {\n        parent::__construct($url, $label, $title, $kbinUsername, $type);\n    }\n}\n"
  },
  {
    "path": "src/Markdown/CommonMark/Node/DetailsBlock.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Markdown\\CommonMark\\Node;\n\nuse League\\CommonMark\\Node\\Block\\AbstractBlock;\nuse League\\CommonMark\\Node\\Node;\n\nclass DetailsBlock extends AbstractBlock\n{\n    public const FENCE_CHAR = ':';\n\n    private bool $spoiler = false;\n\n    public function __construct(\n        private string $title,\n        private int $length,\n        private int $offset,\n    ) {\n        parent::__construct();\n    }\n\n    public function getTitle(): string\n    {\n        return $this->title;\n    }\n\n    public function setTitle(string $title)\n    {\n        $this->title = $title;\n    }\n\n    private function isSummaryBlock(Node $node): bool\n    {\n        return 'summary' === $node->data->get('section', '');\n    }\n\n    public function getSummary(): ?Node\n    {\n        foreach ($this->children() as $cnode) {\n            if ($this->isSummaryBlock($cnode)) {\n                return $cnode;\n            }\n        }\n\n        return null;\n    }\n\n    /** @return iterable<Node> */\n    public function getContents(): iterable\n    {\n        $children = [];\n        foreach ($this->children() as $cnode) {\n            if (!$this->isSummaryBlock($cnode)) {\n                $children[] = $cnode;\n            }\n        }\n\n        return $children;\n    }\n\n    public function isSpoiler(): bool\n    {\n        return $this->spoiler;\n    }\n\n    public function setSpoiler(bool $spoiler): void\n    {\n        $this->spoiler = $spoiler;\n        if ($spoiler) {\n            $this->data->append('attributes/class', 'spoiler');\n        } else {\n            $classes = $this->data->get('attributes/class', '');\n            $this->data->set('attributes/class', array_diff(explode(' ', $classes), ['spoiler']));\n        }\n    }\n\n    public function getLength(): int\n    {\n        return $this->length;\n    }\n\n    public function setLength(int $length): void\n    {\n        $this->length = $length;\n    }\n\n    public function getOffset(): int\n    {\n        return $this->offset;\n    }\n\n    public function setOffset(int $offset): void\n    {\n        $this->offset = $offset;\n    }\n}\n"
  },
  {
    "path": "src/Markdown/CommonMark/Node/MentionLink.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Markdown\\CommonMark\\Node;\n\nuse App\\Markdown\\CommonMark\\MentionType;\n\ninterface MentionLink\n{\n    public function getKbinUsername(): string;\n\n    public function getTitle(): ?string;\n\n    public function getType(): MentionType;\n}\n"
  },
  {
    "path": "src/Markdown/CommonMark/Node/RoutedMentionLink.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Markdown\\CommonMark\\Node;\n\nuse App\\Markdown\\CommonMark\\MentionType;\nuse League\\CommonMark\\Extension\\CommonMark\\Node\\Inline\\Link;\n\nclass RoutedMentionLink extends Link implements MentionLink\n{\n    public function __construct(\n        private string $route,\n        private string $paramName,\n        private string $slug,\n        string $label,\n        string $title,\n        private string $kbinUsername,\n        private MentionType $type,\n    ) {\n        parent::__construct($slug, $label, $title);\n    }\n\n    public function getKbinUsername(): string\n    {\n        return $this->kbinUsername;\n    }\n\n    public function getRoute(): string\n    {\n        return $this->route;\n    }\n\n    public function getParamName(): string\n    {\n        return $this->paramName;\n    }\n\n    public function getType(): MentionType\n    {\n        return $this->type;\n    }\n}\n"
  },
  {
    "path": "src/Markdown/CommonMark/Node/TagLink.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Markdown\\CommonMark\\Node;\n\nuse League\\CommonMark\\Extension\\CommonMark\\Node\\Inline\\Link;\n\nclass TagLink extends Link\n{\n}\n"
  },
  {
    "path": "src/Markdown/CommonMark/Node/UnresolvableLink.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Markdown\\CommonMark\\Node;\n\nuse League\\CommonMark\\Node\\Inline\\AbstractStringContainer;\n\nclass UnresolvableLink extends AbstractStringContainer\n{\n}\n"
  },
  {
    "path": "src/Markdown/CommonMark/TagLinkParser.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Markdown\\CommonMark;\n\nuse App\\Markdown\\CommonMark\\Node\\TagLink;\nuse App\\Utils\\RegPatterns;\nuse League\\CommonMark\\Parser\\Inline\\InlineParserInterface;\nuse League\\CommonMark\\Parser\\Inline\\InlineParserMatch;\nuse League\\CommonMark\\Parser\\InlineParserContext;\nuse Symfony\\Component\\Routing\\Generator\\UrlGeneratorInterface;\n\nclass TagLinkParser implements InlineParserInterface\n{\n    public function __construct(private readonly UrlGeneratorInterface $urlGenerator)\n    {\n    }\n\n    public function getMatchDefinition(): InlineParserMatch\n    {\n        return InlineParserMatch::regex(RegPatterns::LOCAL_TAG_REGEX);\n    }\n\n    public function parse(InlineParserContext $ctx): bool\n    {\n        $cursor = $ctx->getCursor();\n        $cursor->advanceBy($ctx->getFullMatchLength());\n\n        [$tag] = $ctx->getSubMatches();\n\n        $url = $this->urlGenerator->generate(\n            'tag_overview',\n            ['name' => $tag],\n            UrlGeneratorInterface::ABSOLUTE_URL,\n        );\n\n        $ctx->getContainer()->appendChild(new TagLink($url, '#'.$tag));\n\n        return true;\n    }\n}\n"
  },
  {
    "path": "src/Markdown/CommonMark/UnresolvableLinkRenderer.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Markdown\\CommonMark;\n\nuse App\\Markdown\\CommonMark\\Node\\UnresolvableLink;\nuse League\\CommonMark\\Node\\Node;\nuse League\\CommonMark\\Renderer\\ChildNodeRendererInterface;\nuse League\\CommonMark\\Renderer\\NodeRendererInterface;\nuse League\\CommonMark\\Util\\HtmlElement;\n\nfinal class UnresolvableLinkRenderer implements NodeRendererInterface\n{\n    /**\n     * @param UnresolvableLink $node\n     */\n    public function render(\n        Node $node,\n        ChildNodeRendererInterface $childRenderer,\n    ): HtmlElement {\n        UnresolvableLink::assertInstanceOf($node);\n\n        return new HtmlElement(\n            'span',\n            [\n                'class' => 'mention mention--unresolvable',\n            ],\n            $node->getLiteral(),\n        );\n    }\n}\n"
  },
  {
    "path": "src/Markdown/Event/BuildCacheContext.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Markdown\\Event;\n\nuse App\\Controller\\User\\ThemeSettingsController;\nuse App\\Markdown\\MarkdownExtension;\nuse App\\Utils\\UrlUtils;\nuse Symfony\\Component\\HttpFoundation\\Request;\n\n/**\n * Event dispatched to build a hash key for Markdown context.\n */\nclass BuildCacheContext\n{\n    private array $context = [];\n\n    public function __construct(\n        private readonly ConvertMarkdown $convertMarkdownEvent,\n        private readonly ?Request $request,\n    ) {\n        $richMdConfig = MarkdownExtension::getMdRichConfig($this->request, $this->convertMarkdownEvent->getSourceType());\n        $this->addToContext('content', $convertMarkdownEvent->getMarkdown());\n        $this->addToContext('target', $convertMarkdownEvent->getRenderTarget()->name);\n        $this->addToContext('userFullName', ThemeSettingsController::getShowUserFullName($this->request) ? '1' : '0');\n        $this->addToContext('magazineFullName', ThemeSettingsController::getShowMagazineFullName($this->request) ? '1' : '0');\n        $this->addToContext('richMention', $richMdConfig['richMention'] ? '1' : '0');\n        $this->addToContext('richMagazineMention', $richMdConfig['richMagazineMention'] ? '1' : '0');\n        $this->addToContext('richAPLink', $richMdConfig['richAPLink'] ? '1' : '0');\n        $this->addToContext('apRequest', UrlUtils::isActivityPubRequest($this->request) ? '1' : '0');\n    }\n\n    public function addToContext(string $key, ?string $value = null): void\n    {\n        $this->context[$key] = $value;\n    }\n\n    public function getConvertMarkdownEvent(): ConvertMarkdown\n    {\n        return $this->convertMarkdownEvent;\n    }\n\n    public function getCacheKey(): string\n    {\n        ksort($this->context);\n\n        $jsonContext = json_encode($this->context);\n        $hash = hash('sha256', $jsonContext);\n\n        return \"md_$hash\";\n    }\n\n    public function hasContext(string $key, ?string $value = null): bool\n    {\n        if (!\\array_key_exists($key, $this->context)) {\n            return false;\n        }\n\n        if (\\func_num_args() < 2) {\n            return true;\n        }\n\n        return $this->context[$key] === $value;\n    }\n}\n"
  },
  {
    "path": "src/Markdown/Event/ConvertMarkdown.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Markdown\\Event;\n\nuse App\\Markdown\\MarkdownConverter;\nuse App\\Markdown\\RenderTarget;\nuse League\\CommonMark\\Output\\RenderedContentInterface;\nuse Symfony\\Contracts\\EventDispatcher\\Event;\n\nclass ConvertMarkdown extends Event\n{\n    private RenderedContentInterface $renderedContent;\n    private array $attributes = [];\n\n    public function __construct(private string $markdown, private string $sourceType)\n    {\n    }\n\n    public function getMarkdown(): string\n    {\n        return $this->markdown;\n    }\n\n    public function getSourceType(): string\n    {\n        return $this->sourceType;\n    }\n\n    public function getRenderedContent(): RenderedContentInterface\n    {\n        return $this->renderedContent;\n    }\n\n    public function setRenderedContent(RenderedContentInterface $renderedContent): void\n    {\n        $this->renderedContent = $renderedContent;\n    }\n\n    public function getRenderTarget(): RenderTarget\n    {\n        return $this->getAttribute(MarkdownConverter::RENDER_TARGET) ?? RenderTarget::Page;\n    }\n\n    /**\n     * @return mixed|null\n     */\n    public function getAttribute(string $key)\n    {\n        return $this->attributes[$key] ?? null;\n    }\n\n    public function addAttribute(string $key, $data): void\n    {\n        $this->attributes[$key] = $data;\n    }\n\n    public function addTag(string $tag): void\n    {\n        $this->attributes['tags'] ??= [];\n        $this->attributes['tags'][] = $tag;\n    }\n\n    /**\n     * @return string[]\n     */\n    public function getTags(): array\n    {\n        return $this->attributes['tags'] ?? [];\n    }\n\n    public function mergeAttributes(array $attributes): void\n    {\n        $this->attributes = array_replace($this->attributes, $attributes);\n    }\n\n    public function removeAttribute(string $key): void\n    {\n        unset($this->attributes[$key]);\n    }\n}\n"
  },
  {
    "path": "src/Markdown/Factory/ConverterFactory.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Markdown\\Factory;\n\nuse League\\CommonMark\\ConverterInterface;\nuse League\\CommonMark\\Environment\\EnvironmentInterface;\nuse League\\CommonMark\\MarkdownConverter;\n\nclass ConverterFactory\n{\n    public function createConverter(EnvironmentInterface $environment): ConverterInterface\n    {\n        return new MarkdownConverter($environment);\n    }\n}\n"
  },
  {
    "path": "src/Markdown/Factory/EnvironmentFactory.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Markdown\\Factory;\n\nuse App\\Markdown\\MarkdownExtension as KbinMarkdownExtension;\nuse App\\Markdown\\RenderTarget;\nuse League\\CommonMark\\Environment\\Environment;\nuse League\\CommonMark\\Environment\\EnvironmentInterface;\nuse League\\CommonMark\\Extension\\Autolink\\UrlAutolinkParser;\nuse League\\CommonMark\\Extension\\CommonMark\\CommonMarkCoreExtension;\nuse League\\CommonMark\\Extension\\Strikethrough\\StrikethroughExtension;\nuse League\\CommonMark\\Extension\\Table\\TableExtension;\nuse Psr\\Container\\ContainerInterface;\n\nclass EnvironmentFactory\n{\n    public function __construct(\n        private readonly ContainerInterface $container,\n        private array $config,\n    ) {\n    }\n\n    public function createEnvironment(\n        RenderTarget $renderTarget,\n        bool $richMention,\n        bool $richMagazineMention,\n        bool $richAPLink,\n    ): EnvironmentInterface {\n        $this->config['kbin'] = [\n            'render_target' => $renderTarget,\n            'richMention' => $richMention,\n            'richMagazineMention' => $richMagazineMention,\n            'richAPLink' => $richAPLink,\n        ];\n\n        $env = new Environment($this->config);\n\n        $env->addInlineParser($this->container->get(UrlAutolinkParser::class))\n            ->addExtension($this->container->get(CommonMarkCoreExtension::class))\n            ->addExtension($this->container->get(StrikethroughExtension::class))\n            ->addExtension($this->container->get(TableExtension::class))\n            ->addExtension($this->container->get(KbinMarkdownExtension::class));\n\n        return $env;\n    }\n}\n"
  },
  {
    "path": "src/Markdown/Listener/CacheMarkdownListener.php",
    "content": "<?php\n\n// SPDX-FileCopyrightText: Copyright (c) 2016-2017 Emma <emma1312@protonmail.ch>\n//\n// SPDX-License-Identifier: Zlib\n\ndeclare(strict_types=1);\n\nnamespace App\\Markdown\\Listener;\n\nuse App\\Markdown\\Event\\BuildCacheContext;\nuse App\\Markdown\\Event\\ConvertMarkdown;\nuse App\\Repository\\ApActivityRepository;\nuse App\\Repository\\MagazineRepository;\nuse App\\Repository\\UserRepository;\nuse App\\Service\\MentionManager;\nuse App\\Utils\\RegPatterns;\nuse App\\Utils\\UrlUtils;\nuse League\\CommonMark\\Output\\RenderedContentInterface;\nuse Psr\\Cache\\CacheException;\nuse Psr\\Cache\\CacheItemInterface;\nuse Psr\\Cache\\CacheItemPoolInterface;\nuse Psr\\EventDispatcher\\EventDispatcherInterface;\nuse Psr\\Log\\LoggerInterface;\nuse Symfony\\Component\\EventDispatcher\\EventSubscriberInterface;\nuse Symfony\\Component\\HttpFoundation\\RequestStack;\n\n/**\n * Fetch and store rendered HTML given the raw input and a generated context.\n */\nfinal class CacheMarkdownListener implements EventSubscriberInterface\n{\n    private const ATTR_CACHE_ITEM = __CLASS__.' cache item';\n    public const ATTR_NO_CACHE_STORE = 'no_cache_store';\n\n    public function __construct(\n        private readonly CacheItemPoolInterface $pool,\n        private readonly EventDispatcherInterface $dispatcher,\n        private readonly RequestStack $requestStack,\n        private readonly LoggerInterface $logger,\n        private readonly ApActivityRepository $activityRepository,\n        private readonly MentionManager $mentionManager,\n        private readonly UserRepository $userRepository,\n        private readonly MagazineRepository $magazineRepository,\n    ) {\n    }\n\n    public static function getSubscribedEvents(): array\n    {\n        return [\n            ConvertMarkdown::class => [\n                ['preConvertMarkdown', 64],\n                ['postConvertMarkdown', -64],\n            ],\n        ];\n    }\n\n    public function preConvertMarkdown(ConvertMarkdown $event): void\n    {\n        $request = $this->requestStack->getCurrentRequest();\n        if (null === $request) {\n            $this->logger->debug('[ConvertMarkdown] request is null]');\n        }\n        $cacheEvent = new BuildCacheContext($event, $request);\n        $this->dispatcher->dispatch($cacheEvent);\n\n        $key = $cacheEvent->getCacheKey();\n        $item = $this->pool->getItem($key);\n\n        if ($item->isHit()) {\n            $content = $item->get();\n\n            if ($content instanceof RenderedContentInterface) {\n                $event->setRenderedContent($content);\n                $event->stopPropagation();\n\n                return;\n            }\n        }\n\n        if (!$event->getAttribute(self::ATTR_NO_CACHE_STORE)) {\n            $event->addAttribute(self::ATTR_CACHE_ITEM, $item);\n        }\n    }\n\n    public function postConvertMarkdown(ConvertMarkdown $event): void\n    {\n        if ($event->getAttribute(self::ATTR_NO_CACHE_STORE)) {\n            return;\n        }\n\n        $item = $event->getAttribute(self::ATTR_CACHE_ITEM);\n        \\assert($item instanceof CacheItemInterface);\n\n        $item->set($event->getRenderedContent());\n\n        try {\n            if (method_exists($item, 'tag')) {\n                $md = $event->getMarkdown();\n                $urls = array_map(fn ($item) => UrlUtils::getCacheKeyForMarkdownUrl($item), $this->getMissingUrlsFromMarkdown($md));\n                $mentions = array_map(fn ($item) => UrlUtils::getCacheKeyForMarkdownUserMention($item), $this->getMissingMentionsFromMarkdown($md));\n                $magazineMentions = array_map(fn ($item) => UrlUtils::getCacheKeyForMarkdownMagazineMention($item), $this->getMissingMagazineMentions($md));\n\n                $tags = array_unique(array_merge($urls, $mentions, $magazineMentions));\n\n                $this->logger->debug('added tags {t} to markdown \"{m}\"', ['t' => join(', ', $tags), 'm' => $md]);\n\n                $item->tag($tags);\n            }\n        } catch (CacheException) {\n        }\n\n        $this->pool->save($item);\n\n        $event->removeAttribute(self::ATTR_CACHE_ITEM);\n    }\n\n    /** @return string[] */\n    private function getMissingUrlsFromMarkdown(string $markdown): array\n    {\n        $urls = [];\n        foreach (UrlUtils::extractUrlsFromString($markdown) as $url) {\n            $entity = $this->activityRepository->findByObjectId($url);\n            if (null === $entity) {\n                $urls[] = $url;\n            }\n        }\n\n        return $urls;\n    }\n\n    /** @return string[] */\n    private function getMissingMentionsFromMarkdown(string $markdown): array\n    {\n        $remoteMentions = $this->mentionManager->extract($markdown, MentionManager::REMOTE) ?? [];\n        $missingMentions = [];\n\n        foreach ($remoteMentions as $mention) {\n            if (null === $this->userRepository->findOneBy(['apId' => $mention])) {\n                $missingMentions[] = $mention;\n            }\n        }\n\n        return $missingMentions;\n    }\n\n    /** @return string[] */\n    private function getMissingMagazineMentions(string $markdown): array\n    {\n        // No-break space is causing issues with word splitting. So replace a no-break (0xc2 0xa0) by a normal space first.\n        $words = preg_split('/[ \\n\\[\\]()]/', str_replace(\\chr(194).\\chr(160), '&nbsp;', $markdown));\n        $missingCommunityMentions = [];\n        foreach ($words as $word) {\n            $matches = null;\n            // Remove newline (\\n), tab (\\t), carriage return (\\r), etc.\n            $word2 = preg_replace('/[[:cntrl:]]/', '', $word);\n            if (preg_match('/'.RegPatterns::COMMUNITY_REGEX.'/', $word2, $matches)) {\n                // Check if the required matched array keys exist\n                if (!isset($matches[1]) || !isset($matches[2])) {\n                    $this->logger->warning('Invalid community mention format: {word}', ['word' => $word2]);\n                    // Just skip and continue\n                    continue;\n                }\n\n                $apId = \"$matches[1]@$matches[2]\";\n                $this->logger->debug(\"searching for magazine '{m}', original word: '{w}', word without cntrl: '{w2}'\", ['m' => $apId, 'w' => $word, 'w2' => $word2]);\n                try {\n                    $magazine = $this->magazineRepository->findOneBy(['apId' => $apId]);\n                    if (!$magazine) {\n                        $missingCommunityMentions[] = $apId;\n                    }\n                } catch (\\Exception $e) {\n                    $this->logger->error('An error occurred while looking for magazine \"{m}\": {t} - {msg}', ['m' => $apId, 't' => \\get_class($e), 'msg' => $e->getMessage()]);\n                }\n            }\n        }\n\n        return $missingCommunityMentions;\n    }\n}\n"
  },
  {
    "path": "src/Markdown/Listener/ConvertMarkdownListener.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Markdown\\Listener;\n\nuse App\\Markdown\\Event\\ConvertMarkdown;\nuse App\\Markdown\\Factory\\ConverterFactory;\nuse App\\Markdown\\Factory\\EnvironmentFactory;\nuse App\\Markdown\\MarkdownExtension;\nuse Psr\\EventDispatcher\\EventDispatcherInterface;\nuse Symfony\\Component\\EventDispatcher\\EventSubscriberInterface;\nuse Symfony\\Component\\HttpFoundation\\RequestStack;\n\nfinal class ConvertMarkdownListener implements EventSubscriberInterface\n{\n    public function __construct(\n        private readonly ConverterFactory $converterFactory,\n        private readonly EnvironmentFactory $environmentFactory,\n        private readonly EventDispatcherInterface $dispatcher,\n        private readonly RequestStack $requestStack,\n    ) {\n    }\n\n    public static function getSubscribedEvents(): array\n    {\n        return [\n            ConvertMarkdown::class => ['onConvertMarkdown'],\n        ];\n    }\n\n    public function onConvertMarkdown(ConvertMarkdown $event): void\n    {\n        $request = $this->requestStack->getCurrentRequest();\n        $richMdConfig = MarkdownExtension::getMdRichConfig($request, $event->getSourceType());\n        $environment = $this->environmentFactory->createEnvironment(\n            $event->getRenderTarget(),\n            $richMdConfig['richMention'],\n            $richMdConfig['richMagazineMention'],\n            $richMdConfig['richAPLink'],\n        );\n\n        $converter = $this->converterFactory->createConverter($environment);\n        $html = $converter->convert($event->getMarkdown());\n\n        $event->setRenderedContent($html);\n    }\n}\n"
  },
  {
    "path": "src/Markdown/MarkdownConverter.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Markdown;\n\nuse App\\Markdown\\Event\\ConvertMarkdown;\nuse Psr\\EventDispatcher\\EventDispatcherInterface;\n\nclass MarkdownConverter\n{\n    public const RENDER_TARGET = 'render_target';\n\n    public function __construct(private readonly EventDispatcherInterface $dispatcher)\n    {\n    }\n\n    public function convertToHtml(string $markdown, string $sourceType = '', array $context = []): string\n    {\n        $event = new ConvertMarkdown($markdown, $sourceType);\n        $event->mergeAttributes($context);\n\n        $this->dispatcher->dispatch($event);\n\n        return (string) $event->getRenderedContent();\n    }\n}\n"
  },
  {
    "path": "src/Markdown/MarkdownExtension.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Markdown;\n\nuse App\\Controller\\User\\ThemeSettingsController;\nuse App\\Markdown\\CommonMark\\CommunityLinkParser;\nuse App\\Markdown\\CommonMark\\DetailsBlockRenderer;\nuse App\\Markdown\\CommonMark\\DetailsBlockStartParser;\nuse App\\Markdown\\CommonMark\\ExternalImagesRenderer;\nuse App\\Markdown\\CommonMark\\ExternalLinkRenderer;\nuse App\\Markdown\\CommonMark\\MentionLinkParser;\nuse App\\Markdown\\CommonMark\\Node\\DetailsBlock;\nuse App\\Markdown\\CommonMark\\Node\\UnresolvableLink;\nuse App\\Markdown\\CommonMark\\TagLinkParser;\nuse App\\Markdown\\CommonMark\\UnresolvableLinkRenderer;\nuse League\\CommonMark\\Environment\\EnvironmentBuilderInterface;\nuse League\\CommonMark\\Extension\\CommonMark\\Node\\Inline\\Image;\nuse League\\CommonMark\\Extension\\CommonMark\\Node\\Inline\\Link;\nuse League\\CommonMark\\Extension\\ConfigurableExtensionInterface;\nuse League\\Config\\ConfigurationBuilderInterface;\nuse Nette\\Schema\\Expect;\nuse Symfony\\Component\\HttpFoundation\\Request;\n\nfinal class MarkdownExtension implements ConfigurableExtensionInterface\n{\n    public function __construct(\n        private readonly CommunityLinkParser $communityLinkParser,\n        private readonly MentionLinkParser $mentionLinkParser,\n        private readonly TagLinkParser $tagLinkParser,\n        private readonly ExternalLinkRenderer $linkRenderer,\n        private readonly ExternalImagesRenderer $imagesRenderer,\n        private readonly UnresolvableLinkRenderer $unresolvableLinkRenderer,\n        private readonly DetailsBlockStartParser $detailsBlockStartParser,\n        private readonly DetailsBlockRenderer $detailsBlockRenderer,\n    ) {\n    }\n\n    public function configureSchema(ConfigurationBuilderInterface $builder): void\n    {\n        $builder->addSchema('kbin', Expect::structure([\n            'render_target' => Expect::type(RenderTarget::class),\n            'richMention' => Expect::bool(true),\n            'richMagazineMention' => Expect::bool(true),\n            'richAPLink' => Expect::bool(true),\n        ]));\n    }\n\n    public function register(EnvironmentBuilderInterface $environment): void\n    {\n        $environment->addBlockStartParser($this->detailsBlockStartParser);\n\n        $environment->addInlineParser($this->communityLinkParser);\n        $environment->addInlineParser($this->mentionLinkParser);\n        $environment->addInlineParser($this->tagLinkParser);\n\n        $environment->addRenderer(Link::class, $this->linkRenderer, 1);\n        $environment->addRenderer(Image::class, $this->imagesRenderer, 1);\n        $environment->addRenderer(UnresolvableLink::class, $this->unresolvableLinkRenderer, 1);\n        $environment->addRenderer(DetailsBlock::class, $this->detailsBlockRenderer, 1);\n    }\n\n    /**\n     * @return array{richMention: bool, richMagazineMention: bool, richAPLink: bool}\n     */\n    public static function getMdRichConfig(?Request $request, string $sourceType = ''): array\n    {\n        if ('entry' === $sourceType) {\n            return [\n                'richMention' => ThemeSettingsController::getShowRichMentionEntry($request),\n                'richMagazineMention' => ThemeSettingsController::getShowRichMagazineMentionEntry($request),\n                'richAPLink' => ThemeSettingsController::getShowRichAPLinkEntries($request),\n            ];\n        } elseif ('post' === $sourceType) {\n            return [\n                'richMention' => ThemeSettingsController::getShowRichMentionPosts($request),\n                'richMagazineMention' => ThemeSettingsController::getShowRichMagazineMentionPosts($request),\n                'richAPLink' => ThemeSettingsController::getShowRichAPLinkPosts($request),\n            ];\n        } else {\n            return [\n                'richMention' => true,\n                'richMagazineMention' => true,\n                'richAPLink' => true,\n            ];\n        }\n    }\n}\n"
  },
  {
    "path": "src/Markdown/RenderTarget.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Markdown;\n\nenum RenderTarget: string\n{\n    case Page = 'Page';\n    case ActivityPub = 'ActivityPub';\n}\n"
  },
  {
    "path": "src/Message/ActivityPub/Inbox/ActivityMessage.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Message\\ActivityPub\\Inbox;\n\nuse App\\Message\\Contracts\\ActivityPubInboxReceiveInterface;\n\n/**\n * @phpstan-type RequestData array{host: string, method: string, uri: string, client_ip: string}\n */\nclass ActivityMessage implements ActivityPubInboxReceiveInterface\n{\n    /**\n     * @phpstan-param RequestData|null $request\n     */\n    public function __construct(public string $payload, public ?array $request = null, public ?array $headers = null)\n    {\n    }\n}\n"
  },
  {
    "path": "src/Message/ActivityPub/Inbox/AddMessage.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Message\\ActivityPub\\Inbox;\n\nuse App\\Message\\Contracts\\ActivityPubInboxInterface;\n\nclass AddMessage implements ActivityPubInboxInterface\n{\n    public function __construct(public array $payload)\n    {\n    }\n}\n"
  },
  {
    "path": "src/Message/ActivityPub/Inbox/AnnounceMessage.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Message\\ActivityPub\\Inbox;\n\nuse App\\Message\\Contracts\\ActivityPubInboxInterface;\n\nclass AnnounceMessage implements ActivityPubInboxInterface\n{\n    public function __construct(public array $payload)\n    {\n    }\n}\n"
  },
  {
    "path": "src/Message/ActivityPub/Inbox/BlockMessage.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Message\\ActivityPub\\Inbox;\n\nuse App\\Message\\Contracts\\ActivityPubInboxInterface;\n\nclass BlockMessage implements ActivityPubInboxInterface\n{\n    public function __construct(\n        public array $payload,\n    ) {\n    }\n}\n"
  },
  {
    "path": "src/Message/ActivityPub/Inbox/ChainActivityMessage.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Message\\ActivityPub\\Inbox;\n\nuse App\\Message\\Contracts\\ActivityPubResolveInterface;\n\nclass ChainActivityMessage implements ActivityPubResolveInterface\n{\n    public function __construct(\n        public array $chain,\n        public ?array $parent = null,\n        public ?array $announce = null,\n        public ?array $like = null,\n        public ?array $dislike = null,\n    ) {\n    }\n}\n"
  },
  {
    "path": "src/Message/ActivityPub/Inbox/CreateMessage.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Message\\ActivityPub\\Inbox;\n\nuse App\\Message\\Contracts\\ActivityPubInboxInterface;\n\nclass CreateMessage implements ActivityPubInboxInterface\n{\n    public function __construct(public array $payload, public ?bool $stickyIt = false, public ?array $fullCreatePayload = null)\n    {\n    }\n}\n"
  },
  {
    "path": "src/Message/ActivityPub/Inbox/DeleteMessage.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Message\\ActivityPub\\Inbox;\n\nuse App\\Message\\Contracts\\ActivityPubInboxInterface;\n\nclass DeleteMessage implements ActivityPubInboxInterface\n{\n    public function __construct(public array $payload)\n    {\n    }\n}\n"
  },
  {
    "path": "src/Message/ActivityPub/Inbox/DislikeMessage.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Message\\ActivityPub\\Inbox;\n\nuse App\\Message\\Contracts\\ActivityPubInboxInterface;\n\nclass DislikeMessage implements ActivityPubInboxInterface\n{\n    public function __construct(public array $payload)\n    {\n    }\n}\n"
  },
  {
    "path": "src/Message/ActivityPub/Inbox/EntryPinMessage.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Message\\ActivityPub\\Inbox;\n\nuse App\\Message\\Contracts\\ActivityPubInboxInterface;\n\nclass EntryPinMessage implements ActivityPubInboxInterface\n{\n    public function __construct(public int $entryId, public bool $sticky, public ?int $actorId)\n    {\n    }\n}\n"
  },
  {
    "path": "src/Message/ActivityPub/Inbox/FlagMessage.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Message\\ActivityPub\\Inbox;\n\nuse App\\Message\\Contracts\\ActivityPubInboxInterface;\nuse JetBrains\\PhpStorm\\ArrayShape;\n\nclass FlagMessage implements ActivityPubInboxInterface\n{\n    #[ArrayShape([\n        '@context' => 'mixed',\n        'type' => 'string',\n        'actor' => 'mixed',\n        'to' => 'mixed',\n        'object' => 'mixed',\n        'audience' => 'string',\n        'summary' => 'string',\n    ])]\n    public array $payload;\n\n    public function __construct(array $payload)\n    {\n        $this->payload = $payload;\n    }\n}\n"
  },
  {
    "path": "src/Message/ActivityPub/Inbox/FollowMessage.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Message\\ActivityPub\\Inbox;\n\nuse App\\Message\\Contracts\\ActivityPubInboxInterface;\n\nclass FollowMessage implements ActivityPubInboxInterface\n{\n    public function __construct(public array $payload)\n    {\n    }\n}\n"
  },
  {
    "path": "src/Message/ActivityPub/Inbox/LikeMessage.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Message\\ActivityPub\\Inbox;\n\nuse App\\Message\\Contracts\\ActivityPubInboxInterface;\n\nclass LikeMessage implements ActivityPubInboxInterface\n{\n    public function __construct(public array $payload)\n    {\n    }\n}\n"
  },
  {
    "path": "src/Message/ActivityPub/Inbox/LockMessage.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Message\\ActivityPub\\Inbox;\n\nuse App\\Message\\Contracts\\ActivityPubInboxInterface;\n\nclass LockMessage implements ActivityPubInboxInterface\n{\n    public array $payload;\n\n    public function __construct(array $payload)\n    {\n        $this->payload = $payload;\n    }\n}\n"
  },
  {
    "path": "src/Message/ActivityPub/Inbox/RemoveMessage.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Message\\ActivityPub\\Inbox;\n\nuse App\\Message\\Contracts\\ActivityPubInboxInterface;\n\nclass RemoveMessage implements ActivityPubInboxInterface\n{\n    public function __construct(public array $payload)\n    {\n    }\n}\n"
  },
  {
    "path": "src/Message/ActivityPub/Inbox/UpdateMessage.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Message\\ActivityPub\\Inbox;\n\nuse App\\Message\\Contracts\\ActivityPubInboxInterface;\n\nclass UpdateMessage implements ActivityPubInboxInterface\n{\n    public function __construct(public array $payload)\n    {\n    }\n}\n"
  },
  {
    "path": "src/Message/ActivityPub/Outbox/AddMessage.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Message\\ActivityPub\\Outbox;\n\nuse App\\Message\\Contracts\\ActivityPubOutboxInterface;\n\nclass AddMessage implements ActivityPubOutboxInterface\n{\n    public function __construct(public int $userActorId, public int $magazineId, public int $addedUserId)\n    {\n    }\n}\n"
  },
  {
    "path": "src/Message/ActivityPub/Outbox/AnnounceLikeMessage.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Message\\ActivityPub\\Outbox;\n\nuse App\\Message\\Contracts\\ActivityPubOutboxInterface;\n\nclass AnnounceLikeMessage implements ActivityPubOutboxInterface\n{\n    public function __construct(\n        public int $userId,\n        public int $objectId,\n        public string $objectType,\n        public bool $undo = false,\n        public ?string $likeMessageId = null,\n    ) {\n    }\n}\n"
  },
  {
    "path": "src/Message/ActivityPub/Outbox/AnnounceMessage.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Message\\ActivityPub\\Outbox;\n\nuse App\\Message\\Contracts\\ActivityPubOutboxInterface;\n\nclass AnnounceMessage implements ActivityPubOutboxInterface\n{\n    public function __construct(\n        public ?int $userId,\n        public ?int $magazineId,\n        public int $objectId,\n        public string $objectType,\n        public bool $removeAnnounce = false,\n        public ?array $createActivity = null,\n    ) {\n    }\n}\n"
  },
  {
    "path": "src/Message/ActivityPub/Outbox/BlockMessage.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Message\\ActivityPub\\Outbox;\n\nuse App\\Message\\Contracts\\ActivityPubOutboxInterface;\n\nclass BlockMessage implements ActivityPubOutboxInterface\n{\n    /**\n     * Exactly of the parameters must not be null.\n     *\n     * @param int|null $magazineBanId if the block has a magazine as a target\n     * @param int|null $bannedUserId  if the block has the instance as a target\n     * @param int|null $actor         the user issuing the ban, only used for instance bans, otherwise MagazineBan::$bannedBy is used\n     *\n     * @see MagazineBan::$bannedBy\n     */\n    public function __construct(\n        public ?int $magazineBanId,\n        public ?int $bannedUserId,\n        public ?int $actor,\n    ) {\n    }\n}\n"
  },
  {
    "path": "src/Message/ActivityPub/Outbox/CreateMessage.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Message\\ActivityPub\\Outbox;\n\nuse App\\Message\\Contracts\\ActivityPubOutboxInterface;\n\nclass CreateMessage implements ActivityPubOutboxInterface\n{\n    public function __construct(public int $id, public string $type)\n    {\n    }\n}\n"
  },
  {
    "path": "src/Message/ActivityPub/Outbox/DeleteMessage.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Message\\ActivityPub\\Outbox;\n\nuse App\\Message\\Contracts\\ActivityPubOutboxInterface;\n\nclass DeleteMessage implements ActivityPubOutboxInterface\n{\n    public function __construct(public array $payload, public int $userId, public int $magazineId)\n    {\n    }\n}\n"
  },
  {
    "path": "src/Message/ActivityPub/Outbox/DeliverMessage.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Message\\ActivityPub\\Outbox;\n\nuse App\\Message\\Contracts\\ActivityPubOutboxDeliverInterface;\n\nclass DeliverMessage implements ActivityPubOutboxDeliverInterface\n{\n    public function __construct(public string $apInboxUrl, public array $payload, public bool $useOldPrivateKey = false)\n    {\n    }\n}\n"
  },
  {
    "path": "src/Message/ActivityPub/Outbox/EntryPinMessage.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Message\\ActivityPub\\Outbox;\n\nuse App\\Message\\Contracts\\ActivityPubOutboxInterface;\n\nclass EntryPinMessage implements ActivityPubOutboxInterface\n{\n    public function __construct(public int $entryId, public bool $sticky, public ?int $actorId)\n    {\n    }\n}\n"
  },
  {
    "path": "src/Message/ActivityPub/Outbox/FlagMessage.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Message\\ActivityPub\\Outbox;\n\nuse App\\Message\\Contracts\\ActivityPubOutboxInterface;\n\nclass FlagMessage implements ActivityPubOutboxInterface\n{\n    public function __construct(public int $reportId)\n    {\n    }\n}\n"
  },
  {
    "path": "src/Message/ActivityPub/Outbox/FollowMessage.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Message\\ActivityPub\\Outbox;\n\nuse App\\Message\\Contracts\\ActivityPubOutboxInterface;\n\nclass FollowMessage implements ActivityPubOutboxInterface\n{\n    public function __construct(\n        public int $followerId,\n        public int $followingId,\n        public bool $unfollow = false,\n        public bool $magazine = false,\n    ) {\n    }\n}\n"
  },
  {
    "path": "src/Message/ActivityPub/Outbox/GenericAnnounceMessage.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Message\\ActivityPub\\Outbox;\n\nuse App\\Message\\Contracts\\ActivityPubOutboxInterface;\n\nclass GenericAnnounceMessage implements ActivityPubOutboxInterface\n{\n    /**\n     * @param array|null $payloadToAnnounce THIS IS NOT USED ANYMORE, ONLY THERE FOR BACKWARDS COMPATIBILITY\n     */\n    public function __construct(public int $announcingMagazineId, public ?array $payloadToAnnounce, public ?string $sourceInstance, public ?string $innerActivityUUID, public ?string $innerActivityUrl)\n    {\n    }\n}\n"
  },
  {
    "path": "src/Message/ActivityPub/Outbox/LikeMessage.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Message\\ActivityPub\\Outbox;\n\nuse App\\Message\\Contracts\\ActivityPubOutboxInterface;\n\nclass LikeMessage implements ActivityPubOutboxInterface\n{\n    public function __construct(\n        public int $userId,\n        public int $objectId,\n        public string $objectType,\n        public bool $removeLike = false,\n    ) {\n    }\n}\n"
  },
  {
    "path": "src/Message/ActivityPub/Outbox/LockMessage.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Message\\ActivityPub\\Outbox;\n\nuse App\\Message\\Contracts\\ActivityPubOutboxInterface;\n\nclass LockMessage implements ActivityPubOutboxInterface\n{\n    public function __construct(\n        public int $actorId,\n        public ?int $entryId = null,\n        public ?int $postId = null,\n    ) {\n    }\n}\n"
  },
  {
    "path": "src/Message/ActivityPub/Outbox/RemoveMessage.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Message\\ActivityPub\\Outbox;\n\nuse App\\Message\\Contracts\\ActivityPubOutboxInterface;\n\nclass RemoveMessage implements ActivityPubOutboxInterface\n{\n    public function __construct(public int $userActorId, public int $magazineId, public int $removedUserId)\n    {\n    }\n}\n"
  },
  {
    "path": "src/Message/ActivityPub/Outbox/UpdateMessage.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Message\\ActivityPub\\Outbox;\n\nuse App\\Message\\Contracts\\ActivityPubOutboxInterface;\n\nclass UpdateMessage implements ActivityPubOutboxInterface\n{\n    public function __construct(public int $id, public string $type, public ?int $editedByUserId = null)\n    {\n    }\n}\n"
  },
  {
    "path": "src/Message/ActivityPub/UpdateActorMessage.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Message\\ActivityPub;\n\nuse App\\Message\\Contracts\\ActivityPubResolveInterface;\n\nclass UpdateActorMessage implements ActivityPubResolveInterface\n{\n    public function __construct(public string $actorUrl, public bool $force = false)\n    {\n    }\n}\n"
  },
  {
    "path": "src/Message/ClearDeadMessagesMessage.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Message;\n\nuse App\\Message\\Contracts\\SchedulerInterface;\n\nclass ClearDeadMessagesMessage implements SchedulerInterface\n{\n    public function __construct()\n    {\n    }\n}\n"
  },
  {
    "path": "src/Message/ClearDeletedUserMessage.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Message;\n\nuse App\\Message\\Contracts\\SchedulerInterface;\n\nclass ClearDeletedUserMessage implements SchedulerInterface\n{\n    public function __construct()\n    {\n    }\n}\n"
  },
  {
    "path": "src/Message/Contracts/ActivityPubInboxInterface.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Message\\Contracts;\n\ninterface ActivityPubInboxInterface extends MessageInterface\n{\n}\n"
  },
  {
    "path": "src/Message/Contracts/ActivityPubInboxReceiveInterface.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Message\\Contracts;\n\ninterface ActivityPubInboxReceiveInterface extends MessageInterface\n{\n}\n"
  },
  {
    "path": "src/Message/Contracts/ActivityPubOutboxDeliverInterface.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Message\\Contracts;\n\ninterface ActivityPubOutboxDeliverInterface extends MessageInterface\n{\n}\n"
  },
  {
    "path": "src/Message/Contracts/ActivityPubOutboxInterface.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Message\\Contracts;\n\ninterface ActivityPubOutboxInterface extends MessageInterface\n{\n}\n"
  },
  {
    "path": "src/Message/Contracts/ActivityPubResolveInterface.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Message\\Contracts;\n\ninterface ActivityPubResolveInterface extends MessageInterface\n{\n}\n"
  },
  {
    "path": "src/Message/Contracts/AsyncMessageInterface.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Message\\Contracts;\n\ninterface AsyncMessageInterface extends MessageInterface\n{\n}\n"
  },
  {
    "path": "src/Message/Contracts/MessageInterface.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Message\\Contracts;\n\ninterface MessageInterface\n{\n}\n"
  },
  {
    "path": "src/Message/Contracts/SchedulerInterface.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Message\\Contracts;\n\ninterface SchedulerInterface extends MessageInterface\n{\n}\n"
  },
  {
    "path": "src/Message/Contracts/SendConfirmationEmailInterface.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Message\\Contracts;\n\ninterface SendConfirmationEmailInterface extends MessageInterface\n{\n}\n"
  },
  {
    "path": "src/Message/DeleteImageMessage.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Message;\n\nuse App\\Message\\Contracts\\AsyncMessageInterface;\n\nclass DeleteImageMessage implements AsyncMessageInterface\n{\n    public function __construct(public int $id, public bool $force = false)\n    {\n    }\n}\n"
  },
  {
    "path": "src/Message/DeleteUserMessage.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Message;\n\nuse App\\Message\\Contracts\\AsyncMessageInterface;\n\nclass DeleteUserMessage implements AsyncMessageInterface\n{\n    public function __construct(public int $id)\n    {\n    }\n}\n"
  },
  {
    "path": "src/Message/EntryEmbedMessage.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Message;\n\nuse App\\Message\\Contracts\\AsyncMessageInterface;\n\nclass EntryEmbedMessage implements AsyncMessageInterface\n{\n    public function __construct(public int $entryId)\n    {\n    }\n}\n"
  },
  {
    "path": "src/Message/LinkEmbedMessage.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Message;\n\nuse App\\Message\\Contracts\\AsyncMessageInterface;\n\nclass LinkEmbedMessage implements AsyncMessageInterface\n{\n    public function __construct(public string $body)\n    {\n    }\n}\n"
  },
  {
    "path": "src/Message/MagazinePurgeMessage.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Message;\n\nuse App\\Message\\Contracts\\AsyncMessageInterface;\n\nclass MagazinePurgeMessage implements AsyncMessageInterface\n{\n    public function __construct(public int $id, public bool $contentOnly)\n    {\n    }\n}\n"
  },
  {
    "path": "src/Message/Notification/EntryCommentCreatedNotificationMessage.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Message\\Notification;\n\nuse App\\Message\\Contracts\\AsyncMessageInterface;\n\nclass EntryCommentCreatedNotificationMessage implements AsyncMessageInterface\n{\n    public function __construct(public int $commentId)\n    {\n    }\n}\n"
  },
  {
    "path": "src/Message/Notification/EntryCommentDeletedNotificationMessage.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Message\\Notification;\n\nuse App\\Message\\Contracts\\AsyncMessageInterface;\n\nclass EntryCommentDeletedNotificationMessage implements AsyncMessageInterface\n{\n    public function __construct(public int $commentId)\n    {\n    }\n}\n"
  },
  {
    "path": "src/Message/Notification/EntryCommentEditedNotificationMessage.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Message\\Notification;\n\nuse App\\Message\\Contracts\\AsyncMessageInterface;\n\nclass EntryCommentEditedNotificationMessage implements AsyncMessageInterface\n{\n    public function __construct(public int $commentId)\n    {\n    }\n}\n"
  },
  {
    "path": "src/Message/Notification/EntryCreatedNotificationMessage.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Message\\Notification;\n\nuse App\\Message\\Contracts\\AsyncMessageInterface;\n\nclass EntryCreatedNotificationMessage implements AsyncMessageInterface\n{\n    public function __construct(public int $entryId)\n    {\n    }\n}\n"
  },
  {
    "path": "src/Message/Notification/EntryDeletedNotificationMessage.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Message\\Notification;\n\nuse App\\Message\\Contracts\\AsyncMessageInterface;\n\nclass EntryDeletedNotificationMessage implements AsyncMessageInterface\n{\n    public function __construct(public int $entryId)\n    {\n    }\n}\n"
  },
  {
    "path": "src/Message/Notification/EntryEditedNotificationMessage.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Message\\Notification;\n\nuse App\\Message\\Contracts\\AsyncMessageInterface;\n\nclass EntryEditedNotificationMessage implements AsyncMessageInterface\n{\n    public function __construct(public int $entryId)\n    {\n    }\n}\n"
  },
  {
    "path": "src/Message/Notification/FavouriteNotificationMessage.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Message\\Notification;\n\nuse App\\Message\\Contracts\\AsyncMessageInterface;\n\nclass FavouriteNotificationMessage implements AsyncMessageInterface\n{\n    public function __construct(public int $subjectId, public string $subjectClass)\n    {\n    }\n}\n"
  },
  {
    "path": "src/Message/Notification/MagazineBanNotificationMessage.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Message\\Notification;\n\nuse App\\Message\\Contracts\\AsyncMessageInterface;\n\nclass MagazineBanNotificationMessage implements AsyncMessageInterface\n{\n    public function __construct(public int $banId)\n    {\n    }\n}\n"
  },
  {
    "path": "src/Message/Notification/PostCommentCreatedNotificationMessage.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Message\\Notification;\n\nuse App\\Message\\Contracts\\AsyncMessageInterface;\n\nclass PostCommentCreatedNotificationMessage implements AsyncMessageInterface\n{\n    public function __construct(public int $commentId)\n    {\n    }\n}\n"
  },
  {
    "path": "src/Message/Notification/PostCommentDeletedNotificationMessage.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Message\\Notification;\n\nuse App\\Message\\Contracts\\AsyncMessageInterface;\n\nclass PostCommentDeletedNotificationMessage implements AsyncMessageInterface\n{\n    public function __construct(public int $commentId)\n    {\n    }\n}\n"
  },
  {
    "path": "src/Message/Notification/PostCommentEditedNotificationMessage.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Message\\Notification;\n\nuse App\\Message\\Contracts\\AsyncMessageInterface;\n\nclass PostCommentEditedNotificationMessage implements AsyncMessageInterface\n{\n    public function __construct(public int $commentId)\n    {\n    }\n}\n"
  },
  {
    "path": "src/Message/Notification/PostCreatedNotificationMessage.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Message\\Notification;\n\nuse App\\Message\\Contracts\\AsyncMessageInterface;\n\nclass PostCreatedNotificationMessage implements AsyncMessageInterface\n{\n    public function __construct(public int $postId)\n    {\n    }\n}\n"
  },
  {
    "path": "src/Message/Notification/PostDeletedNotificationMessage.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Message\\Notification;\n\nuse App\\Message\\Contracts\\AsyncMessageInterface;\n\nclass PostDeletedNotificationMessage implements AsyncMessageInterface\n{\n    public function __construct(public int $postId)\n    {\n    }\n}\n"
  },
  {
    "path": "src/Message/Notification/PostEditedNotificationMessage.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Message\\Notification;\n\nuse App\\Message\\Contracts\\AsyncMessageInterface;\n\nclass PostEditedNotificationMessage implements AsyncMessageInterface\n{\n    public function __construct(public int $postId)\n    {\n    }\n}\n"
  },
  {
    "path": "src/Message/Notification/SentNewSignupNotificationMessage.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Message\\Notification;\n\nuse App\\Message\\Contracts\\AsyncMessageInterface;\n\nclass SentNewSignupNotificationMessage implements AsyncMessageInterface\n{\n    public function __construct(public int $userId)\n    {\n    }\n}\n"
  },
  {
    "path": "src/Message/Notification/VoteNotificationMessage.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Message\\Notification;\n\nuse App\\Message\\Contracts\\AsyncMessageInterface;\n\nclass VoteNotificationMessage implements AsyncMessageInterface\n{\n    public function __construct(public int $subjectId, public string $subjectClass)\n    {\n    }\n}\n"
  },
  {
    "path": "src/Message/UserApplicationAnswerMessage.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Message;\n\nuse App\\Message\\Contracts\\AsyncMessageInterface;\n\nclass UserApplicationAnswerMessage implements AsyncMessageInterface\n{\n    public function __construct(\n        public readonly int $userId,\n        public readonly bool $approved,\n    ) {\n    }\n}\n"
  },
  {
    "path": "src/Message/UserCreatedMessage.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Message;\n\nuse App\\Message\\Contracts\\SendConfirmationEmailInterface;\n\nclass UserCreatedMessage implements SendConfirmationEmailInterface\n{\n    public function __construct(public int $userId)\n    {\n    }\n}\n"
  },
  {
    "path": "src/Message/UserUpdatedMessage.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Message;\n\nuse App\\Message\\Contracts\\SendConfirmationEmailInterface;\n\nclass UserUpdatedMessage implements SendConfirmationEmailInterface\n{\n    public function __construct(public int $userId)\n    {\n    }\n}\n"
  },
  {
    "path": "src/MessageHandler/ActivityPub/Inbox/ActivityHandler.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\MessageHandler\\ActivityPub\\Inbox;\n\nuse App\\Entity\\Instance;\nuse App\\Entity\\Magazine;\nuse App\\Entity\\User;\nuse App\\Exception\\InboxForwardingException;\nuse App\\Exception\\InvalidUserPublicKeyException;\nuse App\\Message\\ActivityPub\\Inbox\\ActivityMessage;\nuse App\\Message\\ActivityPub\\Inbox\\AddMessage;\nuse App\\Message\\ActivityPub\\Inbox\\AnnounceMessage;\nuse App\\Message\\ActivityPub\\Inbox\\BlockMessage;\nuse App\\Message\\ActivityPub\\Inbox\\CreateMessage;\nuse App\\Message\\ActivityPub\\Inbox\\DeleteMessage;\nuse App\\Message\\ActivityPub\\Inbox\\DislikeMessage;\nuse App\\Message\\ActivityPub\\Inbox\\FlagMessage;\nuse App\\Message\\ActivityPub\\Inbox\\FollowMessage;\nuse App\\Message\\ActivityPub\\Inbox\\LikeMessage;\nuse App\\Message\\ActivityPub\\Inbox\\LockMessage;\nuse App\\Message\\ActivityPub\\Inbox\\RemoveMessage;\nuse App\\Message\\ActivityPub\\Inbox\\UpdateMessage;\nuse App\\Message\\Contracts\\MessageInterface;\nuse App\\MessageHandler\\MbinMessageHandler;\nuse App\\Repository\\InstanceRepository;\nuse App\\Service\\ActivityPub\\ApHttpClientInterface;\nuse App\\Service\\ActivityPub\\SignatureValidator;\nuse App\\Service\\ActivityPubManager;\nuse App\\Service\\RemoteInstanceManager;\nuse App\\Service\\SettingsManager;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Psr\\Log\\LoggerInterface;\nuse Symfony\\Component\\HttpKernel\\KernelInterface;\nuse Symfony\\Component\\Messenger\\Attribute\\AsMessageHandler;\nuse Symfony\\Component\\Messenger\\Exception\\UnrecoverableMessageHandlingException;\nuse Symfony\\Component\\Messenger\\MessageBusInterface;\n\n#[AsMessageHandler]\nclass ActivityHandler extends MbinMessageHandler\n{\n    public function __construct(\n        private readonly EntityManagerInterface $entityManager,\n        private readonly KernelInterface $kernel,\n        private readonly SignatureValidator $signatureValidator,\n        private readonly SettingsManager $settingsManager,\n        private readonly MessageBusInterface $bus,\n        private readonly ActivityPubManager $activityPubManager,\n        private readonly ApHttpClientInterface $apHttpClient,\n        private readonly InstanceRepository $instanceRepository,\n        private readonly RemoteInstanceManager $remoteInstanceManager,\n        private readonly LoggerInterface $logger,\n    ) {\n        parent::__construct($this->entityManager, $this->kernel);\n    }\n\n    public function __invoke(ActivityMessage $message): void\n    {\n        $this->workWrapper($message);\n    }\n\n    public function doWork(MessageInterface $message): void\n    {\n        if (!($message instanceof ActivityMessage)) {\n            throw new \\LogicException(\"ActivityHandler called, but is wasn\\'t an ActivityMessage. Type: \".\\get_class($message));\n        }\n\n        $payload = @json_decode($message->payload, true);\n\n        if (null === $payload) {\n            $this->logger->warning('[ActivityHandler::doWork] Activity message from was empty or invalid JSON. Truncated content: {content}, ignoring it', [\n                'content' => substr($message->payload ?? 'No payload provided', 0, 200),\n            ]);\n            throw new UnrecoverableMessageHandlingException('Activity message from was empty or invalid JSON');\n        }\n\n        if ($message->request && $message->headers) {\n            try {\n                $this->signatureValidator->validate($message->request, $message->headers, $message->payload);\n            } catch (InboxForwardingException $exception) {\n                $this->logger->info(\"[ActivityHandler::doWork] The message was forwarded by {receivedFrom}. Dispatching a new activity message '{origin}'\", ['receivedFrom' => $exception->receivedFrom, 'origin' => $exception->realOrigin]);\n\n                if (!$this->settingsManager->isBannedInstance($exception->realOrigin)) {\n                    $body = $this->apHttpClient->getActivityObject($exception->realOrigin, false);\n                    $this->bus->dispatch(new ActivityMessage($body));\n                } else {\n                    $this->logger->info('[ActivityHandler::doWork] The instance is banned, url: {url}', ['url' => $exception->realOrigin]);\n                }\n\n                return;\n            } catch (InvalidUserPublicKeyException $exception) {\n                $this->logger->warning(\"[ActivityHandler::doWork] Unable to extract public key for '{user}'.\", ['user' => $exception->apProfileId]);\n\n                return;\n            }\n        }\n\n        if (null === $payload['id']) {\n            $this->logger->warning('[ActivityHandler::doWork] Activity message has no id field which is required: {json}', ['json' => json_encode($message->payload)]);\n            throw new UnrecoverableMessageHandlingException('Activity message has no id field');\n        }\n\n        $idHost = parse_url($payload['id'], PHP_URL_HOST);\n        if ($idHost) {\n            $instance = $this->instanceRepository->findOneBy(['domain' => $idHost]);\n            if (!$instance) {\n                $instance = new Instance($idHost);\n                $instance->setLastSuccessfulReceive();\n                $this->entityManager->persist($instance);\n                $this->entityManager->flush();\n            } else {\n                $lastDate = $instance->getLastSuccessfulReceive();\n                if ($lastDate < new \\DateTimeImmutable('now - 5 minutes')) {\n                    $instance->setLastSuccessfulReceive();\n                    $this->entityManager->persist($instance);\n                    $this->entityManager->flush();\n                }\n            }\n            $this->remoteInstanceManager->updateInstance($instance);\n        }\n\n        if (isset($payload['payload'])) {\n            $payload = $payload['payload'];\n        }\n\n        try {\n            if (isset($payload['actor']) || isset($payload['attributedTo'])) {\n                if (!$this->verifyInstanceDomain($payload['actor'] ?? $this->activityPubManager->getSingleActorFromAttributedTo($payload['attributedTo']))) {\n                    return;\n                }\n                $user = $this->activityPubManager->findActorOrCreate($payload['actor'] ?? $this->activityPubManager->getSingleActorFromAttributedTo($payload['attributedTo']));\n            } else {\n                if (!$this->verifyInstanceDomain($payload['id'])) {\n                    return;\n                }\n                $user = $this->activityPubManager->findActorOrCreate($payload['id']);\n            }\n        } catch (\\Exception $e) {\n            $this->logger->error('[ActivityHandler::doWork] Payload: '.json_encode($payload));\n\n            return;\n        }\n\n        if ($user instanceof User && $user->isBanned) {\n            return;\n        }\n\n        if (null === $user) {\n            $this->logger->warning('[ActivityHandler::doWork] Could not find an actor discarding ActivityMessage {m}', ['m' => $message->payload]);\n\n            return;\n        }\n\n        $this->handle($payload);\n    }\n\n    private function handle(?array $payload)\n    {\n        if (\\is_null($payload)) {\n            return;\n        }\n\n        if ('Announce' === $payload['type']) {\n            // we check for an array here, because boosts are announces with an url (string) as the object\n            if (\\is_array($payload['object'])) {\n                $actorObject = $this->activityPubManager->findActorOrCreate($payload['actor']);\n                if ($actorObject instanceof Magazine && $actorObject->lastOriginUpdate < (new \\DateTime())->modify('-3 hours')) {\n                    if (isset($payload['object']['type']) && 'Create' === $payload['object']['type']) {\n                        $actorObject->lastOriginUpdate = new \\DateTime();\n                        $this->entityManager->persist($actorObject);\n                        $this->entityManager->flush();\n                    }\n                }\n\n                $payload = $payload['object'];\n                $actor = $payload['actor'] ?? $payload['attributedTo'] ?? null;\n                if ($actor) {\n                    $user = $this->activityPubManager->findActorOrCreate($actor);\n                    if ($user instanceof User && null === $user->apId) {\n                        // don't do anything if we get an announce activity for something a local user did (unless it's a boost, see comment above)\n                        $this->logger->warning('[ActivityHandler::handle] Ignoring this message because it announces an activity from a local user');\n\n                        return;\n                    }\n                }\n            }\n        }\n\n        $this->logger->debug('[ActivityHandler::handle] Got activity message of type {type}: {message}', ['type' => $payload['type'], 'message' => json_encode($payload)]);\n\n        switch ($payload['type']) {\n            case 'Create':\n                $this->bus->dispatch(new CreateMessage($payload['object'], fullCreatePayload: $payload));\n                break;\n            case 'Note':\n            case 'Page':\n            case 'Article':\n            case 'Question':\n            case 'Video':\n                $this->bus->dispatch(new CreateMessage($payload));\n                // no break\n            case 'Announce':\n                $this->bus->dispatch(new AnnounceMessage($payload));\n                break;\n            case 'Like':\n                $this->bus->dispatch(new LikeMessage($payload));\n                break;\n            case 'Dislike':\n                $this->bus->dispatch(new DislikeMessage($payload));\n                break;\n            case 'Follow':\n                $this->bus->dispatch(new FollowMessage($payload));\n                break;\n            case 'Delete':\n                $this->bus->dispatch(new DeleteMessage($payload));\n                break;\n            case 'Undo':\n                $this->handleUndo($payload);\n                break;\n            case 'Accept':\n            case 'Reject':\n                $this->handleAcceptAndReject($payload);\n                break;\n            case 'Update':\n                $this->bus->dispatch(new UpdateMessage($payload));\n                break;\n            case 'Add':\n                $this->bus->dispatch(new AddMessage($payload));\n                break;\n            case 'Remove':\n                $this->bus->dispatch(new RemoveMessage($payload));\n                break;\n            case 'Flag':\n                $this->bus->dispatch(new FlagMessage($payload));\n                break;\n            case 'Block':\n                $this->bus->dispatch(new BlockMessage($payload));\n                break;\n            case 'Lock':\n                $this->bus->dispatch(new LockMessage($payload));\n                break;\n        }\n    }\n\n    private function handleUndo(array $payload): void\n    {\n        if (\\is_array($payload['object'])) {\n            $type = $payload['object']['type'];\n        } else {\n            $type = $payload['type'];\n        }\n\n        switch ($type) {\n            case 'Follow':\n                $this->bus->dispatch(new FollowMessage($payload));\n                break;\n            case 'Announce':\n                $this->bus->dispatch(new AnnounceMessage($payload));\n                break;\n            case 'Like':\n                $this->bus->dispatch(new LikeMessage($payload));\n                break;\n            case 'Dislike':\n                $this->bus->dispatch(new DislikeMessage($payload));\n                break;\n            case 'Block':\n                $this->bus->dispatch(new BlockMessage($payload));\n                break;\n            case 'Lock':\n                $this->bus->dispatch(new LockMessage($payload));\n                break;\n        }\n    }\n\n    private function handleAcceptAndReject(array $payload): void\n    {\n        if (\\is_array($payload['object'])) {\n            $type = $payload['object']['type'];\n        } else {\n            $type = $payload['type'];\n        }\n\n        if ('Follow' === $type) {\n            $this->bus->dispatch(new FollowMessage($payload));\n        }\n    }\n\n    private function verifyInstanceDomain(?string $id): bool\n    {\n        if (!\\is_null($id) && \\in_array(\n            str_replace('www.', '', parse_url($id, PHP_URL_HOST)),\n            $this->instanceRepository->getBannedInstanceUrls()\n        )) {\n            return false;\n        }\n\n        return true;\n    }\n}\n"
  },
  {
    "path": "src/MessageHandler/ActivityPub/Inbox/AddHandler.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\MessageHandler\\ActivityPub\\Inbox;\n\nuse App\\DTO\\ModeratorDto;\nuse App\\Entity\\Entry;\nuse App\\Entity\\Magazine;\nuse App\\Entity\\User;\nuse App\\Message\\ActivityPub\\Inbox\\AddMessage;\nuse App\\Message\\ActivityPub\\Inbox\\CreateMessage;\nuse App\\Message\\ActivityPub\\Outbox\\GenericAnnounceMessage;\nuse App\\Message\\Contracts\\MessageInterface;\nuse App\\MessageHandler\\MbinMessageHandler;\nuse App\\Repository\\ActivityRepository;\nuse App\\Repository\\ApActivityRepository;\nuse App\\Repository\\EntryRepository;\nuse App\\Repository\\MagazineRepository;\nuse App\\Service\\ActivityPub\\ApHttpClientInterface;\nuse App\\Service\\ActivityPubManager;\nuse App\\Service\\EntryManager;\nuse App\\Service\\MagazineManager;\nuse App\\Service\\SettingsManager;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Psr\\Log\\LoggerInterface;\nuse Symfony\\Component\\HttpKernel\\KernelInterface;\nuse Symfony\\Component\\Messenger\\Attribute\\AsMessageHandler;\nuse Symfony\\Component\\Messenger\\MessageBusInterface;\n\n#[AsMessageHandler]\nclass AddHandler extends MbinMessageHandler\n{\n    public function __construct(\n        private readonly EntityManagerInterface $entityManager,\n        private readonly KernelInterface $kernel,\n        private readonly ActivityPubManager $activityPubManager,\n        private readonly ApHttpClientInterface $apHttpClient,\n        private readonly ApActivityRepository $apActivityRepository,\n        private readonly ActivityRepository $activityRepository,\n        private readonly MagazineRepository $magazineRepository,\n        private readonly MagazineManager $magazineManager,\n        private readonly LoggerInterface $logger,\n        private readonly MessageBusInterface $bus,\n        private readonly EntryRepository $entryRepository,\n        private readonly EntryManager $entryManager,\n        private readonly SettingsManager $settingsManager,\n    ) {\n        parent::__construct($this->entityManager, $this->kernel);\n    }\n\n    public function __invoke(AddMessage $message): void\n    {\n        $this->workWrapper($message);\n    }\n\n    public function doWork(MessageInterface $message): void\n    {\n        if (!($message instanceof AddMessage)) {\n            throw new \\LogicException(\"AddHandler called, but is wasn\\'t an AddMessage. Type: \".\\get_class($message));\n        }\n        $payload = $message->payload;\n        $actor = $this->activityPubManager->findUserActorOrCreateOrThrow($payload['actor']);\n        $targetMag = $this->magazineRepository->getMagazineFromModeratorsUrl($payload['target']);\n        if ($targetMag) {\n            $this->handleModeratorAdd($targetMag, $actor, $payload['object'], $payload);\n\n            return;\n        }\n        $targetMag = $this->magazineRepository->getMagazineFromPinnedUrl($payload['target']);\n        if ($targetMag) {\n            $this->handlePinnedAdd($targetMag, $actor, $payload['object'], $payload);\n\n            return;\n        }\n        throw new \\LogicException(\"could not find a magazine with moderators url like: '{$payload['target']}'\");\n    }\n\n    public function handleModeratorAdd(Magazine $targetMag, Magazine|User $actor, $object1, array $messagePayload): void\n    {\n        if (!$targetMag->userIsModerator($actor) and !$targetMag->hasSameHostAsUser($actor)) {\n            throw new \\LogicException(\"the user '$actor->username' ({$actor->getId()}) is not a moderator of '$targetMag->name' ({$targetMag->getId()}) and is not from the same instance. They can therefore not add moderators\");\n        }\n\n        $object = $this->activityPubManager->findUserActorOrCreateOrThrow($object1);\n\n        if ($targetMag->userIsModerator($object)) {\n            $this->logger->warning('the user \"{added}\" ({addedId}) already is a moderator of \"{magName}\" ({magId}). Discarding message', [\n                'added' => $object->username,\n                'addedId' => $object->getId(),\n                'magName' => $targetMag->name,\n                'magId' => $targetMag->getId(),\n            ]);\n\n            return;\n        }\n        $this->logger->info('[AddHandler::handleModeratorAdd] \"{actor}\" ({actorId}) added \"{added}\" ({addedId}) as moderator to \"{magName}\" ({magId})', [\n            'actor' => $actor->username,\n            'actorId' => $actor->getId(),\n            'added' => $object->username,\n            'addedId' => $object->getId(),\n            'magName' => $targetMag->name,\n            'magId' => $targetMag->getId(),\n        ]);\n        $this->magazineManager->addModerator(new ModeratorDto($targetMag, $object, $actor));\n\n        if (null === $targetMag->apId) {\n            $activityToAnnounce = $messagePayload;\n            unset($activityToAnnounce['@context']);\n            $activity = $this->activityRepository->createForRemoteActivity($activityToAnnounce);\n            $this->bus->dispatch(new GenericAnnounceMessage($targetMag->getId(), null, parse_url($actor->apDomain, PHP_URL_HOST), $activity->uuid->toString(), null));\n        }\n    }\n\n    private function handlePinnedAdd(Magazine $targetMag, User $actor, mixed $object, array $messagePayload): void\n    {\n        if (!$targetMag->userIsModerator($actor) && !$targetMag->hasSameHostAsUser($actor)) {\n            throw new \\LogicException(\"the user '$actor->username' ({$actor->getId()}) is not a moderator of $targetMag->name ({$targetMag->getId()}) and is not from the same instance. They can therefore not add pinned entries\");\n        }\n\n        if ('random' === $targetMag->name) {\n            // do not pin anything in the random magazine\n            return;\n        }\n\n        $apId = null;\n        if (\\is_string($object)) {\n            $apId = $object;\n        } elseif (\\is_array($object)) {\n            $apId = $object['id'];\n        } else {\n            throw new \\LogicException('the added object is neither a string or an array');\n        }\n\n        if ($this->settingsManager->isLocalUrl($apId)) {\n            $pair = $this->apActivityRepository->findLocalByApId($apId);\n            if (Entry::class === $pair['type']) {\n                $existingEntry = $this->entryRepository->findOneBy(['id' => $pair['id']]);\n                if ($existingEntry->magazine->getId() !== $targetMag->getId()) {\n                    $this->logger->warning('[AddHandler::handlePinnedAdd] entry {e} is not in the magazine that was targeted {m}. It was in {m2}', ['e' => $existingEntry->title, 'm' => $targetMag->name, 'm2' => $existingEntry->magazine->name]);\n                } elseif ($existingEntry && !$existingEntry->sticky) {\n                    $this->logger->info('[AddHandler::handlePinnedAdd] Pinning entry {e} to magazine {m}', ['e' => $existingEntry->title, 'm' => $existingEntry->magazine->name]);\n                    $this->entryManager->pin($existingEntry, $actor);\n                    if (null === $targetMag->apId) {\n                        $this->announcePin($actor, $targetMag, $messagePayload);\n                    }\n                }\n            }\n        } else {\n            $existingEntry = $this->entryRepository->findOneBy(['apId' => $apId]);\n            if ($existingEntry) {\n                if (null !== $existingEntry->magazine->apFeaturedUrl) {\n                    $this->apHttpClient->invalidateCollectionObjectCache($existingEntry->magazine->apFeaturedUrl);\n                }\n                if (!$existingEntry->sticky) {\n                    $this->logger->info('[AddHandler::handlePinnedAdd] Pinning entry {e} to magazine {m}', ['e' => $existingEntry->title, 'm' => $existingEntry->magazine->name]);\n                    $this->entryManager->pin($existingEntry, $actor);\n                    if (null === $targetMag->apId) {\n                        $this->announcePin($actor, $targetMag, $messagePayload);\n                    }\n                }\n            } else {\n                if (!\\is_array($object)) {\n                    if (!$this->settingsManager->isBannedInstance($apId)) {\n                        $object = $this->apHttpClient->getActivityObject($apId);\n\n                        return;\n                    } else {\n                        $this->logger->info('[AddHandler::handlePinnedAdd] The instance is banned, url: {url}', ['url' => $apId]);\n                    }\n                }\n                $this->bus->dispatch(new CreateMessage($object, true));\n            }\n        }\n    }\n\n    private function announcePin(User $actor, Magazine $targetMag, mixed $object): void\n    {\n        $activityToAnnounce = $object;\n        unset($activityToAnnounce['@context']);\n        $activity = $this->activityRepository->createForRemoteActivity($activityToAnnounce);\n        $this->bus->dispatch(new GenericAnnounceMessage($targetMag->getId(), null, parse_url($actor->apDomain, PHP_URL_HOST), $activity->uuid->toString(), null));\n    }\n}\n"
  },
  {
    "path": "src/MessageHandler/ActivityPub/Inbox/AnnounceHandler.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\MessageHandler\\ActivityPub\\Inbox;\n\nuse App\\Entity\\User;\nuse App\\EventSubscriber\\VoteHandleSubscriber;\nuse App\\Message\\ActivityPub\\Inbox\\AnnounceMessage;\nuse App\\Message\\ActivityPub\\Inbox\\ChainActivityMessage;\nuse App\\Message\\Contracts\\MessageInterface;\nuse App\\MessageHandler\\MbinMessageHandler;\nuse App\\Service\\ActivityPubManager;\nuse App\\Service\\VoteManager;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Psr\\Log\\LoggerInterface;\nuse Symfony\\Component\\HttpKernel\\KernelInterface;\nuse Symfony\\Component\\Messenger\\Attribute\\AsMessageHandler;\nuse Symfony\\Component\\Messenger\\MessageBusInterface;\n\n#[AsMessageHandler]\nclass AnnounceHandler extends MbinMessageHandler\n{\n    public function __construct(\n        private readonly ActivityPubManager $activityPubManager,\n        private readonly EntityManagerInterface $entityManager,\n        private readonly MessageBusInterface $bus,\n        private readonly KernelInterface $kernel,\n        private readonly VoteManager $voteManager,\n        private readonly VoteHandleSubscriber $voteHandleSubscriber,\n        private readonly LoggerInterface $logger,\n    ) {\n        parent::__construct($this->entityManager, $this->kernel);\n    }\n\n    public function __invoke(AnnounceMessage $message): void\n    {\n        $this->workWrapper($message);\n    }\n\n    public function doWork(MessageInterface $message): void\n    {\n        if (!($message instanceof AnnounceMessage)) {\n            throw new \\LogicException(\"AnnounceHandler called, but is wasn\\'t an AnnounceMessage. Type: \".\\get_class($message));\n        }\n        $chainDispatchCallback = function (array $object, ?string $adjustedUrl) use ($message) {\n            if ($adjustedUrl) {\n                $this->logger->info('[AnnounceHandler::doWork] Got an adjusted url: {url}, using that instead of {old}', ['url' => $adjustedUrl, 'old' => $message->payload['object']['id'] ?? $message->payload['object']]);\n                $message->payload['object'] = $adjustedUrl;\n            }\n            $this->bus->dispatch(new ChainActivityMessage([$object], announce: $message->payload));\n        };\n\n        if ('Announce' === $message->payload['type']) {\n            $entity = $this->activityPubManager->getEntityObject($message->payload['object'], $message->payload, $chainDispatchCallback);\n            if (!$entity) {\n                return;\n            }\n            $actor = $this->activityPubManager->findActorOrCreate($message->payload['actor']);\n\n            if ($actor instanceof User) {\n                $this->voteManager->upvote($entity, $actor);\n                $this->voteHandleSubscriber->clearCache($entity);\n            } else {\n                $entity->lastActive = new \\DateTime();\n                $this->entityManager->flush();\n            }\n        }\n\n        if ('Undo' === $message->payload['type']) {\n            return;\n        }\n    }\n}\n"
  },
  {
    "path": "src/MessageHandler/ActivityPub/Inbox/BlockHandler.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\MessageHandler\\ActivityPub\\Inbox;\n\nuse App\\DTO\\MagazineBanDto;\nuse App\\Entity\\Magazine;\nuse App\\Entity\\MagazineBan;\nuse App\\Entity\\User;\nuse App\\Exception\\UserCannotBeBanned;\nuse App\\Message\\ActivityPub\\Inbox\\BlockMessage;\nuse App\\Message\\ActivityPub\\Outbox\\GenericAnnounceMessage;\nuse App\\Message\\Contracts\\MessageInterface;\nuse App\\MessageHandler\\MbinMessageHandler;\nuse App\\Repository\\ActivityRepository;\nuse App\\Repository\\MagazineBanRepository;\nuse App\\Service\\ActivityPub\\ApHttpClientInterface;\nuse App\\Service\\ActivityPubManager;\nuse App\\Service\\MagazineManager;\nuse App\\Service\\UserManager;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Psr\\Log\\LoggerInterface;\nuse Symfony\\Component\\HttpKernel\\KernelInterface;\nuse Symfony\\Component\\Messenger\\Attribute\\AsMessageHandler;\nuse Symfony\\Component\\Messenger\\Exception\\UnrecoverableMessageHandlingException;\nuse Symfony\\Component\\Messenger\\MessageBusInterface;\n\n#[AsMessageHandler]\nclass BlockHandler extends MbinMessageHandler\n{\n    public function __construct(\n        EntityManagerInterface $entityManager,\n        KernelInterface $kernel,\n        private readonly MagazineBanRepository $magazineBanRepository,\n        private readonly ActivityPubManager $activityPubManager,\n        private readonly ApHttpClientInterface $apHttpClient,\n        private readonly MagazineManager $magazineManager,\n        private readonly UserManager $userManager,\n        private readonly ActivityRepository $activityRepository,\n        private readonly MessageBusInterface $bus,\n        private readonly LoggerInterface $logger,\n    ) {\n        parent::__construct($entityManager, $kernel);\n    }\n\n    public function __invoke(BlockMessage $message): void\n    {\n        $this->workWrapper($message);\n    }\n\n    public function doWork(MessageInterface $message): void\n    {\n        if (!$message instanceof BlockMessage) {\n            throw new \\LogicException(\"BlockHandler called, but is wasn\\'t a BlockMessage. Type: \".\\get_class($message));\n        }\n\n        if (!isset($message->payload['id']) || !isset($message->payload['actor']) || !isset($message->payload['object'])) {\n            throw new UnrecoverableMessageHandlingException('Malformed block activity');\n        }\n\n        $this->logger->debug('Got block message: {m}', ['m' => $message->payload]);\n\n        $isUndo = 'Undo' === $message->payload['type'];\n        $payload = $isUndo ? $message->payload['object'] : $message->payload;\n\n        if (\\is_string($payload) && filter_var($payload, FILTER_VALIDATE_URL)) {\n            $payload = $this->apHttpClient->getActivityObject($payload);\n        }\n\n        if (!isset($payload['id']) || !isset($payload['actor']) || !isset($payload['object']) || !isset($payload['target'])) {\n            throw new UnrecoverableMessageHandlingException('Malformed block activity');\n        }\n\n        $actor = $this->activityPubManager->findActorOrCreate($payload['actor']);\n        if (null === $actor) {\n            throw new UnrecoverableMessageHandlingException(\"Unable to find user '{$payload['actor']}'\");\n        }\n\n        $bannedUser = $this->activityPubManager->findActorOrCreate($payload['object']);\n        if (null === $bannedUser) {\n            throw new UnrecoverableMessageHandlingException(\"Could not find user '{$payload['object']}'\");\n        }\n        if (!$bannedUser instanceof User) {\n            throw new UnrecoverableMessageHandlingException('The object has to be a user');\n        }\n\n        try {\n            $target = $this->activityPubManager->findActorOrCreate($payload['target']);\n        } catch (\\Exception $e) {\n            if (parse_url($payload['target'], PHP_URL_HOST) === $bannedUser->apDomain) {\n                // if the host part of the url is the same as the users it is probably the instance actor -> ban the user completely\n                $target = null;\n            } else {\n                throw $e;\n            }\n        }\n\n        $reason = $payload['summary'] ?? '';\n        $expireDate = null;\n        if (isset($payload['expires'])) {\n            $expireDate = new \\DateTimeImmutable($payload['expires']);\n        }\n\n        if (null === $target || ($target instanceof User && 'Application' === $target->type)) {\n            $this->handleInstanceBan($bannedUser, $actor, $reason, $isUndo);\n        } else {\n            $this->handleMagazineBan($message->payload, $bannedUser, $actor, $target, $reason, $expireDate, $isUndo);\n        }\n    }\n\n    private function handleInstanceBan(User $bannedUser, User $actor, string $reason, bool $isUndo): void\n    {\n        if ($bannedUser->apDomain !== $actor->apDomain) {\n            throw new UnrecoverableMessageHandlingException(\"Only a user of the same instance can instance ban another user and the domains of the banned $bannedUser->username and the actor $actor->username do not match\");\n        }\n        if ($isUndo) {\n            $this->logger->info('[BlockHandler::handleInstanceBan] {a} is unbanning {u} instance wide', ['a' => $actor->username, 'u' => $bannedUser->username]);\n            $this->userManager->unban($bannedUser, $actor, $reason);\n        } else {\n            $this->logger->info('[BlockHandler::handleInstanceBan] {a} is banning {u} instance wide', ['a' => $actor->username, 'u' => $bannedUser->username]);\n            $this->userManager->ban($bannedUser, $actor, $reason);\n        }\n    }\n\n    private function handleMagazineBan(array $payload, User $bannedUser, User $actor, Magazine $target, string $reason, ?\\DateTimeImmutable $expireDate, bool $isUndo): void\n    {\n        if (!$target->hasSameHostAsUser($actor) && !$target->userIsModerator($actor)) {\n            throw new UnrecoverableMessageHandlingException(\"The user $actor->username is neither from the same instance as the magazine $target->name nor a moderator in it and is therefore not allowed to ban $bannedUser->username\");\n        }\n\n        $existingBan = $this->magazineBanRepository->findOneBy(['magazine' => $target, 'user' => $bannedUser]);\n        if (null === $existingBan) {\n            $this->logger->debug('it is a magazine ban and we do not have an existing one');\n            if ($isUndo) {\n                // nothing has to be done, the user is not banned\n                $this->logger->debug(\"We didn't know that {u} was banned from {m}, so we do not have to undo it\", ['u' => $bannedUser->username, 'm' => $target->name]);\n\n                return;\n            } else {\n                $ban = $this->banImpl($reason, $expireDate, $target, $bannedUser, $actor);\n\n                if (null === $target->apId) {\n                    // local magazine and the user is allowed to ban users -> announce it\n                    $this->announceBan($payload, $target, $actor, $ban);\n                }\n            }\n        } else {\n            $this->logger->debug('it is a magazine ban and we do have an existing one');\n            if ($isUndo) {\n                $this->logger->info(\"[BlockHandler::handleMagazineBan] {a} is unbanning {u} from magazine {m}. Reason: '{r}'\", ['a' => $actor->username, 'u' => $bannedUser->username, 'm' => $target->name, 'r' => $reason]);\n                $ban = $this->magazineManager->unban($target, $bannedUser);\n            } else {\n                $ban = $this->banImpl($reason, $expireDate, $target, $bannedUser, $actor);\n            }\n\n            if (null === $target->apId) {\n                // local magazine and the user is allowed to ban users -> announce it\n                $this->announceBan($payload, $target, $actor, $ban);\n            }\n        }\n    }\n\n    /**\n     * @throws \\Symfony\\Component\\Messenger\\Exception\\ExceptionInterface\n     */\n    private function announceBan(array $payload, Magazine $target, User $actor, MagazineBan $ban): void\n    {\n        $activityToAnnounce = $payload;\n        unset($activityToAnnounce['@context']);\n        $activity = $this->activityRepository->createForRemoteActivity($payload, $ban);\n        $activity->audience = $target;\n        $activity->setActor($actor);\n\n        $this->bus->dispatch(new GenericAnnounceMessage($target->getId(), null, $actor->apInboxUrl, $activity->uuid->toString(), null));\n    }\n\n    private function banImpl(string $reason, ?\\DateTimeImmutable $expireDate, Magazine $target, User $bannedUser, User $actor): MagazineBan\n    {\n        $dto = new MagazineBanDto();\n        $dto->reason = $reason;\n        $dto->expiredAt = $expireDate;\n        try {\n            $this->logger->info(\"[BlockHandler::handleMagazineBan] {a} is banning {u} from magazine {m}. Reason: '{r}'\", ['a' => $actor->username, 'u' => $bannedUser->username, 'm' => $target->name, 'r' => $reason]);\n            $ban = $this->magazineManager->ban($target, $bannedUser, $actor, $dto);\n        } catch (UserCannotBeBanned) {\n            throw new UnrecoverableMessageHandlingException(\"$bannedUser->username is either an admin or a moderator of $target->name and can therefor not be banned from it\");\n        }\n\n        return $ban;\n    }\n}\n"
  },
  {
    "path": "src/MessageHandler/ActivityPub/Inbox/ChainActivityHandler.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\MessageHandler\\ActivityPub\\Inbox;\n\nuse App\\Entity\\Entry;\nuse App\\Entity\\EntryComment;\nuse App\\Entity\\Post;\nuse App\\Entity\\PostComment;\nuse App\\Exception\\EntityNotFoundException;\nuse App\\Exception\\EntryLockedException;\nuse App\\Exception\\InstanceBannedException;\nuse App\\Exception\\PostLockedException;\nuse App\\Exception\\TagBannedException;\nuse App\\Exception\\UserBannedException;\nuse App\\Exception\\UserDeletedException;\nuse App\\Message\\ActivityPub\\Inbox\\AnnounceMessage;\nuse App\\Message\\ActivityPub\\Inbox\\ChainActivityMessage;\nuse App\\Message\\ActivityPub\\Inbox\\DislikeMessage;\nuse App\\Message\\ActivityPub\\Inbox\\LikeMessage;\nuse App\\Message\\Contracts\\MessageInterface;\nuse App\\MessageHandler\\MbinMessageHandler;\nuse App\\Repository\\ApActivityRepository;\nuse App\\Service\\ActivityPub\\ApHttpClientInterface;\nuse App\\Service\\ActivityPub\\Note;\nuse App\\Service\\ActivityPub\\Page;\nuse App\\Service\\SettingsManager;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Psr\\Log\\LoggerInterface;\nuse Symfony\\Component\\HttpKernel\\KernelInterface;\nuse Symfony\\Component\\Messenger\\Attribute\\AsMessageHandler;\nuse Symfony\\Component\\Messenger\\MessageBusInterface;\n\n#[AsMessageHandler]\nclass ChainActivityHandler extends MbinMessageHandler\n{\n    public function __construct(\n        private readonly EntityManagerInterface $entityManager,\n        private readonly KernelInterface $kernel,\n        private readonly LoggerInterface $logger,\n        private readonly ApHttpClientInterface $client,\n        private readonly MessageBusInterface $bus,\n        private readonly ApActivityRepository $repository,\n        private readonly Note $note,\n        private readonly Page $page,\n        private readonly SettingsManager $settingsManager,\n    ) {\n        parent::__construct($this->entityManager, $this->kernel);\n    }\n\n    public function __invoke(ChainActivityMessage $message): void\n    {\n        $this->workWrapper($message);\n    }\n\n    public function doWork(MessageInterface $message): void\n    {\n        if (!($message instanceof ChainActivityMessage)) {\n            throw new \\LogicException(\"ChainActivityHandler called, but is wasn\\'t a ChainActivityMessage. Type: \".\\get_class($message));\n        }\n        $this->logger->debug('Got chain activity message: {m}', ['m' => $message]);\n        if (!$message->chain || 0 === \\sizeof($message->chain)) {\n            return;\n        }\n        $validObjectTypes = ['Page', 'Note', 'Article', 'Question', 'Video'];\n        $object = $message->chain[0];\n        if (!\\in_array($object['type'], $validObjectTypes)) {\n            $this->logger->error('[ChainActivityHandler::doWork] Cannot get the dependencies of the object, its type {t} is not one we can handle. {m]', ['t' => $object['type'], 'm' => $message]);\n\n            return;\n        }\n        try {\n            $entity = $this->retrieveObject($object['id']);\n        } catch (InstanceBannedException) {\n            $this->logger->info('[ChainActivityHandler::doWork] The instance is banned, url: {url}', ['url' => $object['id']]);\n\n            return;\n        }\n\n        if (!$entity) {\n            $this->logger->error('[ChainActivityHandler::doWork] Could not retrieve all the dependencies of {o}', ['o' => $object['id']]);\n\n            return;\n        }\n\n        if ($message->announce) {\n            $this->bus->dispatch(new AnnounceMessage($message->announce));\n        }\n\n        if ($message->like) {\n            $this->bus->dispatch(new LikeMessage($message->like));\n        }\n\n        if ($message->dislike) {\n            $this->bus->dispatch(new DislikeMessage($message->dislike));\n        }\n    }\n\n    /**\n     * @throws \\Exception if there was an unexpected exception\n     */\n    private function retrieveObject(string $apUrl): Entry|EntryComment|Post|PostComment|null\n    {\n        if ($this->settingsManager->isBannedInstance($apUrl)) {\n            throw new InstanceBannedException();\n        }\n        try {\n            $object = $this->client->getActivityObject($apUrl);\n            if (!$object) {\n                $this->logger->warning('[ChainActivityHandler::retrieveObject] Got an empty object for {url}', ['url' => $apUrl]);\n\n                return null;\n            }\n            if (!\\is_array($object)) {\n                $this->logger->warning(\"[ChainActivityHandler::retrieveObject] Didn't get an array for {url}. Got '{val}' instead, exiting\", ['url' => $apUrl, 'val' => $object]);\n\n                return null;\n            }\n\n            if (\\array_key_exists('inReplyTo', $object) && null !== $object['inReplyTo']) {\n                $parentUrl = \\is_string($object['inReplyTo']) ? $object['inReplyTo'] : $object['inReplyTo']['id'];\n                $meta = $this->repository->findByObjectId($parentUrl);\n                if (!$meta) {\n                    $this->retrieveObject($parentUrl);\n                }\n                $meta = $this->repository->findByObjectId($parentUrl);\n                if (!$meta) {\n                    $this->logger->warning('[ChainActivityHandler::retrieveObject] Fetching the parent object ({parent}) did not work for {url}, aborting', ['parent' => $parentUrl, 'url' => $apUrl]);\n\n                    return null;\n                }\n            }\n\n            switch ($object['type']) {\n                case 'Question':\n                case 'Note':\n                    $this->logger->debug('[ChainActivityHandler::retrieveObject] Creating note {o}', ['o' => $object]);\n\n                    return $this->note->create($object);\n                case 'Page':\n                case 'Article':\n                case 'Video':\n                    $this->logger->debug('[ChainActivityHandler::retrieveObject] Creating page {o}', ['o' => $object]);\n\n                    return $this->page->create($object);\n                default:\n                    $this->logger->warning('[ChainActivityHandler::retrieveObject] Could not create an object from type {t} on {url}: {o}', ['t' => $object['type'], 'url' => $apUrl, 'o' => $object]);\n            }\n        } catch (UserBannedException) {\n            $this->logger->info('[ChainActivityHandler::retrieveObject] The user is banned, url: {url}', ['url' => $apUrl]);\n        } catch (UserDeletedException) {\n            $this->logger->info('[ChainActivityHandler::retrieveObject] The user is deleted, url: {url}', ['url' => $apUrl]);\n        } catch (TagBannedException) {\n            $this->logger->info('[ChainActivityHandler::retrieveObject] One of the used tags is banned, url: {url}', ['url' => $apUrl]);\n        } catch (InstanceBannedException) {\n            $this->logger->info('[ChainActivityHandler::retrieveObject] The instance is banned, url: {url}', ['url' => $apUrl]);\n        } catch (EntryLockedException) {\n            $this->logger->error('[ChainActivityHandler::retrieveObject] The entry in which this comment should be created, is locked: {url}', ['url' => $apUrl]);\n        } catch (PostLockedException) {\n            $this->logger->error('[ChainActivityHandler::retrieveObject] The post in which this comment should be created, is locked: {url}', ['url' => $apUrl]);\n        } catch (EntityNotFoundException $e) {\n            $this->logger->error('[ChainActivityHandler::retrieveObject] There was an exception while getting {url}: {ex} - {m}. {o}', ['url' => $apUrl, 'ex' => \\get_class($e), 'm' => $e->getMessage(), 'o' => $e]);\n        }\n\n        return null;\n    }\n}\n"
  },
  {
    "path": "src/MessageHandler/ActivityPub/Inbox/CreateHandler.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\MessageHandler\\ActivityPub\\Inbox;\n\nuse App\\Entity\\Entry;\nuse App\\Entity\\EntryComment;\nuse App\\Entity\\Post;\nuse App\\Entity\\PostComment;\nuse App\\Entity\\User;\nuse App\\Exception\\EntryLockedException;\nuse App\\Exception\\InstanceBannedException;\nuse App\\Exception\\InvalidApPostException;\nuse App\\Exception\\InvalidWebfingerException;\nuse App\\Exception\\PostingRestrictedException;\nuse App\\Exception\\PostLockedException;\nuse App\\Exception\\TagBannedException;\nuse App\\Exception\\UserBannedException;\nuse App\\Exception\\UserBlockedException;\nuse App\\Exception\\UserDeletedException;\nuse App\\Message\\ActivityPub\\Inbox\\ChainActivityMessage;\nuse App\\Message\\ActivityPub\\Inbox\\CreateMessage;\nuse App\\Message\\ActivityPub\\Outbox\\AnnounceMessage;\nuse App\\Message\\Contracts\\MessageInterface;\nuse App\\MessageHandler\\MbinMessageHandler;\nuse App\\Repository\\ActivityRepository;\nuse App\\Repository\\ApActivityRepository;\nuse App\\Service\\ActivityPub\\Note;\nuse App\\Service\\ActivityPub\\Page;\nuse App\\Service\\MessageManager;\nuse App\\Utils\\UrlUtils;\nuse Doctrine\\DBAL\\Exception;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Psr\\Cache\\InvalidArgumentException;\nuse Psr\\Log\\LoggerInterface;\nuse Symfony\\Component\\HttpKernel\\KernelInterface;\nuse Symfony\\Component\\Messenger\\Attribute\\AsMessageHandler;\nuse Symfony\\Component\\Messenger\\MessageBusInterface;\nuse Symfony\\Contracts\\Cache\\CacheInterface;\n\n#[AsMessageHandler]\nclass CreateHandler extends MbinMessageHandler\n{\n    public function __construct(\n        private readonly EntityManagerInterface $entityManager,\n        private readonly KernelInterface $kernel,\n        private readonly Note $note,\n        private readonly Page $page,\n        private readonly MessageBusInterface $bus,\n        private readonly LoggerInterface $logger,\n        private readonly MessageManager $messageManager,\n        private readonly ActivityRepository $activityRepository,\n        private readonly ApActivityRepository $repository,\n        private readonly CacheInterface $cache,\n    ) {\n        parent::__construct($this->entityManager, $this->kernel);\n    }\n\n    /**\n     * @throws \\Exception\n     */\n    public function __invoke(CreateMessage $message): void\n    {\n        $this->workWrapper($message);\n    }\n\n    public function doWork(MessageInterface $message): void\n    {\n        if (!($message instanceof CreateMessage)) {\n            throw new \\LogicException(\"CreateHandler called, but is wasn\\'t a CreateMessage. Type: \".\\get_class($message));\n        }\n        $object = $message->payload;\n        $stickyIt = $message->stickyIt;\n        $this->logger->debug('Got a CreateMessage of type {t}, {m}', ['t' => $message->payload['type'], 'm' => $message->payload]);\n        $entryTypes = ['Page', 'Article', 'Video'];\n        $postTypes = ['Question', 'Note'];\n\n        try {\n            if ('ChatMessage' === $object['type']) {\n                $this->handlePrivateMessage($object);\n            } elseif (\\in_array($object['type'], $postTypes)) {\n                $this->handleChain($object, $stickyIt, $message->fullCreatePayload);\n                if (method_exists($this->cache, 'invalidateTags')) {\n                    // clear markdown renders that are tagged with the id of the post\n                    $tag = UrlUtils::getCacheKeyForMarkdownUrl($object['id']);\n                    $this->cache->invalidateTags([$tag]);\n                    $this->logger->debug('cleared cached items with tag {t}', ['t' => $tag]);\n                }\n            } elseif (\\in_array($object['type'], $entryTypes)) {\n                $this->handlePage($object, $stickyIt, $message->fullCreatePayload);\n                if (method_exists($this->cache, 'invalidateTags')) {\n                    // clear markdown renders that are tagged with the id of the entry\n                    $tag = UrlUtils::getCacheKeyForMarkdownUrl($object['id']);\n                    $this->cache->invalidateTags([$tag]);\n                    $this->logger->debug('cleared cached items with tag {t}', ['t' => $tag]);\n                }\n            } else {\n                $this->logger->warning('received Create activity for unknown type {t} of object {o}; ignoring', [\n                    't' => $object['type'],\n                    'o' => $object['id'] ?? '<no id>',\n                ]);\n            }\n        } catch (UserBannedException) {\n            $this->logger->info('[CreateHandler::doWork] Did not create the post, because the user is banned');\n        } catch (UserDeletedException) {\n            $this->logger->info('[CreateHandler::doWork] Did not create the post, because the user is deleted');\n        } catch (TagBannedException) {\n            $this->logger->info('[CreateHandler::doWork] Did not create the post, because one of the used tags is banned');\n        } catch (PostingRestrictedException $e) {\n            if ($e->actor instanceof User) {\n                $username = $e->actor->getUsername();\n            } else {\n                $username = $e->actor->name;\n            }\n            $this->logger->info('[CreateHandler::doWork] Did not create the post, because the magazine {m} restricts posting to mods and {u} is not a mod', ['m' => $e->magazine, 'u' => $username]);\n        } catch (InstanceBannedException $e) {\n            $this->logger->info('[CreateHandler::doWork] Did not create the post, because the user\\'s instance is banned');\n        } catch (UserBlockedException $e) {\n            $this->logger->info('[CreateHandler::doWork] Did not create the message, because the user is blocked by one of the receivers');\n        } catch (EntryLockedException|PostLockedException) {\n            $this->logger->info('[CreateHandler::doWork] Did not create the comment, because the entry/post is locked');\n        }\n    }\n\n    /**\n     * @throws TagBannedException\n     * @throws UserBannedException\n     * @throws UserDeletedException\n     * @throws InstanceBannedException\n     * @throws EntryLockedException\n     * @throws PostLockedException\n     */\n    private function handleChain(array $object, bool $stickyIt, ?array $fullCreatePayload): void\n    {\n        if (isset($object['inReplyTo']) && $object['inReplyTo']) {\n            $existed = $this->repository->findByObjectId($object['inReplyTo']);\n            if (!$existed) {\n                $this->bus->dispatch(new ChainActivityMessage([$object]));\n\n                return;\n            }\n        }\n\n        $note = $this->note->create($object, stickyIt: $stickyIt);\n        if ($note instanceof EntryComment || $note instanceof Post || $note instanceof PostComment) {\n            if (null !== $note->apId and null === $note->magazine->apId and 'random' !== $note->magazine->name) {\n                $createActivity = $this->activityRepository->findFirstActivitiesByTypeAndObject('Create', $note);\n                if (null === $createActivity) {\n                    if (null !== $fullCreatePayload) {\n                        $this->activityRepository->createForRemoteActivity($fullCreatePayload, $note);\n                    } else {\n                        $this->logger->warning('[CreateHandler::handleChain] Could not create the activity with the full create payload because it was just missing...');\n                    }\n                }\n                // local magazine, but remote post. Random magazine is ignored, as it should not be federated at all\n                $this->bus->dispatch(new AnnounceMessage(null, $note->magazine->getId(), $note->getId(), \\get_class($note)));\n            }\n        }\n    }\n\n    /**\n     * @throws \\Exception\n     * @throws UserBannedException\n     * @throws UserDeletedException\n     * @throws TagBannedException\n     * @throws PostingRestrictedException\n     * @throws InstanceBannedException\n     */\n    private function handlePage(array $object, bool $stickyIt, ?array $createPayload): void\n    {\n        $page = $this->page->create($object, stickyIt: $stickyIt);\n        if ($page instanceof Entry) {\n            if (null !== $page->apId and null === $page->magazine->apId and 'random' !== $page->magazine->name) {\n                $createActivity = $this->activityRepository->findFirstActivitiesByTypeAndObject('Create', $page);\n                if (null === $createActivity) {\n                    if (null !== $createPayload) {\n                        $this->activityRepository->createForRemoteActivity($createPayload, $page);\n                    } else {\n                        $this->logger->warning('[CreateHandler::handlePage] Could not create the activity with the full create payload because it was just missing...');\n                    }\n                }\n                // local magazine, but remote post. Random magazine is ignored, as it should not be federated at all\n                $this->bus->dispatch(new AnnounceMessage(null, $page->magazine->getId(), $page->getId(), \\get_class($page)));\n            }\n        }\n    }\n\n    /**\n     * @throws InvalidApPostException\n     * @throws InvalidArgumentException\n     * @throws UserDeletedException\n     * @throws InvalidWebfingerException\n     * @throws Exception\n     */\n    private function handlePrivateMessage(array $object): void\n    {\n        $this->messageManager->createMessage($object);\n    }\n\n    private function handlePrivateMentions(): void\n    {\n        // TODO implement private mentions\n    }\n}\n"
  },
  {
    "path": "src/MessageHandler/ActivityPub/Inbox/DeleteHandler.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\MessageHandler\\ActivityPub\\Inbox;\n\nuse App\\Entity\\Entry;\nuse App\\Entity\\EntryComment;\nuse App\\Entity\\Post;\nuse App\\Entity\\PostComment;\nuse App\\Entity\\User;\nuse App\\Message\\ActivityPub\\Inbox\\DeleteMessage;\nuse App\\Message\\Contracts\\MessageInterface;\nuse App\\Message\\DeleteUserMessage;\nuse App\\MessageHandler\\MbinMessageHandler;\nuse App\\Repository\\ActivityRepository;\nuse App\\Repository\\ApActivityRepository;\nuse App\\Repository\\UserRepository;\nuse App\\Service\\ActivityPubManager;\nuse App\\Service\\EntryCommentManager;\nuse App\\Service\\EntryManager;\nuse App\\Service\\PostCommentManager;\nuse App\\Service\\PostManager;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Symfony\\Component\\HttpKernel\\KernelInterface;\nuse Symfony\\Component\\Messenger\\Attribute\\AsMessageHandler;\nuse Symfony\\Component\\Messenger\\MessageBusInterface;\n\n#[AsMessageHandler]\nclass DeleteHandler extends MbinMessageHandler\n{\n    public function __construct(\n        private readonly MessageBusInterface $bus,\n        private readonly ActivityPubManager $activityPubManager,\n        private readonly KernelInterface $kernel,\n        private readonly ApActivityRepository $apActivityRepository,\n        private readonly EntityManagerInterface $entityManager,\n        private readonly UserRepository $userRepository,\n        private readonly EntryManager $entryManager,\n        private readonly EntryCommentManager $entryCommentManager,\n        private readonly PostManager $postManager,\n        private readonly PostCommentManager $postCommentManager,\n        private readonly ActivityRepository $activityRepository,\n    ) {\n        parent::__construct($this->entityManager, $this->kernel);\n    }\n\n    public function __invoke(DeleteMessage $message): void\n    {\n        $this->workWrapper($message);\n    }\n\n    public function doWork(MessageInterface $message): void\n    {\n        if (!($message instanceof DeleteMessage)) {\n            throw new \\LogicException(\"DeleteHandler called, but is wasn\\'t a DeleteMessage. Type: \".\\get_class($message));\n        }\n        $actor = $this->activityPubManager->findActorOrCreate($message->payload['actor']);\n\n        $id = \\is_array($message->payload['object']) ? $message->payload['object']['id'] : $message->payload['object'];\n        $object = $this->apActivityRepository->findByObjectId($id);\n\n        if (!$object && $actor) {\n            $user = $this->userRepository->findOneBy(['apProfileId' => $id]);\n            if ($actor instanceof User && $user instanceof User && $actor->apDomain === $user->apDomain) {\n                // only users of the same server can delete each other.\n                // Since the server of both is in charge as to which user can delete each other.\n                $object = [\n                    'type' => User::class,\n                    'id' => $user->getId(),\n                ];\n            }\n        }\n\n        if (!$object) {\n            return;\n        }\n\n        $entity = $this->entityManager->getRepository($object['type'])->find((int) $object['id']);\n\n        if ($entity instanceof Entry || $entity instanceof EntryComment || $entity instanceof Post || $entity instanceof PostComment) {\n            if (!$entity->magazine->apId || ($actor->apId && !$entity->user->apId)) {\n                // local magazine or remote actor for a local users content -> need to announce it later\n                $this->activityRepository->createForRemoteActivity($message->payload, $entity);\n            }\n        }\n\n        if ($entity instanceof Entry) {\n            $this->deleteEntry($entity, $actor);\n        } elseif ($entity instanceof EntryComment) {\n            $this->deleteEntryComment($entity, $actor);\n        } elseif ($entity instanceof Post) {\n            $this->deletePost($entity, $actor);\n        } elseif ($entity instanceof PostComment) {\n            $this->deletePostComment($entity, $actor);\n        } elseif ($entity instanceof User) {\n            $this->bus->dispatch(new DeleteUserMessage($entity->getId()));\n        }\n    }\n\n    private function deleteEntry(Entry $entry, User $user): void\n    {\n        $this->entryManager->delete($user, $entry);\n    }\n\n    private function deleteEntryComment(EntryComment $comment, User $user): void\n    {\n        $this->entryCommentManager->delete($user, $comment);\n    }\n\n    private function deletePost(Post $post, User $user): void\n    {\n        $this->postManager->delete($user, $post);\n    }\n\n    private function deletePostComment(PostComment $comment, User $user): void\n    {\n        $this->postCommentManager->delete($user, $comment);\n    }\n}\n"
  },
  {
    "path": "src/MessageHandler/ActivityPub/Inbox/DislikeHandler.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\MessageHandler\\ActivityPub\\Inbox;\n\nuse App\\Entity\\Contracts\\VotableInterface;\nuse App\\Entity\\Entry;\nuse App\\Entity\\EntryComment;\nuse App\\Entity\\Post;\nuse App\\Entity\\PostComment;\nuse App\\Entity\\User;\nuse App\\Message\\ActivityPub\\Inbox\\ChainActivityMessage;\nuse App\\Message\\ActivityPub\\Inbox\\DislikeMessage;\nuse App\\Message\\ActivityPub\\Outbox\\GenericAnnounceMessage;\nuse App\\Message\\Contracts\\MessageInterface;\nuse App\\MessageHandler\\MbinMessageHandler;\nuse App\\Repository\\ActivityRepository;\nuse App\\Service\\ActivityPubManager;\nuse App\\Service\\SettingsManager;\nuse App\\Service\\VoteManager;\nuse App\\Utils\\DownvotesMode;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Psr\\Log\\LoggerInterface;\nuse Symfony\\Component\\HttpKernel\\KernelInterface;\nuse Symfony\\Component\\Messenger\\Attribute\\AsMessageHandler;\nuse Symfony\\Component\\Messenger\\MessageBusInterface;\n\n#[AsMessageHandler]\nclass DislikeHandler extends MbinMessageHandler\n{\n    public function __construct(\n        private readonly EntityManagerInterface $entityManager,\n        private readonly KernelInterface $kernel,\n        private readonly ActivityPubManager $activityPubManager,\n        private readonly MessageBusInterface $bus,\n        private readonly VoteManager $voteManager,\n        private readonly LoggerInterface $logger,\n        private readonly SettingsManager $settingsManager,\n        private readonly ActivityRepository $activityRepository,\n    ) {\n        parent::__construct($this->entityManager, $this->kernel);\n    }\n\n    public function __invoke(DislikeMessage $message): void\n    {\n        $this->workWrapper($message);\n    }\n\n    public function doWork(MessageInterface $message): void\n    {\n        if (!($message instanceof DislikeMessage)) {\n            throw new \\LogicException(\"DislikeHandler called, but is wasn\\'t a DislikeMessage. Type: \".\\get_class($message));\n        }\n        if (!isset($message->payload['type'])) {\n            return;\n        }\n        if (DownvotesMode::Disabled === $this->settingsManager->getDownvotesMode()) {\n            return;\n        }\n\n        $chainDispatchCallback = function (array $object, ?string $adjustedUrl) use ($message) {\n            if ($adjustedUrl) {\n                $this->logger->info('[DislikeHandler::doWork] got an adjusted url: {url}, using that instead of {old}', ['url' => $adjustedUrl, 'old' => $message->payload['object']['id'] ?? $message->payload['object']]);\n                $message->payload['object'] = $adjustedUrl;\n            }\n            $this->bus->dispatch(new ChainActivityMessage([$object], dislike: $message->payload));\n        };\n\n        if ('Dislike' === $message->payload['type']) {\n            $entity = $this->activityPubManager->getEntityObject($message->payload['object'], $message->payload, $chainDispatchCallback);\n            if (!$entity) {\n                return;\n            }\n\n            $actor = $this->activityPubManager->findActorOrCreate($message->payload['actor']);\n            // Check if actor and entity aren't empty\n            if (!empty($actor) && !empty($entity)) {\n                if ($actor instanceof User && ($entity instanceof Entry || $entity instanceof EntryComment || $entity instanceof Post || $entity instanceof PostComment)) {\n                    $this->voteManager->vote(VotableInterface::VOTE_DOWN, $entity, $actor);\n                    if (null === $entity->magazine->apId && null !== $actor->apId) {\n                        // local magazine, remote user\n                        $dislikeActivity = $this->activityRepository->createForRemoteActivity($message->payload);\n                        $this->bus->dispatch(new GenericAnnounceMessage($entity->magazine->getId(), null, $actor->apInboxUrl, $dislikeActivity->uuid->toString(), null));\n                    }\n                }\n            }\n        } elseif ('Undo' === $message->payload['type']) {\n            if ('Dislike' === $message->payload['object']['type']) {\n                $entity = $this->activityPubManager->getEntityObject($message->payload['object']['object'], $message->payload, $chainDispatchCallback);\n                if (!$entity) {\n                    return;\n                }\n\n                $actor = $this->activityPubManager->findActorOrCreate($message->payload['actor']);\n                // Check if actor and entity aren't empty\n                if ($actor instanceof User && ($entity instanceof Entry || $entity instanceof EntryComment || $entity instanceof Post || $entity instanceof PostComment)) {\n                    $this->voteManager->removeVote($entity, $actor);\n                    if (null === $entity->magazine->apId && null !== $actor->apId) {\n                        // local magazine, remote user\n                        $dislikeActivity = $this->activityRepository->createForRemoteActivity($message->payload);\n                        $this->bus->dispatch(new GenericAnnounceMessage($entity->magazine->getId(), null, $actor->apInboxUrl, $dislikeActivity->uuid->toString(), null));\n                    }\n                }\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "src/MessageHandler/ActivityPub/Inbox/FlagHandler.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\MessageHandler\\ActivityPub\\Inbox;\n\nuse App\\DTO\\ReportDto;\nuse App\\Entity\\Contracts\\ReportInterface;\nuse App\\Entity\\User;\nuse App\\Exception\\SubjectHasBeenReportedException;\nuse App\\Message\\ActivityPub\\Inbox\\FlagMessage;\nuse App\\Message\\Contracts\\MessageInterface;\nuse App\\MessageHandler\\MbinMessageHandler;\nuse App\\Repository\\EntryCommentRepository;\nuse App\\Repository\\EntryRepository;\nuse App\\Repository\\PostCommentRepository;\nuse App\\Repository\\PostRepository;\nuse App\\Service\\ActivityPubManager;\nuse App\\Service\\ReportManager;\nuse App\\Service\\SettingsManager;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Psr\\Log\\LoggerInterface;\nuse Symfony\\Component\\HttpKernel\\KernelInterface;\nuse Symfony\\Component\\Messenger\\Attribute\\AsMessageHandler;\n\n#[AsMessageHandler]\nclass FlagHandler extends MbinMessageHandler\n{\n    public function __construct(\n        private readonly EntityManagerInterface $entityManager,\n        private readonly KernelInterface $kernel,\n        private readonly ActivityPubManager $activityPubManager,\n        private readonly ReportManager $reportManager,\n        private readonly EntryRepository $entryRepository,\n        private readonly EntryCommentRepository $entryCommentRepository,\n        private readonly PostRepository $postRepository,\n        private readonly PostCommentRepository $postCommentRepository,\n        private readonly SettingsManager $settingsManager,\n        private readonly LoggerInterface $logger,\n    ) {\n        parent::__construct($this->entityManager, $this->kernel);\n    }\n\n    public function __invoke(FlagMessage $message): void\n    {\n        $this->workWrapper($message);\n    }\n\n    public function doWork(MessageInterface $message): void\n    {\n        if (!($message instanceof FlagMessage)) {\n            throw new \\LogicException(\"FlagHandler called, but is wasn\\'t a FlagMessage. Type: \".\\get_class($message));\n        }\n        $this->logger->debug('Got FlagMessage: '.json_encode($message));\n        $actor = $this->activityPubManager->findActorOrCreate($message->payload['actor']);\n        $object = $message->payload['object'];\n        $objects = \\is_array($object) ? $object : [$object];\n        if (!$actor instanceof User) {\n            throw new \\LogicException(\"could not find a user actor on url '{$message->payload['actor']}'\");\n        }\n        foreach ($objects as $item) {\n            if (!\\is_string($item)) {\n                continue;\n            }\n\n            if ($this->settingsManager->isLocalUrl($item)) {\n                $subject = $this->findLocalSubject($item);\n            } else {\n                $subject = $this->findRemoteSubject($item);\n            }\n            if (null !== $subject) {\n                try {\n                    $reason = null;\n                    if (\\array_key_exists('summary', $message->payload) && \\is_string($message->payload['summary'])) {\n                        $reason = $message->payload['summary'];\n                    }\n                    $this->reportManager->report(ReportDto::create($subject, $reason), $actor);\n                } catch (SubjectHasBeenReportedException) {\n                }\n            } else {\n                $this->logger->warning(\"could not find the subject of a report: '$item'\");\n            }\n        }\n    }\n\n    private function findRemoteSubject(string $apUrl): ?ReportInterface\n    {\n        $entry = $this->entryRepository->findOneBy(['apId' => $apUrl]);\n        $entryComment = null;\n        $post = null;\n        $postComment = null;\n        if (!$entry) {\n            $entryComment = $this->entryCommentRepository->findOneBy(['apId' => $apUrl]);\n        }\n        if (!$entry and !$entryComment) {\n            $post = $this->postRepository->findOneBy(['apId' => $apUrl]);\n        }\n        if (!$entry and !$entryComment and !$post) {\n            $postComment = $this->postCommentRepository->findOneBy(['apId' => $apUrl]);\n        }\n\n        return $entry ?? $entryComment ?? $post ?? $postComment;\n    }\n\n    private function findLocalSubject(string $apUrl): ?ReportInterface\n    {\n        $matches = null;\n        if (preg_match_all(\"/\\/m\\/([a-zA-Z0-9\\-_:@.]+)\\/t\\/([1-9][0-9]*)\\/-\\/comment\\/([1-9][0-9]*)/\", $apUrl, $matches)) {\n            return $this->entryCommentRepository->findOneBy(['id' => $matches[3][0]]);\n        }\n        if (preg_match_all(\"/\\/m\\/([a-zA-Z0-9\\-_:@.]+)\\/t\\/([1-9][0-9]*)/\", $apUrl, $matches)) {\n            return $this->entryRepository->findOneBy(['id' => $matches[2][0]]);\n        }\n        if (preg_match_all(\"/\\/m\\/([a-zA-Z0-9\\-_:@.]+)\\/p\\/([1-9][0-9]*)\\/-\\/reply\\/([1-9][0-9]*)/\", $apUrl, $matches)) {\n            return $this->postCommentRepository->findOneBy(['id' => $matches[3][0]]);\n        }\n        if (preg_match_all(\"/\\/m\\/([a-zA-Z0-9\\-_:@.]+)\\/p\\/([1-9][0-9]*)/\", $apUrl, $matches)) {\n            return $this->postRepository->findOneBy(['id' => $matches[2][0]]);\n        }\n\n        return null;\n    }\n}\n"
  },
  {
    "path": "src/MessageHandler/ActivityPub/Inbox/FollowHandler.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\MessageHandler\\ActivityPub\\Inbox;\n\nuse App\\Entity\\Magazine;\nuse App\\Entity\\User;\nuse App\\Message\\ActivityPub\\Inbox\\FollowMessage;\nuse App\\Message\\Contracts\\MessageInterface;\nuse App\\MessageHandler\\MbinMessageHandler;\nuse App\\Service\\ActivityPub\\ActivityJsonBuilder;\nuse App\\Service\\ActivityPub\\ApHttpClientInterface;\nuse App\\Service\\ActivityPub\\Wrapper\\FollowResponseWrapper;\nuse App\\Service\\ActivityPubManager;\nuse App\\Service\\MagazineManager;\nuse App\\Service\\UserManager;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Psr\\Log\\LoggerInterface;\nuse Symfony\\Component\\HttpKernel\\KernelInterface;\nuse Symfony\\Component\\Messenger\\Attribute\\AsMessageHandler;\n\n#[AsMessageHandler]\nclass FollowHandler extends MbinMessageHandler\n{\n    public function __construct(\n        private readonly EntityManagerInterface $entityManager,\n        private readonly KernelInterface $kernel,\n        private readonly ActivityPubManager $activityPubManager,\n        private readonly UserManager $userManager,\n        private readonly MagazineManager $magazineManager,\n        private readonly ApHttpClientInterface $client,\n        private readonly LoggerInterface $logger,\n        private readonly FollowResponseWrapper $followResponseWrapper,\n        private readonly ActivityJsonBuilder $activityJsonBuilder,\n    ) {\n        parent::__construct($this->entityManager, $this->kernel);\n    }\n\n    public function __invoke(FollowMessage $message): void\n    {\n        $this->workWrapper($message);\n    }\n\n    public function doWork(MessageInterface $message): void\n    {\n        if (!($message instanceof FollowMessage)) {\n            throw new \\LogicException(\"FollowHandler called, but is wasn\\'t a FollowMessage. Type: \".\\get_class($message));\n        }\n        $this->logger->debug('got a FollowMessage: {message}', [$message]);\n        $actor = $this->activityPubManager->findActorOrCreate($message->payload['actor']);\n        // Check if actor is not empty\n        if (!empty($actor)) {\n            if ('Follow' === $message->payload['type']) {\n                $object = $this->activityPubManager->findActorOrCreate($message->payload['object']);\n                // Check if object is not empty\n                if (!empty($object)) {\n                    if ($object instanceof Magazine and null === $object->apId and 'random' === $object->name) {\n                        $this->handleFollowRequest($message->payload, $object, isReject: true);\n                    } else {\n                        $this->handleFollow($object, $actor);\n\n                        // @todo manually Accept\n                        $this->handleFollowRequest($message->payload, $object);\n                    }\n                }\n\n                return;\n            }\n\n            if (isset($message->payload['object'])) {\n                switch ($message->payload['type']) {\n                    case 'Undo':\n                        $this->handleUnfollow(\n                            $actor,\n                            $this->activityPubManager->findActorOrCreate($message->payload['object']['object'])\n                        );\n                        break;\n                    case 'Accept':\n                        if ($actor instanceof User) {\n                            $this->handleAccept(\n                                $actor,\n                                $this->activityPubManager->findActorOrCreate($message->payload['object']['actor'])\n                            );\n                        }\n                        break;\n                    case 'Reject':\n                        $this->handleReject(\n                            $actor,\n                            $this->activityPubManager->findActorOrCreate($message->payload['object']['actor'])\n                        );\n                        break;\n                    default:\n                        break;\n                }\n            }\n        }\n    }\n\n    private function handleFollow(User|Magazine $object, User $actor): void\n    {\n        match (true) {\n            $object instanceof User => $this->userManager->follow($actor, $object),\n            $object instanceof Magazine => $this->magazineManager->subscribe($object, $actor),\n            default => throw new \\LogicException(),\n        };\n    }\n\n    private function handleFollowRequest(array $payload, User|Magazine $object, bool $isReject = false): void\n    {\n        $activity = $this->followResponseWrapper->build($object, $payload, $isReject);\n        $response = $this->activityJsonBuilder->buildActivityJson($activity);\n        $this->client->post($this->client->getInboxUrl($payload['actor']), $object, $response);\n    }\n\n    private function handleUnfollow(User $actor, User|Magazine|null $object): void\n    {\n        if (!empty($object)) {\n            match (true) {\n                $object instanceof User => $this->userManager->unfollow($actor, $object),\n                $object instanceof Magazine => $this->magazineManager->unsubscribe($object, $actor),\n                default => throw new \\LogicException(),\n            };\n        }\n    }\n\n    private function handleAccept(User $actor, User|Magazine|null $object): void\n    {\n        if (!empty($object)) {\n            if ($object instanceof User) {\n                $this->userManager->acceptFollow($object, $actor);\n            }\n\n            //        if ($object instanceof Magazine) {\n            //            $this->magazineManager->acceptFollow($actor, $object);\n            //        }\n        }\n    }\n\n    private function handleReject(User $actor, User|Magazine|null $object): void\n    {\n        if (!empty($object)) {\n            match (true) {\n                $object instanceof User => $this->userManager->rejectFollow($object, $actor),\n                $object instanceof Magazine => $this->magazineManager->unsubscribe($object, $actor),\n                default => throw new \\LogicException(),\n            };\n        }\n    }\n}\n"
  },
  {
    "path": "src/MessageHandler/ActivityPub/Inbox/LikeHandler.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\MessageHandler\\ActivityPub\\Inbox;\n\nuse App\\Entity\\Entry;\nuse App\\Entity\\EntryComment;\nuse App\\Entity\\Post;\nuse App\\Entity\\PostComment;\nuse App\\Message\\ActivityPub\\Inbox\\ChainActivityMessage;\nuse App\\Message\\ActivityPub\\Inbox\\LikeMessage;\nuse App\\Message\\ActivityPub\\Outbox\\AnnounceLikeMessage;\nuse App\\Message\\Contracts\\MessageInterface;\nuse App\\MessageHandler\\MbinMessageHandler;\nuse App\\Service\\ActivityPubManager;\nuse App\\Service\\FavouriteManager;\nuse App\\Service\\VoteManager;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Psr\\Log\\LoggerInterface;\nuse Symfony\\Component\\HttpKernel\\KernelInterface;\nuse Symfony\\Component\\Messenger\\Attribute\\AsMessageHandler;\nuse Symfony\\Component\\Messenger\\MessageBusInterface;\n\n#[AsMessageHandler]\nclass LikeHandler extends MbinMessageHandler\n{\n    public function __construct(\n        private readonly EntityManagerInterface $entityManager,\n        private readonly KernelInterface $kernel,\n        private readonly ActivityPubManager $activityPubManager,\n        private readonly VoteManager $voteManager,\n        private readonly MessageBusInterface $bus,\n        private readonly FavouriteManager $favouriteManager,\n        private readonly LoggerInterface $logger,\n    ) {\n        parent::__construct($this->entityManager, $this->kernel);\n    }\n\n    public function __invoke(LikeMessage $message): void\n    {\n        $this->workWrapper($message);\n    }\n\n    public function doWork(MessageInterface $message): void\n    {\n        if (!($message instanceof LikeMessage)) {\n            throw new \\LogicException(\"LikeHandler called, but is wasn\\'t a LikeMessage. Type: \".\\get_class($message));\n        }\n        if (!isset($message->payload['type'])) {\n            return;\n        }\n\n        $chainDispatchCallback = function (array $object, ?string $adjustedUrl) use ($message) {\n            if ($adjustedUrl) {\n                $this->logger->info('[LikeHandler::doWork] Got an adjusted url: {url}, using that instead of {old}', ['url' => $adjustedUrl, 'old' => $message->payload['object']['id'] ?? $message->payload['object']]);\n                $message->payload['object'] = $adjustedUrl;\n            }\n            $this->bus->dispatch(new ChainActivityMessage([$object], like: $message->payload));\n        };\n\n        if ('Like' === $message->payload['type']) {\n            $entity = $this->activityPubManager->getEntityObject($message->payload['object'], $message->payload, $chainDispatchCallback);\n            if (!$entity) {\n                return;\n            }\n\n            $actor = $this->activityPubManager->findActorOrCreate($message->payload['actor']);\n            // Check if actor and entity aren't empty\n            if (!empty($actor) && !empty($entity)) {\n                $this->favouriteManager->toggle($actor, $entity, FavouriteManager::TYPE_LIKE);\n            }\n        } elseif ('Undo' === $message->payload['type']) {\n            if ('Like' === $message->payload['object']['type']) {\n                $entity = $this->activityPubManager->getEntityObject($message->payload['object']['object'], $message->payload, $chainDispatchCallback);\n                if (!$entity) {\n                    return;\n                }\n\n                $actor = $this->activityPubManager->findActorOrCreate($message->payload['actor']);\n                // Check if actor and entity aren't empty\n                if (!empty($actor) && !empty($entity)) {\n                    $this->favouriteManager->toggle($actor, $entity, FavouriteManager::TYPE_UNLIKE);\n                    $this->voteManager->removeVote($entity, $actor);\n                }\n            }\n        }\n\n        if (isset($entity) and isset($actor) and ($entity instanceof Entry or $entity instanceof EntryComment or $entity instanceof Post or $entity instanceof PostComment)) {\n            if (!$entity->magazine->apId and $actor->apId and 'random' !== $entity->magazine->name) {\n                // local magazine, but remote user. Don't announce for random magazine\n                $this->bus->dispatch(new AnnounceLikeMessage($actor->getId(), $entity->getId(), \\get_class($entity), 'Undo' === $message->payload['type'], $message->payload['id']));\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "src/MessageHandler/ActivityPub/Inbox/LockHandler.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\MessageHandler\\ActivityPub\\Inbox;\n\nuse App\\Entity\\Entry;\nuse App\\Entity\\Post;\nuse App\\Message\\ActivityPub\\Inbox\\LockMessage;\nuse App\\Message\\ActivityPub\\Outbox\\GenericAnnounceMessage;\nuse App\\Message\\Contracts\\MessageInterface;\nuse App\\MessageHandler\\MbinMessageHandler;\nuse App\\Repository\\ActivityRepository;\nuse App\\Repository\\ApActivityRepository;\nuse App\\Service\\ActivityPubManager;\nuse App\\Service\\EntryManager;\nuse App\\Service\\PostManager;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Psr\\Log\\LoggerInterface;\nuse Symfony\\Component\\HttpKernel\\KernelInterface;\nuse Symfony\\Component\\Messenger\\Attribute\\AsMessageHandler;\nuse Symfony\\Component\\Messenger\\MessageBusInterface;\n\n#[AsMessageHandler]\nclass LockHandler extends MbinMessageHandler\n{\n    public function __construct(\n        private readonly MessageBusInterface $bus,\n        private readonly ActivityPubManager $activityPubManager,\n        private readonly KernelInterface $kernel,\n        private readonly ApActivityRepository $apActivityRepository,\n        private readonly EntityManagerInterface $entityManager,\n        private readonly EntryManager $entryManager,\n        private readonly PostManager $postManager,\n        private readonly ActivityRepository $activityRepository,\n        private readonly LoggerInterface $logger,\n    ) {\n        parent::__construct($this->entityManager, $this->kernel);\n    }\n\n    public function __invoke(LockMessage $message): void\n    {\n        $this->workWrapper($message);\n    }\n\n    public function doWork(MessageInterface $message): void\n    {\n        if (!($message instanceof LockMessage)) {\n            throw new \\LogicException();\n        }\n        $actor = $this->activityPubManager->findActorOrCreate($message->payload['actor']);\n\n        if (null === $actor) {\n            $this->logger->warning('[LockHandler::doWork] Could not find an actor for activity {id}. Supplied actor: \"{actor}\"', ['id' => $message->payload['id'], 'actor' => $message->payload['actor']]);\n\n            return;\n        }\n        $isUndo = 'Undo' === $message->payload['type'];\n        $payload = $message->payload;\n\n        if ($isUndo) {\n            $payload = $message->payload['object'];\n        }\n        $objectId = \\is_array($payload['object']) ? $payload['object']['id'] : $payload['object'];\n        $object = $this->apActivityRepository->findByObjectId($objectId);\n        if (null === $object) {\n            $this->logger->warning('[LockHandler::doWork] Could not find an object for activity \"{id}\". Supplied object: \"{object}\".', ['id' => $payload['id'], 'object' => $message->payload['object']]);\n\n            return;\n        }\n        $objectEntity = $this->entityManager->getRepository($object['type'])->find($object['id']);\n        if ($objectEntity instanceof Entry || $objectEntity instanceof Post) {\n            if ($objectEntity->magazine->userIsModerator($actor) || $actor->getId() === $objectEntity->user->getId() || $actor->apDomain === $objectEntity->user->apDomain || $actor->apDomain === $objectEntity->magazine->apDomain) {\n                // actor is magazine moderator or author or from the same instance as the author (so probably an instance admin)\n                // or from the same instance as the magazine (so probably an instance admin of the magazine)\n                if ($isUndo && $objectEntity->isLocked || !$isUndo && !$objectEntity->isLocked) {\n                    // if it is an undo it should not be locked and if it is not an undo it should be locked,\n                    // so under these 2 conditions we need to toggle the state\n                    if ($objectEntity instanceof Entry) {\n                        $this->entryManager->toggleLock($objectEntity, $actor);\n                    } else {\n                        $this->postManager->toggleLock($objectEntity, $actor);\n                    }\n                }\n                if (null === $objectEntity->magazine->apId && 'random' !== $objectEntity->magazine->name) {\n                    $lockActivity = $this->activityRepository->createForRemoteActivity($message->payload, $objectEntity);\n                    $lockActivity->setActor($actor);\n                    $this->bus->dispatch(new GenericAnnounceMessage($objectEntity->magazine->getId(), null, $actor->apInboxUrl, $lockActivity->uuid->toString(), null));\n                }\n            }\n        } else {\n            $this->logger->warning('[LockHandler::doWork] entity was not entry or post, but \"{type}\"', ['type' => \\get_class($objectEntity)]);\n        }\n    }\n}\n"
  },
  {
    "path": "src/MessageHandler/ActivityPub/Inbox/RemoveHandler.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\MessageHandler\\ActivityPub\\Inbox;\n\nuse App\\Entity\\Entry;\nuse App\\Entity\\Magazine;\nuse App\\Entity\\User;\nuse App\\Message\\ActivityPub\\Inbox\\RemoveMessage;\nuse App\\Message\\ActivityPub\\Outbox\\GenericAnnounceMessage;\nuse App\\Message\\Contracts\\MessageInterface;\nuse App\\MessageHandler\\MbinMessageHandler;\nuse App\\Repository\\ActivityRepository;\nuse App\\Repository\\ApActivityRepository;\nuse App\\Repository\\EntryRepository;\nuse App\\Repository\\MagazineRepository;\nuse App\\Service\\ActivityPubManager;\nuse App\\Service\\EntryManager;\nuse App\\Service\\MagazineManager;\nuse App\\Service\\SettingsManager;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Psr\\Log\\LoggerInterface;\nuse Symfony\\Component\\HttpKernel\\KernelInterface;\nuse Symfony\\Component\\Messenger\\Attribute\\AsMessageHandler;\nuse Symfony\\Component\\Messenger\\MessageBusInterface;\n\n#[AsMessageHandler]\nclass RemoveHandler extends MbinMessageHandler\n{\n    public function __construct(\n        private readonly EntityManagerInterface $entityManager,\n        private readonly KernelInterface $kernel,\n        private readonly ApActivityRepository $apActivityRepository,\n        private readonly ActivityPubManager $activityPubManager,\n        private readonly MagazineRepository $magazineRepository,\n        private readonly MagazineManager $magazineManager,\n        private readonly LoggerInterface $logger,\n        private readonly EntryRepository $entryRepository,\n        private readonly EntryManager $entryManager,\n        private readonly SettingsManager $settingsManager,\n        private readonly MessageBusInterface $bus,\n        private readonly ActivityRepository $activityRepository,\n    ) {\n        parent::__construct($this->entityManager, $this->kernel);\n    }\n\n    public function __invoke(RemoveMessage $message): void\n    {\n        $this->workWrapper($message);\n    }\n\n    public function doWork(MessageInterface $message): void\n    {\n        if (!($message instanceof RemoveMessage)) {\n            throw new \\LogicException(\"RemoveHandler called, but is wasn\\'t a RemoveMessage. Type: \".\\get_class($message));\n        }\n        $payload = $message->payload;\n        $actor = $this->activityPubManager->findUserActorOrCreateOrThrow($payload['actor']);\n        $targetMag = $this->magazineRepository->getMagazineFromModeratorsUrl($payload['target']);\n        if ($targetMag) {\n            $this->handleModeratorRemove($payload['object'], $targetMag, $actor, $payload);\n\n            return;\n        }\n        $targetMag = $this->magazineRepository->getMagazineFromPinnedUrl($payload['target']);\n        if ($targetMag) {\n            $this->handlePinnedRemove($payload['object'], $targetMag, $actor, $payload);\n\n            return;\n        }\n        throw new \\LogicException(\"could not find a magazine with moderators url like: '{$payload['target']}'\");\n    }\n\n    public function handleModeratorRemove($object1, Magazine $targetMag, Magazine|User $actor, array $messagePayload): void\n    {\n        if (!$targetMag->userIsModerator($actor) && !$targetMag->hasSameHostAsUser($actor)) {\n            throw new \\LogicException(\"the user '$actor->username' ({$actor->getId()}) is not a moderator of $targetMag->name ({$targetMag->getId()}) and is not from the same instance. He can therefore not remove moderators\");\n        }\n\n        $object = $this->activityPubManager->findUserActorOrCreateOrThrow($object1);\n        $objectMod = $targetMag->getUserAsModeratorOrNull($object);\n\n        $loggerParams = [\n            'toRemove' => $object->username,\n            'toRemoveId' => $object->getId(),\n            'magName' => $targetMag->name,\n            'magId' => $targetMag->getId(),\n        ];\n\n        if (null === $objectMod) {\n            $this->logger->warning('the user \"{toRemove}\" ({toRemoveId}) is not a moderator of {magName} ({magId}) and can therefore not be removed as one. Discarding message', $loggerParams);\n\n            return;\n        } elseif ($objectMod->isOwner) {\n            $this->logger->warning('the user \"{toRemove}\" ({toRemoveId}) is the owner of {magName} ({magId}) and can therefore not be removed. Discarding message', $loggerParams);\n\n            return;\n        }\n\n        $this->logger->info('[RemoveHandler::handleModeratorRemove] \"{actor}\" ({actorId}) removed \"{removed}\" ({removedId}) as moderator from \"{magName}\" ({magId})', [\n            'actor' => $actor->username,\n            'actorId' => $actor->getId(),\n            'removed' => $object->username,\n            'removedId' => $object->getId(),\n            'magName' => $targetMag->name,\n            'magId' => $targetMag->getId(),\n        ]);\n        $this->magazineManager->removeModerator($objectMod, $actor);\n\n        if (null === $targetMag->apId) {\n            $activityToAnnounce = $messagePayload;\n            unset($activityToAnnounce['@context']);\n            $activity = $this->activityRepository->createForRemoteActivity($activityToAnnounce);\n            $this->bus->dispatch(new GenericAnnounceMessage($targetMag->getId(), null, parse_url($actor->apDomain, PHP_URL_HOST), $activity->uuid->toString(), null));\n        }\n    }\n\n    private function handlePinnedRemove(mixed $object, Magazine $targetMag, User $actor, array $messagePayload): void\n    {\n        if (!$targetMag->userIsModerator($actor) && !$targetMag->hasSameHostAsUser($actor)) {\n            throw new \\LogicException(\"the user '$actor->username' ({$actor->getId()}) is not a moderator of $targetMag->name ({$targetMag->getId()}) and is not from the same instance. They can therefore not add pinned entries\");\n        }\n\n        $apId = null;\n        if (\\is_string($object)) {\n            $apId = $object;\n        } elseif (\\is_array($object)) {\n            $apId = $object['id'];\n        } else {\n            throw new \\LogicException('the added object is neither a string or an array');\n        }\n        if ($this->settingsManager->isLocalUrl($apId)) {\n            $pair = $this->apActivityRepository->findLocalByApId($apId);\n            if (Entry::class === $pair['type']) {\n                $existingEntry = $this->entryRepository->findOneBy(['id' => $pair['id']]);\n                if ($existingEntry && $existingEntry->sticky) {\n                    $this->logger->info('[RemoveHandler::handlePinnedRemove] Unpinning entry {e} to magazine {m}', ['e' => $existingEntry->title, 'm' => $existingEntry->magazine->name]);\n                    $this->entryManager->pin($existingEntry, $actor);\n                    if (null === $targetMag->apId) {\n                        $this->announceRemovePin($actor, $targetMag, $messagePayload);\n                    }\n                }\n            }\n        } else {\n            $existingEntry = $this->entryRepository->findOneBy(['apId' => $apId]);\n            if ($existingEntry && $existingEntry->sticky) {\n                $this->logger->info('[RemoveHandler::handlePinnedRemove] Unpinning entry {e} to magazine {m}', ['e' => $existingEntry->title, 'm' => $existingEntry->magazine->name]);\n                $this->entryManager->pin($existingEntry, $actor);\n                if (null === $targetMag->apId) {\n                    $this->announceRemovePin($actor, $targetMag, $messagePayload);\n                }\n            }\n        }\n    }\n\n    private function announceRemovePin(User $actor, Magazine $targetMag, array $object): void\n    {\n        $activity = $this->activityRepository->createForRemoteActivity($object);\n        $this->bus->dispatch(new GenericAnnounceMessage($targetMag->getId(), null, parse_url($actor->apDomain, PHP_URL_HOST), $activity->uuid->toString(), null));\n    }\n}\n"
  },
  {
    "path": "src/MessageHandler/ActivityPub/Inbox/UpdateHandler.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\MessageHandler\\ActivityPub\\Inbox;\n\nuse App\\DTO\\EntryCommentDto;\nuse App\\DTO\\EntryDto;\nuse App\\DTO\\PostCommentDto;\nuse App\\DTO\\PostDto;\nuse App\\Entity\\Entry;\nuse App\\Entity\\EntryComment;\nuse App\\Entity\\Magazine;\nuse App\\Entity\\Message;\nuse App\\Entity\\Post;\nuse App\\Entity\\PostComment;\nuse App\\Entity\\User;\nuse App\\Factory\\EntryCommentFactory;\nuse App\\Factory\\EntryFactory;\nuse App\\Factory\\ImageFactory;\nuse App\\Factory\\PostCommentFactory;\nuse App\\Factory\\PostFactory;\nuse App\\Message\\ActivityPub\\Inbox\\UpdateMessage;\nuse App\\Message\\ActivityPub\\Outbox\\GenericAnnounceMessage;\nuse App\\Message\\ActivityPub\\UpdateActorMessage;\nuse App\\Message\\Contracts\\MessageInterface;\nuse App\\MessageHandler\\MbinMessageHandler;\nuse App\\Repository\\ApActivityRepository;\nuse App\\Service\\ActivityPub\\ApObjectExtractor;\nuse App\\Service\\ActivityPubManager;\nuse App\\Service\\EntryCommentManager;\nuse App\\Service\\EntryManager;\nuse App\\Service\\MessageManager;\nuse App\\Service\\PostCommentManager;\nuse App\\Service\\PostManager;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Psr\\Log\\LoggerInterface;\nuse Symfony\\Component\\HttpKernel\\KernelInterface;\nuse Symfony\\Component\\Messenger\\Attribute\\AsMessageHandler;\nuse Symfony\\Component\\Messenger\\MessageBusInterface;\n\n#[AsMessageHandler]\nclass UpdateHandler extends MbinMessageHandler\n{\n    public function __construct(\n        private readonly ActivityPubManager $activityPubManager,\n        private readonly ApActivityRepository $apActivityRepository,\n        private readonly KernelInterface $kernel,\n        private readonly EntityManagerInterface $entityManager,\n        private readonly EntryManager $entryManager,\n        private readonly EntryCommentManager $entryCommentManager,\n        private readonly PostManager $postManager,\n        private readonly PostCommentManager $postCommentManager,\n        private readonly EntryFactory $entryFactory,\n        private readonly EntryCommentFactory $entryCommentFactory,\n        private readonly PostFactory $postFactory,\n        private readonly PostCommentFactory $postCommentFactory,\n        private readonly ApObjectExtractor $objectExtractor,\n        private readonly MessageManager $messageManager,\n        private readonly LoggerInterface $logger,\n        private readonly MessageBusInterface $bus,\n        private readonly ImageFactory $imageFactory,\n    ) {\n        parent::__construct($this->entityManager, $this->kernel);\n    }\n\n    public function __invoke(UpdateMessage $message): void\n    {\n        $this->workWrapper($message);\n    }\n\n    public function doWork(MessageInterface $message): void\n    {\n        if (!($message instanceof UpdateMessage)) {\n            throw new \\LogicException(\"UpdateHandler called, but is wasn\\'t an UpdateMessage. Type: \".\\get_class($message));\n        }\n        $payload = $message->payload;\n        $this->logger->debug('[UpdateHandler::doWork] received Update activity: {json}', ['json' => $payload]);\n\n        try {\n            $actor = $this->activityPubManager->findRemoteActor($payload['actor']);\n        } catch (\\Exception) {\n            return;\n        }\n\n        $object = $this->apActivityRepository->findByObjectId($payload['object']['id']);\n\n        if ($object) {\n            $this->editActivity($object, $actor, $payload);\n\n            return;\n        }\n\n        $object = $this->activityPubManager->findActorOrCreate($payload['object']['id']);\n        if ($object instanceof User) {\n            $this->updateUser($object, $actor);\n\n            return;\n        }\n\n        if ($object instanceof Magazine) {\n            $this->updateMagazine($object, $actor, $payload);\n\n            return;\n        }\n\n        throw new \\LogicException('Don\\'t know what to do with the update activity concerning \\''.$payload['object']['id'].'\\'. We didn\\'t have a local object that has this id.');\n    }\n\n    private function editActivity(array $object, User $actor, array $payload): void\n    {\n        $object = $this->entityManager->getRepository($object['type'])->find((int) $object['id']);\n\n        if ($object instanceof Entry) {\n            $this->editEntry($object, $actor, $payload);\n            if (null === $object->magazine->apId) {\n                $this->bus->dispatch(new GenericAnnounceMessage($object->magazine->getId(), null, $actor->apInboxUrl, null, $payload['id']));\n            }\n        } elseif ($object instanceof EntryComment) {\n            $this->editEntryComment($object, $actor, $payload);\n            if (null === $object->magazine->apId) {\n                $this->bus->dispatch(new GenericAnnounceMessage($object->magazine->getId(), null, $actor->apInboxUrl, null, $payload['id']));\n            }\n        } elseif ($object instanceof Post) {\n            $this->editPost($object, $actor, $payload);\n            if (null === $object->magazine->apId) {\n                $this->bus->dispatch(new GenericAnnounceMessage($object->magazine->getId(), null, $actor->apInboxUrl, null, $payload['id']));\n            }\n        } elseif ($object instanceof PostComment) {\n            $this->editPostComment($object, $actor, $payload);\n            if (null === $object->magazine->apId) {\n                $this->bus->dispatch(new GenericAnnounceMessage($object->magazine->getId(), null, $actor->apInboxUrl, null, $payload['id']));\n            }\n        } elseif ($object instanceof Message) {\n            $this->editMessage($object, $actor, $payload);\n        }\n    }\n\n    private function editEntry(Entry $entry, User $user, array $payload): void\n    {\n        if (!$this->entryManager->canUserEditEntry($entry, $user)) {\n            $this->logger->warning('[UpdateHandler::editEntry] User {u} tried to edit entry {et} ({eId}), but is not allowed to', ['u' => $user->apId ?? $user->username, 'et' => $entry->title, 'eId' => $entry->getId()]);\n\n            return;\n        }\n        $dto = $this->entryFactory->createDto($entry);\n\n        $dto->title = $payload['object']['name'];\n\n        $this->extractChanges($dto, $payload);\n        $this->entryManager->edit($entry, $dto, $user);\n    }\n\n    private function editEntryComment(EntryComment $comment, User $user, array $payload): void\n    {\n        if (!$this->entryCommentManager->canUserEditComment($comment, $user)) {\n            $this->logger->warning('[UpdateHandler::editEntryComment] User {u} tried to edit entry comment {et} ({eId}), but is not allowed to', ['u' => $user->apId ?? $user->username, 'et' => $comment->getShortTitle(), 'eId' => $comment->getId()]);\n\n            return;\n        }\n        $dto = $this->entryCommentFactory->createDto($comment);\n\n        $this->extractChanges($dto, $payload);\n\n        $this->entryCommentManager->edit($comment, $dto, $user);\n    }\n\n    private function editPost(Post $post, User $user, array $payload): void\n    {\n        if (!$this->postManager->canUserEditPost($post, $user)) {\n            $this->logger->warning('[UpdateHandler::editPost] User {u} tried to edit post {pt} ({pId}), but is not allowed to', ['u' => $user->apId ?? $user->username, 'pt' => $post->getShortTitle(), 'pId' => $post->getId()]);\n\n            return;\n        }\n        $dto = $this->postFactory->createDto($post);\n\n        $this->extractChanges($dto, $payload);\n\n        $this->postManager->edit($post, $dto, $user);\n    }\n\n    private function editPostComment(PostComment $comment, User $user, array $payload): void\n    {\n        if (!$this->postCommentManager->canUserEditPostComment($comment, $user)) {\n            $this->logger->warning('[UpdateHandler::editPostComment] User {u} tried to edit post comment {pt} ({pId}), but is not allowed to', ['u' => $user->apId ?? $user->username, 'pt' => $comment->getShortTitle(), 'pId' => $comment->getId()]);\n\n            return;\n        }\n        $dto = $this->postCommentFactory->createDto($comment);\n\n        $this->extractChanges($dto, $payload);\n\n        $this->postCommentManager->edit($comment, $dto, $user);\n    }\n\n    private function extractChanges(EntryDto|EntryCommentDto|PostDto|PostCommentDto $dto, array $payload): void\n    {\n        $this->logger->debug('[UpdateHandler::extractChanges] extracting changes from {c}', ['c' => \\get_class($dto)]);\n        if (!empty($payload['object']['content'])) {\n            $dto->body = $this->objectExtractor->getMarkdownBody($payload['object']);\n        } else {\n            $dto->body = null;\n        }\n        if (!empty($payload['object']['attachment'])) {\n            $this->logger->debug('[UpdateHandler::extractChanges] was not empty :)');\n            $image = $this->activityPubManager->handleImages($payload['object']['attachment']);\n            if (null !== $image) {\n                $dto->image = $this->imageFactory->createDto($image);\n            }\n            if ($dto instanceof EntryDto) {\n                $url = ActivityPubManager::extractUrlFromAttachment($payload['object']['attachment']);\n                $dto->url = $url;\n                $this->logger->debug('[UpdateHandler::extractChanges] setting url to {u} which was extracted from the attachment array', ['u' => $url]);\n            }\n        }\n        $dto->apLikeCount = $this->activityPubManager->extractRemoteLikeCount($payload['object']);\n        $dto->apDislikeCount = $this->activityPubManager->extractRemoteDislikeCount($payload['object']);\n        $dto->apShareCount = $this->activityPubManager->extractRemoteShareCount($payload['object']);\n\n        if (isset($payload['object']['commentsEnabled']) && \\is_bool($payload['object']['commentsEnabled']) && ($dto instanceof EntryDto || $dto instanceof PostDto)) {\n            $dto->isLocked = !$payload['object']['commentsEnabled'];\n        }\n    }\n\n    private function editMessage(Message $message, User $user, array $payload): void\n    {\n        if ($this->messageManager->canUserEditMessage($message, $user)) {\n            $this->messageManager->editMessage($message, $payload['object']);\n        } else {\n            $this->logger->warning(\n                '[UpdateHandler::editMessage] Got an update message from a user that is not allowed to edit it. Update actor: {ua}. Original Author: {oa}',\n                ['ua' => $user->apId ?? $user->username, 'oa' => $message->sender->apId ?? $message->sender->username]\n            );\n        }\n    }\n\n    private function updateUser(User $user, User $actor): void\n    {\n        if ($user->canUpdateUser($actor)) {\n            if (null !== $user->apId) {\n                $this->bus->dispatch(new UpdateActorMessage($user->apProfileId, force: true));\n            }\n        } else {\n            $this->logger->warning('[UpdateHandler::updateUser] User {u1} wanted to update user {u2} without being allowed to do so', ['u1' => $actor->apId ?? $actor->username, 'u2' => $user->apId ?? $user->username]);\n        }\n    }\n\n    private function updateMagazine(Magazine $magazine, User $actor, array $payload): void\n    {\n        if ($magazine->canUpdateMagazine($actor)) {\n            if (null !== $magazine->apId) {\n                $this->bus->dispatch(new UpdateActorMessage($magazine->apProfileId, force: true));\n            }\n        } else {\n            $this->logger->warning('[UpdateHandler::updateMagazine] User {u} wanted to update magazine {m} without being allowed to do so', ['u' => $actor->apId ?? $actor->username, 'm' => $magazine->apId ?? $magazine->name]);\n        }\n    }\n}\n"
  },
  {
    "path": "src/MessageHandler/ActivityPub/Outbox/AddHandler.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\MessageHandler\\ActivityPub\\Outbox;\n\nuse App\\Factory\\ActivityPub\\AddRemoveFactory;\nuse App\\Message\\ActivityPub\\Outbox\\AddMessage;\nuse App\\Message\\Contracts\\MessageInterface;\nuse App\\MessageHandler\\MbinMessageHandler;\nuse App\\Repository\\MagazineRepository;\nuse App\\Repository\\UserRepository;\nuse App\\Service\\ActivityPub\\ActivityJsonBuilder;\nuse App\\Service\\DeliverManager;\nuse App\\Service\\SettingsManager;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Symfony\\Component\\HttpKernel\\KernelInterface;\nuse Symfony\\Component\\Messenger\\Attribute\\AsMessageHandler;\n\n#[AsMessageHandler]\nclass AddHandler extends MbinMessageHandler\n{\n    public function __construct(\n        private readonly EntityManagerInterface $entityManager,\n        private readonly KernelInterface $kernel,\n        private readonly UserRepository $userRepository,\n        private readonly MagazineRepository $magazineRepository,\n        private readonly SettingsManager $settingsManager,\n        private readonly AddRemoveFactory $factory,\n        private readonly DeliverManager $deliverManager,\n        private readonly ActivityJsonBuilder $activityJsonBuilder,\n    ) {\n        parent::__construct($this->entityManager, $this->kernel);\n    }\n\n    public function __invoke(AddMessage $message): void\n    {\n        if (!$this->settingsManager->get('KBIN_FEDERATION_ENABLED')) {\n            return;\n        }\n        $this->workWrapper($message);\n    }\n\n    public function doWork(MessageInterface $message): void\n    {\n        if (!($message instanceof AddMessage)) {\n            throw new \\LogicException();\n        }\n\n        $actor = $this->userRepository->find($message->userActorId);\n        $added = $this->userRepository->find($message->addedUserId);\n        $magazine = $this->magazineRepository->find($message->magazineId);\n        if ($magazine->apId) {\n            $audience = [$magazine->apInboxUrl];\n        } else {\n            if ('random' === $magazine->name) {\n                // do not federate the random magazine\n                return;\n            }\n            $audience = $this->magazineRepository->findAudience($magazine);\n        }\n\n        $activity = $this->factory->buildAddModerator($actor, $added, $magazine);\n        $json = $this->activityJsonBuilder->buildActivityJson($activity);\n        $this->deliverManager->deliver($audience, $json);\n    }\n}\n"
  },
  {
    "path": "src/MessageHandler/ActivityPub/Outbox/AnnounceHandler.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\MessageHandler\\ActivityPub\\Outbox;\n\nuse App\\Entity\\Entry;\nuse App\\Entity\\EntryComment;\nuse App\\Entity\\Magazine;\nuse App\\Entity\\Post;\nuse App\\Entity\\PostComment;\nuse App\\Entity\\User;\nuse App\\Message\\ActivityPub\\Outbox\\AnnounceMessage;\nuse App\\Message\\Contracts\\MessageInterface;\nuse App\\MessageHandler\\MbinMessageHandler;\nuse App\\Repository\\ActivityRepository;\nuse App\\Repository\\MagazineRepository;\nuse App\\Repository\\UserRepository;\nuse App\\Service\\ActivityPub\\ActivityJsonBuilder;\nuse App\\Service\\ActivityPub\\Wrapper\\AnnounceWrapper;\nuse App\\Service\\ActivityPub\\Wrapper\\CreateWrapper;\nuse App\\Service\\ActivityPub\\Wrapper\\UndoWrapper;\nuse App\\Service\\ActivityPubManager;\nuse App\\Service\\DeliverManager;\nuse App\\Service\\SettingsManager;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Psr\\Log\\LoggerInterface;\nuse Symfony\\Component\\HttpKernel\\KernelInterface;\nuse Symfony\\Component\\Messenger\\Attribute\\AsMessageHandler;\nuse Symfony\\Component\\Messenger\\Exception\\UnrecoverableMessageHandlingException;\n\n#[AsMessageHandler]\nclass AnnounceHandler extends MbinMessageHandler\n{\n    public function __construct(\n        private readonly UserRepository $userRepository,\n        private readonly KernelInterface $kernel,\n        private readonly MagazineRepository $magazineRepository,\n        private readonly EntityManagerInterface $entityManager,\n        private readonly AnnounceWrapper $announceWrapper,\n        private readonly UndoWrapper $undoWrapper,\n        private readonly CreateWrapper $createWrapper,\n        private readonly ActivityPubManager $activityPubManager,\n        private readonly DeliverManager $deliverManager,\n        private readonly SettingsManager $settingsManager,\n        private readonly ActivityJsonBuilder $activityJsonBuilder,\n        private readonly ActivityRepository $activityRepository,\n        private readonly LoggerInterface $logger,\n    ) {\n        parent::__construct($this->entityManager, $this->kernel);\n    }\n\n    public function __invoke(AnnounceMessage $message): void\n    {\n        if (!$this->settingsManager->get('KBIN_FEDERATION_ENABLED')) {\n            return;\n        }\n        $this->workWrapper($message);\n    }\n\n    public function doWork(MessageInterface $message): void\n    {\n        if (!($message instanceof AnnounceMessage)) {\n            throw new \\LogicException();\n        }\n\n        if (null !== $message->userId) {\n            $actor = $this->userRepository->find($message->userId);\n        } elseif (null !== $message->magazineId) {\n            $actor = $this->magazineRepository->find($message->magazineId);\n        } else {\n            throw new UnrecoverableMessageHandlingException('no actor was specified');\n        }\n\n        $object = $this->entityManager->getRepository($message->objectType)->find($message->objectId);\n\n        if ($actor instanceof Magazine && ($object instanceof Entry || $object instanceof Post || $object instanceof EntryComment || $object instanceof PostComment)) {\n            $createActivity = $this->activityRepository->findFirstActivitiesByTypeAndObject('Create', $object);\n            if (null === $createActivity) {\n                if (null === $object->apId) {\n                    $createActivity = $this->createWrapper->build($object);\n                } else {\n                    throw new UnrecoverableMessageHandlingException(\"We need a create activity to announce objects, but none was found and the object (id: '$object->apId' is from a remote instance, so we cannot build a create activity\");\n                }\n            }\n            $alreadySentActivities = $this->activityRepository->findAllActivitiesByTypeObjectAndActor('Announce', $object, $actor);\n            if (!$message->removeAnnounce && \\sizeof($alreadySentActivities) > 0) {\n                $this->logger->info('[AnnounceHandler::doWork] not sending announcing {object}, because it is not an Undo and the same actor (magazine {magazine}) already announced the Create activity {create}', [\n                    'object' => \\get_class($object).\" \\\"{$object->getShortTitle()}\\\"\",\n                    'magazine' => $actor->name,\n                    'create' => $createActivity->uuid,\n                ]);\n\n                return;\n            }\n            $activity = $this->announceWrapper->build($actor, $createActivity, true);\n        } else {\n            $activity = $this->announceWrapper->build($actor, $object, true);\n        }\n\n        if ($message->removeAnnounce) {\n            $activity = $this->undoWrapper->build($activity);\n        }\n\n        $inboxes = array_merge(\n            $this->magazineRepository->findAudience($object->magazine),\n            [$object->user->apInboxUrl, $object->magazine->apId ? $object->magazine->apInboxUrl : null]\n        );\n\n        $json = $this->activityJsonBuilder->buildActivityJson($activity);\n\n        if ($actor instanceof User) {\n            $inboxes = array_merge(\n                $inboxes,\n                $this->userRepository->findAudience($actor),\n                $this->activityPubManager->createInboxesFromCC($json, $actor),\n            );\n        } elseif ($actor instanceof Magazine) {\n            if ('random' === $actor->name) {\n                // do not federate the random magazine\n                return;\n            }\n            $createHost = parse_url($object->apId, PHP_URL_HOST);\n            $inboxes = array_filter(array_merge(\n                $inboxes,\n                $this->magazineRepository->findAudience($actor),\n            ), fn ($item) => null !== $item and $createHost !== parse_url($item, PHP_URL_HOST));\n        }\n\n        $inboxes = array_filter(array_unique($inboxes));\n        $this->deliverManager->deliver($inboxes, $json);\n    }\n}\n"
  },
  {
    "path": "src/MessageHandler/ActivityPub/Outbox/AnnounceLikeHandler.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\MessageHandler\\ActivityPub\\Outbox;\n\nuse App\\Entity\\Entry;\nuse App\\Entity\\EntryComment;\nuse App\\Entity\\Post;\nuse App\\Entity\\PostComment;\nuse App\\Message\\ActivityPub\\Outbox\\AnnounceLikeMessage;\nuse App\\Message\\Contracts\\MessageInterface;\nuse App\\MessageHandler\\MbinMessageHandler;\nuse App\\Repository\\MagazineRepository;\nuse App\\Repository\\UserRepository;\nuse App\\Service\\ActivityPub\\ActivityJsonBuilder;\nuse App\\Service\\ActivityPub\\Wrapper\\AnnounceWrapper;\nuse App\\Service\\DeliverManager;\nuse App\\Service\\SettingsManager;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Psr\\Log\\LoggerInterface;\nuse Symfony\\Component\\HttpKernel\\KernelInterface;\nuse Symfony\\Component\\Messenger\\Attribute\\AsMessageHandler;\n\n#[AsMessageHandler]\nclass AnnounceLikeHandler extends MbinMessageHandler\n{\n    public function __construct(\n        private readonly UserRepository $userRepository,\n        private readonly MagazineRepository $magazineRepository,\n        private readonly KernelInterface $kernel,\n        private readonly EntityManagerInterface $entityManager,\n        private readonly AnnounceWrapper $announceWrapper,\n        private readonly SettingsManager $settingsManager,\n        private readonly DeliverManager $deliverManager,\n        private readonly ActivityJsonBuilder $activityJsonBuilder,\n        private readonly LoggerInterface $logger,\n    ) {\n        parent::__construct($this->entityManager, $this->kernel);\n    }\n\n    public function __invoke(AnnounceLikeMessage $message): void\n    {\n        if (!$this->settingsManager->get('KBIN_FEDERATION_ENABLED')) {\n            return;\n        }\n        $this->workWrapper($message);\n    }\n\n    public function doWork(MessageInterface $message): void\n    {\n        if (!($message instanceof AnnounceLikeMessage)) {\n            throw new \\LogicException();\n        }\n\n        $user = $this->userRepository->find($message->userId);\n        /** @var Entry|EntryComment|Post|PostComment $object */\n        $object = $this->entityManager->getRepository($message->objectType)->find($message->objectId);\n\n        // blacklist remote magazines\n        if (null !== $object->magazine->apId) {\n            return;\n        }\n\n        // blacklist the random magazine\n        if ('random' === $object->magazine->name) {\n            return;\n        }\n\n        if (null === $message->likeMessageId) {\n            $this->logger->warning('Got an AnnounceLikeMessage without a remote like id, probably an old message though');\n\n            return;\n        }\n        if (false === filter_var($message->likeMessageId, FILTER_VALIDATE_URL)) {\n            $this->logger->warning('Got an AnnounceLikeMessage without a valid remote like id: {url}', ['url' => $message->likeMessageId]);\n\n            return;\n        }\n\n        $this->logger->debug('got AnnounceLikeMessage: {m}', ['m' => json_encode($message)]);\n        $this->logger->debug('building like activity for: {a}', ['a' => json_encode($object)]);\n\n        if (!$message->undo) {\n            $likeActivity = $message->likeMessageId;\n        } else {\n            // we no longer have to wrap anything, as the incoming message will already be the undo one\n            $likeActivity = $message->likeMessageId;\n        }\n\n        $activity = $this->announceWrapper->build($object->magazine, $likeActivity);\n        $json = $this->activityJsonBuilder->buildActivityJson($activity);\n\n        // send the announcement only to the subscribers of the magazine\n        $inboxes = array_filter(\n            $this->magazineRepository->findAudience($object->magazine)\n        );\n        $this->deliverManager->deliver($inboxes, $json);\n    }\n}\n"
  },
  {
    "path": "src/MessageHandler/ActivityPub/Outbox/BlockHandler.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\MessageHandler\\ActivityPub\\Outbox;\n\nuse App\\Entity\\MagazineBan;\nuse App\\Entity\\User;\nuse App\\Factory\\ActivityPub\\BlockFactory;\nuse App\\Message\\ActivityPub\\Outbox\\BlockMessage;\nuse App\\Message\\Contracts\\MessageInterface;\nuse App\\MessageHandler\\MbinMessageHandler;\nuse App\\Repository\\ActivityRepository;\nuse App\\Repository\\MagazineBanRepository;\nuse App\\Repository\\MagazineRepository;\nuse App\\Repository\\UserRepository;\nuse App\\Service\\ActivityPub\\ActivityJsonBuilder;\nuse App\\Service\\ActivityPub\\Wrapper\\UndoWrapper;\nuse App\\Service\\DeliverManager;\nuse App\\Service\\UserManager;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Symfony\\Component\\HttpKernel\\KernelInterface;\nuse Symfony\\Component\\Messenger\\Attribute\\AsMessageHandler;\nuse Symfony\\Component\\Messenger\\Exception\\UnrecoverableMessageHandlingException;\n\n#[AsMessageHandler]\nclass BlockHandler extends MbinMessageHandler\n{\n    public function __construct(\n        EntityManagerInterface $entityManager,\n        KernelInterface $kernel,\n        private readonly MagazineBanRepository $magazineBanRepository,\n        private readonly BlockFactory $blockFactory,\n        private readonly UndoWrapper $undoWrapper,\n        private readonly ActivityJsonBuilder $activityJsonBuilder,\n        private readonly DeliverManager $deliverManager,\n        private readonly MagazineRepository $magazineRepository,\n        private readonly UserRepository $userRepository,\n        private readonly ActivityRepository $activityRepository,\n        private readonly UserManager $userManager,\n    ) {\n        parent::__construct($entityManager, $kernel);\n    }\n\n    public function __invoke(BlockMessage $message): void\n    {\n        $this->workWrapper($message);\n    }\n\n    public function doWork(MessageInterface $message): void\n    {\n        if (!$message instanceof BlockMessage) {\n            throw new \\LogicException();\n        }\n\n        if (null !== $message->magazineBanId) {\n            $ban = $this->magazineBanRepository->find($message->magazineBanId);\n            if (null === $ban) {\n                throw new UnrecoverableMessageHandlingException(\"there is no ban with id $message->magazineBanId\");\n            }\n            $this->handleMagazineBan($ban);\n        } elseif (null !== $message->bannedUserId) {\n            $bannedUser = $this->userRepository->find($message->bannedUserId);\n            $actor = $this->userRepository->find($message->actor);\n            if (null === $bannedUser) {\n                throw new UnrecoverableMessageHandlingException(\"there is no user with id $message->bannedUserId\");\n            }\n            if (null === $actor) {\n                throw new UnrecoverableMessageHandlingException(\"there is no user with id $message->actor\");\n            }\n            $this->handleUserBan($bannedUser, $actor);\n        } else {\n            throw new UnrecoverableMessageHandlingException('nothing to do. `magazineBanId` and `bannedUserId` are both null');\n        }\n    }\n\n    private function handleMagazineBan(MagazineBan $ban): void\n    {\n        $isUndo = null !== $ban->expiredAt && $ban->expiredAt < new \\DateTime();\n\n        $actor = $ban->bannedBy;\n        if (null === $actor) {\n            throw new UnrecoverableMessageHandlingException('An actor is needed to ban a user');\n        } elseif (null !== $actor->apId) {\n            throw new UnrecoverableMessageHandlingException(\"$actor->username is not a local user\");\n        }\n\n        if ($isUndo) {\n            $activity = $this->activityRepository->findFirstActivitiesByTypeAndObject('Block', $ban) ?? $this->blockFactory->createActivityFromMagazineBan($ban);\n            $activity = $this->undoWrapper->build($activity);\n        } else {\n            $activity = $this->blockFactory->createActivityFromMagazineBan($ban);\n        }\n        $json = $this->activityJsonBuilder->buildActivityJson($activity);\n\n        $this->deliverManager->deliver($this->magazineRepository->findAudience($ban->magazine), $json);\n    }\n\n    private function handleUserBan(User $bannedUser, User $actor): void\n    {\n        $isUndo = !$bannedUser->isBanned;\n\n        if ($isUndo) {\n            $activity = $this->activityRepository->findFirstActivitiesByTypeAndObject('Block', $bannedUser) ?? $this->blockFactory->createActivityFromInstanceBan($bannedUser, $actor);\n            $activity = $this->undoWrapper->build($activity);\n        } else {\n            $activity = $this->blockFactory->createActivityFromInstanceBan($bannedUser, $actor);\n        }\n        $json = $this->activityJsonBuilder->buildActivityJson($activity);\n        $inboxes = $this->userManager->getAllInboxesOfInteractions($bannedUser);\n\n        $this->deliverManager->deliver($inboxes, $json);\n    }\n}\n"
  },
  {
    "path": "src/MessageHandler/ActivityPub/Outbox/CreateHandler.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\MessageHandler\\ActivityPub\\Outbox;\n\nuse App\\Entity\\Message;\nuse App\\Message\\ActivityPub\\Outbox\\CreateMessage;\nuse App\\Message\\Contracts\\MessageInterface;\nuse App\\MessageHandler\\MbinMessageHandler;\nuse App\\Repository\\MagazineRepository;\nuse App\\Repository\\UserRepository;\nuse App\\Service\\ActivityPub\\ActivityJsonBuilder;\nuse App\\Service\\ActivityPub\\Wrapper\\CreateWrapper;\nuse App\\Service\\ActivityPubManager;\nuse App\\Service\\DeliverManager;\nuse App\\Service\\MessageManager;\nuse App\\Service\\SettingsManager;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Psr\\Log\\LoggerInterface;\nuse Symfony\\Component\\HttpKernel\\KernelInterface;\nuse Symfony\\Component\\Messenger\\Attribute\\AsMessageHandler;\n\n#[AsMessageHandler]\nclass CreateHandler extends MbinMessageHandler\n{\n    public function __construct(\n        private readonly UserRepository $userRepository,\n        private readonly MagazineRepository $magazineRepository,\n        private readonly CreateWrapper $createWrapper,\n        private readonly EntityManagerInterface $entityManager,\n        private readonly ActivityPubManager $activityPubManager,\n        private readonly SettingsManager $settingsManager,\n        private readonly MessageManager $messageManager,\n        private readonly LoggerInterface $logger,\n        private readonly DeliverManager $deliverManager,\n        private readonly KernelInterface $kernel,\n        private readonly ActivityJsonBuilder $activityJsonBuilder,\n    ) {\n        parent::__construct($this->entityManager, $this->kernel);\n    }\n\n    public function __invoke(CreateMessage $message): void\n    {\n        if (!$this->settingsManager->get('KBIN_FEDERATION_ENABLED')) {\n            return;\n        }\n        $this->workWrapper($message);\n    }\n\n    public function doWork(MessageInterface $message): void\n    {\n        if (!($message instanceof CreateMessage)) {\n            throw new \\LogicException();\n        }\n\n        $entity = $this->entityManager->getRepository($message->type)->find($message->id);\n\n        $activity = $this->createWrapper->build($entity);\n        $json = $this->activityJsonBuilder->buildActivityJson($activity);\n\n        if ($entity instanceof Message) {\n            $receivers = $this->messageManager->findAudience($entity->thread);\n            $this->logger->info('[CreateHandler::doWork] sending message to {p}', ['p' => $receivers]);\n        } else {\n            $receivers = [\n                ...$this->userRepository->findAudience($entity->user),\n                ...$this->activityPubManager->createInboxesFromCC($json, $entity->user),\n            ];\n            if ('random' !== $entity->magazine->name) {\n                // only add the magazine subscribers if it is not the random magazine\n                $receivers = array_merge($receivers, $this->magazineRepository->findAudience($entity->magazine));\n            }\n            $this->logger->debug('[CreateHandler::doWork] Sending create activity to {p}', ['p' => $receivers]);\n        }\n        $this->deliverManager->deliver(array_filter(array_unique($receivers)), $json);\n    }\n}\n"
  },
  {
    "path": "src/MessageHandler/ActivityPub/Outbox/DeleteHandler.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\MessageHandler\\ActivityPub\\Outbox;\n\nuse App\\Message\\ActivityPub\\Outbox\\DeleteMessage;\nuse App\\Message\\Contracts\\MessageInterface;\nuse App\\MessageHandler\\MbinMessageHandler;\nuse App\\Repository\\MagazineRepository;\nuse App\\Repository\\UserRepository;\nuse App\\Service\\ActivityPubManager;\nuse App\\Service\\DeliverManager;\nuse App\\Service\\SettingsManager;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Symfony\\Component\\HttpKernel\\KernelInterface;\nuse Symfony\\Component\\Messenger\\Attribute\\AsMessageHandler;\n\n#[AsMessageHandler]\nclass DeleteHandler extends MbinMessageHandler\n{\n    public function __construct(\n        private readonly EntityManagerInterface $entityManager,\n        private readonly KernelInterface $kernel,\n        private readonly UserRepository $userRepository,\n        private readonly MagazineRepository $magazineRepository,\n        private readonly ActivityPubManager $activityPubManager,\n        private readonly SettingsManager $settingsManager,\n        private readonly DeliverManager $deliverManager,\n    ) {\n        parent::__construct($this->entityManager, $this->kernel);\n    }\n\n    public function __invoke(DeleteMessage $message): void\n    {\n        if (!$this->settingsManager->get('KBIN_FEDERATION_ENABLED')) {\n            return;\n        }\n        $this->workWrapper($message);\n    }\n\n    public function doWork(MessageInterface $message): void\n    {\n        if (!($message instanceof DeleteMessage)) {\n            throw new \\LogicException();\n        }\n\n        $user = $this->userRepository->find($message->userId);\n        $magazine = $this->magazineRepository->find($message->magazineId);\n\n        $inboxes = array_filter(array_unique(array_merge(\n            $this->userRepository->findAudience($user),\n            $this->activityPubManager->createInboxesFromCC($message->payload, $user),\n        )));\n\n        if ('random' !== $magazine->name) {\n            // only add the magazine subscribers if it is not the random magazine\n            $inboxes = array_filter(array_unique(array_merge(\n                $inboxes,\n                $this->magazineRepository->findAudience($magazine),\n            )));\n        }\n\n        $this->deliverManager->deliver($inboxes, $message->payload);\n    }\n}\n"
  },
  {
    "path": "src/MessageHandler/ActivityPub/Outbox/DeliverHandler.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\MessageHandler\\ActivityPub\\Outbox;\n\nuse App\\Entity\\User;\nuse App\\Exception\\InstanceBannedException;\nuse App\\Exception\\InvalidApPostException;\nuse App\\Exception\\InvalidWebfingerException;\nuse App\\Message\\ActivityPub\\Outbox\\DeliverMessage;\nuse App\\Message\\Contracts\\MessageInterface;\nuse App\\MessageHandler\\MbinMessageHandler;\nuse App\\Repository\\InstanceRepository;\nuse App\\Service\\ActivityPub\\ApHttpClientInterface;\nuse App\\Service\\ActivityPubManager;\nuse App\\Service\\SettingsManager;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Psr\\Cache\\InvalidArgumentException;\nuse Psr\\Log\\LoggerInterface;\nuse Symfony\\Component\\HttpKernel\\KernelInterface;\nuse Symfony\\Component\\Messenger\\Attribute\\AsMessageHandler;\nuse Symfony\\Component\\Messenger\\Exception\\RecoverableMessageHandlingException;\nuse Symfony\\Component\\Messenger\\Exception\\UnrecoverableMessageHandlingException;\nuse Symfony\\Contracts\\HttpClient\\Exception\\TransportExceptionInterface;\n\n#[AsMessageHandler]\nclass DeliverHandler extends MbinMessageHandler\n{\n    public const HTTP_RESPONSE_CODE_RATE_LIMITED = 429;\n\n    public function __construct(\n        private readonly EntityManagerInterface $entityManager,\n        private readonly KernelInterface $kernel,\n        private readonly ApHttpClientInterface $client,\n        private readonly ActivityPubManager $activityPubManager,\n        private readonly SettingsManager $settingsManager,\n        private readonly LoggerInterface $logger,\n        private readonly InstanceRepository $instanceRepository,\n    ) {\n        parent::__construct($this->entityManager, $this->kernel);\n    }\n\n    /**\n     * @throws InvalidApPostException\n     */\n    public function __invoke(DeliverMessage $message): void\n    {\n        if (!$this->settingsManager->get('KBIN_FEDERATION_ENABLED')) {\n            return;\n        }\n        $this->workWrapper($message);\n    }\n\n    public function workWrapper(MessageInterface $message): void\n    {\n        $conn = $this->entityManager->getConnection();\n        $conn->getNativeConnection(); // calls connect() internally\n        $conn->beginTransaction();\n        try {\n            $this->doWork($message);\n            $conn->commit();\n        } catch (InvalidApPostException $e) {\n            if (400 <= $e->responseCode && 500 > $e->responseCode && self::HTTP_RESPONSE_CODE_RATE_LIMITED !== $e->responseCode) {\n                $conn->rollBack();\n                $this->logger->debug('{domain} responded with {code} for our request, rolling back the changes and not trying again, request: {body}', [\n                    'domain' => $e->url,\n                    'code' => $e->responseCode,\n                    'body' => $e->payload,\n                ]);\n                throw new UnrecoverableMessageHandlingException('There is a problem with the request which will stay the same, so discarding', previous: $e);\n            } elseif (self::HTTP_RESPONSE_CODE_RATE_LIMITED === $e->responseCode) {\n                $conn->rollBack();\n                // a rate limit is always recoverable\n                throw new RecoverableMessageHandlingException(previous: $e);\n            } else {\n                // we don't roll back on an InvalidApPostException, so the failed delivery attempt gets written to the DB\n                $conn->commit();\n                throw $e;\n            }\n        } catch (TransportExceptionInterface $e) {\n            // we don't roll back on an TransportExceptionInterface, so the failed delivery attempt gets written to the DB\n            $conn->commit();\n            throw $e;\n        } catch (\\Exception $e) {\n            $conn->rollBack();\n            throw $e;\n        }\n\n        $conn->close();\n    }\n\n    /**\n     * @throws InvalidApPostException\n     * @throws TransportExceptionInterface\n     * @throws InvalidArgumentException\n     * @throws InstanceBannedException\n     * @throws InvalidWebfingerException\n     */\n    public function doWork(MessageInterface $message): void\n    {\n        if (!($message instanceof DeliverMessage)) {\n            throw new \\LogicException();\n        }\n\n        $instance = $this->instanceRepository->getOrCreateInstance(parse_url($message->apInboxUrl, PHP_URL_HOST));\n        if ($instance->isDead()) {\n            $this->logger->debug('instance {n} is considered dead. Last successful delivery date: {dd}, failed attempts since then: {fa}', [\n                'n' => $instance->domain,\n                'dd' => $instance->getLastSuccessfulDeliver(),\n                'fa' => $instance->getLastFailedDeliver(),\n            ]);\n\n            return;\n        }\n\n        if ('Announce' !== $message->payload['type']) {\n            $url = $message->payload['object']['attributedTo'] ?? $message->payload['actor'];\n        } else {\n            $url = $message->payload['actor'];\n        }\n        $this->logger->debug(\"Getting Actor for url: $url\");\n        $actor = $this->activityPubManager->findActorOrCreate($url);\n\n        if (!$actor) {\n            $this->logger->debug('got no actor :(');\n\n            return;\n        }\n\n        if ($actor instanceof User && $actor->isBanned) {\n            $this->logger->debug('got an actor, but he is banned :(');\n\n            return;\n        }\n\n        try {\n            $this->client->post($message->apInboxUrl, $actor, $message->payload, $message->useOldPrivateKey);\n            if ($instance->getLastSuccessfulDeliver() < new \\DateTimeImmutable('now - 5 minutes')) {\n                $instance->setLastSuccessfulDeliver();\n                $this->entityManager->persist($instance);\n                $this->entityManager->flush();\n            }\n        } catch (InvalidApPostException|TransportExceptionInterface $e) {\n            $instance->setLastFailedDeliver();\n            $this->entityManager->persist($instance);\n            $this->entityManager->flush();\n\n            throw $e;\n        }\n    }\n}\n"
  },
  {
    "path": "src/MessageHandler/ActivityPub/Outbox/EntryPinMessageHandler.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\MessageHandler\\ActivityPub\\Outbox;\n\nuse App\\Factory\\ActivityPub\\AddRemoveFactory;\nuse App\\Message\\ActivityPub\\Outbox\\EntryPinMessage;\nuse App\\Message\\Contracts\\MessageInterface;\nuse App\\MessageHandler\\MbinMessageHandler;\nuse App\\Repository\\EntryRepository;\nuse App\\Repository\\MagazineRepository;\nuse App\\Repository\\UserRepository;\nuse App\\Service\\ActivityPub\\ActivityJsonBuilder;\nuse App\\Service\\DeliverManager;\nuse App\\Service\\SettingsManager;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Psr\\Log\\LoggerInterface;\nuse Symfony\\Component\\HttpKernel\\KernelInterface;\nuse Symfony\\Component\\Messenger\\Attribute\\AsMessageHandler;\n\n#[AsMessageHandler]\nclass EntryPinMessageHandler extends MbinMessageHandler\n{\n    public function __construct(\n        private readonly EntityManagerInterface $entityManager,\n        private readonly KernelInterface $kernel,\n        private readonly SettingsManager $settingsManager,\n        private readonly EntryRepository $entryRepository,\n        private readonly UserRepository $userRepository,\n        private readonly AddRemoveFactory $addRemoveFactory,\n        private readonly MagazineRepository $magazineRepository,\n        private readonly DeliverManager $deliverManager,\n        private readonly LoggerInterface $logger,\n        private readonly ActivityJsonBuilder $activityJsonBuilder,\n    ) {\n        parent::__construct($this->entityManager, $this->kernel);\n    }\n\n    public function __invoke(EntryPinMessage $message): void\n    {\n        if (!$this->settingsManager->get('KBIN_FEDERATION_ENABLED')) {\n            return;\n        }\n        $this->workWrapper($message);\n    }\n\n    public function doWork(MessageInterface $message): void\n    {\n        if (!($message instanceof EntryPinMessage)) {\n            throw new \\LogicException();\n        }\n        $entry = $this->entryRepository->findOneBy(['id' => $message->entryId]);\n        $user = $this->userRepository->findOneBy(['id' => $message->actorId]);\n\n        if (null !== $entry->magazine->apId && null !== $user->apId) {\n            $this->logger->warning('got an EntryPinMessage for remote magazine {m} by remote user {u}. That does not need to be propagated, as this instance is not the source', ['m' => $entry->magazine->apId, 'u' => $user->apId]);\n\n            return;\n        }\n\n        if ('random' === $entry->magazine->name) {\n            // do not federate the random magazine\n            return;\n        }\n\n        if ($message->sticky) {\n            $activity = $this->addRemoveFactory->buildAddPinnedPost($user, $entry);\n        } else {\n            $activity = $this->addRemoveFactory->buildRemovePinnedPost($user, $entry);\n        }\n\n        if ($entry->magazine->apId) {\n            $audience = [$entry->magazine->apInboxUrl];\n        } else {\n            $audience = $this->magazineRepository->findAudience($entry->magazine);\n        }\n\n        $json = $this->activityJsonBuilder->buildActivityJson($activity);\n        $this->deliverManager->deliver($audience, $json);\n    }\n}\n"
  },
  {
    "path": "src/MessageHandler/ActivityPub/Outbox/FlagHandler.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\MessageHandler\\ActivityPub\\Outbox;\n\nuse App\\Entity\\Moderator;\nuse App\\Entity\\Report;\nuse App\\Factory\\ActivityPub\\FlagFactory;\nuse App\\Message\\ActivityPub\\Outbox\\FlagMessage;\nuse App\\Message\\Contracts\\MessageInterface;\nuse App\\MessageHandler\\MbinMessageHandler;\nuse App\\Repository\\ReportRepository;\nuse App\\Service\\ActivityPub\\ActivityJsonBuilder;\nuse App\\Service\\DeliverManager;\nuse App\\Service\\SettingsManager;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Psr\\Log\\LoggerInterface;\nuse Symfony\\Component\\HttpKernel\\KernelInterface;\nuse Symfony\\Component\\Messenger\\Attribute\\AsMessageHandler;\n\n#[AsMessageHandler]\nclass FlagHandler extends MbinMessageHandler\n{\n    public function __construct(\n        private readonly EntityManagerInterface $entityManager,\n        private readonly KernelInterface $kernel,\n        private readonly SettingsManager $settingsManager,\n        private readonly ReportRepository $reportRepository,\n        private readonly FlagFactory $factory,\n        private readonly LoggerInterface $logger,\n        private readonly DeliverManager $deliverManager,\n        private readonly ActivityJsonBuilder $activityJsonBuilder,\n    ) {\n        parent::__construct($this->entityManager, $this->kernel);\n    }\n\n    public function __invoke(FlagMessage $message): void\n    {\n        if (!$this->settingsManager->get('KBIN_FEDERATION_ENABLED')) {\n            return;\n        }\n        $this->workWrapper($message);\n    }\n\n    public function doWork(MessageInterface $message): void\n    {\n        if (!($message instanceof FlagMessage)) {\n            throw new \\LogicException();\n        }\n        $this->logger->debug('[FlagHandler::doWork] Got a FlagMessage');\n        $report = $this->reportRepository->find($message->reportId);\n        if (!$report) {\n            $this->logger->info(\"[FlagHandler::doWork] Couldn't find report with id {id}\", ['id' => $message->reportId]);\n\n            return;\n        }\n        $this->logger->debug('[FlagHandler::doWork] Found the report: '.json_encode($report));\n        $inboxes = $this->getInboxUrls($report);\n        if (0 === \\sizeof($inboxes)) {\n            $this->logger->info(\"[FlagHandler::doWork] couldn't find any inboxes to send the FlagMessage to\");\n\n            return;\n        }\n\n        $activity = $this->factory->build($report);\n        $json = $this->activityJsonBuilder->buildActivityJson($activity);\n        $this->deliverManager->deliver($inboxes, $json);\n    }\n\n    /**\n     * @return string[]\n     */\n    private function getInboxUrls(Report $report): array\n    {\n        $urls = [];\n\n        if (null === $report->magazine->apId) {\n            foreach ($report->magazine->moderators as /* @var Moderator $moderator */ $moderator) {\n                if ($moderator->user->apId and !\\in_array($moderator->user->apInboxUrl, $urls)) {\n                    $urls[] = $moderator->user->apInboxUrl;\n                }\n            }\n        } else {\n            $urls[] = $report->magazine->apInboxUrl;\n        }\n\n        if ($report->reported->apId and !\\in_array($report->reported->apInboxUrl, $urls)) {\n            $urls[] = $report->reported->apInboxUrl;\n        }\n\n        return $urls;\n    }\n}\n"
  },
  {
    "path": "src/MessageHandler/ActivityPub/Outbox/FollowHandler.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\MessageHandler\\ActivityPub\\Outbox;\n\nuse App\\Message\\ActivityPub\\Outbox\\FollowMessage;\nuse App\\Message\\Contracts\\MessageInterface;\nuse App\\MessageHandler\\MbinMessageHandler;\nuse App\\Repository\\ActivityRepository;\nuse App\\Repository\\MagazineRepository;\nuse App\\Repository\\UserRepository;\nuse App\\Service\\ActivityPub\\ActivityJsonBuilder;\nuse App\\Service\\ActivityPub\\ApHttpClientInterface;\nuse App\\Service\\ActivityPub\\Wrapper\\FollowWrapper;\nuse App\\Service\\ActivityPub\\Wrapper\\UndoWrapper;\nuse App\\Service\\ActivityPubManager;\nuse App\\Service\\DeliverManager;\nuse App\\Service\\SettingsManager;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Symfony\\Component\\HttpKernel\\KernelInterface;\nuse Symfony\\Component\\Messenger\\Attribute\\AsMessageHandler;\n\n#[AsMessageHandler]\nclass FollowHandler extends MbinMessageHandler\n{\n    public function __construct(\n        private readonly EntityManagerInterface $entityManager,\n        private readonly KernelInterface $kernel,\n        private readonly UserRepository $userRepository,\n        private readonly MagazineRepository $magazineRepository,\n        private readonly ActivityPubManager $activityPubManager,\n        private readonly FollowWrapper $followWrapper,\n        private readonly UndoWrapper $undoWrapper,\n        private readonly ApHttpClientInterface $apHttpClient,\n        private readonly SettingsManager $settingsManager,\n        private readonly DeliverManager $deliverManager,\n        private readonly ActivityJsonBuilder $activityJsonBuilder,\n        private readonly ActivityRepository $activityRepository,\n    ) {\n        parent::__construct($this->entityManager, $this->kernel);\n    }\n\n    public function __invoke(FollowMessage $message): void\n    {\n        if (!$this->settingsManager->get('KBIN_FEDERATION_ENABLED')) {\n            return;\n        }\n        $this->workWrapper($message);\n    }\n\n    public function doWork(MessageInterface $message): void\n    {\n        if (!($message instanceof FollowMessage)) {\n            throw new \\LogicException();\n        }\n\n        $follower = $this->userRepository->find($message->followerId);\n        if ($message->magazine) {\n            $following = $this->magazineRepository->find($message->followingId);\n        } else {\n            $following = $this->userRepository->find($message->followingId);\n        }\n\n        $followObject = $this->activityRepository->findFirstActivitiesByTypeObjectAndActor('Follow', $following, $follower);\n        if (null === $followObject) {\n            $followObject = $this->followWrapper->build($follower, $following);\n        }\n\n        if ($message->unfollow) {\n            $followObject = $this->undoWrapper->build($followObject);\n        }\n\n        $json = $this->activityJsonBuilder->buildActivityJson($followObject);\n        $this->deliverManager->deliver([$following->apInboxUrl], $json);\n    }\n}\n"
  },
  {
    "path": "src/MessageHandler/ActivityPub/Outbox/GenericAnnounceHandler.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\MessageHandler\\ActivityPub\\Outbox;\n\nuse App\\Message\\ActivityPub\\Outbox\\GenericAnnounceMessage;\nuse App\\Message\\Contracts\\MessageInterface;\nuse App\\MessageHandler\\MbinMessageHandler;\nuse App\\Repository\\ActivityRepository;\nuse App\\Repository\\MagazineRepository;\nuse App\\Service\\ActivityPub\\ActivityJsonBuilder;\nuse App\\Service\\ActivityPub\\Wrapper\\AnnounceWrapper;\nuse App\\Service\\DeliverManager;\nuse App\\Service\\SettingsManager;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Psr\\Log\\LoggerInterface;\nuse Symfony\\Component\\HttpKernel\\KernelInterface;\nuse Symfony\\Component\\Messenger\\Attribute\\AsMessageHandler;\nuse Symfony\\Component\\Routing\\Generator\\UrlGeneratorInterface;\nuse Symfony\\Component\\Uid\\Uuid;\n\n#[AsMessageHandler]\nclass GenericAnnounceHandler extends MbinMessageHandler\n{\n    public function __construct(\n        private readonly EntityManagerInterface $entityManager,\n        private readonly KernelInterface $kernel,\n        private readonly SettingsManager $settingsManager,\n        private readonly UrlGeneratorInterface $urlGenerator,\n        private readonly MagazineRepository $magazineRepository,\n        private readonly AnnounceWrapper $announceWrapper,\n        private readonly DeliverManager $deliverManager,\n        private readonly ActivityRepository $activityRepository,\n        private readonly ActivityJsonBuilder $activityJsonBuilder,\n        private readonly LoggerInterface $logger,\n    ) {\n        parent::__construct($this->entityManager, $this->kernel);\n    }\n\n    public function __invoke(GenericAnnounceMessage $message): void\n    {\n        if (!$this->settingsManager->get('KBIN_FEDERATION_ENABLED')) {\n            return;\n        }\n        $this->workWrapper($message);\n    }\n\n    public function doWork(MessageInterface $message): void\n    {\n        if (!($message instanceof GenericAnnounceMessage)) {\n            throw new \\LogicException();\n        }\n\n        $magazine = $this->magazineRepository->find($message->announcingMagazineId);\n        if (null !== $magazine->apId) {\n            return;\n        }\n\n        if ('random' === $magazine->name) {\n            // do not federate the random magazine\n            return;\n        }\n\n        if (null !== $message->innerActivityUUID) {\n            $object = $this->activityRepository->findOneBy(['uuid' => Uuid::fromString($message->innerActivityUUID)]);\n            if (!$object) {\n                // this should literally not be possible, but check for it anyway\n\n                throw new \\LogicException('could not find an Object by their Uuid '.$message->innerActivityUUID);\n            }\n        } elseif (null !== $message->innerActivityUrl) {\n            $object = $message->innerActivityUrl;\n        } else {\n            throw new \\LogicException('expect at least one of innerActivityUUID or innerActivityUrl to not be null');\n        }\n\n        $alreadySentActivities = $this->activityRepository->findAllActivitiesByTypeObjectAndActor('Announce', $object, $magazine);\n        if (\\sizeof($alreadySentActivities) > 0) {\n            $this->logger->info('[GenericAnnounceHandler::doWork] not announcing activity {object}, because the same actor (magazine {magazine}) already announced it', [\n                'object' => $object->uuid,\n                'magazine' => $magazine->name,\n            ]);\n\n            return;\n        }\n\n        $announce = $this->announceWrapper->build($magazine, $object);\n        $json = $this->activityJsonBuilder->buildActivityJson($announce);\n        $inboxes = array_filter($this->magazineRepository->findAudience($magazine), fn ($item) => null !== $item && $item !== $message->sourceInstance);\n        $this->deliverManager->deliver($inboxes, $json);\n    }\n}\n"
  },
  {
    "path": "src/MessageHandler/ActivityPub/Outbox/LikeHandler.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\MessageHandler\\ActivityPub\\Outbox;\n\nuse App\\Entity\\Entry;\nuse App\\Entity\\EntryComment;\nuse App\\Entity\\Post;\nuse App\\Entity\\PostComment;\nuse App\\Factory\\ActivityPub\\ActivityFactory;\nuse App\\Message\\ActivityPub\\Outbox\\LikeMessage;\nuse App\\Message\\Contracts\\MessageInterface;\nuse App\\MessageHandler\\MbinMessageHandler;\nuse App\\Repository\\MagazineRepository;\nuse App\\Repository\\UserRepository;\nuse App\\Service\\ActivityPub\\ActivityJsonBuilder;\nuse App\\Service\\ActivityPub\\Wrapper\\LikeWrapper;\nuse App\\Service\\ActivityPub\\Wrapper\\UndoWrapper;\nuse App\\Service\\ActivityPubManager;\nuse App\\Service\\DeliverManager;\nuse App\\Service\\SettingsManager;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Symfony\\Component\\HttpKernel\\KernelInterface;\nuse Symfony\\Component\\Messenger\\Attribute\\AsMessageHandler;\n\n#[AsMessageHandler]\nclass LikeHandler extends MbinMessageHandler\n{\n    public function __construct(\n        private readonly UserRepository $userRepository,\n        private readonly KernelInterface $kernel,\n        private readonly MagazineRepository $magazineRepository,\n        private readonly EntityManagerInterface $entityManager,\n        private readonly LikeWrapper $likeWrapper,\n        private readonly UndoWrapper $undoWrapper,\n        private readonly ActivityPubManager $activityPubManager,\n        private readonly ActivityFactory $activityFactory,\n        private readonly SettingsManager $settingsManager,\n        private readonly DeliverManager $deliverManager,\n        private readonly ActivityJsonBuilder $activityJsonBuilder,\n    ) {\n        parent::__construct($this->entityManager, $this->kernel);\n    }\n\n    public function __invoke(LikeMessage $message): void\n    {\n        if (!$this->settingsManager->get('KBIN_FEDERATION_ENABLED')) {\n            return;\n        }\n        $this->workWrapper($message);\n    }\n\n    public function doWork(MessageInterface $message): void\n    {\n        if (!($message instanceof LikeMessage)) {\n            throw new \\LogicException();\n        }\n\n        $user = $this->userRepository->find($message->userId);\n        /** @var Entry|EntryComment|Post|PostComment $object */\n        $object = $this->entityManager->getRepository($message->objectType)->find($message->objectId);\n\n        $activity = $this->likeWrapper->build($user, $object);\n\n        if ($message->removeLike) {\n            $activity = $this->undoWrapper->build($activity);\n        }\n\n        $inboxes = array_merge(\n            $this->userRepository->findAudience($user),\n            [$object->user->apInboxUrl],\n        );\n\n        if ('random' !== $object->magazine->name) {\n            // only add the magazine subscribers if it is not the random magazine\n            $inboxes = array_merge($inboxes, $this->magazineRepository->findAudience($object->magazine));\n        }\n\n        $this->deliverManager->deliver(array_filter(array_unique($inboxes)), $this->activityJsonBuilder->buildActivityJson($activity));\n    }\n}\n"
  },
  {
    "path": "src/MessageHandler/ActivityPub/Outbox/LockHandler.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\MessageHandler\\ActivityPub\\Outbox;\n\nuse App\\Factory\\ActivityPub\\LockFactory;\nuse App\\Message\\ActivityPub\\Outbox\\LockMessage;\nuse App\\Message\\Contracts\\MessageInterface;\nuse App\\MessageHandler\\MbinMessageHandler;\nuse App\\Repository\\ActivityRepository;\nuse App\\Repository\\EntryRepository;\nuse App\\Repository\\MagazineRepository;\nuse App\\Repository\\PostRepository;\nuse App\\Repository\\UserRepository;\nuse App\\Service\\ActivityPub\\ActivityJsonBuilder;\nuse App\\Service\\ActivityPub\\Wrapper\\UndoWrapper;\nuse App\\Service\\DeliverManager;\nuse App\\Service\\SettingsManager;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Symfony\\Component\\HttpKernel\\KernelInterface;\nuse Symfony\\Component\\Messenger\\Attribute\\AsMessageHandler;\n\n#[AsMessageHandler]\nclass LockHandler extends MbinMessageHandler\n{\n    public function __construct(\n        private readonly EntityManagerInterface $entityManager,\n        private readonly KernelInterface $kernel,\n        private readonly UserRepository $userRepository,\n        private readonly MagazineRepository $magazineRepository,\n        private readonly SettingsManager $settingsManager,\n        private readonly LockFactory $factory,\n        private readonly DeliverManager $deliverManager,\n        private readonly ActivityJsonBuilder $activityJsonBuilder,\n        private readonly EntryRepository $entryRepository,\n        private readonly PostRepository $postRepository,\n        private readonly ActivityRepository $activityRepository,\n        private readonly UndoWrapper $undoWrapper,\n    ) {\n        parent::__construct($this->entityManager, $this->kernel);\n    }\n\n    public function __invoke(LockMessage $message): void\n    {\n        if (!$this->settingsManager->get('KBIN_FEDERATION_ENABLED')) {\n            return;\n        }\n        $this->workWrapper($message);\n    }\n\n    public function doWork(MessageInterface $message): void\n    {\n        if (!($message instanceof LockMessage)) {\n            throw new \\LogicException();\n        }\n\n        $actor = $this->userRepository->find($message->actorId);\n        if (null !== $message->entryId) {\n            $object = $this->entryRepository->find($message->entryId);\n        } elseif (null !== $message->postId) {\n            $object = $this->postRepository->find($message->postId);\n        } else {\n            throw new \\LogicException('There has to be either an entry id or a post id');\n        }\n        $magazine = $object->magazine;\n        if ($magazine->apId) {\n            $audience = [$magazine->apInboxUrl];\n        } else {\n            if ('random' === $magazine->name) {\n                // do not federate the random magazine\n                return;\n            }\n            $audience = $this->magazineRepository->findAudience($magazine);\n        }\n\n        $userAudience = $this->userRepository->findAudience($actor);\n        $audience = array_filter(array_unique(array_merge($userAudience, $audience)));\n\n        if ($object->isLocked) {\n            $activity = $this->factory->build($actor, $object);\n        } else {\n            $activity = $this->activityRepository->findFirstActivitiesByTypeObjectAndActor('Lock', $object, $actor);\n            if (null === $activity) {\n                $activity = $this->factory->build($actor, $object);\n            }\n            $activity = $this->undoWrapper->build($activity, $actor);\n        }\n        $json = $this->activityJsonBuilder->buildActivityJson($activity);\n        $this->deliverManager->deliver($audience, $json);\n    }\n}\n"
  },
  {
    "path": "src/MessageHandler/ActivityPub/Outbox/RemoveHandler.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\MessageHandler\\ActivityPub\\Outbox;\n\nuse App\\Factory\\ActivityPub\\AddRemoveFactory;\nuse App\\Message\\ActivityPub\\Outbox\\RemoveMessage;\nuse App\\Message\\Contracts\\MessageInterface;\nuse App\\MessageHandler\\MbinMessageHandler;\nuse App\\Repository\\MagazineRepository;\nuse App\\Repository\\UserRepository;\nuse App\\Service\\ActivityPub\\ActivityJsonBuilder;\nuse App\\Service\\DeliverManager;\nuse App\\Service\\SettingsManager;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Symfony\\Component\\HttpKernel\\KernelInterface;\nuse Symfony\\Component\\Messenger\\Attribute\\AsMessageHandler;\n\n#[AsMessageHandler]\nclass RemoveHandler extends MbinMessageHandler\n{\n    public function __construct(\n        private readonly EntityManagerInterface $entityManager,\n        private readonly KernelInterface $kernel,\n        private readonly UserRepository $userRepository,\n        private readonly MagazineRepository $magazineRepository,\n        private readonly SettingsManager $settingsManager,\n        private readonly AddRemoveFactory $factory,\n        private readonly DeliverManager $deliverManager,\n        private readonly ActivityJsonBuilder $activityJsonBuilder,\n    ) {\n        parent::__construct($this->entityManager, $this->kernel);\n    }\n\n    public function __invoke(RemoveMessage $message): void\n    {\n        if (!$this->settingsManager->get('KBIN_FEDERATION_ENABLED')) {\n            return;\n        }\n        $this->workWrapper($message);\n    }\n\n    public function doWork(MessageInterface $message): void\n    {\n        if (!($message instanceof RemoveMessage)) {\n            throw new \\LogicException();\n        }\n\n        $actor = $this->userRepository->find($message->userActorId);\n        $removed = $this->userRepository->find($message->removedUserId);\n        $magazine = $this->magazineRepository->find($message->magazineId);\n\n        if ('random' === $magazine->name) {\n            // do not federate the random magazine\n            return;\n        }\n\n        if ($magazine->apId) {\n            $audience = [$magazine->apInboxUrl];\n        } else {\n            $audience = $this->magazineRepository->findAudience($magazine);\n        }\n\n        $activity = $this->factory->buildRemoveModerator($actor, $removed, $magazine);\n        $this->deliverManager->deliver($audience, $this->activityJsonBuilder->buildActivityJson($activity));\n    }\n}\n"
  },
  {
    "path": "src/MessageHandler/ActivityPub/Outbox/UpdateHandler.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\MessageHandler\\ActivityPub\\Outbox;\n\nuse App\\Entity\\Contracts\\ActivityPubActivityInterface;\nuse App\\Entity\\Contracts\\ActivityPubActorInterface;\nuse App\\Entity\\Entry;\nuse App\\Entity\\EntryComment;\nuse App\\Entity\\Magazine;\nuse App\\Entity\\Message;\nuse App\\Entity\\Post;\nuse App\\Entity\\PostComment;\nuse App\\Entity\\User;\nuse App\\Message\\ActivityPub\\Outbox\\UpdateMessage;\nuse App\\Message\\Contracts\\MessageInterface;\nuse App\\MessageHandler\\MbinMessageHandler;\nuse App\\Repository\\MagazineRepository;\nuse App\\Repository\\UserRepository;\nuse App\\Service\\ActivityPub\\ActivityJsonBuilder;\nuse App\\Service\\ActivityPub\\Wrapper\\UpdateWrapper;\nuse App\\Service\\ActivityPubManager;\nuse App\\Service\\DeliverManager;\nuse App\\Service\\SettingsManager;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Psr\\Log\\LoggerInterface;\nuse Symfony\\Component\\HttpKernel\\KernelInterface;\nuse Symfony\\Component\\Messenger\\Attribute\\AsMessageHandler;\n\n#[AsMessageHandler]\nclass UpdateHandler extends MbinMessageHandler\n{\n    public function __construct(\n        private readonly UserRepository $userRepository,\n        private readonly MagazineRepository $magazineRepository,\n        private readonly EntityManagerInterface $entityManager,\n        private readonly ActivityPubManager $activityPubManager,\n        private readonly SettingsManager $settingsManager,\n        private readonly DeliverManager $deliverManager,\n        private readonly UpdateWrapper $updateWrapper,\n        private readonly KernelInterface $kernel,\n        private readonly LoggerInterface $logger,\n        private readonly ActivityJsonBuilder $activityJsonBuilder,\n    ) {\n        parent::__construct($this->entityManager, $this->kernel);\n    }\n\n    public function __invoke(UpdateMessage $message): void\n    {\n        if (!$this->settingsManager->get('KBIN_FEDERATION_ENABLED')) {\n            return;\n        }\n        $this->workWrapper($message);\n    }\n\n    public function doWork(MessageInterface $message): void\n    {\n        if (!($message instanceof UpdateMessage)) {\n            throw new \\LogicException();\n        }\n\n        $entity = $this->entityManager->getRepository($message->type)->find($message->id);\n        $editedByUser = null;\n        if ($message->editedByUserId) {\n            $editedByUser = $this->userRepository->findOneBy(['id' => $message->editedByUserId]);\n        }\n\n        if ($entity instanceof ActivityPubActivityInterface) {\n            $activityObject = $this->updateWrapper->buildForActivity($entity, $editedByUser);\n            $activity = $this->activityJsonBuilder->buildActivityJson($activityObject);\n\n            if ($entity instanceof Entry || $entity instanceof EntryComment || $entity instanceof Post || $entity instanceof PostComment) {\n                if ('random' === $entity->magazine->name) {\n                    // do not federate the random magazine\n                    return;\n                }\n\n                $inboxes = array_filter(array_unique(array_merge(\n                    $this->userRepository->findAudience($entity->user),\n                    $this->activityPubManager->createInboxesFromCC($activity, $entity->user),\n                    $this->magazineRepository->findAudience($entity->magazine)\n                )));\n            } elseif ($entity instanceof Message) {\n                if (null === $message->editedByUserId) {\n                    throw new \\LogicException('a message has to be edited by someone');\n                }\n                $inboxes = array_unique(array_map(fn (User $u) => $u->apInboxUrl, $entity->thread->getOtherParticipants($message->editedByUserId)));\n            } else {\n                throw new \\LogicException('unknown activity type: '.\\get_class($entity));\n            }\n        } elseif ($entity instanceof ActivityPubActorInterface) {\n            $activityObject = $this->updateWrapper->buildForActor($entity, $editedByUser);\n            $activity = $this->activityJsonBuilder->buildActivityJson($activityObject);\n\n            if ($entity instanceof User) {\n                $inboxes = $this->userRepository->findAudience($entity);\n                $this->logger->debug('[UpdateHandler::doWork] sending update user activity for user {u} to {i}', ['u' => $entity->username, 'i' => join(', ', $inboxes)]);\n            } elseif ($entity instanceof Magazine) {\n                if ('random' === $entity->name) {\n                    // do not federate the random magazine\n                    return;\n                }\n\n                if (null === $entity->apId) {\n                    $inboxes = $this->magazineRepository->findAudience($entity);\n                    if (null !== $editedByUser) {\n                        $inboxes = array_filter($inboxes, fn (string $domain) => $editedByUser->apInboxUrl !== $domain);\n                    }\n                } else {\n                    $inboxes = [$entity->apInboxUrl];\n                }\n            } else {\n                throw new \\LogicException('Unknown actor type: '.\\get_class($entity));\n            }\n        } else {\n            throw new \\LogicException('Unknown activity type: '.\\get_class($entity));\n        }\n\n        $this->deliverManager->deliver($inboxes, $activity);\n    }\n}\n"
  },
  {
    "path": "src/MessageHandler/ActivityPub/UpdateActorHandler.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\MessageHandler\\ActivityPub;\n\nuse App\\Message\\ActivityPub\\UpdateActorMessage;\nuse App\\Message\\Contracts\\MessageInterface;\nuse App\\MessageHandler\\MbinMessageHandler;\nuse App\\Repository\\MagazineRepository;\nuse App\\Repository\\UserRepository;\nuse App\\Service\\ActivityPub\\ApHttpClientInterface;\nuse App\\Service\\ActivityPubManager;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Psr\\Log\\LoggerInterface;\nuse Symfony\\Component\\HttpKernel\\KernelInterface;\nuse Symfony\\Component\\Lock\\LockFactory;\nuse Symfony\\Component\\Messenger\\Attribute\\AsMessageHandler;\n\n#[AsMessageHandler]\nclass UpdateActorHandler extends MbinMessageHandler\n{\n    public function __construct(\n        private readonly EntityManagerInterface $entityManager,\n        private readonly KernelInterface $kernel,\n        private readonly ActivityPubManager $activityPubManager,\n        private readonly ApHttpClientInterface $apHttpClient,\n        private readonly LockFactory $lockFactory,\n        private readonly UserRepository $userRepository,\n        private readonly MagazineRepository $magazineRepository,\n        private readonly LoggerInterface $logger,\n    ) {\n        parent::__construct($this->entityManager, $this->kernel);\n    }\n\n    public function __invoke(UpdateActorMessage $message): void\n    {\n        $this->workWrapper($message);\n    }\n\n    public function doWork(MessageInterface $message): void\n    {\n        if (!($message instanceof UpdateActorMessage)) {\n            throw new \\LogicException();\n        }\n        $actorUrl = $message->actorUrl;\n        $lock = $this->lockFactory->createLock('update_actor_'.hash('sha256', $actorUrl), 60);\n\n        if (!$lock->acquire()) {\n            $this->logger->debug(\n                'not updating actor at {url}: ongoing actor update is already in progress',\n                ['url' => $actorUrl]\n            );\n\n            return;\n        }\n\n        $actor = $this->userRepository->findOneBy(['apProfileId' => $actorUrl])\n            ?? $this->magazineRepository->findOneBy(['apProfileId' => $actorUrl]);\n\n        if ($actor) {\n            if ($message->force) {\n                $this->apHttpClient->invalidateActorObjectCache($actorUrl);\n            }\n            if ($message->force || $actor->apFetchedAt < (new \\DateTime())->modify('-1 hour')) {\n                $this->activityPubManager->updateActor($actorUrl);\n            } else {\n                $this->logger->debug('not updating actor {url}: last updated is recent: {fetched}', [\n                    'url' => $actorUrl,\n                    'fetched' => $actor->apFetchedAt,\n                ]);\n            }\n        }\n\n        $lock->release();\n    }\n}\n"
  },
  {
    "path": "src/MessageHandler/AttachEntryEmbedHandler.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\MessageHandler;\n\nuse App\\Entity\\Entry;\nuse App\\Entity\\Image;\nuse App\\Message\\Contracts\\MessageInterface;\nuse App\\Message\\EntryEmbedMessage;\nuse App\\Repository\\EntryRepository;\nuse App\\Repository\\ImageRepository;\nuse App\\Service\\ImageManagerInterface;\nuse App\\Utils\\Embed;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Psr\\Log\\LoggerInterface;\nuse Symfony\\Component\\HttpKernel\\KernelInterface;\nuse Symfony\\Component\\Messenger\\Attribute\\AsMessageHandler;\nuse Symfony\\Component\\Messenger\\Exception\\UnrecoverableMessageHandlingException;\n\n#[AsMessageHandler]\nclass AttachEntryEmbedHandler extends MbinMessageHandler\n{\n    public function __construct(\n        private readonly EntryRepository $entryRepository,\n        private readonly KernelInterface $kernel,\n        private readonly Embed $embed,\n        private readonly ImageManagerInterface $manager,\n        private readonly ImageRepository $imageRepository,\n        private readonly EntityManagerInterface $entityManager,\n        private readonly LoggerInterface $logger,\n    ) {\n        parent::__construct($this->entityManager, $this->kernel);\n    }\n\n    public function __invoke(EntryEmbedMessage $message): void\n    {\n        $this->workWrapper($message);\n    }\n\n    public function doWork(MessageInterface $message): void\n    {\n        if (!($message instanceof EntryEmbedMessage)) {\n            throw new \\LogicException();\n        }\n        $entry = $this->entryRepository->find($message->entryId);\n\n        if (!$entry) {\n            throw new UnrecoverableMessageHandlingException('Entry not found');\n        }\n\n        if (!$entry->url) {\n            $this->logger->debug('[AttachEntryEmbedHandler::doWork] returning, as the entry {id} does not have a url', ['id' => $entry->getId()]);\n\n            return;\n        }\n\n        try {\n            $embed = $this->embed->fetch($entry->url);\n        } catch (\\Exception $e) {\n            return;\n        }\n\n        $html = $embed->html;\n        $type = $embed->getType();\n        $isImage = $embed->isImageUrl();\n\n        $cover = $this->fetchCover($entry, $embed);\n\n        if (!$html && !$cover && !$isImage) {\n            $this->logger->debug('[AttachEntryEmbedHandler::doWork] returning, as the embed is neither html, nor an image url and we could not extract an image from it either. URL: {u}', ['u' => $entry->url]);\n\n            return;\n        }\n\n        $entry->type = $type;\n        $entry->hasEmbed = $html || $isImage;\n        if ($cover) {\n            $this->logger->debug('[AttachEntryEmbedHandler::doWork] setting entry ({id}) image to new one', ['id' => $entry->getId()]);\n            $entry->image = $cover;\n        }\n\n        $this->entityManager->flush();\n    }\n\n    private function fetchCover(Entry $entry, Embed $embed): ?Image\n    {\n        if (!$entry->image) {\n            if ($imageUrl = $this->getCoverUrl($entry, $embed)) {\n                if ($tempFile = $this->fetchImage($imageUrl)) {\n                    $image = $this->imageRepository->findOrCreateFromPath($tempFile);\n                    if ($image && !$image->sourceUrl) {\n                        $image->sourceUrl = $imageUrl;\n                    }\n\n                    return $image;\n                }\n            }\n        }\n\n        $this->logger->debug('[AttachEntryEmbedHandler::fetchCover] returning null, as the entry ({id}) already has an image and does not have an embed', ['id' => $entry->getId()]);\n\n        return null;\n    }\n\n    private function getCoverUrl(Entry $entry, Embed $embed): ?string\n    {\n        if ($embed->image) {\n            return $embed->image;\n        } elseif ($embed->isImageUrl()) {\n            return $entry->url;\n        }\n\n        return null;\n    }\n\n    private function fetchImage(string $url): ?string\n    {\n        try {\n            return $this->manager->download($url);\n        } catch (\\Exception $e) {\n            return null;\n        }\n    }\n}\n"
  },
  {
    "path": "src/MessageHandler/ClearDeadMessagesHandler.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\MessageHandler;\n\nuse App\\Message\\ClearDeadMessagesMessage;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Doctrine\\ORM\\Query\\ResultSetMapping;\nuse Psr\\Log\\LoggerInterface;\nuse Symfony\\Component\\Messenger\\Attribute\\AsMessageHandler;\n\n#[AsMessageHandler]\nclass ClearDeadMessagesHandler\n{\n    public function __construct(\n        private readonly EntityManagerInterface $entityManager,\n        private readonly LoggerInterface $logger,\n    ) {\n    }\n\n    public function __invoke(ClearDeadMessagesMessage $message): void\n    {\n        $this->logger->info('[ClearDeadMessagesHandler::__invoke] Clearing dead messages');\n        $sql = 'DELETE FROM messenger_messages WHERE queue_name = :queue_name';\n        $this->entityManager->createNativeQuery($sql, new ResultSetMapping())\n            ->setParameter('queue_name', 'dead')\n            ->getResult();\n    }\n}\n"
  },
  {
    "path": "src/MessageHandler/ClearDeletedUserHandler.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\MessageHandler;\n\nuse App\\Message\\ClearDeletedUserMessage;\nuse App\\Message\\DeleteUserMessage;\nuse App\\Service\\UserManager;\nuse Psr\\Log\\LoggerInterface;\nuse Symfony\\Component\\Messenger\\Attribute\\AsMessageHandler;\nuse Symfony\\Component\\Messenger\\MessageBusInterface;\n\n#[AsMessageHandler]\nclass ClearDeletedUserHandler\n{\n    public function __construct(\n        private readonly UserManager $userManager,\n        private readonly MessageBusInterface $bus,\n        private readonly LoggerInterface $logger,\n    ) {\n    }\n\n    public function __invoke(ClearDeletedUserMessage $message): void\n    {\n        $users = $this->userManager->getUsersMarkedForDeletionBefore();\n        foreach ($users as $user) {\n            try {\n                $this->bus->dispatch(new DeleteUserMessage($user->getId()));\n            } catch (\\Exception|\\Error $e) {\n                $this->logger->error(\"[ClearDeletedUserHandler::__invoke] Couldn't delete user {user}: {message}\", ['user' => $user->username, 'message' => \\get_class($e).': '.$e->getMessage()]);\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "src/MessageHandler/DeleteImageHandler.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\MessageHandler;\n\nuse App\\Message\\Contracts\\MessageInterface;\nuse App\\Message\\DeleteImageMessage;\nuse App\\Repository\\ImageRepository;\nuse App\\Service\\ImageManagerInterface;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Doctrine\\Persistence\\ManagerRegistry;\nuse Symfony\\Component\\HttpKernel\\KernelInterface;\nuse Symfony\\Component\\Messenger\\Attribute\\AsMessageHandler;\n\n#[AsMessageHandler]\nclass DeleteImageHandler extends MbinMessageHandler\n{\n    public function __construct(\n        private readonly ImageRepository $imageRepository,\n        private readonly KernelInterface $kernel,\n        private readonly ImageManagerInterface $imageManager,\n        private readonly EntityManagerInterface $entityManager,\n        private readonly ManagerRegistry $managerRegistry,\n    ) {\n        parent::__construct($this->entityManager, $this->kernel);\n    }\n\n    public function __invoke(DeleteImageMessage $message): void\n    {\n        $this->workWrapper($message);\n    }\n\n    public function doWork(MessageInterface $message): void\n    {\n        if (!($message instanceof DeleteImageMessage)) {\n            throw new \\LogicException();\n        }\n        $image = $this->imageRepository->findOneBy(['id' => $message->id]);\n\n        if ($image) {\n            $this->entityManager->beginTransaction();\n\n            try {\n                $this->entityManager->remove($image);\n                $this->entityManager->flush();\n\n                $this->entityManager->commit();\n            } catch (\\Exception $e) {\n                $this->entityManager->rollback();\n                $this->managerRegistry->resetManager();\n\n                return;\n            }\n        }\n\n        if ($image?->filePath) {\n            $this->imageManager->remove($image->filePath);\n        }\n    }\n}\n"
  },
  {
    "path": "src/MessageHandler/DeleteUserHandler.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\MessageHandler;\n\nuse App\\DTO\\UserDto;\nuse App\\Entity\\User;\nuse App\\Message\\ActivityPub\\Outbox\\DeliverMessage;\nuse App\\Message\\Contracts\\MessageInterface;\nuse App\\Message\\DeleteUserMessage;\nuse App\\Service\\ActivityPub\\ActivityJsonBuilder;\nuse App\\Service\\ActivityPub\\Wrapper\\DeleteWrapper;\nuse App\\Service\\ImageManagerInterface;\nuse App\\Service\\UserManager;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Psr\\Log\\LoggerInterface;\nuse Symfony\\Component\\HttpKernel\\KernelInterface;\nuse Symfony\\Component\\Messenger\\Attribute\\AsMessageHandler;\nuse Symfony\\Component\\Messenger\\Exception\\UnrecoverableMessageHandlingException;\nuse Symfony\\Component\\Messenger\\MessageBusInterface;\n\n#[AsMessageHandler]\nclass DeleteUserHandler extends MbinMessageHandler\n{\n    public function __construct(\n        private readonly LoggerInterface $logger,\n        private readonly ImageManagerInterface $imageManager,\n        private readonly KernelInterface $kernel,\n        private readonly UserManager $userManager,\n        private readonly DeleteWrapper $deleteWrapper,\n        private readonly MessageBusInterface $bus,\n        private readonly EntityManagerInterface $entityManager,\n        private readonly ActivityJsonBuilder $activityJsonBuilder,\n    ) {\n        parent::__construct($this->entityManager, $this->kernel);\n    }\n\n    public function __invoke(DeleteUserMessage $message): void\n    {\n        $this->workWrapper($message);\n    }\n\n    public function doWork(MessageInterface $message): void\n    {\n        if (!($message instanceof DeleteUserMessage)) {\n            throw new \\LogicException();\n        }\n        /** @var ?User $user */\n        $user = $this->entityManager\n            ->getRepository(User::class)\n            ->find($message->id);\n\n        if (!$user) {\n            throw new UnrecoverableMessageHandlingException('User not found');\n        } elseif ($user->isDeleted && null === $user->markedForDeletionAt) {\n            // user already deleted\n            return;\n        }\n\n        $isLocal = null === $user->apId;\n\n        $privateKey = $user->getPrivateKey();\n        $publicKey = $user->getPublicKey();\n\n        $inboxes = $this->userManager->findAllKnownInboxesNotBannedNotDead();\n\n        // note: email cannot be null. For remote accounts email is set to their 'handle@domain.tld' who knows why...\n        $userDto = UserDto::create($user->username, email: $user->username, createdAt: $user->createdAt);\n        $userDto->plainPassword = ''.time();\n        if (!$isLocal) {\n            $userDto->apId = $user->apId;\n            $userDto->apProfileId = $user->apProfileId;\n        }\n\n        try {\n            $this->userManager->detachAvatar($user);\n        } catch (\\Exception|\\Error $e) {\n            $this->logger->error(\"[ClearDeletedUserHandler::__invoke] Couldn't delete the avatar of {user} at '{path}': {message}\", ['user' => $user->username, 'path' => $user->avatar?->filePath, 'message' => \\get_class($e).': '.$e->getMessage()]);\n        }\n        try {\n            $this->userManager->detachCover($user);\n        } catch (\\Exception|\\Error $e) {\n            $this->logger->error(\"[ClearDeletedUserHandler::__invoke] Couldn't delete the cover of {user} at '{path}': {message}\", ['user' => $user->username, 'path' => $user->cover?->filePath, 'message' => \\get_class($e).': '.$e->getMessage()]);\n        }\n        $filePathsOfUser = $this->userManager->getAllImageFilePathsOfUser($user);\n        foreach ($filePathsOfUser as $path) {\n            try {\n                $this->imageManager->remove($path);\n            } catch (\\Exception|\\Error $e) {\n                $this->logger->error(\"[ClearDeletedUserHandler::__invoke] Couldn't delete image of {user} at '{path}': {message}\", ['user' => $user->username, 'path' => $path, 'message' => \\get_class($e).': '.$e->getMessage()]);\n            }\n        }\n\n        $this->entityManager->beginTransaction();\n        try {\n            // delete the original user, so all the content is cascade deleted\n            $this->entityManager->remove($user);\n            $this->entityManager->flush();\n\n            // recreate a user with the same name, so this handle is blocked\n            $user = $this->userManager->create($userDto, verifyUserEmail: false, rateLimit: false, preApprove: true);\n            $user->isDeleted = true;\n            $user->markedForDeletionAt = null;\n            $user->isVerified = false;\n\n            if ($isLocal) {\n                $user->privateKey = $privateKey;\n                $user->publicKey = $publicKey;\n            }\n\n            $this->entityManager->persist($user);\n            $this->entityManager->flush();\n\n            if ($isLocal) {\n                $this->sendDeleteMessages($inboxes, $user);\n            }\n\n            $this->entityManager->commit();\n        } catch (\\Exception $e) {\n            $this->entityManager->rollback();\n\n            throw $e;\n        }\n    }\n\n    private function sendDeleteMessages(array $targetInboxes, User $deletedUser): void\n    {\n        if (null !== $deletedUser->apId) {\n            return;\n        }\n\n        $activity = $this->deleteWrapper->buildForUser($deletedUser);\n        $message = $this->activityJsonBuilder->buildActivityJson($activity);\n\n        foreach ($targetInboxes as $inbox) {\n            $this->bus->dispatch(new DeliverMessage($inbox, $message));\n        }\n    }\n}\n"
  },
  {
    "path": "src/MessageHandler/LinkEmbedHandler.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\MessageHandler;\n\nuse App\\Message\\Contracts\\MessageInterface;\nuse App\\Message\\LinkEmbedMessage;\nuse App\\Repository\\EmbedRepository;\nuse App\\Utils\\Embed;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Psr\\Cache\\CacheItemPoolInterface;\nuse Symfony\\Component\\HttpKernel\\KernelInterface;\nuse Symfony\\Component\\Messenger\\Attribute\\AsMessageHandler;\n\n#[AsMessageHandler]\nclass LinkEmbedHandler extends MbinMessageHandler\n{\n    public function __construct(\n        private readonly EntityManagerInterface $entityManager,\n        private readonly KernelInterface $kernel,\n        private readonly EmbedRepository $embedRepository,\n        private readonly Embed $embed,\n        private readonly CacheItemPoolInterface $markdownCache,\n    ) {\n        parent::__construct($this->entityManager, $this->kernel);\n    }\n\n    public function __invoke(LinkEmbedMessage $message): void\n    {\n        $this->workWrapper($message);\n    }\n\n    public function doWork(MessageInterface $message): void\n    {\n        if (!($message instanceof LinkEmbedMessage)) {\n            throw new \\LogicException();\n        }\n        preg_match_all('#\\bhttps?://[^,\\s()<>]+(?:\\([\\w\\d]+\\)|([^,[:punct:]\\s]|/))#', $message->body, $match);\n\n        foreach ($match[0] as $url) {\n            try {\n                $embed = $this->embed->fetch($url)->html;\n                if ($embed) {\n                    $entity = new \\App\\Entity\\Embed($url, true);\n                    $this->embedRepository->add($entity);\n                }\n            } catch (\\Exception $e) {\n                $embed = false;\n            }\n\n            if (!$embed) {\n                $entity = new \\App\\Entity\\Embed($url, false);\n                $this->embedRepository->add($entity);\n            }\n        }\n\n        $this->markdownCache->deleteItem(hash('sha256', json_encode(['content' => $message->body])));\n    }\n}\n"
  },
  {
    "path": "src/MessageHandler/MagazinePurgeHandler.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\MessageHandler;\n\nuse App\\Entity\\Entry;\nuse App\\Entity\\EntryComment;\nuse App\\Entity\\Magazine;\nuse App\\Entity\\MagazineOwnershipRequest;\nuse App\\Entity\\ModeratorRequest;\nuse App\\Entity\\Post;\nuse App\\Entity\\PostComment;\nuse App\\Entity\\Report;\nuse App\\Message\\Contracts\\MessageInterface;\nuse App\\Message\\MagazinePurgeMessage;\nuse App\\Service\\EntryCommentManager;\nuse App\\Service\\EntryManager;\nuse App\\Service\\PostCommentManager;\nuse App\\Service\\PostManager;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Symfony\\Component\\HttpKernel\\KernelInterface;\nuse Symfony\\Component\\Messenger\\Attribute\\AsMessageHandler;\nuse Symfony\\Component\\Messenger\\Exception\\UnrecoverableMessageHandlingException;\nuse Symfony\\Component\\Messenger\\MessageBusInterface;\n\n#[AsMessageHandler]\nclass MagazinePurgeHandler extends MbinMessageHandler\n{\n    private ?Magazine $magazine;\n    private int $batchSize = 5;\n\n    public function __construct(\n        private readonly EntryCommentManager $entryCommentManager,\n        private readonly EntryManager $entryManager,\n        private readonly PostCommentManager $postCommentManager,\n        private readonly PostManager $postManager,\n        private readonly MessageBusInterface $bus,\n        private readonly EntityManagerInterface $entityManager,\n        private readonly KernelInterface $kernel,\n    ) {\n        parent::__construct($this->entityManager, $this->kernel);\n    }\n\n    public function __invoke(MagazinePurgeMessage $message): void\n    {\n        $this->workWrapper($message);\n    }\n\n    public function doWork(MessageInterface $message): void\n    {\n        if (!($message instanceof MagazinePurgeMessage)) {\n            throw new \\LogicException();\n        }\n        $this->magazine = $this->entityManager\n            ->getRepository(Magazine::class)\n            ->find($message->id);\n\n        if (!$this->magazine) {\n            throw new UnrecoverableMessageHandlingException('Magazine not found');\n        }\n\n        // TODO: This magazine delete can be improved by introducing missing\n        // cascading in PostgreSQL schema\n        $retry = $this->removeReports()\n            || $this->removeEntryComments()\n            || $this->removeEntries()\n            || $this->removePostComments()\n            || $this->removePosts();\n\n        if ($retry) {\n            $this->bus->dispatch($message);\n        } else {\n            $this->removeModeratorRequests();\n            $this->removeModeratorOwnershipRequests();\n\n            if ($message->contentOnly) {\n                return;\n            }\n\n            $this->entityManager->remove($this->magazine);\n            $this->entityManager->flush();\n        }\n    }\n\n    private function removeEntryComments(): bool\n    {\n        $comments = $this->entityManager\n            ->getRepository(EntryComment::class)\n            ->findBy(\n                [\n                    'magazine' => $this->magazine,\n                ],\n                ['id' => 'DESC'],\n                $this->batchSize\n            );\n\n        $retry = false;\n\n        foreach ($comments as $comment) {\n            $retry = true;\n            $this->entryCommentManager->purge($comment->user, $comment);\n        }\n\n        return $retry;\n    }\n\n    private function removeEntries(): bool\n    {\n        $entries = $this->entityManager\n            ->getRepository(Entry::class)\n            ->findBy(\n                [\n                    'magazine' => $this->magazine,\n                ],\n                ['id' => 'DESC'],\n                $this->batchSize\n            );\n\n        $retry = false;\n\n        foreach ($entries as $entry) {\n            $retry = true;\n            $this->entryManager->purge($entry->user, $entry);\n        }\n\n        return $retry;\n    }\n\n    private function removePostComments(): bool\n    {\n        $comments = $this->entityManager\n            ->getRepository(PostComment::class)\n            ->findBy(\n                [\n                    'magazine' => $this->magazine,\n                ],\n                ['id' => 'DESC'],\n                $this->batchSize\n            );\n\n        $retry = false;\n        foreach ($comments as $comment) {\n            $retry = true;\n            $this->postCommentManager->purge($comment->user, $comment);\n        }\n\n        return $retry;\n    }\n\n    private function removePosts(): bool\n    {\n        $posts = $this->entityManager\n            ->getRepository(Post::class)\n            ->findBy(\n                [\n                    'magazine' => $this->magazine,\n                ],\n                ['id' => 'DESC'],\n                $this->batchSize\n            );\n\n        $retry = false;\n\n        foreach ($posts as $post) {\n            $retry = true;\n            $this->postManager->purge($post->user, $post);\n        }\n\n        return $retry;\n    }\n\n    private function removeReports(): bool\n    {\n        $em = $this->entityManager;\n        $query = $em->createQuery(\n            'DELETE FROM '.Report::class.' r WHERE r.magazine = :magazineId'\n        );\n        $query->setParameter('magazineId', $this->magazine->getId());\n        $query->execute();\n\n        return false;\n    }\n\n    private function removeModeratorRequests(): void\n    {\n        $em = $this->entityManager;\n        $query = $em->createQuery(\n            'DELETE FROM '.ModeratorRequest::class.' r WHERE r.magazine = :magazineId'\n        );\n        $query->setParameter('magazineId', $this->magazine->getId());\n        $query->execute();\n    }\n\n    private function removeModeratorOwnershipRequests(): void\n    {\n        $em = $this->entityManager;\n        $query = $em->createQuery(\n            'DELETE FROM '.MagazineOwnershipRequest::class.' r WHERE r.magazine = :magazineId'\n        );\n        $query->setParameter('magazineId', $this->magazine->getId());\n        $query->execute();\n    }\n}\n"
  },
  {
    "path": "src/MessageHandler/MbinMessageHandler.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\MessageHandler;\n\nuse App\\Message\\Contracts\\MessageInterface;\nuse Doctrine\\DBAL\\Exception;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Symfony\\Component\\HttpKernel\\KernelInterface;\n\nabstract class MbinMessageHandler\n{\n    public function __construct(\n        private readonly EntityManagerInterface $entityManager,\n        private readonly KernelInterface $kernel,\n    ) {\n    }\n\n    /**\n     * @throws \\Throwable\n     * @throws Exception\n     */\n    public function workWrapper(MessageInterface $message): void\n    {\n        // when we are in the test environment this would throw: ConnectionException: There is no active transaction.\n        if ('test' !== $this->kernel->getEnvironment()) {\n            $conn = $this->entityManager->getConnection();\n            $conn->getNativeConnection(); // calls connect() internally\n\n            $conn->transactional(fn () => $this->doWork($message));\n\n            $conn->close();\n        } else {\n            $this->doWork($message);\n        }\n    }\n\n    abstract public function doWork(MessageInterface $message): void;\n}\n"
  },
  {
    "path": "src/MessageHandler/Notification/SentEntryCommentCreatedNotificationHandler.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\MessageHandler\\Notification;\n\nuse App\\Message\\Contracts\\MessageInterface;\nuse App\\Message\\Notification\\EntryCommentCreatedNotificationMessage;\nuse App\\MessageHandler\\MbinMessageHandler;\nuse App\\Repository\\EntryCommentRepository;\nuse App\\Service\\NotificationManager;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Symfony\\Component\\HttpKernel\\KernelInterface;\nuse Symfony\\Component\\Messenger\\Attribute\\AsMessageHandler;\nuse Symfony\\Component\\Messenger\\Exception\\UnrecoverableMessageHandlingException;\n\n#[AsMessageHandler]\nclass SentEntryCommentCreatedNotificationHandler extends MbinMessageHandler\n{\n    public function __construct(\n        private readonly EntityManagerInterface $entityManager,\n        private readonly KernelInterface $kernel,\n        private readonly EntryCommentRepository $repository,\n        private readonly NotificationManager $notificationManager,\n    ) {\n        parent::__construct($this->entityManager, $this->kernel);\n    }\n\n    public function __invoke(EntryCommentCreatedNotificationMessage $message)\n    {\n        $this->workWrapper($message);\n    }\n\n    public function doWork(MessageInterface $message): void\n    {\n        if (!($message instanceof EntryCommentCreatedNotificationMessage)) {\n            throw new \\LogicException();\n        }\n        $comment = $this->repository->find($message->commentId);\n\n        if (!$comment) {\n            throw new UnrecoverableMessageHandlingException('Comment not found');\n        }\n\n        $this->notificationManager->sendCreated($comment);\n    }\n}\n"
  },
  {
    "path": "src/MessageHandler/Notification/SentEntryCommentDeletedNotificationHandler.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\MessageHandler\\Notification;\n\nuse App\\Message\\Contracts\\MessageInterface;\nuse App\\Message\\Notification\\EntryCommentDeletedNotificationMessage;\nuse App\\MessageHandler\\MbinMessageHandler;\nuse App\\Repository\\EntryCommentRepository;\nuse App\\Service\\NotificationManager;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Symfony\\Component\\HttpKernel\\KernelInterface;\nuse Symfony\\Component\\Messenger\\Attribute\\AsMessageHandler;\nuse Symfony\\Component\\Messenger\\Exception\\UnrecoverableMessageHandlingException;\n\n#[AsMessageHandler]\nclass SentEntryCommentDeletedNotificationHandler extends MbinMessageHandler\n{\n    public function __construct(\n        private readonly EntityManagerInterface $entityManager,\n        private readonly KernelInterface $kernel,\n        private readonly EntryCommentRepository $repository,\n        private readonly NotificationManager $notificationManager,\n    ) {\n        parent::__construct($this->entityManager, $this->kernel);\n    }\n\n    public function __invoke(EntryCommentDeletedNotificationMessage $message)\n    {\n        $this->workWrapper($message);\n    }\n\n    public function doWork(MessageInterface $message): void\n    {\n        if (!($message instanceof EntryCommentDeletedNotificationMessage)) {\n            throw new \\LogicException();\n        }\n        $comment = $this->repository->find($message->commentId);\n\n        if (!$comment) {\n            throw new UnrecoverableMessageHandlingException('Comment not found');\n        }\n\n        $this->notificationManager->sendDeleted($comment);\n    }\n}\n"
  },
  {
    "path": "src/MessageHandler/Notification/SentEntryCommentEditedNotificationHandler.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\MessageHandler\\Notification;\n\nuse App\\Message\\Contracts\\MessageInterface;\nuse App\\Message\\Notification\\EntryCommentEditedNotificationMessage;\nuse App\\MessageHandler\\MbinMessageHandler;\nuse App\\Repository\\EntryCommentRepository;\nuse App\\Service\\NotificationManager;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Symfony\\Component\\HttpKernel\\KernelInterface;\nuse Symfony\\Component\\Messenger\\Attribute\\AsMessageHandler;\nuse Symfony\\Component\\Messenger\\Exception\\UnrecoverableMessageHandlingException;\n\n#[AsMessageHandler]\nclass SentEntryCommentEditedNotificationHandler extends MbinMessageHandler\n{\n    public function __construct(\n        private readonly EntityManagerInterface $entityManager,\n        private readonly KernelInterface $kernel,\n        private readonly EntryCommentRepository $repository,\n        private readonly NotificationManager $notificationManager,\n    ) {\n        parent::__construct($this->entityManager, $this->kernel);\n    }\n\n    public function __invoke(EntryCommentEditedNotificationMessage $message)\n    {\n        $this->workWrapper($message);\n    }\n\n    public function doWork(MessageInterface $message): void\n    {\n        if (!($message instanceof EntryCommentEditedNotificationMessage)) {\n            throw new \\LogicException();\n        }\n        $comment = $this->repository->find($message->commentId);\n\n        if (!$comment) {\n            throw new UnrecoverableMessageHandlingException('Comment not found');\n        }\n\n        $this->notificationManager->sendEdited($comment);\n    }\n}\n"
  },
  {
    "path": "src/MessageHandler/Notification/SentEntryCreatedNotificationHandler.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\MessageHandler\\Notification;\n\nuse App\\Message\\Contracts\\MessageInterface;\nuse App\\Message\\Notification\\EntryCreatedNotificationMessage;\nuse App\\MessageHandler\\MbinMessageHandler;\nuse App\\Repository\\EntryRepository;\nuse App\\Service\\NotificationManager;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Symfony\\Component\\HttpKernel\\KernelInterface;\nuse Symfony\\Component\\Messenger\\Attribute\\AsMessageHandler;\nuse Symfony\\Component\\Messenger\\Exception\\UnrecoverableMessageHandlingException;\n\n#[AsMessageHandler]\nclass SentEntryCreatedNotificationHandler extends MbinMessageHandler\n{\n    public function __construct(\n        private readonly EntityManagerInterface $entityManager,\n        private readonly KernelInterface $kernel,\n        private readonly EntryRepository $repository,\n        private readonly NotificationManager $notificationManager,\n    ) {\n        parent::__construct($this->entityManager, $this->kernel);\n    }\n\n    public function __invoke(EntryCreatedNotificationMessage $message)\n    {\n        $this->workWrapper($message);\n    }\n\n    public function doWork(MessageInterface $message): void\n    {\n        if (!($message instanceof EntryCreatedNotificationMessage)) {\n            throw new \\LogicException();\n        }\n        $entry = $this->repository->find($message->entryId);\n\n        if (!$entry) {\n            throw new UnrecoverableMessageHandlingException('Entry not found');\n        }\n\n        $this->notificationManager->sendCreated($entry);\n    }\n}\n"
  },
  {
    "path": "src/MessageHandler/Notification/SentEntryDeletedNotificationHandler.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\MessageHandler\\Notification;\n\nuse App\\Message\\Contracts\\MessageInterface;\nuse App\\Message\\Notification\\EntryDeletedNotificationMessage;\nuse App\\MessageHandler\\MbinMessageHandler;\nuse App\\Repository\\EntryRepository;\nuse App\\Service\\NotificationManager;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Symfony\\Component\\HttpKernel\\KernelInterface;\nuse Symfony\\Component\\Messenger\\Attribute\\AsMessageHandler;\nuse Symfony\\Component\\Messenger\\Exception\\UnrecoverableMessageHandlingException;\n\n#[AsMessageHandler]\nclass SentEntryDeletedNotificationHandler extends MbinMessageHandler\n{\n    public function __construct(\n        private readonly EntityManagerInterface $entityManager,\n        private readonly KernelInterface $kernel,\n        private readonly EntryRepository $repository,\n        private readonly NotificationManager $notificationManager,\n    ) {\n        parent::__construct($this->entityManager, $this->kernel);\n    }\n\n    public function __invoke(EntryDeletedNotificationMessage $message)\n    {\n        $this->workWrapper($message);\n    }\n\n    public function doWork(MessageInterface $message): void\n    {\n        if (!($message instanceof EntryDeletedNotificationMessage)) {\n            throw new \\LogicException();\n        }\n        $entry = $this->repository->find($message->entryId);\n\n        if (!$entry) {\n            throw new UnrecoverableMessageHandlingException('Entry not found');\n        }\n\n        $this->notificationManager->sendDeleted($entry);\n    }\n}\n"
  },
  {
    "path": "src/MessageHandler/Notification/SentEntryEditedNotificationHandler.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\MessageHandler\\Notification;\n\nuse App\\Message\\Contracts\\MessageInterface;\nuse App\\Message\\Notification\\EntryEditedNotificationMessage;\nuse App\\MessageHandler\\MbinMessageHandler;\nuse App\\Repository\\EntryRepository;\nuse App\\Service\\NotificationManager;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Symfony\\Component\\HttpKernel\\KernelInterface;\nuse Symfony\\Component\\Messenger\\Attribute\\AsMessageHandler;\nuse Symfony\\Component\\Messenger\\Exception\\UnrecoverableMessageHandlingException;\n\n#[AsMessageHandler]\nclass SentEntryEditedNotificationHandler extends MbinMessageHandler\n{\n    public function __construct(\n        private readonly EntityManagerInterface $entityManager,\n        private readonly KernelInterface $kernel,\n        private readonly EntryRepository $repository,\n        private readonly NotificationManager $notificationManager,\n    ) {\n        parent::__construct($this->entityManager, $this->kernel);\n    }\n\n    public function __invoke(EntryEditedNotificationMessage $message): void\n    {\n        $this->workWrapper($message);\n    }\n\n    public function doWork(MessageInterface $message): void\n    {\n        if (!($message instanceof EntryEditedNotificationMessage)) {\n            throw new \\LogicException();\n        }\n        $entry = $this->repository->find($message->entryId);\n\n        if (!$entry) {\n            throw new UnrecoverableMessageHandlingException('Entry not found');\n        }\n\n        $this->notificationManager->sendEdited($entry);\n    }\n}\n"
  },
  {
    "path": "src/MessageHandler/Notification/SentFavouriteNotificationHandler.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\MessageHandler\\Notification;\n\nuse App\\Entity\\Contracts\\FavouriteInterface;\nuse App\\Message\\Contracts\\MessageInterface;\nuse App\\Message\\Notification\\FavouriteNotificationMessage;\nuse App\\MessageHandler\\MbinMessageHandler;\nuse App\\Service\\GenerateHtmlClassService;\nuse App\\Service\\SettingsManager;\nuse App\\Service\\VotableRepositoryResolver;\nuse App\\Utils\\IriGenerator;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Symfony\\Component\\HttpKernel\\KernelInterface;\nuse Symfony\\Component\\Mercure\\HubInterface;\nuse Symfony\\Component\\Mercure\\Update;\nuse Symfony\\Component\\Messenger\\Attribute\\AsMessageHandler;\n\n#[AsMessageHandler]\nclass SentFavouriteNotificationHandler extends MbinMessageHandler\n{\n    public function __construct(\n        private readonly EntityManagerInterface $entityManager,\n        private readonly KernelInterface $kernel,\n        private readonly VotableRepositoryResolver $resolver,\n        private readonly HubInterface $publisher,\n        private readonly GenerateHtmlClassService $classService,\n        private readonly SettingsManager $settingsManager,\n    ) {\n        parent::__construct($this->entityManager, $this->kernel);\n    }\n\n    public function __invoke(FavouriteNotificationMessage $message): void\n    {\n        $this->workWrapper($message);\n    }\n\n    public function doWork(MessageInterface $message): void\n    {\n        if (!($message instanceof FavouriteNotificationMessage)) {\n            throw new \\LogicException();\n        }\n        $repo = $this->resolver->resolve($message->subjectClass);\n        $this->notifyMagazine($repo->find($message->subjectId));\n    }\n\n    private function notifyMagazine(FavouriteInterface $subject): void\n    {\n        if (false === $this->settingsManager->get('KBIN_MERCURE_ENABLED')) {\n            return;\n        }\n\n        try {\n            $iri = IriGenerator::getIriFromResource($subject->magazine);\n\n            $update = new Update(\n                ['pub', $iri],\n                $this->getNotification($subject)\n            );\n\n            $this->publisher->publish($update);\n        } catch (\\Exception $e) {\n            dd($e);\n        }\n    }\n\n    private function getNotification(FavouriteInterface $fav): string\n    {\n        $subject = explode('\\\\', \\get_class($fav));\n\n        return json_encode(\n            [\n                'op' => end($subject).'Favourite',\n                'id' => $fav->getId(),\n                'htmlId' => $this->classService->fromEntity($fav),\n                'count' => $fav->favouriteCount,\n            ]\n        );\n    }\n}\n"
  },
  {
    "path": "src/MessageHandler/Notification/SentMagazineBanNotificationHandler.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\MessageHandler\\Notification;\n\nuse App\\Message\\Contracts\\MessageInterface;\nuse App\\Message\\Notification\\MagazineBanNotificationMessage;\nuse App\\MessageHandler\\MbinMessageHandler;\nuse App\\Repository\\MagazineBanRepository;\nuse App\\Service\\NotificationManager;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Symfony\\Component\\HttpKernel\\KernelInterface;\nuse Symfony\\Component\\Messenger\\Attribute\\AsMessageHandler;\nuse Symfony\\Component\\Messenger\\Exception\\UnrecoverableMessageHandlingException;\n\n#[AsMessageHandler]\nclass SentMagazineBanNotificationHandler extends MbinMessageHandler\n{\n    public function __construct(\n        private readonly EntityManagerInterface $entityManager,\n        private readonly KernelInterface $kernel,\n        private readonly MagazineBanRepository $repository,\n        private readonly NotificationManager $notificationManager,\n    ) {\n        parent::__construct($this->entityManager, $this->kernel);\n    }\n\n    public function __invoke(MagazineBanNotificationMessage $message): void\n    {\n        $this->workWrapper($message);\n    }\n\n    public function doWork(MessageInterface $message): void\n    {\n        if (!($message instanceof MagazineBanNotificationMessage)) {\n            throw new \\LogicException();\n        }\n        $ban = $this->repository->find($message->banId);\n\n        if (!$ban) {\n            throw new UnrecoverableMessageHandlingException('Ban not found');\n        }\n\n        $this->notificationManager->sendMagazineBanNotification($ban);\n    }\n}\n"
  },
  {
    "path": "src/MessageHandler/Notification/SentNewSignupNotificationHandler.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\MessageHandler\\Notification;\n\nuse App\\Message\\Contracts\\MessageInterface;\nuse App\\Message\\Notification\\SentNewSignupNotificationMessage;\nuse App\\MessageHandler\\MbinMessageHandler;\nuse App\\Repository\\UserRepository;\nuse App\\Service\\Notification\\SignupNotificationManager;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Symfony\\Component\\HttpKernel\\KernelInterface;\nuse Symfony\\Component\\Messenger\\Attribute\\AsMessageHandler;\nuse Symfony\\Component\\Messenger\\Exception\\UnrecoverableMessageHandlingException;\n\n#[AsMessageHandler]\nclass SentNewSignupNotificationHandler extends MbinMessageHandler\n{\n    public function __construct(\n        EntityManagerInterface $entityManager,\n        KernelInterface $kernel,\n        private readonly UserRepository $userRepository,\n        private readonly SignupNotificationManager $signupNotificationManager,\n    ) {\n        parent::__construct($entityManager, $kernel);\n    }\n\n    public function __invoke(SentNewSignupNotificationMessage $message)\n    {\n        $this->workWrapper($message);\n    }\n\n    public function doWork(MessageInterface $message): void\n    {\n        if (!($message instanceof SentNewSignupNotificationMessage)) {\n            throw new \\LogicException();\n        }\n        $user = $this->userRepository->findOneBy(['id' => $message->userId]);\n        if (!$user) {\n            throw new UnrecoverableMessageHandlingException('user not found');\n        }\n\n        if (!$user->isAccountDeleted() && !$user->isSoftDeleted() && null === $user->markedForDeletionAt) {\n            // only send notifications for new accounts if the account is not deleted,\n            // this is necessary because we create dummy accounts to block the username when an account is deleted\n            $this->signupNotificationManager->sendNewSignupNotification($user);\n        }\n    }\n}\n"
  },
  {
    "path": "src/MessageHandler/Notification/SentPostCommentCreatedNotificationHandler.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\MessageHandler\\Notification;\n\nuse App\\Message\\Contracts\\MessageInterface;\nuse App\\Message\\Notification\\PostCommentCreatedNotificationMessage;\nuse App\\MessageHandler\\MbinMessageHandler;\nuse App\\Repository\\PostCommentRepository;\nuse App\\Service\\NotificationManager;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Symfony\\Component\\HttpKernel\\KernelInterface;\nuse Symfony\\Component\\Messenger\\Attribute\\AsMessageHandler;\nuse Symfony\\Component\\Messenger\\Exception\\UnrecoverableMessageHandlingException;\n\n#[AsMessageHandler]\nclass SentPostCommentCreatedNotificationHandler extends MbinMessageHandler\n{\n    public function __construct(\n        private readonly EntityManagerInterface $entityManager,\n        private readonly KernelInterface $kernel,\n        private readonly PostCommentRepository $repository,\n        private readonly NotificationManager $notificationManager,\n    ) {\n        parent::__construct($this->entityManager, $this->kernel);\n    }\n\n    public function __invoke(PostCommentCreatedNotificationMessage $message): void\n    {\n        $this->workWrapper($message);\n    }\n\n    public function doWork(MessageInterface $message): void\n    {\n        if (!($message instanceof PostCommentCreatedNotificationMessage)) {\n            throw new \\LogicException();\n        }\n        $comment = $this->repository->find($message->commentId);\n\n        if (!$comment) {\n            throw new UnrecoverableMessageHandlingException('Comment not found');\n        }\n\n        $this->notificationManager->sendCreated($comment);\n    }\n}\n"
  },
  {
    "path": "src/MessageHandler/Notification/SentPostCommentDeletedNotificationHandler.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\MessageHandler\\Notification;\n\nuse App\\Message\\Contracts\\MessageInterface;\nuse App\\Message\\Notification\\PostCommentDeletedNotificationMessage;\nuse App\\MessageHandler\\MbinMessageHandler;\nuse App\\Repository\\PostCommentRepository;\nuse App\\Service\\NotificationManager;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Symfony\\Component\\HttpKernel\\KernelInterface;\nuse Symfony\\Component\\Messenger\\Attribute\\AsMessageHandler;\nuse Symfony\\Component\\Messenger\\Exception\\UnrecoverableMessageHandlingException;\n\n#[AsMessageHandler]\nclass SentPostCommentDeletedNotificationHandler extends MbinMessageHandler\n{\n    public function __construct(\n        private readonly EntityManagerInterface $entityManager,\n        private readonly KernelInterface $kernel,\n        private readonly PostCommentRepository $repository,\n        private readonly NotificationManager $notificationManager,\n    ) {\n        parent::__construct($this->entityManager, $this->kernel);\n    }\n\n    public function __invoke(PostCommentDeletedNotificationMessage $message): void\n    {\n        $this->workWrapper($message);\n    }\n\n    public function doWork(MessageInterface $message): void\n    {\n        if (!($message instanceof PostCommentDeletedNotificationMessage)) {\n            throw new \\LogicException();\n        }\n        $comment = $this->repository->find($message->commentId);\n\n        if (!$comment) {\n            throw new UnrecoverableMessageHandlingException('Comment not found');\n        }\n\n        $this->notificationManager->sendDeleted($comment);\n    }\n}\n"
  },
  {
    "path": "src/MessageHandler/Notification/SentPostCommentEditedNotificationHandler.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\MessageHandler\\Notification;\n\nuse App\\Message\\Contracts\\MessageInterface;\nuse App\\Message\\Notification\\PostCommentEditedNotificationMessage;\nuse App\\MessageHandler\\MbinMessageHandler;\nuse App\\Repository\\PostCommentRepository;\nuse App\\Service\\NotificationManager;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Symfony\\Component\\HttpKernel\\KernelInterface;\nuse Symfony\\Component\\Messenger\\Attribute\\AsMessageHandler;\nuse Symfony\\Component\\Messenger\\Exception\\UnrecoverableMessageHandlingException;\n\n#[AsMessageHandler]\nclass SentPostCommentEditedNotificationHandler extends MbinMessageHandler\n{\n    public function __construct(\n        private readonly EntityManagerInterface $entityManager,\n        private readonly KernelInterface $kernel,\n        private readonly PostCommentRepository $repository,\n        private readonly NotificationManager $notificationManager,\n    ) {\n        parent::__construct($this->entityManager, $this->kernel);\n    }\n\n    public function __invoke(PostCommentEditedNotificationMessage $message): void\n    {\n        $this->workWrapper($message);\n    }\n\n    public function doWork(MessageInterface $message): void\n    {\n        if (!($message instanceof PostCommentEditedNotificationMessage)) {\n            throw new \\LogicException();\n        }\n        $comment = $this->repository->find($message->commentId);\n\n        if (!$comment) {\n            throw new UnrecoverableMessageHandlingException('Comment not found');\n        }\n\n        $this->notificationManager->sendEdited($comment);\n    }\n}\n"
  },
  {
    "path": "src/MessageHandler/Notification/SentPostCreatedNotificationHandler.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\MessageHandler\\Notification;\n\nuse App\\Message\\Contracts\\MessageInterface;\nuse App\\Message\\Notification\\PostCreatedNotificationMessage;\nuse App\\MessageHandler\\MbinMessageHandler;\nuse App\\Repository\\PostRepository;\nuse App\\Service\\NotificationManager;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Symfony\\Component\\HttpKernel\\KernelInterface;\nuse Symfony\\Component\\Messenger\\Attribute\\AsMessageHandler;\nuse Symfony\\Component\\Messenger\\Exception\\UnrecoverableMessageHandlingException;\n\n#[AsMessageHandler]\nclass SentPostCreatedNotificationHandler extends MbinMessageHandler\n{\n    public function __construct(\n        private readonly EntityManagerInterface $entityManager,\n        private readonly KernelInterface $kernel,\n        private readonly PostRepository $repository,\n        private readonly NotificationManager $notificationManager,\n    ) {\n        parent::__construct($this->entityManager, $this->kernel);\n    }\n\n    public function __invoke(PostCreatedNotificationMessage $message): void\n    {\n        $this->workWrapper($message);\n    }\n\n    public function doWork(MessageInterface $message): void\n    {\n        if (!($message instanceof PostCreatedNotificationMessage)) {\n            throw new \\LogicException();\n        }\n        $post = $this->repository->find($message->postId);\n\n        if (!$post) {\n            throw new UnrecoverableMessageHandlingException('Post not found');\n        }\n\n        $this->notificationManager->sendCreated($post);\n    }\n}\n"
  },
  {
    "path": "src/MessageHandler/Notification/SentPostDeletedNotificationHandler.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\MessageHandler\\Notification;\n\nuse App\\Message\\Contracts\\MessageInterface;\nuse App\\Message\\Notification\\PostDeletedNotificationMessage;\nuse App\\MessageHandler\\MbinMessageHandler;\nuse App\\Repository\\PostRepository;\nuse App\\Service\\NotificationManager;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Symfony\\Component\\HttpKernel\\KernelInterface;\nuse Symfony\\Component\\Messenger\\Attribute\\AsMessageHandler;\nuse Symfony\\Component\\Messenger\\Exception\\UnrecoverableMessageHandlingException;\n\n#[AsMessageHandler]\nclass SentPostDeletedNotificationHandler extends MbinMessageHandler\n{\n    public function __construct(\n        private readonly EntityManagerInterface $entityManager,\n        private readonly KernelInterface $kernel,\n        private readonly PostRepository $repository,\n        private readonly NotificationManager $notificationManager,\n    ) {\n        parent::__construct($this->entityManager, $this->kernel);\n    }\n\n    public function __invoke(PostDeletedNotificationMessage $message): void\n    {\n        $this->workWrapper($message);\n    }\n\n    public function doWork(MessageInterface $message): void\n    {\n        if (!($message instanceof PostDeletedNotificationMessage)) {\n            throw new \\LogicException();\n        }\n        $post = $this->repository->find($message->postId);\n\n        if (!$post) {\n            throw new UnrecoverableMessageHandlingException('Post not found');\n        }\n\n        $this->notificationManager->sendDeleted($post);\n    }\n}\n"
  },
  {
    "path": "src/MessageHandler/Notification/SentPostEditedNotificationHandler.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\MessageHandler\\Notification;\n\nuse App\\Message\\Contracts\\MessageInterface;\nuse App\\Message\\Notification\\PostEditedNotificationMessage;\nuse App\\MessageHandler\\MbinMessageHandler;\nuse App\\Repository\\PostRepository;\nuse App\\Service\\NotificationManager;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Symfony\\Component\\HttpKernel\\KernelInterface;\nuse Symfony\\Component\\Messenger\\Attribute\\AsMessageHandler;\nuse Symfony\\Component\\Messenger\\Exception\\UnrecoverableMessageHandlingException;\n\n#[AsMessageHandler]\nclass SentPostEditedNotificationHandler extends MbinMessageHandler\n{\n    public function __construct(\n        private readonly EntityManagerInterface $entityManager,\n        private readonly KernelInterface $kernel,\n        private readonly PostRepository $repository,\n        private readonly NotificationManager $notificationManager,\n    ) {\n        parent::__construct($this->entityManager, $this->kernel);\n    }\n\n    public function __invoke(PostEditedNotificationMessage $message): void\n    {\n        $this->workWrapper($message);\n    }\n\n    public function doWork(MessageInterface $message): void\n    {\n        if (!($message instanceof PostEditedNotificationMessage)) {\n            throw new \\LogicException();\n        }\n        $post = $this->repository->find($message->postId);\n\n        if (!$post) {\n            throw new UnrecoverableMessageHandlingException('Post not found');\n        }\n\n        $this->notificationManager->sendEdited($post);\n    }\n}\n"
  },
  {
    "path": "src/MessageHandler/Notification/SentVoteNotificationHandler.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\MessageHandler\\Notification;\n\nuse App\\Entity\\Contracts\\VotableInterface;\nuse App\\Message\\Contracts\\MessageInterface;\nuse App\\Message\\Notification\\VoteNotificationMessage;\nuse App\\MessageHandler\\MbinMessageHandler;\nuse App\\Service\\GenerateHtmlClassService;\nuse App\\Service\\SettingsManager;\nuse App\\Service\\VotableRepositoryResolver;\nuse App\\Utils\\IriGenerator;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Symfony\\Component\\HttpKernel\\KernelInterface;\nuse Symfony\\Component\\Mercure\\HubInterface;\nuse Symfony\\Component\\Mercure\\Update;\nuse Symfony\\Component\\Messenger\\Attribute\\AsMessageHandler;\n\n#[AsMessageHandler]\nclass SentVoteNotificationHandler extends MbinMessageHandler\n{\n    public function __construct(\n        private readonly EntityManagerInterface $entityManager,\n        private readonly KernelInterface $kernel,\n        private readonly VotableRepositoryResolver $resolver,\n        private readonly HubInterface $publisher,\n        private readonly GenerateHtmlClassService $classService,\n        private readonly SettingsManager $settingsManager,\n    ) {\n        parent::__construct($this->entityManager, $this->kernel);\n    }\n\n    public function __invoke(VoteNotificationMessage $message): void\n    {\n        $this->workWrapper($message);\n    }\n\n    public function doWork(MessageInterface $message): void\n    {\n        if (!($message instanceof VoteNotificationMessage)) {\n            throw new \\LogicException();\n        }\n        $repo = $this->resolver->resolve($message->subjectClass);\n        $this->notifyMagazine($repo->find($message->subjectId));\n    }\n\n    private function notifyMagazine(VotableInterface $votable): void\n    {\n        if (false === $this->settingsManager->get('KBIN_MERCURE_ENABLED')) {\n            return;\n        }\n\n        try {\n            $iri = IriGenerator::getIriFromResource($votable->magazine);\n\n            $update = new Update(\n                ['pub', $iri],\n                $this->getNotification($votable)\n            );\n\n            $this->publisher->publish($update);\n        } catch (\\Exception $e) {\n        }\n    }\n\n    private function getNotification(VotableInterface $votable): string\n    {\n        $subject = explode('\\\\', \\get_class($votable));\n\n        return json_encode(\n            [\n                'op' => end($subject).'Vote',\n                'id' => $votable->getId(),\n                'htmlId' => $this->classService->fromEntity($votable),\n                'up' => $votable->countUpVotes(),\n                'down' => $votable->countDownVotes(),\n            ]\n        );\n    }\n}\n"
  },
  {
    "path": "src/MessageHandler/SendApplicationAnswerMailHandler.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\MessageHandler;\n\nuse App\\Entity\\User;\nuse App\\Message\\Contracts\\MessageInterface;\nuse App\\Message\\UserApplicationAnswerMessage;\nuse App\\Repository\\UserRepository;\nuse App\\Service\\SettingsManager;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Symfony\\Bridge\\Twig\\Mime\\TemplatedEmail;\nuse Symfony\\Component\\DependencyInjection\\ParameterBag\\ParameterBagInterface;\nuse Symfony\\Component\\HttpKernel\\KernelInterface;\nuse Symfony\\Component\\Mailer\\MailerInterface;\nuse Symfony\\Component\\Messenger\\Attribute\\AsMessageHandler;\nuse Symfony\\Component\\Messenger\\Exception\\UnrecoverableMessageHandlingException;\nuse Symfony\\Component\\Mime\\Address;\nuse Symfony\\Contracts\\Translation\\TranslatorInterface;\n\n#[AsMessageHandler]\nclass SendApplicationAnswerMailHandler extends MbinMessageHandler\n{\n    public function __construct(\n        private readonly EntityManagerInterface $entityManager,\n        private readonly SettingsManager $settingsManager,\n        private readonly UserRepository $repository,\n        private readonly ParameterBagInterface $params,\n        private readonly TranslatorInterface $translator,\n        private readonly MailerInterface $mailer,\n        private readonly KernelInterface $kernel,\n    ) {\n        parent::__construct($this->entityManager, $this->kernel);\n    }\n\n    public function __invoke(UserApplicationAnswerMessage $message): void\n    {\n        $this->workWrapper($message);\n    }\n\n    public function doWork(MessageInterface $message): void\n    {\n        if (!($message instanceof UserApplicationAnswerMessage)) {\n            throw new \\LogicException();\n        }\n        $user = $this->repository->find($message->userId);\n        if (!$user) {\n            throw new UnrecoverableMessageHandlingException('User not found');\n        }\n\n        $this->sendAnswerMail($user, $message->approved);\n    }\n\n    public function sendAnswerMail(User $user, bool $approved): void\n    {\n        $mail = (new TemplatedEmail())\n            ->from(\n                new Address($this->settingsManager->get('KBIN_SENDER_EMAIL'), $this->params->get('kbin_domain'))\n            )\n            ->to($user->email);\n\n        if ($approved) {\n            $mail->subject($this->translator->trans('email_application_approved_title'))\n                ->htmlTemplate('_email/application_approved.html.twig')\n                ->context(['user' => $user]);\n        } else {\n            $mail->subject($this->translator->trans('email_application_rejected_title'))\n                ->htmlTemplate('_email/application_rejected.html.twig')\n                ->context(['user' => $user]);\n        }\n        $this->mailer->send($mail);\n    }\n}\n"
  },
  {
    "path": "src/MessageHandler/SentUserConfirmationEmailHandler.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\MessageHandler;\n\nuse App\\Entity\\User;\nuse App\\Message\\Contracts\\MessageInterface;\nuse App\\Message\\Contracts\\SendConfirmationEmailInterface;\nuse App\\Repository\\UserRepository;\nuse App\\Security\\EmailVerifier;\nuse App\\Service\\SettingsManager;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Symfony\\Bridge\\Twig\\Mime\\TemplatedEmail;\nuse Symfony\\Component\\DependencyInjection\\ParameterBag\\ParameterBagInterface;\nuse Symfony\\Component\\HttpKernel\\KernelInterface;\nuse Symfony\\Component\\Messenger\\Attribute\\AsMessageHandler;\nuse Symfony\\Component\\Messenger\\Exception\\UnrecoverableMessageHandlingException;\nuse Symfony\\Component\\Mime\\Address;\nuse Symfony\\Contracts\\Translation\\TranslatorInterface;\n\n#[AsMessageHandler]\nclass SentUserConfirmationEmailHandler extends MbinMessageHandler\n{\n    public function __construct(\n        private readonly EntityManagerInterface $entityManager,\n        private readonly KernelInterface $kernel,\n        private readonly SettingsManager $settingsManager,\n        private readonly EmailVerifier $emailVerifier,\n        private readonly UserRepository $repository,\n        private readonly ParameterBagInterface $params,\n        private readonly TranslatorInterface $translator,\n    ) {\n        parent::__construct($this->entityManager, $this->kernel);\n    }\n\n    public function __invoke(SendConfirmationEmailInterface $message): void\n    {\n        $this->workWrapper($message);\n    }\n\n    public function doWork(MessageInterface $message): void\n    {\n        if (!($message instanceof SendConfirmationEmailInterface)) {\n            throw new \\LogicException();\n        }\n        $user = $this->repository->find($message->userId);\n        if (!$user) {\n            throw new UnrecoverableMessageHandlingException('User not found');\n        }\n\n        $this->sendConfirmationEmail($user);\n    }\n\n    /**\n     * @param User $user user that will be sent the confirmation email\n     *\n     * @throws \\Exception\n     */\n    public function sendConfirmationEmail(User $user): void\n    {\n        try {\n            $this->emailVerifier->sendEmailConfirmation(\n                'app_verify_email',\n                $user,\n                (new TemplatedEmail())\n                    ->from(\n                        new Address($this->settingsManager->get('KBIN_SENDER_EMAIL'), $this->params->get('kbin_domain'))\n                    )\n                    ->to($user->email)\n                    ->subject($this->translator->trans('email_confirm_title'))\n                    ->htmlTemplate('_email/confirmation_email.html.twig')\n                    ->context(['user' => $user])\n            );\n        } catch (\\Exception $e) {\n            throw $e;\n        }\n    }\n}\n"
  },
  {
    "path": "src/Middleware/Monitoring/DoctrineConnectionMiddleware.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Middleware\\Monitoring;\n\nuse App\\Service\\Monitor;\nuse Doctrine\\DBAL\\Driver\\Connection;\nuse Doctrine\\DBAL\\Driver\\Middleware\\AbstractConnectionMiddleware;\nuse Doctrine\\DBAL\\Driver\\Result;\nuse Doctrine\\DBAL\\Driver\\Statement;\n\n/**\n * Heavily inspired by https://github.com/inspector-apm/inspector-symfony/blob/master/src/Doctrine/Middleware/V4/Connection.php.\n */\nclass DoctrineConnectionMiddleware extends AbstractConnectionMiddleware\n{\n    public function __construct(\n        private readonly Monitor $monitor,\n        Connection $wrappedConnection,\n    ) {\n        parent::__construct($wrappedConnection);\n    }\n\n    public function prepare(string $sql): Statement\n    {\n        return new DoctrineStatementMiddleware(parent::prepare($sql), $this->monitor, $sql);\n    }\n\n    public function query(string $sql): Result\n    {\n        if (!$this->monitor->shouldRecordQueries() || null === $this->monitor->currentContext) {\n            return parent::query($sql);\n        }\n\n        $this->monitor->startQuery($sql);\n\n        try {\n            return parent::query($sql);\n        } finally {\n            $this->monitor->endQuery();\n        }\n    }\n\n    public function exec(string $sql): int\n    {\n        if (!$this->monitor->shouldRecordQueries() || null === $this->monitor->currentContext) {\n            return parent::exec($sql);\n        }\n\n        $this->monitor->startQuery($sql);\n\n        try {\n            return parent::exec($sql);\n        } finally {\n            $this->monitor->endQuery();\n        }\n    }\n\n    public function beginTransaction(): void\n    {\n        if (!$this->monitor->shouldRecordQueries() || null === $this->monitor->currentContext) {\n            parent::beginTransaction();\n\n            return;\n        }\n\n        $this->monitor->startQuery('START TRANSACTION');\n\n        try {\n            parent::beginTransaction();\n        } finally {\n            $this->monitor->endQuery();\n        }\n    }\n\n    public function commit(): void\n    {\n        if (!$this->monitor->shouldRecordQueries() || null === $this->monitor->currentContext) {\n            parent::commit();\n\n            return;\n        }\n\n        $this->monitor->startQuery('COMMIT');\n\n        try {\n            parent::commit();\n        } finally {\n            $this->monitor->endQuery();\n        }\n    }\n\n    public function rollBack(): void\n    {\n        if (!$this->monitor->shouldRecordQueries() || null === $this->monitor->currentContext) {\n            parent::rollBack();\n\n            return;\n        }\n\n        $this->monitor->startQuery('ROLLBACK');\n\n        try {\n            parent::rollBack();\n        } finally {\n            $this->monitor->endQuery();\n        }\n    }\n}\n"
  },
  {
    "path": "src/Middleware/Monitoring/DoctrineDriverMiddleware.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Middleware\\Monitoring;\n\nuse App\\Service\\Monitor;\nuse Doctrine\\DBAL\\Driver;\nuse Doctrine\\DBAL\\Driver\\Middleware\\AbstractDriverMiddleware;\n\n/**\n * Heavily inspired by https://github.com/inspector-apm/inspector-symfony/blob/master/src/Doctrine/Middleware/InspectorDriver.php.\n */\nclass DoctrineDriverMiddleware extends AbstractDriverMiddleware\n{\n    public function __construct(\n        private readonly Monitor $monitor,\n        Driver $wrappedDriver,\n    ) {\n        parent::__construct($wrappedDriver);\n    }\n\n    public function connect(#[\\SensitiveParameter] array $params): DoctrineConnectionMiddleware\n    {\n        $connection = parent::connect($params);\n\n        return new DoctrineConnectionMiddleware($this->monitor, $connection);\n    }\n}\n"
  },
  {
    "path": "src/Middleware/Monitoring/DoctrineMiddleware.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Middleware\\Monitoring;\n\nuse App\\Service\\Monitor;\nuse Doctrine\\DBAL\\Driver;\nuse Doctrine\\DBAL\\Driver\\Middleware;\n\n/**\n * Heavily inspired by https://github.com/inspector-apm/inspector-symfony/blob/master/src/Doctrine/Middleware/InspectorMiddleware.php.\n */\nclass DoctrineMiddleware implements Middleware\n{\n    public function __construct(\n        protected readonly Monitor $monitor,\n    ) {\n    }\n\n    public function wrap(Driver $driver): Driver\n    {\n        return new DoctrineDriverMiddleware($this->monitor, $driver);\n    }\n}\n"
  },
  {
    "path": "src/Middleware/Monitoring/DoctrineStatementMiddleware.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Middleware\\Monitoring;\n\nuse App\\Service\\Monitor;\nuse Doctrine\\DBAL\\Driver\\Middleware\\AbstractStatementMiddleware;\nuse Doctrine\\DBAL\\Driver\\Result;\nuse Doctrine\\DBAL\\Driver\\Statement;\nuse Doctrine\\DBAL\\ParameterType;\n\n/**\n * Heavily inspired by https://github.com/inspector-apm/inspector-symfony/blob/master/src/Doctrine/Middleware/V4/Statement.php.\n */\nclass DoctrineStatementMiddleware extends AbstractStatementMiddleware\n{\n    private array $parameters = [];\n\n    public function __construct(\n        protected readonly Statement $statement,\n        private readonly Monitor $monitor,\n        private string $sql,\n    ) {\n        parent::__construct($statement);\n    }\n\n    public function bindValue($param, $value, $type = ParameterType::STRING): void\n    {\n        $this->parameters[$param] = $value;\n\n        parent::bindValue($param, $value, $type);\n    }\n\n    public function execute(): Result\n    {\n        if (!$this->monitor->shouldRecordQueries() || null === $this->monitor->currentContext) {\n            return parent::execute();\n        }\n\n        $this->monitor->startQuery($this->sql, $this->parameters);\n\n        try {\n            return parent::execute();\n        } finally {\n            $this->monitor->endQuery();\n        }\n    }\n}\n"
  },
  {
    "path": "src/PageView/ContentPageView.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\PageView;\n\nuse App\\Entity\\User;\nuse App\\Repository\\Criteria;\nuse Symfony\\Bundle\\SecurityBundle\\Security;\n\nclass ContentPageView extends Criteria\n{\n    public function __construct(\n        int $page,\n        private readonly Security $security,\n    ) {\n        parent::__construct($page);\n    }\n\n    public function resolveSort(?string $value): string\n    {\n        $routes = $this->routes();\n        $defaultRoute = $routes['hot'];\n        $user = $this->security->getUser();\n        if ($user instanceof User) {\n            $defaultRoute = $user->frontDefaultSort;\n        }\n\n        return 'default' !== $value ? $routes[$value] : $defaultRoute;\n    }\n}\n"
  },
  {
    "path": "src/PageView/EntryCommentPageView.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\PageView;\n\nuse App\\Entity\\Entry;\nuse App\\Entity\\User;\nuse App\\Repository\\Criteria;\nuse Symfony\\Bundle\\SecurityBundle\\Security;\n\nclass EntryCommentPageView extends Criteria\n{\n    public const SORT_OPTIONS = [\n        self::SORT_NEW,\n        self::SORT_TOP,\n        self::SORT_HOT,\n        self::SORT_NEW,\n        self::SORT_OLD,\n    ];\n\n    public ?Entry $entry = null;\n    public bool $onlyParents = true;\n    /**\n     * @var int|null if null, no filter will be applied\n     */\n    public ?int $parent = null;\n\n    public function __construct(\n        int $page,\n        private readonly Security $security,\n    ) {\n        parent::__construct($page);\n    }\n\n    public function resolveSort(?string $value): string\n    {\n        $routes = $this->routes();\n        $defaultRoute = $routes['hot'];\n        $user = $this->security->getUser();\n        if ($user instanceof User) {\n            $defaultRoute = $user->commentDefaultSort;\n        }\n\n        return 'default' !== $value ? $routes[$value] : $defaultRoute;\n    }\n}\n"
  },
  {
    "path": "src/PageView/EntryPageView.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\PageView;\n\nuse App\\Entity\\User;\nuse App\\Repository\\Criteria;\nuse Symfony\\Bundle\\SecurityBundle\\Security;\n\nclass EntryPageView extends Criteria\n{\n    public const SORT_OPTIONS = [\n        self::SORT_ACTIVE,\n        self::SORT_HOT,\n        self::SORT_NEW,\n        self::SORT_TOP,\n        self::SORT_COMMENTED,\n    ];\n\n    public function __construct(\n        int $page,\n        private readonly Security $security,\n    ) {\n        parent::__construct($page);\n    }\n\n    public function resolveSort(?string $value): string\n    {\n        $routes = $this->routes();\n        $defaultRoute = $routes['hot'];\n        $user = $this->security->getUser();\n        if ($user instanceof User) {\n            $defaultRoute = $user->frontDefaultSort;\n        }\n\n        return 'default' !== $value ? $routes[$value] : $defaultRoute;\n    }\n}\n"
  },
  {
    "path": "src/PageView/MagazinePageView.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\PageView;\n\nuse App\\Repository\\Criteria;\n\nclass MagazinePageView extends Criteria\n{\n    public const SORT_THREADS = 'threads';\n    public const SORT_COMMENTS = 'comments';\n    public const SORT_POSTS = 'posts';\n    /** only applicable if $abandoned === true */\n    public const SORT_OWNER_LAST_ACTIVE = 'ownerLastActive';\n\n    public const FIELDS_NAMES = 'names';\n    public const FIELDS_NAMES_DESCRIPTIONS = 'names_descriptions';\n\n    public const ADULT_HIDE = 'hide';\n    public const ADULT_SHOW = 'show';\n    public const ADULT_ONLY = 'only';\n    public const ADULT_OPTIONS = [\n        self::ADULT_HIDE,\n        self::ADULT_SHOW,\n        self::ADULT_ONLY,\n    ];\n\n    public ?string $query = null;\n    public string $fields = self::FIELDS_NAMES;\n\n    public function __construct(\n        public int $page,\n        public string $sortOption,\n        public string $federation,\n        public string $adult,\n        public bool $abandoned = false,\n    ) {\n        parent::__construct($page);\n        $this->resolveSort($sortOption);\n    }\n\n    public function showOnlyLocalMagazines(): bool\n    {\n        return self::AP_LOCAL === $this->federation;\n    }\n\n    protected function routes(): array\n    {\n        return array_merge(\n            parent::routes(),\n            [\n                'threads' => self::SORT_THREADS,\n                'comments' => self::SORT_COMMENTS,\n                'posts' => self::SORT_POSTS,\n            ],\n        );\n    }\n}\n"
  },
  {
    "path": "src/PageView/MessageThreadPageView.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\PageView;\n\nuse App\\Entity\\MessageThread;\nuse App\\Repository\\Criteria;\n\nclass MessageThreadPageView extends Criteria\n{\n    public const SORT_OPTIONS = [\n        self::SORT_NEW,\n        self::SORT_OLD,\n    ];\n\n    public ?MessageThread $thread = null;\n}\n"
  },
  {
    "path": "src/PageView/PostCommentPageView.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\PageView;\n\nuse App\\Entity\\Post;\nuse App\\Entity\\User;\nuse App\\Repository\\Criteria;\nuse Symfony\\Bundle\\SecurityBundle\\Security;\n\nclass PostCommentPageView extends Criteria\n{\n    public const SORT_OPTIONS = [\n        self::SORT_NEW,\n        self::SORT_OLD,\n        self::SORT_TOP,\n    ];\n\n    public ?Post $post = null;\n    public bool $onlyParents = true;\n\n    public function __construct(\n        int $page,\n        private readonly Security $security,\n    ) {\n        parent::__construct($page);\n    }\n\n    public function resolveSort(?string $value): string\n    {\n        $routes = $this->routes();\n        $defaultRoute = $routes['hot'];\n        $user = $this->security->getUser();\n        if ($user instanceof User) {\n            $defaultRoute = $user->commentDefaultSort;\n        }\n\n        return 'default' !== $value ? $routes[$value] : $defaultRoute;\n    }\n}\n"
  },
  {
    "path": "src/PageView/PostPageView.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\PageView;\n\nuse App\\Entity\\User;\nuse App\\Repository\\Criteria;\nuse Symfony\\Bundle\\SecurityBundle\\Security;\n\nclass PostPageView extends Criteria\n{\n    public const SORT_OPTIONS = [\n        self::SORT_ACTIVE,\n        self::SORT_HOT,\n        self::SORT_NEW,\n        self::SORT_TOP,\n        self::SORT_COMMENTED,\n    ];\n\n    public function __construct(\n        int $page,\n        private readonly Security $security,\n    ) {\n        parent::__construct($page);\n    }\n\n    public function resolveSort(?string $value): string\n    {\n        $routes = $this->routes();\n        $defaultRoute = $routes['hot'];\n        $user = $this->security->getUser();\n        if ($user instanceof User) {\n            $defaultRoute = $user->frontDefaultSort;\n        }\n\n        return 'default' !== $value ? $routes[$value] : $defaultRoute;\n    }\n}\n"
  },
  {
    "path": "src/Pagination/AdapterFactory.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Pagination;\n\nuse Doctrine\\ORM\\QueryBuilder;\nuse Pagerfanta\\Adapter\\AdapterInterface;\nuse Psr\\Cache\\CacheItemPoolInterface;\n\nreadonly class AdapterFactory\n{\n    public function __construct(\n        private CacheItemPoolInterface $pool,\n    ) {\n    }\n\n    public function create(QueryBuilder $queryBuilder): AdapterInterface\n    {\n        return new CachingQueryAdapter(\n            new QueryAdapter(\n                $queryBuilder,\n                false,\n                false,\n            ),\n            $this->pool,\n        );\n    }\n}\n"
  },
  {
    "path": "src/Pagination/CachingQueryAdapter.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Pagination;\n\nuse Pagerfanta\\Adapter\\AdapterInterface;\nuse Psr\\Cache\\CacheItemPoolInterface;\n\nreadonly class CachingQueryAdapter implements AdapterInterface\n{\n    public function __construct(\n        private QueryAdapter $queryAdapter,\n        private CacheItemPoolInterface $pool,\n    ) {\n    }\n\n    public function getNbResults(): int\n    {\n        $nbResult = $this->pool->getItem($this->getCacheKey());\n\n        if ($nbResult->isHit()) {\n            return $nbResult->get();\n        }\n\n        $nbResult->expiresAfter(60);\n        $nbResult->set($this->queryAdapter->getNbResults());\n        $this->pool->save($nbResult);\n\n        return $nbResult->get();\n    }\n\n    public function getSlice(int $offset, int $length): iterable\n    {\n        return $this->queryAdapter->getSlice($offset, $length);\n    }\n\n    private function getCacheKey(): string\n    {\n        $query = $this->queryAdapter->getQuery()->getDQL();\n        $values = $this->queryAdapter->getQuery()->getParameters()->map(function ($val) {\n            $value = $val->getValue();\n\n            if (\\is_object($value) && method_exists($value, 'getId')) {\n                return \\sprintf('%s::%s', \\get_class($value), $value->getId());\n            }\n\n            return $value;\n        });\n\n        return 'pagination_count_'.hash('sha256', $query.json_encode($values->toArray()));\n    }\n}\n"
  },
  {
    "path": "src/Pagination/Cursor/CursorAdapterInterface.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Pagination\\Cursor;\n\n/**\n * @template-covariant T\n * @template-covariant TCursor\n * @template-covariant TCursor2\n */\ninterface CursorAdapterInterface\n{\n    /**\n     * Returns a slice of the results representing the current page of items in the list.\n     *\n     * @param TCursor     $cursor\n     * @param TCursor2    $cursor2\n     * @param int<0, max> $length\n     *\n     * @return iterable<array-key, T>\n     */\n    public function getSlice(mixed $cursor, mixed $cursor2, int $length): iterable;\n\n    /**\n     * Returns a slice of the results representing the previous page of items in reverse.\n     *\n     * @param TCursor     $cursor\n     * @param TCursor2    $cursor2\n     * @param int<0, max> $length\n     *\n     * @return iterable<array-key, T>\n     */\n    public function getPreviousSlice(mixed $cursor, mixed $cursor2, int $length): iterable;\n}\n"
  },
  {
    "path": "src/Pagination/Cursor/CursorPagination.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Pagination\\Cursor;\n\n/**\n * @template-covariant TCursor\n * @template-covariant TCursor2\n * @template-covariant TValue\n */\nclass CursorPagination implements CursorPaginationInterface\n{\n    /**\n     * @var array<TValue>|null\n     */\n    private ?array $currentPageResults = null;\n\n    /**\n     * @var array<TValue>|null\n     */\n    private ?array $previousPageResults = null;\n\n    /**\n     * @var TCursor|null\n     */\n    private mixed $currentCursor = null;\n\n    /**\n     * @var TCursor2|null\n     */\n    private mixed $currentCursor2 = null;\n\n    /**\n     * @var TCursor|null\n     */\n    private mixed $nextCursor = null;\n\n    /**\n     * @var TCursor2|null\n     */\n    private mixed $nextCursor2 = null;\n\n    /**\n     * @param CursorAdapterInterface<TCursor, TCursor2> $adapter\n     * @param ?string                                   $cursor2FieldName  If set the pagination will assume that the adapter uses a secondary cursor\n     * @param ?mixed                                    $cursor2LowerLimit The lower limit of the secondary cursor, if it is an integer field 0 is the default\n     */\n    public function __construct(\n        private readonly CursorAdapterInterface $adapter,\n        private readonly string $cursorFieldName,\n        private int $maxPerPage,\n        private readonly ?string $cursor2FieldName = null,\n        private readonly mixed $cursor2LowerLimit = 0,\n    ) {\n    }\n\n    public function getIterator(): \\Traversable\n    {\n        $results = $this->getCurrentPageResults();\n\n        if ($results instanceof \\Iterator) {\n            return $results;\n        }\n\n        if ($results instanceof \\IteratorAggregate) {\n            return $results->getIterator();\n        }\n\n        if (\\is_array($results)) {\n            return new \\ArrayIterator($results);\n        }\n\n        throw new \\InvalidArgumentException(\\sprintf('Cannot create iterator with page results of type \"%s\".', \\get_class($results)));\n    }\n\n    public function getAdapter(): CursorAdapterInterface\n    {\n        return $this->adapter;\n    }\n\n    public function setMaxPerPage(int $maxPerPage): CursorPaginationInterface\n    {\n        $this->maxPerPage = $maxPerPage;\n\n        return $this;\n    }\n\n    public function getMaxPerPage(): int\n    {\n        return $this->maxPerPage;\n    }\n\n    public function getCurrentPageResults(): iterable\n    {\n        if (null !== $this->currentPageResults) {\n            return $this->currentPageResults;\n        }\n        $results = $this->adapter->getSlice($this->currentCursor, $this->currentCursor2, $this->maxPerPage);\n        $this->currentPageResults = [...$results];\n\n        return $this->currentPageResults;\n    }\n\n    public function haveToPaginate(): bool\n    {\n        return $this->hasNextPage() || $this->hasPreviousPage();\n    }\n\n    public function hasNextPage(): bool\n    {\n        return $this->maxPerPage === \\sizeof($this->currentPageResults ?? [...$this->getCurrentPageResults()]);\n    }\n\n    /**\n     * @return array{0: TCursor, 1: TCursor2}\n     */\n    public function getNextPage(): array\n    {\n        if (null !== $this->nextCursor) {\n            return $this->nextCursor;\n        }\n\n        $cursorFieldName = $this->cursorFieldName;\n        $cursor2FieldName = $this->cursor2FieldName;\n        $array = $this->getCurrentPageResults();\n        $nextCursor = null;\n        $nextCursor2 = null;\n        $i = 0;\n        foreach ($array as $item) {\n            if (\\is_object($item)) {\n                $nextCursor = $item->$cursorFieldName;\n            } elseif (\\is_array($item)) {\n                $nextCursor = $item[$cursorFieldName];\n            } else {\n                throw new \\LogicException('Item has to be an object or array.');\n            }\n            if (null !== $cursor2FieldName) {\n                if (\\is_object($item)) {\n                    $nextCursor2 = $item->$cursor2FieldName;\n                } elseif (\\is_array($item)) {\n                    $nextCursor2 = $item[$cursor2FieldName];\n                } else {\n                    throw new \\LogicException('Item has to be an object or array.');\n                }\n            }\n            ++$i;\n        }\n        if ($this->maxPerPage === $i) {\n            $this->nextCursor = $nextCursor;\n            if (null !== $this->nextCursor2) {\n                $this->nextCursor2 = $nextCursor2;\n            }\n\n            return [$nextCursor, $nextCursor2];\n        }\n        throw new \\LogicException('There is no next page');\n    }\n\n    /**\n     * Generates an iterator to automatically iterate over all pages in a result set.\n     *\n     * @return \\Generator<int, TValue, mixed, void>\n     */\n    public function autoPagingIterator(): \\Generator\n    {\n        while (true) {\n            foreach ($this->getCurrentPageResults() as $item) {\n                yield $item;\n            }\n\n            if (!$this->hasNextPage()) {\n                break;\n            }\n\n            $nextCursors = $this->getNextPage();\n            $this->setCurrentPage($nextCursors[0], $nextCursors[1]);\n        }\n    }\n\n    public function setCurrentPage(mixed $cursor, mixed $cursor2 = null): CursorPaginationInterface\n    {\n        if ($cursor !== $this->currentCursor || $cursor2 !== $this->currentCursor2) {\n            $this->previousPageResults = null;\n            $this->currentCursor = $cursor;\n            $this->currentCursor2 = $cursor2;\n            $this->currentPageResults = null;\n            $this->nextCursor = null;\n            $this->nextCursor2 = null;\n        }\n\n        return $this;\n    }\n\n    public function getCurrentCursor(): array\n    {\n        return [$this->currentCursor, $this->currentCursor2];\n    }\n\n    public function hasPreviousPage(): bool\n    {\n        return \\sizeof($this->getPreviousPageResults()) > 0;\n    }\n\n    /**\n     * @return array{0: TCursor, 1: TCursor2}\n     */\n    public function getPreviousPage(): array\n    {\n        $cursorFieldName = $this->cursorFieldName;\n        $cursor2FieldName = $this->cursor2FieldName;\n        $array = $this->getPreviousPageResults();\n        $key = array_key_last($array);\n\n        $item = $array[$key];\n        if (\\is_object($item)) {\n            $cursor = $item->$cursorFieldName;\n        } elseif (\\is_array($item)) {\n            $cursor = $item[$cursorFieldName];\n        } else {\n            throw new \\LogicException('Item has to be an object or array.');\n        }\n\n        if (null !== $cursor2FieldName) {\n            if (\\is_object($item)) {\n                $cursor2 = $item->$cursor2FieldName;\n            } elseif (\\is_array($item)) {\n                $cursor2 = $item[$cursor2FieldName];\n            } else {\n                throw new \\LogicException('Item has to be an object or array.');\n            }\n        }\n\n        $currentCursors = $this->getCurrentCursor();\n\n        return $this->getPreviousCursors($currentCursors[0], $cursor, $currentCursors[1], $cursor2 ?? null);\n    }\n\n    private function getPreviousPageResults(): array\n    {\n        if (null === $this->previousPageResults) {\n            $this->previousPageResults = [...$this->adapter->getPreviousSlice($this->currentCursor, $this->currentCursor2, $this->maxPerPage)];\n        }\n\n        return $this->previousPageResults;\n    }\n\n    /**\n     * @return array{0: \\DateTimeImmutable|int|mixed, 1: \\DateTimeImmutable|int|mixed}\n     */\n    private function getPreviousCursors(mixed $currentCursor, mixed $cursor, mixed $currentCursor2, mixed $cursor2): array\n    {\n        // we need to modify the value to include the last result of the previous page in reverse,\n        // otherwise we will always be missing one result when going back\n        if (null === $currentCursor2) {\n            if ($currentCursor > $cursor) {\n                return [$this->decreaseCursor($cursor), null];\n            } else {\n                return [$this->increaseCursor($cursor), null];\n            }\n        } else {\n            if ($cursor2 >= $this->cursor2LowerLimit) {\n                if ($currentCursor2 > $cursor2 || $this->currentCursor > $cursor) {\n                    return [$cursor, $this->decreaseCursor($cursor2)];\n                } else {\n                    return [$cursor, $this->increaseCursor($cursor2)];\n                }\n            } else {\n                if ($currentCursor > $cursor) {\n                    return [$this->decreaseCursor($cursor), $this->cursor2LowerLimit];\n                } else {\n                    return [$this->increaseCursor($cursor), $this->cursor2LowerLimit];\n                }\n            }\n        }\n    }\n\n    private function decreaseCursor(mixed $cursor): mixed\n    {\n        if ($cursor instanceof \\DateTime || $cursor instanceof \\DateTimeImmutable) {\n            return (new \\DateTimeImmutable())->setTimestamp($cursor->getTimestamp() - 1);\n        } elseif (\\is_int($cursor)) {\n            return --$cursor;\n        }\n\n        return $cursor;\n    }\n\n    private function increaseCursor(mixed $cursor): mixed\n    {\n        if ($cursor instanceof \\DateTime || $cursor instanceof \\DateTimeImmutable) {\n            return (new \\DateTimeImmutable())->setTimestamp($cursor->getTimestamp() + 1);\n        } elseif (\\is_int($cursor)) {\n            return ++$cursor;\n        }\n\n        return $cursor;\n    }\n}\n"
  },
  {
    "path": "src/Pagination/Cursor/CursorPaginationInterface.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Pagination\\Cursor;\n\nuse Pagerfanta\\Exception\\LogicException;\n\n/**\n * @template-covariant T\n * @template-covariant TCursor\n * @template-covariant TCursor2\n *\n * @extends \\IteratorAggregate<T>\n *\n * @method \\Generator<int, T, mixed, void> autoPagingIterator()\n */\ninterface CursorPaginationInterface extends \\IteratorAggregate\n{\n    /**\n     * @return CursorAdapterInterface<T>\n     */\n    public function getAdapter(): CursorAdapterInterface;\n\n    public function setMaxPerPage(int $maxPerPage): self;\n\n    /**\n     * @param TCursor  $cursor\n     * @param TCursor2 $cursor2\n     */\n    public function setCurrentPage(mixed $cursor, mixed $cursor2 = null): self;\n\n    public function getMaxPerPage(): int;\n\n    /**\n     * @return iterable<array-key, T>\n     */\n    public function getCurrentPageResults(): iterable;\n\n    public function haveToPaginate(): bool;\n\n    public function hasNextPage(): bool;\n\n    /**\n     * @return array{0:TCursor, 1:TCursor2}\n     *\n     * @throws LogicException if there is no next page\n     */\n    public function getNextPage(): array;\n\n    public function hasPreviousPage(): bool;\n\n    /**\n     * @return array{0:TCursor, 1:TCursor2}\n     *\n     * @throws LogicException if there is no previous page\n     */\n    public function getPreviousPage(): array;\n\n    /**\n     * @return array{0:TCursor, 1:TCursor2}\n     */\n    public function getCurrentCursor(): array;\n}\n"
  },
  {
    "path": "src/Pagination/Cursor/NativeQueryCursorAdapter.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Pagination\\Cursor;\n\nuse App\\Pagination\\Transformation\\ResultTransformer;\nuse App\\Pagination\\Transformation\\VoidTransformer;\nuse App\\Utils\\SqlHelpers;\nuse Doctrine\\DBAL\\Connection;\nuse Doctrine\\DBAL\\Exception;\n\n/**\n * @template-covariant TCursor\n * @template-covariant TCursor2\n * @template-covariant T\n */\nclass NativeQueryCursorAdapter implements CursorAdapterInterface\n{\n    /**\n     * @param string                $sql         A sql string that is expected to have a %cursor% string which will be populated by either the $forwardCursor or the $backwardCursor\n     *                                           And a %cursorSort% string which will be populated by either the $forwardCursorSort or the $backwardCursorSort.\n     *                                           Optionally you can also specify a secondary cursor (%cursor2%), a secondary cursor condition (%cursorCondition2%) and a secondary sort (%cursorSort2%).\n     * @param array<string, string> $parameters  parameter name as key, parameter value as the value\n     * @param ResultTransformer     $transformer defaults to the VoidTransformer which does not transform the result in any way\n     *\n     * @throws Exception\n     */\n    public function __construct(\n        private readonly Connection $conn,\n        private string $sql,\n        private string $forwardCursorCondition,\n        private string $backwardCursorCondition,\n        private string $forwardCursorSort,\n        private string $backwardCursorSort,\n        private readonly array $parameters,\n        private ?string $secondaryForwardCursorCondition = null,\n        private ?string $secondaryBackwardCursorCondition = null,\n        private ?string $secondaryForwardCursorSort = null,\n        private ?string $secondaryBackwardCursorSort = null,\n        private readonly ResultTransformer $transformer = new VoidTransformer(),\n    ) {\n    }\n\n    /**\n     * @param TCursor  $cursor\n     * @param TCursor2 $cursor2\n     *\n     * @return iterable<array-key, T>\n     *\n     * @throws Exception\n     */\n    public function getSlice(mixed $cursor, mixed $cursor2, int $length): iterable\n    {\n        $replacedSql = str_replace('%cursorSort%', $this->forwardCursorSort, $this->sql);\n        $replacedSql = str_replace('%cursor%', $this->forwardCursorCondition, $replacedSql);\n        if ($this->secondaryForwardCursorSort) {\n            $replacedSql = str_replace('%cursorSort2%', $this->secondaryForwardCursorSort, $replacedSql);\n        }\n        if ($this->secondaryForwardCursorCondition) {\n            $replacedSql = str_replace('%cursor2%', $this->secondaryForwardCursorCondition, $replacedSql);\n        }\n        $sql = $replacedSql.' LIMIT :limit';\n\n        return $this->query($sql, $cursor, $cursor2, $length);\n    }\n\n    public function getPreviousSlice(mixed $cursor, mixed $cursor2, int $length): iterable\n    {\n        $replacedSql = str_replace('%cursorSort%', $this->backwardCursorSort, $this->sql);\n        $replacedSql = str_replace('%cursor%', $this->backwardCursorCondition, $replacedSql);\n        if ($this->secondaryBackwardCursorSort) {\n            $replacedSql = str_replace('%cursorSort2%', $this->secondaryBackwardCursorSort, $replacedSql);\n        }\n        if ($this->secondaryBackwardCursorCondition) {\n            $replacedSql = str_replace('%cursor2%', $this->secondaryBackwardCursorCondition, $replacedSql);\n        }\n        $sql = $replacedSql.' LIMIT :limit';\n\n        return $this->query($sql, $cursor, $cursor2, $length);\n    }\n\n    /**\n     * @throws Exception\n     */\n    private function query(string $sql, mixed $cursor, mixed $cursor2, int $length): iterable\n    {\n        $statement = $this->conn->prepare($sql);\n        foreach ($this->parameters as $key => $value) {\n            $statement->bindValue($key, $value, SqlHelpers::getSqlType($value));\n        }\n        $statement->bindValue('cursor', $cursor, SqlHelpers::getSqlType($cursor));\n        if (str_contains($sql, ':cursor2')) {\n            $statement->bindValue('cursor2', $cursor2, SqlHelpers::getSqlType($cursor2));\n        }\n        $statement->bindValue('limit', $length);\n\n        return $this->transformer->transform($statement->executeQuery()->fetchAllAssociative());\n    }\n}\n"
  },
  {
    "path": "src/Pagination/NativeQueryAdapter.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Pagination;\n\nuse App\\Pagination\\Transformation\\ResultTransformer;\nuse App\\Pagination\\Transformation\\VoidTransformer;\nuse App\\Utils\\SqlHelpers;\nuse Doctrine\\DBAL\\Connection;\nuse Doctrine\\DBAL\\Exception;\nuse Doctrine\\DBAL\\Statement;\nuse Pagerfanta\\Adapter\\AdapterInterface;\nuse Psr\\Cache\\CacheItemInterface;\nuse Symfony\\Contracts\\Cache\\CacheInterface;\n\n/**\n * This adapter only works if your sql does not define an :offset and a :limit parameter. These will be appended.\n */\nclass NativeQueryAdapter implements AdapterInterface\n{\n    private Statement $statement;\n\n    /**\n     * @param int|null          $numOfResults if this is null, then a query will be executed to get the number of results\n     * @param ResultTransformer $transformer  defaults to the VoidTransformer which does not transform the result in any way\n     *\n     * @throws Exception\n     */\n    public function __construct(\n        private readonly Connection $conn,\n        string $sql,\n        private readonly array $parameters,\n        private ?int $numOfResults = null,\n        private readonly ResultTransformer $transformer = new VoidTransformer(),\n        private readonly ?CacheInterface $cache = null,\n    ) {\n        if (null === $this->numOfResults) {\n            $this->numOfResults = $this->calculateNumOfResultsCached($sql, $this->parameters);\n        }\n\n        $this->statement = $this->conn->prepare($sql.' LIMIT :limit OFFSET :offset');\n        foreach ($this->parameters as $key => $value) {\n            $this->statement->bindValue($key, $value, SqlHelpers::getSqlType($value));\n        }\n    }\n\n    private function calculateNumOfResultsCached(string $sql, array $parameters): int\n    {\n        if (null === $this->cache) {\n            return $this->calculateNumOfResults($sql, $parameters);\n        }\n        $sqlHash = hash('sha256', $sql);\n        $parameterHash = hash('sha256', print_r($parameters, true));\n\n        return $this->cache->get(\"native_query_count_$sqlHash-$parameterHash\", function (CacheItemInterface $item) use ($sql, $parameters) {\n            $count = $this->calculateNumOfResults($sql, $parameters);\n            if ($count > 25000) {\n                $item->expiresAfter(new \\DateInterval('PT6H'));\n            } elseif ($count > 10000) {\n                $item->expiresAfter(new \\DateInterval('PT1H'));\n            } elseif ($count > 1000) {\n                $item->expiresAfter(new \\DateInterval('PT10M'));\n            }\n\n            return $count;\n        });\n    }\n\n    private function calculateNumOfResults(string $sql, array $parameters): int\n    {\n        $sql2 = 'SELECT COUNT(*) as cnt FROM ('.$sql.') sub';\n        $stmt2 = $this->conn->prepare($sql2);\n        foreach ($parameters as $key => $value) {\n            $stmt2->bindValue($key, $value, SqlHelpers::getSqlType($value));\n        }\n        $result = $stmt2->executeQuery()->fetchAllAssociative();\n\n        return $result[0]['cnt'];\n    }\n\n    public function getNbResults(): int\n    {\n        return $this->numOfResults;\n    }\n\n    public function getSlice(int $offset, int $length): iterable\n    {\n        $this->statement->bindValue('offset', $offset);\n        $this->statement->bindValue('limit', $length);\n\n        return $this->transformer->transform($this->statement->executeQuery()->fetchAllAssociative());\n    }\n}\n"
  },
  {
    "path": "src/Pagination/Pagerfanta.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Pagination;\n\nuse Pagerfanta\\Adapter\\AdapterInterface;\nuse Pagerfanta\\Exception\\LessThan1CurrentPageException;\nuse Pagerfanta\\Exception\\LessThan1MaxPagesException;\nuse Pagerfanta\\Exception\\LessThan1MaxPerPageException;\nuse Pagerfanta\\Exception\\LogicException;\nuse Pagerfanta\\Exception\\OutOfBoundsException;\nuse Pagerfanta\\Exception\\OutOfRangeCurrentPageException;\nuse Pagerfanta\\PagerfantaInterface;\n\n/**\n * @template T\n *\n * @implements PagerfantaInterface<T>\n */\nclass Pagerfanta implements PagerfantaInterface, \\JsonSerializable\n{\n    /**\n     * @var AdapterInterface<T>\n     */\n    private AdapterInterface $adapter;\n\n    private bool $allowOutOfRangePages = false;\n    private bool $normalizeOutOfRangePages = false;\n\n    /**\n     * @phpstan-var positive-int\n     */\n    private int $maxPerPage = 10;\n\n    /**\n     * @phpstan-var positive-int\n     */\n    private int $currentPage = 1;\n\n    /**\n     * @phpstan-var int<0, max>|null\n     */\n    private ?int $nbResults = null;\n\n    /**\n     * @phpstan-var positive-int|null\n     */\n    private ?int $maxNbPages = null;\n\n    /**\n     * @phpstan-var iterable<array-key, T>|null\n     */\n    private ?iterable $currentPageResults = null;\n\n    /**\n     * @param AdapterInterface<T> $adapter\n     */\n    public function __construct(AdapterInterface $adapter)\n    {\n        $this->adapter = $adapter;\n    }\n\n    /**\n     * @param AdapterInterface<T> $adapter\n     *\n     * @return self<T>\n     */\n    public static function createForCurrentPageWithMaxPerPage(\n        AdapterInterface $adapter,\n        int $currentPage,\n        int $maxPerPage,\n    ): self {\n        $pagerfanta = new self($adapter);\n        $pagerfanta->setMaxPerPage($maxPerPage);\n        $pagerfanta->setCurrentPage($currentPage);\n\n        return $pagerfanta;\n    }\n\n    /**\n     * @return AdapterInterface<T>\n     */\n    public function getAdapter(): AdapterInterface\n    {\n        return $this->adapter;\n    }\n\n    /**\n     * @return $this<T>\n     */\n    public function setAllowOutOfRangePages(bool $allowOutOfRangePages): PagerfantaInterface\n    {\n        $this->allowOutOfRangePages = $allowOutOfRangePages;\n\n        return $this;\n    }\n\n    public function getAllowOutOfRangePages(): bool\n    {\n        return $this->allowOutOfRangePages;\n    }\n\n    public function setNormalizeOutOfRangePages(bool $normalizeOutOfRangePages): PagerfantaInterface\n    {\n        $this->normalizeOutOfRangePages = $normalizeOutOfRangePages;\n\n        return $this;\n    }\n\n    public function getNormalizeOutOfRangePages(): bool\n    {\n        return $this->normalizeOutOfRangePages;\n    }\n\n    /**\n     * @return $this<T>\n     *\n     * @throws LessThan1MaxPerPageException if the page is less than 1\n     */\n    public function setMaxPerPage(int $maxPerPage): PagerfantaInterface\n    {\n        $this->filterMaxPerPage($maxPerPage);\n\n        $this->maxPerPage = $maxPerPage;\n        $this->resetForMaxPerPageChange();\n        $this->filterOutOfRangeCurrentPage($this->currentPage);\n\n        return $this;\n    }\n\n    private function filterMaxPerPage(int $maxPerPage): void\n    {\n        $this->checkMaxPerPage($maxPerPage);\n    }\n\n    /**\n     * @throws LessThan1MaxPerPageException if the page is less than 1\n     */\n    private function checkMaxPerPage(int $maxPerPage): void\n    {\n        if ($maxPerPage < 1) {\n            throw new LessThan1MaxPerPageException();\n        }\n    }\n\n    private function resetForMaxPerPageChange(): void\n    {\n        $this->currentPageResults = null;\n    }\n\n    /**\n     * @phpstan-return positive-int\n     */\n    public function getMaxPerPage(): int\n    {\n        return $this->maxPerPage;\n    }\n\n    /**\n     * @return $this<T>\n     *\n     * @throws LessThan1CurrentPageException  if the current page is less than 1\n     * @throws OutOfRangeCurrentPageException if It is not allowed out of range pages and they are not normalized\n     */\n    public function setCurrentPage(int $currentPage): PagerfantaInterface\n    {\n        $this->currentPage = $this->filterCurrentPage($currentPage);\n        $this->resetForCurrentPageChange();\n\n        return $this;\n    }\n\n    /**\n     * @phpstan-return positive-int\n     */\n    private function filterCurrentPage(int $currentPage): int\n    {\n        $this->checkCurrentPage($currentPage);\n\n        return $this->filterOutOfRangeCurrentPage($currentPage);\n    }\n\n    /**\n     * @throws LessThan1CurrentPageException if the current page is less than 1\n     */\n    private function checkCurrentPage(int $currentPage): void\n    {\n        if ($currentPage < 1) {\n            throw new LessThan1CurrentPageException();\n        }\n    }\n\n    /**\n     * @phpstan-return positive-int\n     */\n    private function filterOutOfRangeCurrentPage(int $currentPage): int\n    {\n        if ($this->notAllowedCurrentPageOutOfRange($currentPage)) {\n            return $this->normalizeOutOfRangeCurrentPage($currentPage);\n        }\n\n        return $currentPage;\n    }\n\n    private function notAllowedCurrentPageOutOfRange(int $currentPage): bool\n    {\n        return !$this->getAllowOutOfRangePages() && $this->currentPageOutOfRange($currentPage);\n    }\n\n    private function currentPageOutOfRange(int $currentPage): bool\n    {\n        return $currentPage > 1 && $currentPage > $this->getNbPages();\n    }\n\n    /**\n     * @phpstan-return positive-int\n     *\n     * @throws OutOfRangeCurrentPageException if the page should not be normalized\n     */\n    private function normalizeOutOfRangeCurrentPage(int $currentPage): int\n    {\n        if ($this->getNormalizeOutOfRangePages()) {\n            return $this->getNbPages();\n        }\n\n        throw new OutOfRangeCurrentPageException(\\sprintf('Page \"%d\" does not exist. The currentPage must be inferior to \"%d\"', $currentPage, $this->getNbPages()));\n    }\n\n    private function resetForCurrentPageChange(): void\n    {\n        $this->currentPageResults = null;\n    }\n\n    /**\n     * @phpstan-return positive-int\n     */\n    public function getCurrentPage(): int\n    {\n        return $this->currentPage;\n    }\n\n    /**\n     * @return iterable<array-key, T>\n     */\n    public function getCurrentPageResults(): iterable\n    {\n        if (null === $this->currentPageResults) {\n            $this->currentPageResults = $this->getCurrentPageResultsFromAdapter();\n        }\n\n        return $this->currentPageResults;\n    }\n\n    public function setCurrentPageResults(?iterable $paginator): void\n    {\n        $this->currentPageResults = $paginator;\n    }\n\n    /**\n     * @return iterable<array-key, T>\n     */\n    private function getCurrentPageResultsFromAdapter(): iterable\n    {\n        $offset = $this->calculateOffsetForCurrentPageResults();\n        $length = $this->getMaxPerPage();\n\n        return $this->getAdapter()->getSlice($offset, $length);\n    }\n\n    /**\n     * @phpstan-return int<0, max>\n     */\n    private function calculateOffsetForCurrentPageResults(): int\n    {\n        return ($this->getCurrentPage() - 1) * $this->getMaxPerPage();\n    }\n\n    /**\n     * @phpstan-return int<0, max>\n     */\n    public function getCurrentPageOffsetStart(): int\n    {\n        return 0 !== $this->getNbResults() ? $this->calculateOffsetForCurrentPageResults() + 1 : 0;\n    }\n\n    /**\n     * @phpstan-return int<0, max>\n     */\n    public function getCurrentPageOffsetEnd(): int\n    {\n        return $this->hasNextPage() ? $this->getCurrentPage() * $this->getMaxPerPage() : $this->getNbResults();\n    }\n\n    /**\n     * @phpstan-return int<0, max>\n     */\n    public function getNbResults(): int\n    {\n        if (null === $this->nbResults) {\n            $this->nbResults = $this->getAdapter()->getNbResults();\n        }\n\n        return $this->nbResults;\n    }\n\n    /**\n     * @phpstan-return positive-int\n     */\n    public function getNbPages(): int\n    {\n        $nbPages = $this->calculateNbPages();\n\n        if (0 === $nbPages) {\n            return $this->minimumNbPages();\n        }\n\n        if (null !== $this->maxNbPages && $this->maxNbPages < $nbPages) {\n            return $this->maxNbPages;\n        }\n\n        return $nbPages;\n    }\n\n    /**\n     * @phpstan-return int<0, max>\n     */\n    private function calculateNbPages(): int\n    {\n        return (int) ceil($this->getNbResults() / $this->getMaxPerPage());\n    }\n\n    /**\n     * @phpstan-return positive-int\n     */\n    private function minimumNbPages(): int\n    {\n        return 1;\n    }\n\n    /**\n     * @return $this<T>\n     *\n     * @throws LessThan1MaxPagesException if the max number of pages is less than 1\n     */\n    public function setMaxNbPages(int $maxNbPages): PagerfantaInterface\n    {\n        if ($maxNbPages < 1) {\n            throw new LessThan1MaxPagesException();\n        }\n\n        $this->maxNbPages = $maxNbPages;\n\n        return $this;\n    }\n\n    /**\n     * @return $this<T>\n     */\n    public function resetMaxNbPages(): PagerfantaInterface\n    {\n        $this->maxNbPages = null;\n\n        return $this;\n    }\n\n    public function haveToPaginate(): bool\n    {\n        return $this->getNbResults() > $this->maxPerPage;\n    }\n\n    public function hasPreviousPage(): bool\n    {\n        return $this->currentPage > 1;\n    }\n\n    /**\n     * @phpstan-return positive-int\n     *\n     * @throws LogicException if there is no previous page\n     */\n    public function getPreviousPage(): int\n    {\n        if (!$this->hasPreviousPage()) {\n            throw new LogicException('There is no previous page.');\n        }\n\n        return $this->currentPage - 1;\n    }\n\n    public function hasNextPage(): bool\n    {\n        return $this->currentPage < $this->getNbPages();\n    }\n\n    /**\n     * @phpstan-return positive-int\n     *\n     * @throws LogicException if there is no next page\n     */\n    public function getNextPage(): int\n    {\n        if (!$this->hasNextPage()) {\n            throw new LogicException('There is no next page.');\n        }\n\n        return $this->currentPage + 1;\n    }\n\n    /**\n     * @phpstan-return int<0, max>\n     */\n    public function count(): int\n    {\n        return $this->getNbResults();\n    }\n\n    /**\n     * @return \\Traversable<array-key, T>\n     */\n    public function getIterator(): \\Traversable\n    {\n        $results = $this->getCurrentPageResults();\n\n        if ($results instanceof \\Iterator) {\n            return $results;\n        }\n\n        if ($results instanceof \\IteratorAggregate) {\n            return $results->getIterator();\n        }\n\n        if (\\is_array($results)) {\n            return new \\ArrayIterator($results);\n        }\n\n        throw new \\InvalidArgumentException(\\sprintf('Cannot create iterator with page results of type \"%s\".', get_debug_type($results)));\n    }\n\n    public function jsonSerialize(): array\n    {\n        $results = $this->getCurrentPageResults();\n\n        if ($results instanceof \\Traversable) {\n            return iterator_to_array($results);\n        }\n\n        return $results;\n    }\n\n    /**\n     * Get page number of the item at specified position (1-based index).\n     *\n     * @phpstan-param positive-int $position\n     *\n     * @phpstan-return positive-int\n     *\n     * @throws OutOfBoundsException if the item is outside the result set\n     */\n    public function getPageNumberForItemAtPosition(int $position): int\n    {\n        if ($this->getNbResults() < $position) {\n            throw new OutOfBoundsException(\\sprintf('Item requested at position %d, but there are only %d items.', $position, $this->getNbResults()));\n        }\n\n        return (int) ceil($position / $this->getMaxPerPage());\n    }\n\n    public function autoPagingIterator(): \\Generator\n    {\n        while (true) {\n            foreach ($this->getCurrentPageResults() as $item) {\n                yield $item;\n            }\n\n            if (!$this->hasNextPage()) {\n                break;\n            }\n            $this->setCurrentPage($this->getNextPage());\n        }\n    }\n}\n"
  },
  {
    "path": "src/Pagination/QueryAdapter.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Pagination;\n\nuse Doctrine\\ORM\\Query;\nuse Doctrine\\ORM\\QueryBuilder;\nuse Doctrine\\ORM\\Tools\\Pagination\\Paginator;\nuse Pagerfanta\\Adapter\\AdapterInterface;\n\n/**\n * Adapter which calculates pagination from a Doctrine ORM Query or QueryBuilder.\n *\n * @template T\n *\n * @implements AdapterInterface<T>\n */\nreadonly class QueryAdapter implements AdapterInterface\n{\n    /**\n     * @var Paginator<T>\n     */\n    protected Paginator $paginator;\n\n    /**\n     * @param bool      $fetchJoinCollection Whether the query joins a collection (true by default)\n     * @param bool|null $useOutputWalkers    Flag indicating whether output walkers are used in the paginator\n     */\n    public function __construct(\n        private Query|QueryBuilder $query,\n        bool $fetchJoinCollection = true,\n        ?bool $useOutputWalkers = null,\n    ) {\n        $this->paginator = new Paginator($query, $fetchJoinCollection);\n        $this->paginator->setUseOutputWalkers($useOutputWalkers);\n    }\n\n    /**\n     * @phpstan-return int<0, max>\n     */\n    public function getNbResults(): int\n    {\n        return $this->paginator->count();\n    }\n\n    /**\n     * @phpstan-param int<0, max> $offset\n     * @phpstan-param int<0, max> $length\n     *\n     * @return \\Traversable<array-key, T>\n     */\n    public function getSlice(int $offset, int $length): iterable\n    {\n        $this->paginator->getQuery()\n            ->setFirstResult($offset)\n            ->setMaxResults($length);\n\n        return $this->paginator->getIterator();\n    }\n\n    public function getQuery(): Query|QueryBuilder\n    {\n        return $this->query;\n    }\n}\n"
  },
  {
    "path": "src/Pagination/Transformation/ContentPopulationTransformer.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Pagination\\Transformation;\n\nuse App\\Entity\\Entry;\nuse App\\Entity\\EntryComment;\nuse App\\Entity\\Image;\nuse App\\Entity\\Magazine;\nuse App\\Entity\\Post;\nuse App\\Entity\\PostComment;\nuse App\\Entity\\User;\nuse App\\Utils\\SqlHelpers;\nuse Doctrine\\ORM\\EntityManagerInterface;\n\nclass ContentPopulationTransformer implements ResultTransformer\n{\n    public function __construct(\n        private readonly EntityManagerInterface $entityManager,\n    ) {\n    }\n\n    public function transform(iterable $input): iterable\n    {\n        $entryRepository = $this->entityManager->getRepository(Entry::class);\n        $entryCommentRepository = $this->entityManager->getRepository(EntryComment::class);\n        $postRepository = $this->entityManager->getRepository(Post::class);\n        $postCommentRepository = $this->entityManager->getRepository(PostComment::class);\n        $magazineRepository = $this->entityManager->getRepository(Magazine::class);\n        $userRepository = $this->entityManager->getRepository(User::class);\n        $imageRepository = $this->entityManager->getRepository(Image::class);\n\n        $positionsArray = $this->buildPositionArray($input);\n        $entryIds = $this->getOverviewIds((array) $input, 'entry');\n        if (\\count($entryIds) > 0) {\n            $entries = $entryRepository->findBy(['id' => $entryIds]);\n            $entryRepository->hydrate(...$entries);\n        }\n\n        $entryCommentIds = $this->getOverviewIds((array) $input, 'entry_comment');\n        if (\\count($entryCommentIds) > 0) {\n            $entryComments = $entryCommentRepository->findBy(['id' => $entryCommentIds]);\n            $entryCommentRepository->hydrate(...$entryComments);\n        }\n\n        $postIds = $this->getOverviewIds((array) $input, 'post');\n        if (\\count($postIds) > 0) {\n            $post = $postRepository->findBy(['id' => $postIds]);\n            $postRepository->hydrate(...$post);\n        }\n\n        $postCommentIds = $this->getOverviewIds((array) $input, 'post_comment');\n        if (\\count($postCommentIds) > 0) {\n            $postComment = $postCommentRepository->findBy(['id' => $postCommentIds]);\n            $postCommentRepository->hydrate(...$postComment);\n        }\n\n        $magazineIds = $this->getOverviewIds((array) $input, 'magazine');\n        if (\\count($magazineIds) > 0) {\n            $magazines = $magazineRepository->findBy(['id' => $magazineIds]);\n        }\n\n        $userIds = $this->getOverviewIds((array) $input, 'user');\n        if (\\count($userIds) > 0) {\n            $users = $userRepository->findBy(['id' => $userIds]);\n        }\n\n        $imageIds = $this->getOverviewIds((array) $input, 'image');\n        if (\\count($imageIds) > 0) {\n            $images = SqlHelpers::findByAdjusted($imageRepository, 'id', $imageIds);\n        }\n\n        return $this->applyPositions($positionsArray, $entries ?? [], $entryComments ?? [], $post ?? [], $postComment ?? [], $magazines ?? [], $users ?? [], $images ?? []);\n    }\n\n    private function getOverviewIds(array $result, string $type): array\n    {\n        $result = array_filter($result, fn ($subject) => $subject['type'] === $type);\n\n        return array_map(fn ($subject) => $subject['id'], $result);\n    }\n\n    /**\n     * @return int[][]\n     */\n    private function buildPositionArray(iterable $input): array\n    {\n        $entryPositions = [];\n        $entryCommentPositions = [];\n        $postPositions = [];\n        $postCommentPositions = [];\n        $userPositions = [];\n        $magazinePositions = [];\n        $imagePositions = [];\n        $i = 0;\n        foreach ($input as $current) {\n            switch ($current['type']) {\n                case 'entry':\n                    $entryPositions[$current['id']] = $i;\n                    break;\n                case 'entry_comment':\n                    $entryCommentPositions[$current['id']] = $i;\n                    break;\n                case 'post':\n                    $postPositions[$current['id']] = $i;\n                    break;\n                case 'post_comment':\n                    $postCommentPositions[$current['id']] = $i;\n                    break;\n                case 'magazine':\n                    $magazinePositions[$current['id']] = $i;\n                    break;\n                case 'user':\n                    $userPositions[$current['id']] = $i;\n                    break;\n                case 'image':\n                    $imagePositions[$current['id']] = $i;\n                    break;\n            }\n            ++$i;\n        }\n\n        return [\n            'entry' => $entryPositions,\n            'entry_comment' => $entryCommentPositions,\n            'post' => $postPositions,\n            'post_comment' => $postCommentPositions,\n            'magazine' => $magazinePositions,\n            'user' => $userPositions,\n            'image' => $imagePositions,\n        ];\n    }\n\n    /**\n     * @param int[][]        $positionsArray\n     * @param Entry[]        $entries\n     * @param EntryComment[] $entryComments\n     * @param Post[]         $posts\n     * @param PostComment[]  $postComments\n     * @param User[]         $users\n     * @param Image[]        $images\n     */\n    private function applyPositions(array $positionsArray, array $entries, array $entryComments, array $posts, array $postComments, array $magazines, array $users, array $images): array\n    {\n        $result = [];\n        foreach ($entries as $entry) {\n            $result[$positionsArray['entry'][$entry->getId()]] = $entry;\n        }\n        foreach ($entryComments as $entryComment) {\n            $result[$positionsArray['entry_comment'][$entryComment->getId()]] = $entryComment;\n        }\n        foreach ($posts as $post) {\n            $result[$positionsArray['post'][$post->getId()]] = $post;\n        }\n        foreach ($postComments as $postComment) {\n            $result[$positionsArray['post_comment'][$postComment->getId()]] = $postComment;\n        }\n        foreach ($magazines as $magazine) {\n            $result[$positionsArray['magazine'][$magazine->getId()]] = $magazine;\n        }\n        foreach ($users as $user) {\n            $result[$positionsArray['user'][$user->getId()]] = $user;\n        }\n        foreach ($images as $image) {\n            $result[$positionsArray['image'][$image->getId()]] = $image;\n        }\n        ksort($result, SORT_NUMERIC);\n\n        return $result;\n    }\n}\n"
  },
  {
    "path": "src/Pagination/Transformation/ResultTransformer.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Pagination\\Transformation;\n\ninterface ResultTransformer\n{\n    public function transform(iterable $input): iterable;\n}\n"
  },
  {
    "path": "src/Pagination/Transformation/VoidTransformer.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Pagination\\Transformation;\n\nclass VoidTransformer implements ResultTransformer\n{\n    public function transform(iterable $input): iterable\n    {\n        return $input;\n    }\n}\n"
  },
  {
    "path": "src/Payloads/NodeInfo/NodeInfo.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Payloads\\NodeInfo;\n\nclass NodeInfo\n{\n    public ?string $version = null;\n    public ?NodeInfoSoftware $software = null;\n    /** @var string[] */\n    public ?array $protocols = null;\n    public bool $openRegistrations = false;\n    public ?NodeInfoUsage $usage = null;\n    public ?NodeInfoServices $services = null;\n}\n"
  },
  {
    "path": "src/Payloads/NodeInfo/NodeInfoServices.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Payloads\\NodeInfo;\n\nclass NodeInfoServices\n{\n    public array $inbound;\n    public array $outbound;\n}\n"
  },
  {
    "path": "src/Payloads/NodeInfo/NodeInfoSoftware.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Payloads\\NodeInfo;\n\nclass NodeInfoSoftware\n{\n    public ?string $name = null;\n    public ?string $version = null;\n}\n"
  },
  {
    "path": "src/Payloads/NodeInfo/NodeInfoSoftware21.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Payloads\\NodeInfo;\n\nclass NodeInfoSoftware21 extends NodeInfoSoftware\n{\n    public string $repository;\n}\n"
  },
  {
    "path": "src/Payloads/NodeInfo/NodeInfoUsage.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Payloads\\NodeInfo;\n\nclass NodeInfoUsage\n{\n    public NodeInfoUsageUsers $users;\n    public int $localPosts;\n    public int $localComments;\n}\n"
  },
  {
    "path": "src/Payloads/NodeInfo/NodeInfoUsageUsers.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Payloads\\NodeInfo;\n\nclass NodeInfoUsageUsers\n{\n    public int $total;\n    public ?int $activeHalfYear = 0;\n    public ?int $activeMonth = 0;\n}\n"
  },
  {
    "path": "src/Payloads/NodeInfo/WellKnownEndpoint.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Payloads\\NodeInfo;\n\nclass WellKnownEndpoint\n{\n    public string $rel;\n    public string $href;\n}\n"
  },
  {
    "path": "src/Payloads/NodeInfo/WellKnownNodeInfo.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Payloads\\NodeInfo;\n\nclass WellKnownNodeInfo\n{\n    /** @var WellKnownEndpoint[] */\n    public array $links;\n}\n"
  },
  {
    "path": "src/Payloads/NotificationsCountResponsePayload.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Payloads;\n\nclass NotificationsCountResponsePayload\n{\n    public function __construct(\n        public int $notifications,\n        public int $messages,\n    ) {\n    }\n}\n"
  },
  {
    "path": "src/Payloads/PushNotification.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Payloads;\n\nuse App\\Enums\\EPushNotificationType;\n\nclass PushNotification\n{\n    public function __construct(\n        // The id of the notification entity\n        public ?int $id,\n        public string $message,\n        public string $title,\n        public ?string $actionUrl = null,\n        public ?string $avatarUrl = null,\n        public string $iconUrl = '/assets/icons/icon-192-maskable.png',\n        public string $badgeUrl = '/assets/icons/icon-96-maskable-bw.png',\n        public EPushNotificationType $category = EPushNotificationType::Notification,\n    ) {\n    }\n}\n"
  },
  {
    "path": "src/Payloads/RegisterPushRequestPayload.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Payloads;\n\nclass RegisterPushRequestPayload\n{\n    public string $endpoint;\n    public string $serverKey;\n    public string $deviceKey;\n    public string $contentPublicKey;\n}\n"
  },
  {
    "path": "src/Payloads/TestPushRequestPayload.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Payloads;\n\nclass TestPushRequestPayload\n{\n    public string $deviceKey;\n}\n"
  },
  {
    "path": "src/Payloads/UnRegisterPushRequestPayload.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Payloads;\n\nclass UnRegisterPushRequestPayload\n{\n    public string $deviceKey;\n}\n"
  },
  {
    "path": "src/Provider/Authentik.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Provider;\n\nuse League\\OAuth2\\Client\\Provider\\AbstractProvider;\nuse League\\OAuth2\\Client\\Provider\\Exception\\IdentityProviderException;\nuse League\\OAuth2\\Client\\Token\\AccessToken;\nuse League\\OAuth2\\Client\\Tool\\BearerAuthorizationTrait;\nuse Psr\\Http\\Message\\ResponseInterface;\n\nclass Authentik extends AbstractProvider\n{\n    use BearerAuthorizationTrait;\n\n    protected $baseUrl;\n\n    public function __construct(array $options = [], array $collaborators = [])\n    {\n        $this->baseUrl = $options['base_url'] ?? '';\n\n        parent::__construct($options, $collaborators);\n    }\n\n    protected function getBaseUrl(): string\n    {\n        return rtrim($this->baseUrl, '/').'/';\n    }\n\n    protected function getAuthorizationHeaders($token = null): array\n    {\n        return ['Authorization' => 'Bearer '.$token];\n    }\n\n    public function getBaseAuthorizationUrl(): string\n    {\n        return $this->getBaseUrl().'application/o/authorize/';\n    }\n\n    public function getBaseAccessTokenUrl(array $params): string\n    {\n        return $this->getBaseUrl().'application/o/token/';\n    }\n\n    public function getResourceOwnerDetailsUrl(AccessToken $token): string\n    {\n        return $this->getBaseUrl().'application/o/userinfo/';\n    }\n\n    protected function getDefaultScopes(): array\n    {\n        return ['openid', 'profile', 'email'];\n    }\n\n    protected function checkResponse(ResponseInterface $response, $data): void\n    {\n        if (!empty($data['error'])) {\n            $error = htmlentities($data['error'], ENT_QUOTES, 'UTF-8');\n            $message = htmlentities($data['error_description'], ENT_QUOTES, 'UTF-8');\n            throw new IdentityProviderException($message, $response->getStatusCode(), $response);\n        }\n    }\n\n    protected function createResourceOwner(array $response, AccessToken $token): AuthentikResourceOwner\n    {\n        return new AuthentikResourceOwner($response);\n    }\n\n    protected function getScopeSeparator(): string\n    {\n        return ' ';\n    }\n}\n"
  },
  {
    "path": "src/Provider/AuthentikResourceOwner.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Provider;\n\nuse League\\OAuth2\\Client\\Provider\\ResourceOwnerInterface;\n\nclass AuthentikResourceOwner implements ResourceOwnerInterface\n{\n    protected $response;\n\n    public function __construct(array $response)\n    {\n        $this->response = $response;\n    }\n\n    public function getId(): mixed\n    {\n        return $this->getResponseValue('sub');\n    }\n\n    public function getEmail(): mixed\n    {\n        return $this->getResponseValue('email');\n    }\n\n    public function getFamilyName(): mixed\n    {\n        return $this->getResponseValue('family_name');\n    }\n\n    public function getGivenName(): mixed\n    {\n        return $this->getResponseValue('given_name');\n    }\n\n    public function getPreferredUsername(): mixed\n    {\n        return $this->getResponseValue('preferred_username');\n    }\n\n    public function getPictureUrl(): mixed\n    {\n        return $this->getResponseValue('picture');\n    }\n\n    public function toArray(): array\n    {\n        return $this->response;\n    }\n\n    protected function getResponseValue($key): mixed\n    {\n        $keys = explode('.', $key);\n        $value = $this->response;\n\n        foreach ($keys as $k) {\n            if (isset($value[$k])) {\n                $value = $value[$k];\n            } else {\n                return null;\n            }\n        }\n\n        return $value;\n    }\n}\n"
  },
  {
    "path": "src/Provider/SimpleLogin.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Provider;\n\nuse League\\OAuth2\\Client\\Provider\\AbstractProvider;\nuse League\\OAuth2\\Client\\Provider\\Exception\\IdentityProviderException;\nuse League\\OAuth2\\Client\\Token\\AccessToken;\nuse League\\OAuth2\\Client\\Tool\\BearerAuthorizationTrait;\nuse Psr\\Http\\Message\\ResponseInterface;\n\nclass SimpleLogin extends AbstractProvider\n{\n    use BearerAuthorizationTrait;\n\n    protected $baseUrl = 'https://app.simplelogin.io/';\n\n    public function __construct(array $options = [], array $collaborators = [])\n    {\n        parent::__construct($options, $collaborators);\n    }\n\n    protected function getBaseUrl(): string\n    {\n        return rtrim($this->baseUrl, '/').'/';\n    }\n\n    protected function getAuthorizationHeaders($token = null): array\n    {\n        return ['Authorization' => 'Bearer '.$token];\n    }\n\n    public function getBaseAuthorizationUrl(): string\n    {\n        return $this->getBaseUrl().'oauth2/authorize';\n    }\n\n    public function getBaseAccessTokenUrl(array $params): string\n    {\n        return $this->getBaseUrl().'oauth2/token';\n    }\n\n    public function getResourceOwnerDetailsUrl(AccessToken $token): string\n    {\n        return $this->getBaseUrl().'oauth2/userinfo';\n    }\n\n    protected function getDefaultScopes(): array\n    {\n        return ['openid', 'profile', 'email'];\n    }\n\n    protected function checkResponse(ResponseInterface $response, $data): void\n    {\n        if (!empty($data['error'])) {\n            $error = htmlentities($data['error'], ENT_QUOTES, 'UTF-8');\n            $message = htmlentities($data['error_description'], ENT_QUOTES, 'UTF-8');\n            throw new IdentityProviderException($message, $response->getStatusCode(), $response);\n        }\n    }\n\n    protected function createResourceOwner(array $response, AccessToken $token): SimpleLoginResourceOwner\n    {\n        return new SimpleLoginResourceOwner($response);\n    }\n\n    protected function getScopeSeparator(): string\n    {\n        return ' ';\n    }\n}\n"
  },
  {
    "path": "src/Provider/SimpleLoginResourceOwner.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Provider;\n\nuse League\\OAuth2\\Client\\Provider\\ResourceOwnerInterface;\n\nclass SimpleLoginResourceOwner implements ResourceOwnerInterface\n{\n    protected $response;\n\n    public function __construct(array $response)\n    {\n        $this->response = $response;\n    }\n\n    public function getId(): mixed\n    {\n        return $this->getResponseValue('sub');\n    }\n\n    public function getName(): mixed\n    {\n        return $this->getResponseValue('name');\n    }\n\n    public function getEmail(): mixed\n    {\n        return $this->getResponseValue('email');\n    }\n\n    public function getPictureUrl(): mixed\n    {\n        return $this->getResponseValue('avatar_url');\n    }\n\n    public function toArray(): array\n    {\n        return $this->response;\n    }\n\n    protected function getResponseValue($key): mixed\n    {\n        $keys = explode('.', $key);\n        $value = $this->response;\n\n        foreach ($keys as $k) {\n            if (isset($value[$k])) {\n                $value = $value[$k];\n            } else {\n                return null;\n            }\n        }\n\n        return $value;\n    }\n}\n"
  },
  {
    "path": "src/Provider/Zitadel.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Provider;\n\nuse League\\OAuth2\\Client\\Provider\\AbstractProvider;\nuse League\\OAuth2\\Client\\Provider\\Exception\\IdentityProviderException;\nuse League\\OAuth2\\Client\\Token\\AccessToken;\nuse League\\OAuth2\\Client\\Tool\\BearerAuthorizationTrait;\nuse Psr\\Http\\Message\\ResponseInterface;\n\nclass Zitadel extends AbstractProvider\n{\n    use BearerAuthorizationTrait;\n\n    protected $baseUrl;\n\n    public function __construct(array $options = [], array $collaborators = [])\n    {\n        $this->baseUrl = $options['base_url'] ?? '';\n\n        parent::__construct($options, $collaborators);\n    }\n\n    protected function getBaseUrl(): string\n    {\n        return rtrim($this->baseUrl, '/').'/';\n    }\n\n    protected function getAuthorizationHeaders($token = null): array\n    {\n        return ['Authorization' => 'Bearer '.$token];\n    }\n\n    public function getBaseAuthorizationUrl(): string\n    {\n        return $this->getBaseUrl().'oauth/v2/authorize';\n    }\n\n    public function getBaseAccessTokenUrl(array $params): string\n    {\n        return $this->getBaseUrl().'oauth/v2/token';\n    }\n\n    public function getResourceOwnerDetailsUrl(AccessToken $token): string\n    {\n        return $this->getBaseUrl().'oidc/v1/userinfo';\n    }\n\n    protected function getDefaultScopes(): array\n    {\n        return ['openid', 'profile', 'email'];\n    }\n\n    protected function checkResponse(ResponseInterface $response, $data): void\n    {\n        if (!empty($data['error'])) {\n            $error = htmlentities($data['error'], ENT_QUOTES, 'UTF-8');\n            $message = htmlentities($data['error_description'], ENT_QUOTES, 'UTF-8');\n            throw new IdentityProviderException($message, $response->getStatusCode(), $response);\n        }\n    }\n\n    protected function createResourceOwner(array $response, AccessToken $token): ZitadelResourceOwner\n    {\n        return new ZitadelResourceOwner($response);\n    }\n\n    protected function getScopeSeparator(): string\n    {\n        return ' ';\n    }\n}\n"
  },
  {
    "path": "src/Provider/ZitadelResourceOwner.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Provider;\n\nuse League\\OAuth2\\Client\\Provider\\ResourceOwnerInterface;\n\nclass ZitadelResourceOwner implements ResourceOwnerInterface\n{\n    protected $response;\n\n    public function __construct(array $response)\n    {\n        $this->response = $response;\n    }\n\n    public function getId(): mixed\n    {\n        return $this->getResponseValue('sub');\n    }\n\n    public function getEmail(): mixed\n    {\n        return $this->getResponseValue('email');\n    }\n\n    public function getFamilyName(): mixed\n    {\n        return $this->getResponseValue('family_name');\n    }\n\n    public function getGivenName(): mixed\n    {\n        return $this->getResponseValue('given_name');\n    }\n\n    public function getPreferredUsername(): mixed\n    {\n        return $this->getResponseValue('preferred_username');\n    }\n\n    public function getPictureUrl(): mixed\n    {\n        return $this->getResponseValue('picture');\n    }\n\n    public function toArray(): array\n    {\n        return $this->response;\n    }\n\n    protected function getResponseValue($key): mixed\n    {\n        $keys = explode('.', $key);\n        $value = $this->response;\n\n        foreach ($keys as $k) {\n            if (isset($value[$k])) {\n                $value = $value[$k];\n            } else {\n                return null;\n            }\n        }\n\n        return $value;\n    }\n}\n"
  },
  {
    "path": "src/Repository/.gitignore",
    "content": ""
  },
  {
    "path": "src/Repository/ActivityRepository.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Repository;\n\nuse App\\Entity\\Activity;\nuse App\\Entity\\Contracts\\ActivityPubActivityInterface;\nuse App\\Entity\\Contracts\\ActivityPubActorInterface;\nuse App\\Entity\\Contracts\\VisibilityInterface;\nuse App\\Entity\\Entry;\nuse App\\Entity\\EntryComment;\nuse App\\Entity\\Magazine;\nuse App\\Entity\\MagazineBan;\nuse App\\Entity\\Message;\nuse App\\Entity\\Post;\nuse App\\Entity\\PostComment;\nuse App\\Entity\\User;\nuse App\\Pagination\\Pagerfanta;\nuse App\\Pagination\\QueryAdapter;\nuse Doctrine\\Bundle\\DoctrineBundle\\Repository\\ServiceEntityRepository;\nuse Doctrine\\ORM\\QueryBuilder;\nuse Doctrine\\Persistence\\ManagerRegistry;\nuse Pagerfanta\\Adapter\\ArrayAdapter;\nuse Pagerfanta\\PagerfantaInterface;\n\n/**\n * @method Activity|null find($id, $lockMode = null, $lockVersion = null)\n * @method Activity|null findOneBy(array $criteria, array $orderBy = null)\n * @method Activity|null findOneByName(string $name)\n * @method Activity[]    findAll()\n * @method Activity[]    findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)\n */\nclass ActivityRepository extends ServiceEntityRepository\n{\n    public function __construct(ManagerRegistry $registry)\n    {\n        parent::__construct($registry, Activity::class);\n    }\n\n    public function findFirstActivitiesByTypeAndObject(string $type, ActivityPubActivityInterface|ActivityPubActorInterface|MagazineBan $object): ?Activity\n    {\n        $results = $this->findAllActivitiesByTypeAndObject($type, $object);\n        if (!empty($results)) {\n            return $results[0];\n        }\n\n        return null;\n    }\n\n    /**\n     * @return Activity[]|null\n     */\n    public function findAllActivitiesByTypeAndObject(string $type, ActivityPubActivityInterface|ActivityPubActorInterface|MagazineBan $object): ?array\n    {\n        $qb = $this->createQueryBuilder('a');\n        $qb->where('a.type = :type');\n        $qb->setParameter('type', $type);\n\n        $this->addObjectFilter($qb, $object);\n\n        return $qb->getQuery()->getResult();\n    }\n\n    public function findFirstActivitiesByTypeObjectAndActor(string $type, ActivityPubActivityInterface|ActivityPubActorInterface $object, ActivityPubActorInterface $actor): ?Activity\n    {\n        $results = $this->findAllActivitiesByTypeObjectAndActor($type, $object, $actor);\n        if (!empty($results)) {\n            return $results[0];\n        }\n\n        return null;\n    }\n\n    /**\n     * @return Activity[]|null\n     */\n    public function findAllActivitiesByTypeObjectAndActor(string $type, Activity|ActivityPubActivityInterface|ActivityPubActorInterface|string $object, ActivityPubActorInterface $actor): ?array\n    {\n        $qb = $this->createQueryBuilder('a');\n        $qb->where('a.type = :type');\n        $qb->setParameter('type', $type);\n\n        $this->addObjectFilter($qb, $object);\n\n        if ($actor instanceof User) {\n            $qb->andWhere('a.userActor = :user');\n            $qb->setParameter('user', $actor);\n        } elseif ($actor instanceof Magazine) {\n            $qb->andWhere('a.magazineActor = :magazine');\n            $qb->setParameter('magazine', $actor);\n        } else {\n            throw new \\LogicException('Only magazine and user actors supported');\n        }\n\n        return $qb->getQuery()->getResult();\n    }\n\n    /**\n     * @return Activity[]|null\n     */\n    public function findAllActivitiesByObject(ActivityPubActivityInterface|ActivityPubActorInterface $object): ?array\n    {\n        $qb = $this->createQueryBuilder('a');\n\n        $this->addObjectFilter($qb, $object);\n\n        return $qb->getQuery()->getResult();\n    }\n\n    private function addObjectFilter(QueryBuilder $qb, Activity|ActivityPubActivityInterface|ActivityPubActorInterface|MagazineBan|string $object): void\n    {\n        if ($object instanceof Entry) {\n            $qb->andWhere('a.objectEntry = :entry')\n                ->setParameter('entry', $object);\n        } elseif ($object instanceof EntryComment) {\n            $qb->andWhere('a.objectEntryComment = :entryComment')\n                ->setParameter('entryComment', $object);\n        } elseif ($object instanceof Post) {\n            $qb->andWhere('a.objectPost = :post')\n                ->setParameter('post', $object);\n        } elseif ($object instanceof PostComment) {\n            $qb->andWhere('a.objectPostComment = :postComment')\n                ->setParameter('postComment', $object);\n        } elseif ($object instanceof Message) {\n            $qb->andWhere('a.objectMessage = :message')\n                ->setParameter('message', $object);\n        } elseif ($object instanceof User) {\n            $qb->andWhere('a.objectUser = :user')\n                ->setParameter('user', $object);\n        } elseif ($object instanceof Magazine) {\n            $qb->andWhere('a.objectMagazine = :magazine')\n                ->setParameter('magazine', $object);\n        } elseif ($object instanceof MagazineBan) {\n            $qb->andWhere('a.objectMagazineBan = :magazineBan')\n                ->setParameter('magazineBan', $object);\n        } elseif ($object instanceof Activity) {\n            $qb->andWhere('a.innerActivity = :innerActivity')\n                ->setParameter('innerActivity', $object);\n        } elseif (\\is_string($object)) {\n            $qb->andWhere('a.innerActivityUrl = :innerActivityUrl')\n                ->setParameter('innerActivityUrl', $object);\n        }\n    }\n\n    public function getOutboxActivitiesOfUser(User $user): PagerfantaInterface\n    {\n        if ($user->isDeleted || $user->isBanned || $user->isTrashed() || null !== $user->markedForDeletionAt) {\n            return new Pagerfanta(new ArrayAdapter([]));\n        }\n\n        $qb = $this->createQueryBuilder('a')\n            ->leftJoin('a.audience', 'm')\n            ->leftJoin('a.objectEntry', 'e')\n            ->leftJoin('a.objectEntryComment', 'ec')\n            ->leftJoin('a.objectPost', 'p')\n            ->leftJoin('a.objectPostComment', 'pc')\n            ->where('a.userActor = :user')\n            ->andWhere('a.type IN (:types)')\n            ->andWhere('a.objectMessage IS NULL') // chat messages are not public\n            ->andWhere('m IS NULL OR m.visibility = :visible')\n            ->andWhere('e IS NULL OR e.visibility = :visible')\n            ->andWhere('ec IS NULL OR ec.visibility = :visible')\n            ->andWhere('p IS NULL OR p.visibility = :visible')\n            ->andWhere('pc IS NULL OR pc.visibility = :visible')\n            ->setParameter('visible', VisibilityInterface::VISIBILITY_VISIBLE)\n            ->setParameter('user', $user)\n            ->setParameter('types', ['Create', 'Announce'])\n            ->orderBy('a.createdAt', 'DESC')\n            ->addOrderBy('a.uuid', 'DESC');\n\n        return new Pagerfanta(new QueryAdapter($qb));\n    }\n\n    public function createForRemotePayload(array $payload, ActivityPubActivityInterface|Entry|EntryComment|Post|PostComment|ActivityPubActorInterface|User|Magazine|Activity|array|string|null $object = null): Activity\n    {\n        if (isset($payload['@context'])) {\n            unset($payload['@context']);\n        }\n        $activity = new Activity($payload['type']);\n        $activity->activityJson = json_encode($payload['object']);\n        $activity->isRemote = true;\n        if (null !== $object) {\n            $activity->setObject($object);\n        }\n\n        $this->getEntityManager()->persist($activity);\n        $this->getEntityManager()->flush();\n\n        return $activity;\n    }\n\n    public function createForRemoteActivity(array $payload, ActivityPubActivityInterface|Entry|EntryComment|Post|PostComment|ActivityPubActorInterface|User|Magazine|MagazineBan|Activity|array|string|null $object = null): Activity\n    {\n        if (isset($payload['@context'])) {\n            unset($payload['@context']);\n        }\n        $activity = new Activity($payload['type']);\n        $nestedTypes = ['Announce', 'Accept', 'Reject', 'Add', 'Remove', 'Lock'];\n        if (\\in_array($payload['type'], $nestedTypes) && isset($payload['object']) && \\is_array($payload['object'])) {\n            $activity->innerActivity = $this->createForRemoteActivity($payload['object'], $object);\n        } else {\n            $activity->activityJson = json_encode($payload);\n        }\n        $activity->isRemote = true;\n        if (null !== $object) {\n            $activity->setObject($object);\n        }\n\n        $this->getEntityManager()->persist($activity);\n        $this->getEntityManager()->flush();\n\n        return $activity;\n    }\n}\n"
  },
  {
    "path": "src/Repository/ApActivityRepository.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Repository;\n\nuse App\\Entity\\ApActivity;\nuse App\\Entity\\Entry;\nuse App\\Entity\\EntryComment;\nuse App\\Entity\\Message;\nuse App\\Entity\\Post;\nuse App\\Entity\\PostComment;\nuse App\\Service\\SettingsManager;\nuse Doctrine\\Bundle\\DoctrineBundle\\Repository\\ServiceEntityRepository;\nuse Doctrine\\DBAL\\Exception;\nuse Doctrine\\Persistence\\ManagerRegistry;\nuse JetBrains\\PhpStorm\\ArrayShape;\nuse Psr\\Log\\LoggerInterface;\nuse Symfony\\Component\\Routing\\Generator\\UrlGeneratorInterface;\n\n/**\n * @method ApActivity|null find($id, $lockMode = null, $lockVersion = null)\n * @method ApActivity|null findOneBy(array $criteria, array $orderBy = null)\n * @method ApActivity|null findOneByName(string $name)\n * @method ApActivity[]    findAll()\n * @method ApActivity[]    findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)\n */\nclass ApActivityRepository extends ServiceEntityRepository\n{\n    public function __construct(\n        ManagerRegistry $registry,\n        private readonly SettingsManager $settingsManager,\n        private readonly UrlGeneratorInterface $urlGenerator,\n        private readonly LoggerInterface $logger,\n    ) {\n        parent::__construct($registry, ApActivity::class);\n    }\n\n    #[ArrayShape([\n        'id' => 'int',\n        'type' => 'string',\n    ])]\n    public function findByObjectId(string $apId): ?array\n    {\n        $local = $this->findLocalByApId($apId);\n        if ($local) {\n            return $local;\n        }\n\n        $conn = $this->getEntityManager()->getConnection();\n        $tables = [\n            ['table' => 'entry', 'class' => Entry::class],\n            ['table' => 'entry_comment', 'class' => EntryComment::class],\n            ['table' => 'post', 'class' => Post::class],\n            ['table' => 'post_comment', 'class' => PostComment::class],\n            ['table' => 'message', 'class' => Message::class],\n        ];\n        foreach ($tables as $table) {\n            $t = $table['table'];\n            $sql = \"SELECT id FROM $t WHERE ap_id = :apId\";\n            try {\n                $stmt = $conn->prepare($sql);\n                $stmt->bindValue('apId', $apId);\n                $results = $stmt->executeQuery()->fetchAllAssociative();\n\n                if (1 === \\sizeof($results) && \\array_key_exists('id', $results[0])) {\n                    return [\n                        'id' => $results[0]['id'],\n                        'type' => $table['class'],\n                    ];\n                }\n            } catch (Exception) {\n            }\n        }\n\n        return null;\n    }\n\n    #[ArrayShape([\n        'id' => 'int',\n        'type' => 'string',\n    ])]\n    public function findLocalByApId(string $apId): ?array\n    {\n        $parsed = parse_url($apId);\n        if (!isset($parsed['host'])) {\n            // Log the error about missing the host on this apId\n            $this->logger->error('Missing host key on AP ID: {apId}', ['apId' => $apId]);\n\n            return null;\n        }\n\n        if ($parsed['host'] === $this->settingsManager->get('KBIN_DOMAIN') && !empty($parsed['path'])) {\n            $exploded = array_filter(explode('/', $parsed['path']));\n            $id = \\intval(end($exploded));\n            if (\\sizeof($exploded) < 3) {\n                return null;\n            }\n\n            if ('p' === $exploded[3]) {\n                if (4 === \\count($exploded)) {\n                    return [\n                        'id' => $id,\n                        'type' => Post::class,\n                    ];\n                } elseif (5 === \\count($exploded)) {\n                    // post url with slug (non-ap route)\n                    return [\n                        'id' => \\intval($exploded[4]),\n                        'type' => Post::class,\n                    ];\n                } else {\n                    // since the id is just the intval of the last part in the url it will be 0 if that was not a number\n                    if (0 === $id) {\n                        return null;\n                    }\n\n                    return [\n                        'id' => $id,\n                        'type' => PostComment::class,\n                    ];\n                }\n            }\n\n            if ('t' === $exploded[3]) {\n                if (4 === \\count($exploded)) {\n                    return [\n                        'id' => $id,\n                        'type' => Entry::class,\n                    ];\n                } elseif (5 === \\count($exploded)) {\n                    // entry url with slug (non-ap route)\n                    return [\n                        'id' => \\intval($exploded[4]),\n                        'type' => Entry::class,\n                    ];\n                } else {\n                    // since the id is just the intval of the last part in the url it will be 0 if that was not a number\n                    if (0 === $id) {\n                        return null;\n                    }\n\n                    return [\n                        'id' => $id,\n                        'type' => EntryComment::class,\n                    ];\n                }\n            }\n\n            if ('message' === $exploded[3]) {\n                if (4 === \\count($exploded)) {\n                    return [\n                        'id' => $id,\n                        'type' => Message::class,\n                    ];\n                }\n            }\n        }\n\n        return null;\n    }\n\n    public function getLocalUrlOfActivity(string $type, int $id): ?string\n    {\n        $repo = $this->getEntityManager()->getRepository($type);\n        $entity = $repo->find($id);\n\n        return $this->getLocalUrlOfEntity($entity);\n    }\n\n    public function getLocalUrlOfEntity(Entry|EntryComment|Post|PostComment $entity): ?string\n    {\n        if ($entity instanceof Entry) {\n            return $this->urlGenerator->generate('entry_single', ['entry_id' => $entity->getId(), 'magazine_name' => $entity->magazine->name]);\n        } elseif ($entity instanceof EntryComment) {\n            return $this->urlGenerator->generate('entry_comment_view', ['comment_id' => $entity->getId(), 'entry_id' => $entity->entry->getId(), 'magazine_name' => $entity->magazine->name]);\n        } elseif ($entity instanceof Post) {\n            return $this->urlGenerator->generate('post_single', ['post_id' => $entity->getId(), 'magazine_name' => $entity->magazine->name]);\n        } elseif ($entity instanceof PostComment) {\n            return $this->urlGenerator->generate('post_single', ['post_id' => $entity->post->getId(), 'magazine_name' => $entity->magazine->name]).\"#post-comment-{$entity->getId()}\";\n        }\n\n        return null;\n    }\n}\n"
  },
  {
    "path": "src/Repository/BadgeRepository.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Repository;\n\nuse App\\Entity\\Badge;\nuse Doctrine\\Bundle\\DoctrineBundle\\Repository\\ServiceEntityRepository;\nuse Doctrine\\Persistence\\ManagerRegistry;\n\n/**\n * @method Badge|null find($id, $lockMode = null, $lockVersion = null)\n * @method Badge|null findOneBy(array $criteria, array $orderBy = null)\n * @method Badge|null findOneByName(string $name)\n * @method Badge[]    findAll()\n * @method Badge[]    findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)\n */\nclass BadgeRepository extends ServiceEntityRepository\n{\n    public function __construct(ManagerRegistry $registry)\n    {\n        parent::__construct($registry, Badge::class);\n    }\n}\n"
  },
  {
    "path": "src/Repository/BookmarkListRepository.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Repository;\n\nuse App\\DTO\\BookmarkListDto;\nuse App\\DTO\\EntryCommentDto;\nuse App\\DTO\\EntryDto;\nuse App\\DTO\\PostCommentDto;\nuse App\\DTO\\PostDto;\nuse App\\Entity\\BookmarkList;\nuse App\\Entity\\Contracts\\ContentInterface;\nuse App\\Entity\\Entry;\nuse App\\Entity\\EntryComment;\nuse App\\Entity\\Post;\nuse App\\Entity\\PostComment;\nuse App\\Entity\\User;\nuse Doctrine\\Bundle\\DoctrineBundle\\Repository\\ServiceEntityRepository;\nuse Doctrine\\Persistence\\ManagerRegistry;\nuse Symfony\\Bundle\\SecurityBundle\\Security;\n\n/**\n * @method BookmarkList|null find($id, $lockMode = null, $lockVersion = null)\n * @method BookmarkList|null findOneBy(array $criteria, array $orderBy = null)\n * @method BookmarkList[]    findAll()\n * @method BookmarkList[]    findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)\n */\nclass BookmarkListRepository extends ServiceEntityRepository\n{\n    public function __construct(\n        ManagerRegistry $registry,\n        private readonly Security $security,\n    ) {\n        parent::__construct($registry, BookmarkList::class);\n    }\n\n    /**\n     * @return BookmarkList[]\n     */\n    public function findByUser(User $user): array\n    {\n        return $this->findBy(['user' => $user]);\n    }\n\n    public function findOneByUserAndName(User $user, string $name): ?BookmarkList\n    {\n        return $this->findOneBy(['user' => $user, 'name' => $name]);\n    }\n\n    public function findOneByUserDefault(User $user): BookmarkList\n    {\n        $list = $this->findOneBy(['user' => $user, 'isDefault' => true]);\n        if (null === $list) {\n            $list = new BookmarkList($user, 'Default', true);\n            $this->getEntityManager()->persist($list);\n            $this->getEntityManager()->flush();\n        }\n\n        return $list;\n    }\n\n    public function makeListDefault(User $user, BookmarkList $list): void\n    {\n        $sql = 'UPDATE bookmark_list SET is_default = false WHERE user_id = :user';\n        $conn = $this->getEntityManager()->getConnection();\n        $stmt = $conn->prepare($sql);\n        $stmt->bindValue('user', $user->getId());\n        $stmt->executeStatement();\n\n        $sql = 'UPDATE bookmark_list SET is_default = true WHERE user_id = :user AND id = :id';\n        $stmt = $conn->prepare($sql);\n        $stmt->bindValue('user', $user->getId());\n        $stmt->bindValue('id', $list->getId());\n        $stmt->executeStatement();\n        $this->getEntityManager()->refresh($list);\n    }\n\n    public function deleteList(BookmarkList $list): void\n    {\n        $sql = 'DELETE FROM bookmark_list WHERE id = :id';\n        $conn = $this->getEntityManager()->getConnection();\n        $stmt = $conn->prepare($sql);\n        $stmt->bindValue('id', $list->getId());\n        $stmt->executeStatement();\n    }\n\n    public function editList(User $user, BookmarkList $list, BookmarkListDto $dto): void\n    {\n        $sql = 'UPDATE bookmark_list SET name = :name WHERE id = :id';\n        $conn = $this->getEntityManager()->getConnection();\n        $stmt = $conn->prepare($sql);\n        $stmt->bindValue('id', $list->getId());\n        $stmt->bindValue('name', $dto->name);\n        $rows = $stmt->executeStatement();\n\n        if ($dto->isDefault) {\n            $this->makeListDefault($user, $list);\n        } else {\n            // makeListDefault already refreshes the entity, so we do not need to do it\n            $this->getEntityManager()->refresh($list);\n        }\n    }\n\n    /**\n     * @return BookmarkList[]\n     */\n    public function findListsBySubject(Entry|EntryDto|EntryComment|EntryCommentDto|Post|PostDto|PostComment|PostCommentDto $content, User $user): array\n    {\n        $qb = $this->createQueryBuilder('bl')\n            ->join('bl.entities', 'b')\n            ->where('bl.user = :user')\n            ->setParameter('user', $user);\n\n        if ($content instanceof Entry || $content instanceof EntryDto) {\n            $qb->andWhere('b.entry = :content');\n        } elseif ($content instanceof EntryComment || $content instanceof EntryCommentDto) {\n            $qb->andWhere('b.entryComment = :content');\n        } elseif ($content instanceof Post || $content instanceof PostDto) {\n            $qb->andWhere('b.post = :content');\n        } elseif ($content instanceof PostComment || $content instanceof PostCommentDto) {\n            $qb->andWhere('b.postComment = :content');\n        }\n        $qb->setParameter('content', $content->getId());\n\n        return $qb->getQuery()->getResult();\n    }\n\n    /**\n     * @return string[]|null\n     */\n    public function getBookmarksOfContentInterface(ContentInterface $content): ?array\n    {\n        if ($user = $this->security->getUser()) {\n            if ($user instanceof User && (\n                $content instanceof Entry\n                || $content instanceof EntryDto\n                || $content instanceof EntryComment\n                || $content instanceof EntryCommentDto\n                || $content instanceof Post\n                || $content instanceof PostDto\n                || $content instanceof PostComment\n                || $content instanceof PostCommentDto\n            )) {\n                return array_map(fn ($list) => $list->name, $this->findListsBySubject($content, $user));\n            }\n\n            return [];\n        }\n\n        return null;\n    }\n}\n"
  },
  {
    "path": "src/Repository/BookmarkRepository.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Repository;\n\nuse App\\Entity\\Bookmark;\nuse App\\Entity\\BookmarkList;\nuse App\\Entity\\Entry;\nuse App\\Entity\\EntryComment;\nuse App\\Entity\\Post;\nuse App\\Entity\\PostComment;\nuse App\\Entity\\User;\nuse App\\Pagination\\NativeQueryAdapter;\nuse App\\Pagination\\Pagerfanta;\nuse App\\Pagination\\Transformation\\ContentPopulationTransformer;\nuse App\\Utils\\SqlHelpers;\nuse Doctrine\\Bundle\\DoctrineBundle\\Repository\\ServiceEntityRepository;\nuse Doctrine\\Persistence\\ManagerRegistry;\nuse Pagerfanta\\PagerfantaInterface;\nuse Psr\\Log\\LoggerInterface;\n\n/**\n * @method Bookmark|null find($id, $lockMode = null, $lockVersion = null)\n * @method Bookmark|null findOneBy(array $criteria, array $orderBy = null)\n * @method Bookmark[]    findAll()\n * @method Bookmark[]    findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)\n */\nclass BookmarkRepository extends ServiceEntityRepository\n{\n    public function __construct(\n        ManagerRegistry $registry,\n        private readonly LoggerInterface $logger,\n        private readonly ContentPopulationTransformer $transformer,\n    ) {\n        parent::__construct($registry, Bookmark::class);\n    }\n\n    /**\n     * @return Bookmark[]\n     */\n    public function findByList(User $user, BookmarkList $list): array\n    {\n        return $this->createQueryBuilder('b')\n            ->where('b.user = :user')\n            ->andWhere('b.list = :list')\n            ->setParameter('user', $user)\n            ->setParameter('list', $list)\n            ->getQuery()\n            ->getResult();\n    }\n\n    public function removeAllBookmarksForContent(User $user, Entry|EntryComment|Post|PostComment $content): void\n    {\n        if ($content instanceof Entry) {\n            $contentWhere = 'entry_id = :id';\n        } elseif ($content instanceof EntryComment) {\n            $contentWhere = 'entry_comment_id = :id';\n        } elseif ($content instanceof Post) {\n            $contentWhere = 'post_id = :id';\n        } elseif ($content instanceof PostComment) {\n            $contentWhere = 'post_comment_id = :id';\n        } else {\n            throw new \\LogicException();\n        }\n\n        $sql = \"DELETE FROM bookmark WHERE user_id = :u AND $contentWhere\";\n        $conn = $this->getEntityManager()->getConnection();\n        $stmt = $conn->prepare($sql);\n        $stmt->bindValue('u', $user->getId());\n        $stmt->bindValue('id', $content->getId());\n        $stmt->executeStatement();\n    }\n\n    public function removeBookmarkFromList(User $user, BookmarkList $list, Entry|EntryComment|Post|PostComment $content): void\n    {\n        if ($content instanceof Entry) {\n            $contentWhere = 'entry_id = :id';\n        } elseif ($content instanceof EntryComment) {\n            $contentWhere = 'entry_comment_id = :id';\n        } elseif ($content instanceof Post) {\n            $contentWhere = 'post_id = :id';\n        } elseif ($content instanceof PostComment) {\n            $contentWhere = 'post_comment_id = :id';\n        } else {\n            throw new \\LogicException();\n        }\n\n        $sql = \"DELETE FROM bookmark WHERE user_id = :u AND list_id = :l AND $contentWhere\";\n        $conn = $this->getEntityManager()->getConnection();\n        $stmt = $conn->prepare($sql);\n        $stmt->bindValue('u', $user->getId());\n        $stmt->bindValue('l', $list->getId());\n        $stmt->bindValue('id', $content->getId());\n        $stmt->executeStatement();\n    }\n\n    public function findPopulatedByList(BookmarkList $list, Criteria $criteria, ?int $perPage = null): PagerfantaInterface\n    {\n        $entryWhereArr = ['b.list_id = :list'];\n        $entryCommentWhereArr = ['b.list_id = :list'];\n        $postWhereArr = ['b.list_id = :list'];\n        $postCommentWhereArr = ['b.list_id = :list'];\n        $parameters = [\n            'list' => $list->getId(),\n        ];\n\n        $orderBy = match ($criteria->sortOption) {\n            Criteria::SORT_OLD => 'ORDER BY i.created_at ASC',\n            Criteria::SORT_TOP => 'ORDER BY i.score DESC, i.created_at DESC',\n            Criteria::SORT_HOT => 'ORDER BY i.ranking DESC, i.created_at DESC',\n            default => 'ORDER BY created_at DESC',\n        };\n\n        if (Criteria::AP_LOCAL === $criteria->federation) {\n            $entryWhereArr[] = 'e.ap_id IS NULL';\n            $entryCommentWhereArr[] = 'ec.ap_id IS NULL';\n            $postWhereArr[] = 'p.ap_id IS NULL';\n            $postCommentWhereArr[] = 'pc.ap_id IS NULL';\n        }\n\n        if ('all' !== $criteria->type) {\n            $entryWhereArr[] = 'e.type = :type';\n            $entryCommentWhereArr[] = 'false';\n            $postWhereArr[] = 'false';\n            $postCommentWhereArr[] = 'false';\n\n            $parameters['type'] = $criteria->type;\n        }\n\n        if (Criteria::TIME_ALL !== $criteria->time) {\n            $entryWhereArr[] = 'b.created_at > :time';\n            $entryCommentWhereArr[] = 'b.created_at > :time';\n            $postWhereArr[] = 'b.created_at > :time';\n            $postCommentWhereArr[] = 'b.created_at > :time';\n\n            $parameters['time'] = $criteria->getSince();\n        }\n\n        $entryWhere = SqlHelpers::makeWhereString($entryWhereArr);\n        $entryCommentWhere = SqlHelpers::makeWhereString($entryCommentWhereArr);\n        $postWhere = SqlHelpers::makeWhereString($postWhereArr);\n        $postCommentWhere = SqlHelpers::makeWhereString($postCommentWhereArr);\n\n        $sql = \"\n            SELECT * FROM (\n                SELECT e.id AS id, e.ap_id AS ap_id, e.score AS score, e.ranking AS ranking, b.created_at AS created_at, 'entry' AS type FROM bookmark b\n                    INNER JOIN entry e ON b.entry_id = e.id $entryWhere\n                UNION\n                SELECT ec.id AS id, ec.ap_id AS ap_id, (ec.up_votes + ec.favourite_count - ec.down_votes) AS score, ec.up_votes AS ranking, b.created_at AS created_at, 'entry_comment' AS type FROM bookmark b\n                    INNER JOIN entry_comment ec ON b.entry_comment_id = ec.id $entryCommentWhere\n                UNION\n                SELECT p.id AS id, p.ap_id AS ap_id, p.score AS score, p.ranking AS ranking, b.created_at AS created_at, 'post' AS type FROM bookmark b\n                    INNER JOIN post p ON b.post_id = p.id $postWhere\n                UNION\n                SELECT pc.id AS id, pc.ap_id AS ap_id, (pc.up_votes + pc.favourite_count - pc.down_votes) AS score, pc.up_votes AS ranking, b.created_at AS created_at, 'post_comment' AS type FROM bookmark b\n                    INNER JOIN post_comment pc ON b.post_comment_id = pc.id $postCommentWhere\n            ) i $orderBy\n        \";\n\n        $this->logger->info('bookmark list sql: {sql}', ['sql' => $sql]);\n\n        $conn = $this->getEntityManager()->getConnection();\n        $adapter = new NativeQueryAdapter($conn, $sql, $parameters, transformer: $this->transformer);\n\n        return Pagerfanta::createForCurrentPageWithMaxPerPage($adapter, $criteria->page, $perPage ?? EntryRepository::PER_PAGE);\n    }\n}\n"
  },
  {
    "path": "src/Repository/ContentRepository.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Repository;\n\nuse App\\Entity\\Contracts\\VisibilityInterface;\nuse App\\Entity\\Entry;\nuse App\\Entity\\Post;\nuse App\\Entity\\User;\nuse App\\Pagination\\Cursor\\CursorPagination;\nuse App\\Pagination\\Cursor\\CursorPaginationInterface;\nuse App\\Pagination\\Cursor\\NativeQueryCursorAdapter;\nuse App\\Pagination\\NativeQueryAdapter;\nuse App\\Pagination\\Pagerfanta;\nuse App\\Pagination\\Transformation\\ContentPopulationTransformer;\nuse App\\Utils\\SqlHelpers;\nuse Doctrine\\DBAL\\Exception;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Pagerfanta\\PagerfantaInterface;\nuse Psr\\Log\\LoggerInterface;\nuse Symfony\\Bundle\\SecurityBundle\\Security;\nuse Symfony\\Component\\HttpKernel\\KernelInterface;\nuse Symfony\\Contracts\\Cache\\CacheInterface;\n\nclass ContentRepository\n{\n    public const int PER_PAGE = 25;\n\n    public function __construct(\n        private readonly Security $security,\n        private readonly EntityManagerInterface $entityManager,\n        private readonly ContentPopulationTransformer $contentPopulationTransformer,\n        private readonly CacheInterface $cache,\n        private readonly LoggerInterface $logger,\n        private readonly KernelInterface $kernel,\n    ) {\n    }\n\n    public function findByCriteria(Criteria $criteria): PagerfantaInterface\n    {\n        $query = $this->getQueryAndParameters($criteria, false);\n        $conn = $this->entityManager->getConnection();\n\n        $numResults = null;\n        if ('test' !== $this->kernel->getEnvironment() && !$criteria->magazine && !$criteria->moderated && !$criteria->favourite && Criteria::TIME_ALL === $criteria->time && Criteria::AP_ALL === $criteria->federation && 'all' === $criteria->type) {\n            // pre-set the results to 1000 pages for queries not very limited by the parameters so the count query is not being executed\n            $numResults = 1000 * ($criteria->perPage ?? self::PER_PAGE);\n        }\n        $fanta = new Pagerfanta(new NativeQueryAdapter($conn, $query['sql'], $query['parameters'], numOfResults: $numResults, transformer: $this->contentPopulationTransformer, cache: $this->cache));\n        $fanta->setMaxPerPage($criteria->perPage ?? self::PER_PAGE);\n        $fanta->setCurrentPage($criteria->page);\n\n        return $fanta;\n    }\n\n    /**\n     * @template-covariant TCursor\n     *\n     * @param TCursor|null $currentCursor\n     *\n     * @return CursorPaginationInterface<Entry|Post,TCursor>\n     *\n     * @throws Exception\n     */\n    public function findByCriteriaCursored(Criteria $criteria, mixed $currentCursor, mixed $currentCursor2 = null): CursorPaginationInterface\n    {\n        $query = $this->getQueryAndParameters($criteria, true);\n        $conn = $this->entityManager->getConnection();\n        $orderings = $this->getOrderings($criteria);\n        $start = new \\DateTimeImmutable();\n        $start = $start->setTimestamp(0);\n\n        $fanta = new CursorPagination(\n            new NativeQueryCursorAdapter(\n                $conn,\n                $query['sql'],\n                $this->getCursorWhereFromCriteria($criteria),\n                $this->getCursorWhereInvertedFromCriteria($criteria),\n                join(',', $orderings),\n                join(',', SqlHelpers::invertOrderings($orderings)),\n                $query['parameters'],\n                $this->getSecondaryCursorWhereFromCriteria($criteria),\n                $this->getSecondaryCursorWhereFromCriteriaInverted($criteria),\n                'c.created_at DESC',\n                'c.created_at',\n                transformer: $this->contentPopulationTransformer,\n            ),\n            $this->getCursorFieldFromCriteria($criteria),\n            $criteria->perPage ?? self::PER_PAGE,\n            'createdAt',\n            $start,\n        );\n        $fanta->setCurrentPage($currentCursor ?? $this->guessInitialCursor($criteria->sortOption), $currentCursor2 ?? new \\DateTimeImmutable('now + 1 minute'));\n\n        return $fanta;\n    }\n\n    /**\n     * @return array{sql: string, parameters: array}>\n     */\n    private function getQueryAndParameters(Criteria $criteria, bool $addCursor): array\n    {\n        $includeEntries = Criteria::CONTENT_COMBINED === $criteria->content || Criteria::CONTENT_THREADS === $criteria->content;\n        $includeEntryComments = Criteria::CONTENT_COMBINED === $criteria->content && $criteria->includeBoosts;\n        $includePostComments = (Criteria::CONTENT_COMBINED === $criteria->content || Criteria::CONTENT_MICROBLOG === $criteria->content) && $criteria->includeBoosts;\n\n        $parameters = [\n            'visible' => VisibilityInterface::VISIBILITY_VISIBLE,\n            'private' => VisibilityInterface::VISIBILITY_PRIVATE,\n        ];\n\n        /** @var ?User $user */\n        $user = $this->security->getUser();\n        $currenFilterLists = $user?->getCurrentFilterLists() ?? [];\n        $parameters['loggedInUser'] = $user?->getId();\n\n        $timeClause = '';\n        if ($criteria->time && Criteria::TIME_ALL !== $criteria->time) {\n            $timeClause = 'c.created_at >= :time';\n            $parameters['time'] = $criteria->getSince();\n        }\n\n        $magazineClause = '';\n        if ($criteria->magazine) {\n            $magazineClause = 'c.magazine_id = :magazine';\n            $parameters['magazine'] = $criteria->magazine->getId();\n        }\n\n        $userClause = '';\n        if ($criteria->user) {\n            $userClause = 'c.user_id = :user';\n            $parameters['user'] = $criteria->user->getId();\n        }\n\n        $hashtagClauseEntry = '';\n        $hashtagClausePost = '';\n        $hashtagClauseEntryComment = '';\n        $hashtagClausePostComment = '';\n        if ($criteria->tag) {\n            $hashtagClauseEntry = 'EXISTS (SELECT * FROM hashtag_link hl INNER JOIN hashtag h ON hl.hashtag_id = h.id WHERE hl.entry_id = c.id AND h.tag = :hashtag)';\n            $hashtagClausePost = 'EXISTS (SELECT * FROM hashtag_link hl INNER JOIN hashtag h ON hl.hashtag_id = h.id WHERE hl.post_id = c.id AND h.tag = :hashtag)';\n            $hashtagClauseEntryComment = 'EXISTS (SELECT * FROM hashtag_link hl INNER JOIN hashtag h ON hl.hashtag_id = h.id WHERE hl.entry_comment_id = c.id AND h.tag = :hashtag)';\n            $hashtagClausePostComment = 'EXISTS (SELECT * FROM hashtag_link hl INNER JOIN hashtag h ON hl.hashtag_id = h.id WHERE hl.post_comment_id = c.id AND h.tag = :hashtag)';\n            $parameters['hashtag'] = $criteria->tag;\n        }\n\n        $federationClause = '';\n        if (Criteria::AP_LOCAL === $criteria->federation) {\n            $federationClause = 'c.ap_id IS NULL';\n        } elseif (Criteria::AP_FEDERATED === $criteria->federation) {\n            $federationClause = 'c.ap_id IS NOT NULL';\n        }\n\n        $domainClausePost = '';\n        $domainClauseEntry = '';\n        if ($criteria->domain) {\n            $domainClauseEntry = 'd.name = :domain';\n            $parameters['domain'] = $criteria->domain;\n            $domainClausePost = 'false';\n        }\n\n        $languagesClause = '';\n        if ($criteria->languages) {\n            $languagesClause = 'c.lang IN (:languages)';\n            $parameters['languages'] = $criteria->languages;\n        }\n\n        $contentTypeClauseEntry = '';\n        $contentTypeClausePost = '';\n        if ($criteria->type && 'all' !== $criteria->type) {\n            $contentTypeClauseEntry = 'c.type = :type';\n            $contentTypeClausePost = 'false';\n            $parameters['type'] = $criteria->type;\n        }\n\n        $contentClauseEntry = '';\n        $contentClausePost = '';\n        if (Criteria::CONTENT_COMBINED !== $criteria->content) {\n            if (Criteria::CONTENT_THREADS === $criteria->content) {\n                $contentClausePost = 'false';\n            } elseif (Criteria::CONTENT_MICROBLOG === $criteria->content) {\n                $contentClauseEntry = 'false';\n            } else {\n                throw new \\LogicException(\"cannot handle content of type $criteria->content\");\n            }\n        }\n\n        if (null !== $criteria->cachedUserFollows) {\n            $parameters['cachedUserFollows'] = $criteria->cachedUserFollows;\n        }\n\n        $subClausePost = '';\n        $subClauseEntry = '';\n        $subClauseEntryComment = '';\n        $subClausePostComment = '';\n        if ($user && $criteria->subscribed) {\n            $subClausePost = 'c.user_id = :loggedInUser'\n                .(null === $criteria->cachedUserSubscribedMagazines ?\n                    ' OR EXISTS (SELECT 1 FROM magazine_subscription ms WHERE ms.user_id = :loggedInUser AND ms.magazine_id = m.id)' :\n                    ' OR m.id IN (:cachedUserSubscribedMagazines)')\n                .(null === $criteria->cachedUserFollows ?\n                    ' OR EXISTS (SELECT 1 FROM user_follow uf WHERE uf.follower_id = :loggedInUser AND uf.following_id = c.user_id)' :\n                    ' OR c.user_id IN (:cachedUserFollows)');\n            $subClauseEntry = $subClausePost\n                .(null === $criteria->cachedUserSubscribedDomains ?\n                    ' OR EXISTS (SELECT 1 FROM domain_subscription ds WHERE ds.domain_id = c.domain_id AND ds.user_id = :loggedInUser)' :\n                    ' OR c.domain_id IN (:cachedUserSubscribedDomains)');\n\n            if ($criteria->includeBoosts) {\n                $repliesCommonWhere = 'c.user_id = :loggedInUser'\n                    .(null === $criteria->cachedUserFollows ?\n                        ' OR EXISTS (SELECT 1 FROM user_follow uf WHERE uf.follower_id = :loggedInUser AND uf.following_id = c.user_id)' :\n                        ' OR c.user_id IN (:cachedUserFollows)');\n\n                $subClauseEntryComment = $repliesCommonWhere.\n                    (null === $criteria->cachedUserFollows ?\n                        ' OR EXISTS (SELECT 1 FROM user_follow uf RIGHT OUTER JOIN entry_comment_vote v ON uf.following_id = v.user_id WHERE c.id = v.comment_id AND (uf.follower_id = :loggedInUser OR v.user_id = :loggedInUser) AND v.choice = 1)' :\n                        ' OR EXISTS (SELECT 1 FROM entry_comment_vote v WHERE c.id = v.comment_id AND (v.user_id IN (:cachedUserFollows) OR v.user_id = :loggedInUser) AND v.choice = 1)');\n                $subClausePostComment = $repliesCommonWhere.\n                    (null === $criteria->cachedUserFollows ?\n                        ' OR EXISTS (SELECT 1 FROM user_follow uf RIGHT OUTER JOIN post_comment_vote v ON uf.following_id = v.user_id WHERE c.id = v.comment_id AND (uf.follower_id = :loggedInUser OR v.user_id = :loggedInUser) AND v.choice = 1)' :\n                        ' OR EXISTS (SELECT 1 FROM post_comment_vote v WHERE c.id = v.comment_id AND (v.user_id IN (:cachedUserFollows) OR v.user_id = :loggedInUser) AND v.choice = 1)');\n\n                $subClausePost = $subClausePost\n                    .(null === $criteria->cachedUserFollows ?\n                        ' OR EXISTS (SELECT 1 FROM user_follow uf RIGHT OUTER JOIN post_vote v ON uf.following_id = v.user_id WHERE c.id = v.post_id AND (uf.follower_id = :loggedInUser OR v.user_id = :loggedInUser) AND v.choice = 1)' :\n                        ' OR EXISTS (SELECT 1 FROM post_vote v WHERE c.id = v.post_id AND (v.user_id IN (:cachedUserFollows) OR v.user_id = :loggedInUser) AND v.choice = 1)');\n                $subClauseEntry = $subClauseEntry\n                    .(null === $criteria->cachedUserFollows ?\n                        ' OR EXISTS (SELECT 1 FROM user_follow uf RIGHT OUTER JOIN entry_vote v ON uf.following_id = v.user_id WHERE c.id = v.entry_id AND (uf.follower_id = :loggedInUser OR v.user_id = :loggedInUser) AND v.choice = 1)' :\n                        ' OR EXISTS (SELECT 1 FROM entry_vote v WHERE c.id = v.entry_id AND (v.user_id IN (:cachedUserFollows) OR v.user_id = :loggedInUser) AND v.choice = 1)');\n            }\n\n            if (null !== $criteria->cachedUserSubscribedMagazines) {\n                $parameters['cachedUserSubscribedMagazines'] = $criteria->cachedUserSubscribedMagazines;\n            }\n            if (null !== $criteria->cachedUserSubscribedDomains && $includeEntries) {\n                $parameters['cachedUserSubscribedDomains'] = $criteria->cachedUserSubscribedDomains;\n            }\n        }\n\n        $modClause = '';\n        if ($user && $criteria->moderated) {\n            if (null === $criteria->cachedUserModeratedMagazines) {\n                $modClause = 'EXISTS (SELECT * FROM moderator mod WHERE mod.magazine_id = m.id AND mod.user_id = :loggedInUser)';\n            } else {\n                $modClause = 'm.id IN (:cachedUserModeratedMagazines)';\n                $parameters['cachedUserModeratedMagazines'] = $criteria->cachedUserModeratedMagazines;\n            }\n        }\n\n        $allClause = '';\n        $allClauseU = '';\n        if (!$criteria->moderated && !$criteria->subscribed && !$criteria->magazine && !$criteria->user && !$criteria->domain && !$criteria->tag) {\n            // hide all posts from non-discoverable users and magazines from /all (and only from there)\n            $allClause = 'm.ap_discoverable = true OR m.ap_discoverable IS NULL';\n            $allClauseU = 'u.ap_discoverable = true OR u.ap_discoverable IS NULL';\n        }\n\n        $favClauseEntry = '';\n        $favClausePost = '';\n        $favClauseEntryComment = '';\n        $favClausePostComment = '';\n        if ($user && $criteria->favourite) {\n            $favClauseEntry = 'EXISTS (SELECT * FROM favourite f WHERE f.entry_id = c.id AND f.user_id = :loggedInUser)';\n            $favClausePost = 'EXISTS (SELECT * FROM favourite f WHERE f.post_id = c.id AND f.user_id = :loggedInUser)';\n            $favClauseEntryComment = 'EXISTS (SELECT * FROM favourite f WHERE f.entry_comment_id = c.id AND f.user_id = :loggedInUser)';\n            $favClausePostComment = 'EXISTS (SELECT * FROM favourite f WHERE f.post_comment_id = c.id AND f.user_id = :loggedInUser)';\n        }\n\n        $blockingClausePost = '';\n        $blockingClauseEntry = '';\n        if ($user && (!$criteria->magazine || !$criteria->magazine->userIsModerator($user)) && !$criteria->moderated) {\n            if (null === $criteria->cachedUserBlocks) {\n                $blockingClausePost = 'NOT EXISTS (SELECT * FROM user_block ub WHERE ub.blocker_id = :loggedInUser AND ub.blocked_id = c.user_id)';\n            } else {\n                $blockingClausePost = 'c.user_id NOT IN (:cachedUserBlocks)';\n                $parameters['cachedUserBlocks'] = $criteria->cachedUserBlocks;\n            }\n\n            if (!$criteria->domain) {\n                if (null === $criteria->cachedUserBlockedMagazines) {\n                    $blockingClausePost .= ' AND NOT EXISTS (SELECT * FROM magazine_block mb WHERE mb.magazine_id = m.id AND mb.user_id = :loggedInUser)';\n                } else {\n                    $blockingClausePost .= ' AND (m IS NULL OR m.id NOT IN (:cachedUserBlockedMagazines))';\n                    $parameters['cachedUserBlockedMagazines'] = $criteria->cachedUserBlockedMagazines;\n                }\n            }\n\n            if (null === $criteria->cachedUserBlockedDomains) {\n                $blockingClauseEntry = $blockingClausePost.' AND NOT EXISTS (SELECT * FROM domain_block db WHERE db.user_id = :loggedInUser AND db.domain_id = c.domain_id)';\n            } else {\n                $blockingClauseEntry = $blockingClausePost.' AND (c.domain_id IS NULL OR c.domain_id NOT IN (:cachedUserBlockedDomains))';\n                if ($includeEntries) {\n                    $parameters['cachedUserBlockedDomains'] = $criteria->cachedUserBlockedDomains;\n                }\n            }\n        }\n\n        $hideAdultClause = '';\n        if ($user && $user->hideAdult) {\n            $hideAdultClause = 'c.is_adult = FALSE AND m.is_adult = FALSE';\n        }\n\n        $visibilityClauseM = 'm.visibility = :visible';\n        if (null === $criteria->cachedUserFollows) {\n            $visibilityClauseC = 'c.visibility = :visible OR (c.visibility = :private AND EXISTS (SELECT * FROM user_follow uf WHERE uf.follower_id = :loggedInUser AND uf.following_id = c.user_id))';\n        } else {\n            $visibilityClauseC = 'c.visibility = :visible OR (c.visibility = :private AND c.user_id IN (:cachedUserFollows))';\n        }\n\n        $filterClauseEntry = '';\n        $filterClausePost = '';\n        $filterClauseComments = '';\n        if (\\sizeof($currenFilterLists) > 0) {\n            $listOrsEntry = [];\n            $listOrsPost = [];\n            $listOrsComments = [];\n            $i = 0;\n            foreach ($currenFilterLists as $filterList) {\n                foreach ($filterList->words as $filterListWord) {\n                    $word = $filterListWord['word'];\n                    $exact = $filterListWord['exactMatch'];\n                    if ($exact) {\n                        if ($filterList->feeds) {\n                            $listOrsEntry[] = \"(c.title LIKE :word$i)\";\n                            $listOrsEntry[] = \"(c.body LIKE :word$i)\";\n                            $listOrsPost[] = \"(c.body LIKE :word$i)\";\n                        }\n                        if ($filterList->comments) {\n                            $listOrsComments[] = \"(c.body LIKE :word$i)\";\n                        }\n                    } else {\n                        if ($filterList->feeds) {\n                            $listOrsEntry[] = \"(c.title ILIKE :word$i)\";\n                            $listOrsEntry[] = \"(c.body ILIKE :word$i)\";\n                            $listOrsPost[] = \"(c.body ILIKE :word$i)\";\n                        }\n                        if ($filterList->comments) {\n                            $listOrsComments[] = \"(c.body ILIKE :word$i)\";\n                        }\n                    }\n                    if ($filterList->feeds || ($filterList->comments && ($includeEntryComments || $includePostComments))) {\n                        $parameters[\"word$i\"] = '%'.$word.'%';\n                    }\n                    ++$i;\n                }\n            }\n            if (\\sizeof($listOrsEntry) > 0) {\n                $filterClauseEntry = 'NOT ('.implode(' OR ', $listOrsEntry).') OR c.user_id = :loggedInUser';\n            }\n            if (\\sizeof($listOrsPost) > 0) {\n                $filterClausePost = 'NOT ('.implode(' OR ', $listOrsPost).') OR c.user_id = :loggedInUser';\n            }\n            if (\\sizeof($listOrsComments) > 0) {\n                $filterClauseComments = 'NOT ('.implode(' OR ', $listOrsComments).') OR c.user_id = :loggedInUser';\n            }\n        }\n\n        $deletedClause = 'u.is_deleted = false';\n        $visibilityClauseU = 'u.visibility = :visible';\n\n        $entryWhere = SqlHelpers::makeWhereString([\n            $contentClauseEntry,\n            $timeClause,\n            $magazineClause,\n            $userClause,\n            $hashtagClauseEntry,\n            $federationClause,\n            $domainClauseEntry,\n            $languagesClause,\n            $contentTypeClauseEntry,\n            $subClauseEntry,\n            $modClause,\n            $favClauseEntry,\n            $blockingClauseEntry,\n            $hideAdultClause,\n            $visibilityClauseM,\n            $visibilityClauseC,\n            $allClause,\n            $addCursor ? '%cursor% OR (%cursor2%)' : '',\n            $filterClauseEntry,\n        ]);\n\n        $postWhere = SqlHelpers::makeWhereString([\n            $contentClausePost,\n            $timeClause,\n            $magazineClause,\n            $userClause,\n            $hashtagClausePost,\n            $federationClause,\n            $domainClausePost,\n            $languagesClause,\n            $contentTypeClausePost,\n            $subClausePost,\n            $modClause,\n            $favClausePost,\n            $blockingClausePost,\n            $hideAdultClause,\n            $visibilityClauseM,\n            $visibilityClauseC,\n            $allClause,\n            $addCursor ? '%cursor% OR (%cursor2%)' : '',\n            $filterClausePost,\n        ]);\n\n        $entryCommentWhere = SqlHelpers::makeWhereString([\n            $contentClauseEntry,\n            $timeClause,\n            $magazineClause,\n            $userClause,\n            $hashtagClauseEntryComment,\n            $federationClause,\n            $domainClausePost,\n            $languagesClause,\n            $subClauseEntryComment,\n            $modClause,\n            $favClauseEntryComment,\n            $blockingClausePost,\n            $hideAdultClause,\n            $visibilityClauseM,\n            $visibilityClauseC,\n            $allClause,\n            $filterClauseComments,\n        ]);\n\n        $postCommentWhere = SqlHelpers::makeWhereString([\n            $contentClausePost,\n            $timeClause,\n            $magazineClause,\n            $userClause,\n            $hashtagClausePostComment,\n            $federationClause,\n            $domainClausePost,\n            $languagesClause,\n            $contentTypeClausePost,\n            $subClausePostComment,\n            $modClause,\n            $favClausePostComment,\n            $blockingClausePost,\n            $hideAdultClause,\n            $visibilityClauseM,\n            $visibilityClauseC,\n            $allClause,\n            $filterClauseComments,\n        ]);\n\n        $outerWhere = SqlHelpers::makeWhereString([\n            $visibilityClauseU,\n            $deletedClause,\n            $allClauseU,\n            $addCursor ? '%cursor% OR (%cursor2%)' : '',\n        ]);\n\n        $orderings = $addCursor ? ['%cursorSort%', '%cursorSort2%'] : $this->getOrderings($criteria);\n\n        $orderBy = 'ORDER BY '.join(', ', $orderings);\n        // only join domain if we are explicitly looking at one\n        $domainJoin = $criteria->domain ? 'LEFT JOIN domain d ON d.id = c.domain_id' : '';\n\n        $entrySql = \"SELECT c.id, 'entry' as type, c.type as content_type, c.created_at, c.ranking, c.score, c.comment_count, c.sticky, c.last_active, c.user_id FROM entry c\n            LEFT JOIN magazine m ON c.magazine_id = m.id\n            $domainJoin\n            $entryWhere\";\n        $postSql = \"SELECT c.id, 'post' as type, 'microblog' as content_type, c.created_at, c.ranking, c.score, c.comment_count, c.sticky, c.last_active, c.user_id FROM post c\n            LEFT JOIN magazine m ON c.magazine_id = m.id\n            $postWhere\";\n        $entryCommentSql = \"SELECT c.id, 'entry_comment' as type, 'microblog' as content_type, c.created_at, 0 as ranking, 0 as score, 0 as comment_count, false as sticky, c.last_active, c.user_id FROM entry_comment c\n            LEFT JOIN magazine m ON c.magazine_id = m.id\n            $entryCommentWhere\";\n        $postCommentSql = \"SELECT c.id, 'post_comment' as type, 'microblog' as content_type, c.created_at, 0 as ranking, 0 as score, 0 as comment_count, false as sticky, c.last_active, c.user_id FROM post_comment c\n            LEFT JOIN magazine m ON c.magazine_id = m.id\n            $postCommentWhere\";\n\n        $innerLimit = $addCursor ? 'LIMIT :limit' : '';\n        $innerSql = '';\n        if (Criteria::CONTENT_THREADS === $criteria->content) {\n            if ($includeEntryComments) {\n                $innerSql = \"($entrySql $orderBy $innerLimit) UNION ALL ($entryCommentSql $orderBy $innerLimit)\";\n            } else {\n                $innerSql = \"$entrySql $orderBy $innerLimit\";\n            }\n        } elseif (Criteria::CONTENT_MICROBLOG === $criteria->content) {\n            if ($includePostComments) {\n                $innerSql = \"($postSql $orderBy $innerLimit) UNION ALL ($postCommentSql $orderBy $innerLimit)\";\n            } else {\n                $innerSql = \"$postSql $orderBy $innerLimit\";\n            }\n        } else {\n            $innerSql = \"($entrySql $orderBy $innerLimit) UNION ALL ($postSql $orderBy $innerLimit)\";\n            if ($includeEntryComments) {\n                $innerSql .= \" UNION ALL ($entryCommentSql $orderBy $innerLimit)\";\n            }\n            if ($includePostComments) {\n                $innerSql .= \" UNION ALL ($postCommentSql $orderBy $innerLimit)\";\n            }\n        }\n\n        $sql = \"SELECT c.* FROM ($innerSql) c\n            INNER JOIN \\\"user\\\" u ON c.user_id = u.id\n            $outerWhere\n            $orderBy\";\n\n        if (!str_contains($sql, ':loggedInUser')) {\n            $parameters = array_filter($parameters, fn ($key) => 'loggedInUser' !== $key, mode: ARRAY_FILTER_USE_KEY);\n        }\n\n        $rewritten = SqlHelpers::rewriteArrayParameters($parameters, $sql);\n\n        $this->logger->debug('{s} | {p}', ['s' => $sql, 'p' => $parameters]);\n        $this->logger->debug('Rewritten to: {s} | {p}', ['p' => $rewritten['parameters'], 's' => $rewritten['sql']]);\n\n        return $rewritten;\n    }\n\n    private function getCursorFieldFromCriteria(Criteria $criteria): string\n    {\n        return match ($criteria->sortOption) {\n            Criteria::SORT_TOP => 'score',\n            Criteria::SORT_HOT => 'ranking',\n            Criteria::SORT_COMMENTED => 'commentCount',\n            Criteria::SORT_ACTIVE => 'lastActive',\n            default => 'createdAt',\n        };\n    }\n\n    private function getCursorWhereFromCriteria(Criteria $criteria): string\n    {\n        return match ($criteria->sortOption) {\n            Criteria::SORT_TOP => 'c.score < :cursor',\n            Criteria::SORT_HOT => 'c.ranking < :cursor',\n            Criteria::SORT_COMMENTED => 'c.comment_count < :cursor',\n            Criteria::SORT_ACTIVE => 'c.last_active < :cursor',\n            Criteria::SORT_OLD => 'c.created_at > :cursor',\n            default => 'c.created_at < :cursor',\n        };\n    }\n\n    private function getCursorWhereInvertedFromCriteria(Criteria $criteria): string\n    {\n        return match ($criteria->sortOption) {\n            Criteria::SORT_TOP => 'c.score > :cursor',\n            Criteria::SORT_HOT => 'c.ranking > :cursor',\n            Criteria::SORT_COMMENTED => 'c.comment_count > :cursor',\n            Criteria::SORT_ACTIVE => 'c.last_active > :cursor',\n            Criteria::SORT_OLD => 'c.created_at < :cursor',\n            default => 'c.created_at >= :cursor',\n        };\n    }\n\n    private function getSecondaryCursorWhereFromCriteria(Criteria $criteria): string\n    {\n        return match ($criteria->sortOption) {\n            Criteria::SORT_TOP => 'c.score = :cursor AND c.created_at < :cursor2',\n            Criteria::SORT_HOT => 'c.ranking = :cursor AND c.created_at < :cursor2',\n            Criteria::SORT_COMMENTED => 'c.comment_count = :cursor AND c.created_at < :cursor2',\n            Criteria::SORT_ACTIVE => 'c.last_active = :cursor AND c.created_at < :cursor2',\n            default => 'FALSE',\n        };\n    }\n\n    private function getSecondaryCursorWhereFromCriteriaInverted(Criteria $criteria): string\n    {\n        return match ($criteria->sortOption) {\n            Criteria::SORT_TOP => 'c.score = :cursor AND c.created_at >= :cursor2',\n            Criteria::SORT_HOT => 'c.ranking = :cursor AND c.created_at >= :cursor2',\n            Criteria::SORT_COMMENTED => 'c.comment_count = :cursor AND c.created_at >= :cursor2',\n            Criteria::SORT_ACTIVE => 'c.last_active = :cursor AND c.created_at >= :cursor2',\n            default => 'FALSE',\n        };\n    }\n\n    public function guessInitialCursor(string $sortOption): mixed\n    {\n        return match ($sortOption) {\n            Criteria::SORT_TOP, Criteria::SORT_HOT, Criteria::SORT_COMMENTED => 2147483647, // postgresql max int\n            Criteria::SORT_OLD => (new \\DateTimeImmutable())->setTimestamp(0),\n            default => new \\DateTimeImmutable('now + 1 minute'),\n        };\n    }\n\n    private function getOrderings(Criteria $criteria): array\n    {\n        $orderings = [];\n\n        if ($criteria->stickiesFirst) {\n            $orderings[] = 'sticky DESC';\n        }\n\n        switch ($criteria->sortOption) {\n            case Criteria::SORT_TOP:\n                $orderings[] = 'score DESC';\n                break;\n            case Criteria::SORT_HOT:\n                $orderings[] = 'ranking DESC';\n                break;\n            case Criteria::SORT_COMMENTED:\n                $orderings[] = 'comment_count DESC';\n                break;\n            case Criteria::SORT_ACTIVE:\n                $orderings[] = 'last_active DESC';\n                break;\n            default:\n        }\n\n        switch ($criteria->sortOption) {\n            case Criteria::SORT_OLD:\n                $orderings[] = 'created_at ASC';\n                break;\n            case Criteria::SORT_NEW:\n            default:\n                $orderings[] = 'created_at DESC';\n        }\n\n        return $orderings;\n    }\n}\n"
  },
  {
    "path": "src/Repository/Criteria.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Repository;\n\nuse App\\Entity\\Contracts\\VisibilityInterface;\nuse App\\Entity\\Entry;\nuse App\\Entity\\Magazine;\nuse App\\Entity\\User;\nuse App\\Utils\\SqlHelpers;\n\nabstract class Criteria\n{\n    public const SORT_ACTIVE = 'active';\n    public const SORT_HOT = 'hot';\n    public const SORT_NEW = 'newest';\n    public const SORT_DEFAULT = self::SORT_HOT;\n\n    public const SORT_OLD = 'oldest';\n    public const SORT_TOP = 'top';\n    public const SORT_COMMENTED = 'commented';\n\n    public const TIME_3_HOURS = '3hours';\n    public const TIME_6_HOURS = '6hours';\n    public const TIME_12_HOURS = '12hours';\n    public const TIME_DAY = 'day';\n    public const TIME_WEEK = 'week';\n    public const TIME_MONTH = 'month';\n    public const TIME_YEAR = 'year';\n    public const TIME_ALL = '∞';\n\n    public const AP_ALL = 'all';\n    public const AP_LOCAL = 'local';\n    public const AP_FEDERATED = 'federated';\n\n    public const CONTENT_COMBINED = 'combined';\n    public const CONTENT_THREADS = 'threads';\n    public const CONTENT_MICROBLOG = 'microblog';\n\n    public const SORT_OPTIONS = [\n        self::SORT_ACTIVE,\n        self::SORT_HOT,\n        self::SORT_NEW,\n        self::SORT_OLD,\n        self::SORT_TOP,\n        self::SORT_COMMENTED,\n    ];\n\n    public const TIME_OPTIONS = [\n        self::TIME_6_HOURS,\n        self::TIME_12_HOURS,\n        self::TIME_DAY,\n        self::TIME_WEEK,\n        self::TIME_MONTH,\n        self::TIME_YEAR,\n        self::TIME_ALL,\n    ];\n\n    public const TIME_ROUTES_EN = [\n        '3h',\n        '6h',\n        '12h',\n        '1d',\n        '1w',\n        '1m',\n        '1y',\n        '∞',\n        'all',\n    ];\n\n    public const AP_OPTIONS = [\n        self::AP_ALL,\n        self::AP_FEDERATED,\n        self::AP_LOCAL,\n    ];\n\n    public const array CONTENT_OPTIONS = [\n        self::CONTENT_COMBINED,\n        self::CONTENT_THREADS,\n        self::CONTENT_MICROBLOG,\n    ];\n\n    public int $page = 1;\n    public ?Magazine $magazine = null;\n    public ?User $user = null;\n    public ?int $perPage = null;\n    public string $type = 'all';\n    public string $sortOption = EntryRepository::SORT_DEFAULT;\n    public string $time = EntryRepository::TIME_DEFAULT;\n    public string $visibility = VisibilityInterface::VISIBILITY_VISIBLE;\n    public string $federation = self::AP_ALL;\n    public string $content = self::CONTENT_THREADS;\n    public bool $subscribed = false;\n    public bool $moderated = false;\n    public bool $favourite = false;\n    public bool $includeBoosts = false;\n    public ?string $tag = null;\n    public ?string $domain = null;\n    public ?array $languages = null;\n    public bool $stickiesFirst = false;\n\n    /** @var int[]|null */\n    public ?array $cachedUserFollows = null;\n\n    /** @var int[]|null */\n    public ?array $cachedUserSubscribedMagazines = null;\n\n    /** @var int[]|null */\n    public ?array $cachedUserModeratedMagazines = null;\n\n    /** @var int[]|null */\n    public ?array $cachedUserSubscribedDomains = null;\n\n    /** @var int[]|null */\n    public ?array $cachedUserBlocks = null;\n\n    /** @var int[]|null */\n    public ?array $cachedUserBlockedMagazines = null;\n\n    /** @var int[]|null */\n    public ?array $cachedUserBlockedDomains = null;\n\n    public const THEME_MBIN = 'mbin';\n    public const THEME_KBIN = 'kbin';\n    public const THEME_AUTO = 'default';\n    public const THEME_LIGHT = 'light';\n    public const THEME_DARK = 'dark';\n    public const THEME_SOLARIZED_AUTO = 'solarized';\n    public const THEME_SOLARIZED_LIGHT = 'solarized-light';\n    public const THEME_SOLARIZED_DARK = 'solarized-dark';\n    public const THEME_TOKYO_NIGHT = 'tokyo-night';\n\n    public const THEME_OPTIONS = [\n        // 'Mbin' => SELF::THEME_MBIN, // TODO uncomment when theme is ready\n        '/kbin' => self::THEME_KBIN,\n        'default_theme_auto' => self::THEME_AUTO,\n        'light' => self::THEME_LIGHT,\n        'dark' => self::THEME_DARK,\n        'solarized_auto' => self::THEME_SOLARIZED_AUTO,\n        'solarized_light' => self::THEME_SOLARIZED_LIGHT,\n        'solarized_dark' => self::THEME_SOLARIZED_DARK,\n        'tokyo_night' => self::THEME_TOKYO_NIGHT,\n    ];\n\n    public function __construct(int $page)\n    {\n        $this->page = $page;\n    }\n\n    public function setFederation($feed): self\n    {\n        $this->federation = $feed;\n\n        return $this;\n    }\n\n    public function setType(?string $type): self\n    {\n        if ($type) {\n            $this->type = $type;\n        }\n\n        return $this;\n    }\n\n    public function setContent(string $content): self\n    {\n        $this->content = $content;\n\n        return $this;\n    }\n\n    public function setTag(string $name): self\n    {\n        $this->tag = $name;\n\n        return $this;\n    }\n\n    public function setDomain(string $name): self\n    {\n        $this->domain = $name;\n\n        return $this;\n    }\n\n    public function addLanguage(string $lang): self\n    {\n        if (null === $this->languages) {\n            $this->languages = [];\n        }\n        array_push($this->languages, $lang);\n\n        return $this;\n    }\n\n    public function showSortOption(?string $sortOption): self\n    {\n        if ($sortOption) {\n            $this->sortOption = $sortOption;\n        }\n\n        return $this;\n    }\n\n    protected function routes(): array\n    {\n        // @todo getRoute EntryManager\n        return [\n            'top' => Criteria::SORT_TOP,\n            'hot' => Criteria::SORT_HOT,\n            'active' => Criteria::SORT_ACTIVE,\n            'newest' => Criteria::SORT_NEW,\n            'oldest' => Criteria::SORT_OLD,\n            'commented' => Criteria::SORT_COMMENTED,\n        ];\n    }\n\n    public function resolveSort(?string $value): string\n    {\n        $routes = $this->routes();\n\n        return $routes[$value] ?? $routes['hot'];\n    }\n\n    // resolveTime() converts our internal values into ones for human presenation\n    // $reverse = true indicates converting back, from human values to internal ones\n\n    // This whole approach is a mess; this translation layer is temporary until\n    // we have time to take a pass through the whole codebase and convert so there's\n    // no such thing as multiple alternate value strings and translation layers\n    // between them. This is just a temporary measure to produce desired output\n    // until the whole layer goes away.\n    public function resolveTime(?string $value, bool $reverse = false): ?string\n    {\n        // @todo\n        $routes = [\n            '3h' => Criteria::TIME_3_HOURS,\n            '6h' => Criteria::TIME_6_HOURS,\n            '12h' => Criteria::TIME_12_HOURS,\n            '1d' => Criteria::TIME_DAY,\n            '1w' => Criteria::TIME_WEEK,\n            '1m' => Criteria::TIME_MONTH,\n            '1y' => Criteria::TIME_YEAR,\n            '∞' => Criteria::TIME_ALL,\n            'all' => Criteria::TIME_ALL,\n        ];\n\n        if ($reverse) {\n            if ('all' === $value || '∞' === $value || null === $value) {\n                return '∞';\n            }\n            $reversedRoutes = array_flip($routes);\n\n            return $reversedRoutes[$value] ?? '∞';\n        } else {\n            return $routes[$value] ?? null;\n        }\n    }\n\n    public function resolveType(?string $value): ?string\n    {\n        return match ($value) {\n            'article', 'articles' => Entry::ENTRY_TYPE_ARTICLE,\n            'link', 'links' => Entry::ENTRY_TYPE_LINK,\n            'video', 'videos' => Entry::ENTRY_TYPE_VIDEO,\n            'photo', 'photos', 'image', 'images' => Entry::ENTRY_TYPE_IMAGE,\n            default => 'all',\n        };\n    }\n\n    public function translateType(): string\n    {\n        return match ($this->resolveType($this->type)) {\n            Entry::ENTRY_TYPE_ARTICLE => 'threads',\n            Entry::ENTRY_TYPE_LINK => 'links',\n            Entry::ENTRY_TYPE_VIDEO => 'videos',\n            Entry::ENTRY_TYPE_IMAGE => 'photos',\n            default => 'all',\n        };\n    }\n\n    public function resolveSubscriptionFilter(): ?string\n    {\n        if ($this->subscribed) {\n            return 'subscribed';\n        } elseif ($this->moderated) {\n            return 'moderated';\n        } elseif ($this->favourite) {\n            return 'favourites';\n        } else {\n            return 'all';\n        }\n    }\n\n    public function setVisibility(string $visibility): self\n    {\n        $this->visibility = $visibility;\n\n        return $this;\n    }\n\n    public function setTime(?string $time): self\n    {\n        if ($time) {\n            $this->time = $time;\n        } else {\n            $this->time = EntryRepository::TIME_DEFAULT;\n        }\n\n        return $this;\n    }\n\n    public function getSince(): \\DateTimeImmutable\n    {\n        $since = new \\DateTimeImmutable('@'.time());\n\n        return match ($this->time) {\n            Criteria::TIME_YEAR => $since->modify('-1 year'),\n            Criteria::TIME_MONTH => $since->modify('-1 month'),\n            Criteria::TIME_WEEK => $since->modify('-1 week'),\n            Criteria::TIME_DAY => $since->modify('-1 day'),\n            Criteria::TIME_12_HOURS => $since->modify('-12 hours'),\n            Criteria::TIME_6_HOURS => $since->modify('-6 hours'),\n            Criteria::TIME_3_HOURS => $since->modify('-3 hours'),\n            default => throw new \\LogicException(),\n        };\n    }\n\n    public function getOption(string $key): string\n    {\n        return match ($key) {\n            'sort' => $this->resolveSort($this->sortOption),\n            'time' => '∞' === $this->resolveTime($this->time, true) ? 'all' : $this->resolveTime($this->time, true),\n            'type' => $this->translateType(),\n            'visibility' => $this->visibility,\n            'federation' => $this->federation,\n            'content' => $this->content,\n            'tag' => $this->tag,\n            'domain' => $this->domain,\n            'subscription' => $this->resolveSubscriptionFilter(),\n            default => throw new \\LogicException('Unknown option: '.$key),\n        };\n    }\n\n    public function fetchCachedItems(SqlHelpers $sqlHelpers, User $loggedInUser): void\n    {\n        $this->cachedUserFollows = $sqlHelpers->getCachedUserFollows($loggedInUser);\n\n        if ($this->subscribed) {\n            $this->cachedUserSubscribedDomains = $sqlHelpers->getCachedUserSubscribedDomains($loggedInUser);\n            $this->cachedUserSubscribedMagazines = $sqlHelpers->getCachedUserSubscribedMagazines($loggedInUser);\n        }\n\n        if ($this->moderated) {\n            $this->cachedUserModeratedMagazines = $sqlHelpers->getCachedUserModeratedMagazines($loggedInUser);\n        }\n\n        $this->cachedUserBlocks = $sqlHelpers->getCachedUserBlocks($loggedInUser);\n        $this->cachedUserBlockedDomains = $sqlHelpers->getCachedUserDomainBlocks($loggedInUser);\n        $this->cachedUserBlockedMagazines = $sqlHelpers->getCachedUserMagazineBlocks($loggedInUser);\n    }\n}\n"
  },
  {
    "path": "src/Repository/DomainRepository.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Repository;\n\nuse App\\Entity\\Domain;\nuse App\\Entity\\User;\nuse Doctrine\\Bundle\\DoctrineBundle\\Repository\\ServiceEntityRepository;\nuse Doctrine\\Persistence\\ManagerRegistry;\nuse Pagerfanta\\Doctrine\\Collections\\CollectionAdapter;\nuse Pagerfanta\\Doctrine\\ORM\\QueryAdapter;\nuse Pagerfanta\\Exception\\NotValidCurrentPageException;\nuse Pagerfanta\\Pagerfanta;\nuse Symfony\\Component\\HttpKernel\\Exception\\NotFoundHttpException;\n\n/**\n * @method Domain|null find($id, $lockMode = null, $lockVersion = null)\n * @method Domain|null findOneBy(array $criteria, array $orderBy = null)\n * @method Domain|null findOneByName(string $name)\n * @method Domain[]    findAll()\n * @method Domain[]    findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)\n */\nclass DomainRepository extends ServiceEntityRepository\n{\n    public const PER_PAGE = 100;\n\n    public function __construct(ManagerRegistry $registry)\n    {\n        parent::__construct($registry, Domain::class);\n    }\n\n    public function findAllPaginated(int $page, int $perPage = self::PER_PAGE): Pagerfanta\n    {\n        $qb = $this->createQueryBuilder('d');\n\n        $pagerfanta = new Pagerfanta(\n            new QueryAdapter(\n                $qb\n            )\n        );\n\n        try {\n            $pagerfanta->setMaxPerPage($perPage);\n            $pagerfanta->setCurrentPage($page);\n        } catch (NotValidCurrentPageException $e) {\n            throw new NotFoundHttpException();\n        }\n\n        return $pagerfanta;\n    }\n\n    public function findSubscribedDomains(int $page, User $user, int $perPage = self::PER_PAGE): Pagerfanta\n    {\n        $pagerfanta = new Pagerfanta(\n            new CollectionAdapter(\n                $user->subscribedDomains\n            )\n        );\n\n        try {\n            $pagerfanta->setMaxPerPage($perPage);\n            $pagerfanta->setCurrentPage($page);\n        } catch (NotValidCurrentPageException $e) {\n            throw new NotFoundHttpException();\n        }\n\n        return $pagerfanta;\n    }\n\n    public function findBlockedDomains(int $page, User $user, int $perPage = self::PER_PAGE): Pagerfanta\n    {\n        $pagerfanta = new Pagerfanta(\n            new CollectionAdapter(\n                $user->blockedDomains\n            )\n        );\n\n        try {\n            $pagerfanta->setMaxPerPage($perPage);\n            $pagerfanta->setCurrentPage($page);\n        } catch (NotValidCurrentPageException $e) {\n            throw new NotFoundHttpException();\n        }\n\n        return $pagerfanta;\n    }\n\n    public function search(string $domain, int $page, int $perPage = self::PER_PAGE): Pagerfanta\n    {\n        $qb = $this->createQueryBuilder('d')\n            ->where(\n                'LOWER(d.name) LIKE LOWER(:q)'\n            )\n            ->orderBy('d.entryCount', 'DESC')\n            ->setParameter('q', '%'.$domain.'%');\n\n        $pagerfanta = new Pagerfanta(\n            new QueryAdapter(\n                $qb\n            )\n        );\n\n        try {\n            $pagerfanta->setMaxPerPage($perPage);\n            $pagerfanta->setCurrentPage($page);\n        } catch (NotValidCurrentPageException $e) {\n            throw new NotFoundHttpException();\n        }\n\n        return $pagerfanta;\n    }\n}\n"
  },
  {
    "path": "src/Repository/DomainSubscriptionRepository.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Repository;\n\nuse App\\Entity\\DomainSubscription;\nuse Doctrine\\Bundle\\DoctrineBundle\\Repository\\ServiceEntityRepository;\nuse Doctrine\\Persistence\\ManagerRegistry;\n\n/**\n * @method DomainSubscription|null find($id, $lockMode = null, $lockVersion = null)\n * @method DomainSubscription|null findOneBy(array $criteria, array $orderBy = null)\n * @method DomainSubscription[]    findAll()\n * @method DomainSubscription[]    findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)\n */\nclass DomainSubscriptionRepository extends ServiceEntityRepository\n{\n    public function __construct(ManagerRegistry $registry)\n    {\n        parent::__construct($registry, DomainSubscription::class);\n    }\n}\n"
  },
  {
    "path": "src/Repository/EmbedRepository.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Repository;\n\nuse App\\Entity\\Embed;\nuse Doctrine\\Bundle\\DoctrineBundle\\Repository\\ServiceEntityRepository;\nuse Doctrine\\Persistence\\ManagerRegistry;\nuse Psr\\Log\\LoggerInterface;\n\n/**\n * @method Embed|null find($id, $lockMode = null, $lockVersion = null)\n * @method Embed|null findOneBy(array $criteria, array $orderBy = null)\n * @method Embed|null findOneByUrl(string $url)\n * @method Embed[]    findAll()\n * @method Embed[]    findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)\n */\nclass EmbedRepository extends ServiceEntityRepository\n{\n    public function __construct(\n        ManagerRegistry $registry,\n        private readonly LoggerInterface $logger,\n    ) {\n        parent::__construct($registry, Embed::class);\n    }\n\n    public function add(Embed $entity, bool $flush = true): void\n    {\n        // Check if embed url does not exists yet (null),\n        // before we try to insert a new DB record\n        if (null === $this->findOneByUrl($entity->url)) {\n            // Do not exceed URL length limit defined by db schema\n            try {\n                $this->getEntityManager()->persist($entity);\n\n                if ($flush) {\n                    $this->getEntityManager()->flush();\n                }\n            } catch (\\Exception $e) {\n                $this->logger->warning('Embed URL exceeds allowed length: {url, length}', ['url' => $entity->url, \\strlen($entity->url)]);\n            }\n        }\n    }\n\n    public function remove(Embed $entity, bool $flush = true): void\n    {\n        $this->getEntityManager()->remove($entity);\n        if ($flush) {\n            $this->getEntityManager()->flush();\n        }\n    }\n}\n"
  },
  {
    "path": "src/Repository/EntryCommentRepository.php",
    "content": "<?php\n\n// SPDX-FileCopyrightText: Copyright (c) 2016-2017 Emma <emma1312@protonmail.ch>\n//\n// SPDX-License-Identifier: Zlib\n\ndeclare(strict_types=1);\n\nnamespace App\\Repository;\n\nuse App\\Entity\\Contracts\\VisibilityInterface;\nuse App\\Entity\\DomainBlock;\nuse App\\Entity\\DomainSubscription;\nuse App\\Entity\\Entry;\nuse App\\Entity\\EntryComment;\nuse App\\Entity\\EntryCommentFavourite;\nuse App\\Entity\\HashtagLink;\nuse App\\Entity\\Image;\nuse App\\Entity\\MagazineBlock;\nuse App\\Entity\\MagazineSubscription;\nuse App\\Entity\\Moderator;\nuse App\\Entity\\User;\nuse App\\Entity\\UserBlock;\nuse App\\Entity\\UserFollow;\nuse App\\Service\\SettingsManager;\nuse App\\Utils\\DownvotesMode;\nuse Doctrine\\Bundle\\DoctrineBundle\\Repository\\ServiceEntityRepository;\nuse Doctrine\\DBAL\\ArrayParameterType;\nuse Doctrine\\DBAL\\Types\\Types;\nuse Doctrine\\ORM\\Query\\Expr\\Join;\nuse Doctrine\\ORM\\QueryBuilder;\nuse Doctrine\\Persistence\\ManagerRegistry;\nuse Pagerfanta\\Doctrine\\ORM\\QueryAdapter;\nuse Pagerfanta\\Exception\\NotValidCurrentPageException;\nuse Pagerfanta\\Pagerfanta;\nuse Symfony\\Bundle\\SecurityBundle\\Security;\nuse Symfony\\Component\\HttpKernel\\Exception\\NotFoundHttpException;\n\n/**\n * @method EntryComment|null find($id, $lockMode = null, $lockVersion = null)\n * @method EntryComment|null findOneBy(array $criteria, array $orderBy = null)\n * @method EntryComment[]    findAll()\n * @method EntryComment[]    findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)\n */\nclass EntryCommentRepository extends ServiceEntityRepository\n{\n    public const SORT_DEFAULT = 'active';\n    public const PER_PAGE = 15;\n\n    public function __construct(\n        ManagerRegistry $registry,\n        private readonly Security $security,\n        private readonly SettingsManager $settingsManager,\n    ) {\n        parent::__construct($registry, EntryComment::class);\n    }\n\n    public function findByCriteria(Criteria $criteria): Pagerfanta\n    {\n        $pagerfanta = new Pagerfanta(\n            new QueryAdapter(\n                $this->getEntryQueryBuilder($criteria),\n                false\n            )\n        );\n\n        try {\n            $pagerfanta->setMaxPerPage($criteria->perPage ?? self::PER_PAGE);\n            $pagerfanta->setCurrentPage($criteria->page);\n        } catch (NotValidCurrentPageException $e) {\n            throw new NotFoundHttpException();\n        }\n\n        return $pagerfanta;\n    }\n\n    private function getEntryQueryBuilder(Criteria $criteria): QueryBuilder\n    {\n        $user = $this->security->getUser();\n\n        $qb = $this->createQueryBuilder('c')\n            ->select('c', 'u')\n            ->join('c.user', 'u')\n            ->andWhere('c.visibility IN (:visibility)')\n            ->andWhere('u.visibility IN (:visible)');\n\n        if ($user && VisibilityInterface::VISIBILITY_VISIBLE === $criteria->visibility) {\n            $qb->orWhere(\n                'c.user IN (SELECT IDENTITY(cuf.following) FROM '.UserFollow::class.' cuf WHERE cuf.follower = :cUser AND c.visibility = :cVisibility)'\n            )\n                ->setParameter('cUser', $user)\n                ->setParameter('cVisibility', VisibilityInterface::VISIBILITY_PRIVATE);\n        }\n\n        $qb->setParameter(\n            'visibility',\n            [\n                VisibilityInterface::VISIBILITY_SOFT_DELETED,\n                VisibilityInterface::VISIBILITY_VISIBLE,\n                VisibilityInterface::VISIBILITY_TRASHED,\n            ]\n        )\n            ->setParameter('visible', VisibilityInterface::VISIBILITY_VISIBLE);\n\n        $this->addTimeClause($qb, $criteria);\n        $this->filter($qb, $criteria);\n        $this->addBannedHashtagClause($qb);\n        if ($user instanceof User) {\n            $this->filterWords($qb, $user);\n        }\n\n        return $qb;\n    }\n\n    private function addTimeClause(QueryBuilder $qb, Criteria $criteria): void\n    {\n        if (Criteria::TIME_ALL !== $criteria->time) {\n            $since = $criteria->getSince();\n\n            $qb->andWhere('c.createdAt > :time')\n                ->setParameter('time', $since, Types::DATETIMETZ_IMMUTABLE);\n        }\n    }\n\n    private function addBannedHashtagClause(QueryBuilder $qb): void\n    {\n        $dql = $this->getEntityManager()->createQueryBuilder()\n            ->select('hl2')\n            ->from(HashtagLink::class, 'hl2')\n            ->join('hl2.hashtag', 'h2')\n            ->where('h2.banned = true')\n            ->andWhere('hl2.entryComment = c')\n            ->getDQL();\n        $qb->andWhere($qb->expr()->not($qb->expr()->exists($dql)));\n    }\n\n    private function filter(QueryBuilder $qb, Criteria $criteria): QueryBuilder\n    {\n        $user = $this->security->getUser();\n\n        if (Criteria::AP_LOCAL === $criteria->federation) {\n            $qb->andWhere('c.apId IS NULL');\n        }\n\n        if ($criteria->entry) {\n            $qb->andWhere('c.entry = :entry')\n                ->setParameter('entry', $criteria->entry);\n        }\n\n        if ($criteria->magazine) {\n            $qb->join('c.entry', 'e', Join::WITH, 'e.magazine = :magazine')\n                ->setParameter('magazine', $criteria->magazine)\n                ->andWhere('e.visibility = :visible')\n                ->setParameter('visible', VisibilityInterface::VISIBILITY_VISIBLE);\n        } else {\n            $qb->join('c.entry', 'e')\n                ->andWhere('e.visibility = :visible')\n                ->setParameter('visible', VisibilityInterface::VISIBILITY_VISIBLE);\n        }\n\n        if ($criteria->user) {\n            $qb->andWhere('c.user = :user')\n                ->setParameter('user', $criteria->user);\n        }\n\n        $qb->join('c.entry', 'ce');\n\n        if ($criteria->domain) {\n            $qb->andWhere('ced.name = :domain')\n                ->join('ce.domain', 'ced')\n                ->setParameter('domain', $criteria->domain);\n        }\n\n        if ($criteria->languages) {\n            $qb->andWhere('c.lang IN (:languages)')\n                ->setParameter('languages', $criteria->languages, ArrayParameterType::STRING);\n        }\n\n        if ($criteria->tag) {\n            $qb->andWhere('t.tag = :tag')\n                ->join('c.hashtags', 'h')\n                ->join('h.hashtag', 't')\n                ->setParameter('tag', $criteria->tag);\n        }\n\n        if ($criteria->subscribed) {\n            $qb->andWhere(\n                'c.magazine IN (SELECT IDENTITY(ms.magazine) FROM '.MagazineSubscription::class.' ms WHERE ms.user = :follower)\n                OR\n                c.user IN (SELECT IDENTITY(uf.following) FROM '.UserFollow::class.' uf WHERE uf.follower = :follower)\n                OR\n                c.user = :follower\n                OR\n                ce.domain IN (SELECT IDENTITY(ds.domain) FROM '.DomainSubscription::class.' ds WHERE ds.user = :follower)'\n            );\n            $qb->setParameter('follower', $user);\n        }\n\n        if ($criteria->moderated) {\n            $qb->andWhere(\n                'c.magazine IN (SELECT IDENTITY(cm.magazine) FROM '.Moderator::class.' cm WHERE cm.user = :user)'\n            );\n            $qb->setParameter('user', $this->security->getUser());\n        }\n\n        if ($criteria->favourite) {\n            $qb->andWhere(\n                'c.id IN (SELECT IDENTITY(cf.entryComment) FROM '.EntryCommentFavourite::class.' cf WHERE cf.user = :user)'\n            );\n            $qb->setParameter('user', $this->security->getUser());\n        }\n\n        if ($user && (!$criteria->magazine || !$criteria->magazine->userIsModerator($user)) && !$criteria->moderated) {\n            $qb->andWhere(\n                'c.user NOT IN (SELECT IDENTITY(ub.blocked) FROM '.UserBlock::class.' ub WHERE ub.blocker = :blocker)'\n            );\n\n            $qb->andWhere(\n                'ce.user NOT IN (SELECT IDENTITY(ubc.blocked) FROM '.UserBlock::class.' ubc WHERE ubc.blocker = :blocker)'\n            );\n\n            $qb->andWhere(\n                'c.magazine NOT IN (SELECT IDENTITY(mb.magazine) FROM '.MagazineBlock::class.' mb WHERE mb.user = :blocker)'\n            );\n\n            if (!$criteria->domain) {\n                $qb->andWhere(\n                    'ce.domain IS null OR ce.domain NOT IN (SELECT IDENTITY(db.domain) FROM '.DomainBlock::class.' db WHERE db.user = :blocker)'\n                );\n            }\n\n            $qb->setParameter('blocker', $user);\n        }\n\n        if ($criteria->onlyParents) {\n            $qb->andWhere('c.parent IS NULL');\n        }\n\n        if (!$user || $user->hideAdult) {\n            $qb->join('e.magazine', 'm')\n                ->andWhere('m.isAdult = :isAdult')\n                ->andWhere('e.isAdult = :isAdult')\n                ->setParameter('isAdult', false);\n        }\n\n        switch ($criteria->sortOption) {\n            case Criteria::SORT_HOT:\n                $qb->orderBy('c.upVotes', 'DESC');\n                break;\n            case Criteria::SORT_TOP:\n                if (DownvotesMode::Disabled === $this->settingsManager->getDownvotesMode()) {\n                    $qb->orderBy('c.upVotes + c.favouriteCount', 'DESC');\n                } else {\n                    $qb->orderBy('c.upVotes + c.favouriteCount - c.downVotes', 'DESC');\n                }\n                break;\n            case Criteria::SORT_ACTIVE:\n                $qb->orderBy('c.lastActive', 'DESC');\n                break;\n            case Criteria::SORT_NEW:\n                $qb->orderBy('c.createdAt', 'DESC');\n                break;\n            case Criteria::SORT_OLD:\n                $qb->orderBy('c.createdAt', 'ASC');\n                break;\n            default:\n                $qb->addOrderBy('c.lastActive', 'DESC');\n        }\n\n        $qb->addOrderBy('c.createdAt', 'DESC');\n        $qb->addOrderBy('c.id', 'DESC');\n\n        return $qb;\n    }\n\n    private function filterWords(QueryBuilder $qb, User $user): QueryBuilder\n    {\n        $i = 0;\n        foreach ($user->getCurrentFilterLists() as $list) {\n            if (!$list->comments) {\n                continue;\n            }\n\n            foreach ($list->words as $word) {\n                if ($word['exactMatch']) {\n                    $qb->andWhere(\"NOT (c.body LIKE :word$i) OR c.user = :filterUser\")\n                        ->setParameter(\"word$i\", '%'.$word['word'].'%');\n                } else {\n                    $qb->andWhere(\"NOT (lower(c.body) LIKE lower(:word$i)) OR c.user = :filterUser\")\n                        ->setParameter(\"word$i\", '%'.$word['word'].'%');\n                }\n                ++$i;\n            }\n        }\n        if ($i > 0) {\n            $qb->setParameter('filterUser', $user);\n        }\n\n        return $qb;\n    }\n\n    /**\n     * @return Image[]\n     */\n    public function findImagesByEntry(Entry $entry): array\n    {\n        $results = $this->createQueryBuilder('c')\n            ->addSelect('i')\n            ->innerJoin('c.image', 'i')\n            ->andWhere('c.entry = :entry')\n            ->setParameter('entry', $entry)\n            ->getQuery()\n            ->getResult();\n\n        return array_map(fn (EntryComment $comment) => $comment->image, $results);\n    }\n\n    public function hydrateChildren(EntryComment ...$comments): void\n    {\n        $children = $this->createQueryBuilder('c')\n            ->andWhere('c.root IN (:ids)')\n            ->setParameter('ids', $comments)\n            ->getQuery()->getResult();\n\n        $this->hydrate(...$children);\n    }\n\n    public function hydrate(EntryComment ...$comments): void\n    {\n        $this->createQueryBuilder('c')\n            ->select('PARTIAL c.{id}')\n            ->addSelect('u')\n            ->addSelect('e')\n            ->addSelect('em')\n            ->join('c.user', 'u')\n            ->join('c.entry', 'e')\n            ->join('e.magazine', 'em')\n            ->where('c IN (?1)')\n            ->setParameter(1, $comments)\n            ->getQuery()\n            ->execute();\n\n        $this->createQueryBuilder('c')\n            ->select('PARTIAL c.{id}')\n            ->addSelect('cc')\n            ->addSelect('ccu')\n            ->addSelect('ccua')\n            ->leftJoin('c.children', 'cc')\n            ->join('cc.user', 'ccu')\n            ->leftJoin('ccu.avatar', 'ccua')\n            ->where('c IN (?1)')\n            ->setParameter(1, $comments)\n            ->getQuery()\n            ->execute();\n    }\n}\n"
  },
  {
    "path": "src/Repository/EntryRepository.php",
    "content": "<?php\n\n// SPDX-FileCopyrightText: Copyright (c) 2016-2017 Emma <emma1312@protonmail.ch>\n//\n// SPDX-License-Identifier: Zlib\n\ndeclare(strict_types=1);\n\nnamespace App\\Repository;\n\nuse App\\Entity\\Contracts\\VisibilityInterface;\nuse App\\Entity\\DomainBlock;\nuse App\\Entity\\DomainSubscription;\nuse App\\Entity\\Entry;\nuse App\\Entity\\EntryFavourite;\nuse App\\Entity\\HashtagLink;\nuse App\\Entity\\Magazine;\nuse App\\Entity\\MagazineBlock;\nuse App\\Entity\\MagazineSubscription;\nuse App\\Entity\\Moderator;\nuse App\\Entity\\User;\nuse App\\Entity\\UserBlock;\nuse App\\Entity\\UserFollow;\nuse App\\PageView\\EntryPageView;\nuse App\\Pagination\\AdapterFactory;\nuse App\\Service\\SettingsManager;\nuse App\\Utils\\SqlHelpers;\nuse Doctrine\\Bundle\\DoctrineBundle\\Repository\\ServiceEntityRepository;\nuse Doctrine\\DBAL\\ArrayParameterType;\nuse Doctrine\\DBAL\\Types\\Types;\nuse Doctrine\\ORM\\NoResultException;\nuse Doctrine\\ORM\\QueryBuilder;\nuse Doctrine\\Persistence\\ManagerRegistry;\nuse Pagerfanta\\Exception\\NotValidCurrentPageException;\nuse Pagerfanta\\Pagerfanta;\nuse Symfony\\Bundle\\SecurityBundle\\Security;\nuse Symfony\\Component\\HttpKernel\\Exception\\NotFoundHttpException;\nuse Symfony\\Contracts\\Cache\\CacheInterface;\nuse Symfony\\Contracts\\Cache\\ItemInterface;\n\n/**\n * @extends ServiceEntityRepository<Entry>\n *\n * @method Entry|null find($id, $lockMode = null, $lockVersion = null)\n * @method Entry|null findOneBy(array $criteria, array $orderBy = null)\n * @method Entry|null findOneByUrl(string $url)\n * @method Entry[]    findAll()\n * @method Entry[]    findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)\n */\nclass EntryRepository extends ServiceEntityRepository\n{\n    public const SORT_DEFAULT = 'hot';\n    public const TIME_DEFAULT = Criteria::TIME_ALL;\n    public const PER_PAGE = 25;\n\n    public function __construct(\n        ManagerRegistry $registry,\n        private readonly Security $security,\n        private readonly CacheInterface $cache,\n        private readonly AdapterFactory $adapterFactory,\n        private readonly SettingsManager $settingsManager,\n        private readonly SqlHelpers $sqlHelpers,\n    ) {\n        parent::__construct($registry, Entry::class);\n    }\n\n    public function findByCriteria(EntryPageView|Criteria $criteria): Pagerfanta\n    {\n        $pagerfanta = new Pagerfanta($this->adapterFactory->create($this->getEntryQueryBuilder($criteria)));\n\n        try {\n            $pagerfanta->setMaxPerPage($criteria->perPage ?? self::PER_PAGE);\n            $pagerfanta->setCurrentPage($criteria->page);\n            if (!$criteria->magazine) {\n                $pagerfanta->setMaxNbPages(1000);\n            }\n        } catch (NotValidCurrentPageException $e) {\n            throw new NotFoundHttpException();\n        }\n\n        return $pagerfanta;\n    }\n\n    private function getEntryQueryBuilder(EntryPageView $criteria): QueryBuilder\n    {\n        $user = $this->security->getUser();\n\n        $qb = $this->createQueryBuilder('e')\n            ->addSelect('e', 'm', 'u', 'd')\n            ->where('e.visibility = :visibility')\n            ->andWhere('m.visibility = :visible')\n            ->andWhere('u.visibility = :visible')\n            ->andWhere('u.isDeleted = false')\n            ->join('e.magazine', 'm')\n            ->join('e.user', 'u')\n            ->leftJoin('e.domain', 'd');\n\n        if ($user && VisibilityInterface::VISIBILITY_VISIBLE === $criteria->visibility) {\n            $qb->orWhere(\n                'e.user IN (SELECT IDENTITY(euf.following) FROM '.UserFollow::class.' euf WHERE euf.follower = :euf_user AND e.visibility = :euf_visibility)'\n            )\n                ->setParameter('euf_user', $user)\n                ->setParameter('euf_visibility', VisibilityInterface::VISIBILITY_PRIVATE);\n        } else {\n            $qb->orWhere('e.user IS NULL');\n        }\n\n        $qb->setParameter('visibility', $criteria->visibility)\n            ->setParameter('visible', VisibilityInterface::VISIBILITY_VISIBLE);\n\n        $this->addTimeClause($qb, $criteria);\n        $this->addStickyClause($qb, $criteria);\n        $this->filter($qb, $criteria);\n        $this->addBannedHashtagClause($qb);\n\n        return $qb;\n    }\n\n    private function addTimeClause(QueryBuilder $qb, EntryPageView $criteria): void\n    {\n        if (Criteria::TIME_ALL !== $criteria->time) {\n            $since = $criteria->getSince();\n\n            $qb->andWhere('e.createdAt > :time')\n                ->setParameter('time', $since, Types::DATETIMETZ_IMMUTABLE);\n        }\n    }\n\n    private function addStickyClause(QueryBuilder $qb, EntryPageView $criteria): void\n    {\n        if ($criteria->stickiesFirst) {\n            if (1 === $criteria->page) {\n                $qb->addOrderBy('e.sticky', 'DESC');\n            } else {\n                $qb->andWhere($qb->expr()->eq('e.sticky', 'false'));\n            }\n        }\n    }\n\n    private function addBannedHashtagClause(QueryBuilder $qb): void\n    {\n        $dql = $this->getEntityManager()->createQueryBuilder()\n            ->select('hl2')\n            ->from(HashtagLink::class, 'hl2')\n            ->join('hl2.hashtag', 'h2')\n            ->where('h2.banned = true')\n            ->andWhere('hl2.entry = e')\n            ->getDQL();\n        $qb->andWhere(\n            $qb->expr()->not(\n                $qb->expr()->exists($dql)\n            )\n        );\n    }\n\n    private function filter(QueryBuilder $qb, EntryPageView $criteria): QueryBuilder\n    {\n        /** @var User $user */\n        $user = $this->security->getUser();\n\n        if (Criteria::AP_LOCAL === $criteria->federation) {\n            $qb->andWhere('e.apId IS NULL');\n        } elseif (Criteria::AP_FEDERATED === $criteria->federation) {\n            $qb->andWhere('e.apId IS NOT NULL');\n        }\n\n        if ($criteria->magazine) {\n            $qb->andWhere('e.magazine = :magazine')\n                ->setParameter('magazine', $criteria->magazine);\n        }\n\n        if ($criteria->user) {\n            $qb->andWhere('e.user = :user')\n                ->setParameter('user', $criteria->user);\n        }\n\n        if ($criteria->type and 'all' !== $criteria->type) {\n            $qb->andWhere('e.type = :type')\n                ->setParameter('type', $criteria->type);\n        }\n\n        if ($criteria->tag) {\n            $qb->andWhere('t.tag = :tag')\n                ->join('e.hashtags', 'h')\n                ->join('h.hashtag', 't')\n                ->setParameter('tag', $criteria->tag);\n        }\n\n        if ($criteria->domain) {\n            $qb->andWhere('d.name = :domain')\n                ->setParameter('domain', $criteria->domain);\n        }\n\n        if ($criteria->languages) {\n            $qb->andWhere('e.lang IN (:languages)')\n                ->setParameter('languages', $criteria->languages, ArrayParameterType::STRING);\n        }\n\n        if ($criteria->subscribed) {\n            $qb->andWhere(\n                'e.magazine IN (SELECT IDENTITY(ms.magazine) FROM '.MagazineSubscription::class.' ms WHERE ms.user = :user)\n                OR\n                e.user IN (SELECT IDENTITY(uf.following) FROM '.UserFollow::class.' uf WHERE uf.follower = :user)\n                OR\n                e.domain IN (SELECT IDENTITY(ds.domain) FROM '.DomainSubscription::class.' ds WHERE ds.user = :user)\n                OR\n                e.user = :user'\n            )\n                ->setParameter('user', $this->security->getUser());\n        }\n\n        if ($criteria->moderated) {\n            $qb->andWhere(\n                'e.magazine IN (SELECT IDENTITY(mm.magazine) FROM '.Moderator::class.' mm WHERE mm.user = :user)'\n            );\n            $qb->setParameter('user', $this->security->getUser());\n        }\n\n        if ($criteria->favourite) {\n            $qb->andWhere(\n                'e.id IN (SELECT IDENTITY(mf.entry) FROM '.EntryFavourite::class.' mf WHERE mf.user = :user)'\n            );\n            $qb->setParameter('user', $this->security->getUser());\n        }\n\n        if ($user && (!$criteria->magazine || !$criteria->magazine->userIsModerator($user)) && !$criteria->moderated) {\n            $qb->andWhere(\n                'e.user NOT IN (SELECT IDENTITY(ub.blocked) FROM '.UserBlock::class.' ub WHERE ub.blocker = :blocker)'\n            );\n\n            $qb->andWhere(\n                'e.magazine NOT IN (SELECT IDENTITY(mb.magazine) FROM '.MagazineBlock::class.' mb WHERE mb.user = :blocker)'\n            );\n\n            if (!$criteria->domain) {\n                $qb->andWhere(\n                    'e.domain IS null OR e.domain NOT IN (SELECT IDENTITY(db.domain) FROM '.DomainBlock::class.' db WHERE db.user = :blocker)'\n                );\n            }\n\n            $qb->setParameter('blocker', $user);\n        }\n\n        if (!$user || $user->hideAdult) {\n            $qb->andWhere('m.isAdult = :isAdult')\n                ->andWhere('e.isAdult = :isAdult')\n                ->setParameter('isAdult', false);\n        }\n\n        switch ($criteria->sortOption) {\n            case Criteria::SORT_TOP:\n                $qb->addOrderBy('e.score', 'DESC');\n                break;\n            case Criteria::SORT_HOT:\n                $qb->addOrderBy('e.ranking', 'DESC');\n                break;\n            case Criteria::SORT_COMMENTED:\n                $qb->addOrderBy('e.commentCount', 'DESC');\n                break;\n            case Criteria::SORT_ACTIVE:\n                $qb->addOrderBy('e.lastActive', 'DESC');\n                break;\n            default:\n        }\n\n        $qb->addOrderBy('e.createdAt', Criteria::SORT_OLD === $criteria->sortOption ? 'ASC' : 'DESC');\n        $qb->addOrderBy('e.id', 'DESC');\n\n        return $qb;\n    }\n\n    public function hydrate(Entry ...$entries): void\n    {\n        $this->getEntityManager()->createQueryBuilder()\n            ->select('PARTIAL e.{id}')\n            ->addSelect('u')\n            ->addSelect('ua')\n            ->addSelect('m')\n            ->addSelect('mi')\n            ->addSelect('d')\n            ->addSelect('i')\n            ->addSelect('b')\n            ->from(Entry::class, 'e')\n            ->join('e.user', 'u')\n            ->join('e.magazine', 'm')\n            ->join('e.domain', 'd')\n            ->leftJoin('u.avatar', 'ua')\n            ->leftJoin('m.icon', 'mi')\n            ->leftJoin('e.image', 'i')\n            ->leftJoin('e.badges', 'b')\n            ->where('e IN (?1)')\n            ->setParameter(1, $entries)\n            ->getQuery()\n            ->getResult();\n\n        /* we don't need to hydrate all the votes and favourites. We only use the count saved in the entry entity\n        if ($this->security->getUser()) {\n            $this->_em->createQueryBuilder()\n                ->select('PARTIAL e.{id}')\n                ->addSelect('ev')\n                ->addSelect('ef')\n                ->from(Entry::class, 'e')\n                ->leftJoin('e.favourites', 'ef')\n                ->leftJoin('e.votes', 'ev')\n                ->where('e IN (?1)')\n                ->setParameter(1, $entries)\n                ->getQuery()\n                ->getResult();\n        }\n        */\n    }\n\n    public function countEntriesByMagazine(Magazine $magazine): int\n    {\n        return \\intval(\n            $this->createQueryBuilder('e')\n                ->select('count(e.id)')\n                ->where('e.magazine = :magazine')\n                ->andWhere('e.visibility = :visibility')\n                ->setParameter('magazine', $magazine)\n                ->setParameter('visibility', VisibilityInterface::VISIBILITY_VISIBLE)\n                ->getQuery()\n                ->getSingleScalarResult()\n        );\n    }\n\n    public function countEntryCommentsByMagazine(Magazine $magazine): int\n    {\n        return \\intval(\n            $this->createQueryBuilder('e')\n                ->select('sum(e.commentCount)')\n                ->where('e.magazine = :magazine')\n                ->setParameter('magazine', $magazine)\n                ->getQuery()\n                ->getSingleScalarResult()\n        );\n    }\n\n    public function findToDelete(User $user, int $limit): array\n    {\n        return $this->createQueryBuilder('e')\n            ->where('e.visibility != :visibility')\n            ->andWhere('e.user = :user')\n            ->setParameter('visibility', VisibilityInterface::VISIBILITY_SOFT_DELETED)\n            ->setParameter('user', $user)\n            ->orderBy('e.id', 'DESC')\n            ->setMaxResults($limit)\n            ->getQuery()\n            ->getResult();\n    }\n\n    public function findRelatedByTag(string $tag, ?int $limit = 1, ?User $user = null): array\n    {\n        $qb = $this->createQueryBuilder('e');\n\n        $qb->andWhere('e.visibility = :visibility')\n            ->andWhere('m.visibility = :visibility')\n            ->andWhere('u.visibility = :visibility')\n            ->andWhere('u.isDeleted = false')\n            ->andWhere('u.apDiscoverable = true')\n            ->andWhere('m.isAdult = false')\n            ->andWhere('e.isAdult = false')\n            ->andWhere('h.tag = :tag')\n            ->join('e.magazine', 'm')\n            ->join('e.user', 'u')\n            ->join('e.hashtags', 'hl')\n            ->join('hl.hashtag', 'h')\n            ->orderBy('e.createdAt', 'DESC')\n            ->setParameter('visibility', VisibilityInterface::VISIBILITY_VISIBLE)\n            ->setParameter('tag', $tag)\n            ->setMaxResults($limit);\n\n        if (null !== $user) {\n            $qb->andWhere($qb->expr()->not($qb->expr()->exists($this->sqlHelpers->getBlockedMagazinesDql($user))))\n                ->andWhere($qb->expr()->not($qb->expr()->exists($this->sqlHelpers->getBlockedUsersDql($user))));\n            $qb->setParameter('user', $user);\n        }\n\n        return $qb->getQuery()\n            ->getResult();\n    }\n\n    public function findRelatedByMagazine(string $name, ?int $limit = 1, ?User $user = null): array\n    {\n        $qb = $this->createQueryBuilder('e');\n\n        $qb->where('m.name LIKE :name OR m.title LIKE :title')\n            ->andWhere('e.visibility = :visibility')\n            ->andWhere('m.visibility = :visibility')\n            ->andWhere('u.visibility = :visibility')\n            ->andWhere('u.isDeleted = false')\n            ->andWhere('u.apDiscoverable = true')\n            ->andWhere('m.isAdult = false')\n            ->andWhere('e.isAdult = false')\n            ->join('e.magazine', 'm')\n            ->join('e.user', 'u')\n            ->orderBy('e.createdAt', 'DESC')\n            ->setParameter('name', \"%{$name}%\")\n            ->setParameter('title', \"%{$name}%\")\n            ->setParameter('visibility', VisibilityInterface::VISIBILITY_VISIBLE)\n            ->setMaxResults($limit);\n\n        if (null !== $user) {\n            $qb->andWhere($qb->expr()->not($qb->expr()->exists($this->sqlHelpers->getBlockedMagazinesDql($user))))\n                ->andWhere($qb->expr()->not($qb->expr()->exists($this->sqlHelpers->getBlockedUsersDql($user))));\n            $qb->setParameter('user', $user);\n        }\n\n        return $qb->getQuery()\n            ->getResult();\n    }\n\n    public function findLast(int $limit, ?User $user = null): array\n    {\n        $qb = $this->createQueryBuilder('e');\n\n        $qb = $qb->where('e.isAdult = false')\n            ->andWhere('e.visibility = :visibility')\n            ->andWhere('m.visibility = :visibility')\n            ->andWhere('m.apDiscoverable = true')\n            ->andWhere('u.visibility = :visibility')\n            ->andWhere('u.apDiscoverable = true')\n            ->andWhere('u.isDeleted = false')\n            ->andWhere('m.isAdult = false');\n        if ($this->settingsManager->get('MBIN_SIDEBAR_SECTIONS_RANDOM_LOCAL_ONLY')) {\n            $qb = $qb->andWhere('m.apId IS NULL');\n        }\n\n        if (null !== $user) {\n            $magazineBlocks = $this->sqlHelpers->getCachedUserMagazineBlocks($user);\n            if (\\sizeof($magazineBlocks) > 0) {\n                $qb->andWhere($qb->expr()->not($qb->expr()->in('m.id', $magazineBlocks)));\n            }\n            $userBlocks = $this->sqlHelpers->getCachedUserBlocks($user);\n            if (\\sizeof($userBlocks) > 0) {\n                $qb->andWhere($qb->expr()->not($qb->expr()->in('u.id', $userBlocks)));\n            }\n        }\n\n        return $qb->join('e.magazine', 'm')\n            ->join('e.user', 'u')\n            ->orderBy('e.createdAt', 'DESC')\n            ->setParameter('visibility', VisibilityInterface::VISIBILITY_VISIBLE)\n            ->setMaxResults($limit)\n            ->getQuery()\n            ->getResult();\n    }\n\n    /**\n     * @return Entry[]\n     */\n    public function findPinned(Magazine $magazine): array\n    {\n        return $this->createQueryBuilder('e')\n            ->where('e.magazine = :m')\n            ->andWhere('e.sticky = true')\n            ->andWhere('e.visibility = :visibility')\n            ->setParameter('m', $magazine)\n            ->setParameter('visibility', VisibilityInterface::VISIBILITY_VISIBLE)\n            ->getQuery()\n            ->getResult()\n        ;\n    }\n\n    private function countAll(EntryPageView|Criteria $criteria): int\n    {\n        return $this->cache->get(\n            'entries_count_'.$criteria->magazine?->name,\n            function (ItemInterface $item) use ($criteria): int {\n                $item->expiresAfter(60);\n\n                if (!$criteria->magazine) {\n                    $query = $this->getEntityManager()->createQuery(\n                        'SELECT COUNT(p.id) FROM App\\Entity\\Entry p WHERE p.visibility = :visibility'\n                    )\n                        ->setParameter('visibility', 'visible');\n                } else {\n                    $query = $this->getEntityManager()->createQuery(\n                        'SELECT COUNT(p.id) FROM App\\Entity\\Entry p WHERE p.visibility = :visibility AND p.magazine = :magazine'\n                    )\n                        ->setParameter('visibility', 'visible')\n                        ->setParameter('magazine', $criteria->magazine);\n                }\n\n                try {\n                    return $query->getSingleScalarResult();\n                } catch (NoResultException $e) {\n                    return 0;\n                }\n            }\n        );\n    }\n\n    public function findCross(Entry $entry): array\n    {\n        if (\\strlen($entry->title) <= 10 && !$entry->url) {\n            return [];\n        }\n\n        $qb = $this->createQueryBuilder('e');\n\n        if ($entry->url) {\n            $qb->where('e.url = :url')\n                ->setParameter('url', $entry->url);\n        } else {\n            $qb->where('e.title = :title')\n                ->setParameter('title', $entry->title);\n        }\n\n        if ($entry->image) {\n            $qb->leftJoin('e.image', 'i')\n                ->andWhere('i = :img')\n                ->setParameter('img', $entry->image);\n        }\n\n        $qb->andWhere('e.id != :id')\n            ->andWhere('m.visibility = :visibility')\n            ->andWhere('e.visibility = :visibility')\n            ->andWhere('u.isDeleted = false')\n            ->innerJoin('e.user', 'u')\n            ->innerJoin('e.magazine', 'm')\n            ->setParameter('id', $entry->getId())\n            ->setParameter('visibility', VisibilityInterface::VISIBILITY_VISIBLE)\n            ->orderBy('e.createdAt', 'DESC')\n            ->setMaxResults(5);\n\n        return $qb->getQuery()->getResult();\n    }\n}\n"
  },
  {
    "path": "src/Repository/FavouriteRepository.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Repository;\n\nuse App\\Entity\\Contracts\\FavouriteInterface;\nuse App\\Entity\\Entry;\nuse App\\Entity\\EntryComment;\nuse App\\Entity\\EntryCommentFavourite;\nuse App\\Entity\\EntryFavourite;\nuse App\\Entity\\Favourite;\nuse App\\Entity\\Post;\nuse App\\Entity\\PostComment;\nuse App\\Entity\\PostCommentFavourite;\nuse App\\Entity\\PostFavourite;\nuse App\\Entity\\User;\nuse Doctrine\\Bundle\\DoctrineBundle\\Repository\\ServiceEntityRepository;\nuse Doctrine\\Persistence\\ManagerRegistry;\n\n/**\n * @method Favourite|null find($id, $lockMode = null, $lockVersion = null)\n * @method Favourite|null findOneBy(array $criteria, array $orderBy = null)\n * @method Favourite[]    findAll()\n * @method Favourite[]    findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)\n */\nclass FavouriteRepository extends ServiceEntityRepository\n{\n    public function __construct(ManagerRegistry $registry)\n    {\n        parent::__construct($registry, Favourite::class);\n    }\n\n    public function findBySubject(User $user, FavouriteInterface $subject): ?Favourite\n    {\n        return match (true) {\n            $subject instanceof Entry => $this->findByEntry($user, $subject),\n            $subject instanceof EntryComment => $this->findByEntryComment($user, $subject),\n            $subject instanceof Post => $this->findByPost($user, $subject),\n            $subject instanceof PostComment => $this->findByPostComment($user, $subject),\n            default => throw new \\LogicException(),\n        };\n    }\n\n    private function findByEntry(User $user, Entry $entry): ?EntryFavourite\n    {\n        $dql = 'SELECT f FROM '.EntryFavourite::class.' f WHERE f.entry = :entry AND f.user = :user';\n\n        return $this->getEntityManager()->createQuery($dql)\n            ->setParameter('entry', $entry)\n            ->setParameter('user', $user)\n            ->getOneOrNullResult();\n    }\n\n    private function findByEntryComment(User $user, EntryComment $comment): ?EntryCommentFavourite\n    {\n        $dql = 'SELECT f FROM '.EntryCommentFavourite::class.' f WHERE f.entryComment = :comment AND f.user = :user';\n\n        return $this->getEntityManager()->createQuery($dql)\n            ->setParameter('comment', $comment)\n            ->setParameter('user', $user)\n            ->getOneOrNullResult();\n    }\n\n    private function findByPost(User $user, Post $post): ?PostFavourite\n    {\n        $dql = 'SELECT f FROM '.PostFavourite::class.' f WHERE f.post = :post AND f.user = :user';\n\n        return $this->getEntityManager()->createQuery($dql)\n            ->setParameter('post', $post)\n            ->setParameter('user', $user)\n            ->getOneOrNullResult();\n    }\n\n    private function findByPostComment(User $user, PostComment $comment): ?PostCommentFavourite\n    {\n        $dql = 'SELECT f FROM '.PostCommentFavourite::class.' f WHERE f.postComment = :comment AND f.user = :user';\n\n        return $this->getEntityManager()->createQuery($dql)\n            ->setParameter('comment', $comment)\n            ->setParameter('user', $user)\n            ->getOneOrNullResult();\n    }\n}\n"
  },
  {
    "path": "src/Repository/ImageRepository.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Repository;\n\nuse App\\Entity\\Image;\nuse App\\Event\\ImagePostProcessEvent;\nuse App\\Exception\\ImageDownloadTooLargeException;\nuse App\\Pagination\\NativeQueryAdapter;\nuse App\\Pagination\\Pagerfanta;\nuse App\\Pagination\\QueryAdapter;\nuse App\\Pagination\\Transformation\\ContentPopulationTransformer;\nuse App\\Service\\ImageManagerInterface;\nuse App\\Utils\\ImageOrigin;\nuse Doctrine\\Bundle\\DoctrineBundle\\Repository\\ServiceEntityRepository;\nuse Doctrine\\DBAL\\Exception;\nuse Doctrine\\Persistence\\ManagerRegistry;\nuse kornrunner\\Blurhash\\Blurhash;\nuse Psr\\EventDispatcher\\EventDispatcherInterface;\nuse Psr\\Log\\LoggerInterface;\nuse Symfony\\Component\\HttpFoundation\\File\\UploadedFile;\n\n/**\n * @method Image|null find($id, $lockMode = null, $lockVersion = null)\n * @method Image|null findOneBy(array $criteria, array $orderBy = null)\n * @method Image|null findOneBySha256($sha256)\n * @method Image[]    findAll()\n * @method Image[]    findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)\n */\nclass ImageRepository extends ServiceEntityRepository\n{\n    public function __construct(\n        ManagerRegistry $registry,\n        private readonly ImageManagerInterface $imageManager,\n        private readonly EventDispatcherInterface $dispatcher,\n        private readonly LoggerInterface $logger,\n        private readonly ContentPopulationTransformer $contentPopulationTransformer,\n    ) {\n        parent::__construct($registry, Image::class);\n    }\n\n    /**\n     * Process and store an uploaded image.\n     *\n     * @param $upload UploadedFile file path of uploaded image\n     *\n     * @throws \\RuntimeException if image type can't be identified\n     */\n    public function findOrCreateFromUpload(UploadedFile $upload): ?Image\n    {\n        return $this->findOrCreateFromSource($upload->getPathname(), ImageOrigin::Uploaded);\n    }\n\n    /**\n     * Process and store an image from source path.\n     *\n     * @param $source string file path of the image\n     *\n     * @throws \\RuntimeException              if image type can't be identified\n     * @throws ImageDownloadTooLargeException\n     */\n    public function findOrCreateFromPath(string $source): ?Image\n    {\n        return $this->findOrCreateFromSource($source, ImageOrigin::External);\n    }\n\n    /**\n     * Process and store an image from source file given path.\n     *\n     * @param string      $source file path of the image\n     * @param ImageOrigin $origin where the image comes from\n     *\n     * @throws ImageDownloadTooLargeException\n     */\n    private function findOrCreateFromSource(string $source, ImageOrigin $origin): ?Image\n    {\n        [$filePath, $fileName] = $this->imageManager->getFilePathAndName($source);\n        $sha256 = hash_file('sha256', $source, true);\n\n        if ($image = $this->findOneBySha256($sha256)) {\n            if (file_exists($source)) {\n                unlink($source);\n            }\n\n            $this->logger->debug('found image by Sha256, imageId: {id}', ['id' => $image->getId()]);\n\n            return $image;\n        }\n\n        [$width, $height] = @getimagesize($source);\n        $blurhash = $this->blurhash($source);\n\n        $image = new Image($fileName, $filePath, $sha256, $width, $height, $blurhash);\n\n        if (!$image->width || !$image->height) {\n            // why get size again?\n            [$width, $height] = @getimagesize($source);\n            $image->setDimensions($width, $height);\n        }\n\n        $previousFileSize = filesize($source);\n        $image->originalSize = $previousFileSize;\n        $this->dispatcher->dispatch(new ImagePostProcessEvent($source, $filePath, $origin));\n        $afterProcessFileSize = filesize($source);\n        if ($afterProcessFileSize < $previousFileSize) {\n            $image->isCompressed = true;\n        }\n\n        try {\n            $this->imageManager->store($source, $filePath);\n            $image->localSize = $afterProcessFileSize;\n\n            return $image;\n        } catch (ImageDownloadTooLargeException $e) {\n            if (ImageOrigin::External === $origin) {\n                $this->logger->warning(\n                    'findOrCreateFromSource: failed to store image file, because it is too big. Storing only a reference',\n                    ['origin' => $origin, 'type' => \\gettype($e)],\n                );\n                $image->filePath = null;\n                $image->localSize = 0;\n                $image->sourceTooBig = true;\n\n                return $image;\n            } else {\n                $this->logger->error(\n                    'findOrCreateFromSource: failed to store image file, because it is too big - {msg}',\n                    ['origin' => $origin, 'type' => \\gettype($e), 'msg' => $e->getMessage()],\n                );\n                throw $e;\n            }\n        } catch (\\Exception $e) {\n            $this->logger->error(\n                'findOrCreateFromSource: failed to store image file: '.$e->getMessage(),\n                ['origin' => $origin, 'type' => \\gettype($e)],\n            );\n        } finally {\n            if (file_exists($source)) {\n                unlink($source);\n            }\n        }\n\n        return null;\n    }\n\n    public function blurhash(string $filePath): ?string\n    {\n        $maxWidth = 20;\n\n        $componentsX = 4;\n        $componentsY = 3;\n\n        try {\n            $image = imagecreatefromstring(file_get_contents($filePath));\n            $width = imagesx($image);\n            $height = imagesy($image);\n\n            if ($width > $maxWidth) {\n                // resizing image with ratio exceeds max width would yield image with height < 1 and fail\n                $ratio = $width / $height;\n                $image = imagescale($image, $maxWidth, $componentsY * $ratio < $maxWidth ? -1 : $componentsY);\n                if (!$image) {\n                    throw new \\Exception('Could not scale image');\n                }\n\n                $width = imagesx($image);\n                $height = imagesy($image);\n            }\n\n            $pixels = [];\n            for ($y = 0; $y < $height; ++$y) {\n                $row = [];\n                for ($x = 0; $x < $width; ++$x) {\n                    $index = imagecolorat($image, $x, $y);\n                    $colors = imagecolorsforindex($image, $index);\n\n                    $row[] = [$colors['red'], $colors['green'], $colors['blue']];\n                }\n                $pixels[] = $row;\n            }\n\n            return Blurhash::encode($pixels, $componentsX, $componentsY);\n        } catch (\\Exception $e) {\n            $this->logger->info('Failed to calculate blurhash: '.$e->getMessage());\n\n            return null;\n        }\n    }\n\n    /**\n     * @param int $limit use a high limit, as this query takes a few seconds and the limit does not affect that, so we are using as high a number as we can -> we're limited by memory\n     *\n     * @return Pagerfanta<Image>\n     *\n     * @throws Exception\n     */\n    public function findOldRemoteMediaPaginated(int $olderThanDays, int $limit = 10000): Pagerfanta\n    {\n        // this complicated looking query makes sure to not include avatars, covers, icons or banners\n        $sql = 'SELECT id, MAX(last_active) as last_active, MAX(downloaded_at) as downloaded_at, \\'image\\' as type FROM (\n            SELECT i.id, i.downloaded_at, e.last_active FROM image i\n                INNER JOIN entry e ON i.id = e.image_id\n                LEFT JOIN \"user\" u ON i.id = u.avatar_id\n                LEFT JOIN \"user\" u2 ON i.id = u2.cover_id\n                LEFT JOIN magazine m ON i.id = m.icon_id\n                LEFT JOIN magazine m2 ON i.id = m2.banner_id\n                WHERE u IS NULL AND u2 IS NULL AND m IS NULL AND m2 IS NULL AND i.file_path IS NOT NULL AND i.source_url IS NOT NULL\n            UNION ALL\n            SELECT i.id, i.downloaded_at, ec.last_active FROM image i\n                INNER JOIN entry_comment ec ON i.id = ec.image_id\n                LEFT JOIN \"user\" u ON i.id = u.avatar_id\n                LEFT JOIN \"user\" u2 ON i.id = u2.cover_id\n                LEFT JOIN magazine m ON i.id = m.icon_id\n                LEFT JOIN magazine m2 ON i.id = m2.banner_id\n                WHERE u IS NULL AND u2 IS NULL AND m IS NULL AND m2 IS NULL AND i.file_path IS NOT NULL AND i.source_url IS NOT NULL\n            UNION ALL\n            SELECT i.id, i.downloaded_at, p.last_active FROM image i\n                INNER JOIN post p ON i.id = p.image_id\n                LEFT JOIN \"user\" u ON i.id = u.avatar_id\n                LEFT JOIN \"user\" u2 ON i.id = u2.cover_id\n                LEFT JOIN magazine m ON i.id = m.icon_id\n                LEFT JOIN magazine m2 ON i.id = m2.banner_id\n                WHERE u IS NULL AND u2 IS NULL AND m IS NULL AND m2 IS NULL AND i.file_path IS NOT NULL AND i.source_url IS NOT NULL\n            UNION ALL\n            SELECT i.id, i.downloaded_at, pc.last_active FROM image i\n                INNER JOIN post_comment pc ON i.id = pc.image_id\n                LEFT JOIN \"user\" u ON i.id = u.avatar_id\n                LEFT JOIN \"user\" u2 ON i.id = u2.cover_id\n                LEFT JOIN magazine m ON i.id = m.icon_id\n                LEFT JOIN magazine m2 ON i.id = m2.banner_id\n                WHERE u IS NULL AND u2 IS NULL AND m IS NULL AND m2 IS NULL AND i.file_path IS NOT NULL AND i.source_url IS NOT NULL\n        ) images WHERE last_active < :date AND (downloaded_at < :date OR downloaded_at IS NULL) GROUP BY id';\n\n        $adapter = new NativeQueryAdapter($this->getEntityManager()->getConnection(), $sql, ['date' => new \\DateTimeImmutable(\"now - $olderThanDays days\")], transformer: $this->contentPopulationTransformer);\n        $fanta = new Pagerfanta($adapter);\n        $fanta->setCurrentPage(1);\n        $fanta->setMaxPerPage($limit);\n\n        return $fanta;\n    }\n\n    /**\n     * @return Pagerfanta<Image>\n     */\n    public function findSavedImagesPaginated(int $pageSize): Pagerfanta\n    {\n        $query = $this->createQueryBuilder('i')\n            ->andWhere('i.filePath IS NOT NULL')\n            ->orderBy('i.filePath');\n\n        $adapter = new QueryAdapter($query);\n        $fanta = new Pagerfanta($adapter);\n        $fanta->setMaxPerPage($pageSize);\n        $fanta->setCurrentPage(1);\n\n        return $fanta;\n    }\n\n    public function redownloadImage(Image $image): void\n    {\n        if ($image->filePath || !$image->sourceUrl || $image->sourceTooBig) {\n            return;\n        }\n\n        $tempFilePath = $this->imageManager->download($image->sourceUrl);\n        if (null === $tempFilePath) {\n            return;\n        }\n\n        [$filePath, $fileName] = $this->imageManager->getFilePathAndName($tempFilePath);\n\n        $previousFileSize = filesize($tempFilePath);\n        $image->originalSize = $previousFileSize;\n        $this->dispatcher->dispatch(new ImagePostProcessEvent($tempFilePath, $filePath, ImageOrigin::External));\n        $afterProcessFileSize = filesize($tempFilePath);\n        if ($afterProcessFileSize < $previousFileSize) {\n            $image->isCompressed = true;\n        }\n\n        try {\n            if ($this->imageManager->store($tempFilePath, $filePath)) {\n                $image->filePath = $filePath;\n                $image->localSize = $afterProcessFileSize;\n                $image->downloadedAt = new \\DateTimeImmutable('now');\n            }\n        } catch (ImageDownloadTooLargeException) {\n            $image->localSize = 0;\n            $image->sourceTooBig = true;\n        } catch (\\Exception) {\n        }\n    }\n\n    /**\n     * @param Image[] $images\n     */\n    public function redownloadImagesIfNecessary(array $images): void\n    {\n        foreach ($images as $image) {\n            $this->logger->debug('Maybe redownloading images {i}', ['i' => implode(', ', array_map(fn (Image $image) => $image->getId(), $images))]);\n            if ($image && null === $image->filePath && !$image->sourceTooBig && $image->sourceUrl) {\n                // there is an image, but not locally, and it was not too big, and we have the source URL -> try redownloading it\n                $this->redownloadImage($image);\n            }\n        }\n        $this->getEntityManager()->flush();\n    }\n}\n"
  },
  {
    "path": "src/Repository/InstanceRepository.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Repository;\n\nuse App\\Entity\\Contracts\\VisibilityInterface;\nuse App\\Entity\\Instance;\nuse App\\Entity\\Magazine;\nuse App\\Entity\\User;\nuse Doctrine\\Bundle\\DoctrineBundle\\Repository\\ServiceEntityRepository;\nuse Doctrine\\Persistence\\ManagerRegistry;\n\n/**\n * @method Instance|null find($id, $lockMode = null, $lockVersion = null)\n * @method Instance|null findOneBy(array $criteria, array $orderBy = null)\n * @method Instance[]    findAll()\n * @method Instance[]    findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)\n */\nclass InstanceRepository extends ServiceEntityRepository\n{\n    public function __construct(\n        ManagerRegistry $registry,\n    ) {\n        parent::__construct($registry, Instance::class);\n    }\n\n    public function getInstanceOfUser(User $user): ?Instance\n    {\n        return $this->findOneBy(['domain' => $user->apDomain]);\n    }\n\n    public function getInstanceOfMagazine(Magazine $magazine): ?Instance\n    {\n        return $this->findOneBy(['domain' => $magazine->apDomain]);\n    }\n\n    /** @return Instance[] */\n    public function getAllowedInstances(bool $useAllowlist): array\n    {\n        $qb = $this->createQueryBuilder('i');\n\n        if ($useAllowlist) {\n            $qb->Where('i.isExplicitlyAllowed = true');\n        } else {\n            $qb->where('i.isBanned = false');\n        }\n\n        return $qb\n            ->orderBy('i.domain')\n            ->getQuery()\n            ->getResult();\n    }\n\n    /** @return Instance[] */\n    public function getBannedInstances(): array\n    {\n        return $this->createQueryBuilder('i')\n            ->where('i.isBanned = true')\n            ->andWhere('i.isExplicitlyAllowed = false')\n            ->orderBy('i.domain')\n            ->getQuery()\n            ->getResult();\n    }\n\n    /** @return Instance[] */\n    public function getDeadInstances(): array\n    {\n        return $this->createQueryBuilder('i')\n            ->where('i.failedDelivers >= :numToDead')\n            ->andWhere('i.lastSuccessfulDeliver < :dateBeforeDead OR i.lastSuccessfulDeliver IS NULL')\n            ->andWhere('i.lastSuccessfulReceive < :dateBeforeDead OR i.lastSuccessfulReceive IS NULL')\n            ->setParameter('numToDead', Instance::NUMBER_OF_FAILED_DELIVERS_UNTIL_DEAD)\n            ->setParameter('dateBeforeDead', Instance::getDateBeforeDead())\n            ->orderBy('i.domain')\n            ->getQuery()\n            ->getResult();\n    }\n\n    public function getOrCreateInstance(string $domain): Instance\n    {\n        $instance = $this->findOneBy(['domain' => $domain]);\n        if (null !== $instance) {\n            return $instance;\n        }\n\n        $instance = new Instance($domain);\n        $this->getEntityManager()->persist($instance);\n        $this->getEntityManager()->flush();\n\n        return $instance;\n    }\n\n    /** @return string[] */\n    public function getBannedInstanceUrls(): array\n    {\n        return array_map(fn (Instance $i) => $i->domain, $this->getBannedInstances());\n    }\n\n    /**\n     * @return array{magazines: int, users: int, theirUserFollows: int, ourUserFollows: int, theirSubscriptions: int, ourSubscriptions: int}\n     *\n     * @throws \\Doctrine\\DBAL\\Exception\n     */\n    public function getInstanceCounts(Instance $instance): array\n    {\n        $sql = \"SELECT 'users' as type, COUNT(u.id) as c FROM instance i\n                INNER JOIN \\\"user\\\" u ON u.ap_domain = i.domain\n                WHERE u.is_banned = false AND u.is_deleted = false AND u.visibility = :visible AND u.marked_for_deletion_at IS NULL AND i.domain = :domain\n            UNION\n            SELECT 'magazines' as type, COUNT(m.id) as c FROM instance i\n                INNER JOIN magazine m ON m.ap_domain = i.domain\n                WHERE m.visibility = :visible AND m.ap_deleted_at IS NULL AND m.marked_for_deletion_at IS NULL AND i.domain = :domain\n            UNION\n            SELECT 'theirUserFollows' as type, COUNT(uf.id) as c FROM instance i\n                INNER JOIN \\\"user\\\" u ON u.ap_domain = i.domain\n                INNER JOIN user_follow uf ON uf.follower_id = u.id\n                INNER JOIN \\\"user\\\" u2 ON uf.following_id = u2.id AND u2.ap_id IS NULL\n                WHERE u.is_banned = false AND u.is_deleted = false AND u.visibility = :visible AND u.marked_for_deletion_at IS NULL AND i.domain = :domain\n                    AND u2.is_banned = false AND u2.is_deleted = false AND u.visibility = :visible AND u2.marked_for_deletion_at IS NULL\n            UNION\n            SELECT 'ourUserFollows' as type, COUNT(uf.id) as c FROM instance i\n                INNER JOIN \\\"user\\\" u ON u.ap_domain = i.domain\n                INNER JOIN user_follow uf ON uf.following_id = u.id\n                INNER JOIN \\\"user\\\" u2 ON uf.follower_id = u2.id AND u2.ap_id IS NULL\n                WHERE u.is_banned = false AND u.is_deleted = false AND u.visibility = :visible AND u.marked_for_deletion_at IS NULL AND i.domain = :domain\n                    AND u2.is_banned = false AND u2.is_deleted = false AND u.visibility = :visible AND u2.marked_for_deletion_at IS NULL\n            UNION\n            SELECT 'theirSubscriptions' as type, COUNT(ms.id) as c FROM instance i\n                INNER JOIN \\\"user\\\" u ON u.ap_domain = i.domain\n                INNER JOIN magazine_subscription ms ON ms.user_id = u.id\n                INNER JOIN magazine m ON m.id = ms.magazine_id AND m.ap_id IS NULL\n                WHERE u.is_banned = false AND u.is_deleted = false AND u.visibility = :visible AND u.marked_for_deletion_at IS NULL AND i.domain = :domain\n                    AND m.visibility = :visible AND m.ap_deleted_at IS NULL AND m.marked_for_deletion_at IS NULL\n            UNION\n            SELECT 'ourSubscriptions' as type, COUNT(ms.id) as c FROM instance i\n                INNER JOIN magazine m ON m.ap_domain = i.domain\n                INNER JOIN magazine_subscription ms ON ms.magazine_id = m.id\n                INNER JOIN \\\"user\\\" u ON u.id = ms.user_id AND u.ap_id IS NULL\n                WHERE u.is_banned = false AND u.is_deleted = false AND u.visibility = :visible AND u.marked_for_deletion_at IS NULL AND i.domain = :domain\n                    AND m.visibility = :visible AND m.ap_deleted_at IS NULL AND m.marked_for_deletion_at IS NULL\n            \";\n        $stmt = $this->getEntityManager()->getConnection()->prepare($sql);\n\n        $stmt->bindValue('visible', VisibilityInterface::VISIBILITY_VISIBLE);\n        $stmt->bindValue('domain', $instance->domain);\n\n        $result = $stmt->executeQuery()\n            ->fetchAllAssociative();\n\n        $mappedResult = [];\n        foreach ($result as $row) {\n            $mappedResult[$row['type']] = $row['c'];\n        }\n\n        return [\n            'magazines' => $mappedResult['magazines'],\n            'users' => $mappedResult['users'],\n            'ourUserFollows' => $mappedResult['ourUserFollows'],\n            'theirUserFollows' => $mappedResult['theirUserFollows'],\n            'ourSubscriptions' => $mappedResult['ourSubscriptions'],\n            'theirSubscriptions' => $mappedResult['theirSubscriptions'],\n        ];\n    }\n\n    /**\n     * @return Instance[]\n     */\n    public function findAllOrdered(): array\n    {\n        $qb = $this->createQueryBuilder('i')\n            ->orderBy('i.domain', 'ASC');\n\n        return $qb->getQuery()->getResult();\n    }\n}\n"
  },
  {
    "path": "src/Repository/MagazineBanRepository.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Repository;\n\nuse App\\Entity\\MagazineBan;\nuse Doctrine\\Bundle\\DoctrineBundle\\Repository\\ServiceEntityRepository;\nuse Doctrine\\Persistence\\ManagerRegistry;\n\n/**\n * @method MagazineBan|null find($id, $lockMode = null, $lockVersion = null)\n * @method MagazineBan|null findOneBy(array $criteria, array $orderBy = null)\n * @method MagazineBan[]    findAll()\n * @method MagazineBan[]    findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)\n */\nclass MagazineBanRepository extends ServiceEntityRepository\n{\n    public function __construct(ManagerRegistry $registry)\n    {\n        parent::__construct($registry, MagazineBan::class);\n    }\n}\n"
  },
  {
    "path": "src/Repository/MagazineBlockRepository.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Repository;\n\nuse App\\Entity\\MagazineBlock;\nuse App\\Entity\\Settings;\nuse App\\Entity\\User;\nuse Doctrine\\Bundle\\DoctrineBundle\\Repository\\ServiceEntityRepository;\nuse Doctrine\\Persistence\\ManagerRegistry;\n\n/**\n * @extends ServiceEntityRepository<Settings>\n *\n * @method MagazineBlock|null find($id, $lockMode = null, $lockVersion = null)\n * @method MagazineBlock|null findOneBy(array $criteria, array $orderBy = null)\n * @method MagazineBlock[]    findAll()\n * @method MagazineBlock[]    findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)\n */\nclass MagazineBlockRepository extends ServiceEntityRepository\n{\n    public function __construct(ManagerRegistry $registry)\n    {\n        parent::__construct($registry, MagazineBlock::class);\n    }\n\n    public function findMagazineBlocksIds(User $user): array\n    {\n        return array_column(\n            $this->createQueryBuilder('mb')\n                ->select('mbm.id')\n                ->join('mb.magazine', 'mbm')\n                ->where('mb.user = :user')\n                ->setParameter('user', $user)\n                ->getQuery()\n                ->getResult(),\n            'id'\n        );\n    }\n}\n"
  },
  {
    "path": "src/Repository/MagazineLogRepository.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Repository;\n\nuse App\\Entity\\Entry;\nuse App\\Entity\\EntryComment;\nuse App\\Entity\\Magazine;\nuse App\\Entity\\MagazineLog;\nuse App\\Entity\\Post;\nuse App\\Entity\\PostComment;\nuse Doctrine\\Bundle\\DoctrineBundle\\Repository\\ServiceEntityRepository;\nuse Doctrine\\Persistence\\ManagerRegistry;\nuse Pagerfanta\\Doctrine\\ORM\\QueryAdapter;\nuse Pagerfanta\\Exception\\NotValidCurrentPageException;\nuse Pagerfanta\\Pagerfanta;\nuse Pagerfanta\\PagerfantaInterface;\nuse Symfony\\Component\\HttpKernel\\Exception\\NotFoundHttpException;\n\n/**\n * @method MagazineLog|null find($id, $lockMode = null, $lockVersion = null)\n * @method MagazineLog|null findOneBy(array $criteria, array $orderBy = null)\n * @method MagazineLog[]    findAll()\n * @method MagazineLog[]    findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)\n */\nclass MagazineLogRepository extends ServiceEntityRepository\n{\n    public const PER_PAGE = 25;\n\n    public function __construct(ManagerRegistry $registry)\n    {\n        parent::__construct($registry, MagazineLog::class);\n    }\n\n    /**\n     * @param string[]|null $types modlog types\n     */\n    public function findByCustom(int $page, int $perPage = self::PER_PAGE, ?array $types = null, ?Magazine $magazine = null): PagerfantaInterface\n    {\n        $qb = $this->createQueryBuilder('ml');\n\n        if (null !== $types && \\sizeof($types) > 0) {\n            $wheres = array_map(fn ($type) => 'ml INSTANCE OF '.MagazineLog::DISCRIMINATOR_MAP[$type], $types);\n            $qb = $qb->where(implode(' OR ', $wheres));\n            if (null !== $magazine) {\n                $qb = $qb->andWhere('ml.magazine = :magazine')\n                    ->setParameter('magazine', $magazine);\n            }\n        } elseif (null !== $magazine) {\n            $qb = $qb->where('ml.magazine = :magazine')\n                ->setParameter('magazine', $magazine);\n        }\n\n        $qb->orderBy('ml.createdAt', 'DESC');\n\n        $pager = new Pagerfanta(new QueryAdapter($qb));\n        try {\n            $pager->setMaxPerPage($perPage);\n            $pager->setCurrentPage($page);\n        } catch (NotValidCurrentPageException $e) {\n            throw new NotFoundHttpException();\n        }\n\n        return $pager;\n    }\n\n    public function removeEntryLogs(Entry $entry): void\n    {\n        $conn = $this->getEntityManager()->getConnection();\n        $sql = 'DELETE FROM magazine_log AS m WHERE m.entry_id = :entryId';\n\n        $stmt = $conn->prepare($sql);\n        $stmt->bindValue('entryId', $entry->getId());\n\n        $stmt->executeQuery();\n    }\n\n    public function removeEntryCommentLogs(EntryComment $comment): void\n    {\n        $conn = $this->getEntityManager()->getConnection();\n        $sql = 'DELETE FROM magazine_log AS m WHERE m.entry_comment_id = :commentId';\n\n        $stmt = $conn->prepare($sql);\n        $stmt->bindValue('commentId', $comment->getId());\n\n        $stmt->executeQuery();\n    }\n\n    public function removePostLogs(Post $post): void\n    {\n        $conn = $this->getEntityManager()->getConnection();\n        $sql = 'DELETE FROM magazine_log AS m WHERE m.post_id = :postId';\n\n        $stmt = $conn->prepare($sql);\n        $stmt->bindValue('postId', $post->getId());\n\n        $stmt->executeQuery();\n    }\n\n    public function removePostCommentLogs(PostComment $comment): void\n    {\n        $conn = $this->getEntityManager()->getConnection();\n        $sql = 'DELETE FROM magazine_log AS m WHERE m.post_comment_id = :commentId';\n\n        $stmt = $conn->prepare($sql);\n        $stmt->bindValue('commentId', $comment->getId());\n\n        $stmt->executeQuery();\n    }\n}\n"
  },
  {
    "path": "src/Repository/MagazineOwnershipRequestRepository.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Repository;\n\nuse App\\Entity\\MagazineOwnershipRequest;\nuse Doctrine\\Bundle\\DoctrineBundle\\Repository\\ServiceEntityRepository;\nuse Doctrine\\Persistence\\ManagerRegistry;\nuse Pagerfanta\\Doctrine\\ORM\\QueryAdapter;\nuse Pagerfanta\\Exception\\NotValidCurrentPageException;\nuse Pagerfanta\\Pagerfanta;\nuse Pagerfanta\\PagerfantaInterface;\nuse Symfony\\Component\\HttpKernel\\Exception\\NotFoundHttpException;\n\n/**\n * @method MagazineOwnershipRequest|null find($id, $lockMode = null, $lockVersion = null)\n * @method MagazineOwnershipRequest|null findOneBy(array $criteria, array $orderBy = null)\n * @method MagazineOwnershipRequest|null findOneByName(string $name)\n * @method MagazineOwnershipRequest[]    findAll()\n * @method MagazineOwnershipRequest[]    findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)\n */\nclass MagazineOwnershipRequestRepository extends ServiceEntityRepository\n{\n    public const PER_PAGE = 25;\n\n    public function __construct(ManagerRegistry $registry)\n    {\n        parent::__construct($registry, MagazineOwnershipRequest::class);\n    }\n\n    public function findAllPaginated(?int $page): PagerfantaInterface\n    {\n        $qb = $this->createQueryBuilder('r')\n            ->orderBy('r.createdAt', 'ASC');\n\n        $pagerfanta = new Pagerfanta(\n            new QueryAdapter(\n                $qb\n            )\n        );\n\n        try {\n            $pagerfanta->setMaxPerPage($criteria->perPage ?? self::PER_PAGE);\n            $pagerfanta->setCurrentPage($page);\n        } catch (NotValidCurrentPageException $e) {\n            throw new NotFoundHttpException();\n        }\n\n        return $pagerfanta;\n    }\n}\n"
  },
  {
    "path": "src/Repository/MagazineRepository.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Repository;\n\nuse App\\Entity\\Contracts\\VisibilityInterface;\nuse App\\Entity\\Magazine;\nuse App\\Entity\\MagazineSubscription;\nuse App\\Entity\\Moderator;\nuse App\\Entity\\Report;\nuse App\\Entity\\User;\nuse App\\PageView\\MagazinePageView;\nuse App\\Pagination\\NativeQueryAdapter;\nuse App\\Pagination\\Transformation\\ContentPopulationTransformer;\nuse App\\Service\\SettingsManager;\nuse App\\Utils\\SqlHelpers;\nuse App\\Utils\\SubscriptionSort;\nuse Doctrine\\Bundle\\DoctrineBundle\\Repository\\ServiceEntityRepository;\nuse Doctrine\\Common\\Collections\\Collection;\nuse Doctrine\\Common\\Collections\\Criteria;\nuse Doctrine\\Persistence\\ManagerRegistry;\nuse Pagerfanta\\Doctrine\\Collections\\CollectionAdapter;\nuse Pagerfanta\\Doctrine\\Collections\\SelectableAdapter;\nuse Pagerfanta\\Doctrine\\ORM\\QueryAdapter;\nuse Pagerfanta\\Exception\\NotValidCurrentPageException;\nuse Pagerfanta\\Pagerfanta;\nuse Pagerfanta\\PagerfantaInterface;\nuse Symfony\\Component\\HttpKernel\\Exception\\NotFoundHttpException;\nuse Symfony\\Contracts\\Cache\\CacheInterface;\n\n/**\n * @method Magazine|null find($id, $lockMode = null, $lockVersion = null)\n * @method Magazine|null findOneBy(array $criteria, array $orderBy = null)\n * @method Magazine[]    findAll()\n * @method Magazine[]    findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)\n */\nclass MagazineRepository extends ServiceEntityRepository\n{\n    public const PER_PAGE = 48;\n\n    public const SORT_HOT = 'hot';\n    public const SORT_ACTIVE = 'active';\n    public const SORT_NEWEST = 'newest';\n    public const SORT_OPTIONS = [\n        self::SORT_ACTIVE,\n        self::SORT_HOT,\n        self::SORT_NEWEST,\n    ];\n\n    public function __construct(\n        ManagerRegistry $registry,\n        private readonly SettingsManager $settingsManager,\n        private readonly SqlHelpers $sqlHelpers,\n        private readonly ContentPopulationTransformer $contentPopulationTransformer,\n        private readonly CacheInterface $cache,\n    ) {\n        parent::__construct($registry, Magazine::class);\n    }\n\n    public function save(Magazine $entity, bool $flush = false): void\n    {\n        $this->getEntityManager()->persist($entity);\n\n        if ($flush) {\n            $this->getEntityManager()->flush();\n        }\n    }\n\n    public function findOneByName(?string $name): ?Magazine\n    {\n        return $this->createQueryBuilder('m')\n            ->andWhere('LOWER(m.name) = LOWER(:name)')\n            ->setParameter('name', $name)\n            ->getQuery()\n            ->getOneOrNullResult();\n    }\n\n    public function findPaginated(MagazinePageView $criteria): PagerfantaInterface\n    {\n        $qb = $this->createQueryBuilder('m')\n            ->andWhere('m.visibility = :visibility')\n            ->setParameter('visibility', VisibilityInterface::VISIBILITY_VISIBLE);\n\n        if ($criteria->query) {\n            $restrictions = 'LOWER(m.name) LIKE LOWER(:q) OR LOWER(m.title) LIKE LOWER(:q)';\n\n            if ($criteria->fields === $criteria::FIELDS_NAMES_DESCRIPTIONS) {\n                $restrictions .= ' OR LOWER(m.description) LIKE LOWER(:q)';\n            }\n\n            $qb->andWhere($restrictions)\n                ->setParameter('q', '%'.trim($criteria->query).'%');\n        }\n\n        if ($criteria->showOnlyLocalMagazines()) {\n            $qb->andWhere('m.apId IS NULL');\n        }\n\n        if ($criteria->abandoned) {\n            if (!$criteria->showOnlyLocalMagazines()) {\n                throw new \\InvalidArgumentException('filtering for abandoned magazines only works for local');\n            }\n\n            $qb->andWhere('mod.magazine IS NOT NULL')\n                ->andWhere('mod.isOwner = true')\n                ->andWhere('modUser.lastActive < :abandonedThreshold')\n                ->join('m.moderators', 'mod')\n                ->join('mod.user', 'modUser')\n                ->setParameter('abandonedThreshold', new \\DateTime('-1 month'));\n        }\n\n        match ($criteria->adult) {\n            $criteria::ADULT_HIDE => $qb->andWhere('m.isAdult = false'),\n            $criteria::ADULT_ONLY => $qb->andWhere('m.isAdult = true'),\n            $criteria::ADULT_SHOW => true,\n        };\n\n        match ($criteria->sortOption) {\n            default => $qb->addOrderBy('m.subscriptionsCount', 'DESC'),\n            $criteria::SORT_ACTIVE => $qb->addOrderBy('m.lastActive', 'DESC'),\n            $criteria::SORT_NEW => $qb->addOrderBy('m.createdAt', 'DESC'),\n            $criteria::SORT_THREADS => $qb->addOrderBy('m.entryCount', 'DESC'),\n            $criteria::SORT_COMMENTS => $qb->addOrderBy('m.entryCommentCount', 'DESC'),\n            $criteria::SORT_POSTS => $qb->addOrderBy('m.postCount + m.postCommentCount', 'DESC'),\n            $criteria::SORT_OWNER_LAST_ACTIVE => $criteria->abandoned ?\n                $qb->orderBy('modUser.lastActive', 'ASC')\n                : throw new \\InvalidArgumentException($criteria::SORT_OWNER_LAST_ACTIVE.' requires abandoned filter'),\n        };\n\n        $pagerfanta = new Pagerfanta(new QueryAdapter($qb));\n\n        try {\n            $pagerfanta->setMaxPerPage($criteria->perPage ?? self::PER_PAGE);\n            $pagerfanta->setCurrentPage($criteria->page);\n        } catch (NotValidCurrentPageException $e) {\n            throw new NotFoundHttpException();\n        }\n\n        return $pagerfanta;\n    }\n\n    public function findSubscribedMagazines(int $page, User $user, int $perPage = self::PER_PAGE): PagerfantaInterface\n    {\n        $pagerfanta = new Pagerfanta(\n            new CollectionAdapter(\n                $user->subscriptions\n            )\n        );\n\n        try {\n            $pagerfanta->setMaxPerPage($perPage);\n            $pagerfanta->setCurrentPage($page);\n        } catch (NotValidCurrentPageException $e) {\n            throw new NotFoundHttpException();\n        }\n\n        return $pagerfanta;\n    }\n\n    /**\n     * @return Magazine[]\n     */\n    public function findMagazineSubscriptionsOfUser(User $user, SubscriptionSort $sort, int $max): array\n    {\n        $query = $this->createQueryBuilder('m')\n            ->join('m.subscriptions', 'ms')\n            ->join('ms.user', 'u')\n            ->andWhere('u.id = :userId')\n            ->setParameter('userId', $user->getId());\n\n        if (SubscriptionSort::LastActive === $sort) {\n            $query = $query\n                ->orderBy('m.lastActive', 'DESC')\n                ->andWhere('m.lastActive IS NOT NULL');\n        } elseif (SubscriptionSort::Alphabetically === $sort) {\n            $query = $query->orderBy('m.name');\n        }\n\n        $query = $query->getQuery();\n        $query->setMaxResults($max);\n\n        $goodResults = $query->getResult();\n        $remaining = $max - \\sizeof($goodResults);\n        if ($remaining > 0) {\n            $query = $this->createQueryBuilder('m')\n                ->join('m.subscriptions', 'ms')\n                ->join('ms.user', 'u')\n                ->andWhere('u.id = :userId')\n                ->andWhere('m.lastActive IS NULL')\n                ->setParameter('userId', $user->getId())\n                ->setMaxResults($remaining);\n            $additionalResults = $query->getQuery()->getResult();\n            $goodResults = array_merge($goodResults, $additionalResults);\n        }\n\n        return $goodResults;\n    }\n\n    public function findBlockedMagazines(int $page, User $user, int $perPage = self::PER_PAGE): PagerfantaInterface\n    {\n        $pagerfanta = new Pagerfanta(\n            new CollectionAdapter(\n                $user->blockedMagazines\n            )\n        );\n\n        try {\n            $pagerfanta->setMaxPerPage($perPage);\n            $pagerfanta->setCurrentPage($page);\n        } catch (NotValidCurrentPageException $e) {\n            throw new NotFoundHttpException();\n        }\n\n        return $pagerfanta;\n    }\n\n    public function findModerators(\n        Magazine $magazine,\n        ?int $page = 1,\n        int $perPage = self::PER_PAGE,\n    ): PagerfantaInterface {\n        $criteria = Criteria::create()\n            ->orderBy(['isOwner' => 'DESC'])\n            ->orderBy(['createdAt' => 'ASC']);\n\n        $moderators = new Pagerfanta(new SelectableAdapter($magazine->moderators, $criteria));\n        try {\n            $moderators->setMaxPerPage($perPage);\n            $moderators->setCurrentPage($page);\n        } catch (NotValidCurrentPageException $e) {\n            throw new NotFoundHttpException();\n        }\n\n        return $moderators;\n    }\n\n    public function findBans(Magazine $magazine, ?int $page = 1, int $perPage = self::PER_PAGE): PagerfantaInterface\n    {\n        $criteria = Criteria::create()\n            ->andWhere(Criteria::expr()->gt('expiredAt', new \\DateTimeImmutable()))\n            ->orWhere(Criteria::expr()->isNull('expiredAt'))\n            ->orderBy(['createdAt' => 'DESC']);\n\n        $bans = new Pagerfanta(new SelectableAdapter($magazine->bans, $criteria));\n        try {\n            $bans->setMaxPerPage($perPage);\n            $bans->setCurrentPage($page);\n        } catch (NotValidCurrentPageException $e) {\n            throw new NotFoundHttpException();\n        }\n\n        return $bans;\n    }\n\n    public function findReports(\n        Magazine $magazine,\n        ?int $page = 1,\n        int $perPage = self::PER_PAGE,\n        string $status = Report::STATUS_PENDING,\n    ): PagerfantaInterface {\n        $dql = 'SELECT r FROM '.Report::class.' r WHERE r.magazine = :magazine';\n\n        if (Report::STATUS_ANY !== $status) {\n            $dql .= ' AND r.status = :status';\n        }\n\n        $dql .= \" ORDER BY CASE WHEN r.status = 'pending' THEN 1 ELSE 2 END, r.weight DESC, r.createdAt DESC\";\n\n        $query = $this->getEntityManager()->createQuery($dql);\n        $query->setParameter('magazine', $magazine);\n\n        if (Report::STATUS_ANY !== $status) {\n            $query->setParameter('status', $status);\n        }\n\n        $pagerfanta = new Pagerfanta(\n            new QueryAdapter($query)\n        );\n\n        try {\n            $pagerfanta->setMaxPerPage(self::PER_PAGE);\n            $pagerfanta->setCurrentPage($page);\n        } catch (NotValidCurrentPageException $e) {\n            throw new NotFoundHttpException();\n        }\n\n        return $pagerfanta;\n    }\n\n    public function findBadges(Magazine $magazine): Collection\n    {\n        return $magazine->badges;\n    }\n\n    public function findModeratedMagazines(\n        User $user,\n        ?int $page = 1,\n        int $perPage = self::PER_PAGE,\n    ): PagerfantaInterface {\n        $dql =\n            'SELECT m FROM '.Magazine::class.' m WHERE m IN ('.\n            'SELECT IDENTITY(md.magazine) FROM '.Moderator::class.' md WHERE md.user = :user) ORDER BY m.apId DESC, m.lastActive DESC';\n\n        $query = $this->getEntityManager()->createQuery($dql)\n            ->setParameter('user', $user);\n\n        $pagerfanta = new Pagerfanta(\n            new QueryAdapter(\n                $query\n            )\n        );\n\n        try {\n            $pagerfanta->setMaxPerPage($perPage);\n            $pagerfanta->setCurrentPage($page);\n        } catch (NotValidCurrentPageException $e) {\n            throw new NotFoundHttpException();\n        }\n\n        return $pagerfanta;\n    }\n\n    public function findTrashed(Magazine $magazine, int $page = 1, int $perPage = self::PER_PAGE): PagerfantaInterface\n    {\n        $magazineId = $magazine->getId();\n        $sql = '\n            (SELECT id, last_active, magazine_id, \\'entry\\' AS type FROM entry WHERE magazine_id = :magazineId AND visibility = \\'trashed\\')\n            UNION ALL\n            (SELECT id, last_active, magazine_id, \\'entry_comment\\' AS type FROM entry_comment WHERE magazine_id = :magazineId AND visibility = \\'trashed\\')\n            UNION ALL\n            (SELECT id, last_active, magazine_id, \\'post\\' AS type FROM post WHERE magazine_id = :magazineId AND visibility = \\'trashed\\')\n            UNION ALL\n            (SELECT id, last_active, magazine_id, \\'post_comment\\' AS type FROM post_comment WHERE magazine_id = :magazineId AND visibility = \\'trashed\\')\n            ORDER BY last_active DESC';\n\n        $parameters = [\n            'magazineId' => $magazineId,\n        ];\n        $adapter = new NativeQueryAdapter($this->getEntityManager()->getConnection(), $sql, $parameters, transformer: $this->contentPopulationTransformer, cache: $this->cache);\n\n        $pagerfanta = new Pagerfanta($adapter);\n\n        try {\n            $pagerfanta->setMaxPerPage($perPage);\n            $pagerfanta->setCurrentPage($page);\n        } catch (NotValidCurrentPageException) {\n            throw new NotFoundHttpException();\n        }\n\n        return $pagerfanta;\n    }\n\n    public function findAudience(Magazine $magazine): array\n    {\n        if (null !== $magazine->apId) {\n            return [$magazine->apInboxUrl];\n        }\n\n        $dql =\n            'SELECT COUNT(u.id), u.apInboxUrl FROM '.User::class.' u WHERE u IN ('.\n            'SELECT IDENTITY(ms.user) FROM '.MagazineSubscription::class.' ms WHERE ms.magazine = :magazine)'.\n            'AND u.apId IS NOT NULL AND u.isBanned = false AND u.apTimeoutAt IS NULL '.\n            'GROUP BY u.apInboxUrl';\n\n        $res = $this->getEntityManager()->createQuery($dql)\n            ->setParameter('magazine', $magazine)\n            ->getResult();\n\n        return array_map(fn ($item) => $item['apInboxUrl'], $res);\n    }\n\n    public function findWithoutKeys(): array\n    {\n        return $this->createQueryBuilder('m')\n            ->where('m.privateKey IS NULL')\n            ->andWhere('m.apId IS NULL')\n            ->getQuery()\n            ->getResult();\n    }\n\n    public function findByTag($tag): ?Magazine\n    {\n        return $this->createQueryBuilder('m')\n            ->andWhere('m.tags IS NOT NULL AND JSONB_CONTAINS(m.tags, :tag) = true')\n            ->orderBy('m.lastActive', 'DESC')\n            ->setMaxResults(1)\n            ->setParameter('tag', \"\\\"$tag\\\"\")\n            ->getQuery()\n            ->getOneOrNullResult();\n    }\n\n    public function findByActivity()\n    {\n        return $this->createQueryBuilder('m')\n            ->andWhere('m.postCount > 0')\n            ->orWhere('m.entryCount > 0')\n            ->andWhere('m.lastActive >= :date')\n            ->andWhere('m.isAdult = false')\n            ->andWhere('m.visibility = :visibility')\n            ->setMaxResults(50)\n            ->setParameter('date', new \\DateTime('-5 months'))\n            ->setParameter('visibility', VisibilityInterface::VISIBILITY_VISIBLE)\n            ->orderBy('m.entryCount', 'DESC')\n            ->getQuery()\n            ->getResult();\n    }\n\n    public function findByApGroupProfileId(array $apIds): ?Magazine\n    {\n        return $this->createQueryBuilder('m')\n            ->where('m.apProfileId IN (?1)')\n            ->setParameter(1, $apIds)\n            ->setMaxResults(1)\n            ->getQuery()\n            ->getOneOrNullResult();\n    }\n\n    public function search(string $magazine, int $page, int $perPage = self::PER_PAGE): Pagerfanta\n    {\n        $qb = $this->createQueryBuilder('m')\n            ->andWhere('m.visibility = :visibility')\n            ->andWhere(\n                'LOWER(m.name) LIKE LOWER(:q) OR LOWER(m.title) LIKE LOWER(:q) OR LOWER(m.description) LIKE LOWER(:q)'\n            )\n            ->orderBy('m.apId', 'DESC')\n            ->orderBy('m.subscriptionsCount', 'DESC')\n            ->setParameter('visibility', VisibilityInterface::VISIBILITY_VISIBLE)\n            ->setParameter('q', '%'.$magazine.'%');\n\n        $pagerfanta = new Pagerfanta(\n            new QueryAdapter(\n                $qb\n            )\n        );\n\n        try {\n            $pagerfanta->setMaxPerPage($perPage);\n            $pagerfanta->setCurrentPage($page);\n        } catch (NotValidCurrentPageException $e) {\n            throw new NotFoundHttpException();\n        }\n\n        return $pagerfanta;\n    }\n\n    public function findRandom(?User $user = null): array\n    {\n        $conn = $this->getEntityManager()->getConnection();\n        $whereClauses = [\n            'm.is_adult = false',\n            'm.visibility = :visibility',\n        ];\n        $parameters = [\n            'visibility' => VisibilityInterface::VISIBILITY_VISIBLE,\n        ];\n        if ($this->settingsManager->get('MBIN_SIDEBAR_SECTIONS_RANDOM_LOCAL_ONLY')) {\n            $whereClauses[] = 'm.ap_id IS NULL';\n        }\n        if (null !== $user) {\n            $whereClauses[] = 'm.id NOT IN(:blockedMagazines)';\n            $parameters['blockedMagazines'] = $this->sqlHelpers->getCachedUserMagazineBlocks($user);\n        }\n        $whereString = SqlHelpers::makeWhereString($whereClauses);\n        $sql = SqlHelpers::rewriteArrayParameters($parameters, \"SELECT m.id FROM magazine m $whereString ORDER BY random() LIMIT 5\");\n        $stmt = $conn->prepare($sql['sql']);\n        foreach ($sql['parameters'] as $param => $value) {\n            $stmt->bindValue($param, $value, SqlHelpers::getSqlType($value));\n        }\n        $stmt = $stmt->executeQuery();\n        $ids = $stmt->fetchAllAssociative();\n\n        return $this->createQueryBuilder('m')\n            ->where('m.id IN (:ids)')\n            ->setParameter('ids', $ids)\n            ->getQuery()\n            ->getResult();\n    }\n\n    public function findRelated(string $magazine, ?User $user = null): array\n    {\n        $qb = $this->createQueryBuilder('m')\n            ->where('m.entryCount > 0 OR m.postCount > 0')\n            ->andWhere('m.title LIKE :magazine OR m.description LIKE :magazine OR m.name LIKE :magazine')\n            ->andWhere('m.isAdult = false')\n            ->andWhere('m.visibility = :visibility')\n            ->setParameter('visibility', VisibilityInterface::VISIBILITY_VISIBLE)\n            ->setParameter('magazine', \"%{$magazine}%\")\n            ->setMaxResults(5);\n\n        if (null !== $user) {\n            $qb->andWhere($qb->expr()->not($qb->expr()->exists($this->sqlHelpers->getBlockedMagazinesDql($user))));\n            $qb->setParameter('user', $user);\n        }\n\n        return $qb->getQuery()\n            ->getResult();\n    }\n\n    public function findRemoteForUpdate(): array\n    {\n        return $this->createQueryBuilder('m')\n            ->where('m.apId IS NOT NULL')\n            ->andWhere('m.apDomain IS NULL')\n            ->andWhere('m.apDeletedAt IS NULL')\n            ->andWhere('m.apTimeoutAt IS NULL')\n            ->addOrderBy('m.apFetchedAt', 'ASC')\n            ->setMaxResults(1000)\n            ->getQuery()\n            ->getResult();\n    }\n\n    public function findForDeletionPaginated(int $page): PagerfantaInterface\n    {\n        $query = $this->createQueryBuilder('m')\n            ->where('m.apId IS NULL')\n            ->andWhere('m.visibility = :visibility')\n            ->orderBy('m.markedForDeletionAt', 'ASC')\n            ->setParameter('visibility', VisibilityInterface::VISIBILITY_SOFT_DELETED)\n            ->getQuery();\n\n        $pagerfanta = new Pagerfanta(\n            new QueryAdapter(\n                $query\n            )\n        );\n\n        try {\n            $pagerfanta->setMaxPerPage(self::PER_PAGE);\n            $pagerfanta->setCurrentPage($page);\n        } catch (NotValidCurrentPageException $e) {\n            throw new NotFoundHttpException();\n        }\n\n        return $pagerfanta;\n    }\n\n    public function findAbandoned(int $page = 1): PagerfantaInterface\n    {\n        $query = $this->createQueryBuilder('m')\n            ->where('mod.magazine IS NOT NULL')\n            ->andWhere('mod.isOwner = true')\n            ->andWhere('u.lastActive < :date')\n            ->andWhere('m.apId IS NULL')\n            ->join('m.moderators', 'mod')\n            ->join('mod.user', 'u')\n            ->setParameter('date', new \\DateTime('-1 month'))\n            ->orderBy('u.lastActive', 'ASC')\n            ->getQuery();\n\n        $pagerfanta = new Pagerfanta(\n            new QueryAdapter(\n                $query\n            )\n        );\n\n        try {\n            $pagerfanta->setMaxPerPage(self::PER_PAGE);\n            $pagerfanta->setCurrentPage($page);\n        } catch (NotValidCurrentPageException $e) {\n            throw new NotFoundHttpException();\n        }\n\n        return $pagerfanta;\n    }\n\n    public function getMagazineFromModeratorsUrl($target): ?Magazine\n    {\n        if ($this->settingsManager->isLocalUrl($target)) {\n            $matches = [];\n            if (preg_match_all(\"/\\/m\\/([a-zA-Z0-9\\-_:]+)\\/moderators/\", $target, $matches)) {\n                $magName = $matches[1][0];\n\n                return $this->findOneByName($magName);\n            }\n        } else {\n            return $this->findOneBy(['apAttributedToUrl' => $target]);\n        }\n\n        return null;\n    }\n\n    public function getMagazineFromPinnedUrl($target): ?Magazine\n    {\n        if ($this->settingsManager->isLocalUrl($target)) {\n            $matches = [];\n            if (preg_match_all(\"/\\/m\\/([a-zA-Z0-9\\-_:]+)\\/pinned/\", $target, $matches)) {\n                $magName = $matches[1][0];\n\n                return $this->findOneByName($magName);\n            }\n        } else {\n            return $this->findOneBy(['apFeaturedUrl' => $target]);\n        }\n\n        return null;\n    }\n}\n"
  },
  {
    "path": "src/Repository/MagazineSubscriptionRepository.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Repository;\n\nuse App\\Entity\\Entry;\nuse App\\Entity\\Magazine;\nuse App\\Entity\\MagazineSubscription;\nuse App\\Entity\\Post;\nuse Doctrine\\Bundle\\DoctrineBundle\\Repository\\ServiceEntityRepository;\nuse Doctrine\\Persistence\\ManagerRegistry;\nuse Pagerfanta\\Doctrine\\ORM\\QueryAdapter;\nuse Pagerfanta\\Exception\\NotValidCurrentPageException;\nuse Pagerfanta\\Pagerfanta;\nuse Pagerfanta\\PagerfantaInterface;\nuse Symfony\\Component\\HttpKernel\\Exception\\NotFoundHttpException;\n\n/**\n * @method MagazineSubscription|null find($id, $lockMode = null, $lockVersion = null)\n * @method MagazineSubscription|null findOneBy(array $criteria, array $orderBy = null)\n * @method MagazineSubscription[]    findAll()\n * @method MagazineSubscription[]    findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)\n */\nclass MagazineSubscriptionRepository extends ServiceEntityRepository\n{\n    public const PER_PAGE = 48;\n\n    public function __construct(ManagerRegistry $registry)\n    {\n        parent::__construct($registry, MagazineSubscription::class);\n    }\n\n    /**\n     * @return MagazineSubscription[]\n     */\n    public function findNewEntrySubscribers(Entry $entry): array\n    {\n        return $this->createQueryBuilder('ms')\n            ->addSelect('u')\n            ->join('ms.user', 'u')\n            ->where('u.notifyOnNewEntry = true')\n            ->andWhere('ms.magazine = :magazine')\n            ->andWhere('u != :user')\n            ->andWhere('u.apId IS NULL')\n            ->setParameter('magazine', $entry->magazine)\n            ->setParameter('user', $entry->user)\n            ->getQuery()\n            ->getResult();\n    }\n\n    /**\n     * @return MagazineSubscription[]\n     */\n    public function findNewPostSubscribers(Post $post): array\n    {\n        return $this->createQueryBuilder('ms')\n            ->addSelect('u')\n            ->join('ms.user', 'u')\n            ->where('u.notifyOnNewPost = true')\n            ->andWhere('ms.magazine = :magazine')\n            ->andWhere('u != :user')\n            ->andWhere('u.apId IS NULL')\n            ->setParameter('magazine', $post->magazine)\n            ->setParameter('user', $post->user)\n            ->getQuery()\n            ->getResult();\n    }\n\n    public function findMagazineSubscribers(int $page, Magazine $magazine): PagerfantaInterface\n    {\n        $query = $this->createQueryBuilder('ms')\n            ->addSelect('u')\n            ->join('ms.user', 'u')\n            ->andWhere('ms.magazine = :magazine')\n            ->andWhere('u.apId IS NULL')\n            ->setParameter('magazine', $magazine)\n            ->getQuery();\n\n        $pagerfanta = new Pagerfanta(\n            new QueryAdapter(\n                $query\n            )\n        );\n\n        try {\n            $pagerfanta->setMaxPerPage(self::PER_PAGE);\n            $pagerfanta->setCurrentPage($page);\n        } catch (NotValidCurrentPageException $e) {\n            throw new NotFoundHttpException();\n        }\n\n        return $pagerfanta;\n    }\n}\n"
  },
  {
    "path": "src/Repository/MagazineSubscriptionRequestRepository.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Repository;\n\nuse App\\Entity\\MagazineSubscriptionRequest;\nuse App\\Entity\\Settings;\nuse Doctrine\\Bundle\\DoctrineBundle\\Repository\\ServiceEntityRepository;\nuse Doctrine\\Persistence\\ManagerRegistry;\n\n/**\n * @extends ServiceEntityRepository<Settings>\n *\n * @method MagazineSubscriptionRequest|null find($id, $lockMode = null, $lockVersion = null)\n * @method MagazineSubscriptionRequest|null findOneBy(array $criteria, array $orderBy = null)\n * @method MagazineSubscriptionRequest[]    findAll()\n * @method MagazineSubscriptionRequest[]    findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)\n */\nclass MagazineSubscriptionRequestRepository extends ServiceEntityRepository\n{\n    public function __construct(ManagerRegistry $registry)\n    {\n        parent::__construct($registry, MagazineSubscriptionRequest::class);\n    }\n}\n"
  },
  {
    "path": "src/Repository/MessageRepository.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Repository;\n\nuse App\\Entity\\Message;\nuse App\\PageView\\MessageThreadPageView;\nuse App\\Service\\SettingsManager;\nuse Doctrine\\Bundle\\DoctrineBundle\\Repository\\ServiceEntityRepository;\nuse Doctrine\\Persistence\\ManagerRegistry;\nuse Pagerfanta\\Doctrine\\ORM\\QueryAdapter;\nuse Pagerfanta\\Exception\\NotValidCurrentPageException;\nuse Pagerfanta\\Pagerfanta;\nuse Pagerfanta\\PagerfantaInterface;\nuse Symfony\\Component\\HttpKernel\\Exception\\NotFoundHttpException;\n\n/**\n * @method Message|null find($id, $lockMode = null, $lockVersion = null)\n * @method Message|null findOneBy(array $criteria, array $orderBy = null)\n * @method Message|null findOneByName(string $name)\n * @method Message[]    findAll()\n * @method Message[]    findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)\n */\nclass MessageRepository extends ServiceEntityRepository\n{\n    public const PER_PAGE = 25;\n\n    public function __construct(ManagerRegistry $registry, private readonly SettingsManager $settingsManager)\n    {\n        parent::__construct($registry, Message::class);\n    }\n\n    public function findByCriteria(MessageThreadPageView|Criteria $criteria): PagerfantaInterface\n    {\n        $qb = $this->createQueryBuilder('m')\n            ->where('m.thread = :m_thread_id')\n            ->setParameter('m_thread_id', $criteria->thread->getId());\n\n        switch ($criteria->sortOption) {\n            case Criteria::SORT_OLD:\n                $qb->orderBy('m.createdAt', 'ASC');\n                break;\n            default:\n                $qb->orderBy('m.createdAt', 'DESC');\n        }\n\n        $messages = new Pagerfanta(\n            new QueryAdapter(\n                $qb,\n                false\n            )\n        );\n\n        try {\n            $messages->setMaxPerPage($criteria->perPage ?? self::PER_PAGE);\n            $messages->setCurrentPage($criteria->page);\n        } catch (NotValidCurrentPageException $e) {\n            throw new NotFoundHttpException();\n        }\n\n        return $messages;\n    }\n\n    public function findLastMessageBefore(Message $message): ?Message\n    {\n        $results = $this->createQueryBuilder('m')\n            ->where('m.createdAt < :previous_message')\n            ->andWhere('m.thread = :thread')\n            ->orderBy('m.createdAt', 'DESC')\n            ->setMaxResults(1)\n            ->setParameter('previous_message', $message->createdAt)\n            ->setParameter('thread', $message->thread)\n            ->getQuery()\n            ->getResult();\n\n        if (1 === \\sizeof($results)) {\n            return $results[0];\n        }\n\n        return null;\n    }\n\n    public function findByApId(string $apId): ?Message\n    {\n        if ($this->settingsManager->isLocalUrl($apId)) {\n            $path = parse_url($apId, PHP_URL_PATH);\n            preg_match('/\\/messages\\/([\\w\\-]+)/', $path, $matches);\n            if (2 === \\sizeof($matches)) {\n                $uuid = $matches[1];\n\n                return $this->findOneBy(['uuid' => $uuid]);\n            }\n        } else {\n            return $this->findOneBy(['apId' => $apId]);\n        }\n\n        return null;\n    }\n}\n"
  },
  {
    "path": "src/Repository/MessageThreadRepository.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Repository;\n\nuse App\\Entity\\Message;\nuse App\\Entity\\MessageThread;\nuse App\\Entity\\User;\nuse Doctrine\\Bundle\\DoctrineBundle\\Repository\\ServiceEntityRepository;\nuse Doctrine\\DBAL\\Exception;\nuse Doctrine\\DBAL\\ParameterType;\nuse Doctrine\\Persistence\\ManagerRegistry;\nuse Pagerfanta\\Doctrine\\ORM\\QueryAdapter;\nuse Pagerfanta\\Exception\\NotValidCurrentPageException;\nuse Pagerfanta\\Pagerfanta;\nuse Psr\\Log\\LoggerInterface;\nuse Symfony\\Component\\HttpKernel\\Exception\\NotFoundHttpException;\n\n/**\n * @method MessageThread|null find($id, $lockMode = null, $lockVersion = null)\n * @method MessageThread|null findOneBy(array $criteria, array $orderBy = null)\n * @method MessageThread|null findOneByName(string $name)\n * @method MessageThread[]    findAll()\n * @method MessageThread[]    findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)\n */\nclass MessageThreadRepository extends ServiceEntityRepository\n{\n    public const PER_PAGE = 25;\n\n    public function __construct(ManagerRegistry $registry, private readonly LoggerInterface $logger)\n    {\n        parent::__construct($registry, MessageThread::class);\n    }\n\n    public function findUserMessages(?User $user, int $page, int $perPage = self::PER_PAGE)\n    {\n        $qb = $this->createQueryBuilder('mt');\n        $qb->where(':user MEMBER OF mt.participants')\n            ->andWhere($qb->expr()->exists('SELECT m FROM '.Message::class.' m WHERE m.thread = mt'))\n            ->orderBy('mt.updatedAt', 'DESC')\n            ->setParameter(':user', $user);\n\n        $pager = new Pagerfanta(new QueryAdapter($qb));\n        try {\n            $pager->setMaxPerPage($perPage);\n            $pager->setCurrentPage($page);\n        } catch (NotValidCurrentPageException $e) {\n            throw new NotFoundHttpException();\n        }\n\n        return $pager;\n    }\n\n    /**\n     * @param User[] $participants\n     *\n     * @return MessageThread[] the message threads that contain the participants and no one else, order by their updated date (last message)\n     *\n     * @throws Exception\n     */\n    public function findByParticipants(array $participants): array\n    {\n        $this->logger->debug('looking for thread with participants: {p}', ['p' => array_map(fn (User $u) => $u->username, $participants)]);\n        $whereString = '';\n        $parameters = ['ctn' => [\\sizeof($participants), ParameterType::INTEGER]];\n        $i = 0;\n        foreach ($participants as $participant) {\n            $whereString .= \"AND EXISTS(SELECT * FROM message_thread_participants mtp WHERE mtp.message_thread_id = mt.id AND mtp.user_id = :p$i)\";\n            $parameters[\"p$i\"] = [$participant->getId(), ParameterType::INTEGER];\n            ++$i;\n        }\n        $sql = \"SELECT mt.id FROM message_thread mt\n                WHERE (SELECT COUNT(*) FROM message_thread_participants mtp WHERE mtp.message_thread_id = mt.id) = :ctn $whereString\n                ORDER BY mt.updated_at DESC\";\n        $em = $this->getEntityManager();\n        $stmt = $em->getConnection()->prepare($sql);\n        foreach ($parameters as $param => $value) {\n            $stmt->bindValue($param, $value[0], $value[1]);\n        }\n        $results = $stmt->executeQuery()->fetchAllAssociative();\n\n        $this->logger->debug('got results for query {q}: {r}', ['q' => $sql, 'r' => $results]);\n        if (\\sizeof($results) > 0) {\n            $ids = [];\n            foreach ($results as $result) {\n                $ids[] = $result['id'];\n            }\n\n            return $this->findBy(['id' => $ids], ['updatedAt' => 'DESC']);\n        }\n\n        return [];\n    }\n}\n"
  },
  {
    "path": "src/Repository/ModeratorRequestRepository.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Repository;\n\nuse App\\Entity\\Magazine;\nuse App\\Entity\\ModeratorRequest;\nuse Doctrine\\Bundle\\DoctrineBundle\\Repository\\ServiceEntityRepository;\nuse Doctrine\\Persistence\\ManagerRegistry;\nuse Pagerfanta\\Doctrine\\ORM\\QueryAdapter;\nuse Pagerfanta\\Exception\\NotValidCurrentPageException;\nuse Pagerfanta\\Pagerfanta;\nuse Pagerfanta\\PagerfantaInterface;\nuse Symfony\\Component\\HttpKernel\\Exception\\NotFoundHttpException;\n\n/**`\n * @method ModeratorRequest|null find($id, $lockMode = null, $lockVersion = null)\n * @method ModeratorRequest|null findOneBy(array $criteria, array $orderBy = null)\n * @method ModeratorRequest|null findOneByName(string $name)\n * @method ModeratorRequest[]    findAll()\n * @method ModeratorRequest[]    findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)\n */\nclass ModeratorRequestRepository extends ServiceEntityRepository\n{\n    public const PER_PAGE = 25;\n\n    public function __construct(ManagerRegistry $registry)\n    {\n        parent::__construct($registry, ModeratorRequest::class);\n    }\n\n    public function findAllPaginated(Magazine $magazine, ?int $page): PagerfantaInterface\n    {\n        $qb = $this->createQueryBuilder('r')\n            ->where('r.magazine = :magazine')\n            ->orderBy('r.createdAt', 'ASC')\n            ->setParameter('magazine', $magazine);\n\n        $pagerfanta = new Pagerfanta(\n            new QueryAdapter(\n                $qb\n            )\n        );\n\n        try {\n            $pagerfanta->setMaxPerPage($criteria->perPage ?? self::PER_PAGE);\n            $pagerfanta->setCurrentPage($page);\n        } catch (NotValidCurrentPageException $e) {\n            throw new NotFoundHttpException();\n        }\n\n        return $pagerfanta;\n    }\n}\n"
  },
  {
    "path": "src/Repository/MonitoringRepository.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Repository;\n\nuse App\\DTO\\MonitoringExecutionContextFilterDto;\nuse App\\Entity\\MonitoringExecutionContext;\nuse App\\Pagination\\Pagerfanta;\nuse App\\Pagination\\QueryAdapter;\nuse Doctrine\\Bundle\\DoctrineBundle\\Repository\\ServiceEntityRepository;\nuse Doctrine\\Common\\Collections\\Criteria;\nuse Doctrine\\DBAL\\ParameterType;\nuse Doctrine\\Persistence\\ManagerRegistry;\n\n/**\n * @method MonitoringExecutionContext|null find($id, $lockMode = null, $lockVersion = null)\n * @method MonitoringExecutionContext|null findOneBy(array $criteria, array $orderBy = null)\n * @method MonitoringExecutionContext|null findOneByName(string $name)\n * @method MonitoringExecutionContext[]    findAll()\n * @method MonitoringExecutionContext[]    findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)\n */\nclass MonitoringRepository extends ServiceEntityRepository\n{\n    public function __construct(\n        ManagerRegistry $registry,\n        private readonly bool $monitoringEnabled,\n        private readonly bool $monitoringQueryParametersEnabled,\n        private readonly bool $monitoringQueriesEnabled,\n        private readonly bool $monitoringQueriesPersistingEnabled,\n        private readonly bool $monitoringTwigRendersEnabled,\n        private readonly bool $monitoringTwigRendersPersistingEnabled,\n        private readonly bool $monitoringCurlRequestsEnabled,\n        private readonly bool $monitoringCurlRequestPersistingEnabled,\n    ) {\n        parent::__construct($registry, MonitoringExecutionContext::class);\n    }\n\n    public function findByPaginated(Criteria $criteria): Pagerfanta\n    {\n        $qb = $this->createQueryBuilder('m')\n            ->addCriteria($criteria);\n\n        return new Pagerfanta(new QueryAdapter($qb));\n    }\n\n    /**\n     * @return array{path: string, total_duration: float, query_duration: float, twig_render_duration: float, curl_request_duration: float, response_duration: float}\n     *\n     * @throws \\Doctrine\\DBAL\\Exception\n     */\n    public function getOverviewRouteCalls(MonitoringExecutionContextFilterDto $dto, int $limit = 10): array\n    {\n        $criteria = $dto->toSqlWheres();\n        if (null === $dto->createdFrom) {\n            $criteria['whereConditions'][] = 'created_at > now() - \\'30 days\\'::interval';\n        }\n        $whereString = implode(' AND ', $criteria['whereConditions']);\n        if ('mean' === $dto->chartOrdering) {\n            $sql = \"SELECT\n                    path,\n                    (SUM(duration_milliseconds) / COUNT(uuid)) as total_duration,\n                    (SUM(query_duration_milliseconds) / COUNT(uuid)) as query_duration,\n                    (SUM(twig_render_duration_milliseconds) / COUNT(uuid)) as twig_render_duration,\n                    (SUM(curl_request_duration_milliseconds) / COUNT(uuid)) as curl_request_duration,\n                    (SUM(response_sending_duration_milliseconds) / COUNT(uuid)) as response_duration\n                FROM monitoring_execution_context WHERE $whereString GROUP BY path ORDER BY total_duration DESC LIMIT :limit\";\n        } else {\n            $sql = \"SELECT\n                    path,\n                    SUM(duration_milliseconds) as total_duration,\n                    SUM(query_duration_milliseconds) as query_duration,\n                    SUM(twig_render_duration_milliseconds) as twig_render_duration,\n                    SUM(curl_request_duration_milliseconds) as curl_request_duration,\n                    SUM(response_sending_duration_milliseconds) as response_duration\n                FROM monitoring_execution_context WHERE $whereString GROUP BY path ORDER BY total_duration DESC LIMIT :limit\";\n        }\n        $conn = $this->getEntityManager()->getConnection();\n        $stmt = $conn->prepare($sql);\n        $stmt->bindValue('limit', $limit, ParameterType::INTEGER);\n        foreach ($criteria['parameters'] as $key => $value) {\n            if (\\is_array($value)) {\n                $stmt->bindValue($key, $value['value'], $value['type']);\n            } elseif (\\is_int($value)) {\n                $stmt->bindValue($key, $value, ParameterType::INTEGER);\n            } else {\n                $stmt->bindValue($key, $value);\n            }\n        }\n\n        return $stmt->executeQuery()->fetchAllAssociative();\n    }\n\n    public function getFilteredContextsPaginated(MonitoringExecutionContextFilterDto $dto): Pagerfanta\n    {\n        $criteria = $dto->toCriteria();\n        $criteria->orderBy(orderings: ['createdAt' => 'DESC']);\n\n        return $this->findByPaginated($criteria);\n    }\n\n    /**\n     * @return array{\n     *     monitoringEnabled: bool,\n     *     monitoringQueryParametersEnabled: bool,\n     *     monitoringQueriesEnabled: bool,\n     *     monitoringQueriesPersistingEnabled: bool,\n     *     monitoringTwigRendersEnabled: bool,\n     *     monitoringTwigRendersPersistingEnabled: bool,\n     *     monitoringCurlRequestsEnabled: bool,\n     *     monitoringCurlRequestPersistingEnabled: bool\n     * }\n     */\n    public function getConfiguration(): array\n    {\n        return [\n            'monitoringEnabled' => $this->monitoringEnabled,\n            'monitoringQueryParametersEnabled' => $this->monitoringQueryParametersEnabled,\n            'monitoringQueriesEnabled' => $this->monitoringQueriesEnabled,\n            'monitoringQueriesPersistingEnabled' => $this->monitoringQueriesPersistingEnabled,\n            'monitoringTwigRendersEnabled' => $this->monitoringTwigRendersEnabled,\n            'monitoringTwigRendersPersistingEnabled' => $this->monitoringTwigRendersPersistingEnabled,\n            'monitoringCurlRequestsEnabled' => $this->monitoringCurlRequestsEnabled,\n            'monitoringCurlRequestPersistingEnabled' => $this->monitoringCurlRequestPersistingEnabled,\n        ];\n    }\n}\n"
  },
  {
    "path": "src/Repository/NotificationRepository.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Repository;\n\nuse App\\Entity\\Entry;\nuse App\\Entity\\EntryComment;\nuse App\\Entity\\Magazine;\nuse App\\Entity\\Notification;\nuse App\\Entity\\Post;\nuse App\\Entity\\PostComment;\nuse App\\Entity\\User;\nuse Doctrine\\Bundle\\DoctrineBundle\\Repository\\ServiceEntityRepository;\nuse Doctrine\\Persistence\\ManagerRegistry;\nuse Pagerfanta\\Doctrine\\ORM\\QueryAdapter;\nuse Pagerfanta\\Exception\\NotValidCurrentPageException;\nuse Pagerfanta\\Pagerfanta;\nuse Pagerfanta\\PagerfantaInterface;\nuse Symfony\\Component\\HttpKernel\\Exception\\NotFoundHttpException;\n\n/**\n * @method Notification|null find($id, $lockMode = null, $lockVersion = null)\n * @method Notification|null findOneBy(array $criteria, array $orderBy = null)\n * @method Notification[]    findAll()\n * @method Notification[]    findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)\n */\nclass NotificationRepository extends ServiceEntityRepository\n{\n    public const PER_PAGE = 25;\n    public const STATUS_ALL = 'all';\n    public const STATUS_OPTIONS = [\n        self::STATUS_ALL,\n        Notification::STATUS_NEW,\n        Notification::STATUS_READ,\n    ];\n\n    public function __construct(ManagerRegistry $registry)\n    {\n        parent::__construct($registry, Notification::class);\n    }\n\n    public function findByUser(\n        User $user,\n        ?int $page,\n        string $status = self::STATUS_ALL,\n        int $perPage = self::PER_PAGE,\n    ): PagerfantaInterface {\n        $qb = $this->createQueryBuilder('n')\n            ->where('n.user = :user')\n            ->setParameter('user', $user)\n            ->orderBy('n.id', 'DESC');\n\n        if (self::STATUS_ALL !== $status) {\n            $qb->andWhere('n.status = :status')\n                ->setParameter('status', $status);\n        }\n\n        $pagerfanta = new Pagerfanta(\n            new QueryAdapter(\n                $qb\n            )\n        );\n\n        try {\n            $pagerfanta->setMaxPerPage($perPage);\n            $pagerfanta->setCurrentPage($page);\n        } catch (NotValidCurrentPageException $e) {\n            throw new NotFoundHttpException();\n        }\n\n        return $pagerfanta;\n    }\n\n    public function findUnreadEntryNotifications(User $user, Entry $entry): iterable\n    {\n        $result = $this->findUnreadNotifications($user);\n\n        return array_filter(\n            $result,\n            fn ($notification) => (isset($notification->entry) && $notification->entry === $entry)\n                || (isset($notification->entryComment) && $notification->entryComment->entry === $entry)\n        );\n    }\n\n    public function findUnreadNotifications(User $user): array\n    {\n        $dql = 'SELECT n FROM '.Notification::class.' n WHERE n.user = :user AND n.status = :status';\n\n        return $this->getEntityManager()->createQuery($dql)\n            ->setParameter('user', $user)\n            ->setParameter('status', Notification::STATUS_NEW)\n            ->getResult();\n    }\n\n    public function countUnreadNotifications(User $user): int\n    {\n        $dql = 'SELECT count(n.id) FROM '.Notification::class.' n WHERE n.user = :user AND n.status = :status';\n\n        return $this->getEntityManager()->createQuery($dql)\n            ->setParameter('user', $user)\n            ->setParameter('status', Notification::STATUS_NEW)\n            ->getSingleScalarResult();\n    }\n\n    public function findUnreadPostNotifications(User $user, Post $post): iterable\n    {\n        $result = $this->findUnreadNotifications($user);\n\n        return array_filter(\n            $result,\n            fn ($notification) => (isset($notification->post) && $notification->post === $post)\n                || (isset($notification->postComment) && $notification->postComment->post === $post)\n        );\n    }\n\n    public function removeEntryNotifications(Entry $entry): void\n    {\n        $conn = $this->getEntityManager()->getConnection();\n        $sql = 'DELETE FROM notification AS n WHERE n.entry_id = :entryId';\n\n        $stmt = $conn->prepare($sql);\n        $stmt->bindValue('entryId', $entry->getId());\n\n        $stmt->executeQuery();\n    }\n\n    public function removeEntryCommentNotifications(EntryComment $comment): void\n    {\n        $conn = $this->getEntityManager()->getConnection();\n        $sql = 'DELETE FROM notification AS n WHERE n.entry_comment_id = :commentId';\n\n        $stmt = $conn->prepare($sql);\n        $stmt->bindValue('commentId', $comment->getId());\n\n        $stmt->executeQuery();\n    }\n\n    public function removePostNotifications(Post $post): void\n    {\n        $conn = $this->getEntityManager()->getConnection();\n        $sql = 'DELETE FROM notification AS n WHERE n.post_id = :postId';\n\n        $stmt = $conn->prepare($sql);\n        $stmt->bindValue('postId', $post->getId());\n\n        $stmt->executeQuery();\n    }\n\n    public function removePostCommentNotifications(PostComment $comment): void\n    {\n        $conn = $this->getEntityManager()->getConnection();\n        $sql = 'DELETE FROM notification AS n WHERE n.post_comment_id = :commentId';\n\n        $stmt = $conn->prepare($sql);\n        $stmt->bindValue('commentId', $comment->getId());\n\n        $stmt->executeQuery();\n    }\n\n    public function markReportNotificationsAsRead(User $user): void\n    {\n        $conn = $this->getEntityManager()->getConnection();\n        $sql = 'UPDATE notification n SET status = :s\n                      WHERE n.user_id = :uId\n                        AND n.report_id IS NOT NULL';\n        $stmt = $conn->prepare($sql);\n        $stmt->bindValue('s', Notification::STATUS_READ);\n        $stmt->bindValue('uId', $user->getId());\n        $stmt->executeQuery();\n    }\n\n    public function markReportNotificationsInMagazineAsRead(User $user, Magazine $magazine): void\n    {\n        $conn = $this->getEntityManager()->getConnection();\n        $sql = 'UPDATE notification n SET status = :s\n                      WHERE n.user_id = :uId\n                        AND n.report_id IS NOT NULL\n                        AND EXISTS (SELECT id FROM report r WHERE r.id = n.report_id AND r.magazine_id = :mId)';\n        $stmt = $conn->prepare($sql);\n        $stmt->bindValue('s', Notification::STATUS_READ);\n        $stmt->bindValue('uId', $user->getId());\n        $stmt->bindValue('mId', $magazine->getId());\n        $stmt->executeQuery();\n    }\n\n    public function markOwnReportNotificationsAsRead(User $user): void\n    {\n        $conn = $this->getEntityManager()->getConnection();\n        $sql = 'UPDATE notification n SET status = :s\n                      WHERE n.user_id = :uId\n                        AND n.report_id IS NOT NULL\n                        AND EXISTS (SELECT id FROM report r WHERE r.id = n.report_id AND r.reporting_id = :uId)';\n        $stmt = $conn->prepare($sql);\n        $stmt->bindValue('s', Notification::STATUS_READ);\n        $stmt->bindValue('uId', $user->getId());\n        $stmt->executeQuery();\n    }\n\n    public function markUserSignupNotificationsAsRead(User $user, User $signedUpUser): void\n    {\n        $conn = $this->getEntityManager()->getConnection();\n        $sql = 'UPDATE notification n SET status = :s\n                      WHERE n.user_id = :uId\n                        AND n.new_user_id = :newUserId';\n        $stmt = $conn->prepare($sql);\n        $stmt->bindValue('s', Notification::STATUS_READ);\n        $stmt->bindValue('uId', $user->getId());\n        $stmt->bindValue('newUserId', $signedUpUser->getId());\n        $stmt->executeQuery();\n    }\n}\n"
  },
  {
    "path": "src/Repository/NotificationSettingsRepository.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Repository;\n\nuse App\\DTO\\EntryDto;\nuse App\\DTO\\MagazineDto;\nuse App\\DTO\\PostDto;\nuse App\\DTO\\UserDto;\nuse App\\Entity\\Entry;\nuse App\\Entity\\EntryComment;\nuse App\\Entity\\Magazine;\nuse App\\Entity\\NotificationSettings;\nuse App\\Entity\\Post;\nuse App\\Entity\\PostComment;\nuse App\\Entity\\User;\nuse App\\Enums\\ENotificationStatus;\nuse Doctrine\\Bundle\\DoctrineBundle\\Repository\\ServiceEntityRepository;\nuse Doctrine\\DBAL\\Exception;\nuse Doctrine\\Persistence\\ManagerRegistry;\nuse Psr\\Log\\LoggerInterface;\n\n/**\n * @method NotificationSettings|null find($id, $lockMode = null, $lockVersion = null)\n * @method NotificationSettings|null findOneBy(array $criteria, array $orderBy = null)\n * @method NotificationSettings[]    findAll()\n * @method NotificationSettings[]    findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)\n */\nclass NotificationSettingsRepository extends ServiceEntityRepository\n{\n    public function __construct(\n        ManagerRegistry $registry,\n        private readonly LoggerInterface $logger,\n    ) {\n        parent::__construct($registry, NotificationSettings::class);\n    }\n\n    public function findOneByTarget(User $user, Entry|EntryDto|Post|PostDto|User|UserDto|Magazine|MagazineDto $target): ?NotificationSettings\n    {\n        $qb = $this->createQueryBuilder('ns')\n            ->where('ns.user = :user');\n\n        if ($target instanceof User || $target instanceof UserDto) {\n            $qb->andWhere('ns.targetUser = :target');\n        } elseif ($target instanceof Magazine || $target instanceof MagazineDto) {\n            $qb->andWhere('ns.magazine = :target');\n        } elseif ($target instanceof Entry || $target instanceof EntryDto) {\n            $qb->andWhere('ns.entry = :target');\n        } elseif ($target instanceof Post || $target instanceof PostDto) {\n            $qb->andWhere('ns.post = :target');\n        }\n        $qb->setParameter('target', $target->getId());\n        $qb->setParameter('user', $user);\n\n        return $qb->getQuery()\n            ->getOneOrNullResult();\n    }\n\n    public function setStatusByTarget(User $user, Entry|Post|User|Magazine $target, ENotificationStatus $status): void\n    {\n        $setting = $this->findOneByTarget($user, $target);\n        if (null === $setting) {\n            $setting = new NotificationSettings($user, $target, $status);\n        } else {\n            $setting->setStatus($status);\n        }\n        $this->getEntityManager()->persist($setting);\n        $this->getEntityManager()->flush();\n    }\n\n    /**\n     * gets the users that should be notified about the created of $target. This respects user and magazine blocks\n     * as well as custom notification settings and the users default notification settings.\n     *\n     * @return int[]\n     *\n     * @throws Exception\n     */\n    public function findNotificationSubscribersByTarget(Entry|EntryComment|Post|PostComment $target): array\n    {\n        $nestedCommentPostAuthor = 'false';\n        if ($target instanceof Entry || $target instanceof EntryComment) {\n            $targetCol = 'entry_id';\n            if ($target instanceof Entry) {\n                $targetId = $target->getId();\n                $notifyCol = 'notify_on_new_entry';\n                $isMagazineLevel = true;\n                $dontNeedSubscription = false;\n                $dontNeedToBeAuthor = true;\n                $targetParentUserId = null;\n            } else {\n                $targetId = $target->entry->getId();\n                if (null === $target->parent) {\n                    $notifyCol = 'notify_on_new_entry_reply';\n                    $targetParentUserId = $target->entry->user->getId();\n                } else {\n                    $notifyCol = 'notify_on_new_entry_comment_reply';\n                    $targetParentUserId = $target->parent->user->getId();\n\n                    $nestedCommentPostAuthor = 'u.notify_on_new_entry_reply = true\n                                AND u.id = :targetParent2UserId';\n                    $targetParent2UserId = $target->entry->user->getId();\n                }\n                $isMagazineLevel = false;\n                $dontNeedSubscription = true;\n                $dontNeedToBeAuthor = false;\n            }\n        } else {\n            $targetCol = 'post_id';\n            if ($target instanceof Post) {\n                $targetId = $target->getId();\n                $notifyCol = 'notify_on_new_post';\n                $isMagazineLevel = true;\n                $dontNeedSubscription = false;\n                $dontNeedToBeAuthor = true;\n                $targetParentUserId = null;\n            } else {\n                $targetId = $target->post->getId();\n                if (null === $target->parent) {\n                    $notifyCol = 'notify_on_new_post_reply';\n                    $targetParentUserId = $target->post->user->getId();\n                } else {\n                    $notifyCol = 'notify_on_new_post_comment_reply';\n                    $targetParentUserId = $target->parent->user->getId();\n\n                    $nestedCommentPostAuthor = 'u.notify_on_new_post_reply = true\n                                AND u.id = :targetParent2UserId';\n                    $targetParent2UserId = $target->post->user->getId();\n                }\n                $isMagazineLevel = false;\n                $dontNeedSubscription = true;\n                $dontNeedToBeAuthor = false;\n            }\n        }\n\n        $isMagazineLevelString = $isMagazineLevel ? 'true' : 'false';\n        $isNotMagazineLevelString = !$isMagazineLevel ? 'true' : 'false';\n        $dontNeedSubscriptionString = $dontNeedSubscription ? 'true' : 'false';\n        $dontNeedToBeAuthorString = $dontNeedToBeAuthor ? 'true' : 'false';\n\n        $sql = \"SELECT u.id FROM \\\"user\\\" u\n            LEFT JOIN notification_settings ns_user ON ns_user.user_id = u.id AND ns_user.target_user_id = :targetUserId\n            LEFT JOIN notification_settings ns_post ON ns_post.user_id = u.id AND ns_post.$targetCol = :targetId\n            LEFT JOIN notification_settings ns_mag ON ns_mag.user_id = u.id AND ns_mag.magazine_id = :magId\n            WHERE\n                u.ap_id IS NULL\n                AND u.id <> :targetUserId\n                AND (\n                    COALESCE(ns_user.notification_status, :normal) = :loud\n                    OR (\n                        COALESCE(ns_user.notification_status, :normal) = :normal\n                        AND COALESCE(ns_post.notification_status, :normal) = :loud\n                    )\n                    OR (\n                        COALESCE(ns_user.notification_status, :normal) = :normal\n                        AND COALESCE(ns_post.notification_status, :normal) = :normal\n                        AND COALESCE(ns_mag.notification_status, :normal) = :loud\n                        -- deactivate loud magazine notifications for comments\n                        AND $isMagazineLevelString\n                    )\n                    OR (\n                        COALESCE(ns_user.notification_status, :normal) = :normal\n                        AND COALESCE(ns_post.notification_status, :normal) = :normal\n                        AND (\n                            -- ignore the magazine level settings for comments\n                            COALESCE(ns_mag.notification_status, :normal) = :normal\n                            OR $isNotMagazineLevelString\n                        )\n                        AND (\n                            (\n                                u.$notifyCol = true\n                                AND (\n                                    -- deactivate magazine subscription need for comments\n                                    $dontNeedSubscriptionString\n                                    OR EXISTS (SELECT * FROM magazine_subscription ms WHERE ms.user_id = u.id AND ms.magazine_id = :magId)\n                                )\n                                AND (\n                                    -- deactivate the need to be the author of the parent to receive notifications\n                                    $dontNeedToBeAuthorString\n                                    OR u.id = :targetParentUserId\n                                )\n                            ) OR (\n                                $nestedCommentPostAuthor\n                            )\n                        )\n                    )\n                )\n                AND NOT EXISTS (SELECT * FROM user_block ub WHERE ub.blocker_id = u.id AND ub.blocked_id = :targetUserId)\n        \";\n        $conn = $this->getEntityManager()->getConnection();\n        $stmt = $conn->prepare($sql);\n\n        $stmt->bindValue('normal', ENotificationStatus::Default->value);\n        $stmt->bindValue('loud', ENotificationStatus::Loud->value);\n        $stmt->bindValue('targetUserId', $target->user->getId());\n        $stmt->bindValue('targetId', $targetId);\n        $stmt->bindValue('magId', $target->magazine->getId());\n        $stmt->bindValue('targetParentUserId', $targetParentUserId);\n\n        if (isset($targetParent2UserId)) {\n            $stmt->bindValue('targetParent2UserId', $targetParent2UserId);\n        }\n        $result = $stmt->executeQuery();\n        $rows = $result->fetchAllAssociative();\n        $this->logger->debug('got subscribers for target {c} id {id}: {subs}, (magLevel: {ml}, notMagLevel: {nml}, targetCol: {tc}, notifyCol: {nc}, dontNeedSubs: {dns}, doneNeedAuthor: {dna}, nestedComment extra condition: {nested})', [\n            'c' => \\get_class($target),\n            'id' => $target->getId(),\n            'subs' => $rows,\n            'ml' => $isMagazineLevelString,\n            'nml' => $isNotMagazineLevelString,\n            'tc' => $targetCol,\n            'nc' => $notifyCol,\n            'dns' => $dontNeedSubscriptionString,\n            'dna' => $dontNeedToBeAuthorString,\n            'nested' => $nestedCommentPostAuthor,\n        ]);\n\n        return array_map(fn (array $row) => $row['id'], $rows);\n    }\n}\n"
  },
  {
    "path": "src/Repository/OAuth2ClientAccessRepository.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Repository;\n\nuse App\\Entity\\OAuth2ClientAccess;\nuse Doctrine\\Bundle\\DoctrineBundle\\Repository\\ServiceEntityRepository;\nuse Doctrine\\Persistence\\ManagerRegistry;\n\n/**\n * @extends ServiceEntityRepository<OAuth2ClientAccess>\n *\n * @method OAuth2ClientAccess|null find($id, $lockMode = null, $lockVersion = null)\n * @method OAuth2ClientAccess|null findOneBy(array $criteria, array $orderBy = null)\n * @method OAuth2ClientAccess[]    findAll()\n * @method OAuth2ClientAccess[]    findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)\n */\nclass OAuth2ClientAccessRepository extends ServiceEntityRepository\n{\n    public function __construct(ManagerRegistry $registry)\n    {\n        parent::__construct($registry, OAuth2ClientAccess::class);\n    }\n\n    public function save(OAuth2ClientAccess $entity, bool $flush = false): void\n    {\n        $this->getEntityManager()->persist($entity);\n\n        if ($flush) {\n            $this->getEntityManager()->flush();\n        }\n    }\n\n    public function remove(OAuth2ClientAccess $entity, bool $flush = false): void\n    {\n        $this->getEntityManager()->remove($entity);\n\n        if ($flush) {\n            $this->getEntityManager()->flush();\n        }\n    }\n\n    public function getStats(\n        string $intervalStr,\n        ?\\DateTime $start,\n        ?\\DateTime $end,\n    ): array {\n        $interval = $intervalStr ?? 'hour';\n        switch ($interval) {\n            case 'all':\n                return $this->aggregateTotalStats();\n            case 'year':\n            case 'month':\n            case 'day':\n            case 'hour':\n            case 'minute':\n            case 'second':\n            case 'milliseconds':\n                break;\n            default:\n                throw new \\LogicException('Invalid interval provided');\n        }\n\n        return $this->aggregateStats($interval, $start, $end);\n    }\n\n    // Todo - stats need improvement for sure but that's out of the scope of making the starting API\n    private function aggregateStats(string $interval, ?\\DateTime $start, ?\\DateTime $end): array\n    {\n        if (null === $end) {\n            $end = new \\DateTime();\n        }\n\n        if (null === $start) {\n            $start = new \\DateTime('-1 '.$interval);\n        }\n\n        if ($end < $start) {\n            throw new \\LogicException('End date must be after start date!');\n        }\n\n        $conn = $this->getEntityManager()->getConnection();\n\n        $sql = 'SELECT c.name as client, date_trunc(?, e.created_at) as datetime, COUNT(e) as count FROM oauth2_client_access e\n                    JOIN oauth2_client c on c.identifier = e.client_id\n                    WHERE e.created_at BETWEEN ? AND ?\n                    GROUP BY 1, 2 ORDER BY 3 DESC';\n\n        $stmt = $conn->prepare($sql);\n        $stmt->bindValue(1, $interval);\n        $stmt->bindValue(2, $start, 'datetime');\n        $stmt->bindValue(3, $end, 'datetime');\n\n        return $stmt->executeQuery()->fetchAllAssociative();\n    }\n\n    private function aggregateTotalStats(): array\n    {\n        $conn = $this->getEntityManager()->getConnection();\n\n        $sql = 'SELECT e.client_id as client, COUNT(e) as count FROM oauth2_client_access e \n                    GROUP BY 1 ORDER BY 2 DESC';\n\n        $stmt = $conn->prepare($sql);\n\n        return $stmt->executeQuery()->fetchAllAssociative();\n    }\n}\n"
  },
  {
    "path": "src/Repository/OAuth2UserConsentRepository.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Repository;\n\nuse App\\Entity\\OAuth2UserConsent;\nuse Doctrine\\Bundle\\DoctrineBundle\\Repository\\ServiceEntityRepository;\nuse Doctrine\\Persistence\\ManagerRegistry;\n\n/**\n * @extends ServiceEntityRepository<OAuth2UserConsent>\n *\n * @method OAuth2UserConsent|null find($id, $lockMode = null, $lockVersion = null)\n * @method OAuth2UserConsent|null findOneBy(array $criteria, array $orderBy = null)\n * @method OAuth2UserConsent[]    findAll()\n * @method OAuth2UserConsent[]    findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)\n */\nclass OAuth2UserConsentRepository extends ServiceEntityRepository\n{\n    public function __construct(ManagerRegistry $registry)\n    {\n        parent::__construct($registry, OAuth2UserConsent::class);\n    }\n\n    public function save(OAuth2UserConsent $entity, bool $flush = false): void\n    {\n        $this->getEntityManager()->persist($entity);\n\n        if ($flush) {\n            $this->getEntityManager()->flush();\n        }\n    }\n\n    public function remove(OAuth2UserConsent $entity, bool $flush = false): void\n    {\n        $this->getEntityManager()->remove($entity);\n\n        if ($flush) {\n            $this->getEntityManager()->flush();\n        }\n    }\n\n    //    /**\n    //     * @return OAuth2UserConsent[] Returns an array of OAuth2UserConsent objects\n    //     */\n    //    public function findByExampleField($value): array\n    //    {\n    //        return $this->createQueryBuilder('o')\n    //            ->andWhere('o.exampleField = :val')\n    //            ->setParameter('val', $value)\n    //            ->orderBy('o.id', 'ASC')\n    //            ->setMaxResults(10)\n    //            ->getQuery()\n    //            ->getResult()\n    //        ;\n    //    }\n\n    //    public function findOneBySomeField($value): ?OAuth2UserConsent\n    //    {\n    //        return $this->createQueryBuilder('o')\n    //            ->andWhere('o.exampleField = :val')\n    //            ->setParameter('val', $value)\n    //            ->getQuery()\n    //            ->getOneOrNullResult()\n    //        ;\n    //    }\n}\n"
  },
  {
    "path": "src/Repository/PostCommentRepository.php",
    "content": "<?php\n\n// SPDX-FileCopyrightText: Copyright (c) 2016-2017 Emma <emma1312@protonmail.ch>\n//\n// SPDX-License-Identifier: Zlib\n\ndeclare(strict_types=1);\n\nnamespace App\\Repository;\n\nuse App\\Entity\\Contracts\\VisibilityInterface;\nuse App\\Entity\\HashtagLink;\nuse App\\Entity\\Image;\nuse App\\Entity\\Post;\nuse App\\Entity\\PostComment;\nuse App\\Entity\\User;\nuse App\\Entity\\UserBlock;\nuse App\\Entity\\UserFollow;\nuse App\\PageView\\PostCommentPageView;\nuse Doctrine\\Bundle\\DoctrineBundle\\Repository\\ServiceEntityRepository;\nuse Doctrine\\DBAL\\ArrayParameterType;\nuse Doctrine\\DBAL\\Types\\Types;\nuse Doctrine\\ORM\\Query\\Expr\\Join;\nuse Doctrine\\ORM\\QueryBuilder;\nuse Doctrine\\Persistence\\ManagerRegistry;\nuse Pagerfanta\\Doctrine\\ORM\\QueryAdapter;\nuse Pagerfanta\\Exception\\NotValidCurrentPageException;\nuse Pagerfanta\\Pagerfanta;\nuse Symfony\\Bundle\\SecurityBundle\\Security;\nuse Symfony\\Component\\HttpKernel\\Exception\\NotFoundHttpException;\n\n/**\n * @method PostComment|null find($id, $lockMode = null, $lockVersion = null)\n * @method PostComment|null findOneBy(array $criteria, array $orderBy = null)\n * @method PostComment[]    findAll()\n * @method PostComment[]    findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)\n */\nclass PostCommentRepository extends ServiceEntityRepository\n{\n    public const PER_PAGE = 15;\n\n    public function __construct(\n        ManagerRegistry $registry,\n        private readonly Security $security,\n    ) {\n        parent::__construct($registry, PostComment::class);\n    }\n\n    public function findByCriteria(PostCommentPageView $criteria)\n    {\n        //        return $this->createQueryBuilder('pc')\n        //            ->orderBy('pc.createdAt', 'DESC')\n        //            ->setMaxResults(10)\n        //            ->getQuery()\n        //            ->getResult();\n        $pagerfanta = new Pagerfanta(\n            new QueryAdapter(\n                $this->getCommentQueryBuilder($criteria),\n                false\n            )\n        );\n\n        try {\n            $pagerfanta->setMaxPerPage($criteria->perPage ?? self::PER_PAGE);\n            $pagerfanta->setCurrentPage($criteria->page);\n        } catch (NotValidCurrentPageException $e) {\n            throw new NotFoundHttpException();\n        }\n\n        return $pagerfanta;\n    }\n\n    private function getCommentQueryBuilder(Criteria $criteria): QueryBuilder\n    {\n        $user = $this->security->getUser();\n\n        $qb = $this->createQueryBuilder('c')\n            ->select('c', 'u')\n            ->join('c.user', 'u')\n            ->andWhere('c.visibility IN (:visibility)')\n            ->andWhere('u.visibility = :visible');\n\n        if ($user && VisibilityInterface::VISIBILITY_VISIBLE === $criteria->visibility) {\n            $qb->orWhere(\n                'c.user IN (SELECT IDENTITY(cuf.following) FROM '.UserFollow::class.' cuf WHERE cuf.follower = :cUser AND c.visibility = :cVisibility)'\n            )\n                ->setParameter('cUser', $user)\n                ->setParameter('cVisibility', VisibilityInterface::VISIBILITY_PRIVATE);\n        }\n\n        $qb->setParameter(\n            'visibility',\n            [\n                VisibilityInterface::VISIBILITY_SOFT_DELETED,\n                VisibilityInterface::VISIBILITY_VISIBLE,\n                VisibilityInterface::VISIBILITY_TRASHED,\n            ]\n        )\n            ->setParameter('visible', VisibilityInterface::VISIBILITY_VISIBLE);\n\n        $this->addTimeClause($qb, $criteria);\n        $this->filter($qb, $criteria);\n        $this->addBannedHashtagClause($qb);\n\n        if ($user instanceof User) {\n            $this->filterWords($qb, $user);\n        }\n\n        return $qb;\n    }\n\n    private function addTimeClause(QueryBuilder $qb, Criteria $criteria): void\n    {\n        if (Criteria::TIME_ALL !== $criteria->time) {\n            $since = $criteria->getSince();\n\n            $qb->andWhere('c.createdAt > :time')\n                ->setParameter('time', $since, Types::DATETIMETZ_IMMUTABLE);\n        }\n    }\n\n    private function addBannedHashtagClause(QueryBuilder $qb): void\n    {\n        $dql = $this->getEntityManager()->createQueryBuilder()\n            ->select('hl2')\n            ->from(HashtagLink::class, 'hl2')\n            ->join('hl2.hashtag', 'h2')\n            ->where('h2.banned = true')\n            ->andWhere('hl2.postComment = c')\n            ->getDQL();\n        $qb->andWhere($qb->expr()->not($qb->expr()->exists($dql)));\n    }\n\n    private function filter(QueryBuilder $qb, Criteria $criteria)\n    {\n        if ($criteria->post) {\n            $qb->andWhere('c.post = :post')\n                ->setParameter('post', $criteria->post);\n        }\n\n        if ($criteria->magazine) {\n            $qb->join('c.post', 'p', Join::WITH, 'p.magazine = :magazine');\n            $qb->setParameter('magazine', $criteria->magazine);\n        }\n\n        if ($criteria->languages) {\n            $qb->andWhere('c.lang IN (:languages)')\n                ->setParameter('languages', $criteria->languages, ArrayParameterType::STRING);\n        }\n\n        if ($criteria->user) {\n            $qb->andWhere('c.user = :user')\n                ->setParameter('user', $criteria->user);\n        }\n\n        if ($criteria->tag) {\n            $qb->andWhere('t.tag = :tag')\n                ->join('p.hashtags', 'h')\n                ->join('h.hashtag', 't')\n                ->setParameter('tag', $criteria->tag);\n        }\n\n        $user = $this->security->getUser();\n        if ($user && !$criteria->moderated) {\n            $qb->andWhere(\n                'c.user NOT IN (SELECT IDENTITY(ub.blocked) FROM '.UserBlock::class.' ub WHERE ub.blocker = :blocker)'\n            );\n\n            $qb->setParameter('blocker', $user);\n        }\n\n        if ($criteria->onlyParents) {\n            $qb->andWhere('c.parent IS NULL');\n        }\n\n        switch ($criteria->sortOption) {\n            case Criteria::SORT_HOT:\n            case Criteria::SORT_TOP:\n                $qb->orderBy('c.upVotes + c.favouriteCount', 'DESC');\n                break;\n            case Criteria::SORT_ACTIVE:\n                $qb->orderBy('c.lastActive', 'DESC');\n                break;\n            case Criteria::SORT_NEW:\n                $qb->orderBy('c.createdAt', 'DESC');\n                break;\n            case Criteria::SORT_OLD:\n                $qb->orderBy('c.createdAt', 'ASC');\n                break;\n            default:\n                $qb->addOrderBy('c.lastActive', 'DESC');\n        }\n\n        $qb->addOrderBy('c.createdAt', 'DESC');\n        $qb->addOrderBy('c.id', 'DESC');\n    }\n\n    private function filterWords(QueryBuilder $qb, User $user): QueryBuilder\n    {\n        $i = 0;\n        foreach ($user->getCurrentFilterLists() as $list) {\n            if (!$list->comments) {\n                continue;\n            }\n\n            foreach ($list->words as $word) {\n                if ($word['exactMatch']) {\n                    $qb->andWhere(\"NOT (c.body LIKE :word$i) or c.user = :filterUser\")\n                        ->setParameter(\"word$i\", '%'.$word['word'].'%');\n                } else {\n                    $qb->andWhere(\"NOT (lower(c.body) LIKE lower(:word$i)) or c.user = :filterUser\")\n                        ->setParameter(\"word$i\", '%'.$word['word'].'%');\n                }\n                ++$i;\n            }\n        }\n        if ($i > 0) {\n            $qb->setParameter('filterUser', $user);\n        }\n\n        return $qb;\n    }\n\n    /**\n     * @return Image[]\n     */\n    public function findImagesByPost(Post $post): array\n    {\n        $results = $this->createQueryBuilder('c')\n            ->addSelect('i')\n            ->innerJoin('c.image', 'i')\n            ->andWhere('c.post = :post')\n            ->setParameter('post', $post)\n            ->getQuery()\n            ->getResult();\n\n        return array_map(fn (PostComment $comment) => $comment->image, $results);\n    }\n\n    public function hydrateChildren(PostComment ...$comments): void\n    {\n        $children = $this->createQueryBuilder('c')\n            ->andWhere('c.root IN (:ids)')\n            ->setParameter('ids', $comments)\n            ->getQuery()->getResult();\n\n        $this->hydrate(...$children);\n    }\n\n    public function hydrate(PostComment ...$comment): void\n    {\n        $this->getEntityManager()->createQueryBuilder()\n            ->select('PARTIAL c.{id}')\n            ->addSelect('u')\n            ->addSelect('m')\n            ->addSelect('i')\n            ->from(PostComment::class, 'c')\n            ->join('c.user', 'u')\n            ->join('c.magazine', 'm')\n            ->leftJoin('c.image', 'i')\n            ->where('c IN (?1)')\n            ->setParameter(1, $comment)\n            ->getQuery()\n            ->getResult();\n\n        /* we don't need to hydrate all the votes and favourites. We only use the count saved in the PostComment entity\n        if ($this->security->getUser()) {\n            $this->_em->createQueryBuilder()\n                ->select('PARTIAL c.{id}')\n                ->from(PostComment::class, 'c')\n                ->leftJoin('c.votes', 'cv')\n                ->leftJoin('c.favourites', 'cf')\n                ->where('c IN (?1)')\n                ->setParameter(1, $comment)\n                ->getQuery()\n                ->getResult();\n        }\n        */\n    }\n}\n"
  },
  {
    "path": "src/Repository/PostRepository.php",
    "content": "<?php\n\n// SPDX-FileCopyrightText: Copyright (c) 2016-2017 Emma <emma1312@protonmail.ch>\n//\n// SPDX-License-Identifier: Zlib\n\ndeclare(strict_types=1);\n\nnamespace App\\Repository;\n\nuse App\\Entity\\Contracts\\VisibilityInterface;\nuse App\\Entity\\HashtagLink;\nuse App\\Entity\\Magazine;\nuse App\\Entity\\MagazineBlock;\nuse App\\Entity\\MagazineSubscription;\nuse App\\Entity\\Moderator;\nuse App\\Entity\\Post;\nuse App\\Entity\\PostFavourite;\nuse App\\Entity\\User;\nuse App\\Entity\\UserBlock;\nuse App\\Entity\\UserFollow;\nuse App\\PageView\\EntryPageView;\nuse App\\PageView\\PostPageView;\nuse App\\Pagination\\AdapterFactory;\nuse App\\Service\\SettingsManager;\nuse App\\Utils\\SqlHelpers;\nuse Doctrine\\Bundle\\DoctrineBundle\\Repository\\ServiceEntityRepository;\nuse Doctrine\\DBAL\\ArrayParameterType;\nuse Doctrine\\DBAL\\Types\\Types;\nuse Doctrine\\ORM\\NoResultException;\nuse Doctrine\\ORM\\QueryBuilder;\nuse Doctrine\\Persistence\\ManagerRegistry;\nuse Pagerfanta\\Exception\\NotValidCurrentPageException;\nuse Pagerfanta\\Pagerfanta;\nuse Pagerfanta\\PagerfantaInterface;\nuse Symfony\\Bundle\\SecurityBundle\\Security;\nuse Symfony\\Component\\HttpKernel\\Exception\\NotFoundHttpException;\nuse Symfony\\Contracts\\Cache\\CacheInterface;\nuse Symfony\\Contracts\\Cache\\ItemInterface;\n\n/**\n * @method Post|null find($id, $lockMode = null, $lockVersion = null)\n * @method Post|null findOneBy(array $criteria, array $orderBy = null)\n * @method Post[]    findAll()\n * @method Post[]    findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)\n */\nclass PostRepository extends ServiceEntityRepository\n{\n    public const PER_PAGE = 15;\n    public const SORT_DEFAULT = 'hot';\n\n    public function __construct(\n        ManagerRegistry $registry,\n        private readonly Security $security,\n        private readonly CacheInterface $cache,\n        private readonly AdapterFactory $adapterFactory,\n        private readonly SettingsManager $settingsManager,\n        private readonly SqlHelpers $sqlHelpers,\n    ) {\n        parent::__construct($registry, Post::class);\n    }\n\n    public function findByCriteria(PostPageView $criteria): PagerfantaInterface\n    {\n        $pagerfanta = new Pagerfanta($this->adapterFactory->create($this->getEntryQueryBuilder($criteria)));\n\n        try {\n            $pagerfanta->setMaxPerPage($criteria->perPage ?? self::PER_PAGE);\n            $pagerfanta->setCurrentPage($criteria->page);\n            if (!$criteria->magazine) {\n                $pagerfanta->setMaxNbPages(1000);\n            }\n        } catch (NotValidCurrentPageException $e) {\n            throw new NotFoundHttpException();\n        }\n\n        return $pagerfanta;\n    }\n\n    private function getEntryQueryBuilder(PostPageView $criteria): QueryBuilder\n    {\n        $user = $this->security->getUser();\n\n        $qb = $this->createQueryBuilder('p')\n            ->select('p', 'm', 'u')\n            ->where('p.visibility = :visibility')\n            ->join('p.magazine', 'm')\n            ->join('p.user', 'u')\n            ->andWhere('m.visibility = :visible')\n            ->andWhere('u.visibility = :visible');\n\n        if ($user && VisibilityInterface::VISIBILITY_VISIBLE === $criteria->visibility) {\n            $qb->orWhere(\n                'EXISTS (SELECT IDENTITY(puf.following) FROM '.UserFollow::class.' puf WHERE puf.follower = :puf_user AND p.visibility = :puf_visibility AND puf.following = p.user)'\n            )\n                ->setParameter('puf_user', $user)\n                ->setParameter('puf_visibility', VisibilityInterface::VISIBILITY_PRIVATE);\n        } else {\n            $qb->orWhere('p.user IS NULL');\n        }\n\n        $qb->setParameter('visibility', $criteria->visibility)\n            ->setParameter('visible', VisibilityInterface::VISIBILITY_VISIBLE);\n\n        $this->addTimeClause($qb, $criteria);\n        $this->addStickyClause($qb, $criteria);\n        $this->filter($qb, $criteria);\n        $this->addBannedHashtagClause($qb);\n\n        return $qb;\n    }\n\n    private function addTimeClause(QueryBuilder $qb, Criteria $criteria): void\n    {\n        if (Criteria::TIME_ALL !== $criteria->time) {\n            $since = $criteria->getSince();\n\n            $qb->andWhere('p.createdAt > :time')\n                ->setParameter('time', $since, Types::DATETIMETZ_IMMUTABLE);\n        }\n    }\n\n    private function addStickyClause(QueryBuilder $qb, PostPageView $criteria): void\n    {\n        if ($criteria->stickiesFirst) {\n            if (1 === $criteria->page) {\n                $qb->addOrderBy('p.sticky', 'DESC');\n            } else {\n                $qb->andWhere($qb->expr()->eq('p.sticky', 'false'));\n            }\n        }\n    }\n\n    private function addBannedHashtagClause(QueryBuilder $qb): void\n    {\n        $dql = $this->getEntityManager()->createQueryBuilder()\n            ->select('hl2')\n            ->from(HashtagLink::class, 'hl2')\n            ->join('hl2.hashtag', 'h2')\n            ->where('h2.banned = true')\n            ->andWhere('hl2.post = p')\n            ->getDQL();\n        $qb->andWhere($qb->expr()->not($qb->expr()->exists($dql)));\n    }\n\n    private function filter(QueryBuilder $qb, Criteria $criteria): QueryBuilder\n    {\n        /** @var User|null $user */\n        $user = $this->security->getUser();\n\n        if (Criteria::AP_LOCAL === $criteria->federation) {\n            $qb->andWhere('p.apId IS NULL');\n        } elseif (Criteria::AP_FEDERATED === $criteria->federation) {\n            $qb->andWhere('p.apId IS NOT NULL');\n        }\n\n        if ($criteria->magazine) {\n            $qb->andWhere('p.magazine = :magazine')\n                ->setParameter('magazine', $criteria->magazine);\n        }\n\n        if ($criteria->user) {\n            $qb->andWhere('p.user = :user')\n                ->setParameter('user', $criteria->user);\n        }\n\n        if ($criteria->tag) {\n            $qb->andWhere('t.tag = :tag')\n                ->join('p.hashtags', 'h')\n                ->join('h.hashtag', 't')\n                ->setParameter('tag', $criteria->tag);\n        }\n\n        if ($criteria->subscribed) {\n            $qb->andWhere(\n                'EXISTS (SELECT IDENTITY(ms.magazine) FROM '.MagazineSubscription::class.' ms WHERE ms.user = :user AND ms.magazine = p.magazine)\n                OR\n                EXISTS (SELECT IDENTITY(uf.following) FROM '.UserFollow::class.' uf WHERE uf.follower = :user AND uf.following = p.user)\n                OR\n                p.user = :user'\n            );\n            $qb->setParameter('user', $this->security->getUser());\n        }\n\n        if ($criteria->moderated) {\n            $qb->andWhere(\n                'EXISTS (SELECT IDENTITY(mm.magazine) FROM '.Moderator::class.' mm WHERE mm.user = :user AND mm.magazine = p.magazine)'\n            );\n            $qb->setParameter('user', $this->security->getUser());\n        }\n\n        if ($criteria->favourite) {\n            $qb->andWhere(\n                'EXISTS (SELECT IDENTITY(pf.post) FROM '.PostFavourite::class.' pf WHERE pf.user = :user AND pf.post = p)'\n            );\n            $qb->setParameter('user', $this->security->getUser());\n        }\n\n        if ($criteria->languages) {\n            $qb->andWhere('p.lang IN (:languages)')\n                ->setParameter('languages', $criteria->languages, ArrayParameterType::STRING);\n        }\n\n        if ($user && (!$criteria->magazine || !$criteria->magazine->userIsModerator($user)) && !$criteria->moderated) {\n            $qb->andWhere(\n                'NOT EXISTS (SELECT IDENTITY(ub.blocked) FROM '.UserBlock::class.' ub WHERE ub.blocker = :blocker AND ub.blocked = p.user)'\n            );\n            $qb->setParameter('blocker', $user);\n\n            $qb->andWhere(\n                'NOT EXISTS (SELECT IDENTITY(mb.magazine) FROM '.MagazineBlock::class.' mb WHERE mb.user = :magazineBlocker AND mb.magazine = p.magazine)'\n            );\n            $qb->setParameter('magazineBlocker', $user);\n        }\n\n        if (!$user || $user->hideAdult) {\n            $qb->andWhere('m.isAdult = :isAdult')\n                ->andWhere('p.isAdult = :isAdult')\n                ->setParameter('isAdult', false);\n        }\n\n        switch ($criteria->sortOption) {\n            case Criteria::SORT_HOT:\n                $qb->addOrderBy('p.ranking', 'DESC');\n                break;\n            case Criteria::SORT_TOP:\n                $qb->addOrderBy('p.score', 'DESC');\n                break;\n            case Criteria::SORT_COMMENTED:\n                $qb->addOrderBy('p.commentCount', 'DESC');\n                break;\n            case Criteria::SORT_ACTIVE:\n                $qb->addOrderBy('p.lastActive', 'DESC');\n                break;\n            default:\n        }\n\n        $qb->addOrderBy('p.createdAt', Criteria::SORT_OLD === $criteria->sortOption ? 'ASC' : 'DESC');\n        $qb->addOrderBy('p.id', 'DESC');\n\n        return $qb;\n    }\n\n    public function hydrate(Post ...$posts): void\n    {\n        $this->getEntityManager()->createQueryBuilder()\n            ->select('PARTIAL p.{id}')\n            ->addSelect('u')\n            ->addSelect('ua')\n            ->addSelect('m')\n            ->addSelect('i')\n            ->from(Post::class, 'p')\n            ->join('p.user', 'u')\n            ->join('p.magazine', 'm')\n            ->leftJoin('u.avatar', 'ua')\n            ->leftJoin('p.image', 'i')\n            ->where('p IN (?1)')\n            ->setParameter(1, $posts)\n            ->getQuery()\n            ->getResult();\n\n        /* we don't need to hydrate all the votes and favourites. We only use the count saved in the post entity\n        if ($this->security->getUser()) {\n            $this->_em->createQueryBuilder()\n                ->select('PARTIAL p.{id}')\n                ->addSelect('pv')\n                ->addSelect('pf')\n                ->from(Post::class, 'p')\n                ->leftJoin('p.votes', 'pv')\n                ->leftJoin('p.favourites', 'pf')\n                ->where('p IN (?1)')\n                ->setParameter(1, $posts)\n                ->getQuery()\n                ->getResult();\n        }\n        */\n    }\n\n    public function countPostsByMagazine(Magazine $magazine)\n    {\n        return \\intval(\n            $this->createQueryBuilder('p')\n                ->select('count(p.id)')\n                ->where('p.magazine = :magazine')\n                ->andWhere('p.visibility = :visibility')\n                ->setParameter('magazine', $magazine)\n                ->setParameter('visibility', VisibilityInterface::VISIBILITY_VISIBLE)\n                ->getQuery()\n                ->getSingleScalarResult()\n        );\n    }\n\n    public function countPostCommentsByMagazine(Magazine $magazine): int\n    {\n        return \\intval(\n            $this->createQueryBuilder('p')\n                ->select('sum(p.commentCount)')\n                ->where('p.magazine = :magazine')\n                ->setParameter('magazine', $magazine)\n                ->getQuery()\n                ->getSingleScalarResult()\n        );\n    }\n\n    public function findToDelete(User $user, int $limit): array\n    {\n        return $this->createQueryBuilder('p')\n            ->where('p.visibility != :visibility')\n            ->andWhere('p.user = :user')\n            ->setParameter('visibility', VisibilityInterface::VISIBILITY_SOFT_DELETED)\n            ->setParameter('user', $user)\n            ->orderBy('p.id', 'DESC')\n            ->setMaxResults($limit)\n            ->getQuery()\n            ->getResult();\n    }\n\n    public function findRelatedByTag(string $tag, ?int $limit = 1, ?User $user = null): array\n    {\n        $qb = $this->createQueryBuilder('p');\n\n        $qb = $qb\n            ->andWhere('p.visibility = :visibility')\n            ->andWhere('m.visibility = :visibility')\n            ->andWhere('u.visibility = :visibility')\n            ->andWhere('u.apDiscoverable = true')\n            ->andWhere('m.name != :name')\n            ->andWhere('p.isAdult = false')\n            ->andWhere('m.isAdult = false')\n            ->andWhere('h.tag = :name')\n            ->join('p.magazine', 'm')\n            ->join('p.user', 'u')\n            ->join('p.hashtags', 'hl')\n            ->join('hl.hashtag', 'h')\n            ->orderBy('p.createdAt', 'DESC')\n            ->setParameter('visibility', VisibilityInterface::VISIBILITY_VISIBLE)\n            ->setParameter('name', $tag)\n            ->setMaxResults($limit);\n\n        if (null !== $user) {\n            $qb->andWhere($qb->expr()->not($qb->expr()->exists($this->sqlHelpers->getBlockedMagazinesDql($user))))\n                ->andWhere($qb->expr()->not($qb->expr()->exists($this->sqlHelpers->getBlockedUsersDql($user))));\n            $qb->setParameter('user', $user);\n        }\n\n        return $qb->getQuery()\n            ->getResult();\n    }\n\n    public function findRelatedByMagazine(string $name, ?int $limit = 1, ?User $user = null): array\n    {\n        $qb = $this->createQueryBuilder('p');\n\n        $qb = $qb->where('m.name LIKE :name OR m.title LIKE :title')\n            ->andWhere('p.visibility = :visibility')\n            ->andWhere('m.visibility = :visibility')\n            ->andWhere('u.visibility = :visibility')\n            ->andWhere('u.apDiscoverable = true')\n            ->andWhere('p.isAdult = false')\n            ->andWhere('m.isAdult = false')\n            ->join('p.magazine', 'm')\n            ->join('p.user', 'u')\n            ->orderBy('p.createdAt', 'DESC')\n            ->setParameter('name', \"%{$name}%\")\n            ->setParameter('title', \"%{$name}%\")\n            ->setParameter('visibility', VisibilityInterface::VISIBILITY_VISIBLE)\n            ->setMaxResults($limit);\n\n        if (null !== $user) {\n            $qb->andWhere($qb->expr()->not($qb->expr()->exists($this->sqlHelpers->getBlockedMagazinesDql($user))))\n                ->andWhere($qb->expr()->not($qb->expr()->exists($this->sqlHelpers->getBlockedUsersDql($user))));\n            $qb->setParameter('user', $user);\n        }\n\n        return $qb->getQuery()\n            ->getResult();\n    }\n\n    public function findLast(int $limit = 1, ?User $user = null): array\n    {\n        $qb = $this->createQueryBuilder('p');\n\n        $qb = $qb->where('p.isAdult = false')\n            ->andWhere('p.visibility = :visibility')\n            ->andWhere('u.apDiscoverable = true')\n            ->andWhere('m.isAdult = false')\n            ->andWhere('m.apDiscoverable = true');\n        if ($this->settingsManager->get('MBIN_SIDEBAR_SECTIONS_RANDOM_LOCAL_ONLY')) {\n            $qb = $qb->andWhere('m.apId IS NULL');\n        }\n\n        if (null !== $user) {\n            $magazineBlocks = $this->sqlHelpers->getCachedUserMagazineBlocks($user);\n            if (\\sizeof($magazineBlocks) > 0) {\n                $qb->andWhere($qb->expr()->not($qb->expr()->in('m.id', $magazineBlocks)));\n            }\n            $userBlocks = $this->sqlHelpers->getCachedUserBlocks($user);\n            if (\\sizeof($userBlocks) > 0) {\n                $qb->andWhere($qb->expr()->not($qb->expr()->in('u.id', $userBlocks)));\n            }\n        }\n\n        return $qb->join('p.magazine', 'm')\n            ->join('p.user', 'u')\n            ->orderBy('p.createdAt', 'DESC')\n            ->setParameter('visibility', VisibilityInterface::VISIBILITY_VISIBLE)\n            ->setMaxResults($limit)\n            ->getQuery()\n            ->getResult();\n    }\n\n    public function findFederated()\n    {\n        return $this->createQueryBuilder('p')\n            ->andWhere('p.apId IS NOT NULL')\n            ->andWhere('p.visibility = :visibility')\n            ->orderBy('p.createdAt', 'DESC')\n            ->setParameter('visibility', VisibilityInterface::VISIBILITY_VISIBLE)\n            ->getQuery()\n            ->getResult();\n    }\n\n    public function findTaggedFederatedInRandomMagazine()\n    {\n        return $this->createQueryBuilder('p')\n            ->join('p.magazine', 'm')\n            ->andWhere('m.name = :magazine')\n            ->andWhere('p.apId IS NOT NULL')\n            ->andWhere('p.visibility = :visibility')\n            ->orderBy('p.createdAt', 'DESC')\n            ->setParameter('magazine', 'random')\n            ->setParameter('visibility', VisibilityInterface::VISIBILITY_VISIBLE)\n            ->getQuery()\n            ->getResult();\n    }\n\n    public function findUsers(Magazine $magazine, ?bool $federated = false): array\n    {\n        $qb = $this->createQueryBuilder('p')\n            ->select('u.id, COUNT(p.id) as count')\n            ->groupBy('u.id')\n            ->join('p.user', 'u')\n            ->join('p.magazine', 'm')\n            ->andWhere('p.magazine = :magazine')\n            ->andWhere('p.visibility = :visibility')\n            ->andWhere('u.about != :emptyString')\n            ->andWhere('u.isBanned = false');\n\n        if ($federated) {\n            $qb->andWhere('u.apId IS NOT NULL')\n                ->andWhere('u.apDiscoverable = true');\n        } else {\n            $qb->andWhere('u.apId IS NULL');\n        }\n\n        return $qb->orderBy('count', 'DESC')\n            ->setParameter('magazine', $magazine)\n            ->setParameter('visibility', VisibilityInterface::VISIBILITY_VISIBLE)\n            ->setParameter('emptyString', '')\n            ->setMaxResults(100)\n            ->getQuery()\n            ->getResult();\n    }\n\n    private function countAll(EntryPageView|Criteria $criteria): int\n    {\n        return $this->cache->get(\n            'posts_count_'.$criteria->magazine?->name,\n            function (ItemInterface $item) use ($criteria): int {\n                $item->expiresAfter(60);\n\n                if (!$criteria->magazine) {\n                    $query = $this->getEntityManager()->createQuery(\n                        'SELECT COUNT(p.id) FROM App\\Entity\\Post p WHERE p.visibility = :visibility'\n                    )\n                        ->setParameter('visibility', VisibilityInterface::VISIBILITY_VISIBLE);\n                } else {\n                    $query = $this->getEntityManager()->createQuery(\n                        'SELECT COUNT(p.id) FROM App\\Entity\\Post p WHERE p.visibility = :visibility AND p.magazine = :magazine'\n                    )\n                        ->setParameter('visibility', VisibilityInterface::VISIBILITY_VISIBLE)\n                        ->setParameter('magazine', $criteria->magazine);\n                }\n\n                try {\n                    return $query->getSingleScalarResult();\n                } catch (NoResultException $e) {\n                    return 0;\n                }\n            }\n        );\n    }\n}\n"
  },
  {
    "path": "src/Repository/ReportRepository.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Repository;\n\nuse App\\Entity\\Contracts\\ReportInterface;\nuse App\\Entity\\Entry;\nuse App\\Entity\\EntryComment;\nuse App\\Entity\\EntryCommentReport;\nuse App\\Entity\\EntryReport;\nuse App\\Entity\\Post;\nuse App\\Entity\\PostComment;\nuse App\\Entity\\PostCommentReport;\nuse App\\Entity\\PostReport;\nuse App\\Entity\\Report;\nuse App\\Entity\\User;\nuse Doctrine\\Bundle\\DoctrineBundle\\Repository\\ServiceEntityRepository;\nuse Doctrine\\Persistence\\ManagerRegistry;\nuse Pagerfanta\\Doctrine\\ORM\\QueryAdapter;\nuse Pagerfanta\\Exception\\NotValidCurrentPageException;\nuse Pagerfanta\\Pagerfanta;\nuse Pagerfanta\\PagerfantaInterface;\nuse Symfony\\Component\\HttpKernel\\Exception\\NotFoundHttpException;\n\n/**\n * @method Report|null find($id, $lockMode = null, $lockVersion = null)\n * @method Report|null findOneBy(array $criteria, array $orderBy = null)\n * @method Report[]    findAll()\n * @method Report[]    findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)\n */\nclass ReportRepository extends ServiceEntityRepository\n{\n    public const PER_PAGE = 20;\n\n    public function __construct(ManagerRegistry $registry)\n    {\n        parent::__construct($registry, Report::class);\n    }\n\n    public function findBySubject(ReportInterface $subject): ?Report\n    {\n        return match (true) {\n            $subject instanceof Entry => $this->findByEntry($subject),\n            $subject instanceof EntryComment => $this->findByEntryComment($subject),\n            $subject instanceof Post => $this->findByPost($subject),\n            $subject instanceof PostComment => $this->findByPostComment($subject),\n            default => throw new \\LogicException(),\n        };\n    }\n\n    private function findByEntry(Entry $entry): ?EntryReport\n    {\n        $dql = 'SELECT r FROM '.EntryReport::class.' r WHERE r.entry = :entry';\n\n        return $this->getEntityManager()->createQuery($dql)\n            ->setParameter('entry', $entry)\n            ->getOneOrNullResult();\n    }\n\n    private function findByEntryComment(EntryComment $comment): ?EntryCommentReport\n    {\n        $dql = 'SELECT r FROM '.EntryCommentReport::class.' r WHERE r.entryComment = :comment';\n\n        return $this->getEntityManager()->createQuery($dql)\n            ->setParameter('comment', $comment)\n            ->getOneOrNullResult();\n    }\n\n    private function findByPost(Post $post): ?PostReport\n    {\n        $dql = 'SELECT r FROM '.PostReport::class.' r WHERE r.post = :post';\n\n        return $this->getEntityManager()->createQuery($dql)\n            ->setParameter('post', $post)\n            ->getOneOrNullResult();\n    }\n\n    private function findByPostComment(PostComment $comment): ?PostCommentReport\n    {\n        $dql = 'SELECT r FROM '.PostCommentReport::class.' r WHERE r.postComment = :comment';\n\n        return $this->getEntityManager()->createQuery($dql)\n            ->setParameter('comment', $comment)\n            ->getOneOrNullResult();\n    }\n\n    public function findPendingBySubject(ReportInterface $subject): ?Report\n    {\n        return match (true) {\n            $subject instanceof Entry => $this->findPendingByEntry($subject),\n            $subject instanceof EntryComment => $this->findPendingByEntryComment($subject),\n            $subject instanceof Post => $this->findPendingByPost($subject),\n            $subject instanceof PostComment => $this->findPendingByPostComment($subject),\n            default => throw new \\LogicException(),\n        };\n    }\n\n    private function findPendingByEntry(Entry $entry): ?EntryReport\n    {\n        $dql = 'SELECT r FROM '.EntryReport::class.' r WHERE r.entry = :entry AND r.status = :status';\n\n        return $this->getEntityManager()->createQuery($dql)\n            ->setParameter('entry', $entry)\n            ->setParameter('status', Report::STATUS_PENDING)\n            ->getOneOrNullResult();\n    }\n\n    private function findPendingByEntryComment(EntryComment $comment): ?EntryCommentReport\n    {\n        $dql = 'SELECT r FROM '.EntryCommentReport::class.' r WHERE r.entryComment = :comment AND r.status = :status';\n\n        return $this->getEntityManager()->createQuery($dql)\n            ->setParameter('comment', $comment)\n            ->setParameter('status', Report::STATUS_PENDING)\n            ->getOneOrNullResult();\n    }\n\n    private function findPendingByPost(Post $post): ?PostReport\n    {\n        $dql = 'SELECT r FROM '.PostReport::class.' r WHERE r.post = :post AND r.status = :status';\n\n        return $this->getEntityManager()->createQuery($dql)\n            ->setParameter('post', $post)\n            ->setParameter('status', Report::STATUS_PENDING)\n            ->getOneOrNullResult();\n    }\n\n    private function findPendingByPostComment(PostComment $comment): ?PostCommentReport\n    {\n        $dql = 'SELECT r FROM '.PostCommentReport::class.' r WHERE r.postComment = :comment AND r.status = :status';\n\n        return $this->getEntityManager()->createQuery($dql)\n            ->setParameter('comment', $comment)\n            ->setParameter('status', Report::STATUS_PENDING)\n            ->getOneOrNullResult();\n    }\n\n    public function findAllPaginated(int $page = 1, string $status = Report::STATUS_PENDING): PagerfantaInterface\n    {\n        $dql = 'SELECT r FROM '.Report::class.' r';\n\n        if (Report::STATUS_ANY !== $status) {\n            $dql .= ' WHERE r.status = :status';\n        }\n\n        $dql .= \" ORDER BY CASE WHEN r.status = 'pending' THEN 1 ELSE 2 END, r.weight DESC, r.createdAt DESC\";\n\n        $query = $this->getEntityManager()->createQuery($dql);\n\n        if (Report::STATUS_ANY !== $status) {\n            $query->setParameter('status', $status);\n        }\n\n        $pagerfanta = new Pagerfanta(\n            new QueryAdapter($query)\n        );\n\n        try {\n            $pagerfanta->setMaxPerPage(self::PER_PAGE);\n            $pagerfanta->setCurrentPage($page);\n        } catch (NotValidCurrentPageException $e) {\n            throw new NotFoundHttpException();\n        }\n\n        return $pagerfanta;\n    }\n\n    public function findByUserPaginated(User $user, int $page = 1, string $status = Report::STATUS_PENDING): PagerfantaInterface\n    {\n        $qb = $this->createQueryBuilder('r')\n            ->where('r.reporting = :u')\n            ->setParameter('u', $user);\n\n        if (Report::STATUS_ANY !== $status) {\n            $qb->andWhere('r.status = :s')\n                ->setParameter('s', $status);\n        }\n\n        $qb->orderBy('r.createdAt');\n\n        $pagerfanta = new Pagerfanta(\n            new QueryAdapter($qb)\n        );\n\n        try {\n            $pagerfanta->setMaxPerPage(self::PER_PAGE);\n            $pagerfanta->setCurrentPage($page);\n        } catch (NotValidCurrentPageException $e) {\n            throw new NotFoundHttpException();\n        }\n\n        return $pagerfanta;\n    }\n}\n"
  },
  {
    "path": "src/Repository/ReputationRepository.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Repository;\n\nuse App\\Entity\\Site;\nuse App\\Entity\\User;\nuse App\\Pagination\\NativeQueryAdapter;\nuse App\\Service\\SettingsManager;\nuse App\\Utils\\DownvotesMode;\nuse Doctrine\\Bundle\\DoctrineBundle\\Repository\\ServiceEntityRepository;\nuse Doctrine\\DBAL\\ArrayParameterType;\nuse Doctrine\\DBAL\\Exception;\nuse Doctrine\\Persistence\\ManagerRegistry;\nuse Pagerfanta\\Exception\\NotValidCurrentPageException;\nuse Pagerfanta\\Pagerfanta;\nuse Pagerfanta\\PagerfantaInterface;\nuse Symfony\\Component\\HttpKernel\\Exception\\NotFoundHttpException;\nuse Symfony\\Contracts\\Cache\\CacheInterface;\n\nclass ReputationRepository extends ServiceEntityRepository\n{\n    public const TYPE_ENTRY = 'threads';\n    public const TYPE_ENTRY_COMMENT = 'comments';\n    public const TYPE_POST = 'posts';\n    public const TYPE_POST_COMMENT = 'replies';\n\n    public const PER_PAGE = 48;\n\n    public function __construct(\n        ManagerRegistry $registry,\n        private readonly SettingsManager $settingsManager,\n        private readonly CacheInterface $cache,\n    ) {\n        parent::__construct($registry, Site::class);\n    }\n\n    public function getUserReputation(User $user, string $className, int $page = 1): PagerfantaInterface\n    {\n        $table = $this->getEntityManager()->getClassMetadata($className)->getTableName();\n        $voteTable = $table.'_vote';\n        $idColumn = $table.'_id';\n\n        $sql = \"SELECT date_trunc('day', created_at) as day, sum(choice) as points FROM (\n            SELECT v.created_at, v.choice FROM $voteTable v WHERE v.author_id = :userId AND v.choice = -1 --downvotes\n            UNION ALL\n            SELECT v.created_at, 2 as choice FROM $voteTable v WHERE v.author_id = :userId AND v.choice = 1 --boosts -> 2x\n            UNION ALL\n            SELECT f.created_at, 1 as choice FROM favourite f INNER JOIN $table s ON f.$idColumn = s.id WHERE s.user_id = :userId --upvotes -> 1x\n        ) as interactions GROUP BY day ORDER BY day DESC\";\n\n        $adapter = new NativeQueryAdapter($this->getEntityManager()->getConnection(), $sql, ['userId' => $user->getId()], cache: $this->cache);\n        $pagerfanta = new Pagerfanta($adapter);\n\n        try {\n            $pagerfanta->setMaxPerPage(self::PER_PAGE);\n            $pagerfanta->setCurrentPage($page);\n        } catch (NotValidCurrentPageException) {\n            throw new NotFoundHttpException();\n        }\n\n        return $pagerfanta;\n    }\n\n    public function getUserReputationTotal(User $user): int\n    {\n        $conn = $this->getEntityManager()\n            ->getConnection();\n\n        if (DownvotesMode::Disabled === $this->settingsManager->getDownvotesMode()) {\n            $sql = 'SELECT\n                COALESCE((SELECT SUM((up_votes * 2) + favourite_count) FROM entry WHERE user_id = :user), 0) +\n                COALESCE((SELECT SUM((up_votes * 2) + favourite_count) FROM entry_comment WHERE user_id = :user), 0) +\n                COALESCE((SELECT SUM((up_votes * 2) + favourite_count) FROM post WHERE user_id = :user), 0) +\n                COALESCE((SELECT SUM((up_votes * 2) + favourite_count) FROM post_comment WHERE user_id = :user), 0) as total';\n        } else {\n            $sql = 'SELECT\n                COALESCE((SELECT SUM((up_votes * 2) - down_votes + favourite_count) FROM entry WHERE user_id = :user), 0) +\n                COALESCE((SELECT SUM((up_votes * 2) - down_votes + favourite_count) FROM entry_comment WHERE user_id = :user), 0) +\n                COALESCE((SELECT SUM((up_votes * 2) - down_votes + favourite_count) FROM post WHERE user_id = :user), 0) +\n                COALESCE((SELECT SUM((up_votes * 2) - down_votes + favourite_count) FROM post_comment WHERE user_id = :user), 0) as total';\n        }\n\n        $stmt = $conn->prepare($sql);\n        $stmt->bindValue('user', $user->getId());\n        $stmt = $stmt->executeQuery();\n\n        return $stmt->fetchAllAssociative()[0]['total'] ?? 0;\n    }\n\n    /**\n     * @return float[] the percentage of upvotes a user gives (0-100) indexed by the userId\n     *\n     * @throws Exception\n     */\n    public function getUserAttitudes(int ...$userIds): array\n    {\n        if (DownvotesMode::Disabled === $this->settingsManager->getDownvotesMode()) {\n            return array_map(fn () => 0, $userIds);\n        }\n        $conn = $this->getEntityManager()->getConnection();\n        $sql = 'SELECT sum(up_votes) as up_votes, sum(down_votes)as down_votes, user_id FROM (\n                (SELECT COUNT(*) as up_votes, 0 as down_votes, f.user_id as user_id FROM favourite f WHERE f.user_id IN (?) GROUP BY user_id)\n                UNION ALL\n                (SELECT COUNT(*) as up_votes, 0 as down_votes, v.user_id as user_id FROM entry_vote v WHERE v.user_id IN (?) AND v.choice = 1 GROUP BY user_id)\n                UNION ALL\n                (SELECT COUNT(*) as up_votes, 0 as down_votes, v.user_id as user_id FROM entry_comment_vote v WHERE v.user_id IN (?) AND v.choice = 1 GROUP BY user_id)\n                UNION ALL\n                (SELECT COUNT(*) as up_votes, 0 as down_votes, v.user_id as user_id FROM post_vote v WHERE v.user_id IN (?) AND v.choice = 1 GROUP BY user_id)\n                UNION ALL\n                (SELECT COUNT(*) as up_votes, 0 as down_votes, v.user_id as user_id FROM post_comment_vote v WHERE v.user_id IN (?) AND v.choice = 1 GROUP BY user_id)\n                UNION ALL\n                (SELECT 0 as up_votes, COUNT(*) as down_votes, v.user_id as user_id FROM entry_vote v WHERE v.user_id IN (?) AND v.choice = -1 GROUP BY user_id)\n                UNION ALL\n                (SELECT 0 as up_votes, COUNT(*) as down_votes, v.user_id as user_id FROM entry_comment_vote v WHERE v.user_id IN (?) AND v.choice = -1 GROUP BY user_id)\n                UNION ALL\n                (SELECT 0 as up_votes, COUNT(*) as down_votes, v.user_id as user_id FROM post_vote v WHERE v.user_id IN (?) AND v.choice = -1 GROUP BY user_id)\n                UNION ALL\n                (SELECT 0 as up_votes, COUNT(*) as down_votes, v.user_id as user_id FROM post_comment_vote v WHERE v.user_id IN (?) AND v.choice = -1 GROUP BY user_id)\n            ) as votes GROUP BY user_id\n        ';\n\n        // array parameter types are ass in SQL, so this is the nicest way to bind the userIds to this query\n        $rows = $conn->executeQuery(\n            $sql,\n            [$userIds, $userIds, $userIds, $userIds, $userIds, $userIds, $userIds, $userIds, $userIds],\n            [ArrayParameterType::INTEGER, ArrayParameterType::INTEGER, ArrayParameterType::INTEGER, ArrayParameterType::INTEGER, ArrayParameterType::INTEGER, ArrayParameterType::INTEGER, ArrayParameterType::INTEGER, ArrayParameterType::INTEGER, ArrayParameterType::INTEGER]\n        )\n            ->fetchAllAssociative();\n        $result = [];\n        foreach ($rows as $row) {\n            $upVotes = $row['up_votes'] ?? 0;\n            $downVotes = $row['down_votes'] ?? 0;\n            $votes = $upVotes + $downVotes;\n            if (0 === $votes) {\n                $result[$row['user_id']] = -1;\n                continue;\n            }\n            $result[$row['user_id']] = 100 / $votes * $upVotes;\n        }\n\n        return $result;\n    }\n}\n"
  },
  {
    "path": "src/Repository/ResetPasswordRequestRepository.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Repository;\n\nuse App\\Entity\\ResetPasswordRequest;\nuse Doctrine\\Bundle\\DoctrineBundle\\Repository\\ServiceEntityRepository;\nuse Doctrine\\Persistence\\ManagerRegistry;\nuse SymfonyCasts\\Bundle\\ResetPassword\\Model\\ResetPasswordRequestInterface;\nuse SymfonyCasts\\Bundle\\ResetPassword\\Persistence\\Repository\\ResetPasswordRequestRepositoryTrait;\nuse SymfonyCasts\\Bundle\\ResetPassword\\Persistence\\ResetPasswordRequestRepositoryInterface;\n\n/**\n * @method ResetPasswordRequest|null find($id, $lockMode = null, $lockVersion = null)\n * @method ResetPasswordRequest|null findOneBy(array $criteria, array $orderBy = null)\n * @method ResetPasswordRequest[]    findAll()\n * @method ResetPasswordRequest[]    findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)\n */\nclass ResetPasswordRequestRepository extends ServiceEntityRepository implements ResetPasswordRequestRepositoryInterface\n{\n    use ResetPasswordRequestRepositoryTrait;\n\n    public function __construct(\n        ManagerRegistry $registry,\n    ) {\n        parent::__construct($registry, ResetPasswordRequest::class);\n    }\n\n    public function add(ResetPasswordRequest $entity, bool $flush = true): void\n    {\n        $this->getEntityManager()->persist($entity);\n        if ($flush) {\n            $this->getEntityManager()->flush();\n        }\n    }\n\n    public function remove(ResetPasswordRequest $entity, bool $flush = true): void\n    {\n        $this->getEntityManager()->remove($entity);\n        if ($flush) {\n            $this->getEntityManager()->flush();\n        }\n    }\n\n    public function createResetPasswordRequest(\n        object $user,\n        \\DateTimeInterface $expiresAt,\n        string $selector,\n        string $hashedToken,\n    ): ResetPasswordRequestInterface {\n        return new ResetPasswordRequest($user, $expiresAt, $selector, $hashedToken);\n    }\n}\n"
  },
  {
    "path": "src/Repository/SearchRepository.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Repository;\n\nuse App\\Entity\\Contracts\\VisibilityInterface;\nuse App\\Entity\\Magazine;\nuse App\\Entity\\Moderator;\nuse App\\Entity\\User;\nuse App\\Pagination\\NativeQueryAdapter;\nuse App\\Pagination\\Transformation\\ContentPopulationTransformer;\nuse Doctrine\\DBAL\\Exception;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Pagerfanta\\Adapter\\AdapterInterface;\nuse Pagerfanta\\Pagerfanta;\nuse Pagerfanta\\PagerfantaInterface;\nuse Psr\\Log\\LoggerInterface;\nuse Symfony\\Bundle\\SecurityBundle\\Security;\nuse Symfony\\Contracts\\Cache\\CacheInterface;\n\nclass SearchRepository\n{\n    public const PER_PAGE = 25;\n\n    public function __construct(\n        private readonly EntityManagerInterface $entityManager,\n        private readonly ContentPopulationTransformer $transformer,\n        private readonly CacheInterface $cache,\n        private readonly LoggerInterface $logger,\n        private readonly Security $security,\n    ) {\n    }\n\n    public function countModerated(User $user): int\n    {\n        $dql =\n            'SELECT m FROM '.Magazine::class.' m WHERE m IN ('.\n            'SELECT IDENTITY(md.magazine) FROM '.Moderator::class.' md WHERE md.user = :user) ORDER BY m.apId DESC, m.lastActive DESC';\n\n        return \\count(\n            $this->entityManager->createQuery($dql)\n                ->setParameter('user', $user)\n                ->getResult()\n        );\n    }\n\n    public function countBoosts(User $user): int\n    {\n        $conn = $this->entityManager->getConnection();\n        $sql = \"SELECT COUNT(*) as cnt FROM (\n        SELECT entry_id as id, created_at, 'entry' AS type FROM entry_vote WHERE user_id = :userId AND choice = 1\n        UNION ALL\n        SELECT comment_id as id, created_at, 'entry_comment' AS type FROM entry_comment_vote WHERE user_id = :userId AND choice = 1\n        UNION ALL\n        SELECT post_id as id, created_at, 'post' AS type FROM post_vote WHERE user_id = :userId AND choice = 1\n        UNION ALL\n        SELECT comment_id as id, created_at, 'post_comment' AS type FROM post_comment_vote WHERE user_id = :userId AND choice = 1\n        ) sub\";\n\n        $stmt = $conn->prepare($sql);\n        $stmt->bindValue('userId', $user->getId());\n        $stmt = $stmt->executeQuery();\n\n        return $stmt->fetchAllAssociative()[0]['cnt'];\n    }\n\n    public function findBoosts(int $page, User $user): PagerfantaInterface\n    {\n        $conn = $this->entityManager->getConnection();\n        $sql = \"\n        SELECT entry_id as id, created_at, 'entry' AS type FROM entry_vote WHERE user_id = :userId AND choice = 1\n        UNION ALL\n        SELECT comment_id as id, created_at, 'entry_comment' AS type FROM entry_comment_vote WHERE user_id = :userId AND choice = 1\n        UNION ALL\n        SELECT post_id as id, created_at, 'post' AS type FROM post_vote WHERE user_id = :userId AND choice = 1\n        UNION ALL\n        SELECT comment_id as id, created_at, 'post_comment' AS type FROM post_comment_vote WHERE user_id = :userId AND choice = 1\n        ORDER BY created_at DESC\";\n\n        $pagerfanta = new Pagerfanta(new NativeQueryAdapter($conn, $sql, [\n            'userId' => $user->getId(),\n        ], transformer: $this->transformer));\n\n        $pagerfanta->setCurrentPage($page);\n\n        return $pagerfanta;\n    }\n\n    /**\n     * @throws Exception\n     */\n    private function getUserPublicActivityQueryAdapter(User $user, bool $hideAdult): AdapterInterface\n    {\n        $parameters = [\n            'userId' => $user->getId(),\n            'visibility' => VisibilityInterface::VISIBILITY_VISIBLE,\n        ];\n\n        $loggedInUser = $this->security->getUser();\n        $bodyWordsCond = '';\n        $titleWordsCond = '';\n        if ($loggedInUser instanceof User) {\n            $bodyWordsFilter = [];\n            $titleWordsFilter = [];\n\n            $i = 0;\n            foreach ($loggedInUser->getCurrentFilterLists() as $filterList) {\n                if (!$filterList->profile) {\n                    continue;\n                }\n                foreach ($filterList->words as $word) {\n                    if ($word['exactMatch']) {\n                        $titleWordsFilter[] = \"(title LIKE :word$i)\";\n                        $bodyWordsFilter[] = \"(body LIKE :word$i)\";\n                    } else {\n                        $titleWordsFilter[] = \"(title ILIKE :word$i)\";\n                        $bodyWordsFilter[] = \"(body ILIKE :word$i)\";\n                    }\n                    $parameters[\"word$i\"] = '%'.$word['word'].'%';\n                    ++$i;\n                }\n            }\n            if ($i > 0) {\n                $bodyWordsCond = 'AND (NOT ('.implode(' OR ', $bodyWordsFilter).') OR user_id = :loggedInUser)';\n                $titleWordsCond = 'AND (NOT ('.implode(' OR ', $titleWordsFilter).') OR user_id = :loggedInUser)';\n                $parameters['loggedInUser'] = $loggedInUser->getId();\n            }\n        }\n\n        $falseCond = $user->isDeleted ? ' AND FALSE ' : '';\n        $hideAdultCond = $hideAdult ? ' AND is_adult = false ' : '';\n        $sql = \"SELECT id, created_at, 'entry' AS type FROM entry\n            WHERE user_id = :userId AND visibility = :visibility $falseCond $hideAdultCond $titleWordsCond $bodyWordsCond\n        UNION ALL\n        SELECT id, created_at, 'entry_comment' AS type FROM entry_comment\n            WHERE user_id = :userId AND visibility = :visibility $falseCond $hideAdultCond $bodyWordsCond\n        UNION ALL\n        SELECT id, created_at, 'post' AS type FROM post\n            WHERE user_id = :userId AND visibility = :visibility $falseCond $hideAdultCond $bodyWordsCond\n        UNION ALL\n        SELECT id, created_at, 'post_comment' AS type FROM post_comment\n            WHERE user_id = :userId AND visibility = :visibility $falseCond $hideAdultCond $bodyWordsCond\n        ORDER BY created_at DESC\";\n\n        return new NativeQueryAdapter(\n            $this->entityManager->getConnection(),\n            $sql,\n            $parameters,\n            transformer: $this->transformer,\n            cache: $this->cache\n        );\n    }\n\n    /**\n     * @throws Exception\n     */\n    public function findUserPublicActivity(int $page, User $user, bool $hideAdult): PagerfantaInterface\n    {\n        $pagerfanta = new Pagerfanta($this->getUserPublicActivityQueryAdapter($user, $hideAdult));\n        $pagerfanta->setMaxPerPage(self::PER_PAGE);\n        $pagerfanta->setCurrentPage($page);\n\n        return $pagerfanta;\n    }\n\n    /**\n     * @param 'entry'|'post'|'magazine'|'user'|'users+magazines'|'entry+post'|null $specificType\n     */\n    public function search(\n        ?User $searchingUser,\n        string $query,\n        int $page = 1,\n        ?int $authorId = null,\n        ?int $magazineId = null,\n        ?string $specificType = null,\n        ?\\DateTimeImmutable $sinceDate = null,\n        int $perPage = SearchRepository::PER_PAGE,\n    ): PagerfantaInterface {\n        $authorWhere = null !== $authorId ? 'AND e.user_id = :authorId' : '';\n        $magazineWhere = null !== $magazineId ? 'AND e.magazine_id = :magazineId' : '';\n        $createdWhere = null !== $sinceDate ? 'AND e.created_at >= :since' : '';\n        $createdWhereMagazine = null !== $sinceDate ? 'AND m.created_at >= :since' : '';\n        $createdWhereUser = null !== $sinceDate ? 'AND u.created_at >= :since' : '';\n        $blockMagazineAndUserResult = null !== $authorId || null !== $magazineId ? 'AND false' : '';\n        $conn = $this->entityManager->getConnection();\n        $sqlEntry = \"SELECT e.id, e.created_at, e.visibility, 2 * ts_rank_cd(e.title_ts, plainto_tsquery(:query)) + ts_rank_cd(e.body_ts, plainto_tsquery(:query)) as rank, 'entry' AS type FROM entry e\n            INNER JOIN public.user u ON u.id = user_id\n            INNER JOIN magazine m ON e.magazine_id = m.id\n            WHERE (e.body_ts @@ plainto_tsquery( :query ) = true OR e.title_ts @@ plainto_tsquery( :query ) = true OR e.title LIKE :likeQuery)\n                AND e.visibility = :visibility\n                AND u.is_deleted = false\n                AND (u.ap_discoverable = true OR u.ap_discoverable IS NULL)\n                AND (m.ap_discoverable = true OR m.ap_discoverable IS NULL)\n                AND NOT EXISTS (SELECT id FROM user_block ub WHERE ub.blocked_id = u.id AND ub.blocker_id = :queryingUser)\n                AND NOT EXISTS (SELECT id FROM magazine_block mb WHERE mb.magazine_id = m.id AND mb.user_id = :queryingUser)\n                AND NOT EXISTS (SELECT hl.id FROM hashtag_link hl INNER JOIN hashtag h ON h.id = hl.hashtag_id AND h.banned = true WHERE hl.entry_id = e.id)\n                $authorWhere $magazineWhere $createdWhere\n        UNION ALL\n        SELECT e.id, e.created_at, e.visibility, 3 * ts_rank_cd(e.body_ts, plainto_tsquery(:query)) as rank, 'entry_comment' AS type FROM entry_comment e\n            INNER JOIN public.user u ON u.id = user_id\n            INNER JOIN magazine m ON e.magazine_id = m.id\n            WHERE (e.body_ts @@ plainto_tsquery( :query ) = true)\n                AND e.visibility = :visibility\n                AND u.is_deleted = false\n                AND (u.ap_discoverable = true OR u.ap_discoverable IS NULL)\n                AND (m.ap_discoverable = true OR m.ap_discoverable IS NULL)\n                AND NOT EXISTS (SELECT id FROM user_block ub WHERE ub.blocked_id = u.id AND ub.blocker_id = :queryingUser)\n                AND NOT EXISTS (SELECT id FROM magazine_block mb WHERE mb.magazine_id = m.id AND mb.user_id = :queryingUser)\n                AND NOT EXISTS (SELECT hl.id FROM hashtag_link hl INNER JOIN hashtag h ON h.id = hl.hashtag_id AND h.banned = true WHERE hl.entry_comment_id = e.id)\n                $authorWhere $magazineWhere $createdWhere\n        \";\n        $sqlPost = \"SELECT e.id, e.created_at, e.visibility, 3 * ts_rank_cd(e.body_ts, plainto_tsquery(:query)) as rank, 'post' AS type FROM post e\n            INNER JOIN public.user u ON u.id = user_id\n            INNER JOIN magazine m ON e.magazine_id = m.id\n            WHERE (e.body_ts @@ plainto_tsquery( :query ) = true)\n                AND e.visibility = :visibility\n                AND u.is_deleted = false\n                AND (u.ap_discoverable = true OR u.ap_discoverable IS NULL)\n                AND (m.ap_discoverable = true OR m.ap_discoverable IS NULL)\n                AND NOT EXISTS (SELECT id FROM user_block ub WHERE ub.blocked_id = u.id AND ub.blocker_id = :queryingUser)\n                AND NOT EXISTS (SELECT id FROM magazine_block mb WHERE mb.magazine_id = m.id AND mb.user_id = :queryingUser)\n                AND NOT EXISTS (SELECT hl.id FROM hashtag_link hl INNER JOIN hashtag h ON h.id = hl.hashtag_id AND h.banned = true WHERE hl.post_id = e.id)\n                $authorWhere $magazineWhere $createdWhere\n        UNION ALL\n        SELECT e.id, e.created_at, e.visibility, 3 * ts_rank_cd(e.body_ts, plainto_tsquery(:query)) as rank, 'post_comment' AS type FROM post_comment e\n            INNER JOIN public.user u ON u.id = user_id\n            INNER JOIN magazine m ON e.magazine_id = m.id\n            WHERE (e.body_ts @@ plainto_tsquery( :query ) = true)\n                AND e.visibility = :visibility\n                AND u.is_deleted = false\n                AND (u.ap_discoverable = true OR u.ap_discoverable IS NULL)\n                AND (m.ap_discoverable = true OR m.ap_discoverable IS NULL)\n                AND NOT EXISTS (SELECT id FROM user_block ub WHERE ub.blocked_id = u.id AND ub.blocker_id = :queryingUser)\n                AND NOT EXISTS (SELECT id FROM magazine_block mb WHERE mb.magazine_id = m.id AND mb.user_id = :queryingUser)\n                AND NOT EXISTS (SELECT hl.id FROM hashtag_link hl INNER JOIN hashtag h ON h.id = hl.hashtag_id AND h.banned = true WHERE hl.post_comment_id = e.id)\n                $authorWhere $magazineWhere $createdWhere\n        \";\n\n        $sqlMagazine = \"SELECT m.Id, m.created_at, m.visibility, ts_rank_cd(m.name_ts, plainto_tsquery(:query)) + ts_rank_cd(m.title_ts, plainto_tsquery(:query)) + ts_rank_cd(m.description_ts, plainto_tsquery(:query)) as rank, 'magazine' AS type FROM magazine m\n            WHERE (m.name_ts @@ plainto_tsquery( :query ) = true OR m.title_ts @@ plainto_tsquery( :query ) = true OR m.description_ts @@ plainto_tsquery( :query ) = true OR m.title LIKE :likeQuery)\n                AND m.visibility = :visibility\n                AND m.ap_deleted_at IS NULL\n                AND m.marked_for_deletion_at IS NULL\n                AND (m.ap_discoverable = true OR m.ap_discoverable IS NULL)\n                AND NOT EXISTS (SELECT id FROM magazine_block mb WHERE mb.magazine_id = m.id AND mb.user_id = :queryingUser)\n                $createdWhereMagazine $blockMagazineAndUserResult\n        \";\n\n        $sqlUser = \"SELECT u.Id, u.created_at, u.visibility, ts_rank_cd(u.username_ts, plainto_tsquery(:query)) + ts_rank_cd(u.title_ts, plainto_tsquery(:query)) + ts_rank_cd(u.about_ts, plainto_tsquery(:query)) as rank, 'user' AS type FROM \\\"user\\\" u\n            WHERE (u.username_ts @@ plainto_tsquery( :query ) = true OR u.title_ts @@ plainto_tsquery( :query ) = true OR u.about_ts @@ plainto_tsquery( :query ) = true OR u.username LIKE :likeQuery)\n                AND u.visibility = :visibility\n                AND u.is_deleted = false\n                AND u.marked_for_deletion_at IS NULL\n                AND u.ap_deleted_at IS NULL\n                AND (u.ap_discoverable = true OR u.ap_discoverable IS NULL)\n                AND NOT EXISTS (SELECT id FROM user_block ub WHERE ub.blocked_id = u.id AND ub.blocker_id = :queryingUser)\n                $createdWhereUser $blockMagazineAndUserResult\n        \";\n\n        if (null === $specificType) {\n            $sql = \"$sqlEntry UNION ALL $sqlPost UNION ALL $sqlMagazine UNION ALL $sqlUser ORDER BY rank DESC, created_at DESC\";\n        } else {\n            $sql = match ($specificType) {\n                'entry' => \"$sqlEntry ORDER BY rank DESC, created_at DESC\",\n                'post' => \"$sqlPost ORDER BY rank DESC, created_at DESC\",\n                'magazine' => \"$sqlMagazine ORDER BY rank DESC, created_at DESC\",\n                'user' => \"$sqlUser ORDER BY rank DESC, created_at DESC\",\n                'users+magazines' => \"$sqlMagazine UNION ALL $sqlUser ORDER BY rank DESC, created_at DESC\",\n                'entry+post' => \"$sqlEntry UNION ALL $sqlPost ORDER BY rank DESC, created_at DESC\",\n                default => throw new \\LogicException($specificType.' is not supported'),\n            };\n        }\n\n        $parameters = [\n            'query' => $query,\n            'likeQuery' => \"%$query%\",\n            'visibility' => VisibilityInterface::VISIBILITY_VISIBLE,\n            'queryingUser' => $searchingUser?->getId() ?? -1,\n        ];\n\n        $this->logger->debug('Search query: {sql}', ['sql' => $sql]);\n\n        if (null !== $authorId) {\n            $parameters['authorId'] = $authorId;\n        }\n\n        if (null !== $magazineId) {\n            $parameters['magazineId'] = $magazineId;\n        }\n\n        if (null !== $sinceDate) {\n            $parameters['since'] = $sinceDate;\n        }\n\n        $adapter = new NativeQueryAdapter($conn, $sql, $parameters, transformer: $this->transformer);\n\n        $pagerfanta = new Pagerfanta($adapter);\n        $pagerfanta->setMaxPerPage($perPage);\n        $pagerfanta->setCurrentPage($page);\n\n        return $pagerfanta;\n    }\n\n    public function findByApId($url): array\n    {\n        $conn = $this->entityManager->getConnection();\n        $sql = \"\n        SELECT id, created_at, 'entry' AS type FROM entry WHERE ap_id = :url\n        UNION ALL\n        SELECT id, created_at, 'entry_comment' AS type FROM entry_comment WHERE ap_id = :url\n        UNION ALL\n        SELECT id, created_at, 'post' AS type FROM post WHERE ap_id = :url\n        UNION ALL\n        SELECT id, created_at, 'post_comment' AS type FROM post_comment WHERE ap_id = :url\n        UNION ALL\n        SELECT id, created_at, 'user' AS type FROM \\\"user\\\" WHERE ap_profile_id = :url OR ap_public_url = :url\n        UNION ALL\n        SELECT id, created_at, 'magazine' AS type FROM magazine WHERE ap_profile_id = :url OR ap_public_url = :url\n        ORDER BY created_at DESC\n        \";\n\n        $pagerfanta = new Pagerfanta(new NativeQueryAdapter($conn, $sql, [\n            'url' => \"$url\",\n        ], transformer: $this->transformer));\n\n        return $pagerfanta->getCurrentPageResults();\n    }\n}\n"
  },
  {
    "path": "src/Repository/SettingsRepository.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Repository;\n\nuse App\\Entity\\Settings;\nuse Doctrine\\Bundle\\DoctrineBundle\\Repository\\ServiceEntityRepository;\nuse Doctrine\\Persistence\\ManagerRegistry;\n\n/**\n * @extends ServiceEntityRepository<Settings>\n *\n * @method Settings|null find($id, $lockMode = null, $lockVersion = null)\n * @method Settings|null findOneBy(array $criteria, array $orderBy = null)\n * @method Settings[]    findAll()\n * @method Settings[]    findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)\n */\nclass SettingsRepository extends ServiceEntityRepository\n{\n    public function __construct(ManagerRegistry $registry)\n    {\n        parent::__construct($registry, Settings::class);\n    }\n}\n"
  },
  {
    "path": "src/Repository/SiteRepository.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Repository;\n\nuse App\\Entity\\Site;\nuse Doctrine\\Bundle\\DoctrineBundle\\Repository\\ServiceEntityRepository;\nuse Doctrine\\Persistence\\ManagerRegistry;\n\n/**\n * @method Site|null find($id, $lockMode = null, $lockVersion = null)\n * @method Site|null findOneBy(array $criteria, array $orderBy = null)\n * @method Site|null findOneByName(string $name)\n * @method Site[]    findAll()\n * @method Site[]    findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)\n */\nclass SiteRepository extends ServiceEntityRepository\n{\n    public function __construct(ManagerRegistry $registry)\n    {\n        parent::__construct($registry, Site::class);\n    }\n}\n"
  },
  {
    "path": "src/Repository/StatsContentRepository.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Repository;\n\nuse App\\Entity\\Magazine;\nuse App\\Entity\\User;\nuse Doctrine\\ORM\\Query\\ResultSetMapping;\nuse Doctrine\\Persistence\\ManagerRegistry;\nuse JetBrains\\PhpStorm\\ArrayShape;\n\nclass StatsContentRepository extends StatsRepository\n{\n    public function __construct(\n        ManagerRegistry $registry,\n    ) {\n        parent::__construct($registry);\n    }\n\n    #[ArrayShape(['entries' => 'array', 'comments' => 'array', 'posts' => 'array', 'replies' => 'array'])]\n    public function getOverallStats(\n        ?User $user = null,\n        ?Magazine $magazine = null,\n        ?bool $onlyLocal = null,\n    ): array {\n        $this->user = $user;\n        $this->magazine = $magazine;\n        $this->onlyLocal = $onlyLocal;\n\n        $entries = $this->getMonthlyStats('entry');\n        $comments = $this->getMonthlyStats('entry_comment');\n        $posts = $this->getMonthlyStats('post');\n        $replies = $this->getMonthlyStats('post_comment');\n\n        return $this->prepareContentReturn($entries, $comments, $posts, $replies);\n    }\n\n    private function getMonthlyStats(string $table): array\n    {\n        $conn = $this->getEntityManager()\n            ->getConnection();\n\n        $onlyLocalWhere = $this->onlyLocal ? ' AND e.ap_id IS NULL' : '';\n        $userWhere = $this->user ? ' AND e.user_id = :userId ' : '';\n        $magazineWhere = $this->magazine ? ' AND e.magazine_id = :magazineId ' : '';\n        $sql = \"SELECT to_char(e.created_at,'Mon') as month, extract(year from e.created_at) as year, COUNT(e.id) as count FROM $table e\n            INNER JOIN public.user u ON u.id = user_id\n            WHERE u.is_deleted = false $onlyLocalWhere $userWhere $magazineWhere GROUP BY 1,2\";\n\n        $stmt = $conn->prepare($sql);\n        if ($this->user) {\n            $stmt->bindValue('userId', $this->user->getId());\n        } elseif ($this->magazine) {\n            $stmt->bindValue('magazineId', $this->magazine->getId());\n        }\n        $stmt = $stmt->executeQuery();\n\n        return array_map(fn ($val) => [\n            'month' => date_parse($val['month'])['month'],\n            'year' => (int) $val['year'],\n            'count' => (int) $val['count'],\n        ], $stmt->fetchAllAssociative());\n    }\n\n    #[ArrayShape(['entries' => 'array', 'comments' => 'array', 'posts' => 'array', 'replies' => 'array'])]\n    public function getStatsByTime(\\DateTime $start, ?User $user = null, ?Magazine $magazine = null, ?bool $onlyLocal = null): array\n    {\n        $this->start = $start;\n        $this->user = $user;\n        $this->magazine = $magazine;\n        $this->onlyLocal = $onlyLocal;\n\n        return [\n            'entries' => $this->prepareContentDaily($this->getDailyStats('entry')),\n            'comments' => $this->prepareContentDaily($this->getDailyStats('entry_comment')),\n            'posts' => $this->prepareContentDaily($this->getDailyStats('post')),\n            'replies' => $this->prepareContentDaily($this->getDailyStats('post_comment')),\n        ];\n    }\n\n    private function getDailyStats(string $table): array\n    {\n        $conn = $this->getEntityManager()\n            ->getConnection();\n\n        $onlyLocalWhere = $this->onlyLocal ? ' AND e.ap_id IS NULL' : '';\n        $userWhere = $this->user ? ' AND e.user_id = :userId ' : '';\n        $magazineWhere = $this->magazine ? ' AND e.magazine_id = :magazineId ' : '';\n        $sql = \"SELECT date_trunc('day', e.created_at) as day, COUNT(e.id) as count FROM $table e\n            INNER JOIN public.user u ON e.user_id = u.id\n            WHERE u.is_deleted = false AND e.created_at >= :startDate $userWhere $magazineWhere $onlyLocalWhere GROUP BY 1\";\n\n        $stmt = $conn->prepare($sql);\n        if ($this->user) {\n            $stmt->bindValue('userId', $this->user->getId());\n        } elseif ($this->magazine) {\n            $stmt->bindValue('magazineId', $this->magazine->getId());\n        }\n        $stmt->bindValue('startDate', $this->start->format('Y-m-d H:i:s'));\n        $stmt = $stmt->executeQuery();\n\n        $results = $stmt->fetchAllAssociative();\n\n        usort($results, fn ($a, $b): int => $a['day'] <=> $b['day']);\n\n        return $results;\n    }\n\n    public function getStats(?Magazine $magazine, string $interval, ?\\DateTimeImmutable $start, ?\\DateTimeImmutable $end, ?bool $onlyLocal): array\n    {\n        switch ($interval) {\n            case 'all':\n            case 'year':\n            case 'month':\n            case 'day':\n            case 'hour':\n                break;\n            default:\n                throw new \\LogicException('Invalid interval provided');\n        }\n        if (null !== $start && null === $end) {\n            $end = $start->modify('+1 '.$interval);\n        } elseif (null === $start && null !== $end) {\n            $start = $end->modify('-1 '.$interval);\n        }\n\n        return [\n            'entry' => $this->aggregateStats('entry', $start, $end, true !== $onlyLocal, $magazine),\n            'entry_comment' => $this->aggregateStats('entry_comment', $start, $end, true !== $onlyLocal, $magazine),\n            'post' => $this->aggregateStats('post', $start, $end, true !== $onlyLocal, $magazine),\n            'post_comment' => $this->aggregateStats('post_comment', $start, $end, true !== $onlyLocal, $magazine),\n        ];\n    }\n\n    public function aggregateStats(string $tableName, ?\\DateTimeImmutable $sinceDate, ?\\DateTimeImmutable $tilDate, bool $federated, ?Magazine $magazine): int\n    {\n        $tableName = match ($tableName) {\n            'entry' => 'entry',\n            'entry_comment' => 'entry_comment',\n            'post' => 'post',\n            'post_comment' => 'post_comment',\n            default => throw new \\InvalidArgumentException(\"$tableName is not a valid countable\"),\n        };\n\n        $federatedCond = false === $federated ? ' AND e.ap_id IS NULL ' : '';\n        $magazineCond = $magazine ? 'AND e.magazine_id = :magId' : '';\n        $sinceDateCond = $sinceDate ? 'AND e.created_at > :date' : '';\n        $tilDateCond = $tilDate ? 'AND e.created_at < :untilDate' : '';\n\n        $sql = \"SELECT COUNT(e.id) as count FROM $tableName e INNER JOIN public.user u ON e.user_id = u.id WHERE u.is_deleted = false $sinceDateCond $tilDateCond $federatedCond $magazineCond\";\n        $rsm = new ResultSetMapping();\n        $rsm->addScalarResult('count', 0);\n        $query = $this->getEntityManager()->createNativeQuery($sql, $rsm);\n\n        if (null !== $sinceDate) {\n            $query->setParameter(':date', $sinceDate);\n        }\n\n        if (null !== $tilDate) {\n            $query->setParameter(':untilDate', $tilDate);\n        }\n\n        if (null !== $magazine) {\n            $query->setParameter(':magId', $magazine->getId());\n        }\n        $res = $query->getScalarResult();\n\n        if (0 === \\sizeof($res) || 0 === \\sizeof($res[0])) {\n            return 0;\n        }\n\n        return $res[0][0];\n    }\n\n    public function countLocalPosts(): int\n    {\n        $entries = $this->aggregateStats('entry', null, null, false, null);\n        $posts = $this->aggregateStats('post', null, null, false, null);\n\n        return $entries + $posts;\n    }\n\n    public function countLocalComments(): int\n    {\n        $entryComments = $this->aggregateStats('entry_comment', null, null, false, null);\n        $postComments = $this->aggregateStats('post_comment', null, null, false, null);\n\n        return $entryComments + $postComments;\n    }\n\n    public function countUsers(?\\DateTime $startDate = null): int\n    {\n        $users = $this->getEntityManager()->createQueryBuilder()\n            ->select('COUNT(u.id)')\n            ->from(User::class, 'u')\n            ->where('u.apId IS NULL')\n            ->andWhere('u.isDeleted = false')\n        ;\n\n        if ($startDate) {\n            $users->andWhere('u.lastActive >= :startDate')\n                ->setParameter('startDate', $startDate);\n        }\n\n        return $users->getQuery()\n            ->getSingleScalarResult();\n    }\n}\n"
  },
  {
    "path": "src/Repository/StatsRepository.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Repository;\n\nuse App\\Entity\\Magazine;\nuse App\\Entity\\Site;\nuse App\\Entity\\User;\nuse Doctrine\\Bundle\\DoctrineBundle\\Repository\\ServiceEntityRepository;\nuse Doctrine\\Persistence\\ManagerRegistry;\n\nabstract class StatsRepository extends ServiceEntityRepository\n{\n    public const TYPE_GENERAL = 'general';\n    public const TYPE_CONTENT = 'content';\n    public const TYPE_VOTES = 'votes';\n\n    protected ?\\DateTime $start;\n    protected ?User $user;\n    protected ?Magazine $magazine;\n    protected ?bool $onlyLocal;\n\n    public function __construct(ManagerRegistry $registry)\n    {\n        parent::__construct($registry, Site::class);\n    }\n\n    protected function sort(array $results): array\n    {\n        usort($results, fn ($a, $b): int => [$a['year'], $a['month']]\n            <=>\n            [$b['year'], $b['month']]\n        );\n\n        return $results;\n    }\n\n    protected function prepareContentDaily(array $entries): array\n    {\n        $to = new \\DateTime();\n        $interval = \\DateInterval::createFromDateString('1 day');\n        $period = new \\DatePeriod($this->start, $interval, $to);\n\n        $results = [];\n        foreach ($period as $d) {\n            $existed = array_filter(\n                $entries,\n                fn ($entry) => (new \\DateTime($entry['day']))->format('Y-m-d') === $d->format('Y-m-d')\n            );\n\n            if (!empty($existed)) {\n                $existed = current($existed);\n                $existed['day'] = new \\DateTime($existed['day']);\n\n                $results[] = $existed;\n                continue;\n            }\n\n            $results[] = [\n                'day' => $d,\n                'count' => 0,\n            ];\n        }\n\n        return $results;\n    }\n\n    protected function prepareContentOverall(array $entries, int $startYear, int $startMonth): array\n    {\n        $currentMonth = (int) (new \\DateTime('now'))->format('n');\n        $currentYear = (int) (new \\DateTime('now'))->format('Y');\n\n        $results = [];\n        for ($y = $startYear; $y <= $currentYear; ++$y) {\n            for ($m = 1; $m <= 12; ++$m) {\n                if ($y === $currentYear && $m > $currentMonth) {\n                    break;\n                }\n\n                if ($y === $startYear && $m < $startMonth) {\n                    continue;\n                }\n\n                $existed = array_filter($entries, fn ($entry) => $entry['month'] === $m && (int) $entry['year'] === $y);\n\n                if (!empty($existed)) {\n                    $results[] = current($existed);\n                    continue;\n                }\n\n                $results[] = [\n                    'month' => $m,\n                    'year' => $y,\n                    'count' => 0,\n                ];\n            }\n        }\n\n        return $results;\n    }\n\n    protected function getStartDate(array $values): array\n    {\n        return array_map(fn ($val) => ['year' => $val['year'], 'month' => $val['month']], $values);\n    }\n\n    protected function prepareContentReturn(array $entries, array $comments, array $posts, array $replies): array\n    {\n        $startDate = $this->sort(\n            array_merge(\n                $this->getStartDate($entries),\n                $this->getStartDate($comments),\n                $this->getStartDate($posts),\n                $this->getStartDate($replies)\n            )\n        );\n\n        if (empty($startDate) || !\\array_key_exists('year', $startDate[0]) || !\\array_key_exists('month', $startDate[0])) {\n            return [\n                'entries' => [],\n                'comments' => [],\n                'posts' => [],\n                'replies' => [],\n            ];\n        }\n\n        return [\n            'entries' => $this->prepareContentOverall(\n                $this->sort($entries),\n                $startDate[0]['year'],\n                $startDate[0]['month']\n            ),\n            'comments' => $this->prepareContentOverall(\n                $this->sort($comments),\n                $startDate[0]['year'],\n                $startDate[0]['month']\n            ),\n            'posts' => $this->prepareContentOverall($this->sort($posts), $startDate[0]['year'], $startDate[0]['month']),\n            'replies' => $this->prepareContentOverall(\n                $this->sort($replies),\n                $startDate[0]['year'],\n                $startDate[0]['month']\n            ),\n        ];\n    }\n}\n"
  },
  {
    "path": "src/Repository/StatsVotesRepository.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Repository;\n\nuse App\\Entity\\Magazine;\nuse App\\Entity\\User;\nuse App\\Service\\SettingsManager;\nuse App\\Utils\\DownvotesMode;\nuse Doctrine\\DBAL\\ParameterType;\nuse Doctrine\\Persistence\\ManagerRegistry;\nuse JetBrains\\PhpStorm\\ArrayShape;\n\nclass StatsVotesRepository extends StatsRepository\n{\n    public function __construct(\n        private readonly SettingsManager $settingsManager,\n        ManagerRegistry $registry,\n    ) {\n        parent::__construct($registry);\n    }\n\n    #[ArrayShape(['entries' => 'array', 'comments' => 'array', 'posts' => 'array', 'replies' => 'array'])]\n    public function getOverallStats(\n        ?User $user = null,\n        ?Magazine $magazine = null,\n        ?bool $onlyLocal = null,\n    ): array {\n        $this->user = $user;\n        $this->magazine = $magazine;\n        $this->onlyLocal = $onlyLocal;\n\n        $entries = $this->getMonthlyStats('entry_vote', 'entry_id');\n        $comments = $this->getMonthlyStats('entry_comment_vote', 'comment_id');\n        $posts = $this->getMonthlyStats('post_vote', 'post_id');\n        $replies = $this->getMonthlyStats('post_comment_vote', 'comment_id');\n\n        return $this->prepareContentReturn($entries, $comments, $posts, $replies);\n    }\n\n    #[ArrayShape([[\n        'month' => 'string',\n        'year' => 'string',\n        'up' => 'int',\n        'down' => 'int',\n        'boost' => 'int',\n    ]])]\n    private function getMonthlyStats(string $table, ?string $relation = null): array\n    {\n        $votes = $this->getMonthlyVoteStats($table, $relation);\n        $favourites = $this->getMonthlyFavouriteStats($table);\n        $dateMap = [];\n        for ($i = 0; $i < \\count($votes); ++$i) {\n            $key = $votes[$i]['year'].'-'.$votes[$i]['month'];\n            $dateMap[$key] = $i;\n            $votes[$i]['up'] = 0;\n        }\n\n        foreach ($favourites as $favourite) {\n            $key = $favourite['year'].'-'.$favourite['month'];\n            if (\\array_key_exists($key, $dateMap)) {\n                $i = $dateMap[$key];\n                $votes[$i]['up'] = $favourite['up'];\n            } else {\n                $votes[] = [\n                    'year' => $favourite['year'],\n                    'month' => $favourite['month'],\n                    'up' => $favourite['up'],\n                    'boost' => 0,\n                    'down' => 0,\n                ];\n            }\n        }\n\n        return array_map(fn ($val) => [\n            'month' => date_parse($val['month'])['month'],\n            'year' => (int) $val['year'],\n            'up' => (int) $val['up'],\n            'down' => (int) $val['down'],\n            'boost' => (int) $val['boost'],\n        ], $votes);\n    }\n\n    #[ArrayShape([[\n        'month' => 'string',\n        'year' => 'string',\n        'boost' => 'int',\n        'down' => 'int',\n    ]])]\n    private function getMonthlyVoteStats(string $table, string $relation): array\n    {\n        $conn = $this->getEntityManager()->getConnection();\n\n        $onlyLocalWhere = $this->onlyLocal ? 'AND u.ap_id IS NULL' : '';\n        $userWhere = $this->user ? ' AND e.user_id = :userId ' : '';\n        $magazineJoin = $this->magazine ? 'INNER JOIN '.str_replace('_vote', '', $table).' AS parent ON '.$relation.' = parent.id AND parent.magazine_id = :magazineId' : '';\n        $sql = \"SELECT to_char(e.created_at,'Mon') as month, extract(year from e.created_at) as year,\n            COUNT(case e.choice when 1 then 1 else null end) as boost, COUNT(case e.choice when -1 then 1 else null end) as down FROM $table e\n            INNER JOIN public.user u ON u.id = e.user_id\n            $magazineJoin\n            WHERE u.is_deleted = false $onlyLocalWhere $userWhere GROUP BY 1,2\";\n\n        $stmt = $conn->prepare($sql);\n        if ($this->user) {\n            $stmt->bindValue('userId', $this->user->getId());\n        } elseif ($this->magazine) {\n            $stmt->bindValue('magazineId', $this->magazine->getId());\n        }\n\n        $results = $stmt->executeQuery()->fetchAllAssociative();\n        if (DownvotesMode::Disabled === $this->settingsManager->getDownvotesMode()) {\n            for ($i = 0; $i < \\count($results); ++$i) {\n                $results[$i]['down'] = 0;\n            }\n        }\n\n        return $results;\n    }\n\n    #[ArrayShape([[\n        'month' => 'string',\n        'year' => 'string',\n        'up' => 'int',\n    ]])]\n    private function getMonthlyFavouriteStats(string $table): array\n    {\n        $conn = $this->getEntityManager()->getConnection();\n\n        $onlyLocalWhere = $this->onlyLocal ? 'AND u.ap_id IS NULL' : '';\n        $userWhere = $this->user ? ' AND f.user_id = :userId ' : '';\n        $magazineWhere = $this->magazine ? 'AND f.magazine_id = :magazineId ' : '';\n        $idCol = str_replace('_vote', '', $table).'_id';\n        $sql = \"SELECT to_char(f.created_at,'Mon') as month, extract(year from f.created_at) as year, COUNT(f.id) as up FROM favourite f\n            INNER JOIN public.user u ON u.id = f.user_id\n            WHERE u.is_deleted = false AND f.$idCol IS NOT NULL $magazineWhere $onlyLocalWhere $userWhere GROUP BY 1,2\";\n\n        $stmt = $conn->prepare($sql);\n        if ($this->user) {\n            $stmt->bindValue('userId', $this->user->getId());\n        } elseif ($this->magazine) {\n            $stmt->bindValue('magazineId', $this->magazine->getId());\n        }\n\n        $results = $stmt->executeQuery()->fetchAllAssociative();\n        if (DownvotesMode::Disabled === $this->settingsManager->getDownvotesMode()) {\n            for ($i = 0; $i < \\count($results); ++$i) {\n                $results[$i]['down'] = 0;\n            }\n        }\n\n        return $results;\n    }\n\n    protected function prepareContentOverall(array $entries, int $startYear, int $startMonth): array\n    {\n        $currentMonth = (int) (new \\DateTime('now'))->format('n');\n        $currentYear = (int) (new \\DateTime('now'))->format('Y');\n\n        $results = [];\n        for ($y = $startYear; $y <= $currentYear; ++$y) {\n            for ($m = 1; $m <= 12; ++$m) {\n                if ($y === $currentYear && $m > $currentMonth) {\n                    break;\n                }\n\n                if ($y === $startYear && $m < $startMonth) {\n                    continue;\n                }\n\n                $existed = array_filter($entries, fn ($entry) => $entry['month'] === $m && (int) $entry['year'] === $y);\n\n                if (!empty($existed)) {\n                    $results[] = current($existed);\n                    if (DownvotesMode::Disabled === $this->settingsManager->getDownvotesMode()) {\n                        $results[0]['down'] = 0;\n                    }\n                    continue;\n                }\n\n                $results[] = [\n                    'month' => $m,\n                    'year' => $y,\n                    'up' => 0,\n                    'down' => 0,\n                    'boost' => 0,\n                ];\n            }\n        }\n\n        return $results;\n    }\n\n    #[ArrayShape(['entries' => 'array', 'comments' => 'array', 'posts' => 'array', 'replies' => 'array'])]\n    public function getStatsByTime(\\DateTime $start, ?User $user = null, ?Magazine $magazine = null, ?bool $onlyLocal = null): array\n    {\n        $this->start = $start;\n        $this->user = $user;\n        $this->magazine = $magazine;\n        $this->onlyLocal = $onlyLocal;\n\n        return [\n            'entries' => $this->prepareContentDaily($this->getDailyStats('entry_vote', 'entry_id')),\n            'comments' => $this->prepareContentDaily($this->getDailyStats('entry_comment_vote', 'comment_id')),\n            'posts' => $this->prepareContentDaily($this->getDailyStats('post_vote', 'post_id')),\n            'replies' => $this->prepareContentDaily($this->getDailyStats('post_comment_vote', 'comment_id')),\n        ];\n    }\n\n    #[ArrayShape([[\n        'day' => 'string',\n        'up' => 'int',\n        'down' => 'int',\n        'boost' => 'int',\n    ]])]\n    protected function prepareContentDaily(array $entries): array\n    {\n        $to = new \\DateTime();\n        $interval = \\DateInterval::createFromDateString('1 day');\n        $period = new \\DatePeriod($this->start, $interval, $to);\n\n        $results = [];\n        foreach ($period as $d) {\n            $existed = array_filter(\n                $entries,\n                fn ($entry) => (new \\DateTime($entry['day']))->format('Y-m-d') === $d->format('Y-m-d')\n            );\n\n            if (!empty($existed)) {\n                $existed = current($existed);\n                $existed['day'] = new \\DateTime($existed['day']);\n                if (DownvotesMode::Disabled === $this->settingsManager->getDownvotesMode()) {\n                    $existed['down'] = 0;\n                }\n\n                $results[] = $existed;\n                continue;\n            }\n\n            $results[] = [\n                'day' => $d,\n                'up' => 0,\n                'down' => 0,\n                'boost' => 0,\n            ];\n        }\n\n        return $results;\n    }\n\n    #[ArrayShape([[\n        'day' => 'string',\n        'up' => 'int',\n        'down' => 'int',\n        'boost' => 'int',\n    ]])]\n    private function getDailyStats(string $table, string $relation): array\n    {\n        $results = $this->getDailyVoteStats($table, $relation);\n        $dateMap = [];\n        for ($i = 0; $i < \\count($results); ++$i) {\n            $dateMap[$results[$i]['day']] = $i;\n            $results[$i]['up'] = 0;\n        }\n        $favourites = $this->getDailyFavouriteStats($table);\n\n        foreach ($favourites as $favourite) {\n            if (\\array_key_exists($favourite['day'], $dateMap)) {\n                $results[$dateMap[$favourite['day']]]['up'] = $favourite['up'];\n            } else {\n                $results[] = [\n                    'day' => $favourite['day'],\n                    'boost' => 0,\n                    'down' => 0,\n                    'up' => $favourite['up'],\n                ];\n            }\n        }\n\n        usort($results, fn ($a, $b): int => $a['day'] <=> $b['day']);\n\n        return $results;\n    }\n\n    #[ArrayShape([[\n        'day' => 'string',\n        'down' => 'int',\n        'boost' => 'int',\n    ]])]\n    private function getDailyVoteStats(string $table, string $relation): array\n    {\n        $conn = $this->getEntityManager()->getConnection();\n\n        $onlyLocalWhere = $this->onlyLocal ? ' AND u.ap_id IS NULL ' : '';\n        $userWhere = $this->user ? ' AND e.user_id = :userId ' : '';\n        $magazineJoin = $this->magazine ? 'INNER JOIN '.str_replace('_vote', '', $table).' AS parent ON '.$relation.' = parent.id AND parent.magazine_id = :magazineId' : '';\n        $sql = \"SELECT date_trunc('day', e.created_at) as day, COUNT(case e.choice when 1 then 1 else null end) as boost,\n            COUNT(case e.choice when -1 then 1 else null end) as down FROM $table e\n            INNER JOIN public.user u ON u.id = e.user_id\n            $magazineJoin\n            WHERE u.is_deleted = false AND e.created_at >= :startDate $userWhere $onlyLocalWhere\n            GROUP BY 1\";\n\n        $stmt = $conn->prepare($sql);\n        if ($this->user) {\n            $stmt->bindValue('userId', $this->user->getId());\n        }\n        if ($this->magazine) {\n            $stmt->bindValue('magazineId', $this->magazine->getId());\n        }\n        $stmt->bindValue('startDate', $this->start, 'datetime');\n        $stmt = $stmt->executeQuery();\n\n        return $stmt->fetchAllAssociative();\n    }\n\n    #[ArrayShape([[\n        'day' => 'string',\n        'up' => 'int',\n    ]])]\n    private function getDailyFavouriteStats(string $table): array\n    {\n        $conn = $this->getEntityManager()->getConnection();\n        $idCol = str_replace('_vote', '', $table).'_id';\n        $magazineWhere = $this->magazine ? ' AND f.magazine_id = :magazineId' : '';\n        $userWhere = $this->user ? ' AND f.user_id = :userId ' : '';\n        $onlyLocalWhere = $this->onlyLocal ? ' AND u.ap_id IS NULL ' : '';\n        $favSql = \"SELECT date_trunc('day', f.created_at) as day, COUNT(f.id) as up FROM favourite f\n            INNER JOIN public.user u ON f.user_id = u.id\n            WHERE u.is_deleted = false AND f.created_at >= :startDate AND f.$idCol IS NOT NULL $onlyLocalWhere $magazineWhere $userWhere\n            GROUP BY 1\";\n        $stmt = $conn->prepare($favSql);\n        if ($this->user) {\n            $stmt->bindValue('userId', $this->user->getId());\n        }\n        if ($this->magazine) {\n            $stmt->bindValue('magazineId', $this->magazine->getId());\n        }\n        $stmt->bindValue('startDate', $this->start, 'datetime');\n        $stmt = $stmt->executeQuery();\n\n        return $stmt->fetchAllAssociative();\n    }\n\n    public function getStats(?Magazine $magazine, string $intervalStr, ?\\DateTime $start, ?\\DateTime $end, ?bool $onlyLocal): array\n    {\n        $this->onlyLocal = $onlyLocal;\n        $interval = $intervalStr ?? 'month';\n        switch ($interval) {\n            case 'all':\n                return $this->aggregateTotalStats($magazine);\n            case 'year':\n            case 'month':\n            case 'day':\n            case 'hour':\n                break;\n            default:\n                throw new \\LogicException('Invalid interval provided');\n        }\n\n        $this->start = $start ?? new \\DateTime('-1 '.$interval);\n\n        return $this->aggregateStats($magazine, $interval, $end);\n    }\n\n    private function aggregateStats(?Magazine $magazine, string $interval, ?\\DateTime $end): array\n    {\n        if (null === $end) {\n            $end = new \\DateTime();\n        }\n\n        if ($end < $this->start) {\n            throw new \\LogicException('End date must be after start date!');\n        }\n\n        $results = [];\n\n        foreach (['entry', 'entry_comment', 'post', 'post_comment'] as $table) {\n            $results[$table] = $this->aggregateVoteStats($table, $magazine, $interval, $end);\n            $datemap = [];\n            for ($i = 0; $i < \\count($results[$table]); ++$i) {\n                $datemap[$results[$table][$i]['datetime']] = $i;\n                $results[$table][$i]['up'] = 0;\n                if (DownvotesMode::Disabled === $this->settingsManager->getDownvotesMode()) {\n                    $results[$table][$i]['down'] = 0;\n                }\n            }\n\n            $favourites = $this->aggregateFavouriteStats($table, $magazine, $interval, $end);\n            foreach ($favourites as $favourite) {\n                if (\\array_key_exists($favourite['datetime'], $datemap)) {\n                    $results[$table][$datemap[$favourite['datetime']]]['up'] = $favourite['up'];\n                } else {\n                    $results[$table][] = [\n                        'datetime' => $favourite['datetime'],\n                        'boost' => 0,\n                        'down' => 0,\n                        'up' => $favourite['up'],\n                    ];\n                }\n            }\n\n            usort($results[$table], fn ($a, $b): int => $a['datetime'] <=> $b['datetime']);\n        }\n\n        return $results;\n    }\n\n    private function aggregateVoteStats(string $table, ?Magazine $magazine, string $interval, \\DateTime $end): array\n    {\n        $conn = $this->getEntityManager()->getConnection();\n        $relation = false === strstr($table, '_comment') ? $table.'_id' : 'comment_id';\n        $voteTable = $table.'_vote';\n        $magazineJoinCond = $magazine ? ' AND parent.magazine_id = ? ' : '';\n        $onlyLocalWhere = $this->onlyLocal ? 'u.ap_id IS NULL ' : '';\n        $sql = \"SELECT date_trunc(?, e.created_at) as datetime, COUNT(case e.choice when 1 then 1 else null end) as boost, COUNT(case e.choice when -1 then 1 else null end) as down FROM $voteTable e\n                        INNER JOIN $table AS parent ON $relation = parent.id\n                        INNER JOIN public.user u ON e.user_id = u.id $magazineJoinCond\n                        WHERE u.is_deleted = false AND e.created_at BETWEEN ? AND ? $onlyLocalWhere GROUP BY 1 ORDER BY 1\";\n\n        $stmt = $conn->prepare($sql);\n        $index = 1;\n        $stmt->bindValue($index++, $interval);\n        if ($magazine) {\n            $stmt->bindValue($index++, $magazine->getId(), ParameterType::INTEGER);\n        }\n        $stmt->bindValue($index++, $this->start, 'datetime');\n        $stmt->bindValue($index++, $end, 'datetime');\n\n        return $stmt->executeQuery()->fetchAllAssociative();\n    }\n\n    private function aggregateFavouriteStats(string $table, ?Magazine $magazine, string $interval, \\DateTime $end): array\n    {\n        $conn = $this->getEntityManager()->getConnection();\n        $magazineWhere = $magazine ? ' AND e.magazine_id = ? ' : '';\n        $onlyLocalWhere = $this->onlyLocal ? 'u.ap_id IS NULL ' : '';\n        $idCol = $table.'_id';\n        $sql = \"SELECT date_trunc(?, e.created_at) as datetime, COUNT(e.id) as up FROM favourite e\n                INNER JOIN public.user u on e.user_id = u.id\n                WHERE u.is_deleted = false AND e.created_at BETWEEN ? AND ? AND e.$idCol IS NOT NULL $magazineWhere $onlyLocalWhere GROUP BY 1 ORDER BY 1\";\n\n        $stmt = $conn->prepare($sql);\n        $stmt->bindValue(1, $interval);\n        $stmt->bindValue(2, $this->start, 'datetime');\n        $stmt->bindValue(3, $end, 'datetime');\n        if ($magazine) {\n            $stmt->bindValue(4, $magazine->getId(), ParameterType::INTEGER);\n        }\n\n        return $stmt->executeQuery()->fetchAllAssociative();\n    }\n\n    private function aggregateTotalStats(?Magazine $magazine): array\n    {\n        $conn = $this->getEntityManager()->getConnection();\n\n        $results = [];\n\n        foreach (['entry', 'entry_comment', 'post', 'post_comment'] as $table) {\n            $relation = false === strstr($table, '_comment') ? $table.'_id' : 'comment_id';\n            $voteTable = $table.'_vote';\n            $magazineJoinCond = $magazine ? ' AND parent.magazine_id = ?' : '';\n            $onlyLocalWhere = $this->onlyLocal ? ' u.ap_id IS NULL ' : '';\n            $sql = \"SELECT COUNT(case e.choice when 1 then 1 else null end) as boost, COUNT(case e.choice when -1 then 1 else null end) as down FROM $voteTable e\n                INNER JOIN public.user u ON e.user_id = u.id\n                INNER JOIN $table AS parent ON $relation = parent.id $magazineJoinCond\n                WHERE u.is_deleted = false $onlyLocalWhere\";\n\n            $stmt = $conn->prepare($sql);\n            if ($magazine) {\n                $stmt->bindValue(1, $magazine->getId(), ParameterType::INTEGER);\n            }\n\n            $results[$table] = $stmt->executeQuery()->fetchAllAssociative();\n\n            $magazineWhere = $magazine ? ' AND e.magazine_id = ?' : '';\n            $idCol = $table.'_id';\n            $sql = \"SELECT COUNT(e.id) as up FROM favourite e\n                INNER JOIN public.user u on u.id = e.user_id\n                WHERE u.is_deleted = false $magazineWhere $onlyLocalWhere AND e.$idCol IS NOT NULL\";\n\n            $stmt = $conn->prepare($sql);\n            if ($magazine) {\n                $stmt->bindValue(1, $magazine->getId(), ParameterType::INTEGER);\n            }\n\n            $favourites = $stmt->executeQuery()->fetchAllAssociative();\n\n            if (0 < \\count($results[$table]) && 0 < \\count($favourites)) {\n                $results[$table][0]['up'] = $favourites[0]['up'];\n            } elseif (0 < \\count($favourites)) {\n                $results[$table][] = [\n                    'boost' => 0,\n                    'down' => 0,\n                    'up' => $favourites[0]['up'],\n                ];\n            } else {\n                $results[$table][] = [\n                    'boost' => 0,\n                    'down' => 0,\n                    'up' => 0,\n                ];\n            }\n            if (DownvotesMode::Disabled === $this->settingsManager->getDownvotesMode()) {\n                $results[$table][0]['down'] = 0;\n            }\n\n            usort($results[$table], fn ($a, $b): int => $a['datetime'] <=> $b['datetime']);\n        }\n\n        return $results;\n    }\n}\n"
  },
  {
    "path": "src/Repository/TagLinkRepository.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Repository;\n\nuse App\\Entity\\Entry;\nuse App\\Entity\\EntryComment;\nuse App\\Entity\\Hashtag;\nuse App\\Entity\\HashtagLink;\nuse App\\Entity\\Post;\nuse App\\Entity\\PostComment;\nuse Doctrine\\Bundle\\DoctrineBundle\\Repository\\ServiceEntityRepository;\nuse Doctrine\\Persistence\\ManagerRegistry;\n\n/**\n * @method HashtagLink|null find($id, $lockMode = null, $lockVersion = null)\n * @method HashtagLink|null findOneBy(array $criteria, array $orderBy = null)\n * @method HashtagLink[]    findAll()\n * @method HashtagLink[]    findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)\n */\nclass TagLinkRepository extends ServiceEntityRepository\n{\n    public function __construct(\n        ManagerRegistry $registry,\n    ) {\n        parent::__construct($registry, HashtagLink::class);\n    }\n\n    /**\n     * @return string[]\n     */\n    public function getTagsOfContent(Entry|EntryComment|Post|PostComment $content): array\n    {\n        if ($content instanceof Entry) {\n            return $this->getTagsOfEntry($content);\n        } elseif ($content instanceof EntryComment) {\n            return $this->getTagsOfEntryComment($content);\n        } elseif ($content instanceof Post) {\n            return $this->getTagsOfPost($content);\n        } elseif ($content instanceof PostComment) {\n            return $this->getTagsOfPostComment($content);\n        } else {\n            // this is unreachable because of the strict types\n            throw new \\LogicException('Cannot handle content of type '.\\get_class($content));\n        }\n    }\n\n    /**\n     * @return string[]\n     */\n    private function getTagsOfEntry(Entry $entry): array\n    {\n        $result = $this->findBy(['entry' => $entry]);\n\n        return array_map(fn ($row) => $row->hashtag->tag, $result);\n    }\n\n    public function removeTagOfEntry(Entry $entry, Hashtag $tag): void\n    {\n        $link = $this->findOneBy(['entry' => $entry, 'hashtag' => $tag]);\n        $this->getEntityManager()->remove($link);\n        $this->getEntityManager()->flush();\n    }\n\n    public function addTagToEntry(Entry $entry, Hashtag $tag): void\n    {\n        $link = new HashtagLink();\n        $link->entry = $entry;\n        $link->hashtag = $tag;\n        $this->getEntityManager()->persist($link);\n        $this->getEntityManager()->flush();\n    }\n\n    public function entryHasTag(Entry $entry, Hashtag $tag): bool\n    {\n        return null !== $this->findOneBy(['entry' => $entry, 'hashtag' => $tag]);\n    }\n\n    /**\n     * @return string[]\n     */\n    private function getTagsOfEntryComment(EntryComment $entryComment): array\n    {\n        $result = $this->findBy(['entryComment' => $entryComment]);\n\n        return array_map(fn ($row) => $row->hashtag->tag, $result);\n    }\n\n    public function removeTagOfEntryComment(EntryComment $entryComment, Hashtag $tag): void\n    {\n        $link = $this->findOneBy(['entryComment' => $entryComment, 'hashtag' => $tag]);\n        $this->getEntityManager()->remove($link);\n        $this->getEntityManager()->flush();\n    }\n\n    public function addTagToEntryComment(EntryComment $entryComment, Hashtag $tag): void\n    {\n        $link = new HashtagLink();\n        $link->entryComment = $entryComment;\n        $link->hashtag = $tag;\n        $this->getEntityManager()->persist($link);\n        $this->getEntityManager()->flush();\n    }\n\n    /**\n     * @return string[]\n     */\n    private function getTagsOfPost(Post $post): array\n    {\n        $result = $this->findBy(['post' => $post]);\n\n        return array_map(fn ($row) => $row->hashtag->tag, $result);\n    }\n\n    public function removeTagOfPost(Post $post, Hashtag $tag): void\n    {\n        $link = $this->findOneBy(['post' => $post, 'hashtag' => $tag]);\n        $this->getEntityManager()->remove($link);\n        $this->getEntityManager()->flush();\n    }\n\n    public function addTagToPost(Post $post, Hashtag $tag): void\n    {\n        $link = new HashtagLink();\n        $link->post = $post;\n        $link->hashtag = $tag;\n        $this->getEntityManager()->persist($link);\n        $this->getEntityManager()->flush();\n    }\n\n    /**\n     * @return string[]\n     */\n    private function getTagsOfPostComment(PostComment $postComment): array\n    {\n        $result = $this->findBy(['postComment' => $postComment]);\n\n        return array_map(fn ($row) => $row->hashtag->tag, $result);\n    }\n\n    public function removeTagOfPostComment(PostComment $postComment, Hashtag $tag): void\n    {\n        $link = $this->findOneBy(['postComment' => $postComment, 'hashtag' => $tag]);\n        $this->getEntityManager()->remove($link);\n        $this->getEntityManager()->flush();\n    }\n\n    public function addTagToPostComment(PostComment $postComment, Hashtag $tag): void\n    {\n        $link = new HashtagLink();\n        $link->postComment = $postComment;\n        $link->hashtag = $tag;\n        $this->getEntityManager()->persist($link);\n        $this->getEntityManager()->flush();\n    }\n}\n"
  },
  {
    "path": "src/Repository/TagRepository.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Repository;\n\nuse App\\Entity\\Contracts\\VisibilityInterface;\nuse App\\Entity\\Hashtag;\nuse App\\Pagination\\NativeQueryAdapter;\nuse App\\Pagination\\Transformation\\ContentPopulationTransformer;\nuse Doctrine\\Bundle\\DoctrineBundle\\Repository\\ServiceEntityRepository;\nuse Doctrine\\Persistence\\ManagerRegistry;\nuse JetBrains\\PhpStorm\\ArrayShape;\nuse Pagerfanta\\Exception\\NotValidCurrentPageException;\nuse Pagerfanta\\Pagerfanta;\nuse Pagerfanta\\PagerfantaInterface;\nuse Symfony\\Component\\HttpKernel\\Exception\\NotFoundHttpException;\n\n/**\n * @method Hashtag|null find($id, $lockMode = null, $lockVersion = null)\n * @method Hashtag|null findOneBy(array $criteria, array $orderBy = null)\n * @method Hashtag[]    findAll()\n * @method Hashtag[]    findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)\n */\nclass TagRepository extends ServiceEntityRepository\n{\n    public const PER_PAGE = 25;\n\n    public function __construct(\n        ManagerRegistry $registry,\n        private readonly TagLinkRepository $tagLinkRepository,\n        private readonly ContentPopulationTransformer $populationTransformer,\n    ) {\n        parent::__construct($registry, Hashtag::class);\n    }\n\n    public function findOverall(int $page, string $tag): PagerfantaInterface\n    {\n        $hashtag = $this->findBy(['tag' => $tag]);\n        $countAll = $this->tagLinkRepository->createQueryBuilder('link')\n            ->select('count(link.id)')\n            ->where('link.hashtag = :tag')\n            ->setParameter(':tag', $hashtag)\n            ->getQuery()\n            ->getSingleScalarResult();\n\n        $conn = $this->getEntityManager()->getConnection();\n        $sql = \"SELECT e.id, e.created_at, 'entry' AS type FROM entry e\n                INNER JOIN hashtag_link l ON e.id = l.entry_id\n                INNER JOIN hashtag h ON l.hashtag_id = h.id AND h.tag = :tag\n            WHERE visibility = :visibility\n        UNION ALL\n        SELECT ec.id, ec.created_at, 'entry_comment' AS type FROM entry_comment ec\n                INNER JOIN hashtag_link l ON ec.id = l.entry_comment_id\n                INNER JOIN hashtag h ON l.hashtag_id = h.id AND h.tag = :tag\n            WHERE visibility = :visibility\n        UNION ALL\n        SELECT p.id, p.created_at, 'post' AS type FROM post p\n                INNER JOIN hashtag_link l ON p.id = l.post_id\n                INNER JOIN hashtag h ON l.hashtag_id = h.id AND h.tag = :tag\n            WHERE visibility = :visibility\n        UNION ALL\n        SELECT pc.id, created_at, 'post_comment' AS type FROM post_comment pc\n                INNER JOIN hashtag_link l ON pc.id = l.post_comment_id\n                INNER JOIN hashtag h ON l.hashtag_id = h.id AND h.tag = :tag WHERE visibility = :visibility\n        ORDER BY created_at DESC\";\n\n        $adapter = new NativeQueryAdapter($conn, $sql, [\n            'tag' => $tag,\n            'visibility' => VisibilityInterface::VISIBILITY_VISIBLE,\n        ], $countAll, $this->populationTransformer);\n\n        $pagerfanta = new Pagerfanta($adapter);\n\n        try {\n            $pagerfanta->setMaxPerPage(self::PER_PAGE);\n            $pagerfanta->setCurrentPage($page);\n        } catch (NotValidCurrentPageException $e) {\n            throw new NotFoundHttpException();\n        }\n\n        return $pagerfanta;\n    }\n\n    public function create(string $tag): Hashtag\n    {\n        $entity = new Hashtag();\n        $entity->tag = $tag;\n        $this->getEntityManager()->persist($entity);\n        $this->getEntityManager()->flush();\n\n        return $entity;\n    }\n\n    #[ArrayShape([\n        'entry' => 'int',\n        'entry_comment' => 'int',\n        'post' => 'int',\n        'post_comment' => 'int',\n    ])]\n    public function getCounts(string $tag): ?array\n    {\n        $conn = $this->getEntityManager()->getConnection();\n        $stmt = $conn->prepare('SELECT COUNT(entry_id) as entry, COUNT(entry_comment_id) as entry_comment, COUNT(post_id) as post, COUNT(post_comment_id) as post_comment\n            FROM hashtag_link INNER JOIN public.hashtag h ON h.id = hashtag_link.hashtag_id AND h.tag = :tag GROUP BY h.tag');\n        $stmt->bindValue('tag', $tag);\n        $result = $stmt->executeQuery()->fetchAllAssociative();\n        if (1 === \\sizeof($result)) {\n            return $result[0];\n        }\n\n        return [\n            'entry' => 0,\n            'entry_comment' => 0,\n            'post' => 0,\n            'post_comment' => 0,\n        ];\n    }\n}\n"
  },
  {
    "path": "src/Repository/UserBlockRepository.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Repository;\n\nuse App\\Entity\\Settings;\nuse App\\Entity\\User;\nuse App\\Entity\\UserBlock;\nuse Doctrine\\Bundle\\DoctrineBundle\\Repository\\ServiceEntityRepository;\nuse Doctrine\\Persistence\\ManagerRegistry;\n\n/**\n * @extends ServiceEntityRepository<Settings>\n *\n * @method UserBlock|null find($id, $lockMode = null, $lockVersion = null)\n * @method UserBlock|null findOneBy(array $criteria, array $orderBy = null)\n * @method UserBlock[]    findAll()\n * @method UserBlock[]    findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)\n */\nclass UserBlockRepository extends ServiceEntityRepository\n{\n    public function __construct(ManagerRegistry $registry)\n    {\n        parent::__construct($registry, UserBlock::class);\n    }\n\n    public function findUserBlocksIds(User $user): array\n    {\n        return array_column(\n            $this->createQueryBuilder('ub')\n                ->select('ubu.id')\n                ->join('ub.blocked', 'ubu')\n                ->where('ub.blocker = :user')\n                ->setParameter('user', $user)\n                ->getQuery()\n                ->getResult(),\n            'id'\n        );\n    }\n}\n"
  },
  {
    "path": "src/Repository/UserFollowRepository.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Repository;\n\nuse App\\Entity\\Settings;\nuse App\\Entity\\User;\nuse App\\Entity\\UserFollow;\nuse Doctrine\\Bundle\\DoctrineBundle\\Repository\\ServiceEntityRepository;\nuse Doctrine\\Persistence\\ManagerRegistry;\n\n/**\n * @extends ServiceEntityRepository<Settings>\n *\n * @method UserFollow|null find($id, $lockMode = null, $lockVersion = null)\n * @method UserFollow|null findOneBy(array $criteria, array $orderBy = null)\n * @method UserFollow[]    findAll()\n * @method UserFollow[]    findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)\n */\nclass UserFollowRepository extends ServiceEntityRepository\n{\n    public function __construct(ManagerRegistry $registry)\n    {\n        parent::__construct($registry, UserFollow::class);\n    }\n\n    public function findUserFollowIds(User $user): array\n    {\n        return array_column(\n            $this->createQueryBuilder('uf')\n                ->select('ufu.id')\n                ->join('uf.following', 'ufu')\n                ->where('uf.follower = :user')\n                ->setParameter('user', $user)\n                ->getQuery()\n                ->getResult(),\n            'id'\n        );\n    }\n}\n"
  },
  {
    "path": "src/Repository/UserFollowRequestRepository.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Repository;\n\nuse App\\Entity\\Settings;\nuse App\\Entity\\UserFollowRequest;\nuse Doctrine\\Bundle\\DoctrineBundle\\Repository\\ServiceEntityRepository;\nuse Doctrine\\Persistence\\ManagerRegistry;\n\n/**\n * @extends ServiceEntityRepository<Settings>\n *\n * @method UserFollowRequest|null find($id, $lockMode = null, $lockVersion = null)\n * @method UserFollowRequest|null findOneBy(array $criteria, array $orderBy = null)\n * @method UserFollowRequest[]    findAll()\n * @method UserFollowRequest[]    findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)\n */\nclass UserFollowRequestRepository extends ServiceEntityRepository\n{\n    public function __construct(ManagerRegistry $registry)\n    {\n        parent::__construct($registry, UserFollowRequest::class);\n    }\n}\n"
  },
  {
    "path": "src/Repository/UserNoteRepository.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Repository;\n\nuse App\\Entity\\UserNote;\nuse Doctrine\\Bundle\\DoctrineBundle\\Repository\\ServiceEntityRepository;\nuse Doctrine\\Persistence\\ManagerRegistry;\n\n/**\n * @method UserNote|null find($id, $lockMode = null, $lockVersion = null)\n * @method UserNote|null findOneBy(array $criteria, array $orderBy = null)\n * @method UserNote[]    findAll()\n * @method UserNote[]    findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)\n */\nclass UserNoteRepository extends ServiceEntityRepository\n{\n    public function __construct(ManagerRegistry $registry)\n    {\n        parent::__construct($registry, UserNote::class);\n    }\n}\n"
  },
  {
    "path": "src/Repository/UserPushSubscriptionRepository.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Repository;\n\nuse App\\Entity\\UserPushSubscription;\nuse Doctrine\\Bundle\\DoctrineBundle\\Repository\\ServiceEntityRepository;\nuse Doctrine\\Persistence\\ManagerRegistry;\n\n/**\n * @method UserPushSubscription|null find($id, $lockMode = null, $lockVersion = null)\n * @method UserPushSubscription|null findOneBy(array $criteria, array $orderBy = null)\n * @method UserPushSubscription[]    findAll()\n * @method UserPushSubscription[]    findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)\n */\nclass UserPushSubscriptionRepository extends ServiceEntityRepository\n{\n    public function __construct(ManagerRegistry $registry)\n    {\n        parent::__construct($registry, UserPushSubscription::class);\n    }\n}\n"
  },
  {
    "path": "src/Repository/UserRepository.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Repository;\n\nuse App\\Entity\\Contracts\\VisibilityInterface;\nuse App\\Entity\\Magazine;\nuse App\\Entity\\User;\nuse App\\Entity\\UserFollow;\nuse App\\Enums\\EApplicationStatus;\nuse App\\Service\\SettingsManager;\nuse App\\Utils\\SqlHelpers;\nuse Doctrine\\Bundle\\DoctrineBundle\\Repository\\ServiceEntityRepository;\nuse Doctrine\\Common\\Collections\\Order;\nuse Doctrine\\DBAL\\Exception;\nuse Doctrine\\ORM\\Query\\Expr\\OrderBy;\nuse Doctrine\\ORM\\QueryBuilder;\nuse Doctrine\\Persistence\\ManagerRegistry;\nuse Pagerfanta\\Doctrine\\Collections\\CollectionAdapter;\nuse Pagerfanta\\Doctrine\\ORM\\QueryAdapter;\nuse Pagerfanta\\Exception\\NotValidCurrentPageException;\nuse Pagerfanta\\Pagerfanta;\nuse Pagerfanta\\PagerfantaInterface;\nuse Symfony\\Bridge\\Doctrine\\Security\\User\\UserLoaderInterface;\nuse Symfony\\Component\\HttpKernel\\Exception\\NotFoundHttpException;\nuse Symfony\\Component\\Security\\Core\\Exception\\UnsupportedUserException;\nuse Symfony\\Component\\Security\\Core\\User\\PasswordAuthenticatedUserInterface;\nuse Symfony\\Component\\Security\\Core\\User\\PasswordUpgraderInterface;\n\n/**\n * @method User|null find($id, $lockMode = null, $lockVersion = null)\n * @method User|null findOneBy(array $criteria, array $orderBy = null)\n * @method User[]    findAll()\n * @method User[]    findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)\n */\nclass UserRepository extends ServiceEntityRepository implements UserLoaderInterface, PasswordUpgraderInterface\n{\n    public const PER_PAGE = 48;\n    public const USERS_ALL = 'all';\n    public const USERS_LOCAL = 'local';\n    public const USERS_REMOTE = 'remote';\n    public const USERS_OPTIONS = [\n        self::USERS_ALL,\n        self::USERS_LOCAL,\n        self::USERS_REMOTE,\n    ];\n\n    public function __construct(\n        ManagerRegistry $registry,\n        private readonly SettingsManager $settingsManager,\n    ) {\n        parent::__construct($registry, User::class);\n    }\n\n    public function save(User $entity, bool $flush = false): void\n    {\n        $this->getEntityManager()->persist($entity);\n\n        if ($flush) {\n            $this->getEntityManager()->flush();\n        }\n    }\n\n    public function loadUserByUsername(string $username): ?User\n    {\n        return $this->loadUserByIdentifier($username);\n    }\n\n    public function loadUserByIdentifier($val): ?User\n    {\n        return $this->createQueryBuilder('u')\n            ->where('LOWER(u.username) = :email')\n            ->orWhere('LOWER(u.email) = :email')\n            ->setParameter('email', mb_strtolower($val))\n            ->getQuery()\n            ->getOneOrNullResult();\n    }\n\n    public function findFollowing(int $page, User $user, int $perPage = self::PER_PAGE): PagerfantaInterface\n    {\n        $pagerfanta = new Pagerfanta(\n            new CollectionAdapter(\n                $user->follows\n            )\n        );\n\n        try {\n            $pagerfanta->setMaxPerPage($perPage);\n            $pagerfanta->setCurrentPage($page);\n        } catch (NotValidCurrentPageException $e) {\n            throw new NotFoundHttpException();\n        }\n\n        return $pagerfanta;\n    }\n\n    public function findFollowers(int $page, User $user, int $perPage = self::PER_PAGE): PagerfantaInterface\n    {\n        $pagerfanta = new Pagerfanta(\n            new CollectionAdapter(\n                $user->followers\n            )\n        );\n\n        try {\n            $pagerfanta->setMaxPerPage($perPage);\n            $pagerfanta->setCurrentPage($page);\n        } catch (NotValidCurrentPageException $e) {\n            throw new NotFoundHttpException();\n        }\n\n        return $pagerfanta;\n    }\n\n    public function findAudience(User $user): array\n    {\n        $dql =\n            'SELECT COUNT(u.id), u.apInboxUrl FROM '.User::class.' u WHERE u IN ('.\n            'SELECT IDENTITY(us.follower) FROM '.UserFollow::class.' us WHERE us.following = :user)'.\n            'AND u.apId IS NOT NULL AND u.isBanned = false AND u.isDeleted = false AND u.apTimeoutAt IS NULL '.\n            'GROUP BY u.apInboxUrl';\n\n        $res = $this->getEntityManager()->createQuery($dql)\n            ->setParameter('user', $user)\n            ->getResult();\n\n        return array_map(fn ($item) => $item['apInboxUrl'], $res);\n    }\n\n    public function findBlockedUsers(int $page, User $user, int $perPage = self::PER_PAGE): PagerfantaInterface\n    {\n        $pagerfanta = new Pagerfanta(\n            new CollectionAdapter(\n                $user->blocks\n            )\n        );\n\n        try {\n            $pagerfanta->setMaxPerPage($perPage);\n            $pagerfanta->setCurrentPage($page);\n        } catch (NotValidCurrentPageException $e) {\n            throw new NotFoundHttpException();\n        }\n\n        return $pagerfanta;\n    }\n\n    public function findAllActivePaginated(int $page, bool $onlyLocal, ?string $searchTerm = null, ?OrderBy $orderBy = null): PagerfantaInterface\n    {\n        $builder = $this->createBasicQueryBuilder($onlyLocal, $searchTerm);\n\n        $builder\n            ->andWhere('u.visibility = :visibility')\n            ->andWhere('u.isDeleted = false')\n            ->andWhere('u.isBanned = false')\n            ->andWhere('u.applicationStatus = :status')\n            ->setParameter('status', EApplicationStatus::Approved->value)\n            ->setParameter('visibility', VisibilityInterface::VISIBILITY_VISIBLE);\n\n        return $this->executeBasicQueryBuilder($builder, $page, $orderBy);\n    }\n\n    public function findAllInactivePaginated(int $page, bool $onlyLocal = true, ?string $searchTerm = null, ?OrderBy $orderBy = null): PagerfantaInterface\n    {\n        $builder = $this->createBasicQueryBuilder($onlyLocal, $searchTerm, needToBeVerified: false);\n\n        $builder->andWhere('u.visibility = :visibility')\n            ->andWhere('u.isVerified = false')\n            ->andWhere('u.isDeleted = false')\n            ->andWhere('u.isBanned = false')\n            ->andWhere('u.applicationStatus = :status')\n            ->setParameter('status', EApplicationStatus::Approved->value)\n            ->setParameter('visibility', VisibilityInterface::VISIBILITY_VISIBLE);\n\n        return $this->executeBasicQueryBuilder($builder, $page, $orderBy);\n    }\n\n    public function findAllBannedPaginated(int $page, bool $onlyLocal = false, ?string $searchTerm = null, ?OrderBy $orderBy = null): PagerfantaInterface\n    {\n        $builder = $this->createBasicQueryBuilder($onlyLocal, $searchTerm);\n        $builder\n            ->andWhere('u.isBanned = true')\n            ->andWhere('u.isDeleted = false');\n\n        return $this->executeBasicQueryBuilder($builder, $page, $orderBy);\n    }\n\n    public function findAllSuspendedPaginated(int $page, bool $onlyLocal = false, ?string $searchTerm = null, ?OrderBy $orderBy = null): PagerfantaInterface\n    {\n        $builder = $this->createBasicQueryBuilder($onlyLocal, $searchTerm);\n        $builder\n            ->andWhere('u.visibility = :visibility')\n            ->andWhere('u.isDeleted = false')\n            ->setParameter('visibility', VisibilityInterface::VISIBILITY_TRASHED);\n\n        return $this->executeBasicQueryBuilder($builder, $page, $orderBy);\n    }\n\n    public function findForDeletionPaginated(int $page): PagerfantaInterface\n    {\n        $builder = $this->createBasicQueryBuilder(onlyLocal: true, searchTerm: null)\n            ->andWhere('u.visibility = :visibility')\n            ->setParameter('visibility', VisibilityInterface::VISIBILITY_SOFT_DELETED);\n\n        return $this->executeBasicQueryBuilder($builder, $page, new OrderBy('u.markedForDeletionAt', 'ASC'));\n    }\n\n    /**\n     * @param bool|null $needToBeVerified this is only relevant if $onlyLocal is true, requires the user to be verified\n     */\n    private function createBasicQueryBuilder(bool $onlyLocal, ?string $searchTerm, ?bool $needToBeVerified = true): QueryBuilder\n    {\n        $builder = $this->createQueryBuilder('u');\n        if ($onlyLocal) {\n            $builder->where('u.apId IS NULL');\n            if ($needToBeVerified) {\n                $builder->andWhere('u.isVerified = true');\n            }\n        } else {\n            $builder->where('u.apId IS NOT NULL');\n        }\n\n        if ($searchTerm) {\n            $builder\n                ->andWhere('lower(u.username) LIKE lower(:searchTerm) OR lower(u.email) LIKE lower(:searchTerm)')\n                ->setParameter('searchTerm', '%'.$searchTerm.'%');\n        }\n\n        return $builder;\n    }\n\n    private function executeBasicQueryBuilder(QueryBuilder $builder, int $page, ?OrderBy $orderBy = null): Pagerfanta\n    {\n        if (null === $orderBy) {\n            $orderBy = new OrderBy('u.createdAt', 'ASC');\n        }\n\n        $query = $builder\n            ->orderBy($orderBy)\n            ->getQuery();\n\n        $pagerfanta = new Pagerfanta(new QueryAdapter($query));\n\n        try {\n            $pagerfanta->setMaxPerPage(self::PER_PAGE);\n            $pagerfanta->setCurrentPage($page);\n        } catch (NotValidCurrentPageException $e) {\n            throw new NotFoundHttpException();\n        }\n\n        return $pagerfanta;\n    }\n\n    public function upgradePassword(PasswordAuthenticatedUserInterface $user, string $newHashedPassword): void\n    {\n        if (!$user instanceof User) {\n            throw new UnsupportedUserException(\\sprintf('Instances of \"%s\" are not supported.', $user::class));\n        }\n\n        $user->setPassword($newHashedPassword);\n\n        $this->getEntityManager()->persist($user);\n        $this->getEntityManager()->flush();\n    }\n\n    public function findOneByUsername(string $username): ?User\n    {\n        return $this->createQueryBuilder('u')\n            ->Where('LOWER(u.username) = LOWER(:username)')\n            ->andWhere('u.applicationStatus = :status')\n            ->setParameter('status', EApplicationStatus::Approved->value)\n            ->setParameter('username', $username)\n            ->getQuery()\n            ->getOneOrNullResult();\n    }\n\n    public function findByUsernames(array $users): array\n    {\n        return $this->createQueryBuilder('u')\n            ->where('u.username IN (?1)')\n            ->andWhere('u.applicationStatus = :status')\n            ->setParameter('status', EApplicationStatus::Approved->value)\n            ->setParameter(1, $users)\n            ->getQuery()\n            ->getResult();\n    }\n\n    public function findWithoutKeys(): array\n    {\n        return $this->createQueryBuilder('u')\n            ->where('u.privateKey IS NULL')\n            ->andWhere('u.apId IS NULL')\n            ->getQuery()\n            ->getResult();\n    }\n\n    /**\n     * @return User[]\n     */\n    public function findAllRemote(): array\n    {\n        return $this->createQueryBuilder('u')\n            ->where('u.apId IS NOT NULL')\n            ->getQuery()\n            ->getResult();\n    }\n\n    public function findRemoteForUpdate(): array\n    {\n        return $this->createQueryBuilder('u')\n            ->where('u.apId IS NOT NULL')\n            ->andWhere('u.apDomain IS NULL')\n            ->andWhere('u.apDeletedAt IS NULL')\n            ->andWhere('u.apTimeoutAt IS NULL')\n            ->addOrderBy('u.apFetchedAt', 'ASC')\n            ->setMaxResults(1000)\n            ->getQuery()\n            ->getResult();\n    }\n\n    private function findUsersQueryBuilder(string $group, ?bool $recentlyActive = true): QueryBuilder\n    {\n        $qb = $this->createQueryBuilder('u');\n\n        $qb->where('u.visibility = :visibility')\n            ->setParameter('visibility', VisibilityInterface::VISIBILITY_VISIBLE);\n\n        if ($recentlyActive) {\n            $qb->andWhere('u.lastActive >= :lastActive')\n                ->setParameter('lastActive', (new \\DateTime())->modify('-7 days'));\n        }\n\n        switch ($group) {\n            case self::USERS_LOCAL:\n                $qb->andWhere('u.apId IS NULL');\n                break;\n            case self::USERS_REMOTE:\n                $qb->andWhere('u.apId IS NOT NULL')\n                    ->andWhere('u.apDiscoverable = true');\n                break;\n        }\n\n        return $qb\n            ->andWhere('u.isDeleted = false')\n            ->andWhere('u.apDiscoverable = true')\n            ->andWhere('u.applicationStatus = :status')\n            ->setParameter('status', EApplicationStatus::Approved->value)\n            ->orderBy('u.lastActive', 'DESC');\n    }\n\n    public function findPaginated(int $page, bool $needsAbout, string $group = self::USERS_ALL, int $perPage = self::PER_PAGE, ?string $query = null): PagerfantaInterface\n    {\n        $query = $this->findQueryBuilder($group, $query, $needsAbout)->getQuery();\n\n        $pagerfanta = new Pagerfanta(\n            new QueryAdapter(\n                $query\n            )\n        );\n\n        try {\n            $pagerfanta->setMaxPerPage($perPage);\n            $pagerfanta->setCurrentPage($page);\n        } catch (NotValidCurrentPageException $e) {\n            throw new NotFoundHttpException();\n        }\n\n        return $pagerfanta;\n    }\n\n    private function findQueryBuilder(string $group, ?string $query, bool $needsAbout): QueryBuilder\n    {\n        $qb = $this->createQueryBuilder('u');\n\n        if ($needsAbout) {\n            $qb->andWhere('u.about != \\'\\'')\n                ->andWhere('u.about IS NOT NULL');\n        }\n\n        if (null !== $query) {\n            $qb->andWhere('u.username LIKE :query')\n                ->setParameter('query', '%'.$query.'%');\n        }\n\n        switch ($group) {\n            case self::USERS_LOCAL:\n                $qb->andWhere('u.apId IS NULL');\n                break;\n            case self::USERS_REMOTE:\n                $qb->andWhere('u.apId IS NOT NULL')\n                    ->andWhere('u.apDiscoverable = true');\n                break;\n        }\n\n        return $qb\n            ->andWhere('u.applicationStatus = :status')\n            ->setParameter('status', EApplicationStatus::Approved->value)\n            ->orderBy('u.lastActive', 'DESC');\n    }\n\n    public function findUsersForGroup(string $group = self::USERS_ALL, ?bool $recentlyActive = true): array\n    {\n        return $this->findUsersQueryBuilder($group, $recentlyActive)->setMaxResults(28)->getQuery()->getResult();\n    }\n\n    private function findBannedQueryBuilder(string $group): QueryBuilder\n    {\n        $qb = $this->createQueryBuilder('u')\n            ->andWhere('u.isBanned = true');\n\n        switch ($group) {\n            case self::USERS_LOCAL:\n                $qb->andWhere('u.apId IS NULL');\n                break;\n            case self::USERS_REMOTE:\n                $qb->andWhere('u.apId IS NOT NULL')\n                    ->andWhere('u.apDiscoverable = true');\n                break;\n        }\n\n        return $qb->orderBy('u.lastActive', 'DESC');\n    }\n\n    public function findBannedPaginated(\n        int $page,\n        string $group = self::USERS_ALL,\n        int $perPage = self::PER_PAGE,\n    ): PagerfantaInterface {\n        $query = $this->findBannedQueryBuilder($group)->getQuery();\n\n        $pagerfanta = new Pagerfanta(\n            new QueryAdapter(\n                $query\n            )\n        );\n\n        try {\n            $pagerfanta->setMaxPerPage($perPage);\n            $pagerfanta->setCurrentPage($page);\n        } catch (NotValidCurrentPageException $e) {\n            throw new NotFoundHttpException();\n        }\n\n        return $pagerfanta;\n    }\n\n    public function findAdmin(): User\n    {\n        // @todo orderBy lastActivity\n        $result = $this->createQueryBuilder('u')\n            ->andWhere(\"JSONB_CONTAINS(u.roles, '\\\"\".'ROLE_ADMIN'.\"\\\"') = true\")\n            ->andWhere('u.isDeleted = false')\n            ->andWhere('u.applicationStatus = :status')\n            ->setParameter('status', EApplicationStatus::Approved->value)\n            ->getQuery()\n            ->getResult();\n        if (0 === \\sizeof($result)) {\n            throw new \\Exception('the server must always have an active admin account');\n        }\n\n        return $result[0];\n    }\n\n    /**\n     * @return User[]\n     */\n    public function findAllAdmins(): array\n    {\n        return $this->createQueryBuilder('u')\n            ->andWhere(\"JSONB_CONTAINS(u.roles, '\\\"\".'ROLE_ADMIN'.\"\\\"') = true\")\n            ->andWhere('u.isDeleted = false')\n            ->andWhere('u.applicationStatus = :status')\n            ->setParameter('status', EApplicationStatus::Approved->value)\n            ->getQuery()\n            ->getResult();\n    }\n\n    /**\n     * @return User[]\n     */\n    public function findUsersSuggestions(string $query): array\n    {\n        $qb = $this->createQueryBuilder('u');\n\n        return $qb\n            ->andWhere($qb->expr()->like('u.username', ':query'))\n            ->orWhere($qb->expr()->like('u.email', ':query'))\n            ->andWhere('u.isBanned = false')\n            ->andWhere('u.isDeleted = false')\n            ->andWhere('u.applicationStatus = :status')\n            ->setParameter('query', \"{$query}%\")\n            ->setParameter('status', EApplicationStatus::Approved->value)\n            ->setMaxResults(5)\n            ->getQuery()\n            ->getResult();\n    }\n\n    public function findUsersForMagazine(Magazine $magazine, ?bool $federated = false, int $limit = 200, bool $limitTime = false, bool $requireAvatar = false): array\n    {\n        $output = $this->findForMagazineUsersByContentCount($magazine, $federated, $limit, $limitTime, $requireAvatar);\n        $userIds = array_map(fn ($item) => $item['user_id'], $output);\n\n        $qb = $this->createQueryBuilder('u', 'u.id');\n        $qb->andWhere($qb->expr()->in('u.id', $userIds));\n        $qb->setMaxResults($limit);\n\n        try {\n            $users = $qb->getQuery()->getResult(); // @todo\n        } catch (\\Exception $e) {\n            return [];\n        }\n\n        $res = [];\n        $i = 0;\n        foreach ($output as $item) {\n            if (isset($users[$item['user_id']])) {\n                $res[] = $users[$item['user_id']];\n                ++$i;\n            }\n            if ($i >= $limit) {\n                break;\n            }\n        }\n\n        return $res;\n    }\n\n    /**\n     * @return array<array{user_id: int, sum: ?int}>\n     *\n     * @throws Exception\n     * @throws \\DateMalformedStringException\n     */\n    public function findActiveUsers(?Magazine $magazine = null): array\n    {\n        if ($magazine) {\n            $results = $this->findUsersForMagazine($magazine, null, 35, $magazine->getContentCount() > 1000, true);\n        } else {\n            $qb = $this->createQueryBuilder('u')\n                ->andWhere('u.applicationStatus = :status')\n                ->andWhere('u.lastActive >= :lastActive')\n                ->andWhere('u.isBanned = false')\n                ->andWhere('u.isDeleted = false')\n                ->andWhere('u.visibility = :visibility')\n                ->andWhere('u.apDiscoverable = true')\n                ->andWhere('u.apDeletedAt IS NULL')\n                ->andWhere('u.apTimeoutAt IS NULL')\n                ->andWhere('u.avatar IS NOT NULL');\n            if ($this->settingsManager->get('MBIN_SIDEBAR_SECTIONS_USERS_LOCAL_ONLY')) {\n                $qb->andWhere('u.apId IS NULL');\n            }\n\n            $queryResult = $qb->join('u.avatar', 'a')\n                ->orderBy('u.lastActive', 'DESC')\n                ->setParameter('lastActive', (new \\DateTime())->modify('-7 days'))\n                ->setParameter('visibility', VisibilityInterface::VISIBILITY_VISIBLE)\n                ->setParameter('status', EApplicationStatus::Approved->value)\n                ->setMaxResults(35)\n                ->getQuery()\n                ->getResult();\n            $results = array_map(fn (User $user) => ['user_id' => $user->getId(), 'sum' => null], $queryResult);\n        }\n\n        shuffle($results);\n\n        return \\array_slice($results, 0, 12);\n    }\n\n    public function findByProfileIds(array $arr): array\n    {\n        return $this->createQueryBuilder('u')\n            ->andWhere('u.apProfileId IN (:arr)')\n            ->setParameter('arr', $arr)\n            ->getQuery()\n            ->getResult();\n    }\n\n    public function findModerators(int $page = 1): PagerfantaInterface\n    {\n        $query = $this->createQueryBuilder('u')\n            ->where(\"JSONB_CONTAINS(u.roles, '\\\"\".'ROLE_MODERATOR'.\"\\\"') = true\")\n            ->andWhere('u.visibility = :visibility')\n            ->setParameter('visibility', VisibilityInterface::VISIBILITY_VISIBLE);\n\n        $pagerfanta = new Pagerfanta(\n            new QueryAdapter(\n                $query\n            )\n        );\n\n        try {\n            $pagerfanta->setMaxPerPage(self::PER_PAGE);\n            $pagerfanta->setCurrentPage($page);\n        } catch (NotValidCurrentPageException $e) {\n            throw new NotFoundHttpException();\n        }\n\n        return $pagerfanta;\n    }\n\n    /**\n     * @return User[]\n     */\n    public function findAllModerators(): array\n    {\n        return $this->createQueryBuilder('u')\n            ->where(\"JSONB_CONTAINS(u.roles, '\\\"\".'ROLE_MODERATOR'.\"\\\"') = true\")\n            ->andWhere('u.visibility = :visibility')\n            ->setParameter('visibility', VisibilityInterface::VISIBILITY_VISIBLE)\n            ->getQuery()\n            ->getResult()\n        ;\n    }\n\n    public function findAllSignupRequestsPaginated(int $page = 1): PagerfantaInterface\n    {\n        $query = $this->createQueryBuilder('u')\n            ->where('u.applicationStatus = :status')\n            ->andWhere('u.apId IS NULL')\n            ->andWhere('u.isDeleted = false')\n            ->andWhere('u.markedForDeletionAt IS NULL')\n            ->setParameter('status', EApplicationStatus::Pending->value)\n            ->getQuery();\n\n        $fanta = new Pagerfanta(new QueryAdapter($query));\n        $fanta->setCurrentPage($page);\n        $fanta->setMaxPerPage(self::PER_PAGE);\n\n        return $fanta;\n    }\n\n    public function findSignupRequest(string $username): ?User\n    {\n        return $this->createQueryBuilder('u')\n            ->where('u.applicationStatus = :status')\n            ->andWhere('u.apId IS NULL')\n            ->andWhere('u.isDeleted = false')\n            ->andWhere('u.markedForDeletionAt IS NULL')\n            ->andWhere('u.username = :username')\n            ->setParameter('status', EApplicationStatus::Pending->value)\n            ->setParameter('username', $username)\n            ->getQuery()\n            ->getOneOrNullResult();\n    }\n\n    /**\n     * @return array<array{user_id:int, sum:int}>\n     *\n     * @throws Exception\n     */\n    public function findForMagazineUsersByContentCount(Magazine $magazine, ?bool $federated, int $limit, bool $limitTime, bool $requireAvatar): array\n    {\n        $conn = $this->getEntityManager()->getConnection();\n        $userWhere = [\n            'u.is_banned = false',\n            'u.is_deleted = false',\n            'u.application_status = :status',\n            'u.visibility = :visibility',\n            'u.ap_discoverable = true',\n            'u.ap_deleted_at IS NULL',\n            'u.ap_timeout_at IS NULL',\n        ];\n        if ($requireAvatar) {\n            $userWhere[] = 'u.avatar_id IS NOT NULL';\n        }\n        if (true === $federated) {\n            $userWhere[] = 'u.ap_id IS NOT NULL';\n        } elseif (false === $federated) {\n            $userWhere[] = 'u.ap_id IS NULL';\n        }\n        $userWhereString = SqlHelpers::makeWhereString($userWhere);\n        $timeWhere = $limitTime ? \"AND created_at > now() - '30 days'::interval\" : '';\n        $sql = \"\n            SELECT user_id, SUM(count) FROM (\n                (SELECT count(id), user_id FROM entry WHERE magazine_id = :magazineId $timeWhere GROUP BY user_id ORDER BY count DESC LIMIT :limit)\n                UNION ALL\n                (SELECT count(id), user_id FROM entry_comment WHERE magazine_id = :magazineId $timeWhere GROUP BY user_id ORDER BY count DESC LIMIT :limit)\n                UNION ALL\n                (SELECT count(id), user_id FROM post WHERE magazine_id = :magazineId $timeWhere GROUP BY user_id ORDER BY count DESC LIMIT :limit)\n                UNION ALL\n                (SELECT count(id), user_id FROM post_comment WHERE magazine_id = :magazineId $timeWhere GROUP BY user_id ORDER BY count DESC LIMIT :limit)\n            ) grouping\n            INNER JOIN \\\"user\\\" u ON u.id = user_id\n            $userWhereString\n            GROUP BY user_id\n            ORDER BY sum DESC\n            LIMIT :limit\n        \";\n\n        $stmt = $conn->prepare($sql);\n        $stmt->bindValue('magazineId', $magazine->getId());\n        $stmt->bindValue('limit', $limit);\n        $stmt->bindValue('visibility', VisibilityInterface::VISIBILITY_VISIBLE);\n        $stmt->bindValue('status', EApplicationStatus::Approved->value);\n\n        return $stmt->executeQuery()->fetchAllAssociative();\n    }\n\n    public function findOldestUser(): ?User\n    {\n        $qb = $this->createQueryBuilder('u')\n            ->where('u.apId IS NULL')\n            ->orderBy('u.createdAt', Order::Ascending->value);\n\n        $result = $qb->setMaxResults(1)\n            ->getQuery()\n            ->getResult();\n\n        if (0 === \\count($result)) {\n            return null;\n        }\n\n        return $result[0];\n    }\n}\n"
  },
  {
    "path": "src/Repository/VoteRepository.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Repository;\n\nuse Doctrine\\ORM\\EntityManagerInterface;\n\nreadonly class VoteRepository\n{\n    public function __construct(private EntityManagerInterface $entityManager)\n    {\n    }\n\n    public function count(?\\DateTimeImmutable $date = null, ?bool $withFederated = null): int\n    {\n        $count = 0;\n        foreach (['entry_vote', 'entry_comment_vote', 'post_vote', 'post_comment_vote', 'favourite'] as $table) {\n            $conn = $this->entityManager->getConnection();\n            $sql = \"SELECT COUNT(e.id) as cnt FROM $table e INNER JOIN public.user u ON user_id=u.id {$this->where($date, $withFederated)}\";\n\n            $stmt = $conn->prepare($sql);\n            if (null !== $date) {\n                $stmt->bindValue(':date', $date, 'datetime');\n            }\n            $stmt = $stmt->executeQuery();\n            $count += $stmt->fetchAllAssociative()[0]['cnt'];\n        }\n\n        return $count;\n    }\n\n    private function where(?\\DateTimeImmutable $date = null, ?bool $withFederated = null): string\n    {\n        $where = 'WHERE u.is_deleted = false';\n        $dateWhere = $date ? ' AND e.created_at > :date' : '';\n        $federationWhere = $withFederated ? '' : ' AND u.ap_id IS NULL';\n\n        return $where.$dateWhere.$federationWhere;\n    }\n}\n"
  },
  {
    "path": "src/Scheduler/MbinTaskProvider.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Scheduler;\n\nuse App\\Message\\ClearDeadMessagesMessage;\nuse App\\Message\\ClearDeletedUserMessage;\nuse Symfony\\Component\\Scheduler\\Attribute\\AsSchedule;\nuse Symfony\\Component\\Scheduler\\RecurringMessage;\nuse Symfony\\Component\\Scheduler\\Schedule;\nuse Symfony\\Component\\Scheduler\\ScheduleProviderInterface;\nuse Symfony\\Contracts\\Cache\\CacheInterface;\n\n#[AsSchedule]\nclass MbinTaskProvider implements ScheduleProviderInterface\n{\n    private ?Schedule $schedule = null;\n\n    public function __construct(\n        private readonly CacheInterface $cache,\n    ) {\n    }\n\n    public function getSchedule(): Schedule\n    {\n        if (null === $this->schedule) {\n            $this->schedule = (new Schedule())\n                ->add(\n                    RecurringMessage::every('1 day', new ClearDeletedUserMessage()),\n                    RecurringMessage::every('1 day', new ClearDeadMessagesMessage()),\n                )\n                ->stateful($this->cache);\n        }\n\n        return $this->schedule;\n    }\n}\n"
  },
  {
    "path": "src/Schema/ContentSchema.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Schema;\n\nuse App\\DTO\\EntryCommentResponseDto;\nuse App\\DTO\\EntryResponseDto;\nuse App\\DTO\\PostCommentResponseDto;\nuse App\\DTO\\PostResponseDto;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse OpenApi\\Attributes as OA;\n\n#[OA\\Schema(\n    type: 'object',\n    anyOf: [\n        new OA\\Schema(ref: new Model(type: EntryResponseDto::class)),\n        new OA\\Schema(ref: new Model(type: EntryCommentResponseDto::class)),\n        new OA\\Schema(ref: new Model(type: PostResponseDto::class)),\n        new OA\\Schema(ref: new Model(type: PostCommentResponseDto::class)),\n    ],\n    properties: [\n        new OA\\Property('itemType', example: 'string', type: 'string', enum: ContentSchema::TYPES),\n    ]\n)]\nclass ContentSchema\n{\n    public const TYPES = [\n        'entry',\n        'entry_comment',\n        'post',\n        'post_comment',\n    ];\n}\n"
  },
  {
    "path": "src/Schema/CursorPaginationSchema.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Schema;\n\nuse App\\Pagination\\Cursor\\CursorPaginationInterface;\nuse OpenApi\\Attributes as OA;\n\n#[OA\\Schema()]\nclass CursorPaginationSchema implements \\JsonSerializable\n{\n    #[OA\\Property(description: 'The cursor for the current page')]\n    public string $currentCursor;\n    #[OA\\Property(description: 'The secondary cursor for the current page')]\n    public string $currentCursor2;\n    #[OA\\Property(description: 'The cursor for the next page', nullable: true)]\n    public ?string $nextCursor;\n    #[OA\\Property(description: 'The secondary cursor for the next page', nullable: true)]\n    public ?string $nextCursor2;\n    #[OA\\Property(description: 'The cursor for the previous page', nullable: true)]\n    public ?string $previousCursor;\n    #[OA\\Property(description: 'The secondary cursor for the previous page', nullable: true)]\n    public ?string $previousCursor2;\n    #[OA\\Property(description: 'Max number of items per page')]\n    public int $perPage = 0;\n\n    public function __construct(CursorPaginationInterface $pagerfanta)\n    {\n        $current = $pagerfanta->getCurrentCursor();\n        $this->currentCursor = $this->cursorToString($current[0]);\n        $this->currentCursor2 = $this->cursorToString($current[1]);\n        $next = $pagerfanta->hasNextPage() ? $pagerfanta->getNextPage() : null;\n        $this->nextCursor = $next ? $this->cursorToString($next[0]) : null;\n        $this->nextCursor2 = $next ? $this->cursorToString($next[1]) : null;\n        $previous = $pagerfanta->hasPreviousPage() ? $pagerfanta->getPreviousPage() : null;\n        $this->previousCursor = $previous ? $this->cursorToString($previous[0]) : null;\n        $this->previousCursor2 = $previous ? $this->cursorToString($previous[1]) : null;\n        $this->perPage = $pagerfanta->getMaxPerPage();\n    }\n\n    private function cursorToString(mixed $cursor): string\n    {\n        if ($cursor instanceof \\DateTime || $cursor instanceof \\DateTimeImmutable) {\n            return $cursor->format(DATE_ATOM);\n        } elseif (\\is_int($cursor)) {\n            return ''.$cursor;\n        }\n\n        return $cursor->__toString();\n    }\n\n    public function jsonSerialize(): array\n    {\n        return [\n            'currentCursor' => $this->currentCursor,\n            'currentCursor2' => $this->currentCursor2,\n            'nextCursor' => $this->nextCursor,\n            'nextCursor2' => $this->nextCursor2,\n            'previousCursor' => $this->previousCursor,\n            'previousCursor2' => $this->previousCursor2,\n            'perPage' => $this->perPage,\n        ];\n    }\n}\n"
  },
  {
    "path": "src/Schema/Errors/BadRequestErrorSchema.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Schema\\Errors;\n\nuse OpenApi\\Attributes as OA;\n\n#[OA\\Schema(\n    type: 'object',\n    properties: [\n        new OA\\Property(property: 'type', type: 'string', example: 'https://tools.ietf.org/html/rfc2616#section-10'),\n        new OA\\Property(property: 'title', type: 'string', example: 'An error occurred'),\n        new OA\\Property(property: 'status', type: 'integer', example: 400),\n        new OA\\Property(property: 'detail', type: 'string', example: 'Bad Request'),\n    ]\n)]\nclass BadRequestErrorSchema\n{\n}\n"
  },
  {
    "path": "src/Schema/Errors/ForbiddenErrorSchema.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Schema\\Errors;\n\nuse OpenApi\\Attributes as OA;\n\n#[OA\\Schema(\n    type: 'object',\n    properties: [\n        new OA\\Property(property: 'type', type: 'string', example: 'https://tools.ietf.org/html/rfc2616#section-10'),\n        new OA\\Property(property: 'title', type: 'string', example: 'An error occurred'),\n        new OA\\Property(property: 'status', type: 'integer', example: 403),\n        new OA\\Property(property: 'detail', type: 'string', example: 'Forbidden'),\n    ]\n)]\nclass ForbiddenErrorSchema\n{\n}\n"
  },
  {
    "path": "src/Schema/Errors/NotFoundErrorSchema.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Schema\\Errors;\n\nuse OpenApi\\Attributes as OA;\n\n#[OA\\Schema(\n    type: 'object',\n    properties: [\n        new OA\\Property(property: 'type', type: 'string', example: 'https://tools.ietf.org/html/rfc2616#section-10'),\n        new OA\\Property(property: 'title', type: 'string', example: 'An error occurred'),\n        new OA\\Property(property: 'status', type: 'integer', example: 404),\n        new OA\\Property(property: 'detail', type: 'string', example: 'Not Found'),\n    ]\n)]\nclass NotFoundErrorSchema\n{\n}\n"
  },
  {
    "path": "src/Schema/Errors/TooManyRequestsErrorSchema.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Schema\\Errors;\n\nuse OpenApi\\Attributes as OA;\n\n#[OA\\Schema(\n    type: 'object',\n    properties: [\n        new OA\\Property(property: 'type', type: 'string', example: 'https://tools.ietf.org/html/rfc2616#section-10'),\n        new OA\\Property(property: 'title', type: 'string', example: 'An error occurred'),\n        new OA\\Property(property: 'status', type: 'integer', example: 429),\n        new OA\\Property(property: 'detail', type: 'string', example: 'Too Many Requests'),\n    ]\n)]\nclass TooManyRequestsErrorSchema\n{\n}\n"
  },
  {
    "path": "src/Schema/Errors/UnauthorizedErrorSchema.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Schema\\Errors;\n\nuse OpenApi\\Attributes as OA;\n\n#[OA\\Schema(\n    type: 'object',\n    properties: [\n        new OA\\Property(property: 'type', type: 'string', example: 'https://tools.ietf.org/html/rfc2616#section-10'),\n        new OA\\Property(property: 'title', type: 'string', example: 'An error occurred'),\n        new OA\\Property(property: 'status', type: 'integer', example: 401),\n        new OA\\Property(property: 'detail', type: 'string', example: 'Unauthorized'),\n    ]\n)]\nclass UnauthorizedErrorSchema\n{\n}\n"
  },
  {
    "path": "src/Schema/InfoSchema.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Schema;\n\nuse OpenApi\\Attributes as OA;\n\n#[OA\\Schema(\n    type: 'object',\n    properties: [\n        new OA\\Property('softwareName', example: 'mbin', type: 'string'),\n        new OA\\Property('softwareVersion', example: '2.0.0', type: 'string'),\n        new OA\\Property('softwareRepository', example: 'https://github.com/MbinOrg/mbin', type: 'string'),\n        new OA\\Property('websiteDomain', example: 'https://mbin.social', type: 'string'),\n        new OA\\Property('websiteContactEmail', example: 'contact@mbin.social', type: 'string'),\n        new OA\\Property('websiteTitle', example: 'Mbin', type: 'string'),\n        new OA\\Property('websiteOpenRegistrations', example: true, type: 'boolean'),\n        new OA\\Property('websiteFederationEnabled', example: true, type: 'boolean'),\n        new OA\\Property('websiteDefaultLang', example: 'en', type: 'string'),\n    ]\n)]\nclass InfoSchema\n{\n}\n"
  },
  {
    "path": "src/Schema/NotificationSchema.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Schema;\n\nuse App\\DTO\\MagazineBanResponseDto;\nuse App\\DTO\\UserSignupResponseDto;\nuse App\\Entity\\Notification;\nuse App\\Repository\\NotificationRepository;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse OpenApi\\Attributes as OA;\n\n#[OA\\Schema()]\nclass NotificationSchema\n{\n    #[OA\\Property(description: 'The id of the notification')]\n    public int $notificationId = 0;\n    #[OA\\Property(\n        description: 'The notification type',\n        enum: [\n            'entry_created_notification',\n            'entry_edited_notification',\n            'entry_deleted_notification',\n            'entry_mentioned_notification',\n            'entry_comment_created_notification',\n            'entry_comment_edited_notification',\n            'entry_comment_reply_notification',\n            'entry_comment_deleted_notification',\n            'entry_comment_mentioned_notification',\n            'post_created_notification',\n            'post_edited_notification',\n            'post_deleted_notification',\n            'post_mentioned_notification',\n            'post_comment_created_notification',\n            'post_comment_edited_notification',\n            'post_comment_reply_notification',\n            'post_comment_deleted_notification',\n            'post_comment_mentioned_notification',\n            'message_notification',\n            'ban_notification',\n            'report_created_notification',\n            'report_rejected_notification',\n            'report_approved_notification',\n            'new_signup',\n        ]\n    )]\n    public string $type = 'entry_created_notification';\n    #[OA\\Property(description: 'The notification\\'s status', enum: NotificationRepository::STATUS_OPTIONS)]\n    public string $status = Notification::STATUS_NEW;\n    #[OA\\Property(type: 'object', oneOf: [\n        new OA\\Schema(ref: '#/components/schemas/EntryResponseDto'),\n        new OA\\Schema(ref: '#/components/schemas/EntryCommentResponseDto'),\n        new OA\\Schema(ref: '#/components/schemas/PostResponseDto'),\n        new OA\\Schema(ref: '#/components/schemas/PostCommentResponseDto'),\n        new OA\\Schema(ref: '#/components/schemas/MessageResponseDto'),\n        new OA\\Schema(ref: new Model(type: MagazineBanResponseDto::class)),\n        new OA\\Schema(ref: new Model(type: UserSignupResponseDto::class)),\n    ])]\n    public mixed $subject = null;\n\n    #[OA\\Property(description: 'The id of the associated report of this notification')]\n    public ?int $reportId = null;\n}\n"
  },
  {
    "path": "src/Schema/PaginationSchema.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Schema;\n\nuse OpenApi\\Attributes as OA;\nuse Pagerfanta\\PagerfantaInterface;\n\n#[OA\\Schema()]\nclass PaginationSchema implements \\JsonSerializable\n{\n    #[OA\\Property(description: 'The total number of items available')]\n    public int $count = 0;\n    #[OA\\Property(description: 'The current page number returned')]\n    public int $currentPage = 0;\n    #[OA\\Property(description: 'The max page number available')]\n    public int $maxPage = 0;\n    #[OA\\Property(description: 'Max number of items per page')]\n    public int $perPage = 0;\n\n    public function __construct(PagerfantaInterface $pagerfanta)\n    {\n        $this->count = $pagerfanta->count();\n        $this->currentPage = $pagerfanta->getCurrentPage();\n        $this->maxPage = $pagerfanta->getNbPages();\n        $this->perPage = $pagerfanta->getMaxPerPage();\n    }\n\n    public function jsonSerialize(): mixed\n    {\n        return [\n            'count' => $this->count,\n            'currentPage' => $this->currentPage,\n            'maxPage' => $this->maxPage,\n            'perPage' => $this->perPage,\n        ];\n    }\n}\n"
  },
  {
    "path": "src/Schema/SearchActorSchema.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Schema;\n\nuse App\\DTO\\MagazineResponseDto;\nuse App\\DTO\\UserResponseDto;\nuse Nelmio\\ApiDocBundle\\Attribute\\Model;\nuse OpenApi\\Attributes as OA;\n\n#[OA\\Schema(\n    type: 'object',\n    properties: [\n        new OA\\Property('type', example: 'string', type: 'string', enum: SearchActorSchema::TYPES),\n        new OA\\Property('object', type: 'object', oneOf: [\n            new OA\\Schema(ref: new Model(type: MagazineResponseDto::class)),\n            new OA\\Schema(ref: new Model(type: UserResponseDto::class)),\n        ]),\n    ]\n)]\nclass SearchActorSchema\n{\n    public const TYPES = [\n        'user',\n        'magazine',\n    ];\n}\n"
  },
  {
    "path": "src/Security/AuthentikAuthenticator.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Security;\n\nuse App\\DTO\\UserDto;\nuse App\\Entity\\Image;\nuse App\\Entity\\User;\nuse App\\Factory\\ImageFactory;\nuse App\\Provider\\AuthentikResourceOwner;\nuse App\\Repository\\ImageRepository;\nuse App\\Repository\\UserRepository;\nuse App\\Service\\ImageManagerInterface;\nuse App\\Service\\IpResolver;\nuse App\\Service\\SettingsManager;\nuse App\\Service\\UserManager;\nuse App\\Utils\\Slugger;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse KnpU\\OAuth2ClientBundle\\Client\\ClientRegistry;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\Routing\\RouterInterface;\nuse Symfony\\Component\\Security\\Core\\Exception\\CustomUserMessageAuthenticationException;\nuse Symfony\\Component\\Security\\Http\\Authenticator\\Passport\\Badge\\RememberMeBadge;\nuse Symfony\\Component\\Security\\Http\\Authenticator\\Passport\\Badge\\UserBadge;\nuse Symfony\\Component\\Security\\Http\\Authenticator\\Passport\\Passport;\nuse Symfony\\Component\\Security\\Http\\Authenticator\\Passport\\SelfValidatingPassport;\n\nclass AuthentikAuthenticator extends MbinOAuthAuthenticatorBase\n{\n    public function __construct(\n        private readonly ClientRegistry $clientRegistry,\n        private readonly EntityManagerInterface $entityManager,\n        private readonly UserManager $userManager,\n        private readonly ImageManagerInterface $imageManager,\n        private readonly ImageFactory $imageFactory,\n        private readonly ImageRepository $imageRepository,\n        private readonly IpResolver $ipResolver,\n        private readonly Slugger $slugger,\n        private readonly UserRepository $userRepository,\n        private readonly SettingsManager $settingsManager,\n        RouterInterface $router,\n    ) {\n        parent::__construct($router);\n    }\n\n    public function supports(Request $request): ?bool\n    {\n        return 'oauth_authentik_verify' === $request->attributes->get('_route');\n    }\n\n    public function authenticate(Request $request): Passport\n    {\n        $client = $this->clientRegistry->getClient('authentik');\n        $slugger = $this->slugger;\n\n        $provider = $client->getOAuth2Provider();\n\n        $accessToken = $provider->getAccessToken('authorization_code', [\n            'code' => $request->query->get('code'),\n        ]);\n\n        $rememberBadge = new RememberMeBadge();\n        $rememberBadge = $rememberBadge->enable();\n\n        return new SelfValidatingPassport(\n            new UserBadge($accessToken->getToken(), function () use ($accessToken, $client, $slugger, $request) {\n                /** @var AuthentikResourceOwner $authentikUser */\n                $authentikUser = $client->fetchUserFromToken($accessToken);\n\n                $existingUser = $this->entityManager->getRepository(User::class)->findOneBy(\n                    ['oauthAuthentikId' => $authentikUser->getId()]\n                );\n\n                if ($existingUser) {\n                    return $existingUser;\n                }\n\n                $user = $this->userRepository->findOneBy(['email' => $authentikUser->getEmail()]);\n\n                if ($user) {\n                    $user->oauthAuthentikId = $authentikUser->getId();\n\n                    $this->entityManager->persist($user);\n                    $this->entityManager->flush();\n\n                    return $user;\n                }\n\n                if (false === $this->settingsManager->get('MBIN_SSO_REGISTRATIONS_ENABLED')) {\n                    throw new CustomUserMessageAuthenticationException('MBIN_SSO_REGISTRATIONS_ENABLED');\n                }\n\n                $username = $slugger->slug($authentikUser->toArray()['preferred_username']);\n\n                if ($this->userRepository->count(['username' => $username]) > 0) {\n                    $username .= rand(1, 999);\n                    $request->getSession()->set('is_newly_created', true);\n                }\n\n                $dto = (new UserDto())->create(\n                    $username,\n                    $authentikUser->getEmail()\n                );\n\n                $avatar = $this->getAvatar($authentikUser->getPictureUrl());\n\n                if ($avatar) {\n                    $dto->avatar = $this->imageFactory->createDto($avatar);\n                }\n\n                $dto->plainPassword = bin2hex(random_bytes(20));\n                $dto->ip = $this->ipResolver->resolve();\n\n                $user = $this->userManager->create($dto, false);\n                $user->oauthAuthentikId = $authentikUser->getId();\n                $user->avatar = $this->getAvatar($authentikUser->getPictureUrl());\n                $user->isVerified = true;\n\n                $this->entityManager->persist($user);\n                $this->entityManager->flush();\n\n                return $user;\n            }),\n            [\n                $rememberBadge,\n            ]\n        );\n    }\n\n    private function getAvatar(?string $pictureUrl): ?Image\n    {\n        if (!$pictureUrl) {\n            return null;\n        }\n\n        try {\n            $tempFile = $this->imageManager->download($pictureUrl);\n        } catch (\\Exception $e) {\n            $tempFile = null;\n        }\n\n        if ($tempFile) {\n            $image = $this->imageRepository->findOrCreateFromPath($tempFile);\n            if ($image) {\n                $this->entityManager->persist($image);\n                $this->entityManager->flush();\n            }\n        }\n\n        return $image ?? null;\n    }\n}\n"
  },
  {
    "path": "src/Security/AzureAuthenticator.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Security;\n\nuse App\\DTO\\UserDto;\nuse App\\Entity\\User;\nuse App\\Repository\\UserRepository;\nuse App\\Service\\IpResolver;\nuse App\\Service\\SettingsManager;\nuse App\\Service\\UserManager;\nuse App\\Utils\\Slugger;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse KnpU\\OAuth2ClientBundle\\Client\\ClientRegistry;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\Routing\\RouterInterface;\nuse Symfony\\Component\\Security\\Core\\Exception\\CustomUserMessageAuthenticationException;\nuse Symfony\\Component\\Security\\Http\\Authenticator\\Passport\\Badge\\RememberMeBadge;\nuse Symfony\\Component\\Security\\Http\\Authenticator\\Passport\\Badge\\UserBadge;\nuse Symfony\\Component\\Security\\Http\\Authenticator\\Passport\\Passport;\nuse Symfony\\Component\\Security\\Http\\Authenticator\\Passport\\SelfValidatingPassport;\nuse TheNetworg\\OAuth2\\Client\\Provider\\AzureResourceOwner;\n\nclass AzureAuthenticator extends MbinOAuthAuthenticatorBase\n{\n    public function __construct(\n        private readonly ClientRegistry $clientRegistry,\n        RouterInterface $router,\n        private readonly EntityManagerInterface $entityManager,\n        private readonly UserManager $userManager,\n        private readonly IpResolver $ipResolver,\n        private readonly Slugger $slugger,\n        private readonly UserRepository $userRepository,\n        private readonly SettingsManager $settingsManager,\n    ) {\n        parent::__construct($router);\n    }\n\n    public function supports(Request $request): ?bool\n    {\n        return 'oauth_azure_verify' === $request->attributes->get('_route');\n    }\n\n    public function authenticate(Request $request): Passport\n    {\n        $client = $this->clientRegistry->getClient('azure');\n        $slugger = $this->slugger;\n\n        $provider = $client->getOAuth2Provider();\n\n        $accessToken = $provider->getAccessToken('authorization_code', [\n            'code' => $request->query->get('code'),\n        ]);\n\n        $rememberBadge = new RememberMeBadge();\n        $rememberBadge = $rememberBadge->enable();\n\n        return new SelfValidatingPassport(\n            new UserBadge($accessToken->getToken(), function () use ($accessToken, $client, $slugger, $request) {\n                /** @var AzureResourceOwner $azureUser */\n                $azureUser = $client->fetchUserFromToken($accessToken);\n\n                $existingUser = $this->entityManager->getRepository(User::class)->findOneBy(\n                    ['oauthAzureId' => $azureUser->getUpn()]\n                );\n\n                if ($existingUser) {\n                    return $existingUser;\n                }\n\n                $user = $this->userRepository->findOneBy(['email' => $azureUser->getUpn()]);\n\n                if ($user) {\n                    $user->oauthAzureId = $azureUser->getUpn();\n\n                    $this->entityManager->persist($user);\n                    $this->entityManager->flush();\n\n                    return $user;\n                }\n\n                if (false === $this->settingsManager->get('MBIN_SSO_REGISTRATIONS_ENABLED')) {\n                    throw new CustomUserMessageAuthenticationException('MBIN_SSO_REGISTRATIONS_ENABLED');\n                }\n\n                $username = $slugger->slug($azureUser->toArray()['name']);\n\n                if ($this->userRepository->count(['username' => $username]) > 0) {\n                    $username .= rand(1, 999);\n                    $request->getSession()->set('is_newly_created', true);\n                }\n\n                $dto = (new UserDto())->create(\n                    $username,\n                    $azureUser->getUpn()\n                );\n\n                $dto->plainPassword = bin2hex(random_bytes(20));\n                $dto->ip = $this->ipResolver->resolve();\n\n                $user = $this->userManager->create($dto, false);\n                $user->oauthAzureId = $azureUser->getUpn();\n                $user->isVerified = true;\n\n                $this->entityManager->persist($user);\n                $this->entityManager->flush();\n\n                return $user;\n            }),\n            [\n                $rememberBadge,\n            ]\n        );\n    }\n}\n"
  },
  {
    "path": "src/Security/DiscordAuthenticator.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Security;\n\nuse App\\DTO\\UserDto;\nuse App\\Entity\\User;\nuse App\\Service\\IpResolver;\nuse App\\Service\\SettingsManager;\nuse App\\Service\\UserManager;\nuse App\\Utils\\Slugger;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse KnpU\\OAuth2ClientBundle\\Client\\ClientRegistry;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\RequestStack;\nuse Symfony\\Component\\Routing\\RouterInterface;\nuse Symfony\\Component\\Security\\Core\\Exception\\CustomUserMessageAuthenticationException;\nuse Symfony\\Component\\Security\\Http\\Authenticator\\Passport\\Badge\\RememberMeBadge;\nuse Symfony\\Component\\Security\\Http\\Authenticator\\Passport\\Badge\\UserBadge;\nuse Symfony\\Component\\Security\\Http\\Authenticator\\Passport\\Passport;\nuse Symfony\\Component\\Security\\Http\\Authenticator\\Passport\\SelfValidatingPassport;\nuse Wohali\\OAuth2\\Client\\Provider\\DiscordResourceOwner;\n\nclass DiscordAuthenticator extends MbinOAuthAuthenticatorBase\n{\n    public function __construct(\n        private readonly ClientRegistry $clientRegistry,\n        RouterInterface $router,\n        private readonly EntityManagerInterface $entityManager,\n        private readonly UserManager $userManager,\n        private readonly RequestStack $requestStack,\n        private readonly IpResolver $ipResolver,\n        private readonly Slugger $slugger,\n        private readonly SettingsManager $settingsManager,\n    ) {\n        parent::__construct($router);\n    }\n\n    public function supports(Request $request): ?bool\n    {\n        return 'oauth_discord_verify' === $request->attributes->get('_route');\n    }\n\n    public function authenticate(Request $request): Passport\n    {\n        $client = $this->clientRegistry->getClient('discord');\n        $slugger = $this->slugger;\n        $session = $this->requestStack->getSession();\n\n        $accessToken = $this->fetchAccessToken($client, ['prompt' => 'consent', 'accessType' => 'offline']);\n        $session->set('access_token', $accessToken);\n\n        $accessToken = $session->get('access_token');\n\n        if ($accessToken->hasExpired()) {\n            $accessToken = $client->refreshAccessToken($accessToken->getRefreshToken());\n            $session->set('access_token', $accessToken);\n        }\n\n        $rememberBadge = new RememberMeBadge();\n        $rememberBadge = $rememberBadge->enable();\n\n        return new SelfValidatingPassport(\n            new UserBadge($accessToken->getToken(), function () use ($accessToken, $client, $slugger, $request) {\n                /** @var DiscordResourceOwner $discordUser */\n                $discordUser = $client->fetchUserFromToken($accessToken);\n\n                $existingUser = $this->entityManager->getRepository(User::class)->findOneBy(\n                    ['oauthDiscordId' => $discordUser->getId()]\n                );\n\n                if ($existingUser) {\n                    return $existingUser;\n                }\n\n                $user = $this->entityManager->getRepository(User::class)->findOneBy(['email' => $discordUser->getEmail()]\n                );\n\n                if ($user) {\n                    $user->oauthDiscordId = $discordUser->getId();\n\n                    $this->entityManager->persist($user);\n                    $this->entityManager->flush();\n\n                    return $user;\n                }\n\n                if (false === $this->settingsManager->get('MBIN_SSO_REGISTRATIONS_ENABLED')) {\n                    throw new CustomUserMessageAuthenticationException('MBIN_SSO_REGISTRATIONS_ENABLED');\n                }\n\n                $dto = (new UserDto())->create(\n                    $slugger->slug($discordUser->getUsername()).rand(1, 999),\n                    $discordUser->getEmail()\n                );\n\n                $dto->plainPassword = bin2hex(random_bytes(20));\n                $dto->ip = $this->ipResolver->resolve();\n\n                $user = $this->userManager->create($dto, false);\n                $user->oauthDiscordId = $discordUser->getId();\n                $user->isVerified = true;\n\n                $this->entityManager->persist($user);\n                $this->entityManager->flush();\n\n                $request->getSession()->set('is_newly_created', true);\n\n                return $user;\n            }),\n            [\n                $rememberBadge,\n            ]\n        );\n    }\n}\n"
  },
  {
    "path": "src/Security/EmailVerifier.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Security;\n\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Symfony\\Bridge\\Twig\\Mime\\TemplatedEmail;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\Mailer\\MailerInterface;\nuse Symfony\\Component\\Security\\Core\\User\\UserInterface;\nuse SymfonyCasts\\Bundle\\VerifyEmail\\Exception\\VerifyEmailExceptionInterface;\nuse SymfonyCasts\\Bundle\\VerifyEmail\\VerifyEmailHelperInterface;\n\nclass EmailVerifier\n{\n    public function __construct(\n        private readonly VerifyEmailHelperInterface $verifyEmailHelper,\n        private readonly MailerInterface $mailer,\n        private readonly EntityManagerInterface $entityManager,\n    ) {\n    }\n\n    public function sendEmailConfirmation(\n        string $verifyEmailRouteName,\n        UserInterface $user,\n        TemplatedEmail $email,\n    ): void {\n        $signatureComponents = $this->verifyEmailHelper->generateSignature(\n            $verifyEmailRouteName,\n            (string) $user->getId(),\n            $user->email,\n            ['id' => $user->getId()]\n        );\n\n        $context = $email->getContext();\n        $context['signedUrl'] = $signatureComponents->getSignedUrl();\n        $context['expiresAtMessageKey'] = $signatureComponents->getExpirationMessageKey();\n        $context['expiresAtMessageData'] = $signatureComponents->getExpirationMessageData();\n\n        $email->context($context);\n\n        $this->mailer->send($email);\n    }\n\n    /**\n     * @throws VerifyEmailExceptionInterface\n     */\n    public function handleEmailConfirmation(Request $request, UserInterface $user): void\n    {\n        $this->verifyEmailHelper->validateEmailConfirmation($request->getUri(), (string) $user->getId(), $user->email);\n\n        $user->isVerified = true;\n\n        $this->entityManager->persist($user);\n        $this->entityManager->flush();\n    }\n}\n"
  },
  {
    "path": "src/Security/FacebookAuthenticator.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Security;\n\nuse App\\DTO\\UserDto;\nuse App\\Entity\\Image;\nuse App\\Entity\\User;\nuse App\\Factory\\ImageFactory;\nuse App\\Repository\\ImageRepository;\nuse App\\Service\\ImageManagerInterface;\nuse App\\Service\\IpResolver;\nuse App\\Service\\SettingsManager;\nuse App\\Service\\UserManager;\nuse App\\Utils\\Slugger;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse KnpU\\OAuth2ClientBundle\\Client\\ClientRegistry;\nuse League\\OAuth2\\Client\\Provider\\FacebookUser;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\Routing\\RouterInterface;\nuse Symfony\\Component\\Security\\Core\\Exception\\CustomUserMessageAuthenticationException;\nuse Symfony\\Component\\Security\\Http\\Authenticator\\Passport\\Badge\\RememberMeBadge;\nuse Symfony\\Component\\Security\\Http\\Authenticator\\Passport\\Badge\\UserBadge;\nuse Symfony\\Component\\Security\\Http\\Authenticator\\Passport\\Passport;\nuse Symfony\\Component\\Security\\Http\\Authenticator\\Passport\\SelfValidatingPassport;\n\nclass FacebookAuthenticator extends MbinOAuthAuthenticatorBase\n{\n    public function __construct(\n        private readonly ClientRegistry $clientRegistry,\n        RouterInterface $router,\n        private readonly EntityManagerInterface $entityManager,\n        private readonly UserManager $userManager,\n        private readonly ImageManagerInterface $imageManager,\n        private readonly ImageFactory $imageFactory,\n        private readonly ImageRepository $imageRepository,\n        private readonly IpResolver $ipResolver,\n        private readonly Slugger $slugger,\n        private readonly SettingsManager $settingsManager,\n    ) {\n        parent::__construct($router);\n    }\n\n    public function supports(Request $request): ?bool\n    {\n        return 'oauth_facebook_verify' === $request->attributes->get('_route');\n    }\n\n    public function authenticate(Request $request): Passport\n    {\n        $client = $this->clientRegistry->getClient('facebook');\n        $slugger = $this->slugger;\n\n        $accessToken = $this->fetchAccessToken($client);\n\n        try {\n            $provider = $client->getOAuth2Provider();\n            $accessToken = $provider->getLongLivedAccessToken($accessToken->getToken());\n        } catch (\\Exception $e) {\n        }\n\n        $rememberBadge = new RememberMeBadge();\n        $rememberBadge = $rememberBadge->enable();\n\n        return new SelfValidatingPassport(\n            new UserBadge($accessToken->getToken(), function () use ($accessToken, $client, $slugger, $request) {\n                /** @var FacebookUser $facebookUser */\n                $facebookUser = $client->fetchUserFromToken($accessToken);\n\n                $existingUser = $this->entityManager->getRepository(User::class)->findOneBy(\n                    ['oauthFacebookId' => $facebookUser->getId()]\n                );\n\n                if ($existingUser) {\n                    return $existingUser;\n                }\n\n                $user = $this->entityManager->getRepository(User::class)->findOneBy(\n                    ['email' => $facebookUser->getEmail()]\n                );\n\n                if ($user) {\n                    $user->oauthFacebookId = $facebookUser->getId();\n\n                    $this->entityManager->persist($user);\n                    $this->entityManager->flush();\n\n                    return $user;\n                }\n\n                if (false === $this->settingsManager->get('MBIN_SSO_REGISTRATIONS_ENABLED')) {\n                    throw new CustomUserMessageAuthenticationException('MBIN_SSO_REGISTRATIONS_ENABLED');\n                }\n\n                $dto = (new UserDto())->create(\n                    $slugger->slug($facebookUser->getName()).rand(1, 999),\n                    $facebookUser->getEmail()\n                );\n\n                $avatar = $this->getAvatar($facebookUser->getPictureUrl());\n\n                if ($avatar) {\n                    $dto->avatar = $this->imageFactory->createDto($avatar);\n                }\n\n                $dto->plainPassword = bin2hex(random_bytes(20));\n                $dto->ip = $this->ipResolver->resolve();\n\n                $user = $this->userManager->create($dto, false);\n                $user->oauthFacebookId = $facebookUser->getId();\n                $user->avatar = $this->getAvatar($facebookUser->getPictureUrl());\n                $user->isVerified = true;\n\n                $this->entityManager->persist($user);\n                $this->entityManager->flush();\n\n                $request->getSession()->set('is_newly_created', true);\n\n                return $user;\n            }),\n            [\n                $rememberBadge,\n            ]\n        );\n    }\n\n    private function getAvatar(?string $pictureUrl): ?Image\n    {\n        if (!$pictureUrl) {\n            return null;\n        }\n\n        try {\n            $tempFile = $this->imageManager->download($pictureUrl);\n        } catch (\\Exception $e) {\n            $tempFile = null;\n        }\n\n        if ($tempFile) {\n            $image = $this->imageRepository->findOrCreateFromPath($tempFile);\n            if ($image) {\n                $this->entityManager->persist($image);\n                $this->entityManager->flush();\n            }\n        }\n\n        return $image ?? null;\n    }\n}\n"
  },
  {
    "path": "src/Security/GithubAuthenticator.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Security;\n\nuse App\\DTO\\UserDto;\nuse App\\Entity\\User;\nuse App\\Service\\SettingsManager;\nuse App\\Service\\UserManager;\nuse App\\Utils\\Slugger;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse KnpU\\OAuth2ClientBundle\\Client\\ClientRegistry;\nuse League\\OAuth2\\Client\\Provider\\GithubResourceOwner;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\Routing\\RouterInterface;\nuse Symfony\\Component\\Security\\Core\\Exception\\CustomUserMessageAuthenticationException;\nuse Symfony\\Component\\Security\\Http\\Authenticator\\Passport\\Badge\\UserBadge;\nuse Symfony\\Component\\Security\\Http\\Authenticator\\Passport\\Passport;\nuse Symfony\\Component\\Security\\Http\\Authenticator\\Passport\\SelfValidatingPassport;\n\nclass GithubAuthenticator extends MbinOAuthAuthenticatorBase\n{\n    public function __construct(\n        private readonly ClientRegistry $clientRegistry,\n        RouterInterface $router,\n        private readonly EntityManagerInterface $entityManager,\n        private readonly UserManager $userManager,\n        private readonly Slugger $slugger,\n        private readonly SettingsManager $settingsManager,\n    ) {\n        parent::__construct($router);\n    }\n\n    public function supports(Request $request): ?bool\n    {\n        return 'oauth_github_verify' === $request->attributes->get('_route');\n    }\n\n    public function authenticate(Request $request): Passport\n    {\n        $client = $this->clientRegistry->getClient('github');\n        $accessToken = $this->fetchAccessToken($client);\n        $slugger = $this->slugger;\n\n        return new SelfValidatingPassport(\n            new UserBadge($accessToken->getToken(), function () use ($accessToken, $client, $slugger, $request) {\n                /** @var GithubResourceOwner $githubUser */\n                $githubUser = $client->fetchUserFromToken($accessToken);\n\n                $existingUser = $this->entityManager->getRepository(User::class)->findOneBy(\n                    ['oauthGithubId' => \\strval($githubUser->getId())]\n                );\n\n                if ($existingUser) {\n                    return $existingUser;\n                }\n\n                $user = $this->entityManager->getRepository(User::class)->findOneBy(\n                    ['email' => $githubUser->getEmail()]\n                );\n\n                if ($user) {\n                    $user->oauthGithubId = \\strval($githubUser->getId());\n\n                    $this->entityManager->persist($user);\n                    $this->entityManager->flush();\n\n                    return $user;\n                }\n\n                if (false === $this->settingsManager->get('MBIN_SSO_REGISTRATIONS_ENABLED')) {\n                    throw new CustomUserMessageAuthenticationException('MBIN_SSO_REGISTRATIONS_ENABLED');\n                }\n\n                $dto = (new UserDto())->create(\n                    $slugger->slug($githubUser->getNickname()).rand(1, 999),\n                    $githubUser->getEmail(),\n                    null\n                );\n\n                $dto->plainPassword = bin2hex(random_bytes(20));\n\n                $user = $this->userManager->create($dto, false);\n                $user->oauthGithubId = \\strval($githubUser->getId());\n                $user->isVerified = true;\n\n                $this->entityManager->persist($user);\n                $this->entityManager->flush();\n\n                $request->getSession()->set('is_newly_created', true);\n\n                return $user;\n            })\n        );\n    }\n}\n"
  },
  {
    "path": "src/Security/GoogleAuthenticator.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Security;\n\nuse App\\DTO\\UserDto;\nuse App\\Entity\\Image;\nuse App\\Entity\\User;\nuse App\\Factory\\ImageFactory;\nuse App\\Repository\\ImageRepository;\nuse App\\Service\\ImageManagerInterface;\nuse App\\Service\\IpResolver;\nuse App\\Service\\SettingsManager;\nuse App\\Service\\UserManager;\nuse App\\Utils\\Slugger;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse KnpU\\OAuth2ClientBundle\\Client\\ClientRegistry;\nuse League\\OAuth2\\Client\\Provider\\GoogleUser;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\RequestStack;\nuse Symfony\\Component\\Routing\\RouterInterface;\nuse Symfony\\Component\\Security\\Core\\Exception\\CustomUserMessageAuthenticationException;\nuse Symfony\\Component\\Security\\Http\\Authenticator\\Passport\\Badge\\RememberMeBadge;\nuse Symfony\\Component\\Security\\Http\\Authenticator\\Passport\\Badge\\UserBadge;\nuse Symfony\\Component\\Security\\Http\\Authenticator\\Passport\\Passport;\nuse Symfony\\Component\\Security\\Http\\Authenticator\\Passport\\SelfValidatingPassport;\n\nclass GoogleAuthenticator extends MbinOAuthAuthenticatorBase\n{\n    public function __construct(\n        private readonly ClientRegistry $clientRegistry,\n        RouterInterface $router,\n        private readonly EntityManagerInterface $entityManager,\n        private readonly UserManager $userManager,\n        private readonly ImageManagerInterface $imageManager,\n        private readonly ImageFactory $imageFactory,\n        private readonly ImageRepository $imageRepository,\n        private readonly RequestStack $requestStack,\n        private readonly IpResolver $ipResolver,\n        private readonly Slugger $slugger,\n        private readonly SettingsManager $settingsManager,\n    ) {\n        parent::__construct($router);\n    }\n\n    public function supports(Request $request): ?bool\n    {\n        return 'oauth_google_verify' === $request->attributes->get('_route');\n    }\n\n    public function authenticate(Request $request): Passport\n    {\n        $client = $this->clientRegistry->getClient('google');\n        $slugger = $this->slugger;\n        $session = $this->requestStack->getSession();\n\n        $accessToken = $this->fetchAccessToken($client, ['prompt' => 'consent', 'accessType' => 'offline']);\n        $session->set('access_token', $accessToken);\n\n        $accessToken = $session->get('access_token');\n\n        if ($accessToken->hasExpired()) {\n            $accessToken = $client->refreshAccessToken($accessToken->getRefreshToken());\n            $session->set('access_token', $accessToken);\n        }\n\n        $rememberBadge = new RememberMeBadge();\n        $rememberBadge = $rememberBadge->enable();\n\n        return new SelfValidatingPassport(\n            new UserBadge($accessToken->getToken(), function () use ($accessToken, $client, $slugger, $request) {\n                /** @var GoogleUser $googleUser */\n                $googleUser = $client->fetchUserFromToken($accessToken);\n\n                $existingUser = $this->entityManager->getRepository(User::class)->findOneBy(\n                    ['oauthGoogleId' => $googleUser->getId()]\n                );\n\n                if ($existingUser) {\n                    return $existingUser;\n                }\n\n                $user = $this->entityManager->getRepository(User::class)->findOneBy(['email' => $googleUser->getEmail()]\n                );\n\n                if ($user) {\n                    $user->oauthGoogleId = $googleUser->getId();\n\n                    $this->entityManager->persist($user);\n                    $this->entityManager->flush();\n\n                    return $user;\n                }\n\n                if (false === $this->settingsManager->get('MBIN_SSO_REGISTRATIONS_ENABLED')) {\n                    throw new CustomUserMessageAuthenticationException('MBIN_SSO_REGISTRATIONS_ENABLED');\n                }\n\n                $dto = (new UserDto())->create(\n                    $slugger->slug($googleUser->getName()).rand(1, 999),\n                    $googleUser->getEmail()\n                );\n\n                $avatar = $this->getAvatar($googleUser->getAvatar());\n\n                if ($avatar) {\n                    $dto->avatar = $this->imageFactory->createDto($avatar);\n                }\n\n                $dto->plainPassword = bin2hex(random_bytes(20));\n                $dto->ip = $this->ipResolver->resolve();\n\n                $user = $this->userManager->create($dto, false);\n                $user->oauthGoogleId = $googleUser->getId();\n                $user->isVerified = true;\n\n                $this->entityManager->persist($user);\n                $this->entityManager->flush();\n\n                $request->getSession()->set('is_newly_created', true);\n\n                return $user;\n            }),\n            [\n                $rememberBadge,\n            ]\n        );\n    }\n\n    private function getAvatar(?string $pictureUrl): ?Image\n    {\n        if (!$pictureUrl) {\n            return null;\n        }\n\n        try {\n            $tempFile = $this->imageManager->download($pictureUrl);\n        } catch (\\Exception $e) {\n            $tempFile = null;\n        }\n\n        if ($tempFile) {\n            $image = $this->imageRepository->findOrCreateFromPath($tempFile);\n            if ($image) {\n                $this->entityManager->persist($image);\n                $this->entityManager->flush();\n            }\n        }\n\n        return $image ?? null;\n    }\n}\n"
  },
  {
    "path": "src/Security/KbinAuthenticator.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Security;\n\nuse Symfony\\Component\\HttpFoundation\\RedirectResponse;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\Routing\\Generator\\UrlGeneratorInterface;\nuse Symfony\\Component\\Security\\Core\\Authentication\\Token\\TokenInterface;\nuse Symfony\\Component\\Security\\Http\\Authenticator\\AbstractLoginFormAuthenticator;\nuse Symfony\\Component\\Security\\Http\\Authenticator\\Passport\\Badge\\CsrfTokenBadge;\nuse Symfony\\Component\\Security\\Http\\Authenticator\\Passport\\Badge\\RememberMeBadge;\nuse Symfony\\Component\\Security\\Http\\Authenticator\\Passport\\Badge\\UserBadge;\nuse Symfony\\Component\\Security\\Http\\Authenticator\\Passport\\Credentials\\PasswordCredentials;\nuse Symfony\\Component\\Security\\Http\\Authenticator\\Passport\\Passport;\nuse Symfony\\Component\\Security\\Http\\SecurityRequestAttributes;\nuse Symfony\\Component\\Security\\Http\\Util\\TargetPathTrait;\n\nclass KbinAuthenticator extends AbstractLoginFormAuthenticator\n{\n    use TargetPathTrait;\n\n    public const LOGIN_ROUTE = 'app_login';\n\n    private UrlGeneratorInterface $urlGenerator;\n\n    public function __construct(UrlGeneratorInterface $urlGenerator)\n    {\n        $this->urlGenerator = $urlGenerator;\n    }\n\n    public function authenticate(Request $request): Passport\n    {\n        $email = trim($request->request->get('email', ''));\n        $request->getSession()->set(SecurityRequestAttributes::LAST_USERNAME, $email);\n\n        return new Passport(\n            new UserBadge($email),\n            new PasswordCredentials($request->request->get('password', '')),\n            [\n                new RememberMeBadge(),\n                new CsrfTokenBadge('authenticate', $request->get('_csrf_token')),\n            ]\n        );\n    }\n\n    public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response\n    {\n        if ($targetPath = $this->getTargetPath($request->getSession(), $firewallName)) {\n            return new RedirectResponse($targetPath);\n        } else {\n            return new RedirectResponse($this->urlGenerator->generate('front'));\n        }\n    }\n\n    protected function getLoginUrl(Request $request): string\n    {\n        return $this->urlGenerator->generate(self::LOGIN_ROUTE);\n    }\n}\n"
  },
  {
    "path": "src/Security/KeycloakAuthenticator.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Security;\n\nuse App\\DTO\\UserDto;\nuse App\\Entity\\User;\nuse App\\Repository\\UserRepository;\nuse App\\Service\\IpResolver;\nuse App\\Service\\SettingsManager;\nuse App\\Service\\UserManager;\nuse App\\Utils\\Slugger;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse KnpU\\OAuth2ClientBundle\\Client\\ClientRegistry;\nuse Stevenmaguire\\OAuth2\\Client\\Provider\\KeycloakResourceOwner;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\Routing\\RouterInterface;\nuse Symfony\\Component\\Security\\Core\\Exception\\CustomUserMessageAuthenticationException;\nuse Symfony\\Component\\Security\\Http\\Authenticator\\Passport\\Badge\\RememberMeBadge;\nuse Symfony\\Component\\Security\\Http\\Authenticator\\Passport\\Badge\\UserBadge;\nuse Symfony\\Component\\Security\\Http\\Authenticator\\Passport\\Passport;\nuse Symfony\\Component\\Security\\Http\\Authenticator\\Passport\\SelfValidatingPassport;\n\nclass KeycloakAuthenticator extends MbinOAuthAuthenticatorBase\n{\n    public function __construct(\n        private readonly ClientRegistry $clientRegistry,\n        RouterInterface $router,\n        private readonly EntityManagerInterface $entityManager,\n        private readonly UserManager $userManager,\n        private readonly IpResolver $ipResolver,\n        private readonly Slugger $slugger,\n        private readonly UserRepository $userRepository,\n        private readonly SettingsManager $settingsManager,\n    ) {\n        parent::__construct($router);\n    }\n\n    public function supports(Request $request): ?bool\n    {\n        return 'oauth_keycloak_verify' === $request->attributes->get('_route');\n    }\n\n    public function authenticate(Request $request): Passport\n    {\n        $client = $this->clientRegistry->getClient('keycloak');\n        $slugger = $this->slugger;\n\n        $provider = $client->getOAuth2Provider();\n\n        $accessToken = $provider->getAccessToken('authorization_code', [\n            'code' => $request->query->get('code'),\n        ]);\n\n        $rememberBadge = new RememberMeBadge();\n        $rememberBadge = $rememberBadge->enable();\n\n        return new SelfValidatingPassport(\n            new UserBadge($accessToken->getToken(), function () use ($accessToken, $client, $slugger, $request) {\n                /** @var KeycloakResourceOwner $keycloakUser */\n                $keycloakUser = $client->fetchUserFromToken($accessToken);\n\n                $existingUser = $this->entityManager->getRepository(User::class)->findOneBy(\n                    ['oauthKeycloakId' => $keycloakUser->getId()]\n                );\n\n                if ($existingUser) {\n                    return $existingUser;\n                }\n\n                $user = $this->userRepository->findOneBy(['email' => $keycloakUser->getEmail()]);\n\n                if ($user) {\n                    $user->oauthKeycloakId = $keycloakUser->getId();\n\n                    $this->entityManager->persist($user);\n                    $this->entityManager->flush();\n\n                    return $user;\n                }\n\n                if (false === $this->settingsManager->get('MBIN_SSO_REGISTRATIONS_ENABLED')) {\n                    throw new CustomUserMessageAuthenticationException('MBIN_SSO_REGISTRATIONS_ENABLED');\n                }\n\n                $username = $slugger->slug($keycloakUser->toArray()['preferred_username']);\n\n                if ($this->userRepository->count(['username' => $username]) > 0) {\n                    $username .= rand(1, 999);\n                    $request->getSession()->set('is_newly_created', true);\n                }\n\n                $dto = (new UserDto())->create(\n                    $username,\n                    $keycloakUser->getEmail()\n                );\n\n                $dto->plainPassword = bin2hex(random_bytes(20));\n                $dto->ip = $this->ipResolver->resolve();\n\n                $user = $this->userManager->create($dto, false);\n                $user->oauthKeycloakId = $keycloakUser->getId();\n                $user->isVerified = true;\n\n                $this->entityManager->persist($user);\n                $this->entityManager->flush();\n\n                return $user;\n            }),\n            [\n                $rememberBadge,\n            ]\n        );\n    }\n}\n"
  },
  {
    "path": "src/Security/MbinOAuthAuthenticatorBase.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Security;\n\nuse KnpU\\OAuth2ClientBundle\\Security\\Authenticator\\OAuth2Authenticator;\nuse Symfony\\Component\\HttpFoundation\\RedirectResponse;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\Routing\\RouterInterface;\nuse Symfony\\Component\\Security\\Core\\Authentication\\Token\\TokenInterface;\nuse Symfony\\Component\\Security\\Core\\Exception\\AuthenticationException;\n\nabstract class MbinOAuthAuthenticatorBase extends OAuth2Authenticator\n{\n    public function __construct(\n        protected readonly RouterInterface $router,\n    ) {\n    }\n\n    public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response\n    {\n        $session = $request->getSession();\n        if ($url = $session->get('_security.main.target_path')) {\n            $targetUrl = $url;\n        } elseif ($session->get('is_newly_created')) {\n            $targetUrl = $this->router->generate('user_settings_profile');\n            $session->remove('is_newly_created');\n        } else {\n            $targetUrl = $this->router->generate('front');\n        }\n\n        return new RedirectResponse($targetUrl);\n    }\n\n    public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response\n    {\n        $message = strtr($exception->getMessageKey(), $exception->getMessageData());\n\n        if ('MBIN_SSO_REGISTRATIONS_ENABLED' === $message) {\n            $session = $request->getSession();\n            $session->getFlashBag()->add('error', 'sso_registrations_enabled.error');\n\n            return new RedirectResponse($this->router->generate('app_login'));\n        }\n\n        return new Response($message, Response::HTTP_FORBIDDEN);\n    }\n}\n"
  },
  {
    "path": "src/Security/OAuth/ClientCredentialsGrant.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n/**\n * OAuth 2.0 Client credentials grant.\n *\n * @author      Alex Bilbie <hello@alexbilbie.com>\n * @copyright   Copyright (c) Alex Bilbie\n * @license     http://mit-license.org/\n *\n * @see        https://github.com/thephpleague/oauth2-server\n */\n\nnamespace App\\Security\\OAuth;\n\nuse App\\Entity\\Client;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse League\\OAuth2\\Server\\Exception\\OAuthServerException;\nuse League\\OAuth2\\Server\\Grant\\AbstractGrant;\nuse League\\OAuth2\\Server\\Grant\\ClientCredentialsGrant as LeagueClientCredentialsGrant;\nuse League\\OAuth2\\Server\\RequestAccessTokenEvent;\nuse League\\OAuth2\\Server\\RequestEvent;\nuse League\\OAuth2\\Server\\ResponseTypes\\ResponseTypeInterface;\nuse Psr\\Http\\Message\\ServerRequestInterface;\nuse Symfony\\Component\\DependencyInjection\\Attribute\\AsDecorator;\nuse Symfony\\Contracts\\Service\\Attribute\\Required;\n\n/**\n * Client credentials grant class. Modified to provide a bot user agent for the client.\n */\n#[AsDecorator(LeagueClientCredentialsGrant::class)]\nclass ClientCredentialsGrant extends AbstractGrant\n{\n    protected EntityManagerInterface $entityManager;\n\n    #[Required]\n    public function setEntityManager(EntityManagerInterface $entityManager)\n    {\n        $this->entityManager = $entityManager;\n    }\n\n    private function getKbinClientEntityOrFail(string $clientId, ServerRequestInterface $request): Client\n    {\n        $clientEntityInterface = $this->getClientEntityOrFail($clientId, $request);\n\n        $repository = $this->entityManager->getRepository(Client::class);\n\n        /** @var ?Client $client */\n        $client = $repository->findOneBy(['identifier' => $clientEntityInterface->getIdentifier()]);\n\n        if (false === $client instanceof Client) {\n            $this->getEmitter()->emit(new RequestEvent(RequestEvent::CLIENT_AUTHENTICATION_FAILED, $request));\n            throw OAuthServerException::invalidClient($request);\n        }\n\n        if (null === $client->getUser()) {\n            $this->getEmitter()->emit(new RequestEvent(RequestEvent::CLIENT_AUTHENTICATION_FAILED, $request));\n            throw OAuthServerException::invalidClient($request);\n        }\n\n        return $client;\n    }\n\n    public function respondToAccessTokenRequest(\n        ServerRequestInterface $request,\n        ResponseTypeInterface $responseType,\n        \\DateInterval $accessTokenTTL,\n    ): ResponseTypeInterface {\n        list($clientId) = $this->getClientCredentials($request);\n\n        $client = $this->getKbinClientEntityOrFail($clientId, $request);\n\n        if (!$client->isConfidential()) {\n            $this->getEmitter()->emit(new RequestEvent(RequestEvent::CLIENT_AUTHENTICATION_FAILED, $request));\n\n            throw OAuthServerException::invalidClient($request);\n        }\n\n        // Validate request\n        $this->validateClient($request);\n\n        $scopes = $this->validateScopes($this->getRequestParameter('scope', $request, $this->defaultScope));\n\n        // Finalize the requested scopes\n        $finalizedScopes = $this->scopeRepository->finalizeScopes($scopes, $this->getIdentifier(), $client);\n\n        // Issue and persist access token\n        $accessToken = $this->issueAccessToken($accessTokenTTL, $client, $client->getUser()->getUserIdentifier(), $finalizedScopes);\n\n        // Send event to emitter\n        $this->getEmitter()->emit(new RequestAccessTokenEvent(RequestEvent::ACCESS_TOKEN_ISSUED, $request, $accessToken));\n\n        // Inject access token into response type\n        $responseType->setAccessToken($accessToken);\n\n        return $responseType;\n    }\n\n    public function getIdentifier(): string\n    {\n        return 'client_credentials';\n    }\n}\n"
  },
  {
    "path": "src/Security/PrivacyPortalAuthenticator.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Security;\n\nuse App\\DTO\\UserDto;\nuse App\\Entity\\User;\nuse App\\Repository\\UserRepository;\nuse App\\Service\\IpResolver;\nuse App\\Service\\SettingsManager;\nuse App\\Service\\UserManager;\nuse App\\Utils\\Slugger;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse KnpU\\OAuth2ClientBundle\\Client\\ClientRegistry;\nuse League\\OAuth2\\Client\\Provider\\PrivacyPortalResourceOwner;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\Routing\\RouterInterface;\nuse Symfony\\Component\\Security\\Core\\Exception\\CustomUserMessageAuthenticationException;\nuse Symfony\\Component\\Security\\Http\\Authenticator\\Passport\\Badge\\UserBadge;\nuse Symfony\\Component\\Security\\Http\\Authenticator\\Passport\\Passport;\nuse Symfony\\Component\\Security\\Http\\Authenticator\\Passport\\SelfValidatingPassport;\n\nclass PrivacyPortalAuthenticator extends MbinOAuthAuthenticatorBase\n{\n    public function __construct(\n        private readonly ClientRegistry $clientRegistry,\n        RouterInterface $router,\n        private readonly EntityManagerInterface $entityManager,\n        private readonly UserManager $userManager,\n        private readonly SettingsManager $settingsManager,\n        private readonly UserRepository $userRepository,\n        private readonly IpResolver $ipResolver,\n        private readonly Slugger $slugger,\n    ) {\n        parent::__construct($router);\n    }\n\n    public function supports(Request $request): ?bool\n    {\n        return 'oauth_privacyportal_verify' === $request->attributes->get('_route');\n    }\n\n    public function authenticate(Request $request): Passport\n    {\n        $client = $this->clientRegistry->getClient('privacyportal');\n        $accessToken = $this->fetchAccessToken($client);\n        $slugger = $this->slugger;\n\n        return new SelfValidatingPassport(\n            new UserBadge($accessToken->getToken(), function () use ($accessToken, $client, $slugger, $request) {\n                /** @var PrivacyPortalResourceOwner $privacyPortalUser */\n                $privacyPortalUser = $client->fetchUserFromToken($accessToken);\n\n                $existingUser = $this->entityManager->getRepository(User::class)->findOneBy(\n                    ['oauthPrivacyPortalId' => (string) $privacyPortalUser->getId()]\n                );\n\n                if ($existingUser) {\n                    return $existingUser;\n                }\n\n                $user = $this->entityManager->getRepository(User::class)->findOneBy(['email' => $privacyPortalUser->getEmail()]\n                );\n\n                if ($user) {\n                    $user->oauthPrivacyPortalId = (string) $privacyPortalUser->getId();\n\n                    $this->entityManager->persist($user);\n                    $this->entityManager->flush();\n\n                    return $user;\n                }\n\n                if (false === $this->settingsManager->get('MBIN_SSO_REGISTRATIONS_ENABLED')) {\n                    throw new CustomUserMessageAuthenticationException('MBIN_SSO_REGISTRATIONS_ENABLED');\n                }\n\n                $username = $slugger->slug($privacyPortalUser->getName());\n\n                if ($this->userRepository->count(['username' => $username]) > 0) {\n                    $username .= rand(1, 9999);\n                    $request->getSession()->set('is_newly_created', true);\n                }\n\n                $dto = (new UserDto())->create(\n                    $username,\n                    $privacyPortalUser->getEmail()\n                );\n\n                $dto->plainPassword = bin2hex(random_bytes(20));\n                $dto->ip = $this->ipResolver->resolve();\n\n                $user = $this->userManager->create($dto, false);\n                $user->oauthPrivacyPortalId = (string) $privacyPortalUser->getId();\n                $user->isVerified = true;\n\n                $this->entityManager->persist($user);\n                $this->entityManager->flush();\n\n                return $user;\n            })\n        );\n    }\n}\n"
  },
  {
    "path": "src/Security/SimpleLoginAuthenticator.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Security;\n\nuse App\\DTO\\UserDto;\nuse App\\Entity\\Image;\nuse App\\Entity\\User;\nuse App\\Factory\\ImageFactory;\nuse App\\Provider\\SimpleLoginResourceOwner;\nuse App\\Repository\\ImageRepository;\nuse App\\Repository\\UserRepository;\nuse App\\Service\\ImageManagerInterface;\nuse App\\Service\\IpResolver;\nuse App\\Service\\SettingsManager;\nuse App\\Service\\UserManager;\nuse App\\Utils\\Slugger;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse KnpU\\OAuth2ClientBundle\\Client\\ClientRegistry;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\Routing\\RouterInterface;\nuse Symfony\\Component\\Security\\Core\\Exception\\CustomUserMessageAuthenticationException;\nuse Symfony\\Component\\Security\\Http\\Authenticator\\Passport\\Badge\\RememberMeBadge;\nuse Symfony\\Component\\Security\\Http\\Authenticator\\Passport\\Badge\\UserBadge;\nuse Symfony\\Component\\Security\\Http\\Authenticator\\Passport\\Passport;\nuse Symfony\\Component\\Security\\Http\\Authenticator\\Passport\\SelfValidatingPassport;\n\nclass SimpleLoginAuthenticator extends MbinOAuthAuthenticatorBase\n{\n    public function __construct(\n        private readonly ClientRegistry $clientRegistry,\n        RouterInterface $router,\n        private readonly EntityManagerInterface $entityManager,\n        private readonly UserManager $userManager,\n        private readonly ImageManagerInterface $imageManager,\n        private readonly ImageFactory $imageFactory,\n        private readonly ImageRepository $imageRepository,\n        private readonly IpResolver $ipResolver,\n        private readonly Slugger $slugger,\n        private readonly UserRepository $userRepository,\n        private readonly SettingsManager $settingsManager,\n    ) {\n        parent::__construct($router);\n    }\n\n    public function supports(Request $request): ?bool\n    {\n        return 'oauth_simplelogin_verify' === $request->attributes->get('_route');\n    }\n\n    public function authenticate(Request $request): Passport\n    {\n        $client = $this->clientRegistry->getClient('simplelogin');\n        $slugger = $this->slugger;\n\n        $provider = $client->getOAuth2Provider();\n\n        $accessToken = $provider->getAccessToken('authorization_code', [\n            'code' => $request->query->get('code'),\n        ]);\n\n        $rememberBadge = new RememberMeBadge();\n        $rememberBadge = $rememberBadge->enable();\n\n        return new SelfValidatingPassport(\n            new UserBadge($accessToken->getToken(), function () use ($accessToken, $client, $slugger, $request) {\n                /** @var SimpleLoginResourceOwner $simpleloginUser */\n                $simpleloginUser = $client->fetchUserFromToken($accessToken);\n\n                $existingUser = $this->entityManager->getRepository(User::class)->findOneBy(\n                    ['oauthSimpleLoginId' => $simpleloginUser->getId()]\n                );\n\n                if ($existingUser) {\n                    return $existingUser;\n                }\n\n                $user = $this->userRepository->findOneBy(['email' => $simpleloginUser->getEmail()]);\n\n                if ($user) {\n                    $user->oauthSimpleLoginId = $simpleloginUser->getId();\n\n                    $this->entityManager->persist($user);\n                    $this->entityManager->flush();\n\n                    return $user;\n                }\n\n                if (false === $this->settingsManager->get('MBIN_SSO_REGISTRATIONS_ENABLED')) {\n                    throw new CustomUserMessageAuthenticationException('MBIN_SSO_REGISTRATIONS_ENABLED');\n                }\n\n                $name = $simpleloginUser->getName();\n                $name = preg_replace('/\\s+/', '', $name); // remove all whitespace\n                $name = preg_replace('#[[:punct:]]#', '', $name); // remove all punctuation\n\n                $username = $slugger->slug($name);\n\n                $usernameTaken = $this->entityManager->getRepository(User::class)->findOneBy(\n                    ['username' => $username]\n                );\n\n                if ($usernameTaken) {\n                    $username = $username.rand(1, 999);\n                    $request->getSession()->set('is_newly_created', true);\n                }\n\n                $dto = (new UserDto())->create(\n                    $username,\n                    $simpleloginUser->getEmail()\n                );\n\n                $avatar = $this->getAvatar($simpleloginUser->getPictureUrl());\n\n                if ($avatar) {\n                    $dto->avatar = $this->imageFactory->createDto($avatar);\n                }\n\n                $dto->plainPassword = bin2hex(random_bytes(20));\n                $dto->ip = $this->ipResolver->resolve();\n\n                $user = $this->userManager->create($dto, false);\n                $user->oauthSimpleLoginId = $simpleloginUser->getId();\n                $user->avatar = $this->getAvatar($simpleloginUser->getPictureUrl());\n                $user->isVerified = true;\n\n                $this->entityManager->persist($user);\n                $this->entityManager->flush();\n\n                return $user;\n            }),\n            [\n                $rememberBadge,\n            ]\n        );\n    }\n\n    private function getAvatar(?string $pictureUrl): ?Image\n    {\n        if (!$pictureUrl) {\n            return null;\n        }\n\n        try {\n            $tempFile = $this->imageManager->download($pictureUrl);\n        } catch (\\Exception $e) {\n            $tempFile = null;\n        }\n\n        if ($tempFile) {\n            $image = $this->imageRepository->findOrCreateFromPath($tempFile);\n            if ($image) {\n                $this->entityManager->persist($image);\n                $this->entityManager->flush();\n            }\n        }\n\n        return $image ?? null;\n    }\n}\n"
  },
  {
    "path": "src/Security/UserChecker.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Security;\n\nuse App\\Entity\\User as AppUser;\nuse App\\Enums\\EApplicationStatus;\nuse App\\Service\\UserManager;\nuse Symfony\\Component\\Routing\\Generator\\UrlGeneratorInterface;\nuse Symfony\\Component\\Security\\Core\\Exception\\BadCredentialsException;\nuse Symfony\\Component\\Security\\Core\\Exception\\CustomUserMessageAccountStatusException;\nuse Symfony\\Component\\Security\\Core\\User\\UserCheckerInterface;\nuse Symfony\\Component\\Security\\Core\\User\\UserInterface;\nuse Symfony\\Contracts\\Translation\\TranslatorInterface;\n\nclass UserChecker implements UserCheckerInterface\n{\n    public function __construct(\n        private readonly TranslatorInterface $translator,\n        private readonly UrlGeneratorInterface $urlGenerator,\n        private readonly UserManager $userManager,\n    ) {\n    }\n\n    public function checkPreAuth(UserInterface $user): void\n    {\n        if (!$user instanceof AppUser) {\n            return;\n        }\n\n        if ($user->apId) {\n            throw new BadCredentialsException();\n        }\n\n        if ($user->isDeleted) {\n            if ($user->markedForDeletionAt > (new \\DateTime('now'))) {\n                $this->userManager->removeDeleteRequest($user);\n            } else {\n                throw new BadCredentialsException();\n            }\n        }\n\n        $applicationStatus = $user->getApplicationStatus();\n        if (EApplicationStatus::Approved !== $applicationStatus) {\n            if (EApplicationStatus::Pending === $applicationStatus) {\n                throw new CustomUserMessageAccountStatusException($this->translator->trans('your_account_is_not_yet_approved'));\n            } elseif (EApplicationStatus::Rejected === $applicationStatus) {\n                throw new BadCredentialsException();\n            } else {\n                throw new \\LogicException(\"Unrecognized application status $applicationStatus->value\");\n            }\n        }\n\n        if (!$user->isVerified) {\n            $resendEmailActivationUrl = $this->urlGenerator->generate('app_resend_email_activation');\n            throw new CustomUserMessageAccountStatusException($this->translator->trans('your_account_is_not_active', ['%link_target%' => $resendEmailActivationUrl]));\n        }\n\n        if ($user->isBanned) {\n            throw new CustomUserMessageAccountStatusException($this->translator->trans('your_account_has_been_banned'));\n        }\n    }\n\n    public function checkPostAuth(UserInterface $user): void\n    {\n        if (!$user instanceof AppUser) {\n            return;\n        }\n    }\n}\n"
  },
  {
    "path": "src/Security/Voter/EntryCommentVoter.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Security\\Voter;\n\nuse App\\Entity\\EntryComment;\nuse App\\Entity\\User;\nuse Symfony\\Component\\Security\\Core\\Authentication\\Token\\TokenInterface;\nuse Symfony\\Component\\Security\\Core\\Authorization\\Voter\\Voter;\n\nclass EntryCommentVoter extends Voter\n{\n    public const EDIT = 'edit';\n    public const DELETE = 'delete';\n    public const PURGE = 'purge';\n    public const VOTE = 'vote';\n    public const MODERATE = 'moderate';\n\n    protected function supports(string $attribute, $subject): bool\n    {\n        return $subject instanceof EntryComment && \\in_array(\n            $attribute,\n            [self::EDIT, self::DELETE, self::PURGE, self::VOTE, self::MODERATE],\n            true\n        );\n    }\n\n    protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool\n    {\n        $user = $token->getUser();\n\n        if (!$user instanceof User) {\n            return false;\n        }\n\n        return match ($attribute) {\n            self::EDIT => $this->canEdit($subject, $user),\n            self::PURGE => $this->canPurge($subject, $user),\n            self::DELETE => $this->canDelete($subject, $user),\n            self::VOTE => $this->canVote($subject, $user),\n            self::MODERATE => $this->canModerate($subject, $user),\n            default => throw new \\LogicException(),\n        };\n    }\n\n    private function canEdit(EntryComment $comment, User $user): bool\n    {\n        if ($comment->user === $user) {\n            return true;\n        }\n\n        return false;\n    }\n\n    private function canPurge(EntryComment $comment, User $user): bool\n    {\n        return $user->isAdmin();\n    }\n\n    private function canDelete(EntryComment $comment, User $user): bool\n    {\n        if ($user->isAdmin() || $user->isModerator()) {\n            return true;\n        }\n\n        if ($comment->user === $user) {\n            return true;\n        }\n\n        if ($comment->entry->magazine->userIsModerator($user)) {\n            return true;\n        }\n\n        return false;\n    }\n\n    private function canVote(EntryComment $comment, User $user): bool\n    {\n        //        if ($comment->user === $user) {\n        //            return false;\n        //        }\n\n        if ($comment->entry->magazine->isBanned($user) || $user->isBanned()) {\n            return false;\n        }\n\n        return true;\n    }\n\n    private function canModerate(EntryComment $comment, User $user): bool\n    {\n        return $comment->magazine->userIsModerator($user) || $user->isAdmin() || $user->isModerator();\n    }\n}\n"
  },
  {
    "path": "src/Security/Voter/EntryVoter.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Security\\Voter;\n\nuse App\\Entity\\Entry;\nuse App\\Entity\\User;\nuse Symfony\\Component\\Security\\Core\\Authentication\\Token\\TokenInterface;\nuse Symfony\\Component\\Security\\Core\\Authorization\\Voter\\Voter;\n\nclass EntryVoter extends Voter\n{\n    public const CREATE = 'create';\n    public const EDIT = 'edit';\n    public const DELETE = 'delete';\n    public const PURGE = 'purge';\n    public const COMMENT = 'comment';\n    public const VOTE = 'vote';\n    public const MODERATE = 'moderate';\n    public const LOCK = 'lock';\n\n    protected function supports(string $attribute, $subject): bool\n    {\n        return $subject instanceof Entry\n            && \\in_array(\n                $attribute,\n                [self::CREATE, self::EDIT, self::DELETE, self::PURGE, self::COMMENT, self::VOTE, self::MODERATE, self::LOCK],\n                true\n            );\n    }\n\n    protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool\n    {\n        $user = $token->getUser();\n\n        if (!$user instanceof User) {\n            return false;\n        }\n\n        return match ($attribute) {\n            self::EDIT => $this->canEdit($subject, $user),\n            self::DELETE => $this->canDelete($subject, $user),\n            self::PURGE => $this->canPurge($subject, $user),\n            self::COMMENT => $this->canComment($subject, $user),\n            self::VOTE => $this->canVote($subject, $user),\n            self::MODERATE => $this->canModerate($subject, $user),\n            self::LOCK => $subject->user === $user || $this->canModerate($subject, $user),\n            default => throw new \\LogicException(),\n        };\n    }\n\n    private function canEdit(Entry $entry, User $user): bool\n    {\n        if ($user->isAdmin() || $user->isModerator()) {\n            return true;\n        }\n\n        if ($entry->user === $user) {\n            return true;\n        }\n\n        return false;\n    }\n\n    private function canDelete(Entry $entry, User $user): bool\n    {\n        if ($user->isAdmin() || $user->isModerator()) {\n            return true;\n        }\n\n        if ($entry->user === $user) {\n            return true;\n        }\n\n        if ($entry->magazine->userIsModerator($user)) {\n            return true;\n        }\n\n        return false;\n    }\n\n    private function canPurge(Entry $entry, User $user): bool\n    {\n        return $user->isAdmin();\n    }\n\n    private function canComment(Entry $entry, User $user): bool\n    {\n        return !$entry->magazine->isBanned($user) && !$user->isBanned;\n    }\n\n    private function canVote(Entry $entry, User $user): bool\n    {\n        //        if ($entry->user === $user) {\n        //            return false;\n        //        }\n\n        if ($entry->magazine->isBanned($user) || $user->isBanned()) {\n            return false;\n        }\n\n        return true;\n    }\n\n    private function canModerate(Entry $entry, User $user): bool\n    {\n        return $entry->magazine->userIsModerator($user) || $user->isAdmin() || $user->isModerator();\n    }\n}\n"
  },
  {
    "path": "src/Security/Voter/FilterListVoter.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Security\\Voter;\n\nuse App\\Entity\\User;\nuse App\\Entity\\UserFilterList;\nuse Symfony\\Component\\Security\\Core\\Authentication\\Token\\TokenInterface;\nuse Symfony\\Component\\Security\\Core\\Authorization\\Voter\\Voter;\n\nclass FilterListVoter extends Voter\n{\n    public const string EDIT = 'edit';\n    public const string DELETE = 'delete';\n\n    protected function supports(string $attribute, $subject): bool\n    {\n        return $subject instanceof UserFilterList\n            && \\in_array(\n                $attribute,\n                [self::EDIT, self::DELETE],\n                true\n            );\n    }\n\n    protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool\n    {\n        $user = $token->getUser();\n\n        if (!$user instanceof User) {\n            return false;\n        }\n\n        return match ($attribute) {\n            self::EDIT, self::DELETE => $this->isOwner($subject, $user),\n            default => throw new \\LogicException(),\n        };\n    }\n\n    private function isOwner(UserFilterList $list, User $loggedInUser): bool\n    {\n        if ($list->user === $loggedInUser) {\n            return true;\n        }\n\n        return false;\n    }\n}\n"
  },
  {
    "path": "src/Security/Voter/MagazineVoter.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Security\\Voter;\n\nuse App\\Entity\\Magazine;\nuse App\\Entity\\User;\nuse Symfony\\Component\\Security\\Core\\Authentication\\Token\\TokenInterface;\nuse Symfony\\Component\\Security\\Core\\Authorization\\Voter\\Voter;\n\nclass MagazineVoter extends Voter\n{\n    public const CREATE_CONTENT = 'create_content';\n    public const EDIT = 'edit';\n    public const DELETE = 'delete';\n    public const PURGE = 'purge';\n    public const MODERATE = 'moderate';\n    public const SUBSCRIBE = 'subscribe';\n    public const BLOCK = 'block';\n\n    protected function supports(string $attribute, $subject): bool\n    {\n        return $subject instanceof Magazine\n            && \\in_array(\n                $attribute,\n                [\n                    self::CREATE_CONTENT,\n                    self::EDIT,\n                    self::DELETE,\n                    self::PURGE,\n                    self::MODERATE,\n                    self::SUBSCRIBE,\n                    self::BLOCK,\n                ],\n                true\n            );\n    }\n\n    protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool\n    {\n        $user = $token->getUser();\n\n        if (!$user instanceof User) {\n            return false;\n        }\n\n        return match ($attribute) {\n            self::CREATE_CONTENT => $this->canCreateContent($subject, $user),\n            self::EDIT => $this->canEdit($subject, $user),\n            self::DELETE => $this->canDelete($subject, $user),\n            self::PURGE => $this->canPurge($subject, $user),\n            self::MODERATE => $this->canModerate($subject, $user),\n            self::SUBSCRIBE => $this->canSubscribe($subject, $user),\n            self::BLOCK => $this->canBlock($subject, $user),\n            default => throw new \\LogicException(),\n        };\n    }\n\n    private function canCreateContent(Magazine $magazine, User $user): bool\n    {\n        return !$magazine->isBanned($user) && !$user->isBanned();\n    }\n\n    private function canEdit(Magazine $magazine, User $user): bool\n    {\n        return $magazine->userIsOwner($user) || $user->isAdmin() || $user->isModerator();\n    }\n\n    private function canDelete(Magazine $magazine, User $user): bool\n    {\n        return $magazine->userIsOwner($user) || $user->isAdmin() || $user->isModerator();\n    }\n\n    private function canPurge(Magazine $magazine, User $user): bool\n    {\n        return $user->isAdmin();\n    }\n\n    private function canModerate(Magazine $magazine, User $user): bool\n    {\n        return $magazine->userIsModerator($user) || $user->isAdmin() || $user->isModerator();\n    }\n\n    public function canSubscribe(Magazine $magazine, User $user): bool\n    {\n        return !$magazine->isBanned($user) && !$user->isBanned();\n    }\n\n    public function canBlock(Magazine $magazine, User $user): bool\n    {\n        if ($magazine->userIsOwner($user)) {\n            return false;\n        }\n\n        return !$magazine->isBanned($user) && !$user->isBanned();\n    }\n}\n"
  },
  {
    "path": "src/Security/Voter/MessageThreadVoter.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Security\\Voter;\n\nuse App\\Entity\\MessageThread;\nuse App\\Entity\\User;\nuse Symfony\\Component\\Security\\Core\\Authentication\\Token\\TokenInterface;\nuse Symfony\\Component\\Security\\Core\\Authorization\\Voter\\Voter;\n\nclass MessageThreadVoter extends Voter\n{\n    public const SHOW = 'show';\n    public const REPLY = 'reply';\n\n    protected function supports(string $attribute, $subject): bool\n    {\n        return $subject instanceof MessageThread\n            && \\in_array(\n                $attribute,\n                [self::SHOW, self::REPLY],\n                true\n            );\n    }\n\n    protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool\n    {\n        $user = $token->getUser();\n\n        if (!$user instanceof User) {\n            return false;\n        }\n\n        return match ($attribute) {\n            self::SHOW => $this->canShow($subject, $user),\n            self::REPLY => $this->canReply($subject, $user),\n            default => throw new \\LogicException(),\n        };\n    }\n\n    private function canShow(MessageThread $thread, User $user): bool\n    {\n        if (!$user instanceof User) {\n            return false;\n        }\n\n        if (!$thread->userIsParticipant($user)) {\n            return false;\n        }\n\n        return true;\n    }\n\n    private function canReply(MessageThread $thread, User $user): bool\n    {\n        if (!$user instanceof User) {\n            return false;\n        }\n\n        if (!$thread->userIsParticipant($user)) {\n            return false;\n        }\n\n        return true;\n    }\n}\n"
  },
  {
    "path": "src/Security/Voter/MessageVoter.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Security\\Voter;\n\nuse App\\Entity\\Message;\nuse App\\Entity\\User;\nuse Symfony\\Component\\Security\\Core\\Authentication\\Token\\TokenInterface;\nuse Symfony\\Component\\Security\\Core\\Authorization\\Voter\\Voter;\n\nclass MessageVoter extends Voter\n{\n    public const DELETE = 'delete';\n\n    protected function supports(string $attribute, $subject): bool\n    {\n        return $subject instanceof Message\n            && \\in_array(\n                $attribute,\n                [self::DELETE],\n                true\n            );\n    }\n\n    protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool\n    {\n        $user = $token->getUser();\n\n        if (!$user instanceof User) {\n            return false;\n        }\n\n        return match ($attribute) {\n            self::DELETE => $this->canDelete($subject, $user),\n            default => throw new \\LogicException(),\n        };\n    }\n\n    private function canDelete(Message $message, User $user): bool\n    {\n        return false;\n    }\n}\n"
  },
  {
    "path": "src/Security/Voter/NotificationVoter.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Security\\Voter;\n\nuse App\\Entity\\Notification;\nuse App\\Entity\\User;\nuse Symfony\\Component\\Security\\Core\\Authentication\\Token\\TokenInterface;\nuse Symfony\\Component\\Security\\Core\\Authorization\\Voter\\Voter;\n\nclass NotificationVoter extends Voter\n{\n    public const VIEW = 'view';\n    public const DELETE = 'delete';\n\n    protected function supports(string $attribute, $subject): bool\n    {\n        return $subject instanceof Notification\n            && \\in_array(\n                $attribute,\n                [self::VIEW, self::DELETE],\n                true\n            );\n    }\n\n    protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool\n    {\n        $user = $token->getUser();\n\n        if (!$user instanceof User) {\n            return false;\n        }\n\n        return match ($attribute) {\n            self::VIEW => $this->canView($subject, $user),\n            self::DELETE => $this->canDelete($subject, $user),\n            default => throw new \\LogicException(),\n        };\n    }\n\n    private function canView(Notification $notification, User $user): bool\n    {\n        return $notification->user->getId() === $user->getId();\n    }\n\n    private function canDelete(Notification $notification, User $user): bool\n    {\n        return $notification->user->getId() === $user->getId();\n    }\n}\n"
  },
  {
    "path": "src/Security/Voter/OAuth2UserConsentVoter.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Security\\Voter;\n\nuse App\\Entity\\OAuth2UserConsent;\nuse App\\Entity\\User;\nuse Symfony\\Component\\Security\\Core\\Authentication\\Token\\TokenInterface;\nuse Symfony\\Component\\Security\\Core\\Authorization\\Voter\\Voter;\n\nclass OAuth2UserConsentVoter extends Voter\n{\n    public const VIEW = 'view';\n    public const EDIT = 'edit';\n\n    protected function supports(string $attribute, $subject): bool\n    {\n        return $subject instanceof OAuth2UserConsent\n            && \\in_array(\n                $attribute,\n                [self::VIEW, self::EDIT],\n                true\n            );\n    }\n\n    protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool\n    {\n        $user = $token->getUser();\n\n        if (!$user instanceof User) {\n            return false;\n        }\n\n        return match ($attribute) {\n            self::VIEW => $this->canView($subject, $user),\n            self::EDIT => $this->canEdit($subject, $user),\n            default => throw new \\LogicException(),\n        };\n    }\n\n    private function canView(OAuth2UserConsent $consent, User $user): bool\n    {\n        if ($consent->getUser() !== $user) {\n            return false;\n        }\n\n        return true;\n    }\n\n    private function canEdit(OAuth2UserConsent $consent, User $user): bool\n    {\n        if ($consent->getUser() !== $user) {\n            return false;\n        }\n\n        return true;\n    }\n}\n"
  },
  {
    "path": "src/Security/Voter/PostCommentVoter.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Security\\Voter;\n\nuse App\\Entity\\PostComment;\nuse App\\Entity\\User;\nuse Symfony\\Component\\Security\\Core\\Authentication\\Token\\TokenInterface;\nuse Symfony\\Component\\Security\\Core\\Authorization\\Voter\\Voter;\n\nclass PostCommentVoter extends Voter\n{\n    public const EDIT = 'edit';\n    public const DELETE = 'delete';\n    public const PURGE = 'purge';\n    public const VOTE = 'vote';\n    public const MODERATE = 'moderate';\n\n    protected function supports(string $attribute, $subject): bool\n    {\n        return $subject instanceof PostComment && \\in_array(\n            $attribute,\n            [self::EDIT, self::DELETE, self::PURGE, self::VOTE, self::MODERATE],\n            true\n        );\n    }\n\n    protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool\n    {\n        $user = $token->getUser();\n\n        if (!$user instanceof User) {\n            return false;\n        }\n\n        return match ($attribute) {\n            self::EDIT => $this->canEdit($subject, $user),\n            self::PURGE => $this->canPurge($subject, $user),\n            self::DELETE => $this->canDelete($subject, $user),\n            self::VOTE => $this->canVote($subject, $user),\n            self::MODERATE => $this->canModerate($subject, $user),\n            default => throw new \\LogicException(),\n        };\n    }\n\n    private function canEdit(PostComment $comment, User $user): bool\n    {\n        if ($comment->user === $user) {\n            return true;\n        }\n\n        return false;\n    }\n\n    private function canPurge(PostComment $comment, User $user): bool\n    {\n        return $user->isAdmin();\n    }\n\n    private function canDelete(PostComment $comment, User $user): bool\n    {\n        if ($user->isAdmin() || $user->isModerator()) {\n            return true;\n        }\n\n        if ($comment->user === $user) {\n            return true;\n        }\n\n        if ($comment->post->magazine->userIsModerator($user)) {\n            return true;\n        }\n\n        return false;\n    }\n\n    private function canVote(PostComment $comment, User $user): bool\n    {\n        //        if ($comment->user === $user) {\n        //            return false;\n        //        }\n\n        if ($comment->post->magazine->isBanned($user) || $user->isBanned()) {\n            return false;\n        }\n\n        return true;\n    }\n\n    private function canModerate(PostComment $comment, User $user): bool\n    {\n        return $comment->magazine->userIsModerator($user) || $user->isAdmin() || $user->isModerator();\n    }\n}\n"
  },
  {
    "path": "src/Security/Voter/PostVoter.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Security\\Voter;\n\nuse App\\Entity\\Post;\nuse App\\Entity\\User;\nuse Symfony\\Component\\Security\\Core\\Authentication\\Token\\TokenInterface;\nuse Symfony\\Component\\Security\\Core\\Authorization\\Voter\\Voter;\n\nclass PostVoter extends Voter\n{\n    public const CREATE = 'create';\n    public const EDIT = 'edit';\n    public const DELETE = 'delete';\n    public const PURGE = 'purge';\n    public const COMMENT = 'comment';\n    public const VOTE = 'vote';\n    public const MODERATE = 'moderate';\n    public const LOCK = 'lock';\n\n    protected function supports(string $attribute, $subject): bool\n    {\n        return $subject instanceof Post\n            && \\in_array(\n                $attribute,\n                [self::CREATE, self::EDIT, self::DELETE, self::PURGE, self::COMMENT, self::VOTE, self::MODERATE, self::LOCK],\n                true\n            );\n    }\n\n    protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool\n    {\n        $user = $token->getUser();\n\n        if (!$user instanceof User) {\n            return false;\n        }\n\n        return match ($attribute) {\n            self::EDIT => $this->canEdit($subject, $user),\n            self::DELETE => $this->canDelete($subject, $user),\n            self::PURGE => $this->canPurge($subject, $user),\n            self::COMMENT => $this->canComment($subject, $user),\n            self::VOTE => $this->canVote($subject, $user),\n            self::MODERATE => $this->canModerate($subject, $user),\n            self::LOCK => $subject->user === $user || $this->canModerate($subject, $user),\n            default => throw new \\LogicException(),\n        };\n    }\n\n    private function canEdit(Post $post, User $user): bool\n    {\n        if ($post->user === $user) {\n            return true;\n        }\n\n        return false;\n    }\n\n    private function canDelete(Post $post, User $user): bool\n    {\n        if ($user->isAdmin() || $user->isModerator()) {\n            return true;\n        }\n\n        if ($post->user === $user) {\n            return true;\n        }\n\n        if ($post->magazine->userIsModerator($user)) {\n            return true;\n        }\n\n        return false;\n    }\n\n    private function canPurge(Post $post, User $user): bool\n    {\n        return $user->isAdmin();\n    }\n\n    private function canComment(Post $post, User $user): bool\n    {\n        return !$post->magazine->isBanned($user) && !$user->isBanned();\n    }\n\n    private function canVote(Post $post, User $user): bool\n    {\n        //        if ($post->user === $user) {\n        //            return false;\n        //        }\n\n        if ($post->magazine->isBanned($user) || $user->isBanned()) {\n            return false;\n        }\n\n        return true;\n    }\n\n    private function canModerate(Post $post, User $user): bool\n    {\n        return $post->magazine->userIsModerator($user) || $user->isAdmin() || $user->isModerator();\n    }\n}\n"
  },
  {
    "path": "src/Security/Voter/PrivateInstanceVoter.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Security\\Voter;\n\nuse App\\Entity\\User;\nuse App\\Service\\SettingsManager;\nuse Scheb\\TwoFactorBundle\\Security\\Authentication\\Token\\TwoFactorTokenInterface;\nuse Symfony\\Component\\Security\\Core\\Authentication\\Token\\TokenInterface;\nuse Symfony\\Component\\Security\\Core\\Authorization\\Voter\\Voter;\n\nclass PrivateInstanceVoter extends Voter\n{\n    public function __construct(private SettingsManager $settingsManager)\n    {\n    }\n\n    protected function supports(string $attribute, $subject): bool\n    {\n        return 'PUBLIC_ACCESS_UNLESS_PRIVATE_INSTANCE' === $attribute;\n    }\n\n    protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool\n    {\n        if ($token instanceof TwoFactorTokenInterface) {\n            return false;\n        }\n\n        if ($this->settingsManager->get('MBIN_PRIVATE_INSTANCE')) {\n            $user = $token->getUser();\n\n            if (!$user instanceof User) {\n                return false;\n            }\n        }\n\n        return true;\n    }\n}\n"
  },
  {
    "path": "src/Security/Voter/UserVoter.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Security\\Voter;\n\nuse App\\Entity\\User;\nuse Symfony\\Component\\Security\\Core\\Authentication\\Token\\TokenInterface;\nuse Symfony\\Component\\Security\\Core\\Authorization\\Voter\\Voter;\n\nclass UserVoter extends Voter\n{\n    public const FOLLOW = 'follow';\n    public const BLOCK = 'block';\n    public const EDIT_PROFILE = 'edit_profile';\n    public const EDIT_USERNAME = 'edit_username';\n    public const MESSAGE = 'message';\n\n    protected function supports(string $attribute, $subject): bool\n    {\n        return $subject instanceof User\n            && \\in_array(\n                $attribute,\n                [self::FOLLOW, self::BLOCK, self::MESSAGE, self::EDIT_PROFILE, self::EDIT_USERNAME],\n                true\n            );\n    }\n\n    protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool\n    {\n        $user = $token->getUser();\n\n        if (!$user instanceof User) {\n            return false;\n        }\n\n        return match ($attribute) {\n            self::FOLLOW => $this->canFollow($subject, $user),\n            self::BLOCK => $this->canBlock($subject, $user),\n            self::MESSAGE => $this->canMessage($subject, $user),\n            self::EDIT_PROFILE => $this->canEditProfile($subject, $user),\n            self::EDIT_USERNAME => $this->canEditUsername($subject, $user),\n            default => throw new \\LogicException(),\n        };\n    }\n\n    private function canFollow(User $following, User $follower): bool\n    {\n        if ($following === $follower) {\n            return false;\n        }\n\n        return true;\n    }\n\n    private function canBlock(User $blocked, User $blocker): bool\n    {\n        if ($blocked === $blocker) {\n            return false;\n        }\n\n        return true;\n    }\n\n    private function canMessage(User $receiver, User $sender): bool\n    {\n        if (!$sender instanceof User) {\n            return false;\n        }\n\n        if ($receiver->isBlocked($sender) || $sender->isBlocked($receiver)) {\n            return false;\n        }\n\n        return true;\n    }\n\n    private function canEditProfile(User $subject, User $user): bool\n    {\n        return $subject === $user;\n    }\n\n    private function canEditUsername(User $subject, User $user): bool\n    {\n        return $this->canEditProfile($subject, $user)\n            && !$user->entries->count()\n            && !$user->entryComments->count()\n            && !$user->posts->count()\n            && !$user->postComments->count()\n            && !$user->subscriptions->count()\n            && !$user->follows->count()\n            && !$user->followers->count()\n            && !$user->entryVotes->count()\n            && !$user->entryVotes->count()\n            && !$user->entryCommentVotes->count()\n            && !$user->postVotes->count()\n            && !$user->postCommentVotes->count()\n            && !$user->blocks->count()\n            && !$user->favourites->count();\n    }\n}\n"
  },
  {
    "path": "src/Security/ZitadelAuthenticator.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Security;\n\nuse App\\DTO\\UserDto;\nuse App\\Entity\\Image;\nuse App\\Entity\\User;\nuse App\\Factory\\ImageFactory;\nuse App\\Provider\\ZitadelResourceOwner;\nuse App\\Repository\\ImageRepository;\nuse App\\Repository\\UserRepository;\nuse App\\Service\\ImageManagerInterface;\nuse App\\Service\\IpResolver;\nuse App\\Service\\SettingsManager;\nuse App\\Service\\UserManager;\nuse App\\Utils\\Slugger;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse KnpU\\OAuth2ClientBundle\\Client\\ClientRegistry;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\Routing\\RouterInterface;\nuse Symfony\\Component\\Security\\Core\\Exception\\CustomUserMessageAuthenticationException;\nuse Symfony\\Component\\Security\\Http\\Authenticator\\Passport\\Badge\\RememberMeBadge;\nuse Symfony\\Component\\Security\\Http\\Authenticator\\Passport\\Badge\\UserBadge;\nuse Symfony\\Component\\Security\\Http\\Authenticator\\Passport\\Passport;\nuse Symfony\\Component\\Security\\Http\\Authenticator\\Passport\\SelfValidatingPassport;\n\nclass ZitadelAuthenticator extends MbinOAuthAuthenticatorBase\n{\n    public function __construct(\n        private readonly ClientRegistry $clientRegistry,\n        RouterInterface $router,\n        private readonly EntityManagerInterface $entityManager,\n        private readonly UserManager $userManager,\n        private readonly ImageManagerInterface $imageManager,\n        private readonly ImageFactory $imageFactory,\n        private readonly ImageRepository $imageRepository,\n        private readonly IpResolver $ipResolver,\n        private readonly Slugger $slugger,\n        private readonly UserRepository $userRepository,\n        private readonly SettingsManager $settingsManager,\n    ) {\n        parent::__construct($router);\n    }\n\n    public function supports(Request $request): ?bool\n    {\n        return 'oauth_zitadel_verify' === $request->attributes->get('_route');\n    }\n\n    public function authenticate(Request $request): Passport\n    {\n        $client = $this->clientRegistry->getClient('zitadel');\n        $slugger = $this->slugger;\n\n        $provider = $client->getOAuth2Provider();\n\n        $accessToken = $provider->getAccessToken('authorization_code', [\n            'code' => $request->query->get('code'),\n        ]);\n\n        $rememberBadge = new RememberMeBadge();\n        $rememberBadge = $rememberBadge->enable();\n\n        return new SelfValidatingPassport(\n            new UserBadge($accessToken->getToken(), function () use ($accessToken, $client, $slugger, $request) {\n                /** @var ZitadelResourceOwner $zitadelUser */\n                $zitadelUser = $client->fetchUserFromToken($accessToken);\n\n                $existingUser = $this->entityManager->getRepository(User::class)->findOneBy(\n                    ['oauthZitadelId' => $zitadelUser->getId()]\n                );\n\n                if ($existingUser) {\n                    return $existingUser;\n                }\n\n                $user = $this->userRepository->findOneBy(['email' => $zitadelUser->getEmail()]);\n\n                if ($user) {\n                    $user->oauthZitadelId = $zitadelUser->getId();\n\n                    $this->entityManager->persist($user);\n                    $this->entityManager->flush();\n\n                    return $user;\n                }\n\n                if (false === $this->settingsManager->get('MBIN_SSO_REGISTRATIONS_ENABLED')) {\n                    throw new CustomUserMessageAuthenticationException('MBIN_SSO_REGISTRATIONS_ENABLED');\n                }\n\n                $email = $zitadelUser->toArray()['preferred_username'];\n                $username = $slugger->slug(substr($email, 0, strrpos($email, '@')));\n\n                if ($this->userRepository->count(['username' => $username]) > 0) {\n                    $username .= rand(1, 999);\n                    $request->getSession()->set('is_newly_created', true);\n                }\n\n                $dto = (new UserDto())->create(\n                    $username,\n                    $zitadelUser->getEmail()\n                );\n\n                $avatar = $this->getAvatar($zitadelUser->getPictureUrl());\n\n                if ($avatar) {\n                    $dto->avatar = $this->imageFactory->createDto($avatar);\n                }\n\n                $dto->plainPassword = bin2hex(random_bytes(20));\n                $dto->ip = $this->ipResolver->resolve();\n\n                $user = $this->userManager->create($dto, false);\n                $user->oauthZitadelId = $zitadelUser->getId();\n                $user->avatar = $this->getAvatar($zitadelUser->getPictureUrl());\n                $user->isVerified = true;\n\n                $this->entityManager->persist($user);\n                $this->entityManager->flush();\n\n                return $user;\n            }),\n            [\n                $rememberBadge,\n            ]\n        );\n    }\n\n    private function getAvatar(?string $pictureUrl): ?Image\n    {\n        if (!$pictureUrl) {\n            return null;\n        }\n\n        try {\n            $tempFile = $this->imageManager->download($pictureUrl);\n        } catch (\\Exception $e) {\n            $tempFile = null;\n        }\n\n        if ($tempFile) {\n            $image = $this->imageRepository->findOrCreateFromPath($tempFile);\n            if ($image) {\n                $this->entityManager->persist($image);\n                $this->entityManager->flush();\n            }\n        }\n\n        return $image ?? null;\n    }\n}\n"
  },
  {
    "path": "src/Service/ActivityPub/ActivityJsonBuilder.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service\\ActivityPub;\n\nuse App\\Entity\\Activity;\nuse App\\Entity\\Contracts\\ActivityPubActivityInterface;\nuse App\\Entity\\Contracts\\ActivityPubActorInterface;\nuse App\\Entity\\Contracts\\ReportInterface;\nuse App\\Entity\\Entry;\nuse App\\Entity\\EntryComment;\nuse App\\Entity\\Magazine;\nuse App\\Entity\\MagazineBan;\nuse App\\Entity\\Message;\nuse App\\Entity\\Post;\nuse App\\Entity\\PostComment;\nuse App\\Entity\\User;\nuse App\\Factory\\ActivityPub\\ActivityFactory;\nuse App\\Factory\\ActivityPub\\EntryCommentNoteFactory;\nuse App\\Factory\\ActivityPub\\EntryPageFactory;\nuse App\\Factory\\ActivityPub\\GroupFactory;\nuse App\\Factory\\ActivityPub\\InstanceFactory;\nuse App\\Factory\\ActivityPub\\PersonFactory;\nuse App\\Factory\\ActivityPub\\PostCommentNoteFactory;\nuse App\\Factory\\ActivityPub\\PostNoteFactory;\nuse Psr\\Log\\LoggerInterface;\nuse Symfony\\Component\\HttpKernel\\KernelInterface;\nuse Symfony\\Component\\Routing\\Generator\\UrlGeneratorInterface;\n\nclass ActivityJsonBuilder\n{\n    public function __construct(\n        private readonly UrlGeneratorInterface $urlGenerator,\n        private readonly InstanceFactory $instanceFactory,\n        private readonly PersonFactory $personFactory,\n        private readonly GroupFactory $groupFactory,\n        private readonly ActivityFactory $activityFactory,\n        private readonly ContextsProvider $contextsProvider,\n        private readonly EntryPageFactory $entryPageFactory,\n        private readonly EntryCommentNoteFactory $entryCommentNoteFactory,\n        private readonly PostNoteFactory $postNoteFactory,\n        private readonly PostCommentNoteFactory $postCommentNoteFactory,\n        private readonly LoggerInterface $logger,\n        private readonly ApHttpClientInterface $apHttpClient,\n        private readonly KernelInterface $kernel,\n    ) {\n    }\n\n    public function buildActivityJson(Activity $activity, bool $includeContext = true): array\n    {\n        $this->logger->debug('activity json: build for {id}', ['id' => $activity->uuid->toString()]);\n        if (null !== $activity->activityJson) {\n            $json = json_decode($activity->activityJson, true);\n            $this->logger->debug('activity json: {json}', ['json' => json_encode($json, JSON_PRETTY_PRINT)]);\n\n            return $json;\n        }\n\n        $json = match ($activity->type) {\n            'Create' => $this->buildCreateFromActivity($activity),\n            'Like' => $this->buildLikeFromActivity($activity),\n            'Undo' => $this->buildUndoFromActivity($activity),\n            'Announce' => $this->buildAnnounceFromActivity($activity),\n            'Delete' => $this->buildDeleteFromActivity($activity),\n            'Add', 'Remove' => $this->buildAddRemoveFromActivity($activity),\n            'Flag' => $this->buildFlagFromActivity($activity),\n            'Follow' => $this->buildFollowFromActivity($activity),\n            'Accept', 'Reject' => $this->buildAcceptRejectFromActivity($activity),\n            'Update' => $this->buildUpdateFromActivity($activity),\n            'Block' => $this->buildBlockFromActivity($activity),\n            'Lock' => $this->buildLockFromActivity($activity),\n            default => new \\LogicException(),\n        };\n        $this->logger->debug('activity json: {json}', ['json' => json_encode($json, JSON_PRETTY_PRINT)]);\n\n        if (!$includeContext) {\n            unset($json['@context']);\n        }\n\n        return $json;\n    }\n\n    public function buildCreateFromActivity(Activity $activity): array\n    {\n        $o = $activity->objectEntry ?? $activity->objectEntryComment ?? $activity->objectPost ?? $activity->objectPostComment ?? $activity->objectMessage;\n        $item = $this->activityFactory->create($o, true);\n\n        unset($item['@context']);\n\n        $activityJson = [\n            '@context' => $this->contextsProvider->referencedContexts(),\n            'id' => $this->urlGenerator->generate('ap_object', ['id' => $activity->uuid], UrlGeneratorInterface::ABSOLUTE_URL),\n            'type' => 'Create',\n            'actor' => $item['attributedTo'],\n            'published' => $item['published'],\n            'to' => $item['to'],\n            'cc' => $item['cc'],\n            'object' => $item,\n        ];\n\n        if (isset($item['audience'])) {\n            $activityJson['audience'] = $item['audience'];\n        }\n\n        return $activityJson;\n    }\n\n    public function buildLikeFromActivity(Activity $activity): array\n    {\n        $actor = $this->personFactory->getActivityPubId($activity->userActor);\n        if (null !== $activity->userActor->apId) {\n            if ('test' === $this->kernel->getEnvironment()) {\n                // ignore this in testing\n            } else {\n                throw new \\LogicException('activities cannot be build for remote users');\n            }\n        }\n        $object = $activity->getObject();\n        if (!\\is_string($object)) {\n            throw new \\LogicException('object must be a string');\n        }\n\n        $activityJson = [\n            '@context' => $this->contextsProvider->referencedContexts(),\n            'id' => $this->urlGenerator->generate('ap_object', ['id' => $activity->uuid], UrlGeneratorInterface::ABSOLUTE_URL),\n            'type' => 'Like',\n            'actor' => $actor,\n            'to' => [ActivityPubActivityInterface::PUBLIC_URL],\n            'cc' => [\n                $this->urlGenerator->generate('ap_user_followers', ['username' => $activity->userActor->username], UrlGeneratorInterface::ABSOLUTE_URL),\n            ],\n            'object' => $object,\n        ];\n\n        if (null !== $activity->audience) {\n            $magazineId = $this->groupFactory->getActivityPubId($activity->audience);\n            $activityJson['cc'][] = $magazineId;\n            $activityJson['audience'] = $magazineId;\n        }\n\n        return $activityJson;\n    }\n\n    public function buildUndoFromActivity(Activity $activity): array\n    {\n        if (null !== $activity->innerActivity) {\n            $object = $this->buildActivityJson($activity->innerActivity);\n        } elseif (null !== $activity->innerActivityUrl) {\n            $object = $this->apHttpClient->getActivityObject($activity->innerActivityUrl);\n            if (!\\is_array($object)) {\n                throw new \\LogicException('object must be another activity');\n            }\n        } else {\n            throw new \\LogicException('undo activity must have an inner activity / -url');\n        }\n\n        unset($object['@context']);\n\n        $activityJson = [\n            '@context' => $this->contextsProvider->referencedContexts(),\n            'id' => $this->urlGenerator->generate('ap_object', ['id' => $activity->uuid], UrlGeneratorInterface::ABSOLUTE_URL),\n            'type' => 'Undo',\n            'actor' => $object['actor'],\n            'object' => $object,\n            'to' => $object['to'],\n            'cc' => $object['cc'] ?? [],\n        ];\n\n        if (isset($object['audience'])) {\n            $activityJson['audience'] = $object['audience'];\n        }\n\n        return $activityJson;\n    }\n\n    public function buildAnnounceFromActivity(Activity $activity): array\n    {\n        $actor = $activity->getActor();\n        $to = [ActivityPubActivityInterface::PUBLIC_URL];\n\n        $cc = [];\n        if ($actor instanceof User) {\n            $cc[] = $this->personFactory->getActivityPubFollowersId($actor);\n        } elseif ($actor instanceof Magazine) {\n            $cc[] = $this->groupFactory->getActivityPubFollowersId($actor);\n        }\n\n        $object = $activity->getObject();\n\n        if (null !== $activity->innerActivity) {\n            $object = $this->buildActivityJson($activity->innerActivity);\n        } elseif (null !== $activity->innerActivityUrl) {\n            $object = $this->apHttpClient->getActivityObject($activity->innerActivityUrl);\n        } elseif ($object instanceof ActivityPubActivityInterface) {\n            $object = $this->activityFactory->create($object);\n            if (isset($object['attributedTo'])) {\n                $to[] = $object['attributedTo'];\n            } elseif (isset($object['actor'])) {\n                $to[] = $object['actor'];\n            }\n        }\n\n        if (isset($object['@context'])) {\n            unset($object['@context']);\n        }\n        $actorUrl = $actor instanceof User ? $this->personFactory->getActivityPubId($actor) : $this->groupFactory->getActivityPubId($actor);\n\n        if (isset($object['cc'])) {\n            $cc = array_merge($cc, array_filter($object['cc'], fn (string $url) => $url !== $actorUrl));\n        }\n\n        $activityJson = [\n            '@context' => $this->contextsProvider->referencedContexts(),\n            'id' => $this->urlGenerator->generate('ap_object', ['id' => $activity->uuid], UrlGeneratorInterface::ABSOLUTE_URL),\n            'type' => 'Announce',\n            'actor' => $actorUrl,\n            'object' => $object,\n            'to' => $to,\n            'cc' => $cc,\n            'published' => (new \\DateTime())->format(DATE_ATOM),\n        ];\n\n        if ($actor instanceof Magazine) {\n            $activityJson['audience'] = $this->groupFactory->getActivityPubId($actor);\n        }\n\n        return $activityJson;\n    }\n\n    public function buildDeleteFromActivity(Activity $activity): array\n    {\n        $item = $activity->getObject();\n        if (!\\is_array($item)) {\n            throw new \\LogicException();\n        }\n\n        $activityActor = $activity->getActor();\n        if ($activityActor instanceof User) {\n            $userUrl = $this->personFactory->getActivityPubId($activityActor);\n        } elseif ($activityActor instanceof Magazine) {\n            $userUrl = $this->groupFactory->getActivityPubId($activityActor);\n        } else {\n            throw new \\LogicException();\n        }\n\n        if (isset($item->magazine)) {\n            $audience = $this->groupFactory->getActivityPubId($item->magazine);\n        }\n\n        $activityJson = [\n            '@context' => $this->contextsProvider->referencedContexts(),\n            'id' => $this->urlGenerator->generate('ap_object', ['id' => $activity->uuid], UrlGeneratorInterface::ABSOLUTE_URL),\n            'type' => 'Delete',\n            'actor' => $userUrl,\n            'object' => [\n                'id' => $item['id'],\n                'type' => 'Tombstone',\n            ],\n            'to' => $item['to'],\n            'cc' => $item['cc'],\n        ];\n\n        if (isset($audience)) {\n            $activityJson['audience'] = $audience;\n        }\n\n        return $activityJson;\n    }\n\n    public function buildAddRemoveFromActivity(Activity $activity): array\n    {\n        if (null !== $activity->objectUser) {\n            $object = $this->personFactory->getActivityPubId($activity->objectUser);\n        } elseif (null !== $activity->objectEntry) {\n            $object = $this->entryPageFactory->getActivityPubId($activity->objectEntry);\n        } else {\n            throw new \\LogicException('There is no object set for the add/remove activity');\n        }\n\n        return [\n            '@context' => $this->contextsProvider->referencedContexts(),\n            'id' => $this->urlGenerator->generate('ap_object', ['id' => $activity->uuid], UrlGeneratorInterface::ABSOLUTE_URL),\n            'actor' => $this->personFactory->getActivityPubId($activity->userActor),\n            'to' => [ActivityPubActivityInterface::PUBLIC_URL],\n            'object' => $object,\n            'cc' => [$this->groupFactory->getActivityPubId($activity->audience)],\n            'type' => $activity->type,\n            'target' => $activity->targetString,\n            'audience' => $this->groupFactory->getActivityPubId($activity->audience),\n        ];\n    }\n\n    public function buildFlagFromActivity(Activity $activity): array\n    {\n        // mastodon does not accept a report that does not have an array as object.\n        // I created an issue for it: https://github.com/mastodon/mastodon/issues/28159\n        $mastodonObject = [\n            $this->getPublicUrl($activity->getObject()),\n            $this->personFactory->getActivityPubId($activity->objectUser),\n        ];\n\n        // lemmy does not accept a report that does have an array as object.\n        // I created an issue for it: https://github.com/LemmyNet/lemmy/issues/4217\n        $lemmyObject = $this->getPublicUrl($activity->getObject());\n\n        if ('random' !== $activity->audience || $activity->audience->apId) {\n            // apAttributedToUrl is not a standardized field,\n            // so it is not implemented by every software that supports groups.\n            // Some don't have moderation at all, so it will probably remain optional in the future.\n            $audience = $this->groupFactory->getActivityPubId($activity->audience);\n            $object = $lemmyObject;\n        } else {\n            $audience = $this->personFactory->getActivityPubId($activity->objectUser);\n            $object = $mastodonObject;\n        }\n\n        $result = [\n            '@context' => ActivityPubActivityInterface::CONTEXT_URL,\n            'id' => $this->urlGenerator->generate('ap_object', ['id' => $activity->uuid], UrlGeneratorInterface::ABSOLUTE_URL),\n            'type' => 'Flag',\n            'actor' => $this->personFactory->getActivityPubId($activity->userActor),\n            'object' => $object,\n            'audience' => $audience,\n            'summary' => $activity->contentString,\n            'content' => $activity->contentString,\n        ];\n\n        if ('random' !== $activity->audience->name || $activity->audience->apId) {\n            $result['to'] = [$this->groupFactory->getActivityPubId($activity->audience)];\n        }\n\n        return $result;\n    }\n\n    public function buildFollowFromActivity(Activity $activity): array\n    {\n        $object = $activity->getObject();\n        if ($object instanceof User) {\n            $activityObject = $this->personFactory->getActivityPubId($object);\n        } else {\n            $activityObject = $this->groupFactory->getActivityPubId($object);\n        }\n\n        return [\n            '@context' => $this->contextsProvider->referencedContexts(),\n            'id' => $this->urlGenerator->generate('ap_object', ['id' => $activity->uuid], UrlGeneratorInterface::ABSOLUTE_URL),\n            'type' => 'Follow',\n            'actor' => $this->personFactory->getActivityPubId($activity->userActor),\n            'object' => $activityObject,\n            'to' => [\n                $activityObject,\n            ],\n        ];\n    }\n\n    public function buildAcceptRejectFromActivity(Activity $activity): array\n    {\n        $activityActor = $activity->getActor();\n        if ($activityActor instanceof User) {\n            $actor = $this->personFactory->getActivityPubId($activityActor);\n        } elseif ($activityActor instanceof Magazine) {\n            $actor = $this->groupFactory->getActivityPubId($activityActor);\n        } else {\n            throw new \\LogicException();\n        }\n\n        if (null !== ($activityObject = $activity->getObject())) {\n            $object = $activityObject;\n        } elseif (null !== $activity->innerActivity) {\n            $object = $this->buildActivityJson($activity->innerActivity);\n            if (isset($object['@context'])) {\n                unset($object['@context']);\n            }\n        } else {\n            throw new \\LogicException('There is no object set for the accept/reject activity');\n        }\n\n        return [\n            '@context' => $this->contextsProvider->referencedContexts(),\n            'id' => $this->urlGenerator->generate('ap_object', ['id' => $activity->uuid], UrlGeneratorInterface::ABSOLUTE_URL),\n            'type' => $activity->type,\n            'actor' => $actor,\n            'object' => $object,\n            'to' => [\n                $object['actor'],\n            ],\n        ];\n    }\n\n    public function buildUpdateFromActivity(Activity $activity): array\n    {\n        $object = $activity->getObject();\n        if ($object instanceof ActivityPubActivityInterface) {\n            return $this->buildUpdateForContentFromActivity($activity, $object);\n        } elseif ($object instanceof ActivityPubActorInterface) {\n            return $this->buildUpdateForActorFromActivity($activity, $object);\n        } else {\n            throw new \\LogicException();\n        }\n    }\n\n    public function buildUpdateForContentFromActivity(Activity $activity, ActivityPubActivityInterface $content): array\n    {\n        $entity = $this->activityFactory->create($content);\n\n        $entity['object']['updated'] = $content->editedAt ? $content->editedAt->format(DATE_ATOM) : (new \\DateTime())->format(DATE_ATOM);\n\n        $activityJson = [\n            '@context' => $this->contextsProvider->referencedContexts(),\n            'id' => $this->urlGenerator->generate('ap_object', ['id' => $activity->uuid], UrlGeneratorInterface::ABSOLUTE_URL),\n            'type' => 'Update',\n            'actor' => $this->personFactory->getActivityPubId($activity->userActor),\n            'published' => $entity['published'],\n            'to' => $entity['to'],\n            'cc' => $entity['cc'],\n            'object' => $entity,\n        ];\n\n        if (null !== $activity->audience) {\n            $activityJson['audience'] = $this->groupFactory->getActivityPubId($activity->audience);\n        }\n\n        return $activityJson;\n    }\n\n    public function buildUpdateForActorFromActivity(Activity $activity, ActivityPubActorInterface $object): array\n    {\n        if ($object instanceof User) {\n            $activityObject = $this->personFactory->create($object, false);\n            if (null === $object->apId) {\n                $cc = [$this->urlGenerator->generate('ap_user_followers', ['username' => $object->username], UrlGeneratorInterface::ABSOLUTE_URL)];\n            } else {\n                $cc = [$object->apFollowersUrl];\n            }\n        } elseif ($object instanceof Magazine) {\n            $activityObject = $this->groupFactory->create($object, false);\n            if (null === $object->apId) {\n                $cc = [$this->urlGenerator->generate('ap_magazine_followers', ['name' => $object->name], UrlGeneratorInterface::ABSOLUTE_URL)];\n            } else {\n                $cc = [$object->apFollowersUrl];\n            }\n        } else {\n            throw new \\LogicException('Unknown actor type: '.\\get_class($object));\n        }\n\n        $actorUrl = $this->personFactory->getActivityPubId($activity->userActor);\n\n        return [\n            '@context' => $this->contextsProvider->referencedContexts(),\n            'id' => $this->urlGenerator->generate('ap_object', ['id' => $activity->uuid], UrlGeneratorInterface::ABSOLUTE_URL),\n            'type' => 'Update',\n            'actor' => $actorUrl,\n            'published' => $activityObject['published'],\n            'to' => [ActivityPubActivityInterface::PUBLIC_URL],\n            'cc' => $cc,\n            'object' => $activityObject,\n        ];\n    }\n\n    private function buildBlockFromActivity(Activity $activity): array\n    {\n        $object = $activity->getObject();\n        $expires = null;\n        $cc = [];\n        if ($object instanceof MagazineBan) {\n            $reason = $object->reason;\n            $jsonObject = $this->personFactory->getActivityPubId($object->user);\n            $target = $this->groupFactory->getActivityPubId($object->magazine);\n            $expires = $object->expiredAt?->format(DATE_ATOM);\n            $cc = [$this->groupFactory->getActivityPubId($activity->audience)];\n        } elseif ($object instanceof User) {\n            $reason = $object->banReason;\n            $jsonObject = $this->personFactory->getActivityPubId($object);\n            $target = $this->instanceFactory->getTargetUrl();\n        } else {\n            throw new \\LogicException('Object of a block activity has to be of type MagazineBan');\n        }\n\n        return [\n            '@context' => $this->contextsProvider->referencedContexts(),\n            'id' => $this->urlGenerator->generate('ap_object', ['id' => $activity->uuid], UrlGeneratorInterface::ABSOLUTE_URL),\n            'type' => 'Block',\n            'actor' => $this->personFactory->getActivityPubId($activity->userActor),\n            'object' => $jsonObject,\n            'target' => $target,\n            'summary' => $reason,\n            'audience' => $activity->audience ? $this->groupFactory->getActivityPubId($activity->audience) : null,\n            'expires' => $expires,\n            'to' => [ActivityPubActivityInterface::PUBLIC_URL],\n            'cc' => $cc,\n        ];\n    }\n\n    private function buildLockFromActivity(Activity $activity): array\n    {\n        $object = $activity->getObject();\n        if ($object instanceof Entry) {\n            $objectUrl = $this->entryPageFactory->getActivityPubId($object);\n        } elseif ($object instanceof Post) {\n            $objectUrl = $this->postNoteFactory->getActivityPubId($object);\n        } else {\n            throw new \\LogicException('Lock activity is only supported for entries and posts, not for '.\\get_class($object));\n        }\n\n        return [\n            '@context' => $this->contextsProvider->referencedContexts(),\n            'id' => $this->urlGenerator->generate('ap_object', ['id' => $activity->uuid], UrlGeneratorInterface::ABSOLUTE_URL),\n            'type' => 'Lock',\n            'actor' => $this->personFactory->getActivityPubId($activity->userActor),\n            'to' => [ActivityPubActivityInterface::PUBLIC_URL],\n            'cc' => [\n                $this->groupFactory->getActivityPubId($object->magazine),\n                $this->urlGenerator->generate('ap_user_followers', ['username' => $activity->userActor->username], UrlGeneratorInterface::ABSOLUTE_URL),\n            ],\n            'object' => $objectUrl,\n        ];\n    }\n\n    public function getPublicUrl(ReportInterface|ActivityPubActivityInterface $subject): string\n    {\n        if ($subject instanceof Entry) {\n            return $this->entryPageFactory->getActivityPubId($subject);\n        } elseif ($subject instanceof EntryComment) {\n            return $this->entryCommentNoteFactory->getActivityPubId($subject);\n        } elseif ($subject instanceof Post) {\n            return $this->postNoteFactory->getActivityPubId($subject);\n        } elseif ($subject instanceof PostComment) {\n            return $this->postCommentNoteFactory->getActivityPubId($subject);\n        } elseif ($subject instanceof Message) {\n            return $this->urlGenerator->generate('ap_message', ['uuid' => $subject->uuid], UrlGeneratorInterface::ABSOLUTE_URL);\n        }\n\n        throw new \\LogicException(\"can't handle \".\\get_class($subject));\n    }\n}\n"
  },
  {
    "path": "src/Service/ActivityPub/ActivityPubContent.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service\\ActivityPub;\n\nuse App\\DTO\\EntryCommentDto;\nuse App\\DTO\\EntryDto;\nuse App\\DTO\\PostCommentDto;\nuse App\\DTO\\PostDto;\nuse App\\Entity\\Contracts\\ActivityPubActivityInterface;\nuse App\\Entity\\Contracts\\VisibilityInterface;\nuse App\\Entity\\User;\nuse App\\Utils\\JsonldUtils;\n\nabstract class ActivityPubContent\n{\n    /**\n     * @throws \\LogicException\n     */\n    protected function getVisibility(array $object, User $actor): string\n    {\n        $toAndCC = array_merge(JsonldUtils::getArrayValue($object, 'to'), JsonldUtils::getArrayValue($object, 'cc'));\n        if (!\\in_array(ActivityPubActivityInterface::PUBLIC_URL, $toAndCC)) {\n            if (!\\in_array($actor->apFollowersUrl, $toAndCC)) {\n                throw new \\LogicException('PM: not implemented.');\n            }\n\n            return VisibilityInterface::VISIBILITY_PRIVATE;\n        }\n\n        return VisibilityInterface::VISIBILITY_VISIBLE;\n    }\n\n    protected function handleDate(PostDto|PostCommentDto|EntryCommentDto|EntryDto $dto, string $date): void\n    {\n        $dto->createdAt = new \\DateTimeImmutable($date);\n        $dto->lastActive = new \\DateTime($date);\n    }\n\n    protected function handleSensitiveMedia(PostDto|PostCommentDto|EntryCommentDto|EntryDto $dto, string|bool $sensitive): void\n    {\n        if (true === filter_var($sensitive, FILTER_VALIDATE_BOOLEAN)) {\n            $dto->isAdult = true;\n        }\n    }\n}\n"
  },
  {
    "path": "src/Service/ActivityPub/ApHttpClient.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service\\ActivityPub;\n\nuse App\\Entity\\Magazine;\nuse App\\Entity\\User;\nuse App\\Event\\ActivityPub\\CurlRequestBeginningEvent;\nuse App\\Event\\ActivityPub\\CurlRequestFinishedEvent;\nuse App\\Exception\\InvalidApPostException;\nuse App\\Exception\\InvalidWebfingerException;\nuse App\\Factory\\ActivityPub\\GroupFactory;\nuse App\\Factory\\ActivityPub\\PersonFactory;\nuse App\\Factory\\ActivityPub\\TombstoneFactory;\nuse App\\Repository\\MagazineRepository;\nuse App\\Repository\\SiteRepository;\nuse App\\Repository\\UserRepository;\nuse App\\Service\\ProjectInfoService;\nuse JetBrains\\PhpStorm\\ArrayShape;\nuse Psr\\Cache\\InvalidArgumentException;\nuse Psr\\Log\\LoggerInterface;\nuse Symfony\\Component\\Cache\\CacheItem;\nuse Symfony\\Component\\EventDispatcher\\EventDispatcherInterface;\nuse Symfony\\Component\\HttpClient\\CurlHttpClient;\nuse Symfony\\Contracts\\Cache\\CacheInterface;\nuse Symfony\\Contracts\\Cache\\ItemInterface;\nuse Symfony\\Contracts\\HttpClient\\Exception\\ClientExceptionInterface;\nuse Symfony\\Contracts\\HttpClient\\Exception\\RedirectionExceptionInterface;\nuse Symfony\\Contracts\\HttpClient\\Exception\\ServerExceptionInterface;\nuse Symfony\\Contracts\\HttpClient\\Exception\\TransportExceptionInterface;\nuse Symfony\\Contracts\\HttpClient\\ResponseInterface;\n\n/*\n * source:\n * https://github.com/aaronpk/Nautilus/blob/master/app/ActivityPub/HTTPSignature.php\n * https://github.com/pixelfed/pixelfed/blob/dev/app/Util/ActivityPub/HttpSignature.php\n */\n\nenum ApRequestType\n{\n    case ActivityPub;\n    case WebFinger;\n    case NodeInfo;\n}\n\nclass ApHttpClient implements ApHttpClientInterface\n{\n    public const TIMEOUT = 8;\n    public const MAX_DURATION = 15;\n\n    public function __construct(\n        private readonly string $kbinDomain,\n        private readonly TombstoneFactory $tombstoneFactory,\n        private readonly PersonFactory $personFactory,\n        private readonly GroupFactory $groupFactory,\n        private readonly LoggerInterface $logger,\n        private readonly CacheInterface $cache,\n        private readonly UserRepository $userRepository,\n        private readonly MagazineRepository $magazineRepository,\n        private readonly SiteRepository $siteRepository,\n        private readonly ProjectInfoService $projectInfo,\n        private readonly EventDispatcherInterface $dispatcher,\n    ) {\n    }\n\n    /**\n     * Retrieve a remote activity object from an URL. And cache the result.\n     *\n     * @param bool $decoded (optional)\n     *\n     * @return array|string|null JSON Response body (as PHP Object)\n     */\n    public function getActivityObject(string $url, bool $decoded = true): array|string|null\n    {\n        $key = $this->getActivityObjectCacheKey($url);\n        if ($this->cache->hasItem($key)) {\n            /** @var CacheItem $item */\n            $item = $this->cache->getItem($key);\n            $resp = $item->get();\n\n            return $decoded ? json_decode($resp, true) : $resp;\n        }\n\n        $resp = $this->getActivityObjectImpl($url);\n\n        if (!$resp) {\n            return null;\n        }\n\n        /** @var CacheItem $item */\n        $item = $this->cache->getItem($key);\n        $item->expiresAt(new \\DateTime('+1 hour'));\n        $item->set($resp);\n        $this->cache->save($item);\n\n        return $decoded ? json_decode($resp, true) : $resp;\n    }\n\n    /**\n     * Do a GET request for an ActivityPub object and return the response content.\n     *\n     * @return string|null returns the response content or null if the request failed\n     *\n     * @throws InvalidApPostException\n     */\n    private function getActivityObjectImpl(string $url): ?string\n    {\n        $this->logger->debug(\"[ApHttpClient::getActivityObjectImpl] URL: $url\");\n        $content = null;\n        try {\n            $this->dispatcher->dispatch(new CurlRequestBeginningEvent($url));\n        } catch (\\Throwable) {\n        }\n\n        try {\n            $client = new CurlHttpClient();\n            $response = $client->request('GET', $url, [\n                'max_duration' => self::MAX_DURATION,\n                'timeout' => self::TIMEOUT,\n                'headers' => $this->getInstanceHeaders($url),\n            ]);\n\n            $statusCode = $response->getStatusCode();\n            // Accepted status code are 2xx or 410 (used Tombstone types)\n            if (!str_starts_with((string) $statusCode, '2') && 410 !== $statusCode) {\n                // Do NOT include the response content in the error message, this will be often a full HTML page\n                throw new InvalidApPostException('Invalid status code while getting', $url, $statusCode);\n            }\n\n            // Read also non-OK responses (like 410) by passing 'false'\n            $content = $response->getContent(false);\n            $this->logger->debug('[ApHttpClient::getActivityObjectImpl] URL: {url} - content: {content}', ['url' => $url, 'content' => $content]);\n            try {\n                $this->dispatcher->dispatch(new CurlRequestFinishedEvent($url, true, $content));\n            } catch (\\Throwable) {\n            }\n        } catch (\\Exception $e) {\n            $this->logRequestException($response ?? null, $url, 'ApHttpClient:getActivityObject', $e);\n        }\n\n        return $content;\n    }\n\n    public function getActivityObjectCacheKey(string $url): string\n    {\n        return 'ap_object_'.hash('sha256', $url);\n    }\n\n    /**\n     * Retrieve AP actor object (could be a user or magazine).\n     *\n     * @return string return the inbox URL of the actor\n     *\n     * @throws \\LogicException|InvalidApPostException if the AP actor object cannot be found\n     */\n    public function getInboxUrl(string $apProfileId): string\n    {\n        $actor = $this->getActorObject($apProfileId);\n        if (!empty($actor)) {\n            return $actor['endpoints']['sharedInbox'] ?? $actor['inbox'];\n        } else {\n            throw new \\LogicException(\"Unable to find AP actor (user or magazine) with URL: $apProfileId\");\n        }\n    }\n\n    /**\n     * Execute a webfinger request according to RFC 7033 (https://tools.ietf.org/html/rfc7033).\n     *\n     * @param string $url the URL of the user/magazine to get the webfinger object for\n     *\n     * @return array|null The webfinger object (as PHP Object)\n     *\n     * @throws InvalidWebfingerException|InvalidArgumentException\n     */\n    public function getWebfingerObject(string $url): ?array\n    {\n        $key = 'wf_'.hash('sha256', $url);\n        if ($this->cache->hasItem($key)) {\n            /** @var CacheItem $item */\n            $item = $this->cache->getItem($key);\n            $resp = $item->get();\n\n            return $resp ? json_decode($resp, true) : null;\n        }\n\n        $resp = $this->getWebfingerObjectImpl($url);\n\n        /** @var CacheItem $item */\n        $item = $this->cache->getItem($key);\n        $item->expiresAt(new \\DateTime('+1 hour'));\n        $item->set($resp);\n        $this->cache->save($item);\n\n        return $resp ? json_decode($resp, true) : null;\n    }\n\n    private function getWebfingerObjectImpl(string $url): ?string\n    {\n        $this->logger->debug(\"[ApHttpClient::getWebfingerObjectImpl] URL: $url\");\n        $response = null;\n        try {\n            $this->dispatcher->dispatch(new CurlRequestBeginningEvent($url));\n        } catch (\\Throwable) {\n        }\n        try {\n            $client = new CurlHttpClient();\n            $response = $client->request('GET', $url, [\n                'max_duration' => self::MAX_DURATION,\n                'timeout' => self::TIMEOUT,\n                'headers' => $this->getInstanceHeaders($url, null, 'get', ApRequestType::WebFinger),\n            ]);\n            $content = $response->getContent();\n            $this->dispatcher->dispatch(new CurlRequestFinishedEvent($url, true, $content));\n        } catch (\\Exception $e) {\n            $this->logRequestException($response, $url, 'ApHttpClient:getWebfingerObject', $e);\n        }\n\n        return $content;\n    }\n\n    private function getActorCacheKey(string $apProfileId): string\n    {\n        return 'ap_'.hash('sha256', $apProfileId);\n    }\n\n    private function getCollectionCacheKey(string $apAddress): string\n    {\n        return 'ap_collection'.hash('sha256', $apAddress);\n    }\n\n    /**\n     * Retrieve AP actor object (could be a user or magazine).\n     *\n     * @return array|null key/value array of actor response body (as PHP Object)\n     *\n     * @throws InvalidApPostException|InvalidArgumentException\n     */\n    public function getActorObject(string $apProfileId): ?array\n    {\n        $key = $this->getActorCacheKey($apProfileId);\n        if ($this->cache->hasItem($key)) {\n            /** @var CacheItem $item */\n            $item = $this->cache->getItem($key);\n            $resp = $item->get();\n\n            return $resp ? json_decode($resp, true) : null;\n        }\n\n        $resp = $this->getActorObjectImpl($apProfileId);\n\n        /** @var CacheItem $item */\n        $item = $this->cache->getItem($key);\n        $item->expiresAt(new \\DateTime('+1 hour'));\n        $item->set($resp);\n        $this->cache->save($item);\n\n        return $resp ? json_decode($resp, true) : null;\n    }\n\n    private function getActorObjectImpl(string $apProfileId): ?string\n    {\n        $this->logger->debug(\"[ApHttpClient::getActorObjectImpl] URL: $apProfileId\");\n        $response = null;\n\n        try {\n            $this->dispatcher->dispatch(new CurlRequestBeginningEvent($apProfileId));\n        } catch (\\Throwable) {\n        }\n\n        try {\n            // Set-up request\n            $client = new CurlHttpClient();\n            $response = $client->request('GET', $apProfileId, [\n                'max_duration' => self::MAX_DURATION,\n                'timeout' => self::TIMEOUT,\n                'headers' => $this->getInstanceHeaders($apProfileId, null, 'get', ApRequestType::ActivityPub),\n            ]);\n            // If 4xx error response, try to find the actor locally\n            if (str_starts_with((string) $response->getStatusCode(), '4')) {\n                if ($user = $this->userRepository->findOneByApProfileId($apProfileId)) {\n                    $user->apDeletedAt = new \\DateTime();\n                    $this->userRepository->save($user, true);\n                }\n                if ($magazine = $this->magazineRepository->findOneByApProfileId($apProfileId)) {\n                    $magazine->apDeletedAt = new \\DateTime();\n                    $this->magazineRepository->save($magazine, true);\n                }\n            }\n\n            // Pass the 'false' option to getContent so it doesn't throw errors on \"non-OK\" respones (eg. 410 status codes).\n            $content = $response->getContent(false);\n            try {\n                $this->dispatcher->dispatch(new CurlRequestFinishedEvent($apProfileId, true, $content));\n            } catch (\\Throwable) {\n            }\n        } catch (\\Exception|TransportExceptionInterface $e) {\n            // If an exception occurred, try to find the actor locally\n            if ($user = $this->userRepository->findOneByApProfileId($apProfileId)) {\n                $user->apTimeoutAt = new \\DateTime();\n                $this->userRepository->save($user, true);\n            }\n            if ($magazine = $this->magazineRepository->findOneByApProfileId($apProfileId)) {\n                $magazine->apTimeoutAt = new \\DateTime();\n                $this->magazineRepository->save($magazine, true);\n            }\n            $this->logRequestException($response, $apProfileId, 'ApHttpClient:getActorObject', $e);\n        }\n\n        if (404 === $response->getStatusCode()) {\n            // treat a 404 error the same as a tombstone, since we think there was an actor, but it isn't there anymore\n            return json_encode($this->tombstoneFactory->create($apProfileId));\n        }\n\n        return $content;\n    }\n\n    /**\n     * Remove actor object from cache.\n     *\n     * @param string $apProfileId AP profile ID to remove from cache\n     */\n    public function invalidateActorObjectCache(string $apProfileId): void\n    {\n        $this->cache->delete($this->getActorCacheKey($apProfileId));\n    }\n\n    /**\n     * Remove collection object from cache.\n     *\n     * @param string $apAddress AP address to remove from cache\n     */\n    public function invalidateCollectionObjectCache(string $apAddress): void\n    {\n        $this->cache->delete($this->getCollectionCacheKey($apAddress));\n    }\n\n    /**\n     * Retrieve AP collection object. First look in cache, then try to retrieve from AP server.\n     * And finally, save the response to cache.\n     *\n     * @return array|null JSON Response body (as PHP Object)\n     *\n     * @throws InvalidArgumentException\n     */\n    public function getCollectionObject(string $apAddress): ?array\n    {\n        $key = $this->getCollectionCacheKey($apAddress);\n        if ($this->cache->hasItem($key)) {\n            /** @var CacheItem $item */\n            $item = $this->cache->getItem($key);\n            $resp = $item->get();\n\n            return $resp ? json_decode($resp, true) : null;\n        }\n\n        $resp = $this->getCollectionObjectImpl($apAddress);\n\n        /** @var CacheItem $item */\n        $item = $this->cache->getItem($key);\n        $item->expiresAt(new \\DateTime('+24 hour'));\n        $item->set($resp);\n        $this->cache->save($item);\n\n        return $resp ? json_decode($resp, true) : null;\n    }\n\n    private function getCollectionObjectImpl(string $apAddress): ?string\n    {\n        $this->logger->debug(\"[ApHttpClient::getCollectionObjectImpl] URL: $apAddress\");\n        $response = null;\n\n        try {\n            $this->dispatcher->dispatch(new CurlRequestBeginningEvent($apAddress));\n        } catch (\\Throwable) {\n        }\n\n        try {\n            // Set-up request\n            $client = new CurlHttpClient();\n            $response = $client->request('GET', $apAddress, [\n                'max_duration' => self::MAX_DURATION,\n                'timeout' => self::TIMEOUT,\n                'headers' => $this->getInstanceHeaders($apAddress, null, 'get', ApRequestType::ActivityPub),\n            ]);\n\n            $statusCode = $response->getStatusCode();\n            // Accepted status code are 2xx or 410 (used Tombstone types)\n            if (!str_starts_with((string) $statusCode, '2') && 410 !== $statusCode) {\n                // Do NOT include the response content in the error message, this will be often a full HTML page\n                throw new InvalidApPostException('Invalid status code while getting', $apAddress, $statusCode);\n            }\n            $content = $response->getContent();\n            try {\n                $this->dispatcher->dispatch(new CurlRequestFinishedEvent($apAddress, true, $content));\n            } catch (\\Throwable) {\n            }\n        } catch (\\Exception $e) {\n            $this->logRequestException($response, $apAddress, 'ApHttpClient:getCollectionObject', $e);\n        }\n\n        // When everything goes OK, return the data\n        return $content;\n    }\n\n    /**\n     * Helper function for logging get/post/.. requests to the error & debug log with additional info.\n     *\n     * @param ResponseInterface|null $response    Optional response object\n     * @param string                 $requestUrl  Full URL of the request\n     * @param string                 $requestType an additional string where the error happened in the code\n     * @param \\Exception             $e           Error object\n     *\n     * @throws InvalidApPostException rethrows the error\n     */\n    private function logRequestException(?ResponseInterface $response, string $requestUrl, string $requestType, \\Exception $e, ?string $requestBody = null): void\n    {\n        if (null !== $response) {\n            try {\n                $content = $response->getContent(false);\n            } catch (ClientExceptionInterface|RedirectionExceptionInterface|ServerExceptionInterface|TransportExceptionInterface $e) {\n                $class = \\get_class($e);\n                $content = \"there was an exception while getting the content, $class: {$e->getMessage()}\";\n            }\n        }\n\n        // Often 400, 404 errors just return the full HTML page, so we don't want to log the full content of them\n        // We truncate the content to 200 characters max.\n        $this->logger->error('[ApHttpClient::logRequestException] {type} failed: {address}, ex: {e}: {msg}. Truncated content: {content}. Truncated request body: {body}', [\n            'type' => $requestType,\n            'address' => $requestUrl,\n            'e' => \\get_class($e),\n            'msg' => $e->getMessage(),\n            'content' => substr($content ?? 'No content provided', 0, 200),\n            'body' => substr($requestBody ?? 'No body provided', 0, 200),\n        ]);\n        // And only log the full content in debug log mode\n        if (isset($content)) {\n            $this->logger->debug('[ApHttpClient::logRequestException] Full response body content: {content}', [\n                'content' => $content,\n            ]);\n        }\n        try {\n            $this->dispatcher->dispatch(new CurlRequestFinishedEvent($requestUrl, false, $content ?? null, $e));\n        } catch (\\Throwable $e) {\n        }\n\n        throw $e; // re-throw the exception\n    }\n\n    /**\n     * Sends a POST request to the specified URL with optional request body and caching mechanism.\n     *\n     * @param string        $url              the URL to which the POST request will be sent\n     * @param User|Magazine $actor            The actor initiating the request, either a User or Magazine object\n     * @param array|null    $body             (Optional) The body of the POST request. Defaults to null.\n     * @param bool          $useOldPrivateKey (Optional) Whether to use the old private key for signing (e.g. to send an update activity rotating the private key)\n     *\n     * @throws InvalidApPostException      if the POST request fails with a non-2xx response status code\n     * @throws TransportExceptionInterface\n     */\n    public function post(string $url, User|Magazine $actor, ?array $body = null, bool $useOldPrivateKey = false): void\n    {\n        $cacheKey = 'ap_'.hash('sha256', $url.':'.$body['id']);\n\n        if ($this->cache->hasItem($cacheKey)) {\n            $this->logger->warning('[ApHttpClient::post] Not posting activity with id {id} to {inbox} again, as we already did that sometime in the last 45 minutes', [\n                'id' => $body['id'],\n                'inbox' => $url,\n            ]);\n\n            return;\n        }\n\n        $jsonBody = json_encode($body ?? []);\n\n        $this->logger->debug(\"[ApHttpClient::post] URL: $url\");\n        $this->logger->debug(\"[ApHttpClient::post] Body: $jsonBody\");\n\n        try {\n            $this->dispatcher->dispatch(new CurlRequestBeginningEvent($url, 'POST', $jsonBody));\n        } catch (\\Throwable) {\n        }\n\n        // Set-up request\n        try {\n            $client = new CurlHttpClient();\n            $response = $client->request('POST', $url, [\n                'max_duration' => self::MAX_DURATION,\n                'timeout' => self::TIMEOUT,\n                'body' => $jsonBody,\n                'headers' => $this->getHeaders($url, $actor, $body, $useOldPrivateKey),\n            ]);\n\n            $statusCode = $response->getStatusCode();\n            if (!str_starts_with((string) $statusCode, '2')) {\n                // Do NOT include the response content in the error message, this will be often a full HTML page\n                throw new InvalidApPostException('Post failed', $url, $statusCode, $body);\n            }\n            try {\n                $this->dispatcher->dispatch(new CurlRequestFinishedEvent($url, true));\n            } catch (\\Throwable) {\n            }\n        } catch (\\Exception $e) {\n            $this->logRequestException($response ?? null, $url, 'ApHttpClient:post', $e, $jsonBody);\n        }\n\n        // build cache\n        $item = $this->cache->getItem($cacheKey);\n        $item->set(true);\n        $item->expiresAt(new \\DateTime('+45 minutes'));\n        $this->cache->save($item);\n    }\n\n    public function fetchInstanceNodeInfoEndpoints(string $domain, bool $decoded = true): array|string|null\n    {\n        $url = \"https://$domain/.well-known/nodeinfo\";\n\n        $resp = $this->generalFetchCached('nodeinfo_endpoints_', 'nodeinfo endpoints', $url, ApRequestType::NodeInfo);\n\n        if (!$resp) {\n            return null;\n        }\n\n        return $decoded ? json_decode($resp, true) : $resp;\n    }\n\n    public function fetchInstanceNodeInfo(string $url, bool $decoded = true): array|string|null\n    {\n        $resp = $this->generalFetchCached('nodeinfo_', 'nodeinfo', $url, ApRequestType::NodeInfo);\n\n        if (!$resp) {\n            return null;\n        }\n\n        return $decoded ? json_decode($resp, true) : $resp;\n    }\n\n    /**\n     * @throws TransportExceptionInterface\n     * @throws ServerExceptionInterface\n     * @throws RedirectionExceptionInterface\n     * @throws ClientExceptionInterface\n     */\n    private function generalFetch(string $url, ApRequestType $requestType = ApRequestType::ActivityPub): string\n    {\n        $client = new CurlHttpClient();\n        $this->logger->debug(\"[ApHttpClient::generalFetch] URL: $url\");\n        $r = $client->request('GET', $url, [\n            'max_duration' => self::MAX_DURATION,\n            'timeout' => self::TIMEOUT,\n            'headers' => $this->getInstanceHeaders($url, requestType: $requestType),\n        ]);\n\n        return $r->getContent();\n    }\n\n    private function generalFetchCached(string $cachePrefix, string $fetchType, string $url, ApRequestType $requestType = ApRequestType::ActivityPub): ?string\n    {\n        $key = $cachePrefix.hash('sha256', $url);\n\n        if ($this->cache->hasItem($key)) {\n            /** @var CacheItem $item */\n            $item = $this->cache->getItem($key);\n\n            return $item->get();\n        }\n\n        try {\n            try {\n                $this->dispatcher->dispatch(new CurlRequestBeginningEvent($url));\n            } catch (\\Throwable) {\n            }\n            $resp = $this->generalFetch($url, $requestType);\n            try {\n                $this->dispatcher->dispatch(new CurlRequestFinishedEvent($url, true, $resp));\n            } catch (\\Throwable) {\n            }\n        } catch (\\Exception $e) {\n            $this->logger->warning('[ApHttpClient::generalFetchCached] There was an exception fetching {type} from {url}: {e} - {msg}', [\n                'type' => $fetchType,\n                'url' => $url,\n                'e' => \\get_class($e),\n                'msg' => $e->getMessage(),\n            ]);\n            $resp = null;\n            $this->dispatcher->dispatch(new CurlRequestFinishedEvent($url, false));\n        }\n\n        if (!$resp) {\n            return null;\n        }\n\n        $item = $this->cache->getItem($key);\n        $item->set($resp);\n        $item->expiresAt(new \\DateTime('+1 day'));\n        $this->cache->save($item);\n\n        return $resp;\n    }\n\n    private function getFetchAcceptHeaders(ApRequestType $requestType): array\n    {\n        return match ($requestType) {\n            ApRequestType::WebFinger => [\n                'Accept' => 'application/jrd+json',\n                'Content-Type' => 'application/jrd+json',\n            ],\n            ApRequestType::ActivityPub => [\n                'Accept' => 'application/activity+json',\n                'Content-Type' => 'application/activity+json',\n            ],\n            ApRequestType::NodeInfo => [\n                'Accept' => 'application/json',\n                'Content-Type' => 'application/json',\n            ],\n        };\n    }\n\n    private static function headersToCurlArray($headers): array\n    {\n        return array_map(function ($k, $v) {\n            return \"$k: $v\";\n        }, array_keys($headers), $headers);\n    }\n\n    private function getHeaders(string $url, User|Magazine $actor, ?array $body = null, bool $useOldPrivateKey = false): array\n    {\n        if ($useOldPrivateKey) {\n            $this->logger->debug('[ApHttpClient::getHeaders] Signing headers using the old private key');\n        }\n        $headers = self::headersToSign($url, $body ? self::digest($body) : null);\n        $stringToSign = self::headersToSigningString($headers);\n        $signedHeaders = implode(' ', array_map('strtolower', array_keys($headers)));\n        $key = openssl_pkey_get_private($useOldPrivateKey ? $actor->oldPrivateKey : $actor->privateKey);\n        if (false !== $key) {\n            $success_sign = openssl_sign($stringToSign, $signature, $key, OPENSSL_ALGO_SHA256);\n        } else {\n            $success_sign = false;\n        }\n        $signatureHeader = null;\n        if ($success_sign) {\n            $signature = base64_encode($signature);\n            $keyId = $actor instanceof User\n                ? $this->personFactory->getActivityPubId($actor).'#main-key'\n                : $this->groupFactory->getActivityPubId($actor).'#main-key';\n            $signatureHeader = 'keyId=\"'.$keyId.'\",headers=\"'.$signedHeaders.'\",algorithm=\"rsa-sha256\",signature=\"'.$signature.'\"';\n        } else {\n            $this->logger->error('[ApHttpClient::getHeaders] Failed to sign headers for {url} with private key of {actor}: {headers}', [\n                'url' => $url,\n                'headers' => $headers,\n                'actor' => $actor->apId ?? (\n                    $actor instanceof User\n                        ? $this->personFactory->getActivityPubId($actor).'#main-key'\n                        : $this->groupFactory->getActivityPubId($actor).'#main-key'\n                ),\n            ]);\n            throw new \\Exception('Failed to sign headers');\n        }\n        unset($headers['(request-target)']);\n        if ($signatureHeader) {\n            $headers['Signature'] = $signatureHeader;\n        }\n        $headers['User-Agent'] = $this->projectInfo->getUserAgent();\n        $headers['Accept'] = 'application/activity+json';\n        $headers['Content-Type'] = 'application/activity+json';\n\n        return $headers;\n    }\n\n    private function getInstanceHeaders(string $url, ?array $body = null, string $method = 'get', ApRequestType $requestType = ApRequestType::ActivityPub): array\n    {\n        $keyId = 'https://'.$this->kbinDomain.'/i/actor#main-key';\n        $privateKey = $this->getInstancePrivateKey();\n        $headers = self::headersToSign($url, $body ? self::digest($body) : null, $method);\n        $stringToSign = self::headersToSigningString($headers);\n        $signedHeaders = implode(' ', array_map('strtolower', array_keys($headers)));\n        $key = openssl_pkey_get_private($privateKey);\n        if (false !== $key) {\n            $success_sign = openssl_sign($stringToSign, $signature, $key, OPENSSL_ALGO_SHA256);\n        } else {\n            $success_sign = false;\n        }\n        $signatureHeader = null;\n        if ($success_sign) {\n            $signature = base64_encode($signature);\n            $signatureHeader = 'keyId=\"'.$keyId.'\",headers=\"'.$signedHeaders.'\",algorithm=\"rsa-sha256\",signature=\"'.$signature.'\"';\n        } else {\n            $this->logger->error('[ApHttpClient::getInstanceHeaders] Failed to sign headers for {url}: {headers}', [\n                'url' => $url,\n                'headers' => $headers,\n            ]);\n            throw new \\Exception('Failed to sign headers');\n        }\n        unset($headers['(request-target)']);\n        if ($signatureHeader) {\n            $headers['Signature'] = $signatureHeader;\n        }\n        $headers['User-Agent'] = $this->projectInfo->getUserAgent();\n        $headers = array_merge($headers, $this->getFetchAcceptHeaders($requestType));\n\n        return $headers;\n    }\n\n    #[ArrayShape([\n        '(request-target)' => 'string',\n        'Date' => 'string',\n        'Host' => 'mixed',\n        'Accept' => 'string',\n        'Digest' => 'string',\n    ])]\n    protected static function headersToSign(string $url, ?string $digest = null, string $method = 'post'): array\n    {\n        $date = new \\DateTime('UTC');\n\n        if (!\\in_array($method, ['post', 'get'])) {\n            throw new InvalidApPostException('Invalid method used to sign headers in ApHttpClient');\n        }\n        $headers = [\n            '(request-target)' => $method.' '.parse_url($url, PHP_URL_PATH),\n            'Date' => $date->format('D, d M Y H:i:s \\G\\M\\T'),\n            'Host' => parse_url($url, PHP_URL_HOST),\n        ];\n\n        if (!empty($digest)) {\n            $headers['Digest'] = 'SHA-256='.$digest;\n        }\n\n        return $headers;\n    }\n\n    private static function digest(array $body): string\n    {\n        return base64_encode(hash('sha256', json_encode($body), true));\n    }\n\n    private static function headersToSigningString(array $headers): string\n    {\n        return implode(\n            \"\\n\",\n            array_map(function ($k, $v) {\n                return strtolower($k).': '.$v;\n            }, array_keys($headers), $headers)\n        );\n    }\n\n    private function getInstancePrivateKey(): string\n    {\n        return $this->cache->get('instance_private_key', function (ItemInterface $item) {\n            $item->expiresAt(new \\DateTime('+1 day'));\n\n            return $this->siteRepository->findAll()[0]->privateKey;\n        });\n    }\n\n    public function getInstancePublicKey(): string\n    {\n        return $this->cache->get('instance_public_key', function (ItemInterface $item) {\n            $item->expiresAt(new \\DateTime('+1 day'));\n\n            return $this->siteRepository->findAll()[0]->publicKey;\n        });\n    }\n}\n"
  },
  {
    "path": "src/Service/ActivityPub/ApHttpClientInterface.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service\\ActivityPub;\n\nuse App\\Entity\\Magazine;\nuse App\\Entity\\User;\nuse App\\Exception\\InvalidApPostException;\nuse App\\Exception\\InvalidWebfingerException;\nuse Psr\\Cache\\InvalidArgumentException;\nuse Symfony\\Contracts\\HttpClient\\Exception\\TransportExceptionInterface;\n\ninterface ApHttpClientInterface\n{\n    /**\n     * Retrieve a remote activity object from an URL. And cache the result.\n     *\n     * @param bool $decoded (optional)\n     *\n     * @return array|string|null JSON Response body (as PHP Object)\n     */\n    public function getActivityObject(string $url, bool $decoded = true): array|string|null;\n\n    public function getActivityObjectCacheKey(string $url): string;\n\n    /**\n     * Retrieve AP actor object (could be a user or magazine).\n     *\n     * @return string return the inbox URL of the actor\n     *\n     * @throws \\LogicException|InvalidApPostException if the AP actor object cannot be found\n     */\n    public function getInboxUrl(string $apProfileId): string;\n\n    /**\n     * Execute a webfinger request according to RFC 7033 (https://tools.ietf.org/html/rfc7033).\n     *\n     * @param string $url the URL of the user/magazine to get the webfinger object for\n     *\n     * @return array|null The webfinger object (as PHP Object)\n     *\n     * @throws InvalidWebfingerException|InvalidArgumentException\n     */\n    public function getWebfingerObject(string $url): ?array;\n\n    /**\n     * Retrieve AP actor object (could be a user or magazine).\n     *\n     * @return array|null key/value array of actor response body (as PHP Object)\n     *\n     * @throws InvalidApPostException|InvalidArgumentException\n     */\n    public function getActorObject(string $apProfileId): ?array;\n\n    /**\n     * Remove actor object from cache.\n     *\n     * @param string $apProfileId AP profile ID to remove from cache\n     */\n    public function invalidateActorObjectCache(string $apProfileId): void;\n\n    /**\n     * Remove collection object from cache.\n     *\n     * @param string $apAddress AP address to remove from cache\n     */\n    public function invalidateCollectionObjectCache(string $apAddress): void;\n\n    /**\n     * Retrieve AP collection object. First look in cache, then try to retrieve from AP server.\n     * And finally, save the response to cache.\n     *\n     * @return array|null JSON Response body (as PHP Object)\n     *\n     * @throws InvalidArgumentException\n     */\n    public function getCollectionObject(string $apAddress): ?array;\n\n    /**\n     * Sends a POST request to the specified URL with optional request body and caching mechanism.\n     *\n     * @param string        $url              the URL to which the POST request will be sent\n     * @param User|Magazine $actor            The actor initiating the request, either a User or Magazine object\n     * @param array|null    $body             (Optional) The body of the POST request. Defaults to null.\n     * @param bool          $useOldPrivateKey (Optional) Whether to use the old private key for signing (e.g. to send an update activity rotating the private key)\n     *\n     * @throws InvalidApPostException      if the POST request fails with a non-2xx response status code\n     * @throws TransportExceptionInterface\n     */\n    public function post(string $url, User|Magazine $actor, ?array $body = null, bool $useOldPrivateKey = false): void;\n\n    public function fetchInstanceNodeInfoEndpoints(string $domain, bool $decoded = true): array|string|null;\n\n    public function fetchInstanceNodeInfo(string $url, bool $decoded = true): array|string|null;\n\n    public function getInstancePublicKey(): string;\n}\n"
  },
  {
    "path": "src/Service/ActivityPub/ApObjectExtractor.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service\\ActivityPub;\n\nuse App\\Service\\ActivityPubManager;\n\nclass ApObjectExtractor\n{\n    public const MARKDOWN_TYPE = 'text/markdown';\n\n    public function __construct(\n        private readonly MarkdownConverter $markdownConverter,\n        private readonly ActivityPubManager $activityPubManager,\n    ) {\n    }\n\n    public function getMarkdownBody(array $object): ?string\n    {\n        $content = $object['content'] ?? null;\n        $source = $object['source'] ?? null;\n\n        // object has no content nor source to extract body from\n        if (null === $content && null === $source) {\n            return null;\n        }\n\n        if ($source && (isset($source['mediaType']) && self::MARKDOWN_TYPE === $source['mediaType'])) {\n            // markdown source found, return them\n            return $source['content'] ?? null;\n        } elseif ($content && (isset($object['mediaType']) && self::MARKDOWN_TYPE === $object['mediaType'])) {\n            // markdown source isn't found but object's content is specified\n            // to be markdown, also return them\n            return $content;\n        } elseif ($content && \\is_string($content)) {\n            // assuming default content mediaType of text/html,\n            // returning html -> markdown conversion of content\n            return $this->markdownConverter->convert($content, $object['tag'] ?? []);\n        }\n\n        return '';\n    }\n\n    public function getExternalMediaBody(array $object): ?string\n    {\n        $body = null;\n\n        if (isset($object['attachment'])) {\n            $attachments = $object['attachment'];\n\n            if ($images = $this->activityPubManager->handleExternalImages($attachments)) {\n                $body .= \"\\n\\n\".implode(\n                    \"  \\n\",\n                    array_map(\n                        fn ($image) => \\sprintf(\n                            '![%s](%s)',\n                            preg_replace('/\\r\\n|\\r|\\n/', ' ', $image->name),\n                            $image->url\n                        ),\n                        $images\n                    )\n                );\n            }\n\n            if ($videos = $this->activityPubManager->handleExternalVideos($attachments)) {\n                $body .= \"\\n\\n\".implode(\n                    \"  \\n\",\n                    array_map(\n                        fn ($video) => \\sprintf(\n                            '![%s](%s)',\n                            preg_replace('/\\r\\n|\\r|\\n/', ' ', $video->name),\n                            $video->url\n                        ),\n                        $videos\n                    )\n                );\n            }\n        }\n\n        return $body;\n    }\n}\n"
  },
  {
    "path": "src/Service/ActivityPub/ContextsProvider.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service\\ActivityPub;\n\nuse App\\Entity\\Contracts\\ActivityPubActivityInterface;\nuse Symfony\\Component\\Routing\\Generator\\UrlGeneratorInterface;\n\nclass ContextsProvider\n{\n    public function __construct(\n        private readonly UrlGeneratorInterface $urlGenerator,\n    ) {\n    }\n\n    public static function embeddedContexts(): array\n    {\n        return [\n            [\n                ...ActivityPubActivityInterface::ADDITIONAL_CONTEXTS,\n            ],\n        ];\n    }\n\n    public function referencedContexts(): array\n    {\n        return [\n            ActivityPubActivityInterface::CONTEXT_URL,\n            ActivityPubActivityInterface::SECURITY_URL,\n            $this->urlGenerator->generate('ap_contexts', [], UrlGeneratorInterface::ABSOLUTE_URL),\n        ];\n    }\n}\n"
  },
  {
    "path": "src/Service/ActivityPub/DeleteService.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service\\ActivityPub;\n\nuse App\\Entity\\Entry;\nuse App\\Entity\\EntryComment;\nuse App\\Entity\\Post;\nuse App\\Entity\\PostComment;\nuse App\\Entity\\User;\nuse App\\Message\\ActivityPub\\Outbox\\DeleteMessage;\nuse App\\Repository\\ActivityRepository;\nuse App\\Service\\ActivityPub\\Wrapper\\AnnounceWrapper;\nuse App\\Service\\ActivityPub\\Wrapper\\DeleteWrapper;\nuse Symfony\\Component\\Messenger\\MessageBusInterface;\n\nclass DeleteService\n{\n    public function __construct(\n        private readonly MessageBusInterface $bus,\n        private readonly AnnounceWrapper $announceWrapper,\n        private readonly DeleteWrapper $deleteWrapper,\n        private readonly ActivityJsonBuilder $activityJsonBuilder,\n        private readonly ActivityRepository $activityRepository,\n    ) {\n    }\n\n    public function announceIfNecessary(?User $deletingUser, Entry|EntryComment|Post|PostComment $content): void\n    {\n        if (null !== $deletingUser && (!$content->apId || !$content->magazine->apId || !$deletingUser->apId) && ($content->magazine->userIsModerator($deletingUser) || $content->magazine->hasSameHostAsUser($deletingUser) || $content->isAuthor($deletingUser))) {\n            if ($deletingUser->apId) {\n                $deleteActivity = $this->activityRepository->findFirstActivitiesByTypeAndObject('Delete', $content);\n                if (!$deleteActivity) {\n                    throw new \\Exception('Cannot announce an activity that is not in the DB');\n                }\n                if (!$content->apId) {\n                    // local content, but remote actor ->\n                    // this activity should be just forwarded to the inbox of the accounts following the author,\n                    // but we do not have anything for that, yet, so instead we just announce it as the user\n                    $activity = $this->announceWrapper->build($content->user, $deleteActivity);\n                } elseif (!$content->magazine->apId) {\n                    // local magazine, but remote actor -> announce\n                    $activity = $this->announceWrapper->build($content->magazine, $deleteActivity);\n                }\n            } else {\n                $activity = $this->deleteWrapper->build($content, $deletingUser);\n            }\n            $payload = $this->activityJsonBuilder->buildActivityJson($activity);\n            $this->bus->dispatch(new DeleteMessage($payload, $content->user->getId(), $content->magazine->getId()));\n        }\n    }\n}\n"
  },
  {
    "path": "src/Service/ActivityPub/HttpSignature.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service\\ActivityPub;\n\nuse App\\Exception\\InvalidApSignatureException;\nuse JetBrains\\PhpStorm\\ArrayShape;\n\n/*\n * source:\n * https://github.com/aaronpk/Nautilus/blob/master/app/ActivityPub/HTTPSignature.php\n * https://github.com/pixelfed/pixelfed/blob/dev/app/Util/ActivityPub/HttpSignature.php\n */\n\nclass HttpSignature\n{\n    /**\n     * Splits a signature header string into component pieces.\n     */\n    #[ArrayShape([\n        'keyId' => 'string',\n        'algorithm' => 'string',\n        'headers' => 'string',\n        'signature' => 'string',\n    ])]\n    public static function parseSignatureHeader(string $signature): array\n    {\n        $parts = explode(',', $signature);\n        $signatureData = [];\n\n        foreach ($parts as $part) {\n            if (preg_match('/(.+)=\"(.+)\"/', $part, $match)) {\n                $signatureData[$match[1]] = $match[2];\n            }\n        }\n\n        if (!isset($signatureData['keyId'])) {\n            throw new InvalidApSignatureException('No keyId was found in the signature header. Found: '.implode(', ', array_keys($signatureData)));\n        }\n\n        if (!filter_var($signatureData['keyId'], FILTER_VALIDATE_URL)) {\n            throw new InvalidApSignatureException('keyId is not a URL: '.$signatureData['keyId']);\n        }\n\n        if (!isset($signatureData['headers']) || !isset($signatureData['signature'])) {\n            throw new InvalidApSignatureException('Signature is missing headers or signature parts.');\n        }\n\n        return $signatureData;\n    }\n}\n"
  },
  {
    "path": "src/Service/ActivityPub/KeysGenerator.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service\\ActivityPub;\n\nuse App\\Entity\\Contracts\\ActivityPubActorInterface;\nuse phpseclib3\\Crypt\\RSA;\n\nclass KeysGenerator\n{\n    public static function generate(ActivityPubActorInterface $actor): ActivityPubActorInterface\n    {\n        $privateKey = RSA::createKey(4096);\n\n        $actor->publicKey = (string) $privateKey->getPublicKey();\n        $actor->privateKey = (string) $privateKey;\n\n        return $actor;\n    }\n}\n"
  },
  {
    "path": "src/Service/ActivityPub/MarkdownConverter.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service\\ActivityPub;\n\nuse App\\Entity\\Magazine;\nuse App\\Entity\\User;\nuse App\\Service\\ActivityPubManager;\nuse App\\Service\\MentionManager;\nuse App\\Service\\TagExtractor;\nuse League\\HTMLToMarkdown\\Converter\\TableConverter;\nuse League\\HTMLToMarkdown\\HtmlConverter;\n\nclass MarkdownConverter\n{\n    public function __construct(\n        private readonly TagExtractor $tagExtractor,\n        private readonly MentionManager $mentionManager,\n        private readonly ActivityPubManager $activityPubManager,\n    ) {\n    }\n\n    public function convert(string $value, array $apTags): string\n    {\n        $converter = new HtmlConverter(['strip_tags' => true]);\n        $converter->getEnvironment()->addConverter(new TableConverter());\n        $converter->getEnvironment()->addConverter(new StrikethroughConverter());\n        $value = stripslashes($converter->convert($value));\n\n        // an example value: [@user](https://some.instance.tld/u/user)\n        preg_match_all('/\\[([^]]*)\\] *\\(([^)]*)\\)/i', $value, $matches, PREG_SET_ORDER);\n\n        foreach ($matches as $match) {\n            if ($this->mentionManager->extract($match[1])) {\n                $mentionFromTag = $this->findMentionFromTag($match, $apTags);\n                if (\\count($mentionFromTag)) {\n                    $mentionFromTagObj = $mentionFromTag[array_key_first($mentionFromTag)];\n                    $mentioned = null;\n                    try {\n                        $mentioned = $this->activityPubManager->findActorOrCreate($mentionFromTagObj['href']);\n                    } catch (\\Throwable) {\n                    }\n                    if ($mentioned instanceof User) {\n                        $replace = $this->mentionManager->getUsername($mentioned->username, true);\n                    } elseif ($mentioned instanceof Magazine) {\n                        $replace = $this->mentionManager->getUsername('@'.$mentioned->name, true);\n                    } else {\n                        $replace = $mentionFromTagObj['name'] ?? $match[1];\n                    }\n                } else {\n                    try {\n                        $actor = $this->activityPubManager->findActorOrCreate($match[2]);\n                        $username = $actor instanceof User ? $actor->username : $actor->name;\n                        $replace = $this->mentionManager->getUsername($username, true);\n                    } catch (\\Throwable) {\n                        $replace = $match[1];\n                    }\n                }\n                $value = str_replace($match[0], $replace, $value);\n            }\n\n            if ($this->tagExtractor->extract($match[1])) {\n                $value = str_replace($match[0], $match[1], $value);\n            }\n        }\n\n        return $value;\n    }\n\n    private function findMentionFromTag(array $match, array $apTags): array\n    {\n        $res = [];\n        foreach ($apTags as $tag) {\n            if ('Mention' === $tag['type']) {\n                if ($match[2] === $tag['href']) {\n                    // the href in the tag array might be the same as the link from the text\n                    $res[] = $tag;\n                } elseif ($match[1] === $tag['name']) {\n                    // or it might not be, but the linktext from the text might be the same as the name in the tag array\n                    $res[] = $tag;\n                } elseif (($host = parse_url($tag['href'], PHP_URL_HOST)) && \"$match[1]@$host\" === $tag['name']) {\n                    // or the tag array might contain the full handle, but the linktext might only be the name part of the handle\n                    $res[] = $tag;\n                }\n            }\n        }\n\n        return $res;\n    }\n}\n"
  },
  {
    "path": "src/Service/ActivityPub/Note.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service\\ActivityPub;\n\nuse App\\DTO\\EntryCommentDto;\nuse App\\DTO\\PostCommentDto;\nuse App\\DTO\\PostDto;\nuse App\\Entity\\Contracts\\ActivityPubActivityInterface;\nuse App\\Entity\\Entry;\nuse App\\Entity\\EntryComment;\nuse App\\Entity\\Magazine;\nuse App\\Entity\\Post;\nuse App\\Entity\\PostComment;\nuse App\\Entity\\User;\nuse App\\Exception\\EntryLockedException;\nuse App\\Exception\\InstanceBannedException;\nuse App\\Exception\\PostLockedException;\nuse App\\Exception\\TagBannedException;\nuse App\\Exception\\UserBannedException;\nuse App\\Exception\\UserDeletedException;\nuse App\\Factory\\ImageFactory;\nuse App\\Repository\\ApActivityRepository;\nuse App\\Service\\ActivityPubManager;\nuse App\\Service\\EntryCommentManager;\nuse App\\Service\\PostCommentManager;\nuse App\\Service\\PostManager;\nuse App\\Service\\SettingsManager;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Psr\\Log\\LoggerInterface;\nuse Symfony\\Component\\Messenger\\Exception\\UnrecoverableMessageHandlingException;\n\nclass Note extends ActivityPubContent\n{\n    public function __construct(\n        private readonly LoggerInterface $logger,\n        private readonly ApActivityRepository $repository,\n        private readonly PostManager $postManager,\n        private readonly EntryCommentManager $entryCommentManager,\n        private readonly PostCommentManager $postCommentManager,\n        private readonly ActivityPubManager $activityPubManager,\n        private readonly EntityManagerInterface $entityManager,\n        private readonly SettingsManager $settingsManager,\n        private readonly ImageFactory $imageFactory,\n        private readonly ApObjectExtractor $objectExtractor,\n    ) {\n    }\n\n    /**\n     * @throws TagBannedException\n     * @throws UserBannedException\n     * @throws UserDeletedException\n     * @throws InstanceBannedException\n     * @throws EntryLockedException\n     * @throws PostLockedException\n     * @throws \\Exception\n     */\n    public function create(array $object, ?array $root = null, bool $stickyIt = false): EntryComment|PostComment|Post\n    {\n        // First try to find the activity object in the database\n        $current = $this->repository->findByObjectId($object['id']);\n        if ($current) {\n            return $this->entityManager->getRepository($current['type'])->find((int) $current['id']);\n        }\n        if ($this->settingsManager->isBannedInstance($object['id'])) {\n            throw new InstanceBannedException();\n        }\n\n        if (\\is_string($object['to'])) {\n            $object['to'] = [$object['to']];\n        }\n\n        if (!isset($object['cc'])) {\n            $object['cc'] = [];\n        } elseif (\\is_string($object['cc'])) {\n            $object['cc'] = [$object['cc']];\n        }\n\n        if (isset($object['inReplyTo']) && $replyTo = $object['inReplyTo']) {\n            // Create post or entry comment\n            $parentObjectId = $this->repository->findByObjectId($replyTo);\n            $parent = $this->entityManager->getRepository($parentObjectId['type'])->find((int) $parentObjectId['id']);\n\n            if ($parent instanceof Entry) {\n                $root = $parent;\n\n                return $this->createEntryComment($object, $parent, $root);\n            } elseif ($parent instanceof EntryComment) {\n                $root = $parent->entry;\n\n                return $this->createEntryComment($object, $parent, $root);\n            } elseif ($parent instanceof Post) {\n                $root = $parent;\n\n                return $this->createPostComment($object, $parent, $root);\n            } elseif ($parent instanceof PostComment) {\n                $root = $parent->post;\n\n                return $this->createPostComment($object, $parent, $root);\n            } else {\n                throw new \\LogicException(\\get_class($parent).' is not a valid parent');\n            }\n        }\n\n        return $this->createPost($object, $stickyIt);\n    }\n\n    /**\n     * @throws TagBannedException\n     * @throws UserBannedException\n     * @throws UserDeletedException\n     * @throws EntryLockedException\n     * @throws \\Exception\n     */\n    private function createEntryComment(array $object, ActivityPubActivityInterface $parent, ?ActivityPubActivityInterface $root = null): EntryComment\n    {\n        $dto = new EntryCommentDto();\n        if ($parent instanceof EntryComment) {\n            $dto->parent = $parent;\n            $dto->root = $parent->root ?? $parent;\n        }\n\n        $dto->entry = $root;\n        $dto->apId = $object['id'];\n\n        if (\n            isset($object['attachment'])\n            && $image = $this->activityPubManager->handleImages($object['attachment'])\n        ) {\n            $dto->image = $this->imageFactory->createDto($image);\n        }\n\n        $actor = $this->activityPubManager->findActorOrCreate($object['attributedTo']);\n        if ($actor instanceof User) {\n            if ($actor->isBanned) {\n                throw new UserBannedException();\n            }\n            if ($actor->isDeleted || $actor->isSoftDeleted() || $actor->isTrashed()) {\n                throw new UserDeletedException();\n            }\n            $dto->body = $this->objectExtractor->getMarkdownBody($object);\n            if ($media = $this->objectExtractor->getExternalMediaBody($object)) {\n                $dto->body .= $media;\n            }\n\n            $dto->visibility = $this->getVisibility($object, $actor);\n            $this->handleDate($dto, $object['published']);\n            if (isset($object['sensitive'])) {\n                $this->handleSensitiveMedia($dto, $object['sensitive']);\n            }\n\n            if (!empty($object['language'])) {\n                $dto->lang = $object['language']['identifier'];\n            } elseif (!empty($object['contentMap'])) {\n                $dto->lang = array_keys($object['contentMap'])[0];\n            } else {\n                $dto->lang = $this->settingsManager->get('KBIN_DEFAULT_LANG');\n            }\n\n            $dto->apLikeCount = $this->activityPubManager->extractRemoteLikeCount($object);\n            $dto->apDislikeCount = $this->activityPubManager->extractRemoteDislikeCount($object);\n            $dto->apShareCount = $this->activityPubManager->extractRemoteShareCount($object);\n\n            return $this->entryCommentManager->create($dto, $actor, false);\n        } elseif ($actor instanceof Magazine) {\n            throw new UnrecoverableMessageHandlingException('Actor \"'.$object['attributedTo'].'\" is not a user, but a magazine for post \"'.$dto->apId.'\".');\n        } else {\n            throw new UnrecoverableMessageHandlingException('Actor \"'.$object['attributedTo'].'\"could not be found for post \"'.$dto->apId.'\".');\n        }\n    }\n\n    /**\n     * @throws UserDeletedException\n     * @throws TagBannedException\n     * @throws UserBannedException\n     */\n    private function createPost(array $object, bool $stickyIt = false): Post\n    {\n        $dto = new PostDto();\n        $dto->magazine = $this->activityPubManager->findOrCreateMagazineByToCCAndAudience($object);\n        $dto->apId = $object['id'];\n\n        $actor = $this->activityPubManager->findActorOrCreate($object['attributedTo']);\n        if ($actor instanceof User) {\n            if ($actor->isBanned) {\n                throw new UserBannedException();\n            }\n            if ($actor->isDeleted || $actor->isSoftDeleted() || $actor->isTrashed()) {\n                throw new UserDeletedException();\n            }\n\n            if (isset($object['attachment']) && $image = $this->activityPubManager->handleImages($object['attachment'])) {\n                $dto->image = $this->imageFactory->createDto($image);\n                $this->logger->debug(\"adding image to post '{title}', {image}\", ['title' => $dto->slug, 'image' => $image->getId()]);\n            }\n\n            $dto->body = $this->objectExtractor->getMarkdownBody($object);\n            if ($media = $this->objectExtractor->getExternalMediaBody($object)) {\n                $dto->body .= $media;\n            }\n\n            $dto->visibility = $this->getVisibility($object, $actor);\n            $this->handleDate($dto, $object['published']);\n            if (isset($object['sensitive'])) {\n                $this->handleSensitiveMedia($dto, $object['sensitive']);\n            }\n\n            if (!empty($object['language'])) {\n                $dto->lang = $object['language']['identifier'];\n            } elseif (!empty($object['contentMap'])) {\n                $dto->lang = array_keys($object['contentMap'])[0];\n            } else {\n                $dto->lang = $this->settingsManager->get('KBIN_DEFAULT_LANG');\n            }\n            $dto->apLikeCount = $this->activityPubManager->extractRemoteLikeCount($object);\n            $dto->apDislikeCount = $this->activityPubManager->extractRemoteDislikeCount($object);\n            $dto->apShareCount = $this->activityPubManager->extractRemoteShareCount($object);\n\n            if (isset($object['commentsEnabled']) && \\is_bool($object['commentsEnabled'])) {\n                $dto->isLocked = !$object['commentsEnabled'];\n            }\n\n            return $this->postManager->create($dto, $actor, false, $stickyIt);\n        } elseif ($actor instanceof Magazine) {\n            throw new UnrecoverableMessageHandlingException('Actor \"'.$object['attributedTo'].'\" is not a user, but a magazine for post \"'.$dto->apId.'\".');\n        } else {\n            throw new UnrecoverableMessageHandlingException('Actor \"'.$object['attributedTo'].'\"could not be found for post \"'.$dto->apId.'\".');\n        }\n    }\n\n    /**\n     * @throws UserDeletedException\n     * @throws TagBannedException\n     * @throws UserBannedException\n     * @throws PostLockedException\n     */\n    private function createPostComment(array $object, ActivityPubActivityInterface $parent, ?ActivityPubActivityInterface $root = null): PostComment\n    {\n        $dto = new PostCommentDto();\n        if ($parent instanceof PostComment) {\n            $dto->parent = $parent;\n        }\n\n        $dto->post = $root;\n        $dto->apId = $object['id'];\n\n        if (\n            isset($object['attachment'])\n            && $image = $this->activityPubManager->handleImages($object['attachment'])\n        ) {\n            $dto->image = $this->imageFactory->createDto($image);\n        }\n\n        $actor = $this->activityPubManager->findActorOrCreate($object['attributedTo']);\n        if ($actor instanceof User) {\n            if ($actor->isBanned) {\n                throw new UserBannedException();\n            }\n            if ($actor->isDeleted || $actor->isSoftDeleted() || $actor->isTrashed()) {\n                throw new UserDeletedException();\n            }\n            $dto->body = $this->objectExtractor->getMarkdownBody($object);\n            if ($media = $this->objectExtractor->getExternalMediaBody($object)) {\n                $dto->body .= $media;\n            }\n\n            $dto->visibility = $this->getVisibility($object, $actor);\n            $this->handleDate($dto, $object['published']);\n            if (isset($object['sensitive'])) {\n                $this->handleSensitiveMedia($dto, $object['sensitive']);\n            }\n\n            if (!empty($object['language'])) {\n                $dto->lang = $object['language']['identifier'];\n            } elseif (!empty($object['contentMap'])) {\n                $dto->lang = array_keys($object['contentMap'])[0];\n            } else {\n                $dto->lang = $this->settingsManager->get('KBIN_DEFAULT_LANG');\n            }\n            $dto->apLikeCount = $this->activityPubManager->extractRemoteLikeCount($object);\n            $dto->apDislikeCount = $this->activityPubManager->extractRemoteDislikeCount($object);\n            $dto->apShareCount = $this->activityPubManager->extractRemoteShareCount($object);\n\n            return $this->postCommentManager->create($dto, $actor, false);\n        } elseif ($actor instanceof Magazine) {\n            throw new UnrecoverableMessageHandlingException('Actor \"'.$object['attributedTo'].'\" is not a user, but a magazine for post \"'.$dto->apId.'\".');\n        } else {\n            throw new UnrecoverableMessageHandlingException('Actor \"'.$object['attributedTo'].'\"could not be found for post \"'.$dto->apId.'\".');\n        }\n    }\n}\n"
  },
  {
    "path": "src/Service/ActivityPub/Page.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service\\ActivityPub;\n\nuse App\\DTO\\EntryDto;\nuse App\\Entity\\Entry;\nuse App\\Entity\\User;\nuse App\\Exception\\EntityNotFoundException;\nuse App\\Exception\\InstanceBannedException;\nuse App\\Exception\\PostingRestrictedException;\nuse App\\Exception\\TagBannedException;\nuse App\\Exception\\UserBannedException;\nuse App\\Exception\\UserDeletedException;\nuse App\\Factory\\ImageFactory;\nuse App\\Repository\\ApActivityRepository;\nuse App\\Repository\\InstanceRepository;\nuse App\\Service\\ActivityPubManager;\nuse App\\Service\\EntryManager;\nuse App\\Service\\SettingsManager;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Psr\\Log\\LoggerInterface;\n\nclass Page extends ActivityPubContent\n{\n    public function __construct(\n        private readonly ApActivityRepository $repository,\n        private readonly EntryManager $entryManager,\n        private readonly ActivityPubManager $activityPubManager,\n        private readonly EntityManagerInterface $entityManager,\n        private readonly SettingsManager $settingsManager,\n        private readonly ImageFactory $imageFactory,\n        private readonly ApObjectExtractor $objectExtractor,\n        private readonly LoggerInterface $logger,\n        private readonly InstanceRepository $instanceRepository,\n    ) {\n    }\n\n    /**\n     * @throws TagBannedException\n     * @throws UserBannedException\n     * @throws UserDeletedException\n     * @throws EntityNotFoundException    if the user could not be found or a sub exception occurred\n     * @throws PostingRestrictedException if the target magazine has Magazine::postingRestrictedToMods = true and the actor is a magazine or a user that is not a mod\n     * @throws InstanceBannedException    if the actor is from a banned instance\n     * @throws \\Exception                 if there was an error\n     */\n    public function create(array $object, bool $stickyIt = false): Entry\n    {\n        // First try to find the activity object in the database\n        $current = $this->repository->findByObjectId($object['id']);\n        if ($current) {\n            return $this->entityManager->getRepository($current['type'])->find((int) $current['id']);\n        }\n        $actorUrl = $this->activityPubManager->getSingleActorFromAttributedTo($object['attributedTo']);\n        if ($this->settingsManager->isBannedInstance($actorUrl)) {\n            throw new InstanceBannedException();\n        }\n        $actor = $this->activityPubManager->findActorOrCreate($actorUrl);\n        if (!empty($actor)) {\n            if ($actor->isBanned) {\n                throw new UserBannedException();\n            }\n            if ($actor->isDeleted || $actor->isTrashed() || $actor->isSoftDeleted()) {\n                throw new UserDeletedException();\n            }\n\n            $current = $this->repository->findByObjectId($object['id']);\n            if ($current) {\n                $this->logger->debug('Page already exists, not creating it');\n\n                return $this->entityManager->getRepository($current['type'])->find((int) $current['id']);\n            }\n\n            if (\\is_string($object['to'])) {\n                $object['to'] = [$object['to']];\n            }\n\n            if (\\is_string($object['cc'])) {\n                $object['cc'] = [$object['cc']];\n            }\n\n            $magazine = $this->activityPubManager->findOrCreateMagazineByToCCAndAudience($object);\n            if ($magazine->isActorPostingRestricted($actor)) {\n                throw new PostingRestrictedException($magazine, $actor);\n            }\n\n            $dto = new EntryDto();\n            $dto->magazine = $magazine;\n            $dto->title = $object['name'];\n            $dto->apId = $object['id'];\n\n            if ((isset($object['attachment']) || isset($object['image'])) && $image = $this->activityPubManager->handleImages($object['attachment'])) {\n                $this->logger->debug(\"adding image to entry '{title}', {image}\", ['title' => $dto->title, 'image' => $image->getId()]);\n                $dto->image = $this->imageFactory->createDto($image);\n            }\n\n            $dto->body = $this->objectExtractor->getMarkdownBody($object);\n            $dto->visibility = $this->getVisibility($object, $actor);\n            $this->extractUrlIntoDto($dto, $object, $actor);\n            $this->handleDate($dto, $object['published']);\n            if (isset($object['sensitive'])) {\n                $this->handleSensitiveMedia($dto, $object['sensitive']);\n            }\n\n            if (isset($object['sensitive']) && true === $object['sensitive']) {\n                $dto->isAdult = true;\n            }\n\n            if (!empty($object['language'])) {\n                $dto->lang = $object['language']['identifier'];\n            } elseif (!empty($object['contentMap'])) {\n                $dto->lang = array_keys($object['contentMap'])[0];\n            } else {\n                $dto->lang = $this->settingsManager->get('KBIN_DEFAULT_LANG');\n            }\n            $dto->apLikeCount = $this->activityPubManager->extractRemoteLikeCount($object);\n            $dto->apDislikeCount = $this->activityPubManager->extractRemoteDislikeCount($object);\n            $dto->apShareCount = $this->activityPubManager->extractRemoteShareCount($object);\n\n            if (isset($object['commentsEnabled']) && \\is_bool($object['commentsEnabled'])) {\n                $dto->isLocked = !$object['commentsEnabled'];\n            }\n\n            $this->logger->debug('creating page');\n\n            return $this->entryManager->create($dto, $actor, false, $stickyIt);\n        } else {\n            throw new EntityNotFoundException('Actor could not be found for entry.');\n        }\n    }\n\n    private function extractUrlIntoDto(EntryDto $dto, ?array $object, User $actor): void\n    {\n        $attachment = \\array_key_exists('attachment', $object) ? $object['attachment'] : null;\n\n        $dto->url = ActivityPubManager::extractUrlFromAttachment($attachment);\n        if (null === $dto->url) {\n            $instance = $this->instanceRepository->findOneBy(['domain' => $actor->apDomain]);\n            if ($instance && 'peertube' === $instance->software) {\n                // we make an exception for PeerTube as we need their embed viewer.\n                // Normally the URL field only links to a user-friendly UI if that differs from the AP id,\n                // which we do not want to have as a URL, but without the embed from PeerTube\n                // a video is only viewable by clicking more -> open original URL\n                // which is not very user-friendly.\n                $url = \\array_key_exists('url', $object) ? $object['url'] : null;\n                $dto->url = ActivityPubManager::extractUrlFromAttachment($url);\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "src/Service/ActivityPub/SignatureValidator.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service\\ActivityPub;\n\nuse App\\Exception\\InboxForwardingException;\nuse App\\Exception\\InvalidApSignatureException;\nuse App\\Exception\\InvalidUserPublicKeyException;\nuse App\\Message\\ActivityPub\\Inbox\\ActivityMessage;\nuse App\\Service\\ActivityPubManager;\nuse Psr\\Log\\LoggerInterface;\n\n/**\n * @phpstan-import-type RequestType from ActivityMessage\n */\nreadonly class SignatureValidator\n{\n    public function __construct(\n        private ActivityPubManager $activityPubManager,\n        private ApHttpClientInterface $client,\n        private LoggerInterface $logger,\n    ) {\n    }\n\n    /**\n     * Attempts to validate an incoming signed HTTP request.\n     *\n     * @param array  $request The information about the incoming request\n     * @param array  $headers Headers attached to the incoming request\n     * @param string $body    The body of the incoming request\n     *\n     * @phpstan-param RequestType $request\n     *\n     * @throws InvalidApSignatureException   The HTTP request was not signed appropriately\n     * @throws InvalidUserPublicKeyException The public key of the specified user is invalid or null\n     * @throws InboxForwardingException\n     */\n    public function validate(array $request, array $headers, string $body): void\n    {\n        $payload = json_decode($body, true);\n\n        $signature = \\is_array($headers['signature']) ? $headers['signature'][0] : $headers['signature'];\n        $date = \\is_array($headers['date']) ? $headers['date'][0] : $headers['date'];\n\n        if (!$signature || !$date) {\n            throw new InvalidApSignatureException('Missing required signature and/or date header');\n        }\n\n        // @todo verify headers date\n\n        $signature = HttpSignature::parseSignatureHeader($signature);\n\n        $this->validateUrl($signature['keyId']);\n\n        if (!isset($payload['id'])) {\n            throw new InvalidApSignatureException('Missing required \"id\" field in the payload');\n        }\n\n        $this->validateUrl($id = \\is_array($payload['id']) ? $payload['id'][0] : $payload['id']);\n\n        $keyDomain = parse_url($signature['keyId'], PHP_URL_HOST);\n        $idDomain = parse_url($id, PHP_URL_HOST);\n\n        $actorKeyIdMismatch = false;\n        $firstActorHost = null;\n        $erroredActor = null;\n\n        if (isset($payload['actor'])) {\n            $actors = $payload['actor'];\n            if (\\is_string($actors)) {\n                $actors = [$actors];\n            }\n            foreach ($actors as $actor) {\n                $url = $actor;\n                if (!\\is_string($actor) and isset($actor['id'])) {\n                    $url = $actor['id'];\n                }\n                $host = parse_url($url, PHP_URL_HOST);\n                if (!$firstActorHost) {\n                    $firstActorHost = $host;\n                }\n                if ($host !== $keyDomain) {\n                    $actorKeyIdMismatch = true;\n                    $erroredActor = $url;\n                    break;\n                }\n            }\n        }\n\n        $forwardedMessage = false;\n        $keyAndIdMismatch = false;\n        if (!$keyDomain || !$idDomain || $keyDomain !== $idDomain) {\n            $keyAndIdMismatch = true;\n        }\n\n        if ($keyAndIdMismatch or $actorKeyIdMismatch and $firstActorHost === $idDomain) {\n            foreach (ActivityPubManager::getReceivers($payload) as $item) {\n                // if the payload has an inbox of the keyId domain than this is a case of inbox forwarding\n                // and we should dispatch a new message to get the activity from the \"real\" host\n                $itemDomain = parse_url($item, PHP_URL_HOST);\n                if ($itemDomain === $keyDomain) {\n                    $forwardedMessage = true;\n                    break;\n                }\n            }\n        }\n\n        if ($forwardedMessage) {\n            throw new InboxForwardingException($signature['keyId'], $id);\n        } elseif ($actorKeyIdMismatch) {\n            throw new InvalidApSignatureException(\"Supplied key domain does not match domain of incoming activities 'actor' property. actor: '$erroredActor', keyId : '$keyDomain'\");\n        } elseif ($keyAndIdMismatch) {\n            throw new InvalidApSignatureException(\"Supplied key domain does not match domain of incoming activity. idDomain: '$idDomain' keyDomain: '$keyDomain'\");\n        }\n\n        $actorUrl = \\is_array($payload['actor']) ? $payload['actor'][0] : $payload['actor'];\n\n        $user = $this->activityPubManager->findActorOrCreate($actorUrl);\n        if (!empty($user)) {\n            $pem = $user->publicKey ?? $this->client->getActorObject($user->apProfileId)['publicKey']['publicKeyPem'] ?? null;\n            if (null === $pem) {\n                throw new InvalidUserPublicKeyException($user->apProfileId);\n            }\n            $pkey = openssl_pkey_get_public($pem);\n\n            if (false === $pkey) {\n                throw new InvalidUserPublicKeyException($user->apProfileId);\n            }\n\n            $this->verifySignature($pkey, $signature, $headers, $request['uri'], $body);\n        }\n    }\n\n    private function validateUrl(string $url): void\n    {\n        $valid = filter_var($url, FILTER_VALIDATE_URL);\n        if (!$valid) {\n            throw new InvalidApSignatureException('Necessary supplied URL not valid.');\n        }\n\n        $parsed = parse_url($url);\n        if ('https' !== $parsed['scheme']) {\n            throw new InvalidApSignatureException('Necessary supplied URL does not use HTTPS.');\n        }\n    }\n\n    /**\n     * Verifies the signature of request against the given public key.\n     *\n     * @param array $signature Parsed signature value\n     *\n     * @throws InvalidApSignatureException Signature failed verification\n     */\n    private function verifySignature(\n        \\OpenSSLAsymmetricKey $pkey,\n        array $signature,\n        array $headers,\n        string $inboxUrl,\n        string $payload,\n    ): void {\n        $digest = 'SHA-256='.base64_encode(hash('sha256', $payload, true));\n\n        if (isset($headers['digest']) && $digest !== $suppliedDigest = \\is_array($headers['digest']) ? $headers['digest'][0] : $headers['digest']) {\n            $this->logger->warning('Supplied digest of incoming request does not match calculated value', ['supplied-digest' => $suppliedDigest]);\n        }\n\n        $headersToSign = [];\n        foreach (explode(' ', $signature['headers']) as $h) {\n            if ('(request-target)' === $h) {\n                $headersToSign[$h] = 'post '.$inboxUrl;\n            } elseif ('digest' === $h) {\n                $headersToSign[$h] = $digest;\n            } elseif (isset($headers[$h][0])) {\n                $headersToSign[$h] = $headers[$h][0];\n            }\n        }\n\n        $signingString = self::headersToSigningString($headersToSign);\n\n        $verified = openssl_verify($signingString, base64_decode($signature['signature']), $pkey, OPENSSL_ALGO_SHA256);\n\n        if (!$verified) {\n            throw new InvalidApSignatureException('Signature of request could not be verified.');\n        }\n\n        $this->logger->debug('Successfully verified signature of incoming AP request.', ['digest' => $digest]);\n    }\n\n    private static function headersToSigningString($headers): string\n    {\n        return implode(\n            \"\\n\",\n            array_map(function ($k, $v) {\n                return strtolower($k).': '.$v;\n            }, array_keys($headers), $headers)\n        );\n    }\n}\n"
  },
  {
    "path": "src/Service/ActivityPub/StrikethroughConverter.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service\\ActivityPub;\n\nuse League\\HTMLToMarkdown\\Configuration;\nuse League\\HTMLToMarkdown\\ConfigurationAwareInterface;\nuse League\\HTMLToMarkdown\\Converter\\ConverterInterface;\nuse League\\HTMLToMarkdown\\ElementInterface;\n\n/**\n * Inspired by https://github.com/thephpleague/html-to-markdown/blob/master/src/Converter/EmphasisConverter.php.\n */\nclass StrikethroughConverter implements ConverterInterface, ConfigurationAwareInterface\n{\n    protected Configuration $config;\n\n    public function getSupportedTags(): array\n    {\n        return ['del', 'strike'];\n    }\n\n    public function setConfig(Configuration $config): void\n    {\n        $this->config = $config;\n    }\n\n    public function convert(ElementInterface $element): string\n    {\n        $value = $element->getValue();\n        if (!trim($value)) {\n            return $value;\n        }\n\n        $prefix = ltrim($value) !== $value ? ' ' : '';\n        $suffix = rtrim($value) !== $value ? ' ' : '';\n\n        /* If this node is immediately preceded or followed by one of the same type don't emit\n         * the start or end $style, respectively. This prevents <del>foo</del><del>bar</del> from\n         * being converted to ~~foo~~~~bar~~ which is incorrect. We want ~~foobar~~ instead.\n         */\n        $preStyle = \\in_array($element->getPreviousSibling()?->getTagName(), $this->getSupportedTags()) ? '' : '~~';\n        $postStyle = \\in_array($element->getNextSibling()?->getTagName(), $this->getSupportedTags()) ? '' : '~~';\n\n        return $prefix.$preStyle.trim($value).$postStyle.$suffix;\n    }\n}\n"
  },
  {
    "path": "src/Service/ActivityPub/Webfinger/WebFinger.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\n/*\n * This file is part of the ActivityPhp package.\n *\n * Copyright (c) landrok at github.com/landrok\n *\n * For the full copyright and license information, please see\n * <https://github.com/landrok/activitypub/blob/master/LICENSE>.\n */\n\nnamespace App\\Service\\ActivityPub\\Webfinger;\n\nuse App\\Exception\\InvalidWebfingerException;\n\n/**\n * A simple WebFinger container of data.\n */\nclass WebFinger\n{\n    /**\n     * @var string\n     */\n    protected $subject;\n\n    /**\n     * @var string[]\n     */\n    protected $aliases = [];\n\n    /**\n     * @var array\n     */\n    protected $links = [];\n\n    /**\n     * Construct WebFinger instance.\n     *\n     * @param array $data A WebFinger response\n     */\n    public function __construct(array $data)\n    {\n        $data['aliases'] = [];\n\n        foreach (['subject', 'aliases', 'links'] as $key) {\n            if (!isset($data[$key])) {\n                throw new \\Exception(\"WebFinger profile must contain '$key' property\");\n            }\n            $method = 'set'.ucfirst($key);\n            $this->$method($data[$key]);\n        }\n    }\n\n    /**\n     * Get ActivityPhp profile id URL.\n     *\n     * @return string\n     *\n     * @throws InvalidWebfingerException\n     */\n    public function getProfileId()\n    {\n        foreach ($this->links as $link) {\n            if ($this->isLinkProfileId($link)) {\n                return $link['href'];\n            }\n        }\n\n        throw new InvalidWebfingerException('WebFinger data contains no AP profile identifier');\n    }\n\n    public function getProfileIds(): array\n    {\n        $urls = [];\n        foreach ($this->links as $link) {\n            if ($this->isLinkProfileId($link)) {\n                $urls[] = $link['href'];\n            }\n        }\n\n        return $urls;\n    }\n\n    private function isLinkProfileId(array $link): bool\n    {\n        if (isset($link['rel'], $link['type'], $link['href'])) {\n            if ('self' === $link['rel'] && 'application/activity+json' === $link['type']) {\n                return true;\n            }\n        }\n\n        return false;\n    }\n\n    /**\n     * Get WebFinger response as an array.\n     *\n     * @return array\n     */\n    public function toArray()\n    {\n        return [\n            'subject' => $this->subject,\n            'aliases' => $this->aliases,\n            'links' => $this->links,\n        ];\n    }\n\n    /**\n     * Get aliases.\n     *\n     * @return array\n     */\n    public function getAliases()\n    {\n        return $this->aliases;\n    }\n\n    /**\n     * Set aliases property.\n     */\n    protected function setAliases(array $aliases)\n    {\n        foreach ($aliases as $alias) {\n            if (!\\is_string($alias)) {\n                throw new \\Exception('WebFinger aliases must be an array of strings');\n            }\n\n            $this->aliases[] = $alias;\n        }\n    }\n\n    /**\n     * Get links.\n     *\n     * @return array\n     */\n    public function getLinks()\n    {\n        return $this->links;\n    }\n\n    /**\n     * Set links property.\n     */\n    protected function setLinks(array $links)\n    {\n        foreach ($links as $link) {\n            if (!\\is_array($link)) {\n                throw new \\Exception('WebFinger links must be an array of objects');\n            }\n\n            if (!isset($link['rel'])) {\n                throw new \\Exception(\"WebFinger links object must contain 'rel' property\");\n            }\n\n            $tmp = [];\n            $tmp['rel'] = $link['rel'];\n\n            foreach (['type', 'href', 'template'] as $key) {\n                if (isset($link[$key]) && \\is_string($link[$key])) {\n                    $tmp[$key] = $link[$key];\n                }\n            }\n\n            $this->links[] = $tmp;\n        }\n    }\n\n    /**\n     * Get subject fetched from profile.\n     *\n     * @return string|null Subject\n     */\n    public function getSubject()\n    {\n        return $this->subject;\n    }\n\n    /**\n     * Set subject property.\n     *\n     * @param string $subject\n     */\n    protected function setSubject($subject)\n    {\n        if (!\\is_string($subject)) {\n            throw new \\Exception('WebFinger subject must be a string');\n        }\n\n        $this->subject = $subject;\n    }\n\n    /**\n     * Get subject handle fetched from profile.\n     *\n     * @return string|null\n     */\n    public function getHandle()\n    {\n        return substr($this->subject, 5);\n    }\n}\n"
  },
  {
    "path": "src/Service/ActivityPub/Webfinger/WebFingerFactory.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\n/*\n * This file is part of the ActivityPhp package, with modifications.\n *\n * Copyright (c) landrok at github.com/landrok\n *\n * For the full copyright and license information, please see\n * <https://github.com/landrok/activitypub/blob/master/LICENSE>.\n */\n\nnamespace App\\Service\\ActivityPub\\Webfinger;\n\nuse App\\ActivityPub\\ActorHandle;\nuse App\\Service\\ActivityPub\\ApHttpClientInterface;\n\n/**\n * A simple WebFinger discoverer tool.\n */\nclass WebFingerFactory\n{\n    public const WEBFINGER_URL = '%s://%s%s/.well-known/webfinger?resource=acct:%s';\n\n    public function __construct(private readonly ApHttpClientInterface $client)\n    {\n    }\n\n    public function get(string $handle, string $scheme = 'https')\n    {\n        $actorHandle = ActorHandle::parse($handle);\n\n        if (!$actorHandle) {\n            throw new \\Exception(\"WebFinger handle is malformed '{$handle}'\");\n        }\n\n        // Build a WebFinger URL\n        $url = \\sprintf(\n            self::WEBFINGER_URL,\n            $scheme,\n            $actorHandle->host,\n            $actorHandle->getPortString(),\n            $actorHandle->plainHandle(),\n        );\n\n        $content = $this->client->getWebfingerObject($url);\n\n        if (!\\is_array($content) || !\\count($content)) {\n            throw new \\Exception('WebFinger fetching has failed, no contents returned for '.$handle);\n        }\n\n        return new WebFinger($content);\n    }\n}\n"
  },
  {
    "path": "src/Service/ActivityPub/Webfinger/WebFingerParameters.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service\\ActivityPub\\Webfinger;\n\nuse Symfony\\Component\\HttpFoundation\\Request;\n\nclass WebFingerParameters\n{\n    public const REL_KEY_NAME = 'rel';\n    public const HOST_KEY_NAME = 'host';\n    public const ACCOUNT_KEY_NAME = 'account';\n\n    public function __construct()\n    {\n    }\n\n    /**\n     * @return array{acccount?: string, host?: string, rel?: array}\n     */\n    public function getParams(Request $request): array\n    {\n        $params = [];\n\n        if ($resource = $request->query->get('resource')) {\n            $host = $request->server->get('HTTP_HOST'); // @todo\n\n            if (!str_contains($resource, '//')) {\n                $resource = str_replace(':', '://', $resource);\n            }\n\n            if (!str_contains($resource, 'acct:')) {\n                $resource = 'acct://'.$resource;\n            }\n\n            $url = parse_url($resource);\n            if (!empty($url['scheme']) && !empty($url['user']) && !empty($url['host'])) {\n                $params[static::HOST_KEY_NAME] = $host;\n\n                if ('acct' === $url['scheme']) {\n                    if ($host === $url['host']) {\n                        $params[static::ACCOUNT_KEY_NAME] = $url['user']; // @todo\n                    }\n                }\n            }\n        }\n\n        if ($request->query->has('rel')) {\n            $params[static::REL_KEY_NAME] = (array) $request->query->get('rel');\n        }\n\n        return $params;\n    }\n}\n"
  },
  {
    "path": "src/Service/ActivityPub/Wrapper/AnnounceWrapper.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service\\ActivityPub\\Wrapper;\n\nuse App\\Entity\\Activity;\nuse App\\Entity\\Contracts\\ActivityPubActivityInterface;\nuse App\\Entity\\Magazine;\nuse App\\Entity\\User;\nuse App\\Factory\\ActivityPub\\ActivityFactory;\nuse Doctrine\\ORM\\EntityManagerInterface;\n\nclass AnnounceWrapper\n{\n    public function __construct(\n        private readonly ActivityFactory $activityFactory,\n        private readonly EntityManagerInterface $entityManager,\n    ) {\n    }\n\n    /**\n     * @param User|Magazine                                $actor      the actor doing the announce\n     * @param ActivityPubActivityInterface|Activity|string $object     the thing the actor is announcing.\n     *                                                                 If it is a string it will be treated as a url to the activity this is announcing\n     * @param bool                                         $idAsObject use only the id of $object as the 'object' in the payload.\n     *                                                                 This should only be true for user boosts\n     *\n     * @return Activity an announce activity\n     */\n    public function build(User|Magazine $actor, ActivityPubActivityInterface|Activity|string $object, bool $idAsObject = false): Activity\n    {\n        $activity = new Activity('Announce');\n        $activity->setActor($actor);\n        if ($object instanceof Activity) {\n            $activity->innerActivity = $object;\n        } elseif ($object instanceof ActivityPubActivityInterface) {\n            if ($idAsObject) {\n                $arr = $this->activityFactory->create($object);\n                $activity->setObject($arr['id']);\n            } else {\n                $activity->setObject($object);\n            }\n        } else {\n            $url = filter_var($object, FILTER_VALIDATE_URL);\n            if (false === $url) {\n                throw new \\LogicException('expecting the object to be an url if it is a string');\n            }\n            $activity->innerActivityUrl = $url;\n        }\n\n        $this->entityManager->persist($activity);\n        $this->entityManager->flush();\n\n        return $activity;\n    }\n}\n"
  },
  {
    "path": "src/Service/ActivityPub/Wrapper/CollectionInfoWrapper.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service\\ActivityPub\\Wrapper;\n\nuse App\\Service\\ActivityPub\\ContextsProvider;\nuse JetBrains\\PhpStorm\\ArrayShape;\nuse Symfony\\Component\\Routing\\Generator\\UrlGeneratorInterface;\n\nclass CollectionInfoWrapper\n{\n    public function __construct(\n        private readonly UrlGeneratorInterface $urlGenerator,\n        private readonly ContextsProvider $contextsProvider,\n    ) {\n    }\n\n    #[ArrayShape([\n        '@context' => 'string',\n        'type' => 'string',\n        'id' => 'string',\n        'first' => 'string',\n        'totalItems' => 'int',\n    ])]\n    public function build(string $routeName, array $routeParams, int $count, bool $includeContext = true): array\n    {\n        $result = [\n            '@context' => $this->contextsProvider->referencedContexts(),\n            'type' => 'OrderedCollection',\n            'id' => $this->urlGenerator->generate($routeName, $routeParams, UrlGeneratorInterface::ABSOLUTE_URL),\n            'first' => $this->urlGenerator->generate(\n                $routeName,\n                $routeParams + ['page' => 1],\n                UrlGeneratorInterface::ABSOLUTE_URL\n            ),\n            'totalItems' => $count,\n        ];\n\n        if (!$includeContext) {\n            unset($result['@context']);\n        }\n\n        return $result;\n    }\n}\n"
  },
  {
    "path": "src/Service/ActivityPub/Wrapper/CollectionItemsWrapper.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service\\ActivityPub\\Wrapper;\n\nuse App\\Service\\ActivityPub\\ContextsProvider;\nuse JetBrains\\PhpStorm\\ArrayShape;\nuse Pagerfanta\\PagerfantaInterface;\nuse Symfony\\Component\\Routing\\Generator\\UrlGeneratorInterface;\n\nclass CollectionItemsWrapper\n{\n    public function __construct(\n        private readonly UrlGeneratorInterface $urlGenerator,\n        private readonly ContextsProvider $contextProvider,\n    ) {\n    }\n\n    #[ArrayShape([\n        '@context' => 'string',\n        'type' => 'string',\n        'partOf' => 'string',\n        'id' => 'string',\n        'totalItems' => 'int',\n        'orderedItems' => \"\\Pagerfanta\\PagerfantaInterface\",\n        'next' => 'string',\n    ])]\n    public function build(\n        string $routeName,\n        array $routeParams,\n        PagerfantaInterface $pagerfanta,\n        array $items,\n        int $page,\n        bool $includeContext = true,\n    ): array {\n        $result = [\n            '@context' => $this->contextProvider->referencedContexts(),\n            'type' => 'OrderedCollectionPage',\n            'partOf' => $this->urlGenerator->generate(\n                $routeName,\n                $routeParams,\n                UrlGeneratorInterface::ABSOLUTE_URL\n            ),\n            'id' => $this->urlGenerator->generate(\n                $routeName,\n                $routeParams + ['page' => $page],\n                UrlGeneratorInterface::ABSOLUTE_URL\n            ),\n            'totalItems' => $pagerfanta->getNbResults(),\n            'orderedItems' => $items,\n        ];\n\n        if (!$includeContext) {\n            unset($result['@context']);\n        }\n\n        if ($pagerfanta->hasNextPage()) {\n            $result['next'] = $this->urlGenerator->generate(\n                $routeName,\n                $routeParams + ['page' => $pagerfanta->getNextPage()],\n                UrlGeneratorInterface::ABSOLUTE_URL\n            );\n        }\n\n        return $result;\n    }\n}\n"
  },
  {
    "path": "src/Service/ActivityPub/Wrapper/CreateWrapper.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service\\ActivityPub\\Wrapper;\n\nuse App\\Entity\\Activity;\nuse App\\Entity\\Contracts\\ActivityPubActivityInterface;\nuse App\\Entity\\Entry;\nuse App\\Entity\\EntryComment;\nuse App\\Entity\\Message;\nuse App\\Entity\\Post;\nuse App\\Entity\\PostComment;\nuse Doctrine\\ORM\\EntityManagerInterface;\n\nclass CreateWrapper\n{\n    public function __construct(\n        private readonly EntityManagerInterface $entityManager,\n    ) {\n    }\n\n    public function build(ActivityPubActivityInterface $item): Activity\n    {\n        $activity = new Activity('Create');\n        $activity->setObject($item);\n        if ($item instanceof Entry || $item instanceof EntryComment || $item instanceof Post || $item instanceof PostComment) {\n            $activity->userActor = $item->getUser();\n        } elseif ($item instanceof Message) {\n            $activity->userActor = $item->sender;\n        }\n        $this->entityManager->persist($activity);\n        $this->entityManager->flush();\n\n        return $activity;\n    }\n}\n"
  },
  {
    "path": "src/Service/ActivityPub/Wrapper/DeleteWrapper.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service\\ActivityPub\\Wrapper;\n\nuse App\\Entity\\Activity;\nuse App\\Entity\\Contracts\\ActivityPubActivityInterface;\nuse App\\Entity\\Entry;\nuse App\\Entity\\EntryComment;\nuse App\\Entity\\Post;\nuse App\\Entity\\PostComment;\nuse App\\Entity\\User;\nuse App\\Factory\\ActivityPub\\ActivityFactory;\nuse App\\Service\\ActivityPub\\ActivityJsonBuilder;\nuse App\\Service\\ActivityPub\\ContextsProvider;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Symfony\\Component\\Routing\\Generator\\UrlGeneratorInterface;\n\nclass DeleteWrapper\n{\n    public function __construct(\n        private readonly ActivityFactory $factory,\n        private readonly AnnounceWrapper $announceWrapper,\n        private readonly UrlGeneratorInterface $urlGenerator,\n        private readonly EntityManagerInterface $entityManager,\n        private readonly ContextsProvider $contextsProvider,\n        private readonly ActivityJsonBuilder $activityJsonBuilder,\n    ) {\n    }\n\n    public function build(ActivityPubActivityInterface $item, ?User $deletingUser = null, bool $includeContext = true): Activity\n    {\n        $activity = new Activity('Delete');\n        $item = $this->factory->create($item);\n\n        $userUrl = $item['attributedTo'];\n\n        if (null !== $deletingUser) {\n            // overwrite the actor in the json with the supplied deleting user\n            if (null !== $deletingUser->apId) {\n                $userUrl = $deletingUser->apPublicUrl;\n            } else {\n                $userUrl = $this->urlGenerator->generate('user_overview', ['username' => $deletingUser->username], UrlGeneratorInterface::ABSOLUTE_URL);\n            }\n        }\n\n        $this->entityManager->persist($activity);\n        $this->entityManager->flush();\n\n        $json = [\n            '@context' => $this->contextsProvider->referencedContexts(),\n            'id' => $this->urlGenerator->generate('ap_object', ['id' => $activity->uuid], UrlGeneratorInterface::ABSOLUTE_URL),\n            'type' => 'Delete',\n            'actor' => $userUrl,\n            'object' => [\n                'id' => $item['id'],\n                'type' => 'Tombstone',\n            ],\n            'to' => $item['to'],\n            'cc' => $item['cc'],\n        ];\n\n        if (!$includeContext) {\n            unset($json['@context']);\n        }\n\n        $activity->activityJson = json_encode($json);\n        $this->entityManager->persist($activity);\n        $this->entityManager->flush();\n\n        return $activity;\n    }\n\n    public function buildForUser(User $user): Activity\n    {\n        $activity = new Activity('Delete');\n        $this->entityManager->persist($activity);\n        $this->entityManager->flush();\n        $userId = $this->urlGenerator->generate('ap_user', ['username' => $user->username], UrlGeneratorInterface::ABSOLUTE_URL);\n\n        $activity->activityJson = json_encode([\n            'id' => $this->urlGenerator->generate('ap_object', ['id' => $activity->uuid], UrlGeneratorInterface::ABSOLUTE_URL),\n            'type' => 'Delete',\n            'actor' => $userId,\n            'object' => $userId,\n            'to' => [ActivityPubActivityInterface::PUBLIC_URL],\n            'cc' => [$this->urlGenerator->generate('ap_user_followers', ['username' => $user->username], UrlGeneratorInterface::ABSOLUTE_URL)],\n            // this is a lemmy specific tag, that should cause the deletion of the data of a user (see this issue https://github.com/LemmyNet/lemmy/issues/4544)\n            'removeData' => true,\n        ]);\n        $this->entityManager->persist($activity);\n        $this->entityManager->flush();\n\n        return $activity;\n    }\n\n    public function adjustDeletePayload(?User $actor, Entry|EntryComment|Post|PostComment $content, bool $includeContext = true): Activity\n    {\n        $payload = $this->build($content, $actor, $includeContext);\n        $json = json_decode($payload->activityJson, true);\n\n        if (null !== $actor && $content->user->getId() !== $actor->getId()) {\n            // if the user is different, then this is a mod action. Lemmy requires a mod action to have a summary\n            $json['summary'] = ' ';\n        }\n\n        if (null !== $actor?->apId) {\n            $announceActivity = $this->announceWrapper->build($content->magazine, $payload);\n            $json = $this->activityJsonBuilder->buildActivityJson($announceActivity);\n        }\n\n        $payload->activityJson = json_encode($json);\n        $this->entityManager->persist($payload);\n        $this->entityManager->flush();\n\n        return $payload;\n    }\n}\n"
  },
  {
    "path": "src/Service/ActivityPub/Wrapper/FollowResponseWrapper.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service\\ActivityPub\\Wrapper;\n\nuse App\\Entity\\Activity;\nuse App\\Entity\\Magazine;\nuse App\\Entity\\User;\nuse Doctrine\\ORM\\EntityManagerInterface;\n\nclass FollowResponseWrapper\n{\n    public function __construct(\n        private readonly EntityManagerInterface $entityManager,\n    ) {\n    }\n\n    public function build(User|Magazine $actor, array|Activity $request, bool $isReject = false): Activity\n    {\n        $activity = new Activity($isReject ? 'Reject' : 'Accept');\n        $activity->setActor($actor);\n        $activity->setObject($request);\n\n        $this->entityManager->persist($activity);\n        $this->entityManager->flush();\n\n        return $activity;\n    }\n}\n"
  },
  {
    "path": "src/Service/ActivityPub/Wrapper/FollowWrapper.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service\\ActivityPub\\Wrapper;\n\nuse App\\Entity\\Activity;\nuse App\\Entity\\Magazine;\nuse App\\Entity\\User;\nuse Doctrine\\ORM\\EntityManagerInterface;\n\nclass FollowWrapper\n{\n    public function __construct(\n        private readonly EntityManagerInterface $entityManager,\n    ) {\n    }\n\n    public function build(User $follower, User|Magazine $following): Activity\n    {\n        $activity = new Activity('Follow');\n        $activity->setActor($follower);\n        $activity->setObject($following);\n\n        $this->entityManager->persist($activity);\n        $this->entityManager->flush();\n\n        return $activity;\n    }\n}\n"
  },
  {
    "path": "src/Service/ActivityPub/Wrapper/ImageWrapper.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service\\ActivityPub\\Wrapper;\n\nuse App\\Entity\\Image;\nuse App\\Service\\ImageManagerInterface;\n\nclass ImageWrapper\n{\n    public function __construct(private readonly ImageManagerInterface $imageManager)\n    {\n    }\n\n    public function build(array $item, Image $image, string $title = ''): array\n    {\n        $item['attachment'][] = [\n            'type' => 'Image',\n            'mediaType' => $this->imageManager->getMimetype($image),\n            'url' => $this->imageManager->getUrl($image),\n            'name' => $image->altText,\n            'blurhash' => $image->blurhash,\n            'focalPoint' => [0, 0],\n            'width' => $image->width,\n            'height' => $image->height,\n        ];\n\n        $item['image'] = [ // @todo Lemmy\n            'type' => 'Image',\n            'url' => $this->imageManager->getUrl($image),\n        ];\n\n        return $item;\n    }\n}\n"
  },
  {
    "path": "src/Service/ActivityPub/Wrapper/LikeWrapper.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service\\ActivityPub\\Wrapper;\n\nuse App\\Entity\\Activity;\nuse App\\Entity\\Contracts\\ActivityPubActivityInterface;\nuse App\\Entity\\Entry;\nuse App\\Entity\\EntryComment;\nuse App\\Entity\\Post;\nuse App\\Entity\\PostComment;\nuse App\\Entity\\User;\nuse App\\Factory\\ActivityPub\\ActivityFactory;\nuse Doctrine\\ORM\\EntityManagerInterface;\n\nclass LikeWrapper\n{\n    public function __construct(\n        private readonly EntityManagerInterface $entityManager,\n        private readonly ActivityFactory $activityFactory,\n    ) {\n    }\n\n    public function build(User $user, ActivityPubActivityInterface $object): Activity\n    {\n        $activityObject = $this->activityFactory->create($object);\n        $activity = new Activity('Like');\n        $activity->setObject($activityObject['id']);\n        $activity->userActor = $user;\n\n        if ($object instanceof Entry || $object instanceof EntryComment || $object instanceof Post || $object instanceof PostComment) {\n            $activity->audience = $object->magazine;\n        }\n\n        $this->entityManager->persist($activity);\n        $this->entityManager->flush();\n\n        return $activity;\n    }\n}\n"
  },
  {
    "path": "src/Service/ActivityPub/Wrapper/MentionsWrapper.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service\\ActivityPub\\Wrapper;\n\nuse App\\Service\\ActivityPubManager;\nuse App\\Service\\MentionManager;\nuse App\\Service\\SettingsManager;\nuse Symfony\\Component\\Routing\\Generator\\UrlGeneratorInterface;\n\nclass MentionsWrapper\n{\n    public function __construct(\n        private readonly ActivityPubManager $activityPubManager,\n        private readonly UrlGeneratorInterface $urlGenerator,\n        private readonly MentionManager $mentionManager,\n        private readonly SettingsManager $settingsManager,\n    ) {\n    }\n\n    public function build(?array $mentions, ?string $body = null): array\n    {\n        $mentions = array_unique(array_merge($mentions ?? [], $this->mentionManager->extract($body ?? '') ?? []));\n\n        $results = [];\n        foreach ($mentions as $index => $mention) {\n            try {\n                $actor = $this->activityPubManager->findActorOrCreate($mention);\n\n                if (!$actor) {\n                    continue;\n                }\n\n                if (substr_count($mention, '@') < 2) {\n                    $mention = $mention.'@'.$this->settingsManager->get('KBIN_DOMAIN');\n                }\n\n                $results[$index] = [\n                    'type' => 'Mention',\n                    'href' => $actor->apProfileId ??\n                        $this->urlGenerator->generate(\n                            'ap_user',\n                            ['username' => $actor->getUserIdentifier()],\n                            UrlGeneratorInterface::ABSOLUTE_URL\n                        ),\n                    'name' => $mention,\n                ];\n            } catch (\\Exception $e) {\n                continue;\n            }\n        }\n\n        return $results;\n    }\n}\n"
  },
  {
    "path": "src/Service/ActivityPub/Wrapper/TagsWrapper.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service\\ActivityPub\\Wrapper;\n\nuse Symfony\\Component\\Routing\\Generator\\UrlGeneratorInterface;\n\nclass TagsWrapper\n{\n    public function __construct(private readonly UrlGeneratorInterface $urlGenerator)\n    {\n    }\n\n    public function build(array $tags): array\n    {\n        return array_map(fn ($tag) => [\n            'type' => 'Hashtag',\n            'href' => $this->urlGenerator->generate(\n                'tag_overview',\n                ['name' => $tag],\n                UrlGeneratorInterface::ABSOLUTE_URL\n            ),\n            'name' => '#'.$tag,\n        ], $tags);\n    }\n}\n"
  },
  {
    "path": "src/Service/ActivityPub/Wrapper/UndoWrapper.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service\\ActivityPub\\Wrapper;\n\nuse App\\Entity\\Activity;\nuse App\\Entity\\User;\nuse Doctrine\\ORM\\EntityManagerInterface;\n\nclass UndoWrapper\n{\n    public function __construct(\n        private readonly EntityManagerInterface $entityManager,\n    ) {\n    }\n\n    public function build(Activity|string $object, ?User $actor = null): Activity\n    {\n        $activity = new Activity('Undo');\n        if ($object instanceof Activity) {\n            $activity->innerActivity = $object;\n            $activity->setActor($object->getActor());\n        } else {\n            if (null === $actor) {\n                throw new \\LogicException('actor must not be null if the object is a url');\n            }\n            $activity->innerActivityUrl = $object;\n            $activity->setActor($actor);\n        }\n\n        $this->entityManager->persist($activity);\n        $this->entityManager->flush();\n\n        return $activity;\n    }\n}\n"
  },
  {
    "path": "src/Service/ActivityPub/Wrapper/UpdateWrapper.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service\\ActivityPub\\Wrapper;\n\nuse App\\Entity\\Activity;\nuse App\\Entity\\Contracts\\ActivityPubActivityInterface;\nuse App\\Entity\\Contracts\\ActivityPubActorInterface;\nuse App\\Entity\\Entry;\nuse App\\Entity\\EntryComment;\nuse App\\Entity\\Post;\nuse App\\Entity\\PostComment;\nuse App\\Entity\\User;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse JetBrains\\PhpStorm\\ArrayShape;\n\nclass UpdateWrapper\n{\n    public function __construct(\n        private readonly EntityManagerInterface $entityManager,\n    ) {\n    }\n\n    public function buildForActivity(ActivityPubActivityInterface $content, ?User $editedBy = null): Activity\n    {\n        $activity = new Activity('Update');\n        $activity->setActor($editedBy ?? $content->getUser());\n        $activity->setObject($content);\n\n        if ($content instanceof Entry || $content instanceof EntryComment || $content instanceof Post || $content instanceof PostComment) {\n            $activity->audience = $content->magazine;\n        }\n\n        $this->entityManager->persist($activity);\n        $this->entityManager->flush();\n\n        return $activity;\n    }\n\n    #[ArrayShape([\n        '@context' => 'mixed',\n        'id' => 'mixed',\n        'type' => 'string',\n        'actor' => 'mixed',\n        'published' => 'mixed',\n        'to' => 'mixed',\n        'cc' => 'mixed',\n        'object' => 'array',\n    ])]\n    public function buildForActor(ActivityPubActorInterface $item, ?User $editedBy = null): Activity\n    {\n        $activity = new Activity('Update');\n        $activity->setActor($editedBy ?? $item);\n        $activity->setObject($item);\n\n        $this->entityManager->persist($activity);\n        $this->entityManager->flush();\n\n        return $activity;\n    }\n}\n"
  },
  {
    "path": "src/Service/ActivityPubManager.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service;\n\nuse App\\DTO\\ActivityPub\\ImageDto;\nuse App\\DTO\\ActivityPub\\VideoDto;\nuse App\\DTO\\ModeratorDto;\nuse App\\Entity\\Contracts\\ActivityPubActivityInterface;\nuse App\\Entity\\Contracts\\ActivityPubActorInterface;\nuse App\\Entity\\Entry;\nuse App\\Entity\\EntryComment;\nuse App\\Entity\\Image;\nuse App\\Entity\\Instance;\nuse App\\Entity\\Magazine;\nuse App\\Entity\\Moderator;\nuse App\\Entity\\Post;\nuse App\\Entity\\PostComment;\nuse App\\Entity\\User;\nuse App\\Exception\\InstanceBannedException;\nuse App\\Exception\\InvalidApPostException;\nuse App\\Exception\\InvalidWebfingerException;\nuse App\\Factory\\ActivityPub\\PersonFactory;\nuse App\\Factory\\MagazineFactory;\nuse App\\Factory\\UserFactory;\nuse App\\Message\\ActivityPub\\Inbox\\CreateMessage;\nuse App\\Message\\ActivityPub\\UpdateActorMessage;\nuse App\\Message\\DeleteImageMessage;\nuse App\\Message\\DeleteUserMessage;\nuse App\\Repository\\ApActivityRepository;\nuse App\\Repository\\EntryRepository;\nuse App\\Repository\\ImageRepository;\nuse App\\Repository\\InstanceRepository;\nuse App\\Repository\\MagazineRepository;\nuse App\\Repository\\UserRepository;\nuse App\\Service\\ActivityPub\\ApHttpClientInterface;\nuse App\\Service\\ActivityPub\\ApObjectExtractor;\nuse App\\Service\\ActivityPub\\Webfinger\\WebFinger;\nuse App\\Service\\ActivityPub\\Webfinger\\WebFingerFactory;\nuse App\\Utils\\UrlUtils;\nuse Doctrine\\Common\\Collections\\Criteria;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse League\\HTMLToMarkdown\\HtmlConverter;\nuse Psr\\Cache\\InvalidArgumentException;\nuse Psr\\Log\\LoggerInterface;\nuse Symfony\\Component\\Cache\\Exception\\CacheException;\nuse Symfony\\Component\\Messenger\\MessageBusInterface;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Routing\\Generator\\UrlGeneratorInterface;\nuse Symfony\\Contracts\\Cache\\CacheInterface;\n\nclass ActivityPubManager\n{\n    public function __construct(\n        private readonly ApActivityRepository $activityRepository,\n        private readonly UserRepository $userRepository,\n        private readonly UserManager $userManager,\n        private readonly UserFactory $userFactory,\n        private readonly MagazineManager $magazineManager,\n        private readonly MagazineFactory $magazineFactory,\n        private readonly MagazineRepository $magazineRepository,\n        private readonly ApHttpClientInterface $apHttpClient,\n        private readonly ImageRepository $imageRepository,\n        private readonly ImageManagerInterface $imageManager,\n        private readonly EntityManagerInterface $entityManager,\n        private readonly PersonFactory $personFactory,\n        private readonly SettingsManager $settingsManager,\n        private readonly WebFingerFactory $webFingerFactory,\n        private readonly MentionManager $mentionManager,\n        private readonly UrlGeneratorInterface $urlGenerator,\n        private readonly MessageBusInterface $bus,\n        private readonly LoggerInterface $logger,\n        private readonly RateLimiterFactoryInterface $apUpdateActorLimiter,\n        private readonly EntryRepository $entryRepository,\n        private readonly EntryManager $entryManager,\n        private readonly RemoteInstanceManager $remoteInstanceManager,\n        private readonly InstanceRepository $instanceRepository,\n        private readonly CacheInterface $cache,\n    ) {\n    }\n\n    public function getActorProfileId(ActivityPubActorInterface $actor): string\n    {\n        if ($actor instanceof User) {\n            if (!$actor->apId) {\n                return $this->personFactory->getActivityPubId($actor);\n            }\n        }\n\n        // @todo blid webfinger\n        return $actor->apProfileId;\n    }\n\n    public function findRemoteActor(string $actorUrl): ?User\n    {\n        return $this->userRepository->findOneBy(['apProfileId' => $actorUrl]);\n    }\n\n    public function createCcFromBody(?string $body): array\n    {\n        $mentions = $this->mentionManager->extract($body) ?? [];\n\n        $urls = [];\n        foreach ($mentions as $handle) {\n            try {\n                $actor = $this->findActorOrCreate($handle);\n            } catch (\\Exception $e) {\n                continue;\n            }\n\n            if (!$actor) {\n                continue;\n            }\n\n            $urls[] = $actor->apProfileId ?? $this->urlGenerator->generate(\n                'ap_user',\n                ['username' => $actor->getUserIdentifier()],\n                UrlGeneratorInterface::ABSOLUTE_URL\n            );\n        }\n\n        return $urls;\n    }\n\n    /**\n     * Find an existing actor or create a new one if the actor doesn't yet exists.\n     *\n     * @param ?string $actorUrlOrHandle actorUrlOrHandle actor URL or actor handle (could even be null)\n     *\n     * @return User|Magazine|null or Magazine or null on error\n     *\n     * @throws InvalidApPostException\n     * @throws InvalidArgumentException\n     * @throws InvalidWebfingerException\n     */\n    public function findActorOrCreate(?string $actorUrlOrHandle): User|Magazine|null\n    {\n        if (\\is_null($actorUrlOrHandle)) {\n            return null;\n        }\n\n        $this->logger->debug('[ActivityPubManager::findActorOrCreate] Searching for actor at \"{handle}\"', ['handle' => $actorUrlOrHandle]);\n        if (str_contains($actorUrlOrHandle, $this->settingsManager->get('KBIN_DOMAIN').'/m/')) {\n            $magazine = str_replace('https://'.$this->settingsManager->get('KBIN_DOMAIN').'/m/', '', $actorUrlOrHandle);\n            $this->logger->debug('[ActivityPubManager::findActorOrCreate] Found magazine: \"{magName}\"', ['magName' => $magazine]);\n\n            return $this->magazineRepository->findOneByName($magazine);\n        }\n\n        $actorUrl = $actorUrlOrHandle;\n        if (false === filter_var($actorUrl, FILTER_VALIDATE_URL)) {\n            if (!substr_count(ltrim($actorUrl, '@'), '@')) {\n                // local user. Maybe an @ at the beginning, but not in the middle\n                $user = $this->userRepository->findOneBy(['username' => ltrim($actorUrl, '@')]);\n            } else {\n                // remote user. Maybe @user@domain, maybe only user@domain -> trim left and look in apId\n                $user = $this->userRepository->findOneBy(['apId' => ltrim($actorUrl, '@')]);\n            }\n            if ($user instanceof User) {\n                if ($user->apId && !$user->isDeleted && !$user->isSoftDeleted() && !$user->isTrashed() && (!$user->apFetchedAt || $user->apFetchedAt->modify('+1 hour') < (new \\DateTime()))) {\n                    $this->dispatchUpdateActor($user->apProfileId);\n                }\n\n                return $user;\n            }\n\n            if (!substr_count(ltrim($actorUrl, '@'), '@')) {\n                // local magazine. Maybe an @ at the beginning, but not in the middle\n                $magazine = $this->magazineRepository->findOneBy(['name' => ltrim($actorUrl, '@')]);\n            } else {\n                // remote magazine. Maybe !magazine@domain, maybe only magazine@domain -> trim left and look in apId\n                $magazine = $this->magazineRepository->findOneBy(['apId' => ltrim($actorUrl, '@!')]);\n            }\n            if ($magazine instanceof Magazine) {\n                if ($magazine->apId && !$magazine->isSoftDeleted() && !$magazine->isTrashed() && (!$magazine->apFetchedAt || $magazine->apFetchedAt->modify('+1 hour') < (new \\DateTime()))) {\n                    $this->dispatchUpdateActor($magazine->apProfileId);\n                }\n\n                return $magazine;\n            }\n\n            $actorUrl = $this->webfinger($actorUrl)->getProfileId();\n        }\n\n        if (\\in_array(\n            parse_url($actorUrl, PHP_URL_HOST),\n            [$this->settingsManager->get('KBIN_DOMAIN'), 'localhost', '127.0.0.1']\n        )) {\n            $name = explode('/', $actorUrl);\n            $name = end($name);\n\n            $this->logger->debug('[ActivityPubManager::findActorOrCreate] Found user: \"{user}\"', ['user' => $name]);\n\n            return $this->userRepository->findOneBy(['username' => $name]);\n        }\n\n        // Check if the instance is banned\n        if ($this->settingsManager->isBannedInstance($actorUrl)) {\n            return null;\n        }\n\n        $user = $this->userRepository->findOneBy(['apProfileId' => $actorUrl]);\n        if (!$user) {\n            // also try the public URL if it was not found by the profile id\n            $user = $this->userRepository->findOneBy(['apPublicUrl' => $actorUrl]);\n        }\n        if ($user instanceof User) {\n            $this->logger->debug('[ActivityPubManager::findActorOrCreate] Found remote user for url: \"{url}\" in db', ['url' => $actorUrl]);\n            if ($user->apId && !$user->isDeleted && !$user->isSoftDeleted() && !$user->isTrashed() && (!$user->apFetchedAt || $user->apFetchedAt->modify('+1 hour') < (new \\DateTime()))) {\n                $this->dispatchUpdateActor($user->apProfileId);\n            }\n\n            return $user;\n        }\n\n        $magazine = $this->magazineRepository->findOneBy(['apProfileId' => $actorUrl]);\n        if (!$magazine) {\n            // also try the public URL if it was not found by the profile id\n            $magazine = $this->magazineRepository->findOneBy(['apPublicUrl' => $actorUrl]);\n        }\n        if ($magazine instanceof Magazine) {\n            $this->logger->debug('[ActivityPubManager::findActorOrCreate] Found remote user for url: \"{url}\" in db', ['url' => $actorUrl]);\n            if (!$magazine->isTrashed() && !$magazine->isSoftDeleted() && (!$magazine->apFetchedAt || $magazine->apFetchedAt->modify('+1 hour') < (new \\DateTime()))) {\n                $this->dispatchUpdateActor($magazine->apProfileId);\n            }\n\n            return $magazine;\n        }\n\n        $actor = $this->apHttpClient->getActorObject($actorUrl);\n        // Check if actor isn't empty (not set/null/empty array/etc.) and check if actor type is set\n        if (!empty($actor) && isset($actor['type'])) {\n            // User (we don't make a distinction between bots with type Service as Lemmy does)\n            if (\\in_array($actor['type'], User::USER_TYPES)) {\n                $this->logger->debug('[ActivityPubManager::findActorOrCreate] Found remote user at \"{url}\"', ['url' => $actorUrl]);\n\n                return $this->createUser($actorUrl);\n            }\n\n            // Magazine (Group)\n            if ('Group' === $actor['type']) {\n                $this->logger->debug('[ActivityPubManager::findActorOrCreate] Found remote magazine at \"{url}\"', ['url' => $actorUrl]);\n\n                return $this->createMagazine($actorUrl);\n            }\n\n            if ('Tombstone' === $actor['type']) {\n                // deleted actor\n                if (null !== ($magazine = $this->magazineRepository->findOneBy(['apProfileId' => $actorUrl])) && null !== $magazine->apId) {\n                    $this->magazineManager->purge($magazine);\n                    $this->logger->warning('[ActivityPubManager::findActorOrCreate] Got a tombstone for magazine {name} at {url}, deleting it', ['name' => $magazine->name, 'url' => $actorUrl]);\n                } elseif (null !== ($user = $this->userRepository->findOneBy(['apProfileId' => $actorUrl])) && null !== $user->apId) {\n                    $this->bus->dispatch(new DeleteUserMessage($user->getId()));\n                    $this->logger->warning('[ActivityPubManager::findActorOrCreate] Got a tombstone for user {name} at {url}, deleting it', ['name' => $user->username, 'url' => $actorUrl]);\n                }\n            }\n        } else {\n            $this->logger->debug(\"[ActivityPubManager::findActorOrCreate] Actor not found, actorUrl: $actorUrl\");\n        }\n\n        return null;\n    }\n\n    public function dispatchUpdateActor(string $actorUrl)\n    {\n        if ($this->settingsManager->isBannedInstance($actorUrl)) {\n            return;\n        }\n        $limiter = $this->apUpdateActorLimiter\n            ->create($actorUrl)\n            ->consume(1);\n\n        if ($limiter->isAccepted()) {\n            $this->bus->dispatch(new UpdateActorMessage($actorUrl));\n        } else {\n            $this->logger->debug(\n                '[ActivityPubManager::dispatchUpdateActor] Not dispatching updating actor for {actor}: one has been dispatched recently',\n                ['actor' => $actorUrl, 'retry' => $limiter->getRetryAfter()]\n            );\n        }\n    }\n\n    /**\n     * Try to find an existing actor or create a new one if the actor doesn't yet exists.\n     *\n     * @param ?string $actorUrlOrHandle actor URL or handle (could even be null)\n     *\n     * @throws \\LogicException when the returned actor is not a user or is null\n     */\n    public function findUserActorOrCreateOrThrow(?string $actorUrlOrHandle): User|Magazine\n    {\n        $object = $this->findActorOrCreate($actorUrlOrHandle);\n        if (!$object) {\n            throw new \\LogicException(\"Could not find actor for 'object' property at: '$actorUrlOrHandle'\");\n        } elseif (!$object instanceof User) {\n            throw new \\LogicException(\"Could not find user actor for 'object' property at: '$actorUrlOrHandle'\");\n        }\n\n        return $object;\n    }\n\n    public function webfinger(string $id): WebFinger\n    {\n        $this->logger->debug('[ActivityPubManager::webfinger] Fetching webfinger \"{id}\"', ['id' => $id]);\n\n        if (false === filter_var($id, FILTER_VALIDATE_URL)) {\n            $id = ltrim($id, '@');\n\n            return $this->webFingerFactory->get($id);\n        }\n\n        $handle = $this->buildHandle($id);\n\n        return $this->webFingerFactory->get($handle);\n    }\n\n    private function buildHandle(string $id): string\n    {\n        $port = !\\is_null(parse_url($id, PHP_URL_PORT))\n            ? ':'.parse_url($id, PHP_URL_PORT)\n            : '';\n        $apObj = $this->apHttpClient->getActorObject($id);\n        if (!isset($apObj['preferredUsername'])) {\n            throw new \\InvalidArgumentException(\"webfinger from $id does not supply a valid user object\");\n        }\n\n        return \\sprintf(\n            '%s@%s%s',\n            $apObj['preferredUsername'],\n            parse_url($id, PHP_URL_HOST),\n            $port\n        );\n    }\n\n    /**\n     * Creates a new user.\n     *\n     * @param string $actorUrl actor URL\n     *\n     * @return ?User or null on error\n     *\n     * @throws InstanceBannedException\n     */\n    private function createUser(string $actorUrl): ?User\n    {\n        if ($this->settingsManager->isBannedInstance($actorUrl)) {\n            throw new InstanceBannedException();\n        }\n        $webfinger = $this->webfinger($actorUrl);\n        $dto = $this->userFactory->createDtoFromAp($actorUrl, $webfinger->getHandle());\n        $this->userManager->create(\n            $dto,\n            false,\n            false,\n            preApprove: true,\n        );\n\n        if (method_exists($this->cache, 'invalidateTags')) {\n            // clear markdown renders that are tagged with the handle of the user\n            $tag = UrlUtils::getCacheKeyForMarkdownUserMention($dto->apId);\n            $this->cache->invalidateTags([$tag]);\n            $this->logger->debug('cleared cached items with tag {t}', ['t' => $tag]);\n        }\n\n        return $this->updateUser($actorUrl);\n    }\n\n    /**\n     * Update existing user and return user object.\n     *\n     * @param string $actorUrl actor URL\n     *\n     * @return ?User or null on error (e.g. actor not found)\n     */\n    private function updateUser(string $actorUrl): ?User\n    {\n        $this->logger->info('[ActivityPubManager::updateUser] Updating user {name}', ['name' => $actorUrl]);\n        $user = $this->userRepository->findOneBy(['apProfileId' => $actorUrl]);\n\n        if ($user->isDeleted || $user->isTrashed() || $user->isSoftDeleted()) {\n            return $user;\n        }\n\n        $actor = $this->apHttpClient->getActorObject($actorUrl);\n        if (!$actor || !\\is_array($actor)) {\n            return null;\n        }\n\n        if (isset($actor['type']) && 'Tombstone' === $actor['type'] && $user instanceof User) {\n            $this->bus->dispatch(new DeleteUserMessage($user->getId()));\n\n            return null;\n        }\n\n        // Check if actor isn't empty (not set/null/empty array/etc.)\n        if (isset($actor['endpoints']['sharedInbox']) || isset($actor['inbox'])) {\n            // Update the following user columns\n            $user->type = $actor['type'] ?? 'Person';\n            $user->apInboxUrl = $actor['endpoints']['sharedInbox'] ?? $actor['inbox'];\n            $user->apDomain = parse_url($actor['id'], PHP_URL_HOST);\n            if ($actor['preferredUsername']) {\n                $newUsername = '@'.$actor['preferredUsername'].'@'.$user->apDomain;\n                if ($user->username !== $newUsername) {\n                    $this->logger->info('The handle of \"{u}\" ({url}) changed to \"{u2}\" for id {id}', ['u' => $user->username, 'url' => $user->apProfileId, 'u2' => $newUsername, 'id' => $user->getId()]);\n                    $user->username = $newUsername;\n                }\n            }\n            $user->apFollowersUrl = $actor['followers'] ?? null;\n            $user->apAttributedToUrl = $actor['attributedTo'] ?? null;\n            $user->apPreferredUsername = $actor['preferredUsername'] ?? null;\n            $user->title = $actor['name'] ?? null;\n            $user->apDiscoverable = $actor['discoverable'] ?? null;\n            $user->apIndexable = $actor['indexable'] ?? null;\n            $user->apManuallyApprovesFollowers = $actor['manuallyApprovesFollowers'] ?? false;\n            $actorUrlValue = $actor['url'] ?? $actorUrl;\n            if (\\is_array($actorUrlValue)) {\n                // Pick the link with the fewest path segments as the most canonical profile URL.\n                // Fall back to $actorUrl if no valid href is found.\n                $best = null;\n                $bestCount = PHP_INT_MAX;\n                foreach ($actorUrlValue as $link) {\n                    $href = \\is_array($link) ? ($link['href'] ?? null) : (string) $link;\n                    if (null === $href) {\n                        continue;\n                    }\n                    $pathSegments = \\count(array_filter(explode('/', parse_url($href, PHP_URL_PATH) ?? '')));\n                    if ($pathSegments < $bestCount) {\n                        $bestCount = $pathSegments;\n                        $best = $href;\n                    }\n                }\n                $actorUrlValue = $best ?? $actorUrl;\n            }\n            $user->apPublicUrl = \\is_string($actorUrlValue) ? $actorUrlValue : $actorUrl;\n            $user->apDeletedAt = null;\n            $user->apTimeoutAt = null;\n            $user->apFetchedAt = new \\DateTime();\n\n            if (isset($actor['published'])) {\n                try {\n                    $createdAt = new \\DateTimeImmutable($actor['published']);\n                    $now = new \\DateTimeImmutable();\n                    if ($createdAt < $now) {\n                        $user->createdAt = $createdAt;\n                    }\n                } catch (\\Exception) {\n                }\n            }\n\n            // Only update about when summary is set\n            if (isset($actor['summary'])) {\n                $converter = new HtmlConverter(['strip_tags' => true]);\n                $user->about = stripslashes($converter->convert($actor['summary']));\n            }\n\n            // Only update avatar if icon is set\n            if (isset($actor['icon'])) {\n                // we only have to wrap the property in an array if it is not already an array, though that is not that easy to determine\n                // because each json object is an associative array -> each image has to have a 'type' property so use that to check it\n                $icon = !\\array_key_exists('type', $actor['icon']) ? $actor['icon'] : [$actor['icon']];\n                $newImage = $this->handleImages($icon);\n                if ($user->avatar && $newImage !== $user->avatar) {\n                    $this->bus->dispatch(new DeleteImageMessage($user->avatar->getId()));\n                }\n                $user->avatar = $newImage;\n            } elseif (null !== $user->avatar) {\n                $this->bus->dispatch(new DeleteImageMessage($user->avatar->getId()));\n                $user->avatar = null;\n            }\n\n            // Only update cover if image is set\n            if (isset($actor['image'])) {\n                // we only have to wrap the property in an array if it is not already an array, though that is not that easy to determine\n                // because each json object is an associative array -> each image has to have a 'type' property so use that to check it\n                $cover = !\\array_key_exists('type', $actor['image']) ? $actor['image'] : [$actor['image']];\n                $newImage = $this->handleImages($cover);\n                if ($user->cover && $newImage !== $user->cover) {\n                    $this->bus->dispatch(new DeleteImageMessage($user->cover->getId()));\n                }\n                $user->cover = $newImage;\n            } elseif (null !== $user->cover) {\n                $this->bus->dispatch(new DeleteImageMessage($user->cover->getId()));\n                $user->cover = null;\n            }\n\n            if (isset($actor['publicKey']['publicKeyPem']) && $user->publicKey !== $actor['publicKey']['publicKeyPem']) {\n                if (null !== $user->publicKey) {\n                    // only log the message if there already was a public key. When initially created the actors do not get one\n                    $this->logger->info('The public key of user \"{u}\" has changed', ['u' => $user->username]);\n                    $user->lastKeyRotationDate = new \\DateTime();\n                }\n                $user->oldPublicKey = $user->publicKey;\n                $user->publicKey = $actor['publicKey']['publicKeyPem'];\n            }\n\n            if (null !== $user->apFollowersUrl) {\n                try {\n                    $followersObj = $this->apHttpClient->getCollectionObject($user->apFollowersUrl);\n                    if (isset($followersObj['totalItems']) and \\is_int($followersObj['totalItems'])) {\n                        $user->apFollowersCount = $followersObj['totalItems'];\n                        $user->updateFollowCounts();\n                    }\n                } catch (InvalidApPostException|InvalidArgumentException $ignored) {\n                }\n            }\n\n            if (null !== $user->apId) {\n                $instance = $this->instanceRepository->findOneBy(['domain' => $user->apDomain]);\n                if (null === $instance) {\n                    $instance = new Instance($user->apDomain);\n                }\n                $this->remoteInstanceManager->updateInstance($instance);\n            }\n\n            // Write to DB\n            $this->entityManager->flush();\n\n            return $user;\n        } else {\n            $this->logger->debug(\"[ActivityPubManager::updateUser] Actor not found, actorUrl: $actorUrl\");\n        }\n\n        return null;\n    }\n\n    public function handleImages(array $attachment): ?Image\n    {\n        $images = array_filter(\n            $attachment,\n            fn ($val) => $this->isImageAttachment($val)\n        ); // @todo multiple images\n\n        if (\\count($images)) {\n            try {\n                $imageObject = $images[array_key_first($images)];\n                if (isset($imageObject['height'])) {\n                    // determine the highest resolution image\n                    foreach ($images as $i) {\n                        if (isset($i['height']) && $i['height'] ?? 0 > $imageObject['height'] ?? 0) {\n                            $imageObject = $i;\n                        }\n                    }\n                }\n                if ($tempFile = $this->imageManager->download($imageObject['url'])) {\n                    $image = $this->imageRepository->findOrCreateFromPath($tempFile);\n                    $image->sourceUrl = $imageObject['url'];\n                    if ($image && isset($imageObject['name'])) {\n                        $image->altText = $imageObject['name'];\n                    }\n                    $this->entityManager->persist($image);\n                    $this->entityManager->flush();\n                }\n            } catch (\\Exception $e) {\n                return null;\n            }\n\n            return $image ?? null;\n        }\n\n        return null;\n    }\n\n    public static function extractUrlFromAttachment(mixed $attachment): ?string\n    {\n        $url = null;\n        if (\\is_array($attachment)) {\n            $link = array_filter(\n                $attachment,\n                fn ($val) => 'Link' === $val['type']\n            );\n\n            $firstArrayKey = array_key_first($link);\n            if (!empty($link[$firstArrayKey]) && isset($link[$firstArrayKey]['href']) && \\is_string($link[$firstArrayKey]['href'])) {\n                $url = $link[$firstArrayKey]['href'];\n            } elseif (isset($link['href']) && \\is_string($link['href'])) {\n                $url = $link['href'];\n            }\n        }\n\n        return $url;\n    }\n\n    /**\n     * Creates a new magazine (Group).\n     *\n     * @param string $actorUrl actor URL\n     *\n     * @return ?Magazine or null on error\n     *\n     * @throws InstanceBannedException\n     */\n    private function createMagazine(string $actorUrl): ?Magazine\n    {\n        if ($this->settingsManager->isBannedInstance($actorUrl)) {\n            throw new InstanceBannedException();\n        }\n        $dto = $this->magazineFactory->createDtoFromAp($actorUrl, $this->buildHandle($actorUrl));\n        $this->magazineManager->create(\n            $dto,\n            null,\n            false\n        );\n\n        try {\n            if (method_exists($this->cache, 'invalidateTags')) {\n                // clear markdown renders that are tagged with the handle of the magazine\n                $tag = UrlUtils::getCacheKeyForMarkdownMagazineMention($dto->apId);\n                $this->cache->invalidateTags([$tag]);\n                $this->logger->debug('cleared cached items with tag {t}', ['t' => $tag]);\n            }\n        } catch (CacheException $ex) {\n            $this->logger->error('An error occurred during cache clearing: {e} - {m}', ['e' => \\get_class($ex), 'm' => $ex->getMessage()]);\n        }\n\n        return $this->updateMagazine($actorUrl);\n    }\n\n    /**\n     * Update an existing magazine.\n     *\n     * @param string $actorUrl actor URL\n     *\n     * @return ?Magazine or null on error\n     */\n    private function updateMagazine(string $actorUrl): ?Magazine\n    {\n        $this->logger->info('[ActivityPubManager::updateMagazine] Updating magazine \"{magName}\"', ['magName' => $actorUrl]);\n        $magazine = $this->magazineRepository->findOneBy(['apProfileId' => $actorUrl]);\n\n        if ($magazine->isTrashed() || $magazine->isSoftDeleted()) {\n            return $magazine;\n        }\n\n        $actor = $this->apHttpClient->getActorObject($actorUrl);\n        // Check if actor isn't empty (not set/null/empty array/etc.)\n\n        if ($actor && 'Tombstone' === $actor['type'] && $magazine instanceof Magazine && null !== $magazine->apId) {\n            // tombstone for remote magazine -> delete it\n            $this->magazineManager->purge($magazine);\n\n            return null;\n        }\n\n        if (isset($actor['endpoints']['sharedInbox']) || isset($actor['inbox'])) {\n            if (isset($actor['summary'])) {\n                $magazine->description = $this->extractMarkdownSummary($actor);\n            }\n\n            if (isset($actor['icon'])) {\n                // we only have to wrap the property in an array if it is not already an array, though that is not that easy to determine\n                // because each json object is an associative array -> each image has to have a 'type' property so use that to check it\n                $icon = !\\array_key_exists('type', $actor['icon']) ? $actor['icon'] : [$actor['icon']];\n                $newImage = $this->handleImages($icon);\n                if ($magazine->icon && $newImage !== $magazine->icon) {\n                    $this->bus->dispatch(new DeleteImageMessage($magazine->icon->getId()));\n                }\n                $magazine->icon = $newImage;\n            } elseif (null !== $magazine->icon) {\n                $this->bus->dispatch(new DeleteImageMessage($magazine->icon->getId()));\n                $magazine->icon = null;\n            }\n\n            if (isset($actor['image'])) {\n                $banner = !\\array_key_exists('type', $actor['image']) ? $actor['image'] : [$actor['image']];\n                $newImage = $this->handleImages($banner);\n                if ($magazine->banner && $newImage !== $magazine->banner) {\n                    $this->bus->dispatch(new DeleteImageMessage($magazine->banner->getId()));\n                }\n                $magazine->banner = $newImage;\n            } elseif (null !== $magazine->banner) {\n                $this->bus->dispatch(new DeleteImageMessage($magazine->banner->getId()));\n                $magazine->banner = null;\n            }\n\n            if ($actor['name']) {\n                $magazine->title = $actor['name'];\n            } elseif ($actor['preferredUsername']) {\n                $magazine->title = $actor['preferredUsername'];\n            }\n\n            if (isset($actor['published'])) {\n                try {\n                    $createdAt = new \\DateTimeImmutable($actor['published']);\n                    $now = new \\DateTimeImmutable();\n                    if ($createdAt < $now) {\n                        $magazine->createdAt = $createdAt;\n                    }\n                } catch (\\Exception) {\n                }\n            }\n\n            $magazine->apInboxUrl = $actor['endpoints']['sharedInbox'] ?? $actor['inbox'];\n            $magazine->apDomain = parse_url($actor['id'], PHP_URL_HOST);\n            $magazine->apFollowersUrl = $actor['followers'] ?? null;\n            $magazine->apAttributedToUrl = isset($actor['attributedTo']) && \\is_string($actor['attributedTo']) ? $actor['attributedTo'] : null;\n            $magazine->apFeaturedUrl = $actor['featured'] ?? null;\n            $magazine->apPreferredUsername = $actor['preferredUsername'] ?? null;\n            $magazine->apDiscoverable = $actor['discoverable'] ?? null;\n            $magazine->apPublicUrl = $actor['url'] ?? $actorUrl;\n            $magazine->apDeletedAt = null;\n            $magazine->apTimeoutAt = null;\n            $magazine->apFetchedAt = new \\DateTime();\n            $magazine->isAdult = $actor['sensitive'] ?? false;\n            $magazine->postingRestrictedToMods = filter_var($actor['postingRestrictedToMods'] ?? false, FILTER_VALIDATE_BOOLEAN) ?? false;\n            $magazine->apIndexable = $actor['indexable'] ?? null;\n\n            if (null !== $magazine->apFollowersUrl) {\n                try {\n                    $this->logger->debug('[ActivityPubManager::updateMagazine] Updating remote followers of magazine \"{magUrl}\"', ['magUrl' => $actorUrl]);\n                    $followersObj = $this->apHttpClient->getCollectionObject($magazine->apFollowersUrl);\n                    if (isset($followersObj['totalItems']) and \\is_int($followersObj['totalItems'])) {\n                        $magazine->apFollowersCount = $followersObj['totalItems'];\n                        $magazine->updateSubscriptionsCount();\n                    }\n                } catch (InvalidApPostException|InvalidArgumentException $ignored) {\n                }\n            }\n\n            if (null !== $magazine->apAttributedToUrl) {\n                try {\n                    $this->handleModeratorCollection($actorUrl, $magazine);\n                } catch (InvalidArgumentException $ignored) {\n                }\n            } elseif (isset($actor['attributedTo']) && \\is_array($actor['attributedTo'])) {\n                $this->handleModeratorArray($magazine, $this->getActorFromAttributedTo($actor['attributedTo']));\n            }\n\n            if (null !== $magazine->apFeaturedUrl) {\n                try {\n                    $this->handleMagazineFeaturedCollection($actorUrl, $magazine);\n                } catch (InvalidArgumentException $ignored) {\n                }\n            }\n\n            if (isset($actor['publicKey']['publicKeyPem']) && $magazine->publicKey !== $actor['publicKey']['publicKeyPem']) {\n                if (null !== $magazine->publicKey) {\n                    // only log the message if there already was a public key. When initially created the actors do not get one\n                    $this->logger->info('The public key of magazine \"{m}\" has changed', ['m' => $magazine->name]);\n                    $magazine->lastKeyRotationDate = new \\DateTime();\n                }\n                $magazine->oldPublicKey = $magazine->publicKey;\n                $magazine->publicKey = $actor['publicKey']['publicKeyPem'];\n            }\n\n            if (null !== $magazine->apId) {\n                $instance = $this->instanceRepository->findOneBy(['domain' => $magazine->apDomain]);\n                if (null === $instance) {\n                    $instance = new Instance($magazine->apDomain);\n                }\n                $this->remoteInstanceManager->updateInstance($instance);\n            }\n            $this->entityManager->flush();\n\n            return $magazine;\n        } else {\n            $this->logger->debug(\"[ActivityPubManager::updateMagazine] Actor not found, actorUrl: $actorUrl\");\n        }\n\n        return null;\n    }\n\n    /**\n     * @throws InvalidArgumentException\n     */\n    private function handleModeratorCollection(string $actorUrl, Magazine $magazine): void\n    {\n        try {\n            $this->logger->debug('[ActivityPubManager::handleModeratorCollection] Fetching moderators of remote magazine: \"{magUrl}\"', ['magUrl' => $actorUrl]);\n            $attributedObj = $this->apHttpClient->getCollectionObject($magazine->apAttributedToUrl);\n            $items = null;\n            if (isset($attributedObj['items']) and \\is_array($attributedObj['items'])) {\n                $items = $attributedObj['items'];\n            } elseif (isset($attributedObj['orderedItems']) and \\is_array($attributedObj['orderedItems'])) {\n                $items = $attributedObj['orderedItems'];\n            }\n\n            $this->logger->debug('[ActivityPubManager::handleModeratorCollection] Got moderator items for magazine: \"{magName}\": {json}', ['magName' => $magazine->name, 'json' => json_encode($attributedObj)]);\n\n            if (null !== $items) {\n                $this->handleModeratorArray($magazine, $items);\n            } else {\n                $this->logger->warning('[ActivityPubManager::handleModeratorCollection] Could not update the moderators of \"{url}\", the response doesn\\'t have a \"items\" or \"orderedItems\" property or it is not an array', ['url' => $actorUrl]);\n            }\n        } catch (InvalidApPostException $ignored) {\n        }\n    }\n\n    private function handleModeratorArray(Magazine $magazine, array $items): void\n    {\n        $moderatorsToRemove = [];\n        /** @var Moderator $mod */\n        foreach ($magazine->moderators as $mod) {\n            $moderatorsToRemove[] = $mod->user;\n        }\n        $indexesNotToRemove = [];\n\n        foreach ($items as $item) {\n            if (\\is_string($item)) {\n                try {\n                    $user = $this->findActorOrCreate($item);\n                    if ($user instanceof User) {\n                        foreach ($moderatorsToRemove as $key => $existMod) {\n                            if ($existMod->username === $user->username) {\n                                $indexesNotToRemove[] = $key;\n                                break;\n                            }\n                        }\n                        if (!$magazine->userIsModerator($user)) {\n                            $this->logger->info('[ActivityPubManager::handleModeratorArray] Adding \"{user}\" as moderator in \"{magName}\" because they are a mod upstream, but not locally', ['user' => $user->username, 'magName' => $magazine->name]);\n                            $this->magazineManager->addModerator(new ModeratorDto($magazine, $user, null));\n                        }\n                    }\n                } catch (\\Exception) {\n                    $this->logger->warning('[ActivityPubManager::handleModeratorArray] Something went wrong while fetching actor \"{actor}\" as moderator of \"{magName}\"', ['actor' => $item, 'magName' => $magazine->name]);\n                }\n            }\n        }\n\n        foreach ($indexesNotToRemove as $i) {\n            $moderatorsToRemove[$i] = null;\n        }\n\n        foreach ($moderatorsToRemove as $modToRemove) {\n            if (null === $modToRemove) {\n                continue;\n            }\n            $criteria = Criteria::create()->where(Criteria::expr()->eq('magazine', $magazine));\n            $modObject = $modToRemove->moderatorTokens->matching($criteria)->first();\n            $this->logger->info('[ActivityPubManager::handleModeratorArray] Removing \"{exMod}\" from \"{magName}\" as mod locally because they are no longer mod upstream', ['exMod' => $modToRemove->username, 'magName' => $magazine->name]);\n            $this->magazineManager->removeModerator($modObject, null);\n        }\n    }\n\n    /**\n     * @throws InvalidArgumentException\n     */\n    private function handleMagazineFeaturedCollection(string $actorUrl, Magazine $magazine): void\n    {\n        try {\n            $this->logger->debug('[ActivityPubManager::handleMagazineFeaturedCollection] Fetching featured posts of remote magazine: {url}', ['url' => $actorUrl]);\n            $attributedObj = $this->apHttpClient->getCollectionObject($magazine->apFeaturedUrl);\n            $items = null;\n            if (isset($attributedObj['items']) and \\is_array($attributedObj['items'])) {\n                $items = $attributedObj['items'];\n            } elseif (isset($attributedObj['orderedItems']) and \\is_array($attributedObj['orderedItems'])) {\n                $items = $attributedObj['orderedItems'];\n            }\n\n            $this->logger->debug('[ActivityPubManager::handleMagazineFeaturedCollection] Got featured items for magazine: \"{magName}\": {json}', ['magName' => $magazine->name, 'json' => json_encode($attributedObj)]);\n\n            if (null !== $items) {\n                $pinnedToRemove = $this->entryRepository->findPinned($magazine);\n                $indexesNotToRemove = [];\n                $idsToPin = [];\n                foreach ($items as $item) {\n                    $apId = null;\n                    $isString = false;\n                    if (\\is_string($item)) {\n                        $apId = $item;\n                        $isString = true;\n                    } elseif (\\is_array($item)) {\n                        $apId = $item['id'];\n                    } else {\n                        $this->logger->debug('[ActivityPubManager::handleMagazineFeaturedCollection] Ignoring {item}, because it is not a string and not an array', ['item' => json_encode($item)]);\n                        continue;\n                    }\n\n                    $entry = null;\n\n                    $alreadyPinned = false;\n                    if ($this->settingsManager->isLocalUrl($apId)) {\n                        $pair = $this->activityRepository->findLocalByApId($apId);\n                        if (Entry::class === $pair['type']) {\n                            foreach ($pinnedToRemove as $i => $entry) {\n                                if ($entry->getId() === $pair['id']) {\n                                    $indexesNotToRemove[] = $i;\n                                    $alreadyPinned = true;\n                                }\n                            }\n                        }\n                    } else {\n                        foreach ($pinnedToRemove as $i => $entry) {\n                            if ($entry->apId === $apId) {\n                                $indexesNotToRemove[] = $i;\n                                $alreadyPinned = true;\n                            }\n                        }\n\n                        if (!$alreadyPinned) {\n                            $existingEntry = $this->entryRepository->findOneBy(['apId' => $apId]);\n                            if ($existingEntry) {\n                                $this->logger->debug('[ActivityPubManager::handleMagazineFeaturedCollection] Pinning existing entry: {title}', ['title' => $existingEntry->title]);\n                                $this->entryManager->pin($existingEntry, null);\n                            } else {\n                                if (!$this->settingsManager->isBannedInstance($apId)) {\n                                    $object = $item;\n                                    if ($isString) {\n                                        $this->logger->debug('[ActivityPubManager::handleMagazineFeaturedCollection] Getting {url} because we dont have it', ['url' => $apId]);\n                                        $object = $this->apHttpClient->getActivityObject($apId);\n                                    }\n                                    $this->logger->debug('[ActivityPubManager::handleMagazineFeaturedCollection] Dispatching create message for entry: {e}', ['e' => json_encode($object)]);\n                                    $this->bus->dispatch(new CreateMessage($object, true));\n                                } else {\n                                    $this->logger->info('[ActivityPubManager::handleMagazineFeaturedCollection] The instance is banned, url: {url}', ['url' => $apId]);\n                                }\n                            }\n                        }\n                    }\n                }\n\n                foreach ($indexesNotToRemove as $i) {\n                    $pinnedToRemove[$i] = null;\n                }\n\n                foreach (array_filter($pinnedToRemove) as $pinnedEntry) {\n                    // the pin method also unpins if the entry is already pinned\n                    $this->logger->debug('[ActivityPubManager::handleMagazineFeaturedCollection] Unpinning entry: \"{title}\"', ['title' => $pinnedEntry->title]);\n                    $this->entryManager->pin($pinnedEntry, null);\n                }\n            }\n        } catch (InvalidApPostException $ignored) {\n        }\n    }\n\n    public function createInboxesFromCC(array $activity, User $user): array\n    {\n        $followersUrl = $this->urlGenerator->generate(\n            'ap_user_followers',\n            ['username' => $user->username],\n            UrlGeneratorInterface::ABSOLUTE_URL\n        );\n\n        $arr = array_unique(\n            array_filter(\n                array_merge(\n                    \\App\\Utils\\JsonldUtils::getArrayValue($activity, 'cc'),\n                    \\App\\Utils\\JsonldUtils::getArrayValue($activity, 'to'),\n                ),\n                fn ($val) => !\\in_array($val, [ActivityPubActivityInterface::PUBLIC_URL, $followersUrl, []])\n            )\n        );\n\n        $users = [];\n        foreach ($arr as $url) {\n            if ($user = $this->findActorOrCreate($url)) {\n                $users[] = $user;\n            }\n        }\n\n        return array_map(fn ($user) => $user->apInboxUrl, $users);\n    }\n\n    public function handleVideos(array $attachment): ?VideoDto\n    {\n        $videos = array_filter(\n            $attachment,\n            fn ($val) => \\in_array($val['type'], ['Document', 'Video']) && VideoManager::isVideoUrl($val['url'])\n        );\n\n        if (\\count($videos)) {\n            return (new VideoDto())->create(\n                $videos[0]['url'],\n                $videos[0]['mediaType'],\n                !empty($videos['0']['name']) ? $videos['0']['name'] : $videos['0']['mediaType']\n            );\n        }\n\n        return null;\n    }\n\n    public function handleExternalImages(array $attachment): ?array\n    {\n        $images = array_filter(\n            $attachment,\n            fn ($val) => $this->isImageAttachment($val)\n        );\n\n        array_shift($images);\n\n        if (\\count($images)) {\n            return array_map(fn ($val) => (new ImageDto())->create(\n                $val['url'],\n                $val['mediaType'],\n                !empty($val['name']) ? $val['name'] : $val['mediaType']\n            ), $images);\n        }\n\n        return null;\n    }\n\n    public function handleExternalVideos(array $attachment): ?array\n    {\n        $videos = array_filter(\n            $attachment,\n            fn ($val) => \\in_array($val['type'], ['Document', 'Video']) && VideoManager::isVideoUrl($val['url'])\n        );\n\n        if (\\count($videos)) {\n            return array_map(fn ($val) => (new VideoDto())->create(\n                $val['url'],\n                $val['mediaType'],\n                !empty($val['name']) ? $val['name'] : $val['mediaType']\n            ), $videos);\n        }\n\n        return null;\n    }\n\n    /**\n     * Update existing actor.\n     *\n     * @param string $actorUrl actor URL\n     *\n     * @return Magazine|User|null null on error\n     */\n    public function updateActor(string $actorUrl): Magazine|User|null\n    {\n        if ($this->settingsManager->isBannedInstance($actorUrl)) {\n            return null;\n        }\n\n        if ($this->userRepository->findOneBy(['apProfileId' => $actorUrl])) {\n            return $this->updateUser($actorUrl);\n        } elseif ($this->magazineRepository->findOneBy(['apProfileId' => $actorUrl])) {\n            return $this->updateMagazine($actorUrl);\n        }\n\n        return null;\n    }\n\n    public function findOrCreateMagazineByToCCAndAudience(array $object): ?Magazine\n    {\n        $potentialGroups = self::getReceivers($object);\n        $magazine = $this->magazineRepository->findByApGroupProfileId($potentialGroups);\n        if ($magazine and $magazine->apId && !$magazine->isTrashed() && !$magazine->isSoftDeleted() && (!$magazine->apFetchedAt || $magazine->apFetchedAt->modify('+1 Day') < (new \\DateTime()))) {\n            $this->dispatchUpdateActor($magazine->apPublicUrl);\n        }\n\n        if (null === $magazine) {\n            foreach ($potentialGroups as $potentialGroup) {\n                $result = $this->findActorOrCreate($potentialGroup);\n                if ($result instanceof Magazine) {\n                    $magazine = $result;\n                    break;\n                }\n            }\n        }\n\n        if (null === $magazine) {\n            $magazine = $this->magazineRepository->findOneByName('random');\n        }\n\n        return $magazine;\n    }\n\n    public static function getReceivers(array $object): array\n    {\n        $res = array_merge(\n            \\App\\Utils\\JsonldUtils::getArrayValue($object, 'audience'),\n            \\App\\Utils\\JsonldUtils::getArrayValue($object, 'to'),\n            \\App\\Utils\\JsonldUtils::getArrayValue($object, 'cc'),\n        );\n\n        if (isset($object['object']) and \\is_array($object['object'])) {\n            $res = array_merge(\n                $res,\n                \\App\\Utils\\JsonldUtils::getArrayValue($object['object'], 'audience'),\n                \\App\\Utils\\JsonldUtils::getArrayValue($object['object'], 'to'),\n                \\App\\Utils\\JsonldUtils::getArrayValue($object['object'], 'cc'),\n            );\n        } elseif (isset($object['attributedTo']) && \\is_array($object['attributedTo'])) {\n            // if there is no \"object\" inside of this it will probably be a create activity which has an attributedTo field\n            // this was implemented for peertube support, because they list the channel (Group) and the user in an array in that field\n            $groups = array_filter($object['attributedTo'], fn ($item) => \\is_array($item) && !empty($item['type']) && 'Group' === $item['type']);\n            $res = array_merge($res, array_map(fn ($item) => $item['id'], $groups));\n        }\n\n        $res = array_filter($res, fn ($i) => null !== $i and ActivityPubActivityInterface::PUBLIC_URL !== $i);\n\n        return array_unique($res);\n    }\n\n    private function isImageAttachment(array $object): bool\n    {\n        // attachment object has acceptable object type\n        if (!\\in_array($object['type'], ['Document', 'Image'])) {\n            return false;\n        }\n\n        // attachment is either:\n        // - has `mediaType` field and is a recognized image types\n        // - image url looks like a link to image\n        return (!empty($object['mediaType']) && ImageManager::isImageType($object['mediaType']))\n            || ImageManager::isImageUrl($object['url']);\n    }\n\n    /**\n     * @param string|array                                       $apObject      the object that should be like, so a post of any kind in its AP array representation or a URL\n     * @param array                                              $fullPayload   the full message payload, only used to log it\n     * @param callable(array $object, ?string $adjustedUrl):void $chainDispatch if we do not have the object in our db this is called to dispatch a new ChainActivityMessage.\n     *                                                                          Since the explicit object has to be set in the message this has to be done as a callback method.\n     *                                                                          The object parameter is an associative array representing the first dependency of the activity.\n     *                                                                          The $adjustedUrl parameter is only set if the object was fetched from a different url than the id of the object might suggest\n     *\n     * @see ChainActivityMessage\n     */\n    public function getEntityObject(string|array $apObject, array $fullPayload, callable $chainDispatch): Entry|EntryComment|Post|PostComment|null\n    {\n        $object = null;\n        $activity = null;\n        $calledUrl = null;\n        if (\\is_string($apObject)) {\n            if (false === filter_var($apObject, FILTER_VALIDATE_URL)) {\n                $this->logger->error('[ActivityPubManager::getEntityObject] The like activity references an object by string, but that is not a URL, discarding the message', $fullPayload);\n\n                return null;\n            }\n            // First try to find the activity object in our database\n            $activity = $this->activityRepository->findByObjectId($apObject);\n            $calledUrl = $apObject;\n            if (!$activity) {\n                if (!$this->settingsManager->isBannedInstance($apObject)) {\n                    $this->logger->debug('[ActivityPubManager::getEntityObject] Object is fetched from {url} because it is a string and could not be found in our repo', ['url' => $apObject]);\n                    $object = $this->apHttpClient->getActivityObject($apObject);\n                } else {\n                    $this->logger->info('[ActivityPubManager::getEntityObject] The instance is banned, url: {url}', ['url' => $apObject]);\n\n                    return null;\n                }\n            }\n        } else {\n            $activity = $this->activityRepository->findByObjectId($apObject['id']);\n            $calledUrl = $apObject['id'];\n            if (!$activity) {\n                $this->logger->debug('[ActivityPubManager::getEntityObject] Object is fetched from {url} because it is not a string and could not be found in our repo', ['url' => $apObject['id']]);\n                $object = $apObject;\n            }\n        }\n\n        if (!$activity && !$object) {\n            $this->logger->error(\"[ActivityPubManager::getEntityObject] The activity is still null and we couldn't get the object from the url, discarding\", $fullPayload);\n\n            return null;\n        }\n\n        if ($object) {\n            $adjustedUrl = null;\n            if ($object['id'] !== $calledUrl) {\n                $this->logger->warning('[ActivityPubManager::getEntityObject] The url {url} returned a different object id: {id}', ['url' => $calledUrl, 'id' => $object['id']]);\n                $adjustedUrl = $object['id'];\n            }\n\n            $this->logger->debug('[ActivityPubManager::getEntityObject] Dispatching a ChainActivityMessage, because the object could not be found: {o}', ['o' => $apObject]);\n            $this->logger->debug('[ActivityPubManager::getEntityObject] The object for ChainActivityMessage with object {o}', ['o' => $object]);\n            $chainDispatch($object, $adjustedUrl);\n\n            return null;\n        }\n\n        return $this->entityManager->getRepository($activity['type'])->find((int) $activity['id']);\n    }\n\n    public function extractMarkdownSummary(array $apObject): ?string\n    {\n        if (isset($apObject['source']) && isset($apObject['source']['mediaType']) && isset($apObject['source']['content']) && ApObjectExtractor::MARKDOWN_TYPE === $apObject['source']['mediaType']) {\n            return $apObject['source']['content'];\n        } else {\n            $converter = new HtmlConverter(['strip_tags' => true]);\n\n            return stripslashes($converter->convert($apObject['summary']));\n        }\n    }\n\n    public function extractMarkdownContent(array $apObject)\n    {\n        if (isset($apObject['source']) && isset($apObject['source']['mediaType']) && isset($apObject['source']['content']) && ApObjectExtractor::MARKDOWN_TYPE === $apObject['source']['mediaType']) {\n            return $apObject['source']['content'];\n        } else {\n            $converter = new HtmlConverter(['strip_tags' => true]);\n\n            return stripslashes($converter->convert($apObject['content']));\n        }\n    }\n\n    public function isActivityPublic(array $payload): bool\n    {\n        $to = array_merge(\n            \\App\\Utils\\JsonldUtils::getArrayValue($payload, 'to'),\n            \\App\\Utils\\JsonldUtils::getArrayValue($payload, 'cc'),\n        );\n\n        foreach ($to as $receiver) {\n            $id = null;\n            if (\\is_string($receiver)) {\n                $id = $receiver;\n            } elseif (\\is_array($receiver) && !empty($receiver['id'])) {\n                $id = $receiver['id'];\n            }\n\n            if (null !== $id) {\n                $actor = $this->findActorOrCreate($id);\n                if ($actor instanceof Magazine) {\n                    return true;\n                }\n            }\n        }\n\n        return false;\n    }\n\n    public function getSingleActorFromAttributedTo(string|array|null $attributedTo, bool $filterForPerson = true): ?string\n    {\n        $actors = $this->getActorFromAttributedTo($attributedTo, $filterForPerson);\n        if (\\sizeof($actors) > 0) {\n            return $actors[0];\n        }\n\n        return null;\n    }\n\n    /**\n     * @return string[]\n     */\n    public function getActorFromAttributedTo(string|array|null $attributedTo, bool $filterForPerson = true): array\n    {\n        if (\\is_string($attributedTo)) {\n            return [$attributedTo];\n        } elseif (\\is_array($attributedTo)) {\n            $actors = array_filter($attributedTo, fn ($item) => \\is_string($item) || (\\is_array($item) && !empty($item['type']) && (!$filterForPerson || 'Person' === $item['type'])));\n\n            return array_map(fn ($item) => $item['id'], $actors);\n        }\n\n        return [];\n    }\n\n    public function extractUrl(string|array|null $url): ?string\n    {\n        if (\\is_string($url)) {\n            return $url;\n        } elseif (\\is_array($url)) {\n            $urls = array_filter($url, fn ($item) => \\is_string($item) || (\\is_array($item) && !empty($item['type']) && 'Link' === $item['type'] && (empty($item['mediaType']) || 'text/html' === $item['mediaType'])));\n            if (\\sizeof($urls) >= 1) {\n                if (\\is_string($urls[0])) {\n                    return $urls[0];\n                } elseif (!empty($urls[0]['href'])) {\n                    return $urls[0]['href'];\n                }\n            }\n        }\n\n        return null;\n    }\n\n    public function extractTotalAmountFromCollection(mixed $collection): ?int\n    {\n        $id = null;\n        if (\\is_string($collection)) {\n            if (false !== filter_var($collection, FILTER_VALIDATE_URL)) {\n                $id = $collection;\n            }\n        } elseif (\\is_array($collection)) {\n            if (isset($collection['totalItems'])) {\n                return \\intval($collection['totalItems']);\n            } elseif (isset($collection['id'])) {\n                $id = $collection['id'];\n            }\n        }\n\n        if ($id) {\n            $this->apHttpClient->invalidateCollectionObjectCache($id);\n            $collection = $this->apHttpClient->getCollectionObject($id);\n            if (isset($collection['totalItems']) && \\is_int($collection['totalItems'])) {\n                return $collection['totalItems'];\n            }\n        }\n\n        return null;\n    }\n\n    public function extractRemoteLikeCount(array $apObject): ?int\n    {\n        if (!empty($apObject['likes'])) {\n            return $this->extractTotalAmountFromCollection($apObject['likes']);\n        }\n\n        return null;\n    }\n\n    public function extractRemoteDislikeCount(array $apObject): ?int\n    {\n        if (!empty($apObject['dislikes'])) {\n            return $this->extractTotalAmountFromCollection($apObject['dislikes']);\n        }\n\n        return null;\n    }\n\n    public function extractRemoteShareCount(array $apObject): ?int\n    {\n        if (!empty($apObject['shares'])) {\n            return $this->extractTotalAmountFromCollection($apObject['shares']);\n        }\n\n        return null;\n    }\n}\n"
  },
  {
    "path": "src/Service/BadgeManager.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service;\n\nuse App\\DTO\\BadgeDto;\nuse App\\Entity\\Badge;\nuse App\\Entity\\Entry;\nuse Doctrine\\Common\\Collections\\Collection;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Webmozart\\Assert\\Assert;\n\nclass BadgeManager\n{\n    public function __construct(private readonly EntityManagerInterface $entityManager)\n    {\n    }\n\n    public function create(BadgeDto $dto): Badge\n    {\n        $badge = new Badge($dto->magazine, $dto->name);\n\n        $this->entityManager->persist($badge);\n        $this->entityManager->flush();\n\n        return $badge;\n    }\n\n    public function edit(Badge $badge, BadgeDto $dto): Badge\n    {\n        Assert::same($badge->magazine->getId(), $badge->magazine->getId());\n\n        $badge->name = $dto->name;\n\n        $this->entityManager->persist($badge);\n        $this->entityManager->flush();\n\n        return $badge;\n    }\n\n    public function delete(Badge $badge): void\n    {\n        $this->purge($badge);\n    }\n\n    public function purge(Badge $badge): void\n    {\n        $this->entityManager->remove($badge);\n        $this->entityManager->flush();\n    }\n\n    public function assign(Entry $entry, Collection $badges): Entry\n    {\n        $badges = $entry->magazine->badges->filter(\n            static function (Badge $badge) use ($badges) {\n                return $badges->contains($badge->name);\n            }\n        );\n\n        $entry->setBadges(...$badges);\n\n        return $entry;\n    }\n}\n"
  },
  {
    "path": "src/Service/BookmarkManager.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service;\n\nuse App\\Entity\\Bookmark;\nuse App\\Entity\\BookmarkList;\nuse App\\Entity\\Entry;\nuse App\\Entity\\EntryComment;\nuse App\\Entity\\Post;\nuse App\\Entity\\PostComment;\nuse App\\Entity\\User;\nuse App\\Repository\\BookmarkListRepository;\nuse App\\Repository\\BookmarkRepository;\nuse Doctrine\\ORM\\EntityManagerInterface;\n\nclass BookmarkManager\n{\n    public function __construct(\n        private readonly EntityManagerInterface $entityManager,\n        private readonly BookmarkListRepository $bookmarkListRepository,\n        private readonly BookmarkRepository $bookmarkRepository,\n    ) {\n    }\n\n    public function createList(User $user, string $name): BookmarkList\n    {\n        $list = new BookmarkList($user, $name);\n        $this->entityManager->persist($list);\n        $this->entityManager->flush();\n\n        return $list;\n    }\n\n    public function isBookmarked(User $user, Entry|EntryComment|Post|PostComment $content): bool\n    {\n        if ($content instanceof Entry) {\n            return !empty($this->bookmarkRepository->findBy(['user' => $user, 'entry' => $content]));\n        } elseif ($content instanceof EntryComment) {\n            return !empty($this->bookmarkRepository->findBy(['user' => $user, 'entryComment' => $content]));\n        } elseif ($content instanceof Post) {\n            return !empty($this->bookmarkRepository->findBy(['user' => $user, 'post' => $content]));\n        } elseif ($content instanceof PostComment) {\n            return !empty($this->bookmarkRepository->findBy(['user' => $user, 'postComment' => $content]));\n        }\n\n        return false;\n    }\n\n    public function isBookmarkedInList(User $user, BookmarkList $list, Entry|EntryComment|Post|PostComment $content): bool\n    {\n        if ($content instanceof Entry) {\n            return null !== $this->bookmarkRepository->findOneBy(['user' => $user, 'list' => $list, 'entry' => $content]);\n        } elseif ($content instanceof EntryComment) {\n            return null !== $this->bookmarkRepository->findOneBy(['user' => $user, 'list' => $list, 'entryComment' => $content]);\n        } elseif ($content instanceof Post) {\n            return null !== $this->bookmarkRepository->findOneBy(['user' => $user, 'list' => $list, 'post' => $content]);\n        } elseif ($content instanceof PostComment) {\n            return null !== $this->bookmarkRepository->findOneBy(['user' => $user, 'list' => $list, 'postComment' => $content]);\n        }\n\n        return false;\n    }\n\n    public function addBookmarkToDefaultList(User $user, Entry|EntryComment|Post|PostComment $content): void\n    {\n        $list = $this->bookmarkListRepository->findOneByUserDefault($user);\n        $this->addBookmark($user, $list, $content);\n    }\n\n    public function addBookmark(User $user, BookmarkList $list, Entry|EntryComment|Post|PostComment $content): void\n    {\n        $bookmark = new Bookmark($user, $list);\n        $bookmark->setContent($content);\n        $this->entityManager->persist($bookmark);\n        $this->entityManager->flush();\n    }\n\n    public static function GetClassFromSubjectType(string $subjectType): string\n    {\n        return match ($subjectType) {\n            'entry' => Entry::class,\n            'entry_comment' => EntryComment::class,\n            'post' => Post::class,\n            'post_comment' => PostComment::class,\n            default => throw new \\LogicException(\"cannot match type $subjectType\"),\n        };\n    }\n}\n"
  },
  {
    "path": "src/Service/CacheService.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service;\n\nuse App\\Entity\\Contracts\\FavouriteInterface;\nuse App\\Entity\\Contracts\\VotableInterface;\nuse Doctrine\\ORM\\EntityManagerInterface;\n\nclass CacheService\n{\n    public function __construct(private readonly EntityManagerInterface $entityManager)\n    {\n    }\n\n    public function getVotersCacheKey(VotableInterface $subject): string\n    {\n        return \"voters_{$this->getKey($subject)}_{$subject->getId()}\";\n    }\n\n    private function getKey(VotableInterface|FavouriteInterface $subject): string\n    {\n        $className = $this->entityManager->getClassMetadata(\\get_class($subject))->name;\n        $className = explode('\\\\', $className);\n\n        return end($className);\n    }\n\n    public function getFavouritesCacheKey(FavouriteInterface $subject): string\n    {\n        return \"favourites_{$this->getKey($subject)}_{$subject->getId()}\";\n    }\n}\n"
  },
  {
    "path": "src/Service/ContactManager.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service;\n\nuse App\\DTO\\ContactDto;\nuse Symfony\\Bridge\\Twig\\Mime\\TemplatedEmail;\nuse Symfony\\Component\\HttpKernel\\Exception\\TooManyRequestsHttpException;\nuse Symfony\\Component\\Mailer\\MailerInterface;\nuse Symfony\\Component\\Mime\\Address;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Contracts\\Translation\\TranslatorInterface;\n\nclass ContactManager\n{\n    public function __construct(\n        private readonly SettingsManager $settings,\n        private readonly MailerInterface $mailer,\n        private readonly TranslatorInterface $translator,\n        private readonly RateLimiterFactoryInterface $contactLimiter,\n    ) {\n    }\n\n    public function send(ContactDto $dto)\n    {\n        $limiter = $this->contactLimiter->create($dto->ip);\n        if (false === $limiter->consume()->isAccepted()) {\n            throw new TooManyRequestsHttpException();\n        }\n\n        $email = (new TemplatedEmail())\n            ->from(new Address($this->settings->get('KBIN_SENDER_EMAIL'), $this->settings->get('KBIN_DOMAIN')))\n            ->to($this->settings->get('KBIN_CONTACT_EMAIL'))\n            ->subject($this->translator->trans('contact').' - '.$this->settings->get('KBIN_DOMAIN'))\n            ->htmlTemplate('_email/contact.html.twig')\n            ->context([\n                'name' => $dto->name,\n                'senderEmail' => $dto->email,\n                'message' => $dto->message,\n            ]);\n\n        $this->mailer->send($email);\n    }\n}\n"
  },
  {
    "path": "src/Service/Contracts/ContentManagerInterface.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service\\Contracts;\n\ninterface ContentManagerInterface extends ManagerInterface\n{\n}\n"
  },
  {
    "path": "src/Service/Contracts/ContentNotificationManagerInterface.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service\\Contracts;\n\nuse App\\Entity\\Contracts\\ContentInterface;\n\ninterface ContentNotificationManagerInterface extends ManagerInterface\n{\n    public function sendCreated(ContentInterface $subject): void;\n\n    public function sendEdited(ContentInterface $subject): void;\n\n    public function sendDeleted(ContentInterface $subject): void;\n}\n"
  },
  {
    "path": "src/Service/Contracts/ManagerInterface.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service\\Contracts;\n\ninterface ManagerInterface\n{\n}\n"
  },
  {
    "path": "src/Service/DeliverManager.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service;\n\nuse App\\Entity\\Traits\\ActivityPubActorTrait;\nuse App\\Message\\ActivityPub\\Outbox\\DeliverMessage;\nuse Psr\\Log\\LoggerInterface;\nuse Symfony\\Component\\Messenger\\MessageBusInterface;\n\nreadonly class DeliverManager\n{\n    public function __construct(\n        private SettingsManager $settingsManager,\n        private MessageBusInterface $bus,\n        private LoggerInterface $logger,\n    ) {\n    }\n\n    /**\n     * @param string[]|ActivityPubActorTrait[] $inboxes\n     */\n    public function deliver(array $inboxes, array $activity, bool $useOldPrivateKey = false): void\n    {\n        foreach ($inboxes as $inbox) {\n            if (!$inbox) {\n                continue;\n            }\n\n            $inboxUrl = \\is_string($inbox) ? $inbox : $inbox->apInboxUrl;\n\n            if ($this->settingsManager->isBannedInstance($inboxUrl)) {\n                continue;\n            }\n\n            if ($this->settingsManager->isLocalUrl($inboxUrl)) {\n                $this->logger->warning('tried delivering to a local url, {payload}', ['payload' => $activity]);\n                continue;\n            }\n\n            $this->bus->dispatch(new DeliverMessage($inboxUrl, $activity, $useOldPrivateKey));\n        }\n    }\n}\n"
  },
  {
    "path": "src/Service/DomainManager.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service;\n\nuse App\\Entity\\Contracts\\DomainInterface;\nuse App\\Entity\\Domain;\nuse App\\Entity\\User;\nuse App\\Event\\DomainBlockedEvent;\nuse App\\Event\\DomainSubscribedEvent;\nuse App\\Repository\\DomainRepository;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Psr\\EventDispatcher\\EventDispatcherInterface;\n\nclass DomainManager\n{\n    public function __construct(\n        private readonly DomainRepository $repository,\n        private readonly EventDispatcherInterface $dispatcher,\n        private readonly EntityManagerInterface $entityManager,\n        private readonly SettingsManager $settingsManager,\n    ) {\n    }\n\n    public function extract(DomainInterface $subject): void\n    {\n        $domainName = $subject->getUrl();\n        if (!$domainName) {\n            return;\n        }\n\n        $domainName = preg_replace('/^www\\./i', '', parse_url($domainName)['host']);\n\n        $domain = $this->repository->findOneByName($domainName);\n\n        if (!$domain) {\n            $domain = new Domain($subject, $domainName);\n            $subject->domain = $domain;\n            $this->entityManager->persist($domain);\n        }\n\n        $domain->addEntry($subject);\n        $domain->updateCounts();\n\n        $this->entityManager->flush();\n    }\n\n    public function subscribe(Domain $domain, User $user): void\n    {\n        $user->unblockDomain($domain);\n\n        $domain->subscribe($user);\n\n        $this->entityManager->flush();\n\n        $this->dispatcher->dispatch(new DomainSubscribedEvent($domain, $user));\n    }\n\n    public function block(Domain $domain, User $user): void\n    {\n        $this->unsubscribe($domain, $user);\n\n        $user->blockDomain($domain);\n\n        $this->entityManager->flush();\n\n        $this->dispatcher->dispatch(new DomainBlockedEvent($domain, $user));\n    }\n\n    public function unsubscribe(Domain $domain, User $user): void\n    {\n        $domain->unsubscribe($user);\n\n        $this->entityManager->flush();\n\n        $this->dispatcher->dispatch(new DomainSubscribedEvent($domain, $user));\n    }\n\n    public function unblock(Domain $domain, User $user): void\n    {\n        $user->unblockDomain($domain);\n\n        $this->entityManager->flush();\n\n        $this->dispatcher->dispatch(new DomainBlockedEvent($domain, $user));\n    }\n\n    public static function shouldRatio(string $domain): bool\n    {\n        $domainsWithRatio = ['youtube.com', 'streamable.com', 'youtu.be', 'm.youtube.com'];\n\n        return (bool) array_filter($domainsWithRatio, fn ($item) => str_contains($domain, $item));\n    }\n}\n"
  },
  {
    "path": "src/Service/EntryCommentManager.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service;\n\nuse App\\DTO\\EntryCommentDto;\nuse App\\Entity\\Contracts\\VisibilityInterface;\nuse App\\Entity\\EntryComment;\nuse App\\Entity\\User;\nuse App\\Event\\EntryComment\\EntryCommentBeforeDeletedEvent;\nuse App\\Event\\EntryComment\\EntryCommentBeforePurgeEvent;\nuse App\\Event\\EntryComment\\EntryCommentCreatedEvent;\nuse App\\Event\\EntryComment\\EntryCommentDeletedEvent;\nuse App\\Event\\EntryComment\\EntryCommentEditedEvent;\nuse App\\Event\\EntryComment\\EntryCommentPurgedEvent;\nuse App\\Event\\EntryComment\\EntryCommentRestoredEvent;\nuse App\\Exception\\EntryLockedException;\nuse App\\Exception\\InstanceBannedException;\nuse App\\Exception\\TagBannedException;\nuse App\\Exception\\UserBannedException;\nuse App\\Factory\\EntryCommentFactory;\nuse App\\Message\\DeleteImageMessage;\nuse App\\Repository\\ImageRepository;\nuse App\\Service\\Contracts\\ContentManagerInterface;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Psr\\EventDispatcher\\EventDispatcherInterface;\nuse Psr\\Log\\LoggerInterface;\nuse Symfony\\Component\\HttpKernel\\Exception\\TooManyRequestsHttpException;\nuse Symfony\\Component\\Messenger\\MessageBusInterface;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Webmozart\\Assert\\Assert;\n\nclass EntryCommentManager implements ContentManagerInterface\n{\n    public function __construct(\n        private readonly LoggerInterface $logger,\n        private readonly TagManager $tagManager,\n        private readonly TagExtractor $tagExtractor,\n        private readonly MentionManager $mentionManager,\n        private readonly EntryCommentFactory $factory,\n        private readonly RateLimiterFactoryInterface $entryCommentLimiter,\n        private readonly EventDispatcherInterface $dispatcher,\n        private readonly MessageBusInterface $bus,\n        private readonly EntityManagerInterface $entityManager,\n        private readonly ImageRepository $imageRepository,\n        private readonly SettingsManager $settingsManager,\n    ) {\n    }\n\n    /**\n     * @throws InstanceBannedException\n     * @throws TagBannedException\n     * @throws UserBannedException\n     * @throws TooManyRequestsHttpException\n     * @throws EntryLockedException\n     * @throws \\Exception\n     */\n    public function create(EntryCommentDto $dto, User $user, $rateLimit = true): EntryComment\n    {\n        if ($rateLimit) {\n            $limiter = $this->entryCommentLimiter->create($dto->ip);\n            if ($limiter && false === $limiter->consume()->isAccepted()) {\n                throw new TooManyRequestsHttpException();\n            }\n        }\n\n        if ($dto->entry->magazine->isBanned($user) || $user->isBanned()) {\n            throw new UserBannedException();\n        }\n\n        if ($this->tagManager->isAnyTagBanned($this->tagManager->extract($dto->body))) {\n            throw new TagBannedException();\n        }\n\n        if (null !== $dto->entry->magazine->apId && $this->settingsManager->isBannedInstance($dto->entry->magazine->apInboxUrl)) {\n            throw new InstanceBannedException();\n        }\n\n        if ($dto->entry->isLocked) {\n            throw new EntryLockedException();\n        }\n\n        $comment = $this->factory->createFromDto($dto, $user);\n\n        $comment->magazine = $dto->entry->magazine;\n        $comment->lang = $dto->lang;\n        $comment->isAdult = $dto->isAdult || $comment->magazine->isAdult;\n        $comment->image = $dto->image ? $this->imageRepository->find($dto->image->id) : null;\n        if ($comment->image && !$comment->image->altText) {\n            $comment->image->altText = $dto->imageAlt;\n        }\n        $comment->mentions = $dto->body\n            ? array_merge($dto->mentions ?? [], $this->mentionManager->handleChain($comment))\n            : $dto->mentions;\n        $comment->visibility = $dto->visibility;\n        $comment->apId = $dto->apId;\n        $comment->apLikeCount = $dto->apLikeCount;\n        $comment->apDislikeCount = $dto->apDislikeCount;\n        $comment->apShareCount = $dto->apShareCount;\n        $comment->magazine->lastActive = new \\DateTime();\n        $comment->user->lastActive = new \\DateTime();\n        $comment->lastActive = $dto->lastActive ?? $comment->lastActive;\n        $comment->createdAt = $dto->createdAt ?? $comment->createdAt;\n        if (empty($comment->body) && null === $comment->image) {\n            throw new \\Exception('Comment body and image cannot be empty');\n        }\n\n        $comment->entry->addComment($comment);\n\n        $comment->updateScore();\n        $comment->updateRanking();\n\n        $this->entityManager->persist($comment);\n        $this->entityManager->flush();\n\n        $this->tagManager->updateEntryCommentTags($comment, $this->tagExtractor->extract($comment->body) ?? []);\n\n        $this->dispatcher->dispatch(new EntryCommentCreatedEvent($comment));\n\n        return $comment;\n    }\n\n    public function canUserEditComment(EntryComment $comment, User $user): bool\n    {\n        $entryCommentHost = null !== $comment->apId ? parse_url($comment->apId, PHP_URL_HOST) : $this->settingsManager->get('KBIN_DOMAIN');\n        $userHost = null !== $user->apId ? parse_url($user->apProfileId, PHP_URL_HOST) : $this->settingsManager->get('KBIN_DOMAIN');\n        $magazineHost = null !== $comment->magazine->apId ? parse_url($comment->magazine->apProfileId, PHP_URL_HOST) : $this->settingsManager->get('KBIN_DOMAIN');\n\n        return $entryCommentHost === $userHost || $userHost === $magazineHost || $comment->magazine->userIsModerator($user);\n    }\n\n    public function edit(EntryComment $comment, EntryCommentDto $dto, ?User $editedByUser = null): EntryComment\n    {\n        Assert::same($comment->entry->getId(), $dto->entry->getId());\n\n        $comment->body = $dto->body;\n        $comment->lang = $dto->lang;\n        $comment->isAdult = $dto->isAdult || $comment->magazine->isAdult;\n        $oldImage = $comment->image;\n        if ($dto->image) {\n            $comment->image = $this->imageRepository->find($dto->image->id);\n        }\n        $this->tagManager->updateEntryCommentTags($comment, $this->tagManager->getTagsFromEntryCommentDto($dto));\n        $comment->mentions = $dto->body\n            ? array_merge($dto->mentions ?? [], $this->mentionManager->handleChain($comment))\n            : $dto->mentions;\n        $comment->visibility = $dto->visibility;\n        $comment->editedAt = new \\DateTimeImmutable('@'.time());\n        if (empty($comment->body) && null === $comment->image) {\n            throw new \\Exception('Comment body and image cannot be empty');\n        }\n\n        $comment->apLikeCount = $dto->apLikeCount;\n        $comment->apDislikeCount = $dto->apDislikeCount;\n        $comment->apShareCount = $dto->apShareCount;\n        $comment->updateScore();\n        $comment->updateRanking();\n\n        $this->entityManager->flush();\n\n        if ($oldImage && $comment->image !== $oldImage) {\n            $this->bus->dispatch(new DeleteImageMessage($oldImage->getId()));\n        }\n\n        $this->dispatcher->dispatch(new EntryCommentEditedEvent($comment, $editedByUser));\n\n        return $comment;\n    }\n\n    public function delete(User $user, EntryComment $comment): void\n    {\n        if ($user->apDomain && $user->apDomain !== parse_url($comment->apId ?? '', PHP_URL_HOST) && !$comment->magazine->userIsModerator($user)) {\n            $this->logger->info('Got a delete activity from user {u}, but they are not from the same instance as the deleted post and they are not a moderator on {m]', ['u' => $user->apId, 'm' => $comment->magazine->apId ?? $comment->magazine->name]);\n\n            return;\n        }\n\n        if ($comment->isAuthor($user) && $comment->children->isEmpty()) {\n            $this->purge($user, $comment);\n\n            return;\n        }\n\n        $this->isTrashed($user, $comment) ? $comment->trash() : $comment->softDelete();\n\n        $this->dispatcher->dispatch(new EntryCommentBeforeDeletedEvent($comment, $user));\n\n        $this->entityManager->flush();\n\n        $this->dispatcher->dispatch(new EntryCommentDeletedEvent($comment, $user));\n    }\n\n    public function trash(User $user, EntryComment $comment): void\n    {\n        $comment->trash();\n\n        $this->dispatcher->dispatch(new EntryCommentBeforeDeletedEvent($comment, $user));\n\n        $this->entityManager->flush();\n\n        $this->dispatcher->dispatch(new EntryCommentDeletedEvent($comment, $user));\n    }\n\n    public function purge(User $user, EntryComment $comment): void\n    {\n        $this->dispatcher->dispatch(new EntryCommentBeforePurgeEvent($comment, $user));\n\n        $magazine = $comment->entry->magazine;\n        $image = $comment->image?->getId();\n        $comment->entry->removeComment($comment);\n\n        $this->entityManager->remove($comment);\n        $this->entityManager->flush();\n\n        if ($image) {\n            $this->bus->dispatch(new DeleteImageMessage($image));\n        }\n\n        $this->dispatcher->dispatch(new EntryCommentPurgedEvent($magazine));\n    }\n\n    private function isTrashed(User $user, EntryComment $comment): bool\n    {\n        return !$comment->isAuthor($user);\n    }\n\n    public function restore(User $user, EntryComment $comment): void\n    {\n        if (VisibilityInterface::VISIBILITY_TRASHED !== $comment->visibility) {\n            throw new \\Exception('Invalid visibility');\n        }\n\n        $comment->visibility = VisibilityInterface::VISIBILITY_VISIBLE;\n\n        $this->entityManager->persist($comment);\n        $this->entityManager->flush();\n\n        $this->dispatcher->dispatch(new EntryCommentRestoredEvent($comment, $user));\n    }\n\n    public function createDto(EntryComment $comment): EntryCommentDto\n    {\n        return $this->factory->createDto($comment);\n    }\n\n    public function detachImage(EntryComment $comment): void\n    {\n        $image = $comment->image->getId();\n\n        $comment->image = null;\n\n        $this->entityManager->persist($comment);\n        $this->entityManager->flush();\n\n        $this->bus->dispatch(new DeleteImageMessage($image));\n    }\n}\n"
  },
  {
    "path": "src/Service/EntryManager.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service;\n\nuse App\\DTO\\EntryDto;\nuse App\\Entity\\Contracts\\VisibilityInterface;\nuse App\\Entity\\Entry;\nuse App\\Entity\\Magazine;\nuse App\\Entity\\MagazineLogEntryLocked;\nuse App\\Entity\\MagazineLogEntryPinned;\nuse App\\Entity\\MagazineLogEntryUnlocked;\nuse App\\Entity\\MagazineLogEntryUnpinned;\nuse App\\Entity\\User;\nuse App\\Event\\Entry\\EntryBeforeDeletedEvent;\nuse App\\Event\\Entry\\EntryBeforePurgeEvent;\nuse App\\Event\\Entry\\EntryCreatedEvent;\nuse App\\Event\\Entry\\EntryDeletedEvent;\nuse App\\Event\\Entry\\EntryEditedEvent;\nuse App\\Event\\Entry\\EntryLockEvent;\nuse App\\Event\\Entry\\EntryPinEvent;\nuse App\\Event\\Entry\\EntryRestoredEvent;\nuse App\\Exception\\InstanceBannedException;\nuse App\\Exception\\PostingRestrictedException;\nuse App\\Exception\\TagBannedException;\nuse App\\Exception\\UserBannedException;\nuse App\\Factory\\EntryFactory;\nuse App\\Message\\DeleteImageMessage;\nuse App\\Message\\EntryEmbedMessage;\nuse App\\Repository\\EntryRepository;\nuse App\\Repository\\ImageRepository;\nuse App\\Service\\ActivityPub\\ApHttpClientInterface;\nuse App\\Service\\Contracts\\ContentManagerInterface;\nuse App\\Utils\\Slugger;\nuse App\\Utils\\UrlCleaner;\nuse Doctrine\\Common\\Collections\\Criteria;\nuse Doctrine\\Common\\Collections\\Order;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Psr\\EventDispatcher\\EventDispatcherInterface;\nuse Psr\\Log\\LoggerInterface;\nuse Symfony\\Component\\HttpKernel\\Exception\\TooManyRequestsHttpException;\nuse Symfony\\Component\\Messenger\\MessageBusInterface;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Contracts\\Cache\\CacheInterface;\nuse Symfony\\Contracts\\Translation\\TranslatorInterface;\nuse Webmozart\\Assert\\Assert;\n\nclass EntryManager implements ContentManagerInterface\n{\n    public function __construct(\n        private readonly LoggerInterface $logger,\n        private readonly SettingsManager $settingsManager,\n        private readonly TagExtractor $tagExtractor,\n        private readonly TagManager $tagManager,\n        private readonly MentionManager $mentionManager,\n        private readonly EntryCommentManager $entryCommentManager,\n        private readonly UrlCleaner $urlCleaner,\n        private readonly Slugger $slugger,\n        private readonly BadgeManager $badgeManager,\n        private readonly EntryFactory $factory,\n        private readonly EventDispatcherInterface $dispatcher,\n        private readonly RateLimiterFactoryInterface $entryLimiter,\n        private readonly MessageBusInterface $bus,\n        private readonly TranslatorInterface $translator,\n        private readonly EntityManagerInterface $entityManager,\n        private readonly EntryRepository $entryRepository,\n        private readonly ImageRepository $imageRepository,\n        private readonly ApHttpClientInterface $apHttpClient,\n        private readonly CacheInterface $cache,\n    ) {\n    }\n\n    /**\n     * @throws TagBannedException\n     * @throws UserBannedException\n     * @throws TooManyRequestsHttpException\n     * @throws PostingRestrictedException\n     * @throws InstanceBannedException\n     * @throws \\Exception                   if title, body and image are empty\n     */\n    public function create(EntryDto $dto, User $user, bool $rateLimit = true, bool $stickyIt = false): Entry\n    {\n        if ($rateLimit) {\n            $limiter = $this->entryLimiter->create($dto->ip);\n            if (false === $limiter->consume()->isAccepted()) {\n                throw new TooManyRequestsHttpException();\n            }\n        }\n\n        if ($dto->magazine->isBanned($user) || $user->isBanned()) {\n            throw new UserBannedException();\n        }\n\n        if ($this->tagManager->isAnyTagBanned($this->tagManager->extract($dto->body))) {\n            throw new TagBannedException();\n        }\n\n        if ($dto->magazine->isActorPostingRestricted($user)) {\n            throw new PostingRestrictedException($dto->magazine, $user);\n        }\n\n        if (null !== $dto->magazine->apId && $this->settingsManager->isBannedInstance($dto->magazine->apInboxUrl)) {\n            throw new InstanceBannedException();\n        }\n\n        $this->logger->debug('creating entry from dto');\n        $entry = $this->factory->createFromDto($dto, $user);\n\n        $entry->lang = $dto->lang;\n        $entry->isAdult = $dto->isAdult || $entry->magazine->isAdult;\n        $entry->slug = $this->slugger->slug($dto->title);\n        $entry->image = $dto->image ? $this->imageRepository->find($dto->image->id) : null;\n        $this->logger->debug('setting image to {imageId}, dto was {dtoImageId}', ['imageId' => $entry->image?->getId() ?? 'none', 'dtoImageId' => $dto->image?->id ?? 'none']);\n        if ($entry->image && !$entry->image->altText) {\n            $entry->image->altText = $dto->imageAlt;\n        }\n        if ($entry->url) {\n            $entry->url = ($this->urlCleaner)($dto->url);\n        }\n        $entry->mentions = $dto->body ? $this->mentionManager->extract($dto->body) : null;\n        $entry->visibility = $dto->visibility;\n        $entry->apId = $dto->apId;\n        $entry->apLikeCount = $dto->apLikeCount;\n        $entry->apDislikeCount = $dto->apDislikeCount;\n        $entry->apShareCount = $dto->apShareCount;\n        $entry->magazine->lastActive = new \\DateTime();\n        $entry->user->lastActive = new \\DateTime();\n        $entry->lastActive = $dto->lastActive ?? $entry->lastActive;\n        $entry->createdAt = $dto->createdAt ?? $entry->createdAt;\n        if (empty($entry->body) && empty($entry->title) && null === $entry->image && null === $entry->url) {\n            throw new \\Exception('Entry body, name, url and image cannot all be empty');\n        }\n\n        $entry = $this->setType($dto, $entry);\n\n        if ($dto->badges) {\n            $this->badgeManager->assign($entry, $dto->badges);\n        }\n\n        $entry->updateScore();\n        $entry->updateRanking();\n\n        $this->entityManager->persist($entry);\n        $this->entityManager->flush();\n\n        $tags = array_unique(array_merge($this->tagExtractor->extract($entry->body) ?? [], $dto->tags ?? []));\n        $this->tagManager->updateEntryTags($entry, $tags);\n\n        $this->dispatcher->dispatch(new EntryCreatedEvent($entry));\n\n        if ($stickyIt) {\n            $this->pin($entry, null);\n        }\n\n        return $entry;\n    }\n\n    private function setType(EntryDto $dto, Entry $entry): Entry\n    {\n        if ($dto->image) {\n            $entry->type = Entry::ENTRY_TYPE_IMAGE;\n            $entry->hasEmbed = true;\n        } elseif ($dto->url) {\n            if (ImageManager::isImageUrl($dto->url)) {\n                $entry->type = Entry::ENTRY_TYPE_IMAGE;\n                $entry->hasEmbed = true;\n            } else {\n                $entry->type = Entry::ENTRY_TYPE_LINK;\n            }\n        } elseif ($dto->body) {\n            $entry->type = Entry::ENTRY_TYPE_ARTICLE;\n        } else {\n            $this->logger->warning('entry has neither image nor url nor body; defaulting to article');\n            $entry->type = Entry::ENTRY_TYPE_ARTICLE;\n        }\n\n        // TODO handle ENTRY_TYPE_VIDEO\n\n        return $entry;\n    }\n\n    public function canUserEditEntry(Entry $entry, User $user): bool\n    {\n        $entryHost = null !== $entry->apId ? parse_url($entry->apId, PHP_URL_HOST) : $this->settingsManager->get('KBIN_DOMAIN');\n        $userHost = null !== $user->apId ? parse_url($user->apProfileId, PHP_URL_HOST) : $this->settingsManager->get('KBIN_DOMAIN');\n        $magazineHost = null !== $entry->magazine->apId ? parse_url($entry->magazine->apProfileId, PHP_URL_HOST) : $this->settingsManager->get('KBIN_DOMAIN');\n\n        return $entryHost === $userHost || $userHost === $magazineHost || $entry->magazine->userIsModerator($user);\n    }\n\n    public function edit(Entry $entry, EntryDto $dto, User $editedBy): Entry\n    {\n        Assert::same($entry->magazine->getId(), $dto->magazine->getId());\n\n        $entry->title = $dto->title;\n        $oldUrl = $entry->url;\n        $entry->url = $dto->url;\n        $entry->body = $dto->body;\n        $entry->lang = $dto->lang;\n        $entry->isAdult = $dto->isAdult || $entry->magazine->isAdult;\n        $entry->isLocked = $dto->isLocked;\n        $entry->slug = $this->slugger->slug($dto->title);\n        $entry->visibility = $dto->visibility;\n        $oldImage = $entry->image;\n        $entry->image = $dto->image ? $this->imageRepository->find($dto->image->id) : null;\n        $this->logger->debug('setting image to {imageId}, dto was {dtoImageId}', ['imageId' => $entry->image?->getId() ?? 'none', 'dtoImageId' => $dto->image?->id ?? 'none']);\n        if ($entry->image && !$entry->image->altText) {\n            $entry->image->altText = $dto->imageAlt;\n        }\n        $this->tagManager->updateEntryTags($entry, $this->tagManager->getTagsFromEntryDto($dto));\n\n        $entry->mentions = $dto->body ? $this->mentionManager->extract($dto->body) : null;\n        $entry->isOc = $dto->isOc;\n        $entry->lang = $dto->lang;\n        $entry->editedAt = new \\DateTimeImmutable('@'.time());\n        if ($dto->badges) {\n            $this->badgeManager->assign($entry, $dto->badges);\n        }\n        if (empty($entry->body) && empty($entry->title) && null === $entry->image && null === $entry->url) {\n            throw new \\Exception('Entry body, name, url and image cannot all be empty');\n        }\n\n        $entry->apLikeCount = $dto->apLikeCount;\n        $entry->apDislikeCount = $dto->apDislikeCount;\n        $entry->apShareCount = $dto->apShareCount;\n        $entry->updateScore();\n        $entry->updateRanking();\n\n        $this->entityManager->flush();\n\n        if ($oldImage && $entry->image !== $oldImage) {\n            $this->bus->dispatch(new DeleteImageMessage($oldImage->getId()));\n        }\n\n        if ($entry->url !== $oldUrl) {\n            $this->bus->dispatch(new EntryEmbedMessage($entry->getId()));\n        }\n\n        $this->dispatcher->dispatch(new EntryEditedEvent($entry, $editedBy));\n\n        return $entry;\n    }\n\n    public function delete(User $user, Entry $entry): void\n    {\n        if ($user->apDomain && $user->apDomain !== parse_url($entry->apId ?? '', PHP_URL_HOST) && !$entry->magazine->userIsModerator($user)) {\n            $this->logger->info('Got a delete activity from user {u}, but they are not from the same instance as the deleted post and they are not a moderator on {m]', ['u' => $user->apId, 'm' => $entry->magazine->apId ?? $entry->magazine->name]);\n\n            return;\n        }\n\n        if ($entry->isAuthor($user) && $entry->comments->isEmpty()) {\n            $this->purge($user, $entry);\n\n            return;\n        }\n\n        $entry->isAuthor($user) ? $entry->softDelete() : $entry->trash();\n\n        $this->dispatcher->dispatch(new EntryBeforeDeletedEvent($entry, $user));\n\n        $this->entityManager->flush();\n\n        $this->dispatcher->dispatch(new EntryDeletedEvent($entry, $user));\n    }\n\n    public function trash(User $user, Entry $entry): void\n    {\n        $entry->trash();\n\n        $this->dispatcher->dispatch(new EntryBeforeDeletedEvent($entry, $user));\n\n        $this->entityManager->flush();\n\n        $this->dispatcher->dispatch(new EntryDeletedEvent($entry, $user));\n    }\n\n    public function purge(User $user, Entry $entry): void\n    {\n        $this->dispatcher->dispatch(new EntryBeforePurgeEvent($entry, $user));\n\n        $image = $entry->image?->getId();\n\n        $sort = new Criteria(null, ['createdAt' => Order::Descending]);\n        foreach ($entry->comments->matching($sort) as $comment) {\n            $this->entryCommentManager->purge($user, $comment);\n        }\n\n        $this->entityManager->remove($entry);\n        $this->entityManager->flush();\n\n        if ($image) {\n            $this->bus->dispatch(new DeleteImageMessage($image));\n        }\n    }\n\n    public function restore(User $user, Entry $entry): void\n    {\n        if (VisibilityInterface::VISIBILITY_TRASHED !== $entry->visibility) {\n            throw new \\Exception('Invalid visibility');\n        }\n\n        $entry->visibility = VisibilityInterface::VISIBILITY_VISIBLE;\n\n        $this->entityManager->persist($entry);\n        $this->entityManager->flush();\n\n        $this->dispatcher->dispatch(new EntryRestoredEvent($entry, $user));\n    }\n\n    /**\n     * this toggles the pin state of the entry. If it was not pinned it pins, if it was pinned it unpins it.\n     *\n     * @param User|null $actor this should only be null if it is a system call\n     */\n    public function pin(Entry $entry, ?User $actor): Entry\n    {\n        $entry->sticky = !$entry->sticky;\n\n        if ($entry->sticky) {\n            $log = new MagazineLogEntryPinned($entry->magazine, $actor, $entry);\n        } else {\n            $log = new MagazineLogEntryUnpinned($entry->magazine, $actor, $entry);\n        }\n        $this->entityManager->persist($log);\n\n        $this->entityManager->flush();\n\n        $this->dispatcher->dispatch(new EntryPinEvent($entry, $actor));\n\n        if (null !== $entry->magazine->apFeaturedUrl) {\n            $this->apHttpClient->invalidateCollectionObjectCache($entry->magazine->apFeaturedUrl);\n        }\n\n        return $entry;\n    }\n\n    public function toggleLock(Entry $entry, ?User $actor): Entry\n    {\n        $entry->isLocked = !$entry->isLocked;\n\n        if ($entry->isLocked) {\n            $log = new MagazineLogEntryLocked($entry, $actor);\n        } else {\n            $log = new MagazineLogEntryUnlocked($entry, $actor);\n        }\n        $this->entityManager->persist($log);\n        $this->entityManager->flush();\n\n        $this->dispatcher->dispatch(new EntryLockEvent($entry, $actor));\n\n        return $entry;\n    }\n\n    public function createDto(Entry $entry): EntryDto\n    {\n        return $this->factory->createDto($entry);\n    }\n\n    public function detachImage(Entry $entry): void\n    {\n        $image = $entry->image->getId();\n\n        $entry->image = null;\n\n        $this->entityManager->persist($entry);\n        $this->entityManager->flush();\n\n        $this->bus->dispatch(new DeleteImageMessage($image));\n    }\n\n    public function getSortRoute(string $sortBy): string\n    {\n        return strtolower($this->translator->trans($sortBy));\n    }\n\n    public function changeMagazine(Entry $entry, Magazine $magazine): void\n    {\n        $this->entityManager->beginTransaction();\n\n        try {\n            $oldMagazine = $entry->magazine;\n            $entry->magazine = $magazine;\n\n            foreach ($entry->comments as $comment) {\n                $comment->magazine = $magazine;\n            }\n\n            $this->entityManager->flush();\n            $this->entityManager->commit();\n        } catch (\\Exception $e) {\n            $this->entityManager->rollback();\n\n            return;\n        }\n\n        $oldMagazine->entryCommentCount = $this->entryRepository->countEntryCommentsByMagazine($oldMagazine);\n        $oldMagazine->entryCount = $this->entryRepository->countEntriesByMagazine($oldMagazine);\n\n        $magazine->entryCommentCount = $this->entryRepository->countEntryCommentsByMagazine($magazine);\n        $magazine->entryCount = $this->entryRepository->countEntriesByMagazine($magazine);\n\n        $this->entityManager->flush();\n\n        $this->cache->invalidateTags(['entry_'.$entry->getId()]);\n    }\n}\n"
  },
  {
    "path": "src/Service/FactoryResolver.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service;\n\nuse App\\Entity\\Entry;\nuse App\\Entity\\EntryComment;\nuse App\\Entity\\Magazine;\nuse App\\Entity\\Post;\nuse App\\Entity\\PostComment;\nuse App\\Factory\\EntryCommentFactory;\nuse App\\Factory\\EntryFactory;\nuse App\\Factory\\MagazineFactory;\nuse App\\Factory\\PostCommentFactory;\nuse App\\Factory\\PostFactory;\nuse Doctrine\\ORM\\EntityManagerInterface;\n\nclass FactoryResolver\n{\n    public function __construct(\n        private readonly EntityManagerInterface $entityManager,\n        private readonly EntryFactory $entryFactory,\n        private readonly EntryCommentFactory $entryCommentFactory,\n        private readonly PostFactory $postFactory,\n        private readonly PostCommentFactory $postCommentFactory,\n        private readonly MagazineFactory $magazineFactory,\n    ) {\n    }\n\n    public function resolve($subject)\n    {\n        return match ($this->entityManager->getClassMetadata(\\get_class($subject))->name) {\n            Entry::class => $this->entryFactory,\n            EntryComment::class => $this->entryCommentFactory,\n            Post::class => $this->postFactory,\n            PostComment::class => $this->postCommentFactory,\n            Magazine::class => $this->magazineFactory,\n            default => throw new \\LogicException(),\n        };\n    }\n}\n"
  },
  {
    "path": "src/Service/FavouriteManager.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service;\n\nuse App\\Entity\\Contracts\\FavouriteInterface;\nuse App\\Entity\\Entry;\nuse App\\Entity\\EntryComment;\nuse App\\Entity\\Favourite;\nuse App\\Entity\\Post;\nuse App\\Entity\\PostComment;\nuse App\\Entity\\User;\nuse App\\Event\\FavouriteEvent;\nuse App\\Factory\\FavouriteFactory;\nuse App\\Repository\\FavouriteRepository;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Psr\\EventDispatcher\\EventDispatcherInterface;\n\nclass FavouriteManager\n{\n    public const TYPE_LIKE = 'like';\n    public const TYPE_UNLIKE = 'unlike';\n\n    public function __construct(\n        private readonly FavouriteFactory $factory,\n        private readonly FavouriteRepository $repository,\n        private readonly EntityManagerInterface $entityManager,\n        private readonly EventDispatcherInterface $dispatcher,\n    ) {\n    }\n\n    public function toggle(User $user, FavouriteInterface $subject, ?string $type = null): ?Favourite\n    {\n        if (!($favourite = $this->repository->findBySubject($user, $subject))) {\n            if (self::TYPE_UNLIKE === $type) {\n                return null;\n            }\n\n            $favourite = $this->factory->createFromEntity($user, $subject);\n            $this->entityManager->persist($favourite);\n\n            $subject->favourites->add($favourite);\n            $subject->updateCounts();\n            $subject->updateScore();\n            $subject->updateRanking();\n\n            if ($subject instanceof Entry || $subject instanceof EntryComment || $subject instanceof Post || $subject instanceof PostComment) {\n                if (null !== $subject->apLikeCount) {\n                    ++$subject->apLikeCount;\n                }\n            }\n        } else {\n            if (self::TYPE_LIKE === $type) {\n                if ($subject instanceof Entry || $subject instanceof EntryComment || $subject instanceof Post || $subject instanceof PostComment) {\n                    if (null !== $subject->apLikeCount) {\n                        ++$subject->apLikeCount;\n                    }\n                }\n\n                return $favourite;\n            }\n\n            $subject->favourites->removeElement($favourite);\n            $subject->updateCounts();\n            $subject->updateScore();\n            $subject->updateRanking();\n            $favourite = null;\n            if ($subject instanceof Entry || $subject instanceof EntryComment || $subject instanceof Post || $subject instanceof PostComment) {\n                if (null !== $subject->apLikeCount) {\n                    --$subject->apLikeCount;\n                }\n            }\n        }\n\n        $this->entityManager->flush();\n\n        $this->dispatcher->dispatch(new FavouriteEvent($subject, $user, null === $favourite));\n\n        return $favourite ?? null;\n    }\n}\n"
  },
  {
    "path": "src/Service/FeedManager.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service;\n\nuse App\\Entity\\Entry;\nuse App\\Entity\\Post;\nuse App\\Markdown\\MarkdownConverter;\nuse App\\PageView\\ContentPageView;\nuse App\\Repository\\ContentRepository;\nuse App\\Repository\\Criteria;\nuse App\\Repository\\MagazineRepository;\nuse App\\Repository\\TagLinkRepository;\nuse App\\Repository\\UserRepository;\nuse App\\Twig\\Runtime\\MediaExtensionRuntime;\nuse App\\Utils\\IriGenerator;\nuse FeedIo\\Feed;\nuse FeedIo\\Feed\\Item;\nuse FeedIo\\Feed\\Node\\Category;\nuse FeedIo\\FeedInterface;\nuse Symfony\\Bundle\\SecurityBundle\\Security;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpKernel\\Exception\\NotFoundHttpException;\nuse Symfony\\Component\\Routing\\Generator\\UrlGeneratorInterface;\n\nclass FeedManager\n{\n    public function __construct(\n        private readonly SettingsManager $settings,\n        private readonly ContentRepository $contentRepository,\n        private readonly MagazineRepository $magazineRepository,\n        private readonly UserRepository $userRepository,\n        private readonly TagLinkRepository $tagLinkRepository,\n        private readonly UrlGeneratorInterface $urlGenerator,\n        private readonly Security $security,\n        private readonly MediaExtensionRuntime $mediaExtensionRuntime,\n        private readonly MentionManager $mentionManager,\n        private readonly ImageManager $imageManager,\n        private readonly MarkdownConverter $markdownConverter,\n    ) {\n    }\n\n    public function getFeed(Request $request): FeedInterface\n    {\n        $criteria = $this->getCriteriaFromRequest($request);\n        $feed = $this->createFeed($criteria);\n\n        $content = $this->contentRepository->findByCriteriaCursored($criteria, $this->contentRepository->guessInitialCursor($criteria->sortOption));\n\n        foreach ($this->getItems($content->getCurrentPageResults()) as $item) {\n            $feed->add($item);\n        }\n\n        return $feed;\n    }\n\n    private function createFeed(Criteria $criteria): Feed\n    {\n        $feed = new Feed();\n        if ($criteria->magazine) {\n            $title = \"{$criteria->magazine->title} - {$this->settings->get('KBIN_META_TITLE')}\";\n            $url = $this->urlGenerator->generate('front_magazine', ['name' => $criteria->magazine->name, 'content' => $criteria->content], UrlGeneratorInterface::ABSOLUTE_URL);\n        } elseif ($criteria->user) {\n            $title = \"{$criteria->user->username} - {$this->settings->get('KBIN_META_TITLE')}\";\n            $url = $this->urlGenerator->generate('user_overview', ['username' => $criteria->user->username, 'content' => $criteria->content], UrlGeneratorInterface::ABSOLUTE_URL);\n        } else {\n            $title = $this->settings->get('KBIN_META_TITLE');\n            $url = $this->urlGenerator->generate('front', ['content' => $criteria->content], UrlGeneratorInterface::ABSOLUTE_URL);\n        }\n\n        $feed->setTitle($title);\n        $feed->setDescription($this->settings->get('KBIN_META_DESCRIPTION'));\n        $feed->setUrl($url);\n\n        return $feed;\n    }\n\n    /**\n     * @param iterable<Post|Entry> $content\n     *\n     * @return \\Generator<Item>\n     */\n    public function getItems(iterable $content): \\Generator\n    {\n        foreach ($content as $subject) {\n            $item = new Item();\n            $item->setLastModified(\\DateTime::createFromImmutable($subject->createdAt));\n            $item->setPublicId(IriGenerator::getIriFromResource($subject));\n            $item->setAuthor((new Item\\Author())->setName($this->mentionManager->getUsername($subject->user->username, true)));\n\n            if ($subject->image) {\n                $media = new Item\\Media();\n                $media->setUrl($this->mediaExtensionRuntime->getPublicPath($subject->image));\n                $media->setTitle($subject->image->altText);\n                $media->setType($this->imageManager->getMimetype($subject->image));\n                $item->addMedia($media);\n            }\n\n            if ($subject instanceof Entry) {\n                $link = $this->urlGenerator->generate('entry_single', [\n                    'magazine_name' => $subject->magazine->name,\n                    'entry_id' => $subject->getId(),\n                    'slug' => $subject->slug,\n                ], UrlGeneratorInterface::ABSOLUTE_URL);\n\n                $item->setContent($this->markdownConverter->convertToHtml($subject->body ?? '', 'entry'));\n                $item->setSummary($subject->getShortDesc());\n                $item->setTitle($subject->title);\n                $item->setLink($link);\n                $item->set('comments', $link.'#comments');\n            } elseif ($subject instanceof Post) {\n                $link = $this->urlGenerator->generate('post_single', [\n                    'magazine_name' => $subject->magazine->name,\n                    'post_id' => $subject->getId(),\n                    'slug' => $subject->slug,\n                ], UrlGeneratorInterface::ABSOLUTE_URL);\n\n                $item->setContent($this->markdownConverter->convertToHtml($subject->body ?? '', 'post'));\n                $item->setSummary($subject->getShortTitle());\n                $item->setLink($link);\n                $item->set('comments', $link.'#comments');\n            } else {\n                continue;\n            }\n\n            foreach ($this->tagLinkRepository->getTagsOfContent($subject) as $tag) {\n                $category = new Category();\n                $category->setLabel($tag);\n\n                $item->addCategory($category);\n            }\n\n            yield $item;\n        }\n    }\n\n    private function getCriteriaFromRequest(Request $request): ContentPageView\n    {\n        $criteria = new ContentPageView(1, $this->security);\n        $criteria->sortOption = Criteria::SORT_NEW;\n\n        $content = $request->get('content');\n        if ($content && \\in_array($content, Criteria::CONTENT_OPTIONS, true)) {\n            $criteria->setContent($content);\n        } else {\n            $criteria->setContent(Criteria::CONTENT_THREADS);\n        }\n\n        if ($magazineName = $request->get('magazine')) {\n            $magazine = $this->magazineRepository->findOneBy(['name' => $magazineName]);\n            if (!$magazine) {\n                throw new NotFoundHttpException(\"The magazine $magazineName does not exist\");\n            }\n            $criteria->magazine = $magazine;\n        }\n\n        if ($userName = $request->get('user')) {\n            $user = $this->userRepository->findOneByUsername($userName);\n            if (!$user) {\n                throw new NotFoundHttpException(\"The user $userName does not exist\");\n            }\n            $criteria->user = $user;\n        }\n\n        if ($domain = $request->get('domain')) {\n            $criteria->setDomain($domain);\n        }\n\n        if ($tag = $request->get('tag')) {\n            $criteria->tag = $tag;\n        }\n\n        if ($sortBy = $request->get('sortBy')) {\n            $criteria->showSortOption($sortBy);\n        }\n\n        // Since we currently do not have a way of authenticating the user, these feeds do not work.\n        // They are also not being generated and therefore not used in the sidebar.\n        // $id = $request->get('id');\n        // if ('sub' === $id) {\n        //     $criteria->subscribed = true;\n        // } elseif ('mod' === $id) {\n        //     $criteria->moderated = true;\n        // }\n\n        return $criteria;\n    }\n}\n"
  },
  {
    "path": "src/Service/GenerateHtmlClassService.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service;\n\nuse App\\Entity\\Contracts\\ContentInterface;\nuse App\\Entity\\Entry;\nuse App\\Entity\\EntryComment;\nuse App\\Entity\\Post;\nuse App\\Entity\\PostComment;\n\nclass GenerateHtmlClassService\n{\n    public function fromEntity(ContentInterface $subject): string\n    {\n        return match (true) {\n            $subject instanceof Entry => \"entry-{$subject->getId()}\",\n            $subject instanceof EntryComment => \"entry-comment-{$subject->getId()}\",\n            $subject instanceof Post => \"post-{$subject->getId()}\",\n            $subject instanceof PostComment => \"post-comment-{$subject->getId()}\",\n            default => throw new \\LogicException(),\n        };\n    }\n\n    public function fromClassName(string $class, int $id): string\n    {\n        return match ($class) {\n            'Entry' => \"entry-{$id}\",\n            'EntryComment' => \"entry-comment-{$id}\",\n            'Post' => \"post-{$id}\",\n            'PostComment' => \"post-comment-{$id}\",\n            default => throw new \\LogicException(),\n        };\n    }\n}\n"
  },
  {
    "path": "src/Service/ImageManager.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service;\n\nuse App\\Entity\\Image as MbinImage;\nuse App\\Exception\\CorruptedFileException;\nuse App\\Exception\\ImageDownloadTooLargeException;\nuse App\\Repository\\ImageRepository;\nuse App\\Twig\\Runtime\\FormattingExtensionRuntime;\nuse App\\Utils\\GeneralUtil;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Imagine\\Gd\\Imagine;\nuse League\\Flysystem\\FileAttributes;\nuse League\\Flysystem\\FilesystemException;\nuse League\\Flysystem\\FilesystemOperator;\nuse League\\Flysystem\\StorageAttributes;\nuse Liip\\ImagineBundle\\Imagine\\Cache\\CacheManager;\nuse Psr\\Log\\LoggerInterface;\nuse Symfony\\Component\\Messenger\\Exception\\UnrecoverableMessageHandlingException;\nuse Symfony\\Component\\Mime\\MimeTypesInterface;\nuse Symfony\\Component\\Validator\\Constraints\\Image;\nuse Symfony\\Component\\Validator\\Validator\\ValidatorInterface;\nuse Symfony\\Contracts\\HttpClient\\HttpClientInterface;\n\nclass ImageManager implements ImageManagerInterface\n{\n    public const array IMAGE_MIMETYPES = [\n        'image/jpeg', 'image/jpg', 'image/gif', 'image/png',\n        'image/jxl', 'image/heic', 'image/heif',\n        'image/webp', 'image/avif',\n    ];\n    public const string IMAGE_MIMETYPE_STR = 'image/jpeg, image/jpg, image/gif, image/png, image/jxl, image/heic, image/heif, image/webp, image/avif';\n\n    public function __construct(\n        private readonly string $storageUrl,\n        private readonly FilesystemOperator $publicUploadsFilesystem,\n        private readonly HttpClientInterface $httpClient,\n        private readonly MimeTypesInterface $mimeTypeGuesser,\n        private readonly ValidatorInterface $validator,\n        private readonly LoggerInterface $logger,\n        private readonly SettingsManager $settings,\n        private readonly FormattingExtensionRuntime $formattingExtensionRuntime,\n        private readonly float $imageCompressionQuality,\n        private readonly CacheManager $imagineCacheManager,\n        private readonly EntityManagerInterface $entityManager,\n    ) {\n    }\n\n    public static function isImageUrl(string $url): bool\n    {\n        $urlExt = mb_strtolower(pathinfo($url, PATHINFO_EXTENSION));\n\n        $types = array_map(fn ($type) => str_replace('image/', '', $type), self::IMAGE_MIMETYPES);\n\n        return \\in_array($urlExt, $types);\n    }\n\n    public static function isImageType(string $mediaType): bool\n    {\n        return \\in_array($mediaType, self::IMAGE_MIMETYPES);\n    }\n\n    /**\n     * @throws \\Exception if the file could not be found\n     */\n    public function store(string $source, string $filePath): bool\n    {\n        $fh = fopen($source, 'rb');\n\n        try {\n            if (filesize($source) > $this->settings->getMaxImageBytes()) {\n                throw new ImageDownloadTooLargeException('the image is too large, max size is '.$this->settings->getMaxImageBytes());\n            }\n\n            $this->validate($source);\n\n            $this->publicUploadsFilesystem->writeStream($filePath, $fh);\n\n            if (!$this->publicUploadsFilesystem->has($filePath)) {\n                throw new \\Exception('File not found');\n            }\n\n            return true;\n        } finally {\n            \\is_resource($fh) and fclose($fh);\n        }\n    }\n\n    /**\n     * Tries to compress an image until its size is smaller than $maxBytes. This overwrites the existing image.\n     *\n     * @return bool whether the image was compressed\n     */\n    public function compressUntilSize(string $filePath, string $extension, int $maxBytes): bool\n    {\n        if (-1 === $this->imageCompressionQuality || filesize($filePath) <= $maxBytes) {\n            // don't compress images if disabled or smaller than max bytes\n            return false;\n        }\n        $imagine = new Imagine();\n        $image = $imagine->open($filePath);\n        $bytes = filesize($filePath);\n        $initialBytes = $bytes;\n        $tempPath = \"{$filePath}_temp_compress.$extension\";\n        $compressed = false;\n        $quality = 0.9;\n        if (0.1 <= $this->imageCompressionQuality && 1 > $this->imageCompressionQuality) {\n            $quality = $this->imageCompressionQuality;\n        }\n        while ($bytes > $maxBytes && $quality > 0.1) {\n            $this->logger->debug('[ImageManager::compressUntilSize] Trying to compress \"{path}\" with {q}% quality', ['path' => $tempPath, 'q' => $quality * 100]);\n            $image->save($tempPath, [\n                'jpeg_quality' => $quality * 100, // jpeg max value is 100\n                'png_compression_level' => 9, // this is lossless compression, so always use the max\n                'webp_quality' => $quality * 100, // webp quality max is 100\n            ]);\n            $bytes = filesize($tempPath);\n            if ($initialBytes === $bytes) {\n                // there were no changes, so maybe it is in a format that cannot be compressed...\n                break;\n            }\n            $compressed = true;\n            $quality -= 0.05;\n        }\n        $copied = false;\n        if ($compressed) {\n            if (copy($tempPath, $filePath)) {\n                $copied = true;\n                $this->logger->debug('[ImageManager::compressUntilSize] successfully compressed \"{path}\" with {q}% quality: {bytesBefore} -> {bytesNow}', [\n                    'path' => $filePath,\n                    'q' => ($quality + 0.05) * 100, // re-add the last step, because it is always subtracted in the end if successful\n                    'bytesBefore' => $this->formattingExtensionRuntime->abbreviateNumber($initialBytes).'B',\n                    'bytesNow' => $this->formattingExtensionRuntime->abbreviateNumber($bytes).'B',\n                ]);\n            }\n        }\n        if (file_exists($tempPath)) {\n            unlink($tempPath);\n        }\n\n        return $copied;\n    }\n\n    private function validate(string $filePath): bool\n    {\n        $violations = $this->validator->validate(\n            $filePath,\n            [\n                new Image(detectCorrupted: true),\n            ]\n        );\n\n        if (\\count($violations) > 0) {\n            throw new CorruptedFileException();\n        }\n\n        return true;\n    }\n\n    public function download(string $url): ?string\n    {\n        $tempFile = @tempnam('/', 'kbin');\n\n        if (false === $tempFile) {\n            throw new UnrecoverableMessageHandlingException('Couldn\\'t create temporary file');\n        }\n\n        $fh = fopen($tempFile, 'wb');\n\n        try {\n            $response = $this->httpClient->request(\n                'GET',\n                $url,\n                [\n                    'timeout' => 5,\n                    'headers' => [\n                        'Accept' => implode(', ', array_diff(self::IMAGE_MIMETYPES, ['image/webp', 'image/avif'])),\n                    ],\n                ]\n            );\n\n            foreach ($this->httpClient->stream($response) as $chunk) {\n                fwrite($fh, $chunk->getContent());\n            }\n\n            fclose($fh);\n\n            $this->validate($tempFile);\n            $this->logger->debug('downloaded file from {url}', ['url' => $url]);\n        } catch (\\Exception $e) {\n            if ($fh && \\is_resource($fh)) {\n                fclose($fh);\n            }\n            unlink($tempFile);\n            $this->logger->warning(\"couldn't download file from {url}\", ['url' => $url]);\n\n            return null;\n        }\n\n        return $tempFile;\n    }\n\n    /**\n     * @return array{string, string}\n     */\n    public function getFilePathAndName(string $file): array\n    {\n        $name = $this->getFileName($file);\n        $path = $this->getFilePathFromName($name);\n\n        return [$path, $name];\n    }\n\n    public function getFilePath(string $file): string\n    {\n        $name = $this->getFileName($file);\n\n        return $this->getFilePathFromName($name);\n    }\n\n    private function getFilePathFromName(string $name): string\n    {\n        return \\sprintf(\n            '%s/%s/%s',\n            substr($name, 0, 2),\n            substr($name, 2, 2),\n            $name\n        );\n    }\n\n    public function getFileName(string $file): string\n    {\n        $hash = hash_file('sha256', $file);\n        $mimeType = $this->mimeTypeGuesser->guessMimeType($file);\n\n        if (!$mimeType) {\n            throw new \\RuntimeException(\"Couldn't guess MIME type of image\");\n        }\n\n        $ext = $this->mimeTypeGuesser->getExtensions($mimeType)[0] ?? null;\n\n        if (!$ext) {\n            throw new \\RuntimeException(\"Couldn't guess extension of image (invalid image?)\");\n        }\n\n        return \\sprintf('%s.%s', $hash, $ext);\n    }\n\n    public function remove(string $path): void\n    {\n        $this->publicUploadsFilesystem->delete($path);\n        $this->imagineCacheManager->remove($path);\n    }\n\n    public function getPath(MbinImage $image): string\n    {\n        return $this->publicUploadsFilesystem->read($image->filePath);\n    }\n\n    public function getUrl(?MbinImage $image): ?string\n    {\n        if (!$image) {\n            return null;\n        }\n\n        if ($image->filePath) {\n            return $this->storageUrl.'/'.$image->filePath;\n        }\n\n        return $image->sourceUrl;\n    }\n\n    public function getMimetype(MbinImage $image): string\n    {\n        try {\n            return $this->publicUploadsFilesystem->mimeType($image->filePath);\n        } catch (\\Throwable $e) {\n            return 'none';\n        }\n    }\n\n    public function deleteOrphanedFiles(ImageRepository $repository, bool $dryRun, array $ignoredPaths): iterable\n    {\n        foreach ($this->deleteOrphanedFilesIntern($repository, $dryRun, $ignoredPaths, '/') as $deletedPath) {\n            yield $deletedPath;\n        }\n    }\n\n    /**\n     * @return iterable<array{path: string, internalPath: string, deleted: bool, successful: bool, fileSize: ?int, exception: ?\\Throwable} the deleted files/directories\n     *\n     * @throws FilesystemException\n     */\n    private function deleteOrphanedFilesIntern(ImageRepository $repository, bool $dryRun, array $ignoredPaths, string $path): iterable\n    {\n        $contents = $this->publicUploadsFilesystem->listContents($path, deep: true);\n        foreach ($contents as $content) {\n            if (GeneralUtil::shouldPathBeIgnored($ignoredPaths, $content->path())) {\n                continue;\n            }\n\n            if ($content->isFile() && $content instanceof FileAttributes) {\n                [$internalImagePath, $fileName] = $this->getInternalImagePathAndName($content);\n                $image = $repository->findOneBy(['fileName' => $fileName, 'filePath' => $internalImagePath]);\n                if (!$image) {\n                    try {\n                        if (!$dryRun) {\n                            $this->publicUploadsFilesystem->delete($content->path());\n                        }\n                        yield [\n                            'path' => $content->path(),\n                            'internalPath' => $internalImagePath,\n                            'deleted' => true,\n                            'successful' => true,\n                            'fileSize' => $content->fileSize(),\n                            'exception' => null,\n                        ];\n                    } catch (\\Throwable $e) {\n                        yield [\n                            'path' => $content->path(),\n                            'internalPath' => $internalImagePath,\n                            'deleted' => true,\n                            'successful' => false,\n                            'fileSize' => $content->fileSize(),\n                            'exception' => $e,\n                        ];\n                    }\n                } else {\n                    yield [\n                        'path' => $content->path(),\n                        'internalPath' => $internalImagePath,\n                        'deleted' => false,\n                        'successful' => true,\n                        'fileSize' => $content->fileSize(),\n                        'exception' => null,\n                    ];\n                }\n            } elseif ($content->isDir()) {\n                foreach ($this->deleteOrphanedFilesIntern($repository, $dryRun, $ignoredPaths, $content->path()) as $file) {\n                    yield $file;\n                }\n            }\n        }\n    }\n\n    /**\n     * @return array{0: string, 1: string} 0=path 1=name\n     */\n    private function getInternalImagePathAndName(StorageAttributes $flySystemFile): array\n    {\n        if (!$flySystemFile->isFile()) {\n            $parts = explode('/', $flySystemFile->path());\n\n            return [$flySystemFile->path(), end($parts)];\n        }\n\n        $path = $flySystemFile->path();\n        if (str_starts_with($path, '/')) {\n            $path = substr($path, 1);\n        }\n\n        if (str_starts_with($path, 'cache')) {\n            $parts = explode('/', $path);\n            $newParts = \\array_slice($parts, 2);\n            $path = implode('/', $newParts);\n\n            $doubleExtensions = ['jpg', 'jpeg', 'gif', 'png', 'webp'];\n            foreach ($doubleExtensions as $extension) {\n                if (str_ends_with($path, \".$extension.webp\")) {\n                    $path = str_replace(\".$extension.webp\", \".$extension\", $path);\n                    break;\n                }\n            }\n        }\n        $parts = explode('/', $path);\n\n        return [$path, end($parts)];\n    }\n\n    public function removeCachedImage(MbinImage $image): bool\n    {\n        if (!$image->filePath || !$image->sourceUrl) {\n            return false;\n        }\n\n        try {\n            $this->publicUploadsFilesystem->delete($image->filePath);\n            $this->imagineCacheManager->remove($image->filePath);\n            $sql = 'UPDATE image SET file_path = NULL, downloaded_at = NULL WHERE id = :id';\n            $image->filePath = null;\n            $image->downloadedAt = null;\n            $this->entityManager->getConnection()->executeStatement($sql, ['id' => $image->getId()]);\n\n            return true;\n        } catch (\\Exception|FilesystemException $e) {\n            $this->logger->error('Unable to remove cached images for \"{path}\": {ex} - {m}', [\n                'path' => $image->filePath,\n                'ex' => \\get_class($e),\n                'm' => $e->getMessage(),\n                'exception' => $e,\n            ]);\n\n            return false;\n        }\n    }\n}\n"
  },
  {
    "path": "src/Service/ImageManagerInterface.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service;\n\nuse App\\Repository\\ImageRepository;\nuse League\\Flysystem\\FilesystemException;\n\ninterface ImageManagerInterface\n{\n    /**\n     * @throws \\Exception if the file could not be found\n     */\n    public function store(string $source, string $filePath): bool;\n\n    public function download(string $url): ?string;\n\n    /**\n     * @return array{string, string}\n     */\n    public function getFilePathAndName(string $file): array;\n\n    public function getFilePath(string $file): string;\n\n    public function getFileName(string $file): string;\n\n    public function remove(string $path): void;\n\n    public function getPath(\\App\\Entity\\Image $image): string;\n\n    public function getUrl(?\\App\\Entity\\Image $image): ?string;\n\n    public function getMimetype(\\App\\Entity\\Image $image): string;\n\n    /**\n     * @param string[] $ignoredPaths\n     *\n     * @return iterable<array{path: string, internalPath: string, deleted: bool, successful: bool, fileSize: ?int, exception: ?\\Throwable} the deleted files/directories\n     *\n     * @throws FilesystemException\n     */\n    public function deleteOrphanedFiles(ImageRepository $repository, bool $dryRun, array $ignoredPaths): iterable;\n\n    public function compressUntilSize(string $filePath, string $extension, int $maxBytes): bool;\n}\n"
  },
  {
    "path": "src/Service/InstanceManager.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service;\n\nuse App\\DTO\\ModeratorDto;\nuse App\\Entity\\Instance;\nuse App\\Entity\\User;\nuse App\\Repository\\InstanceRepository;\nuse Doctrine\\ORM\\EntityManagerInterface;\n\nreadonly class InstanceManager\n{\n    public function __construct(\n        private EntityManagerInterface $entityManager,\n        private SettingsManager $settingsManager,\n        private InstanceRepository $instanceRepository,\n    ) {\n    }\n\n    public function addModerator(ModeratorDto $dto): void\n    {\n        $dto->user->roles = array_unique(array_merge($dto->user->roles, ['ROLE_MODERATOR']));\n\n        $this->entityManager->persist($dto->user);\n        $this->entityManager->flush();\n    }\n\n    public function removeModerator(User $user): void\n    {\n        $user->roles = array_diff($user->roles, ['ROLE_MODERATOR']);\n\n        $this->entityManager->persist($user);\n        $this->entityManager->flush();\n    }\n\n    /** @param string[] $bannedInstances */\n    #[\\Deprecated]\n    public function setBannedInstances(array $bannedInstances): void\n    {\n        $previousBannedInstances = $this->instanceRepository->getBannedInstanceUrls();\n        foreach ($bannedInstances as $instance) {\n            if (!\\in_array($instance, $previousBannedInstances, true)) {\n                $this->banInstance($this->instanceRepository->getOrCreateInstance($instance));\n            }\n        }\n        foreach ($previousBannedInstances as $instance) {\n            if (!\\in_array($instance, $bannedInstances, true)) {\n                $this->unbanInstance($this->instanceRepository->getOrCreateInstance($instance));\n            }\n        }\n    }\n\n    public function banInstance(Instance $instance): void\n    {\n        if ($this->settingsManager->getUseAllowList()) {\n            throw new \\LogicException('Cannot ban an instance when using an allow list');\n        }\n        $instance->isBanned = true;\n        $instance->isExplicitlyAllowed = false;\n\n        $this->entityManager->flush();\n    }\n\n    public function unbanInstance(Instance $instance): void\n    {\n        if ($this->settingsManager->getUseAllowList()) {\n            throw new \\LogicException('Cannot unban an instance when using an allow list');\n        }\n        $instance->isBanned = false;\n\n        $this->entityManager->flush();\n    }\n\n    public function allowInstanceFederation(Instance $instance): void\n    {\n        if (!$this->settingsManager->getUseAllowList()) {\n            throw new \\LogicException('Cannot allow instance federation when not using an allow list');\n        }\n        $instance->isExplicitlyAllowed = true;\n        $instance->isBanned = false;\n\n        $this->entityManager->flush();\n    }\n\n    public function denyInstanceFederation(Instance $instance): void\n    {\n        if (!$this->settingsManager->getUseAllowList()) {\n            throw new \\LogicException('Cannot deny instance federation when not using an allow list');\n        }\n        $instance->isExplicitlyAllowed = false;\n\n        $this->entityManager->flush();\n    }\n}\n"
  },
  {
    "path": "src/Service/InstanceStatsManager.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service;\n\nuse App\\Repository\\MagazineRepository;\nuse App\\Repository\\StatsContentRepository;\nuse App\\Repository\\UserRepository;\nuse App\\Repository\\VoteRepository;\nuse Doctrine\\Common\\Collections\\Criteria;\nuse Symfony\\Contracts\\Cache\\CacheInterface;\nuse Symfony\\Contracts\\Cache\\ItemInterface;\n\nclass InstanceStatsManager\n{\n    public function __construct(\n        private readonly UserRepository $userRepository,\n        private readonly MagazineRepository $magazineRepository,\n        private readonly StatsContentRepository $statsContentRepository,\n        private readonly VoteRepository $voteRepository,\n        private readonly CacheInterface $cache,\n    ) {\n    }\n\n    public function count(?string $period = null, bool $withFederated = false)\n    {\n        $periodDate = $period ? \\DateTimeImmutable::createFromMutable(new \\DateTime($period)) : null;\n\n        return $this->cache->get('instance_stats', function (ItemInterface $item) use ($periodDate, $withFederated) {\n            $item->expiresAfter(0);\n\n            $criteria = Criteria::create();\n\n            if ($periodDate) {\n                $criteria->where(\n                    Criteria::expr()\n                        ->gt('createdAt', $periodDate)\n                );\n            }\n\n            if (!$withFederated) {\n                if ($periodDate) {\n                    $criteria->andWhere(\n                        Criteria::expr()->eq('apId', null)\n                    );\n                } else {\n                    $criteria->where(\n                        Criteria::expr()->eq('apId', null)\n                    );\n                }\n            }\n\n            $userCriteria = clone $criteria;\n            $userCriteria->andWhere(Criteria::expr()->eq('isDeleted', false));\n\n            return [\n                'users' => $this->userRepository->matching($userCriteria)->count(),\n                'magazines' => $this->magazineRepository->matching($criteria)->count(),\n                'entries' => $this->statsContentRepository->aggregateStats('entry', $periodDate, null, $withFederated, null),\n                'comments' => $this->statsContentRepository->aggregateStats('entry_comment', $periodDate, null, $withFederated, null),\n                'posts' => $this->statsContentRepository->aggregateStats('post', $periodDate, null, $withFederated, null) +\n                    $this->statsContentRepository->aggregateStats('post_comment', $periodDate, null, $withFederated, null),\n                'votes' => $this->voteRepository->count($periodDate, $withFederated),\n            ];\n        });\n    }\n}\n"
  },
  {
    "path": "src/Service/IpResolver.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service;\n\nuse Symfony\\Component\\HttpFoundation\\RequestStack;\n\nclass IpResolver\n{\n    public function __construct(private readonly RequestStack $requestStack)\n    {\n    }\n\n    public function resolve(): ?string\n    {\n        $request = $this->requestStack->getCurrentRequest();\n        if (null === $request) {\n            return null;\n        }\n\n        if ($fastly = $request->server->get('HTTP_FASTLY_CLIENT_IP')) {\n            return $fastly;\n        }\n\n        return $request->server->get('HTTP_CF_CONNECTING_IP') ?? $request->getClientIp();\n    }\n}\n"
  },
  {
    "path": "src/Service/MagazineManager.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service;\n\nuse App\\DTO\\MagazineBanDto;\nuse App\\DTO\\MagazineDto;\nuse App\\DTO\\MagazineThemeDto;\nuse App\\DTO\\ModeratorDto;\nuse App\\Entity\\Magazine;\nuse App\\Entity\\MagazineBan;\nuse App\\Entity\\MagazineOwnershipRequest;\nuse App\\Entity\\Moderator;\nuse App\\Entity\\ModeratorRequest;\nuse App\\Entity\\User;\nuse App\\Event\\Magazine\\MagazineBanEvent;\nuse App\\Event\\Magazine\\MagazineBlockedEvent;\nuse App\\Event\\Magazine\\MagazineModeratorAddedEvent;\nuse App\\Event\\Magazine\\MagazineModeratorRemovedEvent;\nuse App\\Event\\Magazine\\MagazineSubscribedEvent;\nuse App\\Event\\Magazine\\MagazineUpdatedEvent;\nuse App\\Exception\\UserCannotBeBanned;\nuse App\\Factory\\MagazineFactory;\nuse App\\Message\\DeleteImageMessage;\nuse App\\Message\\MagazinePurgeMessage;\nuse App\\Repository\\ImageRepository;\nuse App\\Repository\\MagazineSubscriptionRepository;\nuse App\\Repository\\MagazineSubscriptionRequestRepository;\nuse App\\Service\\ActivityPub\\KeysGenerator;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Psr\\EventDispatcher\\EventDispatcherInterface;\nuse Psr\\Log\\LoggerInterface;\nuse Symfony\\Component\\HttpKernel\\Exception\\TooManyRequestsHttpException;\nuse Symfony\\Component\\Messenger\\MessageBusInterface;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Routing\\Generator\\UrlGeneratorInterface;\nuse Symfony\\Component\\Security\\Core\\Exception\\AccessDeniedException;\nuse Symfony\\Contracts\\Cache\\CacheInterface;\nuse Webmozart\\Assert\\Assert;\n\nclass MagazineManager\n{\n    public function __construct(\n        private readonly MagazineFactory $factory,\n        private readonly EventDispatcherInterface $dispatcher,\n        private readonly RateLimiterFactoryInterface $magazineLimiter,\n        private readonly CacheInterface $cache,\n        private readonly MessageBusInterface $bus,\n        private readonly EntityManagerInterface $entityManager,\n        private readonly MagazineSubscriptionRequestRepository $requestRepository,\n        private readonly MagazineSubscriptionRepository $subscriptionRepository,\n        private readonly UrlGeneratorInterface $urlGenerator,\n        private readonly ImageRepository $imageRepository,\n        private readonly SettingsManager $settingsManager,\n        private readonly LoggerInterface $logger,\n    ) {\n    }\n\n    public function create(MagazineDto $dto, ?User $user, bool $rateLimit = true): Magazine\n    {\n        if (!$dto->apId && true === $this->settingsManager->get('MBIN_RESTRICT_MAGAZINE_CREATION') && !$user->isAdmin() && !$user->isModerator()) {\n            throw new AccessDeniedException();\n        }\n\n        if ($rateLimit) {\n            $limiter = $this->magazineLimiter->create($dto->ip);\n            if (false === $limiter->consume()->isAccepted()) {\n                throw new TooManyRequestsHttpException();\n            }\n        }\n\n        $magazine = $this->factory->createFromDto($dto, $user);\n        $magazine->apId = $dto->apId;\n        $magazine->apProfileId = $dto->apProfileId;\n        $magazine->apFeaturedUrl = $dto->apFeaturedUrl;\n\n        if (!$dto->apId) {\n            $magazine = KeysGenerator::generate($magazine);\n            $magazine->apProfileId = $this->urlGenerator->generate(\n                'ap_magazine',\n                ['name' => $magazine->name],\n                UrlGeneratorInterface::ABSOLUTE_URL\n            );\n            // default new local magazines to be discoverable\n            $magazine->apDiscoverable = $dto->discoverable ?? true;\n            // default new local magazines to be indexable\n            $magazine->apIndexable = $dto->indexable ?? true;\n        }\n\n        if ($dto->nameAsTag) {\n            $magazine->tags = [$magazine->name];\n        }\n\n        $this->entityManager->persist($magazine);\n        $this->entityManager->flush();\n\n        $this->logger->debug('created magazine with name {n}, apId {id} and public url {url}', ['n' => $magazine->name, 'id' => $magazine->apId, 'url' => $magazine->apProfileId]);\n\n        if (!$dto->apId) {\n            $this->subscribe($magazine, $user);\n        }\n\n        return $magazine;\n    }\n\n    public function acceptFollow(User $user, Magazine $magazine): void\n    {\n        if ($request = $this->requestRepository->findOneby(['user' => $user, 'magazine' => $magazine])) {\n            $this->entityManager->remove($request);\n        }\n\n        if ($this->subscriptionRepository->findOneBy(['user' => $user, 'magazine' => $magazine])) {\n            return;\n        }\n\n        $this->subscribe($magazine, $user, false);\n    }\n\n    public function subscribe(Magazine $magazine, User $user, $createRequest = true): void\n    {\n        $user->unblockMagazine($magazine);\n\n        //        if ($magazine->apId && $createRequest) {\n        //            if ($this->requestRepository->findOneby(['user' => $user, 'magazine' => $magazine])) {\n        //                return;\n        //            }\n        //\n        //            $request = new MagazineSubscriptionRequest($user, $magazine);\n        //            $this->entityManager->persist($request);\n        //            $this->entityManager->flush();\n        //\n        //            $this->dispatcher->dispatch(new MagazineSubscribedEvent($magazine, $user));\n        //\n        //            return;\n        //        }\n\n        $magazine->subscribe($user);\n\n        $this->entityManager->flush();\n\n        $this->dispatcher->dispatch(new MagazineSubscribedEvent($magazine, $user));\n    }\n\n    public function edit(Magazine $magazine, MagazineDto $dto, User $editedBy): Magazine\n    {\n        Assert::same($magazine->name, $dto->name);\n\n        $magazine->title = $dto->title;\n        $magazine->description = $dto->description;\n        $magazine->rules = $dto->rules;\n        $magazine->isAdult = $dto->isAdult;\n        $magazine->postingRestrictedToMods = $dto->isPostingRestrictedToMods;\n\n        if (null !== $dto->discoverable) {\n            $magazine->apDiscoverable = $dto->discoverable;\n        }\n        if (null !== $dto->indexable) {\n            $magazine->apIndexable = $dto->indexable;\n        }\n\n        $this->entityManager->flush();\n\n        $this->dispatcher->dispatch(new MagazineUpdatedEvent($magazine, $editedBy));\n\n        return $magazine;\n    }\n\n    public function delete(Magazine $magazine): void\n    {\n        $magazine->softDelete();\n\n        $this->entityManager->flush();\n    }\n\n    public function restore(Magazine $magazine): void\n    {\n        $magazine->restore();\n\n        $this->entityManager->flush();\n    }\n\n    public function purge(Magazine $magazine, bool $contentOnly = false): void\n    {\n        $this->bus->dispatch(new MagazinePurgeMessage($magazine->getId(), $contentOnly));\n    }\n\n    public function createDto(Magazine $magazine): MagazineDto\n    {\n        return $this->factory->createDto($magazine);\n    }\n\n    public function block(Magazine $magazine, User $user): void\n    {\n        if ($magazine->isSubscribed($user)) {\n            $this->unsubscribe($magazine, $user);\n        }\n\n        $user->blockMagazine($magazine);\n\n        $this->entityManager->flush();\n\n        $this->dispatcher->dispatch(new MagazineBlockedEvent($magazine, $user));\n    }\n\n    public function unsubscribe(Magazine $magazine, User $user): void\n    {\n        $magazine->unsubscribe($user);\n\n        $this->entityManager->flush();\n\n        $this->dispatcher->dispatch(new MagazineSubscribedEvent($magazine, $user, true));\n    }\n\n    public function unblock(Magazine $magazine, User $user): void\n    {\n        $user->unblockMagazine($magazine);\n\n        $this->entityManager->flush();\n\n        $this->dispatcher->dispatch(new MagazineBlockedEvent($magazine, $user));\n    }\n\n    public function ban(Magazine $magazine, User $user, User $bannedBy, MagazineBanDto $dto): ?MagazineBan\n    {\n        if ($user->isAdmin() || $magazine->userIsModerator($user)) {\n            throw new UserCannotBeBanned();\n        }\n\n        Assert::nullOrGreaterThan($dto->expiredAt, new \\DateTimeImmutable());\n\n        $ban = $magazine->addBan($user, $bannedBy, $dto->reason, $dto->expiredAt);\n\n        if (!$ban) {\n            return null;\n        }\n\n        $this->entityManager->flush();\n\n        $this->dispatcher->dispatch(new MagazineBanEvent($ban));\n\n        return $ban;\n    }\n\n    public function unban(Magazine $magazine, User $user): ?MagazineBan\n    {\n        if (!$magazine->isBanned($user)) {\n            return null;\n        }\n\n        $ban = $magazine->unban($user);\n\n        $this->entityManager->flush();\n\n        $this->dispatcher->dispatch(new MagazineBanEvent($ban));\n\n        return $ban;\n    }\n\n    public function addModerator(ModeratorDto $dto, ?bool $isOwner = false): void\n    {\n        $magazine = $dto->magazine;\n\n        $magazine->addModerator(new Moderator($magazine, $dto->user, $dto->addedBy, $isOwner, true));\n\n        $this->entityManager->flush();\n\n        $this->clearCommentsCache($dto->user);\n        $this->dispatcher->dispatch(new MagazineModeratorAddedEvent($magazine, $dto->user, $dto->addedBy));\n    }\n\n    private function clearCommentsCache(User $user)\n    {\n        $this->cache->invalidateTags([\n            'post_comments_user_'.$user->getId(),\n            'entry_comments_user_'.$user->getId(),\n        ]);\n    }\n\n    public function removeModerator(Moderator $moderator, ?User $removedBy): void\n    {\n        $user = $moderator->user;\n\n        $this->entityManager->remove($moderator);\n        $this->entityManager->flush();\n\n        $this->clearCommentsCache($user);\n        $this->dispatcher->dispatch(new MagazineModeratorRemovedEvent($moderator->magazine, $moderator->user, $removedBy));\n    }\n\n    public function changeTheme(MagazineThemeDto $dto): Magazine\n    {\n        $magazine = $dto->magazine;\n\n        if ($dto->icon && $magazine->icon?->getId() !== $dto->icon->id) {\n            $magazine->icon = $this->imageRepository->find($dto->icon->id);\n        }\n\n        if ($dto->banner && $magazine->banner?->getId() !== $dto->banner->id) {\n            $magazine->banner = $this->imageRepository->find($dto->banner->id);\n        }\n\n        // custom css\n        $customCss = $dto->customCss;\n\n        // add custom background to custom CSS if defined\n        $background = null;\n        if ($dto->backgroundImage) {\n            $background = match ($dto->backgroundImage) {\n                'shape1' => '/build/images/shape.png',\n                'shape2' => '/build/images/shape2.png',\n                default => null,\n            };\n\n            $background = $background ? \"#middle { background: url($background); height: 100%; }\" : null;\n            if ($background) {\n                $customCss = \\sprintf('%s %s', $customCss, $background);\n            }\n        }\n\n        $magazine->customCss = $customCss;\n        $this->entityManager->persist($magazine);\n        $this->entityManager->flush();\n\n        return $magazine;\n    }\n\n    public function detachIcon(Magazine $magazine): void\n    {\n        if (!$magazine->icon) {\n            return;\n        }\n\n        $image = $magazine->icon->getId();\n\n        $magazine->icon = null;\n\n        $this->entityManager->persist($magazine);\n        $this->entityManager->flush();\n\n        $this->bus->dispatch(new DeleteImageMessage($image));\n    }\n\n    public function detachBanner(Magazine $magazine): void\n    {\n        if (!$magazine->banner) {\n            return;\n        }\n\n        $image = $magazine->banner->getId();\n\n        $magazine->banner = null;\n        $this->entityManager->persist($magazine);\n        $this->entityManager->flush();\n        $this->bus->dispatch(new DeleteImageMessage($image));\n    }\n\n    public function removeSubscriptions(Magazine $magazine): void\n    {\n        foreach ($magazine->subscriptions as $subscription) {\n            $this->unsubscribe($subscription->magazine, $subscription->user);\n        }\n    }\n\n    public function toggleOwnershipRequest(Magazine $magazine, User $user): void\n    {\n        $request = $this->entityManager->getRepository(MagazineOwnershipRequest::class)->findOneBy([\n            'magazine' => $magazine,\n            'user' => $user,\n        ]);\n\n        if ($request) {\n            $this->entityManager->remove($request);\n            $this->entityManager->flush();\n\n            return;\n        }\n\n        $request = new MagazineOwnershipRequest($magazine, $user);\n\n        $this->entityManager->persist($request);\n        $this->entityManager->flush();\n    }\n\n    public function acceptOwnershipRequest(Magazine $magazine, User $user, ?User $addedBy): void\n    {\n        $owner = $magazine->getOwnerModerator();\n        if ($owner) {\n            $this->removeModerator($owner, $addedBy);\n        }\n\n        $this->addModerator(new ModeratorDto($magazine, $user, $addedBy), true);\n\n        $request = $this->entityManager->getRepository(MagazineOwnershipRequest::class)->findOneBy([\n            'magazine' => $magazine,\n            'user' => $user,\n        ]);\n\n        $this->entityManager->remove($request);\n        $this->entityManager->flush();\n    }\n\n    public function userRequestedOwnership(Magazine $magazine, User $user): bool\n    {\n        $ownerRequest = $this->entityManager->getRepository(MagazineOwnershipRequest::class)->findOneBy([\n            'magazine' => $magazine,\n            'user' => $user,\n        ]);\n\n        return null !== $ownerRequest;\n    }\n\n    /**\n     * @return MagazineOwnershipRequest[]\n     */\n    public function listOwnershipRequests(?Magazine $magazine): array\n    {\n        if ($magazine) {\n            return $this->entityManager->getRepository(MagazineOwnershipRequest::class)->findBy([\n                'magazine' => $magazine,\n            ]);\n        } else {\n            return $this->entityManager->getRepository(MagazineOwnershipRequest::class)->findAll();\n        }\n    }\n\n    public function toggleModeratorRequest(Magazine $magazine, User $user): void\n    {\n        $request = $this->entityManager->getRepository(ModeratorRequest::class)->findOneBy([\n            'magazine' => $magazine,\n            'user' => $user,\n        ]);\n\n        if ($request) {\n            $this->entityManager->remove($request);\n            $this->entityManager->flush();\n\n            return;\n        }\n\n        $request = new ModeratorRequest($magazine, $user);\n\n        $this->entityManager->persist($request);\n        $this->entityManager->flush();\n    }\n\n    public function acceptModeratorRequest(Magazine $magazine, User $user, ?User $addedBy): void\n    {\n        $this->addModerator(new ModeratorDto($magazine, $user, $addedBy));\n\n        $request = $this->entityManager->getRepository(ModeratorRequest::class)->findOneBy([\n            'magazine' => $magazine,\n            'user' => $user,\n        ]);\n\n        $this->entityManager->remove($request);\n        $this->entityManager->flush();\n    }\n\n    public function userRequestedModerator(Magazine $magazine, User $user): bool\n    {\n        $modRequest = $this->entityManager->getRepository(ModeratorRequest::class)->findOneBy([\n            'magazine' => $magazine,\n            'user' => $user,\n        ]);\n\n        return null !== $modRequest;\n    }\n\n    /**\n     * @return ModeratorRequest[]\n     */\n    public function listModeratorRequests(?Magazine $magazine): array\n    {\n        if ($magazine) {\n            return $this->entityManager->getRepository(ModeratorRequest::class)->findBy([\n                'magazine' => $magazine,\n            ]);\n        } else {\n            return $this->entityManager->getRepository(ModeratorRequest::class)->findAll();\n        }\n    }\n}\n"
  },
  {
    "path": "src/Service/MentionManager.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service;\n\nuse App\\Entity\\Contracts\\ActivityPubActivityInterface;\nuse App\\Entity\\EntryComment;\nuse App\\Entity\\PostComment;\nuse App\\Entity\\User;\nuse App\\Repository\\UserRepository;\nuse App\\Utils\\RegPatterns;\n\nclass MentionManager\n{\n    public const ALL = 1;\n    public const LOCAL = 2;\n    public const REMOTE = 3;\n\n    public function __construct(\n        private readonly UserRepository $userRepository,\n        private readonly SettingsManager $settingsManager,\n    ) {\n    }\n\n    /**\n     * @return User[]\n     */\n    public function getUsersFromArray(?array $users): array\n    {\n        if ($users) {\n            return $this->userRepository->findByUsernames($users);\n        }\n\n        return [];\n    }\n\n    public function handleChain(ActivityPubActivityInterface $activity): array\n    {\n        $subject = match (true) {\n            $activity instanceof EntryComment => $activity->parent ?? $activity->entry,\n            $activity instanceof PostComment => $activity->parent ?? $activity->post,\n            default => throw new \\LogicException(),\n        };\n\n        $activity->mentions = array_unique(\n            array_merge($activity->mentions ?? [], $this->extract($activity->body) ?? [])\n        );\n\n        $subjectActor = ['@'.ltrim($subject->user->username, '@')];\n\n        $result = array_unique(\n            array_merge(\n                empty($subject->mentions) ? [] : $subject->mentions,\n                empty($activity->mentions) ? [] : $activity->mentions,\n                $subjectActor\n            )\n        );\n\n        $result = array_filter(\n            $result,\n            function ($val) {\n                preg_match(RegPatterns::LOCAL_USER, $val, $l);\n\n                return preg_match(RegPatterns::AP_USER, $val) || $val === ($l[0] ?? '');\n            }\n        );\n\n        return array_filter(\n            $result,\n            fn ($val) => !\\in_array(\n                $val,\n                [\n                    '@'.$activity->user->username,\n                    '@'.$activity->user->username.'@'.$this->settingsManager->get('KBIN_DOMAIN'),\n                ]\n            )\n        );\n    }\n\n    /**\n     * Try to extract mentions from the body (eg. @username@domain.tld).\n     *\n     * @param val Body input string\n     * @param type Type of mentions to extract (ALL, LOCAL only or REMOTE only)\n     *\n     * @return string[]\n     */\n    public function extract(?string $body, $type = self::ALL): ?array\n    {\n        if (!$body) {\n            return null;\n        }\n\n        $result = match ($type) {\n            self::ALL => array_merge($this->byApPrefix($body), $this->byPrefix($body)),\n            self::LOCAL => $this->byPrefix($body),\n            self::REMOTE => $this->byApPrefix($body),\n        };\n\n        $result = array_map(fn ($val) => trim($val), $result);\n\n        return \\count($result) ? array_unique($result) : null;\n    }\n\n    /**\n     * Remote activitypub prefix, like @username@domain.tld.\n     *\n     * @param value Input string\n     *\n     * @return string[]\n     */\n    private function byApPrefix(string $value): array\n    {\n        preg_match_all(RegPatterns::REMOTE_USER_REGEX, $value, $matches);\n\n        return \\count($matches[0]) ? array_unique(array_values($matches[0])) : [];\n    }\n\n    /**\n     * Local username prefix, like @username.\n     *\n     * @param value Input string\n     *\n     * @return string[]\n     */\n    private function byPrefix(string $value): array\n    {\n        preg_match_all(RegPatterns::LOCAL_USER_REGEX, $value, $matches);\n        $results = array_filter($matches[0], fn ($val) => !str_ends_with($val, '@'));\n\n        return \\count($results) ? array_unique(array_values($results)) : [];\n    }\n\n    public function joinMentionsToBody(string $body, array $mentions): string\n    {\n        $current = $this->extract($body) ?? [];\n        $current = self::addHandle($current);\n        $mentions = self::addHandle($mentions);\n\n        $join = array_unique(array_merge(array_diff($mentions, $current)));\n\n        if (!empty($join)) {\n            $body .= PHP_EOL.PHP_EOL.implode(' ', $join);\n        }\n\n        return $body;\n    }\n\n    public function addHandle(array $mentions): array\n    {\n        $res = array_map(\n            fn ($val) => 0 === substr_count($val, '@') ? '@'.$val : $val,\n            $mentions\n        );\n\n        return array_map(\n            fn ($val) => substr_count($val, '@') < 2 ? $val.'@'.$this->settingsManager->get('KBIN_DOMAIN') : $val,\n            $res\n        );\n    }\n\n    public function getUsername(string $value, ?bool $withApPostfix = false): string\n    {\n        $value = $this->addHandle([$value])[0];\n\n        if (true === $withApPostfix) {\n            return $value;\n        }\n\n        return explode('@', $value)[1];\n    }\n\n    public function getDomain(string $value): string\n    {\n        if (str_starts_with($value, '@')) {\n            $value = substr($value, 1);\n        }\n        $parts = explode('@', $value);\n        if (\\count($parts) < 2) {\n            return $this->settingsManager->get('KBIN_DOMAIN');\n        } else {\n            return $parts[1];\n        }\n    }\n\n    public function clearLocal(?array $mentions): array\n    {\n        if (null === $mentions) {\n            return [];\n        }\n\n        $domain = '@'.$this->settingsManager->get('KBIN_DOMAIN');\n\n        $mentions = array_map(fn ($val) => preg_replace('/'.preg_quote($domain, '/').'$/', '', $val), $mentions);\n\n        $mentions = array_map(fn ($val) => ltrim($val, '@'), $mentions);\n\n        return array_filter($mentions, fn ($val) => !str_contains($val, '@'));\n    }\n\n    public function getRoute(?array $mentions): array\n    {\n        if (null === $mentions) {\n            return [];\n        }\n\n        $domain = '@'.$this->settingsManager->get('KBIN_DOMAIN');\n\n        $mentions = array_map(fn ($val) => preg_replace('/'.preg_quote($domain, '/').'$/', '', $val), $mentions);\n\n        $mentions = array_map(fn ($val) => ltrim($val, '@'), $mentions);\n\n        return array_map(fn ($val) => ltrim($val, '@'), $mentions);\n    }\n}\n"
  },
  {
    "path": "src/Service/MessageManager.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service;\n\nuse App\\DTO\\MessageDto;\nuse App\\Entity\\Message;\nuse App\\Entity\\MessageThread;\nuse App\\Entity\\User;\nuse App\\Exception\\InvalidApPostException;\nuse App\\Exception\\InvalidWebfingerException;\nuse App\\Exception\\UserBlockedException;\nuse App\\Exception\\UserCannotReceiveDirectMessage;\nuse App\\Exception\\UserDeletedException;\nuse App\\Message\\ActivityPub\\Outbox\\CreateMessage;\nuse App\\Repository\\MessageThreadRepository;\nuse Doctrine\\DBAL\\Exception;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Psr\\Cache\\InvalidArgumentException;\nuse Psr\\Log\\LoggerInterface;\nuse Symfony\\Component\\Messenger\\MessageBusInterface;\n\nclass MessageManager\n{\n    public function __construct(\n        private readonly MessageThreadRepository $messageThreadRepository,\n        private readonly MessageBusInterface $bus,\n        private readonly ActivityPubManager $activityPubManager,\n        private readonly NotificationManager $notificationManager,\n        private readonly EntityManagerInterface $entityManager,\n        private readonly LoggerInterface $logger,\n    ) {\n    }\n\n    public function toThread(MessageDto $dto, User $sender, User ...$receivers): MessageThread\n    {\n        $thread = new MessageThread($sender, ...$receivers);\n        $thread->addMessage($this->toMessage($dto, $thread, $sender));\n\n        $this->entityManager->persist($thread);\n        $this->entityManager->flush();\n\n        return $thread;\n    }\n\n    public function toMessage(MessageDto $dto, MessageThread $thread, User $sender): Message\n    {\n        if ($sender->isDeleted || $sender->isTrashed() || $sender->isSoftDeleted()) {\n            throw new UserDeletedException();\n        }\n        $message = new Message($thread, $sender, $dto->body, $dto->apId);\n\n        foreach ($thread->participants as $participant) {\n            if ($sender->getId() !== $participant->getId()) {\n                if ($participant->isBlocked($sender)) {\n                    throw new UserBlockedException();\n                }\n            }\n        }\n\n        $thread->setUpdatedAt();\n\n        $this->entityManager->persist($thread);\n        $this->entityManager->flush();\n\n        $this->notificationManager->sendMessageNotification($message, $sender);\n        $this->bus->dispatch(new CreateMessage($message->getId(), Message::class));\n\n        return $message;\n    }\n\n    public function readMessages(MessageThread $thread, User $user): void\n    {\n        foreach ($thread->getNewMessages($user) as $message) {\n            /*\n             * @var Message $message\n             */\n            $this->readMessage($message, $user);\n        }\n\n        $this->entityManager->flush();\n    }\n\n    public function readMessage(Message $message, User $user, bool $flush = false): void\n    {\n        $message->status = Message::STATUS_READ;\n\n        $this->notificationManager->readMessageNotification($message, $user);\n\n        if ($flush) {\n            $this->entityManager->flush();\n        }\n    }\n\n    public function unreadMessage(Message $message, User $user, bool $flush = false): void\n    {\n        $message->status = Message::STATUS_NEW;\n\n        $this->notificationManager->unreadMessageNotification($message, $user);\n\n        if ($flush) {\n            $this->entityManager->flush();\n        }\n    }\n\n    public function canUserEditMessage(Message $message, User $user): bool\n    {\n        return $message->sender->apId === $user->apId || $message->sender->apDomain === $user->apDomain;\n    }\n\n    /**\n     * @throws InvalidApPostException\n     * @throws UserBlockedException\n     * @throws InvalidArgumentException\n     * @throws InvalidWebfingerException\n     * @throws Exception\n     * @throws UserDeletedException\n     * @throws UserCannotReceiveDirectMessage\n     */\n    public function createMessage(array $object): Message|MessageThread\n    {\n        $this->logger->debug('creating message from {o}', ['o' => $object]);\n        $obj_to = \\App\\Utils\\JsonldUtils::getArrayValue($object, 'to');\n        $obj_cc = \\App\\Utils\\JsonldUtils::getArrayValue($object, 'cc');\n        $participantIds = array_merge($obj_to, $obj_cc);\n        $participants = array_map(fn ($participant) => $this->activityPubManager->findActorOrCreate(\\is_string($participant) ? $participant : $participant['id']), $participantIds);\n        $author = $this->activityPubManager->findActorOrCreate($object['attributedTo']);\n\n        if ($author->isDeleted || $author->isTrashed() || $author->isSoftDeleted()) {\n            throw new UserDeletedException();\n        }\n\n        foreach ($participants as $participant) {\n            if ($participant->isBlocked($author)) {\n                throw new UserBlockedException();\n            }\n            if (!$participant->canReceiveDirectMessage($author)) {\n                throw new UserCannotReceiveDirectMessage($author, $participant);\n            }\n        }\n\n        $participants[] = $author;\n        $message = new MessageDto();\n        $message->body = $this->activityPubManager->extractMarkdownContent($object);\n        $message->apId = $object['id'] ?? null;\n        $threads = $this->messageThreadRepository->findByParticipants($participants);\n        if (\\sizeof($threads) > 0) {\n            return $this->toMessage($message, $threads[0], $author);\n        } else {\n            return $this->toThread($message, $author, ...$participants);\n        }\n    }\n\n    public function editMessage(Message $message, array $object): void\n    {\n        $this->logger->debug('editing message {m}', ['m' => $message->apId]);\n        $newBody = $this->activityPubManager->extractMarkdownContent($object);\n        if ($message->body !== $newBody) {\n            $message->body = $newBody;\n            $message->editedAt = new \\DateTimeImmutable();\n            $this->entityManager->persist($message);\n            $this->entityManager->flush();\n        }\n    }\n\n    /** @return string[] */\n    public function findAudience(MessageThread $thread): array\n    {\n        $res = [];\n        foreach ($thread->participants as /* @var User $participant */ $participant) {\n            if ($participant->apId && !$participant->isDeleted && !$participant->isBanned) {\n                $res[] = $participant->apInboxUrl;\n            }\n        }\n\n        return array_unique($res);\n    }\n}\n"
  },
  {
    "path": "src/Service/Monitor.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service;\n\nuse App\\Entity\\MonitoringCurlRequest;\nuse App\\Entity\\MonitoringExecutionContext;\nuse App\\Entity\\MonitoringQuery;\nuse App\\Entity\\MonitoringQueryString;\nuse App\\Entity\\MonitoringTwigRender;\nuse App\\Entity\\Traits\\MonitoringPerformanceTrait;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Psr\\Log\\LoggerInterface;\n\nclass Monitor\n{\n    public ?MonitoringExecutionContext $currentContext = null;\n    protected array $contexts = [];\n\n    protected array $contextSegments = [];\n    protected array $oldContextSegments = [];\n    protected ?MonitoringQuery $currentQuery = null;\n    protected ?MonitoringCurlRequest $currentCurlRequest = null;\n\n    /**\n     * @var array<array{level: int, render: MonitoringTwigRender}>\n     */\n    protected array $runningTwigTemplates = [];\n    protected ?float $startSendingResponseTime = null;\n    protected ?float $endSendingResponseTime = null;\n\n    public function __construct(\n        protected readonly EntityManagerInterface $entityManager,\n        protected readonly LoggerInterface $logger,\n        private readonly bool $monitoringEnabled,\n        private readonly bool $monitoringQueryParametersEnabled,\n        private readonly bool $monitoringQueriesEnabled,\n        private readonly bool $monitoringQueriesPersistingEnabled,\n        private readonly bool $monitoringTwigRendersEnabled,\n        private readonly bool $monitoringTwigRendersPersistingEnabled,\n        private readonly bool $monitoringCurlRequestsEnabled,\n        private readonly bool $monitoringCurlRequestPersistingEnabled,\n    ) {\n    }\n\n    public function shouldRecord(): bool\n    {\n        return $this->monitoringEnabled;\n    }\n\n    public function shouldRecordTwigRenders(): bool\n    {\n        return $this->shouldRecord() && $this->monitoringTwigRendersEnabled;\n    }\n\n    public function shouldRecordQueries(): bool\n    {\n        return $this->shouldRecord() && $this->monitoringQueriesEnabled;\n    }\n\n    public function shouldRecordCurlRequests(): bool\n    {\n        return $this->shouldRecord() && $this->monitoringCurlRequestsEnabled;\n    }\n\n    /**\n     * @param string $executionType 'request'|'messenger'\n     * @param string $userType      'anonymous'|'user'|'activity_pub'|'ajax'\n     * @param string $path          the path or the message class\n     * @param string $handler       the controller or the message handler\n     */\n    public function startNewExecutionContext(string $executionType, string $userType, string $path, string $handler): void\n    {\n        $context = new MonitoringExecutionContext();\n        $context->executionType = $executionType;\n        $context->path = $path;\n        $context->userType = $userType;\n        $context->handler = $handler;\n        $context->setStartedAt();\n\n        $this->contexts[] = $context;\n        $this->currentContext = $context;\n        $this->oldContextSegments = array_merge($this->oldContextSegments, $this->contextSegments);\n        $this->contextSegments = [];\n        $this->logger->debug('[Monitor] Starting a new execution context, type: {executionType}, user: {user}, path: {path}, handler: {handler}', [\n            'executionType' => $executionType,\n            'user' => $userType,\n            'path' => $path,\n            'handler' => $handler,\n        ]);\n        $this->logger->debug('[Monitor] queries: {queries}, twig: {twig}, curl: {curl}', [\n            'queries' => $this->monitoringQueriesEnabled,\n            'twig' => $this->monitoringTwigRendersEnabled,\n            'curl' => $this->monitoringCurlRequestsEnabled,\n        ]);\n    }\n\n    public function endCurrentExecutionContext(?int $statusCode = null, ?string $exception = null, ?string $stacktrace = null): void\n    {\n        if (null === $this->currentContext) {\n            $this->logger->error('[Monitor] Trying to end a context, but the current one is null');\n\n            return;\n        }\n\n        $this->currentContext->setEndedAt();\n        $this->currentContext->setDuration();\n        if (null !== $statusCode) {\n            $this->currentContext->statusCode = $statusCode;\n        }\n        if (null !== $exception) {\n            $this->currentContext->exception = $exception;\n        }\n        if (null !== $stacktrace) {\n            $this->currentContext->stacktrace = $stacktrace;\n        }\n\n        $this->logger->debug('[Monitor] Ending an new execution context, type: {executionType}, user: {user}, path: {path}, handler: {handler}, status code: {statusCode}, exception: {exception}, stacktrace: {stacktrace}', [\n            'executionType' => $this->currentContext->executionType,\n            'user' => $this->currentContext->userType,\n            'path' => $this->currentContext->path,\n            'handler' => $this->currentContext->handler,\n            'statusCode' => $this->currentContext->statusCode,\n            'exception' => $this->currentContext->exception,\n            'stacktrace' => $this->currentContext->stacktrace,\n        ]);\n\n        $queryDuration = 0;\n        $twigDuration = 0;\n        $curlDuration = 0;\n\n        foreach ($this->contextSegments as $contextSegment) {\n            if ($contextSegment instanceof MonitoringQuery) {\n                $queryDuration += $contextSegment->getDuration();\n            } elseif ($contextSegment instanceof MonitoringTwigRender && null === $contextSegment->parent) {\n                $twigDuration += $contextSegment->getDuration();\n            } elseif ($contextSegment instanceof MonitoringCurlRequest) {\n                $curlDuration += $contextSegment->getDuration();\n            }\n        }\n\n        $this->currentContext->queryDurationMilliseconds = $queryDuration;\n        $this->currentContext->twigRenderDurationMilliseconds = $twigDuration;\n        $this->currentContext->curlRequestDurationMilliseconds = $curlDuration;\n\n        try {\n            $this->entityManager->persist($this->currentContext);\n            $queryStringRepo = $this->entityManager->getRepository(MonitoringQueryString::class);\n            $queryStringsByHash = [];\n            foreach ($this->contextSegments as $contextSegment) {\n                if ($contextSegment instanceof MonitoringQuery) {\n                    if (!$this->monitoringQueriesPersistingEnabled) {\n                        continue;\n                    }\n                    // we don't want to compute hashes during event listening, as even sha1 will be a bit time-consuming\n                    $hash = hash('sha1', $contextSegment->queryString->query);\n                    if (\\array_key_exists($hash, $queryStringsByHash)) {\n                        $contextSegment->queryString = $queryStringsByHash[$hash];\n                    }\n                    $queryString = $queryStringRepo->find($hash);\n                    if (null !== $queryString) {\n                        $queryStringsByHash[$hash] = $queryString;\n                        $contextSegment->queryString = $queryString;\n                    } else {\n                        // not in cache and not in DB -> persist new entity\n                        $queryStringsByHash[$hash] = $contextSegment->queryString;\n                        $contextSegment->queryString->queryHash = $hash;\n                        $this->entityManager->persist($contextSegment->queryString);\n                    }\n                } elseif ($contextSegment instanceof MonitoringTwigRender) {\n                    if (!$this->monitoringTwigRendersPersistingEnabled) {\n                        continue;\n                    }\n                } elseif ($contextSegment instanceof MonitoringCurlRequest) {\n                    if (!$this->monitoringCurlRequestPersistingEnabled) {\n                        continue;\n                    }\n                }\n                $this->entityManager->persist($contextSegment);\n            }\n            $this->entityManager->flush();\n            $this->currentContext = null;\n        } catch (\\Throwable $exception) {\n            $this->logger->critical('[Monitor] Error during context processing: {m}', [\n                'm' => $exception->getMessage(),\n                'exception' => $exception,\n            ]);\n        }\n    }\n\n    public function cancelCurrentExecutionContext(): void\n    {\n        $this->contexts = array_filter($this->contexts, fn (MonitoringExecutionContext $context) => $this->currentContext !== $context);\n        $this->currentContext = null;\n        $this->contextSegments = [];\n    }\n\n    public function startQuery(string $sql, ?array $parameters = null): void\n    {\n        if (null === $this->currentContext) {\n            $this->logger->error('[Monitor] Trying to start a query, but the current context is null');\n\n            return;\n        }\n        if (null !== $this->currentQuery) {\n            $this->logger->error('[Monitor] Trying to start a query, but another one is still running');\n\n            return;\n        }\n        $this->logger->debug('[Monitor] starting a query');\n        $queryString = new MonitoringQueryString();\n        $queryString->query = $sql;\n        $this->currentQuery = new MonitoringQuery();\n        $this->currentQuery->setStartedAt();\n        $this->currentQuery->queryString = $queryString;\n        if ($this->monitoringQueryParametersEnabled) {\n            $this->currentQuery->parameters = $parameters;\n        }\n        $this->currentQuery->context = $this->currentContext;\n    }\n\n    public function endQuery(): void\n    {\n        if (null === $this->currentQuery) {\n            $this->logger->error('[Monitor] Trying to end a query, but the current one is null');\n\n            return;\n        }\n        $this->logger->debug('[Monitor] ending a query');\n        $this->currentQuery->setEndedAt();\n        $this->currentQuery->setDuration();\n        if ($this->monitoringQueryParametersEnabled) {\n            $this->currentQuery->cleanParameterArray();\n        }\n        $this->contextSegments[] = $this->currentQuery;\n        $this->currentQuery = null;\n    }\n\n    public function startTwigRendering(string $templateName, string $type): void\n    {\n        if (\\array_key_exists($templateName, $this->runningTwigTemplates)) {\n            $this->logger->error('[Monitor] Trying to start a twig render which is already running ({name})', ['name' => $templateName]);\n\n            return;\n        }\n        $this->logger->debug('[Monitor] Starting a twig render of {name}, {type} at level {level}', ['name' => $templateName, 'type' => $type, 'level' => \\sizeof($this->runningTwigTemplates)]);\n\n        $render = new MonitoringTwigRender();\n        $render->templateName = $templateName;\n        $render->context = $this->currentContext;\n        $render->shortDescription = $templateName;\n        $render->setStartedAt();\n\n        $maxLevel = 0;\n        $parent = null;\n        foreach ($this->runningTwigTemplates as $obj) {\n            if ($obj['level'] > $maxLevel) {\n                $maxLevel = $obj['level'];\n                $parent = $obj['render'];\n            }\n        }\n\n        if (null !== $parent) {\n            $render->parent = $parent;\n        }\n\n        $this->runningTwigTemplates[] = [\n            'level' => $maxLevel + 1,\n            'render' => $render,\n        ];\n    }\n\n    public function endTwigRendering(string $templateName, ?int $memoryUsage, ?int $peakMemoryUsage, ?string $name, ?string $type, ?float $profilerDuration): void\n    {\n        if (0 === \\sizeof($this->runningTwigTemplates)) {\n            $this->logger->error('[Monitor] Trying to end a twig render but none have been started ({name})', ['name' => $templateName]);\n\n            return;\n        }\n        $this->logger->debug('[Monitor] Ending a twig render of {name}', ['name' => $templateName]);\n\n        $lastTemplate = array_pop($this->runningTwigTemplates);\n        /** @var MonitoringTwigRender $render */\n        $render = $lastTemplate['render'];\n\n        if ($templateName !== $render->templateName) {\n            $this->logger->warning('[Monitor] the popped twig render has a different template name than the one that should be ended: {name} !== {renderTemplateName}', ['name' => $templateName, 'renderTemplateName' => $render->templateName]);\n        }\n\n        $render->setEndedAt();\n        $render->setDuration();\n        $render->memoryUsage = $memoryUsage;\n        $render->peakMemoryUsage = $peakMemoryUsage;\n        $render->name = $name;\n        $render->type = $type;\n        $render->profilerDuration = $profilerDuration;\n\n        $this->contextSegments[] = $render;\n    }\n\n    public function startCurlRequest(string $targetUrl, string $method): void\n    {\n        if (null === $this->currentContext) {\n            $this->logger->error('[Monitor] Trying to start a curl request, but the current context is null');\n\n            return;\n        }\n        if (null !== $this->currentCurlRequest) {\n            $this->logger->warning('[Monitor] Trying to start a curl request, but another one is running');\n\n            return;\n        }\n        $this->logger->debug('[Monitor] Starting a curl request of {method} - {url}', ['method' => $method, 'url' => $targetUrl]);\n\n        $this->currentCurlRequest = new MonitoringCurlRequest();\n        $this->currentCurlRequest->url = $targetUrl;\n        $this->currentCurlRequest->method = $method;\n        $this->currentCurlRequest->context = $this->currentContext;\n        $this->currentCurlRequest->setStartedAt();\n    }\n\n    public function endCurlRequest(string $url, bool $wasSuccessful, ?\\Throwable $exception): void\n    {\n        if (null === $this->currentContext) {\n            $this->logger->error('[Monitor] Trying to end a curl request, but the current context is null');\n\n            return;\n        }\n        if (null === $this->currentCurlRequest) {\n            $this->logger->warning('[Monitor] Trying to end a curl request, but the current request is null');\n\n            return;\n        }\n        if ($this->currentCurlRequest->url !== $url) {\n            // should never occur, as php is single threaded\n            $this->logger->warning('[Monitor] Trying to end a curl request, but the current request is using another URL: {u1} !== {u2}', ['u1' => $this->currentCurlRequest->url, 'u2' => $url]);\n\n            return;\n        }\n        $this->logger->debug('[Monitor] Ending a curl request of {url}, was successful: {success}', ['url' => $url, 'success' => $wasSuccessful]);\n\n        $this->currentCurlRequest->setEndedAt();\n        $this->currentCurlRequest->setDuration();\n        $this->currentCurlRequest->wasSuccessful = $wasSuccessful;\n        if (null !== $exception) {\n            $this->currentCurlRequest->exception = \\get_class($exception).\": {$exception->getMessage()}\";\n        }\n        $this->contextSegments[] = $this->currentCurlRequest;\n        $this->currentCurlRequest = null;\n    }\n\n    /**\n     * @param iterable<MonitoringPerformanceTrait> $collection\n     */\n    protected function calculateDurationFromCollection(iterable $collection): float\n    {\n        $duration = 0;\n        foreach ($collection as $item) {\n            $duration += $item->getDuration();\n        }\n\n        return $duration;\n    }\n\n    public function startSendingResponse(): void\n    {\n        if (null === $this->currentContext || 'response' !== $this->currentContext->executionType) {\n            $this->startSendingResponseTime = null;\n\n            return;\n        }\n        $this->startSendingResponseTime = microtime(true);\n    }\n\n    public function endSendingResponse(): void\n    {\n        if (null === $this->currentContext || 'response' !== $this->currentContext->executionType || null === $this->startSendingResponseTime) {\n            $this->endSendingResponseTime = null;\n\n            return;\n        }\n        $this->endSendingResponseTime = microtime(true);\n        $this->currentContext->responseSendingDurationMilliseconds = ($this->endSendingResponseTime - $this->startSendingResponseTime) * 1000;\n    }\n}\n"
  },
  {
    "path": "src/Service/MonologFilterHandler.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service;\n\nuse Monolog\\Handler\\AbstractHandler;\nuse Monolog\\LogRecord;\n\nclass MonologFilterHandler extends AbstractHandler\n{\n    private const array TO_IGNORE = [\n        'User Deprecated: Since symfony/http-foundation 7.4: Request::get() is deprecated, ',\n    ];\n\n    public function isHandling(LogRecord $record): bool\n    {\n        return true;\n    }\n\n    public function handle(LogRecord $record): bool\n    {\n        return $this->shouldFilter($record);\n    }\n\n    private function shouldFilter(LogRecord $record): bool\n    {\n        foreach (self::TO_IGNORE as $str) {\n            if (str_contains($record->message, $str)) {\n                return true;\n            }\n        }\n\n        return false;\n    }\n}\n"
  },
  {
    "path": "src/Service/Notification/EntryCommentNotificationManager.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service\\Notification;\n\nuse App\\Entity\\Contracts\\ContentInterface;\nuse App\\Entity\\EntryComment;\nuse App\\Entity\\EntryCommentCreatedNotification;\nuse App\\Entity\\EntryCommentDeletedNotification;\nuse App\\Entity\\EntryCommentEditedNotification;\nuse App\\Entity\\EntryCommentMentionedNotification;\nuse App\\Entity\\EntryCommentReplyNotification;\nuse App\\Entity\\Notification;\nuse App\\Event\\NotificationCreatedEvent;\nuse App\\Factory\\MagazineFactory;\nuse App\\Factory\\UserFactory;\nuse App\\Repository\\MagazineLogRepository;\nuse App\\Repository\\MagazineSubscriptionRepository;\nuse App\\Repository\\NotificationRepository;\nuse App\\Repository\\NotificationSettingsRepository;\nuse App\\Repository\\UserRepository;\nuse App\\Service\\Contracts\\ContentNotificationManagerInterface;\nuse App\\Service\\GenerateHtmlClassService;\nuse App\\Service\\ImageManager;\nuse App\\Service\\ImageManagerInterface;\nuse App\\Service\\MentionManager;\nuse App\\Service\\SettingsManager;\nuse App\\Utils\\IriGenerator;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Symfony\\Component\\EventDispatcher\\EventDispatcherInterface;\nuse Symfony\\Component\\Mercure\\HubInterface;\nuse Symfony\\Component\\Mercure\\Update;\nuse Symfony\\Component\\Routing\\Generator\\UrlGeneratorInterface;\nuse Twig\\Environment;\n\nclass EntryCommentNotificationManager implements ContentNotificationManagerInterface\n{\n    use NotificationTrait;\n\n    public function __construct(\n        private readonly EventDispatcherInterface $eventDispatcher,\n        private readonly MentionManager $mentionManager,\n        private readonly NotificationRepository $notificationRepository,\n        private readonly MagazineLogRepository $magazineLogRepository,\n        private readonly MagazineSubscriptionRepository $magazineRepository,\n        private readonly MagazineFactory $magazineFactory,\n        private readonly UserFactory $userFactory,\n        private readonly HubInterface $publisher,\n        private readonly Environment $twig,\n        private readonly UrlGeneratorInterface $urlGenerator,\n        private readonly EntityManagerInterface $entityManager,\n        private readonly ImageManagerInterface $imageManager,\n        private readonly GenerateHtmlClassService $classService,\n        private readonly SettingsManager $settingsManager,\n        private readonly NotificationSettingsRepository $notificationSettingsRepository,\n        private readonly UserRepository $userRepository,\n    ) {\n    }\n\n    public function sendCreated(ContentInterface $subject): void\n    {\n        if ($subject->user->isBanned || $subject->user->isDeleted || $subject->user->isTrashed() || $subject->user->isSoftDeleted()) {\n            return;\n        }\n        if (!$subject instanceof EntryComment) {\n            throw new \\LogicException();\n        }\n        $comment = $subject;\n\n        $mentioned = $this->sendMentionedNotification($comment);\n\n        $this->notifyMagazine(new EntryCommentCreatedNotification($comment->user, $comment));\n\n        $userIdsToNotify = $this->notificationSettingsRepository->findNotificationSubscribersByTarget($comment);\n        $usersToNotify = $this->userRepository->findBy(['id' => $userIdsToNotify]);\n\n        if (\\count($mentioned)) {\n            $usersToNotify = array_filter($usersToNotify, fn ($user) => !\\in_array($user, $mentioned));\n        }\n\n        foreach ($usersToNotify as $subscriber) {\n            if (null !== $comment->parent && $comment->parent->isAuthor($subscriber)) {\n                $notification = new EntryCommentReplyNotification($subscriber, $comment);\n            } else {\n                $notification = new EntryCommentCreatedNotification($subscriber, $comment);\n            }\n            $this->entityManager->persist($notification);\n            $this->eventDispatcher->dispatch(new NotificationCreatedEvent($notification));\n        }\n\n        $this->entityManager->flush();\n    }\n\n    private function sendMentionedNotification(EntryComment $subject): array\n    {\n        $users = [];\n        $mentions = $this->mentionManager->clearLocal($this->mentionManager->extract($subject->body));\n\n        foreach ($this->mentionManager->getUsersFromArray($mentions) as $user) {\n            if (!$user->apId and !$user->isBlocked($subject->getUser())) {\n                $notification = new EntryCommentMentionedNotification($user, $subject);\n                $this->entityManager->persist($notification);\n                $this->eventDispatcher->dispatch(new NotificationCreatedEvent($notification));\n            }\n\n            $users[] = $user;\n        }\n\n        return $users;\n    }\n\n    private function notifyUser(EntryCommentReplyNotification $notification): void\n    {\n        if (false === $this->settingsManager->get('KBIN_MERCURE_ENABLED')) {\n            return;\n        }\n\n        try {\n            $iri = IriGenerator::getIriFromResource($notification->user);\n\n            $update = new Update(\n                $iri,\n                $this->getResponse($notification)\n            );\n\n            $this->publisher->publish($update);\n        } catch (\\Exception $e) {\n        }\n    }\n\n    private function getResponse(Notification $notification): string\n    {\n        $class = explode('\\\\', $this->entityManager->getClassMetadata(\\get_class($notification))->name);\n\n        /**\n         * @var EntryComment $comment ;\n         */\n        $comment = $notification->getComment();\n\n        return json_encode(\n            [\n                'op' => end($class),\n                'id' => $comment->getId(),\n                'htmlId' => $this->classService->fromEntity($comment),\n                'parent' => $comment->parent ? [\n                    'id' => $comment->parent->getId(),\n                    'htmlId' => $this->classService->fromEntity($comment->parent),\n                ] : null,\n                'parentSubject' => [\n                    'id' => $comment->entry->getId(),\n                    'htmlId' => $this->classService->fromEntity($comment->entry),\n                ],\n                'title' => $comment->entry->title,\n                'body' => $comment->body,\n                'icon' => $this->imageManager->getUrl($comment->image),\n                //                'image' => $this->imageManager->getUrl($comment->image),\n                'url' => $this->urlGenerator->generate('entry_comment_view', [\n                    'magazine_name' => $comment->magazine->name,\n                    'entry_id' => $comment->entry->getId(),\n                    'slug' => $comment->entry->slug,\n                    'comment_id' => $comment->getId(),\n                ]).'#entry-comment-'.$comment->getId(),\n                //                'toast' => $this->twig->render('_layout/_toast.html.twig', ['notification' => $notification]),\n            ]\n        );\n    }\n\n    private function notifyMagazine(Notification $notification): void\n    {\n        if (false === $this->settingsManager->get('KBIN_MERCURE_ENABLED')) {\n            return;\n        }\n\n        try {\n            $iri = IriGenerator::getIriFromResource($notification->getComment()->magazine);\n\n            $update = new Update(\n                ['pub', $iri],\n                $this->getResponse($notification)\n            );\n\n            $this->publisher->publish($update);\n        } catch (\\Exception $e) {\n        }\n    }\n\n    public function sendEdited(ContentInterface $subject): void\n    {\n        if (!$subject instanceof EntryComment) {\n            throw new \\LogicException();\n        }\n        $this->notifyMagazine(new EntryCommentEditedNotification($subject->user, $subject));\n    }\n\n    public function sendDeleted(ContentInterface $subject): void\n    {\n        if (!$subject instanceof EntryComment) {\n            throw new \\LogicException();\n        }\n        $this->notifyMagazine($notification = new EntryCommentDeletedNotification($subject->user, $subject));\n    }\n\n    public function purgeNotifications(EntryComment $comment): void\n    {\n        $this->notificationRepository->removeEntryCommentNotifications($comment);\n    }\n\n    public function purgeMagazineLog(EntryComment $comment): void\n    {\n        $this->magazineLogRepository->removeEntryCommentLogs($comment);\n    }\n}\n"
  },
  {
    "path": "src/Service/Notification/EntryNotificationManager.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service\\Notification;\n\nuse App\\Entity\\Contracts\\ContentInterface;\nuse App\\Entity\\Entry;\nuse App\\Entity\\EntryCreatedNotification;\nuse App\\Entity\\EntryDeletedNotification;\nuse App\\Entity\\EntryEditedNotification;\nuse App\\Entity\\EntryMentionedNotification;\nuse App\\Entity\\Magazine;\nuse App\\Entity\\Notification;\nuse App\\Event\\NotificationCreatedEvent;\nuse App\\Factory\\MagazineFactory;\nuse App\\Repository\\MagazineLogRepository;\nuse App\\Repository\\MagazineSubscriptionRepository;\nuse App\\Repository\\NotificationRepository;\nuse App\\Repository\\NotificationSettingsRepository;\nuse App\\Repository\\UserRepository;\nuse App\\Service\\Contracts\\ContentNotificationManagerInterface;\nuse App\\Service\\GenerateHtmlClassService;\nuse App\\Service\\ImageManager;\nuse App\\Service\\ImageManagerInterface;\nuse App\\Service\\MentionManager;\nuse App\\Service\\SettingsManager;\nuse App\\Service\\UserManager;\nuse App\\Utils\\IriGenerator;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Symfony\\Component\\EventDispatcher\\EventDispatcherInterface;\nuse Symfony\\Component\\Mercure\\HubInterface;\nuse Symfony\\Component\\Mercure\\Update;\nuse Symfony\\Component\\Routing\\Generator\\UrlGeneratorInterface;\nuse Twig\\Environment;\n\nclass EntryNotificationManager implements ContentNotificationManagerInterface\n{\n    use NotificationTrait;\n\n    public function __construct(\n        private readonly EventDispatcherInterface $eventDispatcher,\n        private readonly NotificationRepository $notificationRepository,\n        private readonly MagazineLogRepository $magazineLogRepository,\n        private readonly MagazineSubscriptionRepository $magazineRepository,\n        private readonly MentionManager $mentionManager,\n        private readonly MagazineFactory $magazineFactory,\n        private readonly HubInterface $publisher,\n        private readonly Environment $twig,\n        private readonly UrlGeneratorInterface $urlGenerator,\n        private readonly EntityManagerInterface $entityManager,\n        private readonly ImageManagerInterface $imageManager,\n        private readonly GenerateHtmlClassService $classService,\n        private readonly UserManager $userManager,\n        private readonly SettingsManager $settingsManager,\n        private readonly NotificationSettingsRepository $notificationSettingsRepository,\n        private readonly UserRepository $userRepository,\n    ) {\n    }\n\n    public function sendCreated(ContentInterface $subject): void\n    {\n        if ($subject->user->isBanned || $subject->user->isDeleted || $subject->user->isTrashed() || $subject->user->isSoftDeleted()) {\n            return;\n        }\n\n        if (!$subject instanceof Entry) {\n            throw new \\LogicException();\n        }\n\n        /*\n         * @var Entry $subject\n         */\n        $this->notifyMagazine(new EntryCreatedNotification($subject->user, $subject));\n\n        // Notify mentioned\n        $mentions = $this->mentionManager->clearLocal($this->mentionManager->extract($subject->body));\n        foreach ($this->mentionManager->getUsersFromArray($mentions) as $user) {\n            if (!$user->apId && !$user->isBlocked($subject->user)) {\n                $notification = new EntryMentionedNotification($user, $subject);\n                $this->entityManager->persist($notification);\n                $this->eventDispatcher->dispatch(new NotificationCreatedEvent($notification));\n            }\n        }\n\n        // Notify subscribers\n        $subscriberIds = $this->notificationSettingsRepository->findNotificationSubscribersByTarget($subject);\n        $subscribers = $this->userRepository->findBy(['id' => $subscriberIds]);\n\n        if (\\count($mentions)) {\n            $subscribers = array_filter($subscribers, fn ($s) => !\\in_array($s->username, $mentions ?? []));\n        }\n\n        foreach ($subscribers as $subscriber) {\n            $notification = new EntryCreatedNotification($subscriber, $subject);\n            $this->entityManager->persist($notification);\n            $this->eventDispatcher->dispatch(new NotificationCreatedEvent($notification));\n        }\n\n        $this->entityManager->flush();\n    }\n\n    private function notifyMagazine(Notification $notification): void\n    {\n        if (false === $this->settingsManager->get('KBIN_MERCURE_ENABLED')) {\n            return;\n        }\n\n        try {\n            $iri = IriGenerator::getIriFromResource($notification->entry->magazine);\n\n            $update = new Update(\n                ['pub', $iri],\n                $this->getResponse($notification)\n            );\n\n            $this->publisher->publish($update);\n        } catch (\\Exception $e) {\n        }\n    }\n\n    private function getResponse(Notification $notification): string\n    {\n        $class = explode('\\\\', $this->entityManager->getClassMetadata(\\get_class($notification))->name);\n\n        /**\n         * @var Magazine $magazine\n         * @var Entry    $entry\n         */\n        $entry = $notification->entry;\n        $magazine = $notification->entry->magazine;\n\n        return json_encode(\n            [\n                'op' => end($class),\n                'id' => $entry->getId(),\n                'htmlId' => $this->classService->fromEntity($entry),\n                'magazine' => [\n                    'name' => $magazine->name,\n                ],\n                'title' => $magazine->title,\n                'body' => $entry->title,\n                'icon' => $this->imageManager->getUrl($entry->image),\n                //                'image' => $this->imageManager->getUrl($entry->image),\n                'url' => $this->urlGenerator->generate('entry_single', [\n                    'magazine_name' => $magazine->name,\n                    'entry_id' => $entry->getId(),\n                    'slug' => $entry->slug,\n                ]),\n                //                'toast' => $this->twig->render('_layout/_toast.html.twig', ['notification' => $notification]),\n            ]\n        );\n    }\n\n    public function sendEdited(ContentInterface $subject): void\n    {\n        if (!$subject instanceof Entry) {\n            throw new \\LogicException();\n        }\n        $this->notifyMagazine(new EntryEditedNotification($subject->user, $subject));\n    }\n\n    public function sendDeleted(ContentInterface $subject): void\n    {\n        if (!$subject instanceof Entry) {\n            throw new \\LogicException();\n        }\n        $this->notifyMagazine($notification = new EntryDeletedNotification($subject->user, $subject));\n    }\n\n    public function purgeNotifications(Entry $entry): void\n    {\n        $this->notificationRepository->removeEntryNotifications($entry);\n    }\n\n    public function purgeMagazineLog(Entry $entry): void\n    {\n        $this->magazineLogRepository->removeEntryLogs($entry);\n    }\n}\n"
  },
  {
    "path": "src/Service/Notification/MagazineBanNotificationManager.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service\\Notification;\n\nuse App\\Entity\\MagazineBan;\nuse App\\Entity\\MagazineBanNotification;\nuse App\\Entity\\MagazineUnBanNotification;\nuse App\\Event\\NotificationCreatedEvent;\nuse App\\Repository\\MagazineBanRepository;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Symfony\\Component\\EventDispatcher\\EventDispatcherInterface;\n\nclass MagazineBanNotificationManager\n{\n    use NotificationTrait;\n\n    public function __construct(\n        private readonly EventDispatcherInterface $eventDispatcher,\n        private readonly MagazineBanRepository $repository,\n        private readonly EntityManagerInterface $entityManager,\n    ) {\n    }\n\n    public function send(MagazineBan $ban): void\n    {\n        if ($ban->expiredAt && new \\DateTimeImmutable('now') >= $ban->expiredAt) {\n            $notification = new MagazineUnBanNotification($ban->user, $ban);\n        } else {\n            $notification = new MagazineBanNotification($ban->user, $ban);\n        }\n\n        $this->entityManager->persist($notification);\n        $this->entityManager->flush();\n        $this->eventDispatcher->dispatch(new NotificationCreatedEvent($notification));\n    }\n}\n"
  },
  {
    "path": "src/Service/Notification/MessageNotificationManager.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service\\Notification;\n\nuse App\\Entity\\Message;\nuse App\\Entity\\MessageNotification;\nuse App\\Entity\\User;\nuse App\\Event\\NotificationCreatedEvent;\nuse App\\Factory\\MagazineFactory;\nuse App\\Repository\\MagazineSubscriptionRepository;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Symfony\\Component\\EventDispatcher\\EventDispatcherInterface;\nuse Symfony\\Component\\Mercure\\HubInterface;\n\nclass MessageNotificationManager\n{\n    use NotificationTrait;\n\n    public function __construct(\n        private readonly EventDispatcherInterface $eventDispatcher,\n        private readonly MagazineSubscriptionRepository $repository,\n        private readonly MagazineFactory $magazineFactory,\n        private readonly HubInterface $publisher,\n        private readonly EntityManagerInterface $entityManager,\n    ) {\n    }\n\n    public function send(Message $message, User $sender): void\n    {\n        $thread = $message->thread;\n        $usersToNotify = $thread->getOtherParticipants($sender);\n\n        foreach ($usersToNotify as $subscriber) {\n            $notification = new MessageNotification($subscriber, $message);\n            $this->entityManager->persist($notification);\n            $this->eventDispatcher->dispatch(new NotificationCreatedEvent($notification));\n        }\n\n        $this->entityManager->flush();\n    }\n}\n"
  },
  {
    "path": "src/Service/Notification/NotificationTrait.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service\\Notification;\n\nuse App\\Entity\\MagazineSubscription;\nuse App\\Entity\\User;\n\ntrait NotificationTrait\n{\n    /**\n     * @param MagazineSubscription[] $subscriptions\n     *\n     * @return User[]\n     */\n    private function getUsersToNotify(array $subscriptions): array\n    {\n        return array_map(fn ($sub) => $sub->user, $subscriptions);\n    }\n\n    private function merge(array $subs, array $follows): array\n    {\n        return array_unique(\n            array_merge(\n                $subs,\n                array_filter(\n                    $follows,\n                    function ($val) use ($subs) {\n                        return !\\in_array($val, $subs);\n                    }\n                )\n            ),\n            SORT_REGULAR\n        );\n    }\n}\n"
  },
  {
    "path": "src/Service/Notification/PostCommentNotificationManager.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service\\Notification;\n\nuse App\\Entity\\Contracts\\ContentInterface;\nuse App\\Entity\\Notification;\nuse App\\Entity\\PostComment;\nuse App\\Entity\\PostCommentCreatedNotification;\nuse App\\Entity\\PostCommentDeletedNotification;\nuse App\\Entity\\PostCommentEditedNotification;\nuse App\\Entity\\PostCommentMentionedNotification;\nuse App\\Entity\\PostCommentReplyNotification;\nuse App\\Event\\NotificationCreatedEvent;\nuse App\\Factory\\MagazineFactory;\nuse App\\Factory\\UserFactory;\nuse App\\Repository\\MagazineLogRepository;\nuse App\\Repository\\MagazineSubscriptionRepository;\nuse App\\Repository\\NotificationRepository;\nuse App\\Repository\\NotificationSettingsRepository;\nuse App\\Repository\\UserRepository;\nuse App\\Service\\Contracts\\ContentNotificationManagerInterface;\nuse App\\Service\\GenerateHtmlClassService;\nuse App\\Service\\ImageManager;\nuse App\\Service\\ImageManagerInterface;\nuse App\\Service\\MentionManager;\nuse App\\Service\\SettingsManager;\nuse App\\Utils\\IriGenerator;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Symfony\\Component\\EventDispatcher\\EventDispatcherInterface;\nuse Symfony\\Component\\Mercure\\HubInterface;\nuse Symfony\\Component\\Mercure\\Update;\nuse Symfony\\Component\\Routing\\Generator\\UrlGeneratorInterface;\nuse Twig\\Environment;\n\nclass PostCommentNotificationManager implements ContentNotificationManagerInterface\n{\n    use NotificationTrait;\n\n    public function __construct(\n        private readonly EventDispatcherInterface $eventDispatcher,\n        private readonly MentionManager $mentionManager,\n        private readonly NotificationRepository $notificationRepository,\n        private readonly MagazineLogRepository $magazineLogRepository,\n        private readonly MagazineSubscriptionRepository $magazineRepository,\n        private readonly MagazineFactory $magazineFactory,\n        private readonly UserFactory $userFactory,\n        private readonly HubInterface $publisher,\n        private readonly Environment $twig,\n        private readonly UrlGeneratorInterface $urlGenerator,\n        private readonly EntityManagerInterface $entityManager,\n        private readonly ImageManagerInterface $imageManager,\n        private readonly GenerateHtmlClassService $classService,\n        private readonly SettingsManager $settingsManager,\n        private readonly NotificationSettingsRepository $notificationSettingsRepository,\n        private readonly UserRepository $userRepository,\n    ) {\n    }\n\n    public function sendCreated(ContentInterface $subject): void\n    {\n        if ($subject->user->isBanned || $subject->user->isDeleted || $subject->user->isTrashed() || $subject->user->isSoftDeleted()) {\n            return;\n        }\n        if (!$subject instanceof PostComment) {\n            throw new \\LogicException();\n        }\n        $comment = $subject;\n\n        $mentions = $this->sendMentionedNotification($subject);\n        $this->notifyMagazine(new PostCommentCreatedNotification($comment->user, $comment));\n\n        $userIdsToNotify = $this->notificationSettingsRepository->findNotificationSubscribersByTarget($comment);\n        $usersToNotify = $this->userRepository->findBy(['id' => $userIdsToNotify]);\n\n        if (\\count($mentions)) {\n            $usersToNotify = array_filter($usersToNotify, fn ($user) => !\\in_array($user, $mentions));\n        }\n\n        foreach ($usersToNotify as $subscriber) {\n            if (null !== $comment->parent && $comment->parent->isAuthor($subscriber)) {\n                $notification = new PostCommentReplyNotification($subscriber, $comment);\n            } else {\n                $notification = new PostCommentCreatedNotification($subscriber, $comment);\n            }\n            $this->entityManager->persist($notification);\n            $this->eventDispatcher->dispatch(new NotificationCreatedEvent($notification));\n        }\n\n        $this->entityManager->flush();\n    }\n\n    private function sendMentionedNotification(PostComment $subject): array\n    {\n        $users = [];\n        $mentions = $this->mentionManager->clearLocal($this->mentionManager->extract($subject->body));\n\n        foreach ($this->mentionManager->getUsersFromArray($mentions) as $user) {\n            if (!$user->apId and !$user->isBlocked($subject->getUser())) {\n                $notification = new PostCommentMentionedNotification($user, $subject);\n                $this->entityManager->persist($notification);\n                $this->eventDispatcher->dispatch(new NotificationCreatedEvent($notification));\n            }\n\n            $users[] = $user;\n        }\n\n        return $users;\n    }\n\n    private function notifyUser(PostCommentReplyNotification $notification): void\n    {\n        if (false === $this->settingsManager->get('KBIN_MERCURE_ENABLED')) {\n            return;\n        }\n\n        try {\n            $iri = IriGenerator::getIriFromResource($notification->user);\n\n            $update = new Update(\n                $iri,\n                $this->getResponse($notification)\n            );\n\n            $this->publisher->publish($update);\n        } catch (\\Exception $e) {\n        }\n    }\n\n    private function getResponse(Notification $notification): string\n    {\n        $class = explode('\\\\', $this->entityManager->getClassMetadata(\\get_class($notification))->name);\n\n        /**\n         * @var PostComment $comment\n         */\n        $comment = $notification->getComment();\n\n        return json_encode(\n            [\n                'op' => end($class),\n                'id' => $comment->getId(),\n                'htmlId' => $this->classService->fromEntity($comment),\n                'parent' => $comment->parent ? [\n                    'id' => $comment->parent->getId(),\n                    'htmlId' => $this->classService->fromEntity($comment->parent),\n                ] : null,\n                'parentSubject' => [\n                    'id' => $comment->post->getId(),\n                    'htmlId' => $this->classService->fromEntity($comment->post),\n                ],\n                'title' => $comment->post->body,\n                'body' => $comment->body,\n                'icon' => $this->imageManager->getUrl($comment->image),\n                //                'image' => $this->imageManager->getUrl($comment->image),\n                'url' => $this->urlGenerator->generate('post_single', [\n                    'magazine_name' => $comment->magazine->name,\n                    'post_id' => $comment->post->getId(),\n                    'slug' => $comment->post->slug,\n                ]).'#post-comment-'.$comment->getId(),\n                //                'toast' => $this->twig->render('_layout/_toast.html.twig', ['notification' => $notification]),\n            ]\n        );\n    }\n\n    private function notifyMagazine(Notification $notification): void\n    {\n        if (false === $this->settingsManager->get('KBIN_MERCURE_ENABLED')) {\n            return;\n        }\n\n        try {\n            $iri = IriGenerator::getIriFromResource($notification->getComment()->magazine);\n\n            $update = new Update(\n                ['pub', $iri],\n                $this->getResponse($notification)\n            );\n\n            $this->publisher->publish($update);\n        } catch (\\Exception $e) {\n        }\n    }\n\n    public function sendEdited(ContentInterface $subject): void\n    {\n        if (!$subject instanceof PostComment) {\n            throw new \\LogicException();\n        }\n        $this->notifyMagazine(new PostCommentEditedNotification($subject->user, $subject));\n    }\n\n    public function sendDeleted(ContentInterface $subject): void\n    {\n        if (!$subject instanceof PostComment) {\n            throw new \\LogicException();\n        }\n        $this->notifyMagazine($notification = new PostCommentDeletedNotification($subject->user, $subject));\n    }\n\n    public function purgeNotifications(PostComment $comment): void\n    {\n        $this->notificationRepository->removePostCommentNotifications($comment);\n    }\n\n    public function purgeMagazineLog(PostComment $comment): void\n    {\n        $this->magazineLogRepository->removePostCommentLogs($comment);\n    }\n}\n"
  },
  {
    "path": "src/Service/Notification/PostNotificationManager.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service\\Notification;\n\nuse App\\Entity\\Contracts\\ContentInterface;\nuse App\\Entity\\Notification;\nuse App\\Entity\\Post;\nuse App\\Entity\\PostCreatedNotification;\nuse App\\Entity\\PostDeletedNotification;\nuse App\\Entity\\PostEditedNotification;\nuse App\\Entity\\PostMentionedNotification;\nuse App\\Event\\NotificationCreatedEvent;\nuse App\\Factory\\MagazineFactory;\nuse App\\Repository\\MagazineLogRepository;\nuse App\\Repository\\MagazineSubscriptionRepository;\nuse App\\Repository\\NotificationRepository;\nuse App\\Repository\\NotificationSettingsRepository;\nuse App\\Repository\\UserRepository;\nuse App\\Service\\Contracts\\ContentNotificationManagerInterface;\nuse App\\Service\\GenerateHtmlClassService;\nuse App\\Service\\ImageManager;\nuse App\\Service\\ImageManagerInterface;\nuse App\\Service\\MentionManager;\nuse App\\Service\\SettingsManager;\nuse App\\Utils\\IriGenerator;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Symfony\\Component\\EventDispatcher\\EventDispatcherInterface;\nuse Symfony\\Component\\Mercure\\HubInterface;\nuse Symfony\\Component\\Mercure\\Update;\nuse Symfony\\Component\\Routing\\Generator\\UrlGeneratorInterface;\nuse Twig\\Environment;\n\nclass PostNotificationManager implements ContentNotificationManagerInterface\n{\n    use NotificationTrait;\n\n    public function __construct(\n        private readonly EventDispatcherInterface $eventDispatcher,\n        private readonly MentionManager $mentionManager,\n        private readonly NotificationRepository $notificationRepository,\n        private readonly MagazineLogRepository $magazineLogRepository,\n        private readonly MagazineSubscriptionRepository $magazineRepository,\n        private readonly MagazineFactory $magazineFactory,\n        private readonly HubInterface $publisher,\n        private readonly Environment $twig,\n        private readonly UrlGeneratorInterface $urlGenerator,\n        private readonly EntityManagerInterface $entityManager,\n        private readonly ImageManagerInterface $imageManager,\n        private readonly GenerateHtmlClassService $classService,\n        private readonly SettingsManager $settingsManager,\n        private readonly NotificationSettingsRepository $notificationSettingsRepository,\n        private readonly UserRepository $userRepository,\n    ) {\n    }\n\n    public function sendCreated(ContentInterface $subject): void\n    {\n        if ($subject->user->isBanned || $subject->user->isDeleted || $subject->user->isTrashed() || $subject->user->isSoftDeleted()) {\n            return;\n        }\n        if (!$subject instanceof Post) {\n            throw new \\LogicException();\n        }\n\n        $this->notifyMagazine(new PostCreatedNotification($subject->user, $subject));\n\n        // Notify mentioned\n        $mentions = $this->mentionManager->clearLocal($this->mentionManager->extract($subject->body));\n        foreach ($this->mentionManager->getUsersFromArray($mentions) as $user) {\n            if (!$user->isBlocked($subject->user)) {\n                $notification = new PostMentionedNotification($user, $subject);\n                $this->entityManager->persist($notification);\n                $this->eventDispatcher->dispatch(new NotificationCreatedEvent($notification));\n            }\n        }\n\n        // Notify subscribers\n        $subscriberIds = $this->notificationSettingsRepository->findNotificationSubscribersByTarget($subject);\n        $subscribers = $this->userRepository->findBy(['id' => $subscriberIds]);\n\n        if (\\count($mentions)) {\n            $subscribers = array_filter($subscribers, fn ($s) => !\\in_array($s->username, $mentions));\n        }\n\n        foreach ($subscribers as $subscriber) {\n            $notification2 = new PostCreatedNotification($subscriber, $subject);\n            $this->entityManager->persist($notification2);\n            $this->eventDispatcher->dispatch(new NotificationCreatedEvent($notification2));\n        }\n\n        $this->entityManager->flush();\n    }\n\n    private function notifyMagazine(Notification $notification): void\n    {\n        if (false === $this->settingsManager->get('KBIN_MERCURE_ENABLED')) {\n            return;\n        }\n\n        try {\n            $iri = IriGenerator::getIriFromResource($notification->post->magazine);\n\n            $update = new Update(\n                ['pub', $iri],\n                $this->getResponse($notification)\n            );\n\n            $this->publisher->publish($update);\n        } catch (\\Exception $e) {\n        }\n    }\n\n    private function getResponse(Notification $notification): string\n    {\n        $class = explode('\\\\', $this->entityManager->getClassMetadata(\\get_class($notification))->name);\n\n        /**\n         * @var Post $post ;\n         */\n        $post = $notification->post;\n\n        return json_encode(\n            [\n                'op' => end($class),\n                'id' => $post->getId(),\n                'htmlId' => $this->classService->fromEntity($post),\n                'magazine' => [\n                    'name' => $post->magazine->name,\n                ],\n                'title' => $post->magazine->name,\n                'body' => $post->body,\n                'icon' => $this->imageManager->getUrl($post->image),\n                //                'image' => $this->imageManager->getUrl($post->image),\n                'url' => $this->urlGenerator->generate('post_single', [\n                    'magazine_name' => $post->magazine->name,\n                    'post_id' => $post->getId(),\n                    'slug' => $post->slug,\n                ]),\n                //                'toast' => $this->twig->render('_layout/_toast.html.twig', ['notification' => $notification]),\n            ]\n        );\n    }\n\n    public function sendEdited(ContentInterface $subject): void\n    {\n        if (!$subject instanceof Post) {\n            throw new \\LogicException();\n        }\n        $this->notifyMagazine(new PostEditedNotification($subject->user, $subject));\n    }\n\n    public function sendDeleted(ContentInterface $subject): void\n    {\n        if (!$subject instanceof Post) {\n            throw new \\LogicException();\n        }\n        $this->notifyMagazine($notification = new PostDeletedNotification($subject->user, $subject));\n    }\n\n    public function purgeNotifications(Post $post): void\n    {\n        $this->notificationRepository->removePostNotifications($post);\n    }\n\n    public function purgeMagazineLog(Post $post): void\n    {\n        $this->magazineLogRepository->removePostLogs($post);\n    }\n}\n"
  },
  {
    "path": "src/Service/Notification/ReportNotificationManager.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service\\Notification;\n\nuse App\\Entity\\Moderator;\nuse App\\Entity\\Report;\nuse App\\Entity\\ReportApprovedNotification;\nuse App\\Entity\\ReportCreatedNotification;\nuse App\\Event\\NotificationCreatedEvent;\nuse App\\Repository\\UserRepository;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Symfony\\Component\\EventDispatcher\\EventDispatcherInterface;\n\nclass ReportNotificationManager\n{\n    public function __construct(\n        private readonly EntityManagerInterface $entityManager,\n        private readonly UserRepository $userRepository,\n        private readonly EventDispatcherInterface $dispatcher,\n    ) {\n    }\n\n    public function sendReportCreatedNotification(Report $report): void\n    {\n        $receivers = [];\n        foreach ($report->magazine->moderators as /* @var Moderator $moderator */ $moderator) {\n            if (null === $moderator->user->apId) {\n                $receivers[] = $moderator->user;\n            }\n        }\n\n        foreach ($this->userRepository->findAllModerators() as $moderator) {\n            if (null === $moderator->apId) {\n                $receivers[] = $moderator;\n            }\n        }\n\n        foreach ($this->userRepository->findAllAdmins() as $admin) {\n            if (null === $admin->apId) {\n                $receivers[] = $admin;\n            }\n        }\n\n        $map = [];\n        foreach ($receivers as $receiver) {\n            if (!\\array_key_exists($receiver->getId(), $map)) {\n                $map[$receiver->getId()] = true;\n                $n = new ReportCreatedNotification($receiver, $report);\n                $this->entityManager->persist($n);\n                $this->dispatcher->dispatch(new NotificationCreatedEvent($n));\n            }\n        }\n\n        $this->entityManager->flush();\n    }\n\n    public function sendReportRejectedNotification(Report $report): void\n    {\n    }\n\n    public function sendReportApprovedNotification(Report $report): void\n    {\n        if (null === $report->reported->apId) {\n            $notification = new ReportApprovedNotification($report->reported, $report);\n            $this->entityManager->persist($notification);\n            $this->entityManager->flush();\n            $this->dispatcher->dispatch(new NotificationCreatedEvent($notification));\n        }\n    }\n}\n"
  },
  {
    "path": "src/Service/Notification/SignupNotificationManager.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service\\Notification;\n\nuse App\\Entity\\NewSignupNotification;\nuse App\\Entity\\User;\nuse App\\Event\\NotificationCreatedEvent;\nuse App\\Repository\\UserRepository;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Symfony\\Component\\EventDispatcher\\EventDispatcherInterface;\n\nreadonly class SignupNotificationManager\n{\n    public function __construct(\n        private EntityManagerInterface $entityManager,\n        private UserRepository $userRepository,\n        private EventDispatcherInterface $dispatcher,\n    ) {\n    }\n\n    public function sendNewSignupNotification(User $newUser): void\n    {\n        $receiver_admins = $this->userRepository->findAllAdmins();\n        $receiver_moderators = $this->userRepository->findAllModerators();\n        $receivers = array_merge($receiver_admins, $receiver_moderators);\n        $sentNotificationUserIds = [];\n        foreach ($receivers as $receiver) {\n            if (!$receiver->notifyOnUserSignup || \\array_key_exists($receiver->getId(), $sentNotificationUserIds)) {\n                continue;\n            }\n            $notification = new NewSignupNotification($receiver);\n            $notification->newUser = $newUser;\n            $this->entityManager->persist($notification);\n            $this->dispatcher->dispatch(new NotificationCreatedEvent($notification));\n            $sentNotificationUserIds[$receiver->getId()] = true;\n        }\n        $this->entityManager->flush();\n    }\n}\n"
  },
  {
    "path": "src/Service/Notification/UserPushSubscriptionManager.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service\\Notification;\n\nuse App\\Entity\\Notification;\nuse App\\Entity\\User;\nuse App\\Payloads\\PushNotification;\nuse App\\Repository\\SiteRepository;\nuse App\\Repository\\UserPushSubscriptionRepository;\nuse App\\Service\\SettingsManager;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse League\\Bundle\\OAuth2ServerBundle\\Model\\AccessToken;\nuse Minishlink\\WebPush\\MessageSentReport;\nuse Minishlink\\WebPush\\Subscription;\nuse Minishlink\\WebPush\\WebPush;\nuse Psr\\Log\\LoggerInterface;\nuse Symfony\\Component\\Routing\\Generator\\UrlGeneratorInterface;\nuse Symfony\\Contracts\\Translation\\TranslatorInterface;\n\nclass UserPushSubscriptionManager\n{\n    public function __construct(\n        private readonly SettingsManager $settingsManager,\n        private readonly SiteRepository $siteRepository,\n        private readonly UserPushSubscriptionRepository $pushSubscriptionRepository,\n        private readonly TranslatorInterface $translator,\n        private readonly UrlGeneratorInterface $urlGenerator,\n        private readonly LoggerInterface $logger,\n        private readonly EntityManagerInterface $entityManager,\n    ) {\n    }\n\n    /**\n     * @throws \\ErrorException\n     */\n    public function sendTextToUser(User $user, PushNotification|Notification $pushNotification, ?string $specificDeviceKey = null, ?AccessToken $specificToken = null): void\n    {\n        $webPush = $this->getWebPush();\n        $criteria = ['user' => $user];\n        if ($specificDeviceKey) {\n            $criteria['deviceKey'] = $specificDeviceKey;\n        }\n        if ($specificToken) {\n            $criteria['apiToken'] = $specificToken;\n        }\n        $subs = $this->pushSubscriptionRepository->findBy($criteria);\n        foreach ($subs as $sub) {\n            if ($pushNotification instanceof Notification) {\n                $toSend = $pushNotification->getMessage($this->translator, $sub->locale ?? $this->settingsManager->get('KBIN_DEFAULT_LANG'), $this->urlGenerator);\n            } elseif ($pushNotification instanceof PushNotification) {\n                $toSend = $pushNotification;\n            } else {\n                throw new \\InvalidArgumentException();\n            }\n            $this->logger->debug(\"Sending text '{t}' to {u}#{dk}. {json}\", [\n                't' => $toSend->title.'. '.$toSend->message,\n                'u' => $user->username,\n                'dk' => $sub->deviceKey ?? 'someOAuth',\n                'json' => json_encode($sub),\n            ]);\n            $webPush->queueNotification(\n                new Subscription($sub->endpoint, $sub->contentEncryptionPublicKey, $sub->serverAuthKey, contentEncoding: 'aes128gcm'),\n                payload: json_encode($toSend)\n            );\n        }\n        /**\n         * Check sent results.\n         *\n         * @var MessageSentReport $report\n         */\n        foreach ($webPush->flush() as $report) {\n            $endpoint = $report->getRequest()->getUri()->__toString();\n\n            if ($report->isSuccess()) {\n                $this->logger->debug('[v] Message sent successfully for subscription {e}.', ['e' => $endpoint]);\n            } else {\n                $this->logger->debug('[x] Message failed to sent for subscription {e}: {r}', ['e' => $endpoint, 'r' => $report->getReason()]);\n                if ($report->isSubscriptionExpired()) {\n                    $subscriptions = $this->pushSubscriptionRepository->findBy(['endpoint' => $endpoint]);\n                    foreach ($subscriptions as $sub) {\n                        $this->entityManager->remove($sub);\n                    }\n                    $this->logger->info('Removed push subscription for user \"{u}\" at endpoint \"{e}\", because it expired', ['e' => $endpoint, 'u' => $user->username]);\n                }\n            }\n        }\n    }\n\n    /**\n     * @throws \\ErrorException\n     */\n    public function getWebPush(): WebPush\n    {\n        $site = $this->siteRepository->findAll()[0];\n        $auth = [\n            'VAPID' => [\n                'subject' => $this->settingsManager->get('KBIN_DOMAIN'),\n                'publicKey' => $site->pushPublicKey,\n                'privateKey' => $site->pushPrivateKey,\n            ],\n        ];\n\n        return new WebPush($auth);\n    }\n}\n"
  },
  {
    "path": "src/Service/NotificationManager.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service;\n\nuse App\\Entity\\Contracts\\ContentInterface;\nuse App\\Entity\\MagazineBan;\nuse App\\Entity\\Message;\nuse App\\Entity\\MessageNotification;\nuse App\\Entity\\Notification;\nuse App\\Entity\\User;\nuse App\\Service\\Notification\\MagazineBanNotificationManager;\nuse App\\Service\\Notification\\MessageNotificationManager;\nuse Doctrine\\ORM\\EntityManagerInterface;\n\nclass NotificationManager\n{\n    public function __construct(\n        private readonly NotificationManagerTypeResolver $resolver,\n        private readonly MessageNotificationManager $messageNotificationManager,\n        private readonly EntityManagerInterface $entityManager,\n        private readonly MagazineBanNotificationManager $magazineBanNotificationManager,\n    ) {\n    }\n\n    public function sendCreated(ContentInterface $subject): void\n    {\n        $this->resolver->resolve($subject)->sendCreated($subject);\n    }\n\n    public function sendEdited(ContentInterface $subject): void\n    {\n        $this->resolver->resolve($subject)->sendEdited($subject);\n    }\n\n    public function sendDeleted(ContentInterface $subject): void\n    {\n        $this->resolver->resolve($subject)->sendDeleted($subject);\n    }\n\n    public function sendMessageNotification(Message $message, User $sender): void\n    {\n        $this->messageNotificationManager->send($message, $sender);\n    }\n\n    public function sendMagazineBanNotification(MagazineBan $ban): void\n    {\n        $this->magazineBanNotificationManager->send($ban);\n    }\n\n    public function markAllAsRead(User $user): void\n    {\n        $notifications = $user->getNewNotifications();\n\n        foreach ($notifications as $notification) {\n            $notification->status = Notification::STATUS_READ;\n        }\n\n        $this->entityManager->flush();\n    }\n\n    public function clear(User $user): void\n    {\n        $notifications = $user->notifications;\n\n        foreach ($notifications as $notification) {\n            $this->entityManager->remove($notification);\n        }\n\n        $this->entityManager->flush();\n    }\n\n    public function readMessageNotification(Message $message, User $user): void\n    {\n        $repo = $this->entityManager->getRepository(MessageNotification::class);\n\n        $notifications = $repo->findBy(\n            [\n                'message' => $message,\n                'user' => $user,\n            ]\n        );\n\n        foreach ($notifications as $notification) {\n            $notification->status = Notification::STATUS_READ;\n        }\n\n        $this->entityManager->flush();\n    }\n\n    public function unreadMessageNotification(Message $message, User $user): void\n    {\n        $repo = $this->entityManager->getRepository(MessageNotification::class);\n\n        $notifications = $repo->findBy(\n            [\n                'message' => $message,\n                'user' => $user,\n            ]\n        );\n\n        foreach ($notifications as $notification) {\n            $notification->status = Notification::STATUS_NEW;\n        }\n\n        $this->entityManager->flush();\n    }\n}\n"
  },
  {
    "path": "src/Service/NotificationManagerTypeResolver.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service;\n\nuse App\\Entity\\Contracts\\ContentInterface;\nuse App\\Entity\\Entry;\nuse App\\Entity\\EntryComment;\nuse App\\Entity\\Post;\nuse App\\Entity\\PostComment;\nuse App\\Service\\Contracts\\ContentNotificationManagerInterface;\nuse App\\Service\\Notification\\EntryCommentNotificationManager;\nuse App\\Service\\Notification\\EntryNotificationManager;\nuse App\\Service\\Notification\\PostCommentNotificationManager;\nuse App\\Service\\Notification\\PostNotificationManager;\n\nclass NotificationManagerTypeResolver\n{\n    public function __construct(\n        private readonly EntryNotificationManager $entryNotificationManager,\n        private readonly EntryCommentNotificationManager $entryCommentNotificationManager,\n        private readonly PostNotificationManager $postNotificationManager,\n        private readonly PostCommentNotificationManager $postCommentNotificationManager,\n    ) {\n    }\n\n    public function resolve(ContentInterface $subject): ContentNotificationManagerInterface\n    {\n        return match (true) {\n            $subject instanceof Entry => $this->entryNotificationManager,\n            $subject instanceof EntryComment => $this->entryCommentNotificationManager,\n            $subject instanceof Post => $this->postNotificationManager,\n            $subject instanceof PostComment => $this->postCommentNotificationManager,\n            default => throw new \\LogicException(),\n        };\n    }\n}\n"
  },
  {
    "path": "src/Service/OAuthTokenRevoker.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service;\n\nuse App\\Entity\\Client;\nuse App\\Entity\\User;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse League\\Bundle\\OAuth2ServerBundle\\Model\\AccessToken;\nuse League\\Bundle\\OAuth2ServerBundle\\Model\\AuthorizationCode;\nuse League\\Bundle\\OAuth2ServerBundle\\Model\\RefreshToken;\n\nclass OAuthTokenRevoker\n{\n    public function __construct(\n        private readonly EntityManagerInterface $entityManager,\n    ) {\n    }\n\n    public function revokeCredentialsForUserWithClient(User $user, Client $client): void\n    {\n        $this->entityManager->createQueryBuilder()\n            ->update(AccessToken::class, 'at')\n            ->set('at.revoked', ':revoked')\n            ->where('at.userIdentifier = :userIdentifier')\n            ->andWhere('at.client = :clientIdentifier')\n            ->setParameter('revoked', true)\n            ->setParameter('userIdentifier', $user->getUserIdentifier())\n            ->setParameter('clientIdentifier', $client->getIdentifier())\n            ->getQuery()\n            ->execute();\n\n        $queryBuilder = $this->entityManager->createQueryBuilder();\n        $queryBuilder\n            ->update(RefreshToken::class, 'rt')\n            ->set('rt.revoked', ':revoked')\n            ->where($queryBuilder->expr()->in(\n                'rt.accessToken',\n                $this->entityManager->createQueryBuilder()\n                    ->select('at.identifier')\n                    ->from(AccessToken::class, 'at')\n                    ->where('at.userIdentifier = :userIdentifier')\n                    ->andWhere('at.client = :clientIdentifier')\n                    ->getDQL()\n            ))\n            ->setParameter('revoked', true)\n            ->setParameter('userIdentifier', $user->getUserIdentifier())\n            ->setParameter('clientIdentifier', $client->getIdentifier())\n            ->getQuery()\n            ->execute();\n\n        $this->entityManager->createQueryBuilder()\n            ->update(AuthorizationCode::class, 'ac')\n            ->set('ac.revoked', ':revoked')\n            ->where('ac.userIdentifier = :userIdentifier')\n            ->andWhere('ac.client = :clientIdentifier')\n            ->setParameter('revoked', true)\n            ->setParameter('userIdentifier', $user->getUserIdentifier())\n            ->setParameter('clientIdentifier', $client->getIdentifier())\n            ->getQuery()\n            ->execute();\n    }\n}\n"
  },
  {
    "path": "src/Service/PeopleManager.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service;\n\nuse App\\Entity\\Magazine;\nuse App\\Repository\\PostRepository;\nuse App\\Repository\\UserRepository;\n\nclass PeopleManager\n{\n    public ?Magazine $magazine = null;\n\n    public function __construct(\n        private readonly UserRepository $userRepository,\n        private readonly PostRepository $postRepository,\n    ) {\n    }\n\n    public function byMagazine(Magazine $magazine, bool $federated = false): array\n    {\n        if ($federated) {\n            $users = $this->postRepository->findUsers($magazine, true);\n\n            return $this->sort(\n                $this->userRepository->findBy(\n                    ['id' => array_map(fn ($val) => $val['id'], $users)]\n                ),\n                $users\n            );\n        }\n\n        $local = $this->postRepository->findUsers($magazine);\n\n        return $this->sort(\n            $this->userRepository->findBy(['id' => array_map(fn ($val) => $val['id'], $local)]),\n            $local\n        );\n    }\n\n    private function sort(array $users, array $ids): array\n    {\n        $result = [];\n        foreach ($ids as $id) {\n            $result[] = array_values(array_filter($users, fn ($val) => $val->getId() === $id['id']))[0];\n        }\n\n        return array_values($result);\n    }\n\n    public function general(bool $federated = false): array\n    {\n        if ($federated) {\n            return $this->userRepository->findUsersForGroup(UserRepository::USERS_REMOTE);\n        }\n\n        return $this->userRepository->findUsersForGroup(UserRepository::USERS_LOCAL, false);\n    }\n}\n"
  },
  {
    "path": "src/Service/PostCommentManager.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service;\n\nuse App\\DTO\\PostCommentDto;\nuse App\\Entity\\Contracts\\VisibilityInterface;\nuse App\\Entity\\PostComment;\nuse App\\Entity\\User;\nuse App\\Event\\PostComment\\PostCommentBeforeDeletedEvent;\nuse App\\Event\\PostComment\\PostCommentBeforePurgeEvent;\nuse App\\Event\\PostComment\\PostCommentCreatedEvent;\nuse App\\Event\\PostComment\\PostCommentDeletedEvent;\nuse App\\Event\\PostComment\\PostCommentEditedEvent;\nuse App\\Event\\PostComment\\PostCommentPurgedEvent;\nuse App\\Event\\PostComment\\PostCommentRestoredEvent;\nuse App\\Exception\\InstanceBannedException;\nuse App\\Exception\\PostLockedException;\nuse App\\Exception\\TagBannedException;\nuse App\\Exception\\UserBannedException;\nuse App\\Factory\\PostCommentFactory;\nuse App\\Message\\DeleteImageMessage;\nuse App\\Repository\\ImageRepository;\nuse App\\Service\\Contracts\\ContentManagerInterface;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Psr\\EventDispatcher\\EventDispatcherInterface;\nuse Psr\\Log\\LoggerInterface;\nuse Symfony\\Component\\HttpKernel\\Exception\\TooManyRequestsHttpException;\nuse Symfony\\Component\\Messenger\\MessageBusInterface;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Webmozart\\Assert\\Assert;\n\nclass PostCommentManager implements ContentManagerInterface\n{\n    public function __construct(\n        private readonly LoggerInterface $logger,\n        private readonly TagManager $tagManager,\n        private readonly TagExtractor $tagExtractor,\n        private readonly MentionManager $mentionManager,\n        private readonly PostCommentFactory $factory,\n        private readonly ImageRepository $imageRepository,\n        private readonly EventDispatcherInterface $dispatcher,\n        private readonly RateLimiterFactoryInterface $postCommentLimiter,\n        private readonly MessageBusInterface $bus,\n        private readonly SettingsManager $settingsManager,\n        private readonly EntityManagerInterface $entityManager,\n    ) {\n    }\n\n    /**\n     * @throws TagBannedException\n     * @throws UserBannedException\n     * @throws InstanceBannedException\n     * @throws TooManyRequestsHttpException\n     * @throws PostLockedException\n     * @throws \\Exception\n     */\n    public function create(PostCommentDto $dto, User $user, $rateLimit = true): PostComment\n    {\n        if ($rateLimit) {\n            $limiter = $this->postCommentLimiter->create($dto->ip);\n            if ($limiter && false === $limiter->consume()->isAccepted()) {\n                throw new TooManyRequestsHttpException();\n            }\n        }\n\n        if ($dto->post->magazine->isBanned($user) || $user->isBanned()) {\n            throw new UserBannedException();\n        }\n\n        if ($this->tagManager->isAnyTagBanned($this->tagManager->extract($dto->body))) {\n            throw new TagBannedException();\n        }\n\n        if (null !== $dto->post->magazine->apId && $this->settingsManager->isBannedInstance($dto->post->magazine->apInboxUrl)) {\n            throw new InstanceBannedException();\n        }\n\n        if ($dto->post->isLocked) {\n            throw new PostLockedException();\n        }\n\n        $comment = $this->factory->createFromDto($dto, $user);\n\n        $comment->magazine = $dto->post->magazine;\n        $comment->lang = $dto->lang;\n        $comment->isAdult = $dto->isAdult || $comment->magazine->isAdult;\n        $comment->image = $dto->image ? $this->imageRepository->find($dto->image->id) : null;\n        if ($comment->image && !$comment->image->altText) {\n            $comment->image->altText = $dto->imageAlt;\n        }\n        $comment->mentions = $dto->body\n            ? array_merge($dto->mentions ?? [], $this->mentionManager->handleChain($comment))\n            : $dto->mentions;\n        $comment->visibility = $dto->visibility;\n        $comment->apId = $dto->apId;\n        $comment->apLikeCount = $dto->apLikeCount;\n        $comment->apDislikeCount = $dto->apDislikeCount;\n        $comment->apShareCount = $dto->apShareCount;\n        $comment->magazine->lastActive = new \\DateTime();\n        $comment->user->lastActive = new \\DateTime();\n        $comment->lastActive = $dto->lastActive ?? $comment->lastActive;\n        $comment->createdAt = $dto->createdAt ?? $comment->createdAt;\n        if (empty($comment->body) && null === $comment->image) {\n            throw new \\Exception('Comment body and image cannot be empty');\n        }\n\n        $comment->post->addComment($comment);\n        $comment->updateScore();\n        $comment->updateRanking();\n\n        $this->entityManager->persist($comment);\n        $this->entityManager->flush();\n\n        $this->tagManager->updatePostCommentTags($comment, $this->tagExtractor->extract($comment->body) ?? []);\n\n        $this->dispatcher->dispatch(new PostCommentCreatedEvent($comment));\n\n        return $comment;\n    }\n\n    public function canUserEditPostComment(PostComment $postComment, User $user): bool\n    {\n        $postCommentHost = null !== $postComment->apId ? parse_url($postComment->apId, PHP_URL_HOST) : $this->settingsManager->get('KBIN_DOMAIN');\n        $userHost = null !== $user->apId ? parse_url($user->apProfileId, PHP_URL_HOST) : $this->settingsManager->get('KBIN_DOMAIN');\n        $magazineHost = null !== $postComment->magazine->apId ? parse_url($postComment->magazine->apProfileId, PHP_URL_HOST) : $this->settingsManager->get('KBIN_DOMAIN');\n\n        return $postCommentHost === $userHost || $userHost === $magazineHost || $postComment->magazine->userIsModerator($user);\n    }\n\n    /**\n     * @throws \\Exception\n     */\n    public function edit(PostComment $comment, PostCommentDto $dto, ?User $editedBy = null): PostComment\n    {\n        Assert::same($comment->post->getId(), $dto->post->getId());\n\n        $comment->body = $dto->body;\n        $comment->lang = $dto->lang;\n        $comment->isAdult = $dto->isAdult || $comment->magazine->isAdult;\n        $oldImage = $comment->image;\n        if ($dto->image) {\n            $comment->image = $this->imageRepository->find($dto->image->id);\n        }\n        $this->tagManager->updatePostCommentTags($comment, $this->tagExtractor->extract($dto->body) ?? []);\n        $comment->mentions = $dto->body\n            ? array_merge($dto->mentions ?? [], $this->mentionManager->handleChain($comment))\n            : $dto->mentions;\n        $comment->visibility = $dto->visibility;\n        $comment->editedAt = new \\DateTimeImmutable('@'.time());\n        if (empty($comment->body) && null === $comment->image) {\n            throw new \\Exception('Comment body and image cannot be empty');\n        }\n\n        $comment->apLikeCount = $dto->apLikeCount;\n        $comment->apDislikeCount = $dto->apDislikeCount;\n        $comment->apShareCount = $dto->apShareCount;\n        $comment->updateScore();\n        $comment->updateRanking();\n\n        $this->entityManager->flush();\n\n        if ($oldImage && $comment->image !== $oldImage) {\n            $this->bus->dispatch(new DeleteImageMessage($oldImage->getId()));\n        }\n\n        $this->dispatcher->dispatch(new PostCommentEditedEvent($comment, $editedBy));\n\n        return $comment;\n    }\n\n    public function delete(User $user, PostComment $comment): void\n    {\n        if ($user->apDomain && $user->apDomain !== parse_url($comment->apId ?? '', PHP_URL_HOST) && !$comment->magazine->userIsModerator($user)) {\n            $this->logger->info('Got a delete activity from user {u}, but they are not from the same instance as the deleted post and they are not a moderator on {m]', ['u' => $user->apId, 'm' => $comment->magazine->apId ?? $comment->magazine->name]);\n\n            return;\n        }\n\n        if ($comment->isAuthor($user) && $comment->children->isEmpty()) {\n            $this->purge($user, $comment);\n\n            return;\n        }\n\n        $this->isTrashed($user, $comment) ? $comment->trash() : $comment->softDelete();\n\n        $this->dispatcher->dispatch(new PostCommentBeforeDeletedEvent($comment, $user));\n\n        $this->entityManager->flush();\n\n        $this->dispatcher->dispatch(new PostCommentDeletedEvent($comment, $user));\n    }\n\n    public function trash(User $user, PostComment $comment): void\n    {\n        $comment->trash();\n\n        $this->dispatcher->dispatch(new PostCommentBeforeDeletedEvent($comment, $user));\n\n        $this->entityManager->flush();\n\n        $this->dispatcher->dispatch(new PostCommentDeletedEvent($comment, $user));\n    }\n\n    public function purge(User $user, PostComment $comment): void\n    {\n        $this->dispatcher->dispatch(new PostCommentBeforePurgeEvent($comment, $user));\n\n        $magazine = $comment->post->magazine;\n        $image = $comment->image?->getId();\n        $comment->post->removeComment($comment);\n        $this->entityManager->remove($comment);\n        $this->entityManager->flush();\n\n        $this->dispatcher->dispatch(new PostCommentPurgedEvent($magazine));\n\n        if ($image) {\n            $this->bus->dispatch(new DeleteImageMessage($image));\n        }\n    }\n\n    private function isTrashed(User $user, PostComment $comment): bool\n    {\n        return !$comment->isAuthor($user);\n    }\n\n    /**\n     * @throws \\Exception\n     */\n    public function restore(User $user, PostComment $comment): void\n    {\n        if (VisibilityInterface::VISIBILITY_TRASHED !== $comment->visibility) {\n            throw new \\Exception('Invalid visibility');\n        }\n\n        $comment->visibility = VisibilityInterface::VISIBILITY_VISIBLE;\n\n        $this->entityManager->persist($comment);\n        $this->entityManager->flush();\n\n        $this->dispatcher->dispatch(new PostCommentRestoredEvent($comment, $user));\n    }\n\n    public function createDto(PostComment $comment): PostCommentDto\n    {\n        return $this->factory->createDto($comment);\n    }\n\n    public function detachImage(PostComment $comment): void\n    {\n        $image = $comment->image->getId();\n\n        $comment->image = null;\n\n        $this->entityManager->persist($comment);\n        $this->entityManager->flush();\n\n        $this->bus->dispatch(new DeleteImageMessage($image));\n    }\n}\n"
  },
  {
    "path": "src/Service/PostManager.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service;\n\nuse App\\DTO\\PostDto;\nuse App\\Entity\\Contracts\\VisibilityInterface;\nuse App\\Entity\\Magazine;\nuse App\\Entity\\MagazineLogPostLocked;\nuse App\\Entity\\MagazineLogPostUnlocked;\nuse App\\Entity\\Post;\nuse App\\Entity\\User;\nuse App\\Event\\Entry\\PostLockEvent;\nuse App\\Event\\Post\\PostBeforeDeletedEvent;\nuse App\\Event\\Post\\PostBeforePurgeEvent;\nuse App\\Event\\Post\\PostCreatedEvent;\nuse App\\Event\\Post\\PostDeletedEvent;\nuse App\\Event\\Post\\PostEditedEvent;\nuse App\\Event\\Post\\PostRestoredEvent;\nuse App\\Exception\\InstanceBannedException;\nuse App\\Exception\\TagBannedException;\nuse App\\Exception\\UserBannedException;\nuse App\\Factory\\PostFactory;\nuse App\\Message\\DeleteImageMessage;\nuse App\\Repository\\ImageRepository;\nuse App\\Repository\\PostRepository;\nuse App\\Service\\ActivityPub\\ApHttpClientInterface;\nuse App\\Service\\Contracts\\ContentManagerInterface;\nuse App\\Utils\\Slugger;\nuse Doctrine\\Common\\Collections\\Criteria;\nuse Doctrine\\Common\\Collections\\Order;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Psr\\EventDispatcher\\EventDispatcherInterface;\nuse Psr\\Log\\LoggerInterface;\nuse Symfony\\Component\\HttpKernel\\Exception\\TooManyRequestsHttpException;\nuse Symfony\\Component\\Messenger\\MessageBusInterface;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Contracts\\Cache\\CacheInterface;\nuse Symfony\\Contracts\\Translation\\TranslatorInterface;\nuse Webmozart\\Assert\\Assert;\n\nclass PostManager implements ContentManagerInterface\n{\n    public function __construct(\n        private readonly LoggerInterface $logger,\n        private readonly Slugger $slugger,\n        private readonly MentionManager $mentionManager,\n        private readonly PostCommentManager $postCommentManager,\n        private readonly TagManager $tagManager,\n        private readonly TagExtractor $tagExtractor,\n        private readonly PostFactory $factory,\n        private readonly EventDispatcherInterface $dispatcher,\n        private readonly RateLimiterFactoryInterface $postLimiter,\n        private readonly MessageBusInterface $bus,\n        private readonly TranslatorInterface $translator,\n        private readonly EntityManagerInterface $entityManager,\n        private readonly PostRepository $postRepository,\n        private readonly ImageRepository $imageRepository,\n        private readonly ApHttpClientInterface $apHttpClient,\n        private readonly SettingsManager $settingsManager,\n        private readonly CacheInterface $cache,\n    ) {\n    }\n\n    /**\n     * @throws TagBannedException\n     * @throws UserBannedException\n     * @throws InstanceBannedException\n     * @throws TooManyRequestsHttpException\n     * @throws \\Exception\n     */\n    public function create(PostDto $dto, User $user, $rateLimit = true, bool $stickyIt = false): Post\n    {\n        if ($rateLimit) {\n            $limiter = $this->postLimiter->create($dto->ip);\n            if ($limiter && false === $limiter->consume()->isAccepted()) {\n                throw new TooManyRequestsHttpException();\n            }\n        }\n\n        if ($dto->magazine->isBanned($user) || $user->isBanned()) {\n            throw new UserBannedException();\n        }\n\n        if ($this->tagManager->isAnyTagBanned($this->tagManager->extract($dto->body))) {\n            throw new TagBannedException();\n        }\n\n        if (null !== $dto->magazine->apId && $this->settingsManager->isBannedInstance($dto->magazine->apInboxUrl)) {\n            throw new InstanceBannedException();\n        }\n\n        $post = $this->factory->createFromDto($dto, $user);\n\n        $post->lang = $dto->lang;\n        $post->isAdult = $dto->isAdult || $post->magazine->isAdult;\n        $post->slug = $this->slugger->slug($dto->body ?? $dto->magazine->name.' '.$dto->image->altText);\n        $post->image = $dto->image ? $this->imageRepository->find($dto->image->id) : null;\n        $this->logger->debug('setting image to {imageId}, dto was {dtoImageId}', ['imageId' => $post->image?->getId() ?? 'none', 'dtoImageId' => $dto->image?->id ?? 'none']);\n        if ($post->image && !$post->image->altText) {\n            $post->image->altText = $dto->imageAlt;\n        }\n        $post->mentions = $dto->body ? $this->mentionManager->extract($dto->body) : null;\n        $post->visibility = $dto->visibility;\n        $post->apId = $dto->apId;\n        $post->apLikeCount = $dto->apLikeCount;\n        $post->apDislikeCount = $dto->apDislikeCount;\n        $post->apShareCount = $dto->apShareCount;\n        $post->magazine->lastActive = new \\DateTime();\n        $post->user->lastActive = new \\DateTime();\n        $post->lastActive = $dto->lastActive ?? $post->lastActive;\n        $post->createdAt = $dto->createdAt ?? $post->createdAt;\n        if (empty($post->body) && null === $post->image) {\n            throw new \\Exception('Post body and image cannot be empty');\n        }\n\n        $post->updateScore();\n        $post->updateRanking();\n\n        $this->entityManager->persist($post);\n        $this->entityManager->flush();\n\n        $this->tagManager->updatePostTags($post, $this->tagExtractor->extract($post->body) ?? []);\n\n        $this->dispatcher->dispatch(new PostCreatedEvent($post));\n\n        if ($stickyIt) {\n            $this->pin($post);\n        }\n\n        return $post;\n    }\n\n    public function canUserEditPost(Post $post, User $user): bool\n    {\n        $postHost = null !== $post->apId ? parse_url($post->apId, PHP_URL_HOST) : $this->settingsManager->get('KBIN_DOMAIN');\n        $userHost = null !== $user->apId ? parse_url($user->apProfileId, PHP_URL_HOST) : $this->settingsManager->get('KBIN_DOMAIN');\n        $magazineHost = null !== $post->magazine->apId ? parse_url($post->magazine->apProfileId, PHP_URL_HOST) : $this->settingsManager->get('KBIN_DOMAIN');\n\n        return $postHost === $userHost || $userHost === $magazineHost || $post->magazine->userIsModerator($user);\n    }\n\n    public function edit(Post $post, PostDto $dto, ?User $editedBy = null): Post\n    {\n        Assert::same($post->magazine->getId(), $dto->magazine->getId());\n\n        $post->body = $dto->body;\n        $post->lang = $dto->lang;\n        $post->isAdult = $dto->isAdult || $post->magazine->isAdult;\n        $post->isLocked = $dto->isLocked;\n        $post->slug = $this->slugger->slug($dto->body ?? $dto->magazine->name.' '.$dto->image->altText);\n        $oldImage = $post->image;\n        if ($dto->image) {\n            $post->image = $this->imageRepository->find($dto->image->id);\n        }\n        $this->tagManager->updatePostTags($post, $this->tagExtractor->extract($dto->body) ?? []);\n        $post->mentions = $dto->body ? $this->mentionManager->extract($dto->body) : null;\n        $post->visibility = $dto->visibility;\n        $post->editedAt = new \\DateTimeImmutable('@'.time());\n        if (empty($post->body) && null === $post->image) {\n            throw new \\Exception('Post body and image cannot be empty');\n        }\n\n        $post->apLikeCount = $dto->apLikeCount;\n        $post->apDislikeCount = $dto->apDislikeCount;\n        $post->apShareCount = $dto->apShareCount;\n        $post->updateScore();\n        $post->updateRanking();\n\n        $this->entityManager->flush();\n\n        if ($oldImage && $post->image !== $oldImage) {\n            $this->bus->dispatch(new DeleteImageMessage($oldImage->getId()));\n        }\n\n        $this->dispatcher->dispatch(new PostEditedEvent($post, $editedBy));\n\n        return $post;\n    }\n\n    public function delete(User $user, Post $post): void\n    {\n        if ($user->apDomain && $user->apDomain !== parse_url($post->apId ?? '', PHP_URL_HOST) && !$post->magazine->userIsModerator($user)) {\n            $this->logger->info('Got a delete activity from user {u}, but they are not from the same instance as the deleted post and they are not a moderator on {m]', ['u' => $user->apId, 'm' => $post->magazine->apId ?? $post->magazine->name]);\n\n            return;\n        }\n\n        if ($post->isAuthor($user) && $post->comments->isEmpty()) {\n            $this->purge($user, $post);\n\n            return;\n        }\n\n        $this->isTrashed($user, $post) ? $post->trash() : $post->softDelete();\n\n        $this->dispatcher->dispatch(new PostBeforeDeletedEvent($post, $user));\n\n        $this->entityManager->flush();\n\n        $this->dispatcher->dispatch(new PostDeletedEvent($post, $user));\n    }\n\n    public function trash(User $user, Post $post): void\n    {\n        $post->trash();\n\n        $this->dispatcher->dispatch(new PostBeforeDeletedEvent($post, $user));\n\n        $this->entityManager->flush();\n\n        $this->dispatcher->dispatch(new PostDeletedEvent($post, $user));\n    }\n\n    public function purge(User $user, Post $post): void\n    {\n        $this->dispatcher->dispatch(new PostBeforePurgeEvent($post, $user));\n\n        $image = $post->image?->getId();\n\n        $sort = new Criteria(null, ['createdAt' => Order::Descending]);\n        foreach ($post->comments->matching($sort) as $comment) {\n            $this->postCommentManager->purge($user, $comment);\n        }\n\n        $this->entityManager->remove($post);\n        $this->entityManager->flush();\n\n        if ($image) {\n            $this->bus->dispatch(new DeleteImageMessage($image));\n        }\n    }\n\n    private function isTrashed(User $user, Post $post): bool\n    {\n        return !$post->isAuthor($user);\n    }\n\n    public function restore(User $user, Post $post): void\n    {\n        if (VisibilityInterface::VISIBILITY_TRASHED !== $post->visibility) {\n            throw new \\Exception('Invalid visibility');\n        }\n\n        $post->visibility = VisibilityInterface::VISIBILITY_VISIBLE;\n\n        $this->entityManager->persist($post);\n        $this->entityManager->flush();\n\n        $this->dispatcher->dispatch(new PostRestoredEvent($post, $user));\n    }\n\n    /**\n     * this toggles the pin state of the post. If it was not pinned it pins, if it was pinned it unpins it.\n     */\n    public function pin(Post $post): Post\n    {\n        $post->sticky = !$post->sticky;\n\n        $this->entityManager->flush();\n\n        if (null !== $post->magazine->apFeaturedUrl) {\n            $this->apHttpClient->invalidateCollectionObjectCache($post->magazine->apFeaturedUrl);\n        }\n\n        return $post;\n    }\n\n    public function toggleLock(Post $post, ?User $actor): Post\n    {\n        $post->isLocked = !$post->isLocked;\n\n        if ($post->isLocked) {\n            $log = new MagazineLogPostLocked($post, $actor);\n        } else {\n            $log = new MagazineLogPostUnlocked($post, $actor);\n        }\n        $this->entityManager->persist($log);\n        $this->entityManager->flush();\n\n        $this->dispatcher->dispatch(new PostLockEvent($post, $actor));\n\n        return $post;\n    }\n\n    public function createDto(Post $post): PostDto\n    {\n        return $this->factory->createDto($post);\n    }\n\n    public function detachImage(Post $post): void\n    {\n        $image = $post->image->getId();\n\n        $post->image = null;\n\n        $this->entityManager->persist($post);\n        $this->entityManager->flush();\n\n        $this->bus->dispatch(new DeleteImageMessage($image));\n    }\n\n    public function getSortRoute(string $sortBy): string\n    {\n        return strtolower($this->translator->trans($sortBy));\n    }\n\n    public function changeMagazine(Post $post, Magazine $magazine): void\n    {\n        $this->entityManager->beginTransaction();\n\n        try {\n            $oldMagazine = $post->magazine;\n            $post->magazine = $magazine;\n\n            foreach ($post->comments as $comment) {\n                $comment->magazine = $magazine;\n            }\n\n            $this->entityManager->flush();\n            $this->entityManager->commit();\n        } catch (\\Exception $e) {\n            $this->entityManager->rollback();\n\n            return;\n        }\n\n        $oldMagazine->postCommentCount = $this->postRepository->countPostCommentsByMagazine($oldMagazine);\n        $oldMagazine->postCount = $this->postRepository->countPostsByMagazine($oldMagazine);\n\n        $magazine->postCommentCount = $this->postRepository->countPostCommentsByMagazine($magazine);\n        $magazine->postCount = $this->postRepository->countPostsByMagazine($magazine);\n\n        $this->entityManager->flush();\n\n        $this->cache->invalidateTags(['post_'.$post->getId()]);\n    }\n}\n"
  },
  {
    "path": "src/Service/ProjectInfoService.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service;\n\n/**\n * A service that helps retrieving project information, like current version or project name.\n */\nclass ProjectInfoService\n{\n    // If updating version, please also update http client UA in [/config/packages/framework.yaml]\n    private const VERSION = '1.10.0-rc1'; // TODO: Retrieve the version from git tags or getenv()?\n    private const NAME = 'mbin';\n    private const CANONICAL_NAME = 'Mbin';\n    private const REPOSITORY_URL = 'https://github.com/MbinOrg/mbin';\n\n    public function __construct(\n        private readonly string $kbinDomain,\n    ) {\n    }\n\n    /**\n     * Get Mbin current project version.\n     *\n     * @return version\n     */\n    public function getVersion(): string\n    {\n        return self::VERSION;\n    }\n\n    /**\n     * Get project name.\n     *\n     * @return name\n     */\n    public function getName(): string\n    {\n        return self::NAME;\n    }\n\n    /**\n     * Get project canonical name.\n     *\n     * @return string canonical name\n     */\n    public function getCanonicalName(): string\n    {\n        return self::CANONICAL_NAME;\n    }\n\n    /**\n     * Get user-agent name usable as HTTP client requests.\n     *\n     * @return user-agent string\n     */\n    public function getUserAgent(): string\n    {\n        return \"{$this->getCanonicalName()}/{$this->getVersion()} (+https://{$this->kbinDomain}/agent)\";\n    }\n\n    /**\n     * Get Mbin repository URL.\n     *\n     * @return URL\n     */\n    public function getRepositoryURL(): string\n    {\n        return self::REPOSITORY_URL;\n    }\n}\n"
  },
  {
    "path": "src/Service/RemoteInstanceManager.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service;\n\nuse App\\Controller\\ActivityPub\\NodeInfoController;\nuse App\\Entity\\Instance;\nuse App\\Payloads\\NodeInfo\\NodeInfo;\nuse App\\Payloads\\NodeInfo\\WellKnownNodeInfo;\nuse App\\Service\\ActivityPub\\ApHttpClientInterface;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Psr\\Log\\LoggerInterface;\nuse Symfony\\Component\\PropertyInfo\\Extractor\\ConstructorExtractor;\nuse Symfony\\Component\\PropertyInfo\\Extractor\\PhpDocExtractor;\nuse Symfony\\Component\\PropertyInfo\\Extractor\\ReflectionExtractor;\nuse Symfony\\Component\\PropertyInfo\\PropertyInfoExtractor;\nuse Symfony\\Component\\Serializer\\Encoder\\JsonEncoder;\nuse Symfony\\Component\\Serializer\\Normalizer\\ArrayDenormalizer;\nuse Symfony\\Component\\Serializer\\Normalizer\\ObjectNormalizer;\nuse Symfony\\Component\\Serializer\\Serializer;\n\nclass RemoteInstanceManager\n{\n    public function __construct(\n        private readonly EntityManagerInterface $entityManager,\n        private readonly ApHttpClientInterface $client,\n        private readonly LoggerInterface $logger,\n    ) {\n    }\n\n    public function updateInstance(Instance $instance, bool $force = false): bool\n    {\n        // only update the instance once every day\n        if ($instance->getUpdatedAt() < new \\DateTime('now - 1day') || $force) {\n            $nodeInfoEndpointsRaw = $this->client->fetchInstanceNodeInfoEndpoints($instance->domain, false);\n            $serializer = $this->getSerializer();\n            $linkToUse = null;\n            if (null !== $nodeInfoEndpointsRaw) {\n                /** @var WellKnownNodeInfo $nodeInfoEndpoints */\n                $nodeInfoEndpoints = $serializer->deserialize($nodeInfoEndpointsRaw, WellKnownNodeInfo::class, 'json');\n\n                foreach ($nodeInfoEndpoints->links as $link) {\n                    if (NodeInfoController::NODE_REL_v21 === $link->rel) {\n                        $linkToUse = $link;\n                        break;\n                    } elseif (null === $linkToUse && NodeInfoController::NODE_REL_v20 === $link->rel) {\n                        $linkToUse = $link;\n                    }\n                }\n            }\n\n            if (null === $linkToUse) {\n                $this->logger->info('Instance {i} does not supply a valid nodeinfo endpoint.', ['i' => $instance->domain]);\n                $instance->setUpdatedAt();\n\n                return true;\n            }\n\n            $nodeInfoRaw = $this->client->fetchInstanceNodeInfo($linkToUse->href, false);\n            $this->logger->debug('got raw nodeinfo for url {url}: {raw}', ['raw' => $nodeInfoRaw, 'url' => $linkToUse]);\n            try {\n                /** @var NodeInfo $nodeInfo */\n                $nodeInfo = $serializer->deserialize($nodeInfoRaw, NodeInfo::class, 'json');\n                $instance->software = $nodeInfo?->software?->name;\n                $instance->version = $nodeInfo?->software?->version;\n            } catch (\\Error|\\Exception $e) {\n                $this->logger->warning('There as an exception decoding the nodeinfo from {url}: {e} - {m}', [\n                    'url' => $instance->domain,\n                    'e' => \\get_class($e),\n                    'm' => $e->getMessage(),\n                ]);\n            }\n            $instance->setUpdatedAt();\n            $this->entityManager->persist($instance);\n\n            return true;\n        }\n\n        return false;\n    }\n\n    public function getSerializer(): Serializer\n    {\n        $phpDocExtractor = new PhpDocExtractor();\n        $typeExtractor = new PropertyInfoExtractor(\n            typeExtractors: [\n                new ConstructorExtractor([$phpDocExtractor]),\n                $phpDocExtractor,\n                new ReflectionExtractor(),\n            ]\n        );\n\n        return new Serializer(\n            normalizers: [\n                new ObjectNormalizer(propertyTypeExtractor: $typeExtractor),\n                new ArrayDenormalizer(),\n            ],\n            encoders: ['json' => new JsonEncoder()]\n        );\n    }\n}\n"
  },
  {
    "path": "src/Service/ReportManager.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service;\n\nuse App\\DTO\\ReportDto;\nuse App\\Entity\\Report;\nuse App\\Entity\\User;\nuse App\\Event\\Report\\ReportApprovedEvent;\nuse App\\Event\\Report\\ReportRejectedEvent;\nuse App\\Event\\Report\\SubjectReportedEvent;\nuse App\\Exception\\SubjectHasBeenReportedException;\nuse App\\Factory\\ContentManagerFactory;\nuse App\\Factory\\ReportFactory;\nuse App\\Repository\\ReportRepository;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Psr\\EventDispatcher\\EventDispatcherInterface;\n\nclass ReportManager\n{\n    public function __construct(\n        private readonly ReportFactory $factory,\n        private readonly ReportRepository $repository,\n        private readonly EventDispatcherInterface $dispatcher,\n        private readonly EntityManagerInterface $entityManager,\n        private readonly ContentManagerFactory $managerFactory,\n    ) {\n    }\n\n    public function report(ReportDto $dto, User $reporting): Report\n    {\n        $report = $this->repository->findBySubject($dto->getSubject());\n\n        if ($report) {\n            $report->increaseWeight();\n            $this->entityManager->flush();\n            throw new SubjectHasBeenReportedException();\n        }\n\n        $report = $this->factory->createFromDto($dto);\n        $report->reporting = $reporting;\n\n        $this->entityManager->persist($report);\n        $this->entityManager->flush();\n\n        $this->dispatcher->dispatch(new SubjectReportedEvent($report));\n\n        return $report;\n    }\n\n    public function reject(Report $report, User $moderator): void\n    {\n        $manager = $this->managerFactory->createManager($report->getSubject());\n\n        $report->status = Report::STATUS_REJECTED;\n        $report->consideredBy = $moderator;\n        $report->consideredAt = new \\DateTimeImmutable();\n\n        if ($report->getSubject()->isTrashed()) {\n            $manager->restore($moderator, $report->getSubject());\n        }\n\n        $this->entityManager->flush();\n\n        $this->dispatcher->dispatch(new ReportRejectedEvent($report));\n    }\n\n    public function accept(Report $report, User $moderator): void\n    {\n        $manager = $this->managerFactory->createManager($report->getSubject());\n\n        $report->status = Report::STATUS_APPROVED;\n        $report->consideredBy = $moderator;\n        $report->consideredAt = new \\DateTimeImmutable();\n\n        $manager->delete($moderator, $report->getSubject());\n\n        $this->entityManager->flush();\n\n        $this->dispatcher->dispatch(new ReportApprovedEvent($report));\n    }\n}\n"
  },
  {
    "path": "src/Service/ReputationManager.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service;\n\nuse App\\Repository\\ReputationRepository;\n\nclass ReputationManager\n{\n    public function resolveType(?string $value, ?string $default = null): string\n    {\n        $routes = [\n            'threads' => ReputationRepository::TYPE_ENTRY,\n            'comments' => ReputationRepository::TYPE_ENTRY_COMMENT,\n            'posts' => ReputationRepository::TYPE_POST,\n            'replies' => ReputationRepository::TYPE_POST_COMMENT,\n\n            'treści' => ReputationRepository::TYPE_ENTRY,\n            'komentarze' => ReputationRepository::TYPE_ENTRY_COMMENT,\n            'wpisy' => ReputationRepository::TYPE_POST,\n            'odpowiedzi' => ReputationRepository::TYPE_POST_COMMENT,\n        ];\n\n        return $routes[$value] ?? $routes[$default ?? ReputationRepository::TYPE_ENTRY];\n    }\n}\n"
  },
  {
    "path": "src/Service/SearchManager.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service;\n\nuse App\\ActivityPub\\ActorHandle;\nuse App\\Entity\\Contracts\\ContentInterface;\nuse App\\Entity\\Magazine;\nuse App\\Entity\\User;\nuse App\\Message\\ActivityPub\\Inbox\\ActivityMessage;\nuse App\\Message\\ActivityPub\\Inbox\\CreateMessage;\nuse App\\Repository\\DomainRepository;\nuse App\\Repository\\MagazineRepository;\nuse App\\Repository\\SearchRepository;\nuse App\\Service\\ActivityPub\\ApHttpClientInterface;\nuse App\\Utils\\RegPatterns;\nuse Pagerfanta\\Adapter\\ArrayAdapter;\nuse Pagerfanta\\Pagerfanta;\nuse Pagerfanta\\PagerfantaInterface;\nuse Psr\\Log\\LoggerInterface;\nuse Symfony\\Component\\Messenger\\MessageBusInterface;\nuse Symfony\\Component\\Messenger\\Stamp\\TransportNamesStamp;\n\nclass SearchManager\n{\n    public function __construct(\n        private readonly SearchRepository $repository,\n        private readonly MagazineRepository $magazineRepository,\n        private readonly DomainRepository $domainRepository,\n        private readonly ActivityPubManager $activityPubManager,\n        private readonly MessageBusInterface $bus,\n        private readonly ApHttpClientInterface $apHttpClient,\n        private readonly LoggerInterface $logger,\n    ) {\n    }\n\n    /**\n     * Not implemented yet.\n     */\n    public function findByTagPaginated(string $val, int $page = 1): PagerfantaInterface\n    {\n        return new Pagerfanta(new ArrayAdapter([]));\n    }\n\n    public function findMagazinesPaginated(string $magazine, int $page = 1, int $perPage = MagazineRepository::PER_PAGE): PagerfantaInterface\n    {\n        return $this->magazineRepository->search($magazine, $page, $perPage);\n    }\n\n    public function findDomainsPaginated(string $domain, int $page = 1, int $perPage = DomainRepository::PER_PAGE): Pagerfanta\n    {\n        return $this->domainRepository->search($domain, $page, $perPage);\n    }\n\n    public function findPaginated(\n        ?User $queryingUser,\n        string $val,\n        int $page = 1,\n        int $perPage = SearchRepository::PER_PAGE,\n        ?int $authorId = null,\n        ?int $magazineId = null,\n        ?string $specificType = null,\n        ?\\DateTimeImmutable $sinceDate = null,\n    ): PagerfantaInterface {\n        return $this->repository->search($queryingUser, $val, $page, authorId: $authorId, magazineId: $magazineId, specificType: $specificType, sinceDate: $sinceDate, perPage: $perPage);\n    }\n\n    public function findByApId(string $url): array\n    {\n        return $this->repository->findByApId($url);\n    }\n\n    public function findRelated(string $query): array\n    {\n        return [];\n    }\n\n    /**\n     * Tries to find the actor or object in the DB, else will dispatch a getActorObject or getActivityObject request.\n     *\n     * @param string $handleOrUrl a string that may be a handle or AP URL\n     *\n     * @return array{'results': array{'type': 'magazine'|'user'|'subject', 'object': Magazine|User|ContentInterface}, 'errors': \\Throwable[]}\n     */\n    public function findActivityPubActorsOrObjects(string $handleOrUrl): array\n    {\n        $handle = ActorHandle::parse($handleOrUrl);\n        if (null !== $handle) {\n            $handleOrUrl = $handle->plainHandle();\n            $isUrl = false;\n        } elseif (filter_var($handleOrUrl, FILTER_VALIDATE_URL)) {\n            $isUrl = true;\n        } else {\n            return [\n                'results' => [],\n                'errors' => [],\n            ];\n        }\n\n        // try resolving it as an actor\n        try {\n            $actor = $this->activityPubManager->findActorOrCreate($handleOrUrl);\n            if (null !== $actor) {\n                $objects = $this->mapApResultsToSearchModel([$actor]);\n\n                return [\n                    'results' => $objects,\n                    'errors' => [],\n                ];\n            } elseif (!$isUrl) {\n                // lookup of handle failed -> give up\n                return [\n                    'results' => [],\n                    'errors' => [],\n                ];\n            }\n        } catch (\\Throwable $e) {\n            if (!$isUrl) {\n                // lookup of handle failed -> give up\n                return [\n                    'results' => [],\n                    'errors' => [$e],\n                ];\n            }\n        }\n\n        $url = $handleOrUrl;\n        $exceptions = [];\n        $objects = $this->findByApId($url);\n        if (0 === \\sizeof($objects)) {\n            // the url could resolve to a different id.\n            try {\n                $body = $this->apHttpClient->getActivityObject($url);\n                if (null !== $body && isset($body['id'])) {\n                    $apId = $body['id'];\n                    $objects = $this->findByApId($apId);\n                } else {\n                    $apId = $url;\n                }\n            } catch (\\Throwable $e) {\n                $body = null;\n                $apId = $url;\n                $exceptions[] = $e;\n            }\n\n            if (0 === \\sizeof($objects) && null !== $body) {\n                // maybe it is an entry, post, etc.\n                try {\n                    // process the message in the sync transport, so that the created content is directly visible\n                    $this->bus->dispatch(new CreateMessage($body), [new TransportNamesStamp('sync')]);\n                    $objects = $this->findByApId($apId);\n                } catch (\\Throwable $e) {\n                    $exceptions[] = $e;\n                }\n            }\n\n            if (0 === \\sizeof($objects)) {\n                // maybe it is a magazine or user\n                try {\n                    $this->activityPubManager->findActorOrCreate($apId);\n                    $objects = $this->findByApId($apId);\n                } catch (\\Throwable $e) {\n                    $exceptions[] = $e;\n                }\n            }\n        }\n\n        return [\n            'results' => $this->mapApResultsToSearchModel($objects),\n            'errors' => $exceptions,\n        ];\n    }\n\n    private function mapApResultsToSearchModel(array $objects): array\n    {\n        return array_map(function ($object) {\n            if ($object instanceof Magazine) {\n                $type = 'magazine';\n            } elseif ($object instanceof User) {\n                $type = 'user';\n            } else {\n                $type = 'subject';\n            }\n\n            return [\n                'type' => $type,\n                'object' => $object,\n            ];\n        }, $objects);\n    }\n\n    // region deprecated functions kept for API compatibility\n    /**\n     * @param string $query One or more canonical ActivityPub usernames, such as kbinMeta@kbin.social or @ernest@kbin.social (anything that matches RegPatterns::AP_USER)\n     *\n     * @return array a list of magazines or users that were found using the given identifiers, empty if none were found or no @ is in the query\n     */\n    #[\\Deprecated]\n    public function findActivityPubActorsByUsername(string $query): array\n    {\n        if (false === str_contains($query, '@')) {\n            return [];\n        }\n\n        $objects = [];\n        $name = str_starts_with($query, '!') ? '@'.substr($query, 1) : $query;\n        $name = str_starts_with($name, '@') ? $name : '@'.$name;\n        preg_match(RegPatterns::AP_USER, $name, $matches);\n        if (\\count(array_filter($matches)) >= 4) {\n            try {\n                $webfinger = $this->activityPubManager->webfinger($name);\n                foreach ($webfinger->getProfileIds() as $profileId) {\n                    $object = $this->activityPubManager->findActorOrCreate($profileId);\n                    if (!empty($object)) {\n                        if ($object instanceof Magazine) {\n                            $type = 'magazine';\n                        } elseif ($object instanceof User) {\n                            $type = 'user';\n                        }\n\n                        $objects[] = [\n                            'type' => $type,\n                            'object' => $object,\n                        ];\n                    }\n                }\n            } catch (\\Exception $e) {\n            }\n        }\n\n        return $objects ?? [];\n    }\n\n    /**\n     * @param string $query a string that may or may not be a URL\n     *\n     * @return array A list of objects found by the given query, or an empty array if none were found.\n     *               Will dispatch a getActivityObject request if a valid URL was provided but no item was found\n     *               locally.\n     */\n    #[\\Deprecated]\n    public function findActivityPubObjectsByURL(string $query): array\n    {\n        if (false === filter_var($query, FILTER_VALIDATE_URL)) {\n            return [];\n        }\n\n        $objects = $this->findByApId($query);\n        if (!$objects) {\n            $body = $this->apHttpClient->getActivityObject($query, false);\n            $this->bus->dispatch(new ActivityMessage($body));\n        }\n\n        return $objects ?? [];\n    }\n    // endregion\n}\n"
  },
  {
    "path": "src/Service/SettingsManager.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service;\n\nuse App\\DTO\\SettingsDto;\nuse App\\Entity\\Instance;\nuse App\\Entity\\Settings;\nuse App\\Repository\\InstanceRepository;\nuse App\\Repository\\SettingsRepository;\nuse App\\Utils\\DownvotesMode;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse JetBrains\\PhpStorm\\Pure;\nuse Psr\\Log\\LoggerInterface;\nuse Symfony\\Component\\HttpFoundation\\RequestStack;\nuse Symfony\\Component\\HttpKernel\\KernelInterface;\nuse Symfony\\Component\\Messenger\\Exception\\UnrecoverableMessageHandlingException;\n\nclass SettingsManager\n{\n    private static ?SettingsDto $dto = null;\n\n    private SettingsDto $instanceDto;\n\n    public function __construct(\n        private readonly EntityManagerInterface $entityManager,\n        private readonly SettingsRepository $repository,\n        private readonly RequestStack $requestStack,\n        private readonly KernelInterface $kernel,\n        private readonly InstanceRepository $instanceRepository,\n        private readonly string $kbinDomain,\n        private readonly string $kbinTitle,\n        private readonly string $kbinMetaTitle,\n        private readonly string $kbinMetaDescription,\n        private readonly string $kbinMetaKeywords,\n        private readonly string $kbinDefaultLang,\n        private readonly string $kbinContactEmail,\n        private readonly string $kbinSenderEmail,\n        private readonly string $mbinDefaultTheme,\n        private readonly bool $kbinJsEnabled,\n        private readonly bool $kbinFederationEnabled,\n        private readonly bool $kbinRegistrationsEnabled,\n        private readonly bool $kbinHeaderLogo,\n        private readonly bool $kbinCaptchaEnabled,\n        private readonly bool $kbinFederationPageEnabled,\n        private readonly bool $kbinAdminOnlyOauthClients,\n        private readonly bool $mbinSsoOnlyMode,\n        private readonly int $mbinMaxImageBytes,\n        private readonly DownvotesMode $mbinDownvotesMode,\n        private readonly bool $mbinNewUsersNeedApproval,\n        private readonly LoggerInterface $logger,\n        private readonly bool $mbinUseFederationAllowList,\n    ) {\n        if (!self::$dto || 'test' === $this->kernel->getEnvironment()) {\n            $results = $this->repository->findAll();\n\n            $newUsersNeedApprovalDb = $this->find($results, 'MBIN_NEW_USERS_NEED_APPROVAL');\n            if ('true' === $newUsersNeedApprovalDb) {\n                $newUsersNeedApprovalEdited = true;\n            } elseif ('false' === $newUsersNeedApprovalDb) {\n                $newUsersNeedApprovalEdited = false;\n            } else {\n                $newUsersNeedApprovalEdited = $this->mbinNewUsersNeedApproval;\n            }\n\n            $dto = new SettingsDto(\n                $this->kbinDomain,\n                $this->find($results, 'KBIN_TITLE') ?? $this->kbinTitle,\n                $this->find($results, 'KBIN_META_TITLE') ?? $this->kbinMetaTitle,\n                $this->find($results, 'KBIN_META_KEYWORDS') ?? $this->kbinMetaKeywords,\n                $this->find($results, 'KBIN_META_DESCRIPTION') ?? $this->kbinMetaDescription,\n                $this->find($results, 'KBIN_DEFAULT_LANG') ?? $this->kbinDefaultLang,\n                $this->find($results, 'KBIN_CONTACT_EMAIL') ?? $this->kbinContactEmail,\n                $this->find($results, 'KBIN_SENDER_EMAIL') ?? $this->kbinSenderEmail,\n                $this->find($results, 'MBIN_DEFAULT_THEME') ?? $this->mbinDefaultTheme,\n                $this->find($results, 'KBIN_JS_ENABLED', FILTER_VALIDATE_BOOLEAN) ?? $this->kbinJsEnabled,\n                $this->find(\n                    $results,\n                    'KBIN_FEDERATION_ENABLED',\n                    FILTER_VALIDATE_BOOLEAN\n                ) ?? $this->kbinFederationEnabled,\n                $this->find(\n                    $results,\n                    'KBIN_REGISTRATIONS_ENABLED',\n                    FILTER_VALIDATE_BOOLEAN\n                ) ?? $this->kbinRegistrationsEnabled,\n                $this->find($results, 'KBIN_HEADER_LOGO', FILTER_VALIDATE_BOOLEAN) ?? $this->kbinHeaderLogo,\n                $this->find($results, 'KBIN_CAPTCHA_ENABLED', FILTER_VALIDATE_BOOLEAN) ?? $this->kbinCaptchaEnabled,\n                $this->find($results, 'KBIN_MERCURE_ENABLED', FILTER_VALIDATE_BOOLEAN) ?? false,\n                $this->find($results, 'KBIN_FEDERATION_PAGE_ENABLED', FILTER_VALIDATE_BOOLEAN) ?? $this->kbinFederationPageEnabled,\n                $this->find($results, 'KBIN_ADMIN_ONLY_OAUTH_CLIENTS', FILTER_VALIDATE_BOOLEAN) ?? $this->kbinAdminOnlyOauthClients,\n                $this->find($results, 'MBIN_SSO_ONLY_MODE', FILTER_VALIDATE_BOOLEAN) ?? $this->mbinSsoOnlyMode,\n                $this->find($results, 'MBIN_PRIVATE_INSTANCE', FILTER_VALIDATE_BOOLEAN) ?? false,\n                $this->find($results, 'KBIN_FEDERATED_SEARCH_ONLY_LOGGEDIN', FILTER_VALIDATE_BOOLEAN) ?? true,\n                $this->find($results, 'MBIN_SIDEBAR_SECTIONS_RANDOM_LOCAL_ONLY', FILTER_VALIDATE_BOOLEAN) ?? false,\n                $this->find($results, 'MBIN_SIDEBAR_SECTIONS_USERS_LOCAL_ONLY', FILTER_VALIDATE_BOOLEAN) ?? false,\n                $this->find($results, 'MBIN_SSO_REGISTRATIONS_ENABLED', FILTER_VALIDATE_BOOLEAN) ?? true,\n                $this->find($results, 'MBIN_RESTRICT_MAGAZINE_CREATION', FILTER_VALIDATE_BOOLEAN) ?? false,\n                $this->find($results, 'MBIN_SSO_SHOW_FIRST', FILTER_VALIDATE_BOOLEAN) ?? false,\n                $this->find($results, 'MBIN_DOWNVOTES_MODE') ?? $this->mbinDownvotesMode->value,\n                $newUsersNeedApprovalEdited,\n                $this->find($results, 'MBIN_USE_FEDERATION_ALLOW_LIST', FILTER_VALIDATE_BOOLEAN) ?? $this->mbinUseFederationAllowList,\n            );\n            $this->instanceDto = $dto;\n        } else {\n            $this->instanceDto = self::$dto;\n        }\n    }\n\n    private function find(array $results, string $name, ?int $filter = null)\n    {\n        $res = array_values(array_filter($results, fn ($s) => $s->name === $name));\n\n        if (\\count($res)) {\n            $res = $res[0]->value ?? $res[0]->json;\n\n            if ($filter) {\n                $res = filter_var($res, $filter);\n            }\n\n            return $res;\n        }\n\n        return null;\n    }\n\n    public function getDto(): SettingsDto\n    {\n        return $this->instanceDto;\n    }\n\n    public function save(SettingsDto $dto): void\n    {\n        foreach ($dto as $name => $value) {\n            $s = $this->repository->findOneByName($name);\n\n            if (\\is_bool($value)) {\n                $value = $value ? 'true' : 'false';\n            }\n\n            if (!\\is_string($value) && !\\is_array($value)) {\n                $value = \\strval($value);\n            }\n\n            if (!$s) {\n                $s = new Settings($name, $value);\n            }\n\n            if (\\is_array($value)) {\n                $s->json = $value;\n            } else {\n                $s->value = $value;\n            }\n\n            $this->entityManager->persist($s);\n        }\n\n        $this->entityManager->flush();\n    }\n\n    #[Pure]\n    public function isLocalUrl(string $url): bool\n    {\n        return parse_url($url, PHP_URL_HOST) === $this->get('KBIN_DOMAIN');\n    }\n\n    /**\n     * Check if an instance is banned by\n     * checking if the instance URL has a match with the banned instances list.\n     *\n     * @param string $inboxUrl the inbox URL to check\n     */\n    public function isBannedInstance(string $inboxUrl): bool\n    {\n        $host = parse_url($inboxUrl, PHP_URL_HOST);\n        if (null === $host) {\n            // Try to retrieve the caller function (commented-out for performance reasons)\n            // $bt = debug_backtrace();\n            // $caller_function = ($bt[1]) ? $bt[1]['function'] : 'Unknown function caller';\n            $this->logger->error('SettingsManager::isBannedInstance: unable to parse host from URL: {url}', ['url' => $inboxUrl]);\n\n            // Do not retry, retrying will always cause a failure\n            throw new UnrecoverableMessageHandlingException(\\sprintf('Invalid URL provided: %s', $inboxUrl));\n        }\n\n        $finalUrl = str_replace('www.', '', $host);\n        if (!$this->getUseAllowList()) {\n            return \\in_array($finalUrl, $this->instanceRepository->getBannedInstanceUrls());\n        } else {\n            // when using an allow list the instance is considered banned if it does not exist or if it is not explicitly allowed\n            $instance = $this->instanceRepository->findOneBy(['domain' => $finalUrl]);\n\n            return null === $instance || !$instance->isExplicitlyAllowed;\n        }\n    }\n\n    /** @return Instance[] */\n    public function getBannedInstances(): array\n    {\n        return $this->instanceRepository->getBannedInstances();\n    }\n\n    public function getUseAllowList(): bool\n    {\n        return $this->getDto()->MBIN_USE_FEDERATION_ALLOW_LIST;\n    }\n\n    public function getAllowedInstances(): array\n    {\n        return $this->instanceRepository->getAllowedInstances($this->getUseAllowList());\n    }\n\n    public function get(string $name)\n    {\n        return $this->instanceDto->{$name};\n    }\n\n    public function getDownvotesMode(): DownvotesMode\n    {\n        return DownvotesMode::from($this->getDto()->MBIN_DOWNVOTES_MODE);\n    }\n\n    public function getNewUsersNeedApproval(): bool\n    {\n        return $this->getDto()->MBIN_NEW_USERS_NEED_APPROVAL;\n    }\n\n    public function set(string $name, $value): void\n    {\n        $this->instanceDto->{$name} = $value;\n\n        $this->save($this->instanceDto);\n    }\n\n    public function getValue(string $name): string\n    {\n        return $this->instanceDto->{$name};\n    }\n\n    public function getLocale(): string\n    {\n        $request = $this->requestStack->getCurrentRequest();\n\n        return $request->cookies->get('mbin_lang') ?? $request->getLocale() ?? $this->get('KBIN_DEFAULT_LANG');\n    }\n\n    public function getMaxImageBytes(): int\n    {\n        return $this->mbinMaxImageBytes;\n    }\n\n    public function getMaxImageByteString(): string\n    {\n        $bytes = $this->mbinMaxImageBytes;\n        // We use 1000 for MB (instead of 1024, which would be MiB)\n        // Linux is using SI standard, see also: https://wiki.ubuntu.com/UnitsPolicy\n        $megaBytes = round($bytes / pow(1000, 2), 2);\n\n        return $megaBytes.' MB';\n    }\n\n    /**\n     * this should only be called in the test environment.\n     */\n    public static function resetDto(): void\n    {\n        self::$dto = null;\n    }\n}\n"
  },
  {
    "path": "src/Service/StatsManager.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service;\n\nuse App\\Entity\\Magazine;\nuse App\\Entity\\User;\nuse App\\Repository\\StatsContentRepository;\nuse App\\Repository\\StatsRepository;\nuse App\\Repository\\StatsVotesRepository;\nuse App\\Utils\\DownvotesMode;\nuse Symfony\\Contracts\\Translation\\TranslatorInterface;\nuse Symfony\\UX\\Chartjs\\Builder\\ChartBuilderInterface;\nuse Symfony\\UX\\Chartjs\\Model\\Chart;\n\nclass StatsManager\n{\n    public function __construct(\n        private readonly StatsVotesRepository $votesRepository,\n        private readonly StatsContentRepository $contentRepository,\n        private readonly ChartBuilderInterface $chartBuilder,\n        private readonly SettingsManager $settingsManager,\n        private readonly TranslatorInterface $translator,\n    ) {\n    }\n\n    public function drawMonthlyContentChart(?User $user = null, ?Magazine $magazine = null, ?bool $onlyLocal = null): Chart\n    {\n        $stats = $this->contentRepository->getOverallStats($user, $magazine, $onlyLocal);\n\n        $labels = array_map(fn ($val) => ($val['month'] < 10 ? '0' : '').$val['month'].'/'.$val['year'],\n            $stats['entries']);\n\n        return $this->createGeneralDataset($stats, $labels);\n    }\n\n    private function createGeneralDataset(array $stats, array $labels): Chart\n    {\n        $dataset = [\n            [\n                'label' => $this->translator->trans('threads'),\n                'borderColor' => '#4382AD',\n                'data' => array_map(fn ($val) => $val['count'], $stats['entries']),\n            ],\n            [\n                'label' => $this->translator->trans('comments'),\n                'borderColor' => '#6253ac',\n                'data' => array_map(fn ($val) => $val['count'], $stats['comments']),\n            ],\n            [\n                'label' => $this->translator->trans('posts'),\n                'borderColor' => '#ac5353',\n                'data' => array_map(fn ($val) => $val['count'], $stats['posts']),\n            ],\n            [\n                'label' => $this->translator->trans('replies'),\n                'borderColor' => '#09a084',\n                'data' => array_map(fn ($val) => $val['count'], $stats['replies']),\n            ],\n        ];\n\n        $chart = $this->chartBuilder->createChart(Chart::TYPE_LINE);\n\n        return $chart->setData([\n            'labels' => $labels,\n            'datasets' => $dataset,\n        ]);\n    }\n\n    public function drawDailyContentStatsByTime(\\DateTime $start, ?User $user = null, ?Magazine $magazine = null, ?bool $onlyLocal = null): Chart\n    {\n        $stats = $this->contentRepository->getStatsByTime($start, $user, $magazine, $onlyLocal);\n\n        $labels = array_map(fn ($val) => $val['day']->format('Y-m-d'), $stats['entries']);\n\n        return $this->createGeneralDataset($stats, $labels);\n    }\n\n    public function drawMonthlyVotesChart(?User $user = null, ?Magazine $magazine = null, ?bool $onlyLocal = null): Chart\n    {\n        $stats = $this->votesRepository->getOverallStats($user, $magazine, $onlyLocal);\n\n        $labels = array_map(fn ($val) => ($val['month'] < 10 ? '0' : '').$val['month'].'/'.$val['year'],\n            $stats['entries']);\n\n        return $this->createVotesDataset($stats, $labels);\n    }\n\n    private function createVotesDataset(array $stats, array $labels): Chart\n    {\n        $results = [];\n        foreach ($stats['entries'] as $index => $entry) {\n            $entry['up'] = array_sum(array_map(fn ($type) => $type[$index]['up'], $stats));\n            $entry['down'] = DownvotesMode::Disabled !== $this->settingsManager->getDownvotesMode() ? 0 : array_sum(array_map(fn ($type) => $type[$index]['down'], $stats));\n            $entry['boost'] = array_sum(array_map(fn ($type) => $type[$index]['boost'], $stats));\n\n            $results[] = $entry;\n        }\n\n        $dataset = [\n            [\n                'label' => $this->translator->trans('up_votes'),\n                'borderColor' => '#92924c',\n                'data' => array_map(fn ($val) => $val['boost'], $results),\n            ],\n            [\n                'label' => $this->translator->trans('favourites'),\n                'borderColor' => '#3c5211',\n                'data' => array_map(fn ($val) => $val['up'], $results),\n            ],\n            [\n                'label' => $this->translator->trans('down_votes'),\n                'borderColor' => '#8f0b00',\n                'data' => DownvotesMode::Disabled !== $this->settingsManager->getDownvotesMode() ? [] : array_map(fn ($val) => $val['down'], $results),\n            ],\n        ];\n\n        $chart = $this->chartBuilder->createChart(Chart::TYPE_LINE);\n\n        return $chart->setData([\n            'labels' => $labels,\n            'datasets' => $dataset,\n        ]);\n    }\n\n    public function drawDailyVotesStatsByTime(\\DateTime $start, ?User $user = null, ?Magazine $magazine = null, ?bool $onlyLocal = null): Chart\n    {\n        $stats = $this->votesRepository->getStatsByTime($start, $user, $magazine, $onlyLocal);\n\n        $labels = array_map(fn ($val) => $val['day']->format('Y-m-d'), $stats['entries']);\n\n        return $this->createVotesDataset($stats, $labels);\n    }\n\n    public function resolveType(?string $value, ?string $default = null): string\n    {\n        $routes = [\n            'general' => StatsRepository::TYPE_GENERAL,\n            'content' => StatsRepository::TYPE_CONTENT,\n            'votes' => StatsRepository::TYPE_VOTES,\n        ];\n\n        return $routes[$value] ?? $routes[$default ?? StatsRepository::TYPE_GENERAL];\n    }\n}\n"
  },
  {
    "path": "src/Service/SubjectOverviewManager.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service;\n\nuse App\\Entity\\Entry;\nuse App\\Entity\\EntryComment;\nuse App\\Entity\\Post;\nuse App\\Entity\\PostComment;\nuse Pagerfanta\\PagerfantaInterface;\n\nclass SubjectOverviewManager\n{\n    public function buildList(PagerfantaInterface $activity): array\n    {\n        $postsAndEntries = array_filter(\n            $activity->getCurrentPageResults(),\n            fn ($val) => $val instanceof Entry || $val instanceof Post\n        );\n        $comments = array_filter(\n            $activity->getCurrentPageResults(),\n            fn ($val) => $val instanceof EntryComment || $val instanceof PostComment\n        );\n\n        $results = [];\n        foreach ($postsAndEntries as $parent) {\n            if ($parent instanceof Entry) {\n                $children = array_filter(\n                    $comments,\n                    fn ($val) => $val instanceof EntryComment && $val->entry === $parent\n                );\n                $comments = array_filter(\n                    $comments,\n                    fn ($val) => $val instanceof PostComment || $val instanceof EntryComment && $val->entry !== $parent\n                );\n            } else {\n                $children = array_filter(\n                    $comments,\n                    fn ($val) => $val instanceof PostComment && $val->post === $parent\n                );\n                $comments = array_filter(\n                    $comments,\n                    fn ($val) => $val instanceof EntryComment || $val instanceof PostComment && $val->post !== $parent\n                );\n            }\n\n            $results[] = $parent;\n\n            foreach ($children as $child) {\n                $parent->children[] = $child;\n            }\n        }\n\n        $parents = [];\n        foreach ($comments as $comment) {\n            $inParents = false;\n            $parent = $comment->entry ?? $comment->post;\n\n            foreach ($parents as $val) {\n                if ($val instanceof $parent && $parent === $val) {\n                    $val->children[] = $comment;\n                    $inParents = true;\n                }\n            }\n\n            if (!$inParents) {\n                $parent->children[] = $comment;\n                $parents[] = $parent;\n            }\n        }\n\n        $merged = array_merge($results, $parents);\n\n        uasort($merged, fn ($a, $b) => $a->getCreatedAt() > $b->getCreatedAt() ? -1 : 1);\n\n        $results = [];\n        foreach ($merged as $entry) {\n            $results[] = $entry;\n            uasort($entry->children, fn ($a, $b) => $a->getCreatedAt() < $b->getCreatedAt() ? -1 : 1);\n            foreach ($entry->children as $child) {\n                $results[] = $child;\n            }\n        }\n\n        return $results;\n    }\n}\n"
  },
  {
    "path": "src/Service/TagExtractor.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service;\n\nuse App\\Utils\\RegPatterns;\nuse App\\Utils\\UrlUtils;\n\nclass TagExtractor\n{\n    public function joinTagsToBody(?string $body, array $tags): string\n    {\n        $current = $this->extract($body) ?? [];\n\n        $join = array_unique(array_merge(array_diff($tags, $current)));\n\n        if (!empty($join)) {\n            if (!empty($body)) {\n                $lastTag = end($current);\n                if (($lastTag && !str_ends_with($body, $lastTag)) || !$lastTag) {\n                    $body = $body.PHP_EOL.PHP_EOL;\n                }\n            }\n\n            $body = $body.' #'.implode(' #', $join);\n        }\n\n        return $body;\n    }\n\n    public function extract(?string $val, ?string $magazineName = null): ?array\n    {\n        if (null === $val) {\n            return null;\n        }\n\n        preg_match_all(RegPatterns::LOCAL_TAG, $val, $matches);\n\n        $result = $matches[1];\n        $result = array_map(fn ($tag) => mb_strtolower(trim($tag)), $result);\n\n        $result = array_values($result);\n\n        $result = array_map(fn ($tag) => $this->transliterate($tag), $result);\n\n        if ($magazineName) {\n            $result = array_diff($result, [$magazineName]);\n        }\n\n        if ($urls = UrlUtils::extractUrlsFromString($val)) {\n            $htmlIds = array_map(fn ($url) => parse_url($url, PHP_URL_FRAGMENT), $urls);\n            $result = array_diff($result, $htmlIds);\n        }\n\n        return \\count($result) ? array_unique(array_values($result)) : null;\n    }\n\n    /**\n     * transliterate and normalize a hashtag identifier.\n     *\n     * mostly recreates Mastodon's hashtag normalization rules, using ICU rules\n     * - try to transliterate modified latin characters to ASCII regions\n     * - normalize widths for fullwidth/halfwidth letters\n     * - strip characters that shouldn't be part of a hashtag\n     *   (borrowed the character set from Mastodon)\n     *\n     * @param string $tag input hashtag identifier to normalize\n     *\n     * @return string normalized hashtag identifier\n     *\n     * @see https://github.com/mastodon/mastodon/blob/main/app/lib/hashtag_normalizer.rb\n     * @see https://github.com/mastodon/mastodon/blob/main/app/models/tag.rb\n     */\n    public function transliterate(string $tag): string\n    {\n        $rules = <<<'ENDRULE'\n        :: Latin-ASCII;\n        :: [\\uFF00-\\uFFEF] NFKC;\n        :: [^[:alnum:][\\u0E47-\\u0E4E][_\\u00B7\\u30FB\\u200c]] Remove;\n        ENDRULE;\n\n        $normalizer = \\Transliterator::createFromRules($rules);\n\n        return iconv('UTF-8', 'UTF-8//TRANSLIT', $normalizer->transliterate($tag));\n    }\n}\n"
  },
  {
    "path": "src/Service/TagManager.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service;\n\nuse App\\DTO\\EntryCommentDto;\nuse App\\DTO\\EntryDto;\nuse App\\Entity\\Entry;\nuse App\\Entity\\EntryComment;\nuse App\\Entity\\Hashtag;\nuse App\\Entity\\Post;\nuse App\\Entity\\PostComment;\nuse App\\Repository\\TagLinkRepository;\nuse App\\Repository\\TagRepository;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse JetBrains\\PhpStorm\\ArrayShape;\n\nclass TagManager\n{\n    public function __construct(\n        private readonly TagRepository $tagRepository,\n        private readonly TagLinkRepository $tagLinkRepository,\n        private readonly EntityManagerInterface $entityManager,\n        private readonly TagExtractor $tagExtractor,\n    ) {\n    }\n\n    public function extract(?string $val, ?string $magazineName = null): ?array\n    {\n        return $this->tagExtractor->extract($val, $magazineName);\n    }\n\n    /**\n     * @param string[] $newTags\n     */\n    public function updateEntryTags(Entry $entry, array $newTags): void\n    {\n        $this->updateTags($newTags,\n            fn () => $this->tagLinkRepository->getTagsOfContent($entry),\n            fn (Hashtag $hashtag) => $this->tagLinkRepository->removeTagOfEntry($entry, $hashtag),\n            fn (Hashtag $hashtag) => $this->tagLinkRepository->addTagToEntry($entry, $hashtag)\n        );\n    }\n\n    public function getTagsFromEntryDto(EntryDto $dto): array\n    {\n        return array_unique(\n            array_filter(\n                array_merge(\n                    $dto->tags ?? [],\n                    $this->tagExtractor->extract($dto->body ?? '') ?? []\n                )\n            )\n        );\n    }\n\n    /**\n     * @param string[] $newTags\n     */\n    public function updateEntryCommentTags(EntryComment $entryComment, array $newTags): void\n    {\n        $this->updateTags($newTags,\n            fn () => $this->tagLinkRepository->getTagsOfContent($entryComment),\n            fn (Hashtag $hashtag) => $this->tagLinkRepository->removeTagOfEntryComment($entryComment, $hashtag),\n            fn (Hashtag $hashtag) => $this->tagLinkRepository->addTagToEntryComment($entryComment, $hashtag)\n        );\n    }\n\n    public function getTagsFromEntryCommentDto(EntryCommentDto $dto): array\n    {\n        return array_unique(\n            array_filter(\n                array_merge(\n                    $dto->tags ?? [],\n                    $this->tagExtractor->extract($dto->body ?? '') ?? []\n                )\n            )\n        );\n    }\n\n    /**\n     * @param string[] $newTags\n     */\n    public function updatePostTags(Post $post, array $newTags): void\n    {\n        $this->updateTags($newTags,\n            fn () => $this->tagLinkRepository->getTagsOfContent($post),\n            fn (Hashtag $hashtag) => $this->tagLinkRepository->removeTagOfPost($post, $hashtag),\n            fn (Hashtag $hashtag) => $this->tagLinkRepository->addTagToPost($post, $hashtag)\n        );\n    }\n\n    /**\n     * @param string[] $newTags\n     */\n    public function updatePostCommentTags(PostComment $postComment, array $newTags): void\n    {\n        $this->updateTags($newTags,\n            fn () => $this->tagLinkRepository->getTagsOfContent($postComment),\n            fn (Hashtag $hashtag) => $this->tagLinkRepository->removeTagOfPostComment($postComment, $hashtag),\n            fn (Hashtag $hashtag) => $this->tagLinkRepository->addTagToPostComment($postComment, $hashtag)\n        );\n    }\n\n    /**\n     * @param string[]                $newTags\n     * @param callable(): string[]    $getTags   a callable that should return all the tags of the entity as a string array\n     * @param callable(Hashtag): void $removeTag a callable that gets a string as parameter and should remove the tag\n     * @param callable(Hashtag): void $addTag\n     */\n    private function updateTags(array $newTags, callable $getTags, callable $removeTag, callable $addTag): void\n    {\n        $oldTags = $getTags();\n        $actions = $this->intersectOldAndNewTags($oldTags, $newTags);\n        foreach ($actions['tagsToRemove'] as $tag) {\n            $removeTag($this->tagRepository->findOneBy(['tag' => $tag]));\n        }\n        foreach ($actions['tagsToCreate'] as $tag) {\n            $tagEntity = $this->tagRepository->findOneBy(['tag' => $tag]);\n            if (null === $tagEntity) {\n                $tagEntity = $this->tagRepository->create($tag);\n            }\n            $addTag($tagEntity);\n        }\n    }\n\n    #[ArrayShape([\n        'tagsToRemove' => 'string[]',\n        'tagsToCreate' => 'string[]',\n    ])]\n    private function intersectOldAndNewTags(array $oldTags, array $newTags): array\n    {\n        /** @var string[] $tagsToRemove */\n        $tagsToRemove = [];\n        /** @var string[] $tagsToCreate */\n        $tagsToCreate = [];\n        foreach ($oldTags as $tag) {\n            if (!\\in_array($tag, $newTags)) {\n                $tagsToRemove[] = $tag;\n            }\n        }\n        foreach ($newTags as $tag) {\n            if (!\\in_array($tag, $oldTags)) {\n                $tagsToCreate[] = $tag;\n            }\n        }\n\n        return [\n            'tagsToCreate' => $tagsToCreate,\n            'tagsToRemove' => $tagsToRemove,\n        ];\n    }\n\n    public function ban(Hashtag $hashtag): void\n    {\n        $hashtag->banned = true;\n        $this->entityManager->persist($hashtag);\n        $this->entityManager->flush();\n    }\n\n    public function unban(Hashtag $hashtag): void\n    {\n        $hashtag->banned = false;\n        $this->entityManager->persist($hashtag);\n        $this->entityManager->flush();\n    }\n\n    public function isAnyTagBanned(?array $tags): bool\n    {\n        if ($tags) {\n            $result = $this->tagRepository->findBy(['tag' => $tags, 'banned' => true]);\n            if ($result && 0 !== \\sizeof($result)) {\n                return true;\n            }\n        }\n\n        return false;\n    }\n}\n"
  },
  {
    "path": "src/Service/TwoFactorManager.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service;\n\nuse App\\Entity\\User;\nuse Doctrine\\ORM\\EntityManagerInterface;\n\nreadonly class TwoFactorManager\n{\n    public function __construct(\n        private EntityManagerInterface $entityManager,\n    ) {\n    }\n\n    /**\n     * @return string[]\n     */\n    public function createBackupCodes(User $user): array\n    {\n        $codes = $this->generateCodes();\n\n        $user->setBackupCodes($codes);\n        $this->entityManager->persist($user);\n        $this->entityManager->flush();\n\n        return $codes;\n    }\n\n    public function remove2FA(User $user): void\n    {\n        $user->setTotpSecret(null);\n\n        $this->entityManager->persist($user);\n        $this->entityManager->flush();\n    }\n\n    private function generateCodes(): array\n    {\n        return array_map(\n            fn () => substr(str_shuffle((string) hexdec(bin2hex(random_bytes(6)))), 0, 8),\n            range(0, 9),\n        );\n    }\n}\n"
  },
  {
    "path": "src/Service/UserManager.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service;\n\nuse App\\DTO\\UserDto;\nuse App\\Entity\\Contracts\\VisibilityInterface;\nuse App\\Entity\\Instance;\nuse App\\Entity\\User;\nuse App\\Entity\\UserFollowRequest;\nuse App\\Enums\\EApplicationStatus;\nuse App\\Event\\Instance\\InstanceBanEvent;\nuse App\\Event\\User\\UserApplicationApprovedEvent;\nuse App\\Event\\User\\UserApplicationRejectedEvent;\nuse App\\Event\\User\\UserBlockEvent;\nuse App\\Event\\User\\UserEditedEvent;\nuse App\\Event\\User\\UserFollowEvent;\nuse App\\Exception\\UserCannotBeBanned;\nuse App\\Factory\\UserFactory;\nuse App\\Message\\ClearDeletedUserMessage;\nuse App\\Message\\DeleteImageMessage;\nuse App\\Message\\DeleteUserMessage;\nuse App\\Message\\Notification\\SentNewSignupNotificationMessage;\nuse App\\Message\\UserCreatedMessage;\nuse App\\Message\\UserUpdatedMessage;\nuse App\\MessageHandler\\ClearDeletedUserHandler;\nuse App\\Repository\\ImageRepository;\nuse App\\Repository\\ReputationRepository;\nuse App\\Repository\\UserFollowRepository;\nuse App\\Repository\\UserFollowRequestRepository;\nuse App\\Repository\\UserRepository;\nuse App\\Security\\EmailVerifier;\nuse App\\Service\\ActivityPub\\KeysGenerator;\nuse Doctrine\\DBAL\\Exception;\nuse Doctrine\\DBAL\\ParameterType;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Doctrine\\ORM\\Query\\ResultSetMapping;\nuse Psr\\EventDispatcher\\EventDispatcherInterface;\nuse Psr\\Log\\LoggerInterface;\nuse Symfony\\Bundle\\SecurityBundle\\Security;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\RequestStack;\nuse Symfony\\Component\\HttpKernel\\Exception\\TooManyRequestsHttpException;\nuse Symfony\\Component\\Messenger\\MessageBusInterface;\nuse Symfony\\Component\\PasswordHasher\\Hasher\\UserPasswordHasherInterface;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\nuse Symfony\\Component\\Security\\Core\\Authentication\\Token\\Storage\\TokenStorageInterface;\nuse Symfony\\Contracts\\Cache\\CacheInterface;\nuse Symfony\\Contracts\\Cache\\ItemInterface;\n\nreadonly class UserManager\n{\n    public function __construct(\n        private UserFactory $factory,\n        private UserPasswordHasherInterface $passwordHasher,\n        private TokenStorageInterface $tokenStorage,\n        private RequestStack $requestStack,\n        private EventDispatcherInterface $dispatcher,\n        private MessageBusInterface $bus,\n        private EmailVerifier $verifier,\n        private EntityManagerInterface $entityManager,\n        private RateLimiterFactoryInterface $userRegisterLimiter,\n        private UserFollowRequestRepository $requestRepository,\n        private UserFollowRepository $userFollowRepository,\n        private UserRepository $userRepository,\n        private ImageRepository $imageRepository,\n        private Security $security,\n        private CacheInterface $cache,\n        private ReputationRepository $reputationRepository,\n        private SettingsManager $settingsManager,\n        private EventDispatcherInterface $eventDispatcher,\n        private LoggerInterface $logger,\n    ) {\n    }\n\n    public function acceptFollow(User $follower, User $following): void\n    {\n        if ($request = $this->requestRepository->findOneby(['follower' => $follower, 'following' => $following])) {\n            $this->entityManager->remove($request);\n        }\n\n        if ($this->userFollowRepository->findOneBy(['follower' => $follower, 'following' => $following])) {\n            return;\n        }\n\n        $this->follow($follower, $following, false);\n    }\n\n    public function follow(User $follower, User $following, $createRequest = true): void\n    {\n        if ($following->apManuallyApprovesFollowers && $createRequest) {\n            if ($this->requestRepository->findOneby(['follower' => $follower, 'following' => $following])) {\n                return;\n            }\n\n            $request = new UserFollowRequest($follower, $following);\n            $this->entityManager->persist($request);\n            $this->entityManager->flush();\n\n            $this->dispatcher->dispatch(new UserFollowEvent($follower, $following));\n\n            return;\n        }\n\n        $follower->unblock($following);\n\n        $follower->follow($following);\n\n        $this->entityManager->flush();\n\n        $this->dispatcher->dispatch(new UserFollowEvent($follower, $following));\n    }\n\n    public function unblock(User $blocker, User $blocked): void\n    {\n        $blocker->unblock($blocked);\n\n        $this->entityManager->flush();\n\n        $this->dispatcher->dispatch(new UserBlockEvent($blocker, $blocked));\n    }\n\n    public function rejectFollow(User $follower, User $following): void\n    {\n        if ($request = $this->requestRepository->findOneby(['follower' => $follower, 'following' => $following])) {\n            $this->entityManager->remove($request);\n            $this->entityManager->flush();\n        }\n    }\n\n    public function block(User $blocker, User $blocked): void\n    {\n        if ($blocker->isFollowing($blocked)) {\n            $this->unfollow($blocker, $blocked);\n        }\n\n        $blocker->block($blocked);\n\n        $this->entityManager->flush();\n\n        $this->dispatcher->dispatch(new UserBlockEvent($blocker, $blocked));\n    }\n\n    public function unfollow(User $follower, User $following): void\n    {\n        if ($request = $this->requestRepository->findOneby(['follower' => $follower, 'following' => $following])) {\n            $this->entityManager->remove($request);\n        }\n\n        $follower->unfollow($following);\n\n        $this->entityManager->flush();\n\n        $this->dispatcher->dispatch(new UserFollowEvent($follower, $following, true));\n    }\n\n    public function create(UserDto $dto, bool $verifyUserEmail = true, $rateLimit = true, ?bool $preApprove = null): User\n    {\n        if ($rateLimit) {\n            $limiter = $this->userRegisterLimiter->create($dto->ip);\n            if (false === $limiter->consume()->isAccepted()) {\n                throw new TooManyRequestsHttpException();\n            }\n        }\n        $status = EApplicationStatus::Approved;\n        if (true !== $preApprove && $this->settingsManager->getNewUsersNeedApproval()) {\n            $status = EApplicationStatus::Pending;\n        }\n\n        $user = new User($dto->email, $dto->username, '', ($dto->isBot) ? 'Service' : 'Person', $dto->apProfileId, $dto->apId, applicationStatus: $status, applicationText: $dto->applicationText);\n        $user->setPassword($this->passwordHasher->hashPassword($user, $dto->plainPassword));\n\n        if (!$dto->apId) {\n            $user = KeysGenerator::generate($user);\n            // default new local users to be discoverable\n            $user->apDiscoverable = $dto->discoverable ?? true;\n            // default new local users to be indexable\n            $user->apIndexable = true;\n        }\n\n        $this->entityManager->persist($user);\n        $this->entityManager->flush();\n\n        if (!$dto->apId) {\n            try {\n                $this->bus->dispatch(new SentNewSignupNotificationMessage($user->getId()));\n            } catch (\\Throwable $e) {\n            }\n        }\n\n        if ($verifyUserEmail) {\n            try {\n                $this->bus->dispatch(new UserCreatedMessage($user->getId()));\n            } catch (\\Throwable $e) {\n            }\n        }\n\n        return $user;\n    }\n\n    public function edit(User $user, UserDto $dto): User\n    {\n        $this->entityManager->beginTransaction();\n        $mailUpdated = false;\n\n        try {\n            $user->about = $dto->about;\n\n            $user->title = $dto->title;\n\n            $oldAvatar = $user->avatar;\n            if ($dto->avatar) {\n                $image = $this->imageRepository->find($dto->avatar->id);\n                $user->avatar = $image;\n            }\n\n            $oldCover = $user->cover;\n            if ($dto->cover) {\n                $image = $this->imageRepository->find($dto->cover->id);\n                $user->cover = $image;\n            }\n\n            if ($dto->plainPassword) {\n                $user->setPassword($this->passwordHasher->hashPassword($user, $dto->plainPassword));\n            }\n\n            if ($dto->email !== $user->email) {\n                $mailUpdated = true;\n                $user->isVerified = false;\n                $user->email = $dto->email;\n            }\n\n            if ($this->security->isGranted('edit_profile', $user)) {\n                $user->username = $dto->username;\n            }\n\n            if ($this->security->isGranted('edit_profile', $user)\n                && !$user->isTotpAuthenticationEnabled()\n                && $dto->totpSecret) {\n                $user->setTotpSecret($dto->totpSecret);\n            }\n\n            $user->lastActive = new \\DateTime();\n\n            $this->entityManager->flush();\n            $this->entityManager->commit();\n        } catch (\\Exception $e) {\n            $this->entityManager->rollback();\n            throw $e;\n        }\n\n        if ($oldAvatar && $user->avatar !== $oldAvatar) {\n            $this->bus->dispatch(new DeleteImageMessage($oldAvatar->getId()));\n        }\n\n        if ($oldCover && $user->cover !== $oldCover) {\n            $this->bus->dispatch(new DeleteImageMessage($oldCover->getId()));\n        }\n\n        if ($mailUpdated) {\n            $this->bus->dispatch(new UserUpdatedMessage($user->getId()));\n        }\n\n        $this->dispatcher->dispatch(new UserEditedEvent($user->getId()));\n\n        return $user;\n    }\n\n    public function delete(User $user): void\n    {\n        $this->bus->dispatch(new DeleteUserMessage($user->getId()));\n    }\n\n    public function createDto(User $user, ?int $reputationPoints = null): UserDto\n    {\n        return $this->factory->createDto($user, $reputationPoints);\n    }\n\n    public function verify(Request $request, User $user): void\n    {\n        $this->verifier->handleEmailConfirmation($request, $user);\n    }\n\n    public function adminUserVerify(User $user): void\n    {\n        $user->isVerified = true;\n\n        $this->entityManager->persist($user);\n        $this->entityManager->flush();\n    }\n\n    public function toggleTheme(User $user): void\n    {\n        $user->toggleTheme();\n\n        $this->entityManager->flush();\n    }\n\n    public function logout(): void\n    {\n        $this->tokenStorage->setToken(null);\n        $this->requestStack->getSession()->invalidate();\n    }\n\n    public function ban(User $user, ?User $bannedBy, ?string $reason): void\n    {\n        if ($user->isAdmin() || $user->isModerator()) {\n            throw new UserCannotBeBanned();\n        }\n\n        $user->isBanned = true;\n        $user->banReason = $reason;\n\n        $this->entityManager->persist($user);\n        $this->entityManager->flush();\n\n        $this->eventDispatcher->dispatch(new InstanceBanEvent($user, $bannedBy, $reason));\n    }\n\n    public function unban(User $user, ?User $bannedBy, ?string $reason): void\n    {\n        $user->isBanned = false;\n        $user->banReason = $reason;\n\n        $this->entityManager->persist($user);\n        $this->entityManager->flush();\n        $this->eventDispatcher->dispatch(new InstanceBanEvent($user, $bannedBy, $reason));\n    }\n\n    public function detachAvatar(User $user): void\n    {\n        if (!$user->avatar) {\n            return;\n        }\n\n        $image = $user->avatar->getId();\n\n        $user->avatar = null;\n\n        $this->entityManager->persist($user);\n        $this->entityManager->flush();\n\n        $this->bus->dispatch(new DeleteImageMessage($image));\n    }\n\n    public function detachCover(User $user): void\n    {\n        if (!$user->cover) {\n            return;\n        }\n\n        $image = $user->cover->getId();\n\n        $user->cover = null;\n\n        $this->entityManager->persist($user);\n        $this->entityManager->flush();\n\n        $this->bus->dispatch(new DeleteImageMessage($image));\n    }\n\n    /**\n     * @param User $user        the user that should be deleted\n     * @param bool $immediately if true we will immediately dispatch a DeleteMessage,\n     *                          if false the account will be marked for deletion in 30 days.\n     *                          Our scheduler will then take care of deleting the account via ClearDeletedUserMessage and ClearDeletedUserHandler\n     *\n     * @see ClearDeletedUserMessage\n     * @see ClearDeletedUserHandler\n     */\n    public function deleteRequest(User $user, bool $immediately): void\n    {\n        if (!$immediately) {\n            $user->softDelete();\n\n            $this->entityManager->persist($user);\n            $this->entityManager->flush();\n        } else {\n            $this->delete($user);\n        }\n    }\n\n    /**\n     * If the user is marked for deletion this will remove that mark and restore the user.\n     *\n     * @param User $user the user to be checked\n     */\n    public function removeDeleteRequest(User $user): void\n    {\n        if (null !== $user->markedForDeletionAt) {\n            $user->restore();\n\n            $this->entityManager->persist($user);\n            $this->entityManager->flush();\n        }\n    }\n\n    /**\n     * Suspend user.\n     */\n    public function suspend(User $user): void\n    {\n        $user->visibility = VisibilityInterface::VISIBILITY_TRASHED;\n\n        $this->entityManager->persist($user);\n        $this->entityManager->flush();\n    }\n\n    /**\n     * Unsuspend user.\n     */\n    public function unsuspend(User $user): void\n    {\n        $user->visibility = VisibilityInterface::VISIBILITY_VISIBLE;\n\n        $this->entityManager->persist($user);\n        $this->entityManager->flush();\n    }\n\n    public function removeFollowing(User $user): void\n    {\n        foreach ($user->follows as $follow) {\n            $this->unfollow($user, $follow->following);\n        }\n    }\n\n    /**\n     * Get user reputation total add it behind a cache.\n     */\n    public function getReputationTotal(User $user): int\n    {\n        return $this->cache->get(\n            \"user_reputation_{$user->getId()}\",\n            function (ItemInterface $item) use ($user) {\n                $item->expiresAfter(60);\n\n                return $this->reputationRepository->getUserReputationTotal($user);\n            }\n        );\n    }\n\n    /**\n     * @return User[]\n     */\n    public function getUsersMarkedForDeletionBefore(?\\DateTime $dateTime = null): array\n    {\n        $dateTime ??= new \\DateTime();\n\n        return $this->userRepository->createQueryBuilder('u')\n            ->where('u.markedForDeletionAt <= :datetime')\n            ->setParameter(':datetime', $dateTime)\n            ->getQuery()\n            ->getResult();\n    }\n\n    public function rejectUserApplication(User $user): void\n    {\n        if (EApplicationStatus::Rejected === $user->getApplicationStatus()) {\n            return;\n        }\n        $user->setApplicationStatus(EApplicationStatus::Rejected);\n        $this->entityManager->persist($user);\n        $this->entityManager->flush();\n        $this->logger->debug('Rejecting application for {u}', ['u' => $user->username]);\n        $this->eventDispatcher->dispatch(new UserApplicationRejectedEvent($user));\n    }\n\n    public function approveUserApplication(User $user): void\n    {\n        if (EApplicationStatus::Approved === $user->getApplicationStatus()) {\n            return;\n        }\n        $user->setApplicationStatus(EApplicationStatus::Approved);\n        $this->entityManager->persist($user);\n        $this->entityManager->flush();\n        $this->logger->debug('Approving application for {u}', ['u' => $user->username]);\n        $this->eventDispatcher->dispatch(new UserApplicationApprovedEvent($user));\n    }\n\n    public function getAllInboxesOfInteractions(User $user): array\n    {\n        $sql = '\n                -- remote magazines the user is subscribed to\n                SELECT res.ap_inbox_url FROM magazine_subscription\n                    INNER JOIN public.magazine res ON magazine_subscription.magazine_id = res.id AND res.ap_id IS NOT NULL\n                    INNER JOIN public.user u on magazine_subscription.user_id = u.id\n                        WHERE u.id = :id\n            UNION DISTINCT\n                -- local magazines the user is subscribed to -> their remote subscribers\n                SELECT res.ap_inbox_url FROM magazine_subscription ms\n                    INNER JOIN public.magazine m ON ms.magazine_id = m.id AND m.ap_id IS NULL\n                    INNER JOIN public.user res ON ms.user_id = res.id AND res.ap_id IS NOT NULL\n                    WHERE EXISTS (SELECT id FROM magazine_subscription ms2 WHERE ms2.magazine_id=m.id AND user_id = :id)\n            UNION DISTINCT\n                -- users followed by the user\n                SELECT res.ap_inbox_url FROM user_follow\n                    INNER JOIN public.user res on user_follow.follower_id = res.id AND res.ap_id IS NOT NULL\n                        WHERE user_follow.following_id = :id\n            UNION DISTINCT\n                -- users following the user\n                SELECT res.ap_inbox_url FROM user_follow\n                    INNER JOIN public.user res on user_follow.following_id = res.id AND res.ap_id IS NOT NULL\n                        WHERE user_follow.follower_id = :id\n            UNION DISTINCT\n                -- remote magazines the user posted threads to\n                SELECT res.ap_inbox_url FROM entry\n                    INNER JOIN public.magazine res on entry.magazine_id = res.id AND res.ap_id IS NOT NULL\n                        WHERE entry.user_id = :id\n            UNION DISTINCT\n                -- remote magazines the user posted thread comments to\n                SELECT res.ap_inbox_url FROM entry_comment\n                    INNER JOIN public.magazine res on entry_comment.magazine_id = res.id AND res.ap_id IS NOT NULL\n                        WHERE entry_comment.user_id = :id\n            UNION DISTINCT\n                -- remote magazines the user posted microblogs to\n                SELECT res.ap_inbox_url FROM post\n                    INNER JOIN public.magazine res on post.magazine_id = res.id AND res.ap_id IS NOT NULL\n                        WHERE post.user_id = :id\n            UNION DISTINCT\n                -- remote magazine the user posted microblog comments to\n                SELECT res.ap_inbox_url FROM post_comment\n                    INNER JOIN public.magazine res on post_comment.magazine_id = res.id AND res.ap_id IS NOT NULL\n                        WHERE post_comment.user_id = :id\n            UNION DISTINCT\n                -- local magazines the user posted threads to -> their subscribers\n                SELECT res.ap_inbox_url FROM entry\n                    INNER JOIN magazine m on entry.magazine_id = m.id AND m.ap_id IS NULL\n                    INNER JOIN magazine_subscription ms ON m.id = ms.magazine_id\n                    INNER JOIN public.user res ON ms.user_id = res.id AND res.ap_id IS NOT NULL\n                        WHERE entry.user_id = :id\n            UNION DISTINCT\n                -- local magazines the user posted thread comments to -> their subscribers\n                SELECT res.ap_inbox_url FROM entry_comment\n                    INNER JOIN magazine m on entry_comment.magazine_id = m.id AND m.ap_id IS NULL\n                    INNER JOIN magazine_subscription ms ON m.id = ms.magazine_id\n                    INNER JOIN public.user res ON ms.user_id = res.id AND res.ap_id IS NOT NULL\n                        WHERE entry_comment.user_id = :id\n            UNION DISTINCT\n                -- local magazines the user posted microblogs to -> their subscribers\n                SELECT res.ap_inbox_url FROM post\n                    INNER JOIN magazine m on post.magazine_id = m.id AND m.ap_id IS NULL\n                    INNER JOIN magazine_subscription ms ON m.id = ms.magazine_id\n                    INNER JOIN public.user res ON ms.user_id = res.id AND res.ap_id IS NOT NULL\n                        WHERE post.user_id = :id\n            UNION DISTINCT\n                -- local magazine the user posted microblog comments to -> their subscribers\n                SELECT res.ap_inbox_url FROM post_comment\n                    INNER JOIN magazine m on post_comment.magazine_id = m.id AND m.ap_id IS NULL\n                    INNER JOIN magazine_subscription ms ON m.id = ms.magazine_id\n                    INNER JOIN public.user res ON ms.user_id = res.id AND res.ap_id IS NOT NULL\n                        WHERE post_comment.user_id = :id\n            UNION DISTINCT\n                -- author of micro blogs the user commented on\n                SELECT res.ap_inbox_url FROM post_comment\n                    INNER JOIN public.post p on post_comment.post_id = p.id\n                    INNER JOIN public.user res on p.user_id = res.id AND res.ap_id IS NOT NULL\n                        WHERE post_comment.user_id = :id\n            UNION DISTINCT\n                -- author of the microblog comment the user commented on\n                SELECT res.ap_inbox_url FROM post_comment pc1\n                    INNER JOIN post_comment pc2 ON pc1.parent_id=pc2.id\n                    INNER JOIN public.user res ON pc2.user_id=res.id AND res.ap_id IS NOT NULL\n                        WHERE pc1.user_id = :id\n            UNION DISTINCT\n                -- author of threads the user commented on\n                SELECT res.ap_inbox_url FROM entry_comment\n                    INNER JOIN public.entry e on entry_comment.entry_id = e.id\n                    INNER JOIN public.user res on e.user_id = res.id AND res.ap_id IS NOT NULL\n                        WHERE entry_comment.user_id = :id\n            UNION DISTINCT\n                -- author of thread comments the user commented on\n                SELECT res.ap_inbox_url FROM entry_comment ec1\n                    INNER JOIN entry_comment ec2 ON ec1.parent_id=ec2.id\n                    INNER JOIN public.user res ON ec2.user_id=res.id AND res.ap_id IS NOT NULL\n                        WHERE ec1.user_id = :id\n\n            UNION DISTINCT\n                -- author of thread the user voted on\n                SELECT res.ap_inbox_url FROM entry_vote\n                    INNER JOIN public.user res on entry_vote.author_id = res.id AND res.ap_id IS NOT NULL\n                        WHERE entry_vote.user_id = :id\n            UNION DISTINCT\n                -- magazine of thread the user voted on\n                SELECT res.ap_inbox_url FROM entry_vote\n                    INNER JOIN entry ON entry_vote.entry_id = entry.id\n                    INNER JOIN magazine res ON entry.magazine_id=res.id AND res.ap_id IS NOT NULL\n                        WHERE entry_vote.user_id = :id\n\n            UNION DISTINCT\n                -- author of thread comment the user voted on\n                SELECT res.ap_inbox_url FROM entry_comment_vote\n                    INNER JOIN public.user res on entry_comment_vote.author_id = res.id AND res.ap_id IS NOT NULL\n                        WHERE entry_comment_vote.user_id = :id\n            UNION DISTINCT\n                -- magazine of thread comment the user voted on\n                SELECT res.ap_inbox_url FROM entry_comment_vote\n                    INNER JOIN entry_comment ON entry_comment_vote.comment_id = entry_comment.id\n                    INNER JOIN magazine res ON entry_comment.magazine_id=res.id AND res.ap_id IS NOT NULL\n                        WHERE entry_comment_vote.user_id = :id\n            UNION DISTINCT\n                -- author of microblog the user voted on\n                SELECT res.ap_inbox_url FROM post_vote\n                    INNER JOIN public.user res on post_vote.author_id = res.id AND res.ap_id IS NOT NULL\n                        WHERE post_vote.user_id = :id\n            UNION DISTINCT\n                -- magazine of microblog the user voted on\n                SELECT res.ap_inbox_url FROM post_vote\n                    INNER JOIN entry ON post_vote.post_id = entry.id\n                    INNER JOIN magazine res ON entry.magazine_id=res.id AND res.ap_id IS NOT NULL\n                        WHERE post_vote.user_id = :id\n\n            UNION DISTINCT\n                -- author of microblog comment the user voted on\n                SELECT res.ap_inbox_url FROM post_comment_vote\n                    INNER JOIN public.user res on post_comment_vote.author_id = res.id AND res.ap_id IS NOT NULL\n                        WHERE post_comment_vote.user_id = :id\n            UNION DISTINCT\n                -- magazine of microblog comment the user voted on\n                SELECT res.ap_inbox_url FROM post_comment_vote\n                    INNER JOIN post_comment ON post_comment_vote.comment_id = post_comment.id\n                    INNER JOIN magazine res ON post_comment.magazine_id=res.id AND res.ap_id IS NOT NULL\n                        WHERE post_comment_vote.user_id = :id\n\n\n            UNION DISTINCT\n                -- voters of threads of the user\n                SELECT res.ap_inbox_url FROM entry_vote\n                    INNER JOIN public.user res on entry_vote.user_id = res.id AND res.ap_id IS NOT NULL\n                        WHERE entry_vote.author_id = :id\n\n            UNION DISTINCT\n                -- voters of thread comments of the user\n                SELECT res.ap_inbox_url FROM entry_comment_vote\n                    INNER JOIN public.user res on entry_comment_vote.user_id = res.id AND res.ap_id IS NOT NULL\n                        WHERE entry_comment_vote.author_id = :id\n\n            UNION DISTINCT\n                -- voters of microblog of the user\n                SELECT res.ap_inbox_url FROM post_vote\n                    INNER JOIN public.user res on post_vote.user_id = res.id AND res.ap_id IS NOT NULL\n                        WHERE post_vote.author_id = :id\n\n            UNION DISTINCT\n                -- voters of microblog comments of the user\n                SELECT res.ap_inbox_url FROM post_comment_vote\n                    INNER JOIN public.user res on post_comment_vote.user_id = res.id\n                        WHERE post_comment_vote.author_id = :id AND res.ap_id IS NOT NULL\n\n            UNION DISTINCT\n                -- favourites of entries of the user\n                SELECT res.ap_inbox_url FROM favourite f\n                    INNER JOIN public.user res on f.user_id = res.id AND res.ap_id IS NOT NULL\n                    INNER JOIN entry e ON f.entry_id = e.id\n                        WHERE e.user_id = :id\n            UNION DISTINCT\n                -- favourites of entry comments of the user\n                SELECT res.ap_inbox_url FROM favourite f\n                    INNER JOIN public.user res on f.user_id = res.id AND res.ap_id IS NOT NULL\n                    INNER JOIN entry_comment ec ON f.entry_comment_id = ec.id\n                        WHERE ec.user_id = :id\n            UNION DISTINCT\n                -- favourites of posts of the user\n                SELECT res.ap_inbox_url FROM favourite f\n                    INNER JOIN public.user res on f.user_id = res.id AND res.ap_id IS NOT NULL\n                    INNER JOIN post p ON f.post_id = p.id\n                        WHERE p.user_id = :id\n            UNION DISTINCT\n                -- favourites of post comments of the user\n                SELECT res.ap_inbox_url FROM favourite f\n                    INNER JOIN public.user res on f.user_id = res.id AND res.ap_id IS NOT NULL\n                    INNER JOIN post_comment pc ON f.entry_id = pc.id\n                        WHERE pc.user_id = :id\n\n            UNION DISTINCT\n                -- favourites of the user: entries\n                SELECT res.ap_inbox_url FROM favourite f\n                    INNER JOIN public.user u on f.user_id = u.id AND f.user_id = :id\n                    INNER JOIN entry e ON f.entry_id = e.id\n                    INNER JOIN public.user res ON e.user_id=res.id AND res.ap_id IS NOT NULL\n            UNION DISTINCT\n                -- favourites of the user: entry comments\n                SELECT res.ap_inbox_url FROM favourite f\n                    INNER JOIN public.user u on f.user_id = u.id AND f.user_id = :id\n                    INNER JOIN entry_comment ec ON f.entry_comment_id = ec.id\n                    INNER JOIN public.user res ON ec.user_id=res.id AND res.ap_id IS NOT NULL\n            UNION DISTINCT\n                -- favourites of the user: posts\n                SELECT res.ap_inbox_url FROM favourite f\n                    INNER JOIN public.user u on f.user_id = u.id AND f.user_id = :id\n                    INNER JOIN post p ON f.post_id = p.id\n                    INNER JOIN public.user res ON p.user_id=res.id AND res.ap_id IS NOT NULL\n            UNION DISTINCT\n                -- favourites of the user: post comments\n                SELECT res.ap_inbox_url FROM favourite f\n                    INNER JOIN public.user u on f.user_id = u.id AND f.user_id = :id\n                    INNER JOIN post_comment pc ON f.post_comment_id = pc.id\n                    INNER JOIN public.user res ON pc.user_id=res.id AND res.ap_id IS NOT NULL\n\n            UNION DISTINCT\n                -- favourites of the user: entries -> their magazine\n                SELECT res.ap_inbox_url FROM favourite f\n                    INNER JOIN public.user u on f.user_id = u.id AND f.user_id = :id\n                    INNER JOIN entry e ON f.entry_id = e.id\n                    INNER JOIN magazine res ON e.magazine_id=res.id AND res.ap_id IS NOT NULL\n            UNION DISTINCT\n                -- favourites of the user: entry comments -> their magazine\n                SELECT res.ap_inbox_url FROM favourite f\n                    INNER JOIN public.user u on f.user_id = u.id AND f.user_id = :id\n                    INNER JOIN entry_comment ec ON f.entry_comment_id = ec.id\n                    INNER JOIN magazine res ON ec.magazine_id=res.id AND res.ap_id IS NOT NULL\n            UNION DISTINCT\n                -- favourites of the user: posts -> their magazine\n                SELECT res.ap_inbox_url FROM favourite f\n                    INNER JOIN public.user u on f.user_id = u.id AND f.user_id = :id\n                    INNER JOIN post p ON f.post_id = p.id\n                    INNER JOIN magazine res ON p.magazine_id=res.id AND res.ap_id IS NOT NULL\n            UNION DISTINCT\n                -- favourites of the user: post comments -> their magazine\n                SELECT res.ap_inbox_url FROM favourite f\n                    INNER JOIN public.user u on f.user_id = u.id AND f.user_id = :id\n                    INNER JOIN post_comment pc ON f.post_comment_id = pc.id\n                    INNER JOIN magazine res ON pc.magazine_id=res.id AND res.ap_id IS NOT NULL\n        ';\n\n        $rsm = new ResultSetMapping();\n        $rsm->addScalarResult('ap_inbox_url', 0);\n\n        $result = $this->entityManager->createNativeQuery($sql, $rsm)\n            ->setParameter(':id', $user->getId())\n            // ->execute([\":id\" => $user->getId()]);\n            ->getScalarResult();\n\n        return array_filter(array_map(fn ($row) => $row[0], $result));\n    }\n\n    /**\n     * @return string[]\n     *\n     * @throws Exception\n     */\n    public function findAllKnownInboxesNotBannedNotDead(): array\n    {\n        $sql = '\n            SELECT ap_inbox_url FROM (\n                SELECT u.ap_inbox_url, u.ap_id, u.ap_domain FROM \"user\" u\n                UNION ALL\n                SELECT m.ap_inbox_url, m.ap_id, m.ap_domain FROM magazine m\n            ) inn\n                LEFT JOIN instance i ON ap_domain = i.domain\n                WHERE\n                    (\n                        -- either no instance found, or instance not banned and not dead\n                        i IS NULL\n                        OR (\n                            i.is_banned = false\n                            -- not dead\n                            AND NOT (\n                                i.failed_delivers >= :numToDead\n                                AND (i.last_successful_deliver < :dateBeforeDead OR i.last_successful_deliver IS NULL)\n                                AND (i.last_successful_receive < :dateBeforeDead OR i.last_successful_receive IS NULL)\n                            )\n                        )\n                    )\n                    AND ap_id IS NOT NULL AND ap_inbox_url IS NOT NULL\n                GROUP BY ap_inbox_url';\n        $stmt = $this->entityManager->getConnection()->prepare($sql);\n        $stmt->bindValue(':numToDead', Instance::NUMBER_OF_FAILED_DELIVERS_UNTIL_DEAD, ParameterType::INTEGER);\n        $stmt->bindValue(':dateBeforeDead', Instance::getDateBeforeDead(), 'datetime_immutable');\n        $results = $stmt->executeQuery()->fetchAllAssociative();\n\n        return array_map(fn ($item) => $item['ap_inbox_url'], $results);\n    }\n\n    /**\n     * This method will return all image paths that the user **owns**,\n     * meaning that that image belongs only to posts from the user and not to anybody else's.\n     *\n     * @return string[]\n     */\n    public function getAllImageFilePathsOfUser(User $user): array\n    {\n        $sql = '\n            SELECT i1.file_path FROM entry e INNER JOIN image i1 ON e.image_id = i1.id\n                WHERE e.user_id = :userId AND i1.file_path IS NOT NULL\n                    AND NOT EXISTS (SELECT id FROM entry e2 WHERE e2.user_id <> :userId AND e2.image_id = i1.id)\n                    AND NOT EXISTS (SELECT id FROM post p2 WHERE p2.user_id <> :userId AND p2.image_id = i1.id)\n                    AND NOT EXISTS (SELECT id FROM entry_comment ec2 WHERE ec2.user_id <> :userId AND ec2.image_id = i1.id)\n                    AND NOT EXISTS (SELECT id FROM post_comment pc2 WHERE pc2.user_id <> :userId AND pc2.image_id = i1.id)\n            UNION DISTINCT\n            SELECT i2.file_path FROM post p INNER JOIN image i2 ON p.image_id = i2.id\n                WHERE p.user_id = :userId AND i2.file_path IS NOT NULL\n                    AND NOT EXISTS (SELECT id FROM entry e2 WHERE e2.user_id <> :userId AND e2.image_id = i2.id)\n                    AND NOT EXISTS (SELECT id FROM post p2 WHERE p2.user_id <> :userId AND p2.image_id = i2.id)\n                    AND NOT EXISTS (SELECT id FROM entry_comment ec2 WHERE ec2.user_id <> :userId AND ec2.image_id = i2.id)\n                    AND NOT EXISTS (SELECT id FROM post_comment pc2 WHERE pc2.user_id <> :userId AND pc2.image_id = i2.id)\n            UNION DISTINCT\n            SELECT i3.file_path FROM entry_comment ec INNER JOIN image i3 ON ec.image_id = i3.id\n                WHERE ec.user_id = :userId AND i3.file_path IS NOT NULL\n                    AND NOT EXISTS (SELECT id FROM entry e2 WHERE e2.user_id <> :userId AND e2.image_id = i3.id)\n                    AND NOT EXISTS (SELECT id FROM post p2 WHERE p2.user_id <> :userId AND p2.image_id = i3.id)\n                    AND NOT EXISTS (SELECT id FROM entry_comment ec2 WHERE ec2.user_id <> :userId AND ec2.image_id = i3.id)\n                    AND NOT EXISTS (SELECT id FROM post_comment pc2 WHERE pc2.user_id <> :userId AND pc2.image_id = i3.id)\n            UNION DISTINCT\n            SELECT i4.file_path FROM post_comment pc INNER JOIN image i4 ON pc.image_id = i4.id\n                WHERE pc.user_id = :userId AND i4.file_path IS NOT NULL\n                    AND NOT EXISTS (SELECT id FROM entry e2 WHERE e2.user_id <> :userId AND e2.image_id = i4.id)\n                    AND NOT EXISTS (SELECT id FROM post p2 WHERE p2.user_id <> :userId AND p2.image_id = i4.id)\n                    AND NOT EXISTS (SELECT id FROM entry_comment ec2 WHERE ec2.user_id <> :userId AND ec2.image_id = i4.id)\n                    AND NOT EXISTS (SELECT id FROM post_comment pc2 WHERE pc2.user_id <> :userId AND pc2.image_id = i4.id)\n        ';\n        $rsm = new ResultSetMapping();\n        $rsm->addScalarResult('file_path', 0);\n\n        $result = $this->entityManager->createNativeQuery($sql, $rsm)\n            ->setParameter(':userId', $user->getId())\n            ->getScalarResult();\n\n        return array_filter(array_map(fn ($row) => $row[0], $result));\n    }\n}\n"
  },
  {
    "path": "src/Service/UserNoteManager.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service;\n\nuse App\\DTO\\UserNoteDto;\nuse App\\Entity\\User;\nuse App\\Entity\\UserNote;\nuse App\\Repository\\UserNoteRepository;\nuse Doctrine\\ORM\\EntityManagerInterface;\n\nclass UserNoteManager\n{\n    public function __construct(private UserNoteRepository $repository, private EntityManagerInterface $entityManager)\n    {\n    }\n\n    public function save(User $user, User $target, string $body): UserNote\n    {\n        $this->clear($user, $target);\n\n        $note = new UserNote($user, $target, $body);\n\n        $this->entityManager->persist($note);\n        $this->entityManager->flush();\n\n        return $note;\n    }\n\n    public function clear(User $user, User $target): void\n    {\n        $note = $this->repository->findOneBy([\n            'user' => $user,\n            'target' => $target,\n        ]);\n\n        if ($note) {\n            $this->entityManager->remove($note);\n            $this->entityManager->flush();\n        }\n    }\n\n    public function createDto(User $user, User $target): UserNoteDto\n    {\n        $dto = new UserNoteDto();\n        $dto->target = $target;\n\n        $note = $this->repository->findOneBy([\n            'user' => $user,\n            'target' => $target,\n        ]);\n\n        if ($note) {\n            $dto->body = $note->body;\n        }\n\n        return $dto;\n    }\n}\n"
  },
  {
    "path": "src/Service/UserSettingsManager.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service;\n\nuse App\\DTO\\UserSettingsDto;\nuse App\\Entity\\User;\nuse Doctrine\\ORM\\EntityManagerInterface;\n\nclass UserSettingsManager\n{\n    public function __construct(private readonly EntityManagerInterface $entityManager)\n    {\n    }\n\n    public function createDto(User $user): UserSettingsDto\n    {\n        return new UserSettingsDto(\n            $user->notifyOnNewEntry,\n            $user->notifyOnNewEntryReply,\n            $user->notifyOnNewEntryCommentReply,\n            $user->notifyOnNewPost,\n            $user->notifyOnNewPostReply,\n            $user->notifyOnNewPostCommentReply,\n            $user->hideAdult,\n            $user->showProfileSubscriptions,\n            $user->showProfileFollowings,\n            $user->addMentionsEntries,\n            $user->addMentionsPosts,\n            $user->homepage,\n            $user->frontDefaultSort,\n            $user->commentDefaultSort,\n            $user->showBoostsOfFollowing,\n            $user->featuredMagazines,\n            $user->preferredLanguages,\n            $user->customCss,\n            $user->ignoreMagazinesCustomCss,\n            $user->notifyOnUserSignup,\n            $user->directMessageSetting,\n            $user->frontDefaultContent,\n            $user->apDiscoverable,\n            $user->apIndexable,\n        );\n    }\n\n    public function update(User $user, UserSettingsDto $dto): void\n    {\n        $user->notifyOnNewEntry = $dto->notifyOnNewEntry;\n        $user->notifyOnNewPost = $dto->notifyOnNewPost;\n        $user->notifyOnNewPostReply = $dto->notifyOnNewPostReply;\n        $user->notifyOnNewEntryCommentReply = $dto->notifyOnNewEntryCommentReply;\n        $user->notifyOnNewEntryReply = $dto->notifyOnNewEntryReply;\n        $user->notifyOnNewPostCommentReply = $dto->notifyOnNewPostCommentReply;\n        $user->homepage = $dto->homepage;\n        $user->frontDefaultSort = $dto->frontDefaultSort;\n        $user->commentDefaultSort = $dto->commentDefaultSort;\n        $user->showBoostsOfFollowing = $dto->showFollowingBoosts ?? false;\n        $user->hideAdult = $dto->hideAdult;\n        $user->showProfileSubscriptions = $dto->showProfileSubscriptions;\n        $user->showProfileFollowings = $dto->showProfileFollowings;\n        $user->addMentionsEntries = $dto->addMentionsEntries;\n        $user->addMentionsPosts = $dto->addMentionsPosts;\n        $user->featuredMagazines = $dto->featuredMagazines ? array_unique($dto->featuredMagazines) : null;\n        $user->preferredLanguages = $dto->preferredLanguages ? array_unique($dto->preferredLanguages) : [];\n        $user->customCss = $dto->customCss;\n        $user->ignoreMagazinesCustomCss = $dto->ignoreMagazinesCustomCss;\n        $user->directMessageSetting = $dto->directMessageSetting;\n        $user->frontDefaultContent = $dto->frontDefaultContent;\n\n        if (null !== $dto->notifyOnUserSignup) {\n            $user->notifyOnUserSignup = $dto->notifyOnUserSignup;\n        }\n\n        if (null !== $dto->discoverable) {\n            $user->apDiscoverable = $dto->discoverable;\n        }\n\n        if (null !== $dto->indexable) {\n            $user->apIndexable = $dto->indexable;\n        }\n\n        $this->entityManager->flush();\n    }\n}\n"
  },
  {
    "path": "src/Service/VideoManager.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service;\n\nclass VideoManager\n{\n    public const VIDEO_MIMETYPES = ['video/mp4', 'video/webm'];\n\n    public static function isVideoUrl(string $url): bool\n    {\n        $urlExt = pathinfo($url, PATHINFO_EXTENSION);\n\n        $types = array_map(fn ($type) => str_replace('video/', '', $type), self::VIDEO_MIMETYPES);\n\n        return \\in_array($urlExt, $types);\n    }\n}\n"
  },
  {
    "path": "src/Service/VotableRepositoryResolver.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service;\n\nuse App\\Entity\\Entry;\nuse App\\Entity\\EntryComment;\nuse App\\Entity\\Post;\nuse App\\Entity\\PostComment;\nuse App\\Repository\\EntryCommentRepository;\nuse App\\Repository\\EntryRepository;\nuse App\\Repository\\PostCommentRepository;\nuse App\\Repository\\PostRepository;\n\nclass VotableRepositoryResolver\n{\n    public function __construct(\n        private readonly EntryRepository $entryRepository,\n        private readonly EntryCommentRepository $entryCommentRepository,\n        private readonly PostRepository $postRepository,\n        private readonly PostCommentRepository $postCommentRepository,\n    ) {\n    }\n\n    public function resolve(string $entityClass)\n    {\n        return match ($entityClass) {\n            Entry::class => $this->entryRepository,\n            EntryComment::class => $this->entryCommentRepository,\n            Post::class => $this->postRepository,\n            PostComment::class => $this->postCommentRepository,\n            default => throw new \\LogicException(),\n        };\n    }\n}\n"
  },
  {
    "path": "src/Service/VoteManager.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Service;\n\nuse App\\Entity\\Contracts\\VotableInterface;\nuse App\\Entity\\Entry;\nuse App\\Entity\\EntryComment;\nuse App\\Entity\\Post;\nuse App\\Entity\\PostComment;\nuse App\\Entity\\User;\nuse App\\Entity\\Vote;\nuse App\\Event\\VoteEvent;\nuse App\\Factory\\VoteFactory;\nuse App\\Utils\\DownvotesMode;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Psr\\EventDispatcher\\EventDispatcherInterface;\nuse Symfony\\Component\\HttpKernel\\Exception\\AccessDeniedHttpException;\nuse Symfony\\Component\\HttpKernel\\Exception\\TooManyRequestsHttpException;\nuse Symfony\\Component\\RateLimiter\\RateLimiterFactoryInterface;\n\nclass VoteManager\n{\n    public function __construct(\n        private readonly VoteFactory $factory,\n        private readonly RateLimiterFactoryInterface $voteLimiter,\n        private readonly EventDispatcherInterface $dispatcher,\n        private readonly EntityManagerInterface $entityManager,\n        private readonly SettingsManager $settingsManager,\n    ) {\n    }\n\n    public function vote(int $choice, VotableInterface $votable, User $user, $rateLimit = true): Vote\n    {\n        if ($rateLimit) {\n            $limiter = $this->voteLimiter->create($user->username);\n            if (false === $limiter->consume()->isAccepted()) {\n                throw new TooManyRequestsHttpException();\n            }\n        }\n\n        $downVotesMode = $this->settingsManager->getDownvotesMode();\n        if (DownvotesMode::Disabled === $downVotesMode && VotableInterface::VOTE_DOWN === $choice) {\n            throw new \\LogicException('cannot downvote, because that is disabled');\n        }\n        if (VotableInterface::VOTE_DOWN === $choice && 'Service' === $user->type) {\n            throw new AccessDeniedHttpException('Bots are not allowed to vote on items!');\n        }\n\n        $vote = $votable->getUserVote($user);\n        $votedAgain = false;\n\n        if ($vote) {\n            $votedAgain = true;\n            $choice = $this->guessUserChoice($choice, $votable->getUserChoice($user));\n\n            if ($votable instanceof Entry || $votable instanceof EntryComment || $votable instanceof Post || $votable instanceof PostComment) {\n                if (VotableInterface::VOTE_UP === $vote->choice && null !== $votable->apShareCount) {\n                    --$votable->apShareCount;\n                } elseif (VotableInterface::VOTE_DOWN === $vote->choice && null !== $votable->apDislikeCount) {\n                    --$votable->apDislikeCount;\n                }\n\n                if (VotableInterface::VOTE_UP === $choice && null !== $votable->apShareCount) {\n                    ++$votable->apShareCount;\n                } elseif (VotableInterface::VOTE_DOWN === $choice && null !== $votable->apDislikeCount) {\n                    ++$votable->apDislikeCount;\n                }\n            }\n\n            $vote->choice = $choice;\n        } else {\n            if (VotableInterface::VOTE_UP === $choice) {\n                return $this->upvote($votable, $user);\n            }\n\n            if ($votable instanceof Entry || $votable instanceof EntryComment || $votable instanceof Post || $votable instanceof PostComment) {\n                if (null !== $votable->apDislikeCount) {\n                    ++$votable->apDislikeCount;\n                }\n            }\n\n            $vote = $this->factory->create($choice, $votable, $user);\n            $this->entityManager->persist($vote);\n        }\n\n        $votable->updateVoteCounts();\n\n        $this->entityManager->flush();\n\n        $this->dispatcher->dispatch(new VoteEvent($votable, $vote, $votedAgain));\n\n        return $vote;\n    }\n\n    private function guessUserChoice(int $choice, int $vote): int\n    {\n        if (VotableInterface::VOTE_NONE === $choice) {\n            return $choice;\n        }\n\n        if (VotableInterface::VOTE_UP === $vote) {\n            return match ($choice) {\n                VotableInterface::VOTE_UP => VotableInterface::VOTE_NONE,\n                VotableInterface::VOTE_DOWN => VotableInterface::VOTE_DOWN,\n                default => throw new \\LogicException(),\n            };\n        }\n\n        if (VotableInterface::VOTE_DOWN === $vote) {\n            return match ($choice) {\n                VotableInterface::VOTE_UP => VotableInterface::VOTE_UP,\n                VotableInterface::VOTE_DOWN => VotableInterface::VOTE_NONE,\n                default => throw new \\LogicException(),\n            };\n        }\n\n        return $choice;\n    }\n\n    public function upvote(VotableInterface $votable, User $user): Vote\n    {\n        // @todo save activity pub object id\n        $vote = $votable->getUserVote($user);\n\n        if ($vote) {\n            return $vote;\n        }\n\n        $vote = $this->factory->create(1, $votable, $user);\n\n        $votable->updateVoteCounts();\n\n        $votable->lastActive = new \\DateTime();\n\n        if ($votable instanceof PostComment) {\n            $votable->post->lastActive = new \\DateTime();\n        }\n\n        if ($votable instanceof EntryComment) {\n            $votable->entry->lastActive = new \\DateTime();\n        }\n\n        if ($votable instanceof Entry || $votable instanceof EntryComment || $votable instanceof Post || $votable instanceof PostComment) {\n            if (null !== $votable->apShareCount) {\n                ++$votable->apShareCount;\n            }\n        }\n\n        $this->entityManager->flush();\n\n        $this->dispatcher->dispatch(new VoteEvent($votable, $vote, false));\n\n        return $vote;\n    }\n\n    public function removeVote(VotableInterface $votable, User $user): ?Vote\n    {\n        // @todo save activity pub object id\n        $vote = $votable->getUserVote($user);\n\n        if (!$vote) {\n            return null;\n        }\n        if (VotableInterface::VOTE_UP === $vote->choice) {\n            if ($votable instanceof Entry || $votable instanceof EntryComment || $votable instanceof Post || $votable instanceof PostComment) {\n                if (null !== $votable->apShareCount) {\n                    --$votable->apShareCount;\n                }\n            }\n        } elseif (VotableInterface::VOTE_DOWN === $vote->choice) {\n            if ($votable instanceof Entry || $votable instanceof EntryComment || $votable instanceof Post || $votable instanceof PostComment) {\n                if (null !== $votable->apDislikeCount) {\n                    --$votable->apDislikeCount;\n                }\n            }\n        }\n\n        $vote->choice = VotableInterface::VOTE_NONE;\n\n        $votable->updateVoteCounts();\n\n        $this->entityManager->flush();\n\n        $this->dispatcher->dispatch(new VoteEvent($votable, $vote, false));\n\n        return $vote;\n    }\n}\n"
  },
  {
    "path": "src/Twig/Components/ActiveUsersComponent.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Twig\\Components;\n\nuse App\\Entity\\Magazine;\nuse App\\Entity\\User;\nuse App\\Repository\\UserRepository;\nuse Symfony\\Contracts\\Cache\\CacheInterface;\nuse Symfony\\Contracts\\Cache\\ItemInterface;\nuse Symfony\\UX\\TwigComponent\\Attribute\\AsTwigComponent;\n\n#[AsTwigComponent('active_users')]\nfinal class ActiveUsersComponent\n{\n    /** @var User[] */\n    public array $users = [];\n\n    public function __construct(\n        private readonly UserRepository $userRepository,\n        private readonly CacheInterface $cache,\n    ) {\n    }\n\n    public function mount(?Magazine $magazine): void\n    {\n        $activeUserIds = $this->cache->get(\"active_users_{$magazine?->getId()}\",\n            function (ItemInterface $item) use ($magazine) {\n                $item->expiresAfter(60 * 5); // 5 minutes\n\n                return $this->userRepository->findActiveUsers($magazine);\n            }\n        );\n\n        $this->users = $this->userRepository->findBy(['id' => $activeUserIds]);\n    }\n}\n"
  },
  {
    "path": "src/Twig/Components/AnnouncementComponent.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Twig\\Components;\n\nuse App\\Repository\\SiteRepository;\nuse Symfony\\UX\\TwigComponent\\Attribute\\AsTwigComponent;\nuse Symfony\\UX\\TwigComponent\\ComponentAttributes;\nuse Twig\\Environment;\n\n#[AsTwigComponent('announcement', template: 'components/_cached.html.twig')]\nfinal class AnnouncementComponent\n{\n    public function __construct(\n        private readonly Environment $twig,\n        private readonly SiteRepository $repository,\n    ) {\n    }\n\n    public function getHtml(ComponentAttributes $attributes): string\n    {\n        return $this->twig->render(\n            'components/announcement.html.twig',\n            [\n                'content' => $this->repository->findAll()[0]->announcement ?? '',\n            ]\n        );\n    }\n}\n"
  },
  {
    "path": "src/Twig/Components/BlurhashImageComponent.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Twig\\Components;\n\nuse kornrunner\\Blurhash\\Blurhash;\nuse Symfony\\Contracts\\Cache\\CacheInterface;\nuse Symfony\\Contracts\\Cache\\ItemInterface;\nuse Symfony\\UX\\TwigComponent\\Attribute\\AsTwigComponent;\n\n#[AsTwigComponent('blurhash_image')]\nfinal class BlurhashImageComponent\n{\n    public string $blurhash;\n    public int $width = 20;\n    public int $height = 20;\n\n    public function __construct(private CacheInterface $cache)\n    {\n    }\n\n    public function createImage(string $blurhash, int $width = 20, int $height = 20): string\n    {\n        $context = [$blurhash, $width, $height];\n\n        return $this->cache->get(\n            'bh_'.hash('sha256', serialize($context)),\n            function (ItemInterface $item) use ($blurhash, $width, $height) {\n                $item->expiresAfter(3600);\n\n                $pixels = Blurhash::decode($blurhash, $width, $height);\n                $image = imagecreatetruecolor($width, $height);\n                for ($y = 0; $y < $height; ++$y) {\n                    for ($x = 0; $x < $width; ++$x) {\n                        [$r, $g, $b] = $pixels[$y][$x];\n                        imagesetpixel($image, $x, $y, imagecolorallocate($image, $r, $g, $b));\n                    }\n                }\n\n                // I do not like this\n                ob_start();\n                imagepng($image);\n                $out = ob_get_contents();\n                ob_end_clean();\n\n                return 'data:image/png;base64,'.base64_encode($out);\n            }\n        );\n    }\n}\n"
  },
  {
    "path": "src/Twig/Components/BookmarkListComponent.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Twig\\Components;\n\nuse App\\Entity\\BookmarkList;\nuse App\\Entity\\Entry;\nuse App\\Entity\\EntryComment;\nuse App\\Entity\\Post;\nuse App\\Entity\\PostComment;\nuse Symfony\\UX\\TwigComponent\\Attribute\\AsTwigComponent;\n\n#[AsTwigComponent('bookmark_list')]\nclass BookmarkListComponent\n{\n    public Entry|EntryComment|Post|PostComment $subject;\n    public string $subjectClass;\n    public BookmarkList $list;\n}\n"
  },
  {
    "path": "src/Twig/Components/BookmarkMenuListComponent.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Twig\\Components;\n\nuse App\\Entity\\BookmarkList;\nuse App\\Entity\\Entry;\nuse App\\Entity\\EntryComment;\nuse App\\Entity\\Post;\nuse App\\Entity\\PostComment;\nuse Symfony\\UX\\TwigComponent\\Attribute\\AsTwigComponent;\n\n#[AsTwigComponent('bookmark_menu_list')]\nclass BookmarkMenuListComponent\n{\n    /** @var BookmarkList[] */\n    public array $bookmarkLists;\n\n    public string $subjectType;\n\n    public Entry|EntryComment|Post|PostComment $subject;\n}\n"
  },
  {
    "path": "src/Twig/Components/BookmarkStandardComponent.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Twig\\Components;\n\nuse App\\Entity\\Entry;\nuse App\\Entity\\EntryComment;\nuse App\\Entity\\Post;\nuse App\\Entity\\PostComment;\nuse Symfony\\UX\\TwigComponent\\Attribute\\AsTwigComponent;\n\n#[AsTwigComponent('bookmark_standard')]\nclass BookmarkStandardComponent\n{\n    public Entry|EntryComment|Post|PostComment $subject;\n    public string $subjectClass;\n}\n"
  },
  {
    "path": "src/Twig/Components/BoostComponent.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Twig\\Components;\n\nuse App\\Entity\\Contracts\\ContentInterface;\nuse App\\Entity\\Entry;\nuse App\\Entity\\EntryComment;\nuse App\\Entity\\Post;\nuse App\\Entity\\PostComment;\nuse Symfony\\UX\\TwigComponent\\Attribute\\AsTwigComponent;\nuse Symfony\\UX\\TwigComponent\\Attribute\\PostMount;\n\n#[AsTwigComponent('boost')]\nfinal class BoostComponent\n{\n    public string $formDest;\n    public ContentInterface $subject;\n\n    #[PostMount]\n    public function postMount(array $attr): array\n    {\n        $this->formDest = match (true) {\n            $this->subject instanceof Entry => 'entry',\n            $this->subject instanceof EntryComment => 'entry_comment',\n            $this->subject instanceof Post => 'post',\n            $this->subject instanceof PostComment => 'post_comment',\n            default => throw new \\LogicException(),\n        };\n\n        return $attr;\n    }\n}\n"
  },
  {
    "path": "src/Twig/Components/CursorPaginationComponent.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Twig\\Components;\n\nuse App\\Pagination\\Cursor\\CursorPaginationInterface;\nuse Symfony\\UX\\TwigComponent\\Attribute\\AsTwigComponent;\n\n#[AsTwigComponent('cursor_pagination')]\nclass CursorPaginationComponent\n{\n    public CursorPaginationInterface $pagination;\n}\n"
  },
  {
    "path": "src/Twig/Components/DateComponent.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Twig\\Components;\n\nuse Symfony\\UX\\TwigComponent\\Attribute\\AsTwigComponent;\n\n#[AsTwigComponent('date')]\nfinal class DateComponent\n{\n    public \\DateTimeInterface $date;\n}\n"
  },
  {
    "path": "src/Twig/Components/DateEditedComponent.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Twig\\Components;\n\nuse Symfony\\UX\\TwigComponent\\Attribute\\AsTwigComponent;\n\n#[AsTwigComponent('date_edited')]\nfinal class DateEditedComponent\n{\n    public \\DateTimeInterface $createdAt;\n    public ?\\DateTimeInterface $editedAt = null;\n}\n"
  },
  {
    "path": "src/Twig/Components/DomainComponent.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Twig\\Components;\n\nuse App\\Entity\\Domain;\nuse Symfony\\UX\\TwigComponent\\Attribute\\AsTwigComponent;\n\n#[AsTwigComponent('domain')]\nfinal class DomainComponent\n{\n    public Domain $domain;\n}\n"
  },
  {
    "path": "src/Twig/Components/DomainSubComponent.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Twig\\Components;\n\nuse App\\Entity\\Domain;\nuse Symfony\\UX\\TwigComponent\\Attribute\\AsTwigComponent;\n\n#[AsTwigComponent('domain_sub')]\nfinal class DomainSubComponent\n{\n    public Domain $domain;\n}\n"
  },
  {
    "path": "src/Twig/Components/EditorToolbarComponent.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Twig\\Components;\n\nuse Symfony\\UX\\TwigComponent\\Attribute\\AsTwigComponent;\n\n#[AsTwigComponent('editor_toolbar')]\nfinal class EditorToolbarComponent\n{\n    public string $id;\n}\n"
  },
  {
    "path": "src/Twig/Components/EntriesCrossComponent.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Twig\\Components;\n\nuse App\\Entity\\Entry;\nuse App\\Repository\\EntryRepository;\nuse Symfony\\Bundle\\SecurityBundle\\Security;\nuse Symfony\\Component\\HttpFoundation\\RequestStack;\nuse Symfony\\Contracts\\Cache\\CacheInterface;\nuse Symfony\\Contracts\\Cache\\ItemInterface;\nuse Symfony\\UX\\TwigComponent\\Attribute\\AsTwigComponent;\nuse Twig\\Environment;\n\n#[AsTwigComponent('entries_cross', template: 'components/_cached.html.twig')]\nfinal class EntriesCrossComponent\n{\n    public ?Entry $entry = null;\n\n    public function __construct(\n        private readonly EntryRepository $repository,\n        private readonly CacheInterface $cache,\n        private readonly Environment $twig,\n        private readonly RequestStack $requestStack,\n        private readonly Security $security,\n    ) {\n    }\n\n    public function getHtml(): string\n    {\n        $entryId = $this->entry->getId();\n        $userId = $this->security->getUser()?->getId();\n        $locale = $this->requestStack->getCurrentRequest()?->getLocale();\n\n        return $this->cache->get(\n            \"entries_cross_{$entryId}_{$userId}_{$locale}\",\n            function (ItemInterface $item) use ($entryId) {\n                $item->expiresAfter(60);\n                $entries = $this->repository->findCross($this->entry);\n\n                $item->tag(['entry_'.$entryId]);\n                foreach ($entries as $entry) {\n                    $item->tag(['entry_'.$entry->getId()]);\n                }\n\n                return $this->twig->render(\n                    'components/entries_cross.html.twig',\n                    [\n                        'entries' => $this->repository->findCross($this->entry),\n                    ]\n                );\n            }\n        );\n    }\n}\n"
  },
  {
    "path": "src/Twig/Components/EntryCommentComponent.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Twig\\Components;\n\nuse App\\Controller\\User\\ThemeSettingsController;\nuse App\\Entity\\Contracts\\VisibilityInterface;\nuse App\\Entity\\EntryComment;\nuse App\\PageView\\EntryCommentPageView;\nuse Symfony\\Component\\HttpFoundation\\RequestStack;\nuse Symfony\\Component\\Security\\Core\\Authorization\\AuthorizationCheckerInterface;\nuse Symfony\\UX\\TwigComponent\\Attribute\\AsTwigComponent;\n\n#[AsTwigComponent('entry_comment')]\nfinal class EntryCommentComponent\n{\n    public function __construct(\n        private readonly RequestStack $requestStack,\n        private readonly AuthorizationCheckerInterface $authorizationChecker,\n    ) {\n    }\n\n    public EntryComment $comment;\n    public bool $showMagazineName = true;\n    public bool $showEntryTitle = true;\n    public bool $showNested = false;\n    public int $level = 1;\n    public bool $canSeeTrash = false;\n    public bool $dateAsUrl = true;\n    public EntryCommentPageView $criteria;\n\n    public function postMount(array $attr): array\n    {\n        $this->canSeeTrashed();\n\n        return $attr;\n    }\n\n    public function getLevel(): int\n    {\n        if (ThemeSettingsController::CLASSIC === $this->requestStack->getMainRequest()->cookies->get(\n            ThemeSettingsController::ENTRY_COMMENTS_VIEW\n        )) {\n            return min($this->level, 2);\n        }\n\n        return min($this->level, 10);\n    }\n\n    public function canSeeTrashed(): bool\n    {\n        if (VisibilityInterface::VISIBILITY_VISIBLE === $this->comment->visibility) {\n            return true;\n        }\n\n        if (VisibilityInterface::VISIBILITY_TRASHED === $this->comment->visibility\n            && $this->authorizationChecker->isGranted(\n                'moderate',\n                $this->comment\n            )\n            && $this->canSeeTrash) {\n            return true;\n        }\n\n        $this->comment->image = null;\n\n        return false;\n    }\n}\n"
  },
  {
    "path": "src/Twig/Components/EntryCommentInlineComponent.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Twig\\Components;\n\nuse App\\Entity\\EntryComment;\nuse Symfony\\UX\\TwigComponent\\Attribute\\AsTwigComponent;\n\n#[AsTwigComponent('entry_comment_inline_md')]\nfinal class EntryCommentInlineComponent\n{\n    public EntryComment $comment;\n}\n"
  },
  {
    "path": "src/Twig/Components/EntryCommentsNestedComponent.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Twig\\Components;\n\nuse App\\Controller\\User\\ThemeSettingsController;\nuse App\\Entity\\EntryComment;\nuse App\\PageView\\EntryCommentPageView;\nuse Symfony\\UX\\TwigComponent\\Attribute\\AsTwigComponent;\n\n#[AsTwigComponent('entry_comments_nested')]\nfinal class EntryCommentsNestedComponent\n{\n    public EntryComment $comment;\n    public int $level;\n    public string $view = ThemeSettingsController::TREE;\n    public EntryCommentPageView $criteria;\n}\n"
  },
  {
    "path": "src/Twig/Components/EntryComponent.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Twig\\Components;\n\nuse App\\Entity\\Contracts\\VisibilityInterface;\nuse App\\Entity\\Entry;\nuse Symfony\\Component\\Security\\Core\\Authorization\\AuthorizationCheckerInterface;\nuse Symfony\\UX\\TwigComponent\\Attribute\\AsTwigComponent;\nuse Symfony\\UX\\TwigComponent\\Attribute\\PostMount;\n\n#[AsTwigComponent('entry')]\nfinal class EntryComponent\n{\n    public function __construct(private readonly AuthorizationCheckerInterface $authorizationChecker)\n    {\n    }\n\n    public ?Entry $entry;\n    public bool $isSingle = false;\n    public bool $showShortSentence = true;\n    public bool $showBody = false;\n    public bool $showMagazineName = true;\n    public bool $canSeeTrash = false;\n\n    #[PostMount]\n    public function postMount(array $attr): array\n    {\n        $this->canSeeTrashed();\n\n        if ($this->isSingle) {\n            if (isset($attr['class'])) {\n                $attr['class'] = trim('entry--single section--top '.$attr['class']);\n            } else {\n                $attr['class'] = 'entry--single section--top';\n            }\n        }\n\n        return $attr;\n    }\n\n    public function canSeeTrashed(): bool\n    {\n        if (VisibilityInterface::VISIBILITY_VISIBLE === $this->entry->visibility) {\n            return true;\n        }\n\n        if (VisibilityInterface::VISIBILITY_TRASHED === $this->entry->visibility\n            && $this->authorizationChecker->isGranted(\n                'moderate',\n                $this->entry\n            )\n            && $this->canSeeTrash) {\n            return true;\n        }\n\n        $this->showBody = false;\n        $this->showShortSentence = false;\n        $this->entry->image = null;\n\n        return false;\n    }\n}\n"
  },
  {
    "path": "src/Twig/Components/EntryCrossComponent.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Twig\\Components;\n\nuse App\\Entity\\Entry;\nuse Symfony\\UX\\TwigComponent\\Attribute\\AsTwigComponent;\n\n#[AsTwigComponent('entry_cross', template: 'components/entry_cross.html.twig')]\nfinal class EntryCrossComponent\n{\n    public ?Entry $entry;\n}\n"
  },
  {
    "path": "src/Twig/Components/EntryInlineComponent.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Twig\\Components;\n\nuse App\\Entity\\Entry;\nuse Symfony\\UX\\TwigComponent\\Attribute\\AsTwigComponent;\n\n#[AsTwigComponent('entry_inline')]\nfinal class EntryInlineComponent\n{\n    public Entry $entry;\n}\n"
  },
  {
    "path": "src/Twig/Components/EntryInlineMdComponent.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Twig\\Components;\n\nuse App\\Entity\\Entry;\nuse Symfony\\UX\\TwigComponent\\Attribute\\AsTwigComponent;\n\n#[AsTwigComponent('entry_inline_md')]\nfinal class EntryInlineMdComponent\n{\n    public Entry $entry;\n\n    public bool $userFullName = false;\n\n    public bool $magazineFullName = false;\n}\n"
  },
  {
    "path": "src/Twig/Components/FavouriteComponent.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Twig\\Components;\n\nuse App\\Entity\\Contracts\\ContentInterface;\nuse App\\Entity\\Entry;\nuse App\\Entity\\EntryComment;\nuse App\\Entity\\Post;\nuse App\\Entity\\PostComment;\nuse Symfony\\UX\\TwigComponent\\Attribute\\AsTwigComponent;\nuse Symfony\\UX\\TwigComponent\\Attribute\\PostMount;\n\n#[AsTwigComponent('favourite')]\nfinal class FavouriteComponent\n{\n    public string $formDest;\n    public ContentInterface $subject;\n\n    #[PostMount]\n    public function postMount(array $attr): array\n    {\n        $this->formDest = match (true) {\n            $this->subject instanceof Entry => 'entry',\n            $this->subject instanceof EntryComment => 'entry_comment',\n            $this->subject instanceof Post => 'post',\n            $this->subject instanceof PostComment => 'post_comment',\n            default => throw new \\LogicException(),\n        };\n\n        return $attr;\n    }\n}\n"
  },
  {
    "path": "src/Twig/Components/FeaturedMagazinesComponent.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Twig\\Components;\n\nuse App\\Entity\\Contracts\\VisibilityInterface;\nuse App\\Entity\\Magazine;\nuse App\\Repository\\MagazineRepository;\nuse Symfony\\UX\\TwigComponent\\Attribute\\AsTwigComponent;\nuse Symfony\\UX\\TwigComponent\\ComponentAttributes;\nuse Twig\\Environment;\n\n#[AsTwigComponent('featured_magazines', template: 'components/_cached.html.twig')]\nfinal class FeaturedMagazinesComponent\n{\n    public ?Magazine $magazine = null;\n\n    public function __construct(private readonly Environment $twig, private readonly MagazineRepository $repository)\n    {\n    }\n\n    public function getHtml(ComponentAttributes $attributes): string\n    {\n        return $this->render();\n    }\n\n    private function render(): string\n    {\n        $magazines = $this->repository->findBy(\n            ['apId' => null, 'visibility' => VisibilityInterface::VISIBILITY_VISIBLE],\n            ['lastActive' => 'DESC'],\n            28\n        );\n\n        if ($this->magazine && !\\in_array($this->magazine, $magazines)) {\n            array_unshift($magazines, $this->magazine);\n        }\n\n        usort($magazines, fn ($a, $b) => $a->lastActive < $b->lastActive ? 1 : -1);\n\n        return $this->twig->render(\n            'components/featured_magazines.html.twig',\n            [\n                'magazines' => array_map(fn ($mag) => $mag->name, $magazines),\n                'magazine' => $this->magazine,\n            ]\n        );\n    }\n}\n"
  },
  {
    "path": "src/Twig/Components/FilterListComponent.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Twig\\Components;\n\nuse App\\Entity\\UserFilterList;\nuse Symfony\\UX\\TwigComponent\\Attribute\\AsTwigComponent;\n\n#[AsTwigComponent('filter_lists')]\nclass FilterListComponent\n{\n    public UserFilterList $filterList;\n}\n"
  },
  {
    "path": "src/Twig/Components/InstanceList.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Twig\\Components;\n\nuse App\\Entity\\Instance;\nuse Symfony\\UX\\TwigComponent\\Attribute\\AsTwigComponent;\n\n#[AsTwigComponent('instance_list')]\nclass InstanceList\n{\n    /** @var Instance[] */\n    public array $instances;\n\n    public bool $showUnBanButton = false;\n    public bool $showBanButton = false;\n\n    public bool $showDenyButton = false;\n\n    public bool $showAllowButton = false;\n}\n"
  },
  {
    "path": "src/Twig/Components/LoaderComponent.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Twig\\Components;\n\nuse Symfony\\UX\\TwigComponent\\Attribute\\AsTwigComponent;\n\n#[AsTwigComponent('loader')]\nfinal class LoaderComponent\n{\n}\n"
  },
  {
    "path": "src/Twig/Components/LoginSocialsComponent.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Twig\\Components;\n\nuse Symfony\\Component\\DependencyInjection\\Attribute\\Autowire;\nuse Symfony\\UX\\TwigComponent\\Attribute\\AsTwigComponent;\n\n#[AsTwigComponent('login_socials')]\nfinal class LoginSocialsComponent\n{\n    public function __construct(\n        #[Autowire('%oauth_google_id%')]\n        private readonly ?string $oauthGoogleId,\n        #[Autowire('%oauth_facebook_id%')]\n        private readonly ?string $oauthFacebookId,\n        #[Autowire('%oauth_github_id%')]\n        private readonly ?string $oauthGithubId,\n        #[Autowire('%oauth_discord_id%')]\n        private readonly ?string $oauthDiscordId,\n        #[Autowire('%oauth_privacyportal_id%')]\n        private readonly ?string $oauthPrivacyPortalId,\n        #[Autowire('%oauth_keycloak_id%')]\n        private readonly ?string $oauthKeycloakId,\n        #[Autowire('%oauth_simplelogin_id%')]\n        private readonly ?string $oauthSimpleLoginId,\n        #[Autowire('%oauth_zitadel_id%')]\n        private readonly ?string $oauthZitadelId,\n        #[Autowire('%oauth_authentik_id%')]\n        private readonly ?string $oauthAuthentikId,\n        #[Autowire('%oauth_azure_id%')]\n        private readonly ?string $oauthAzureId,\n    ) {\n    }\n\n    public function googleEnabled(): bool\n    {\n        return !empty($this->oauthGoogleId);\n    }\n\n    public function discordEnabled(): bool\n    {\n        return !empty($this->oauthDiscordId);\n    }\n\n    public function facebookEnabled(): bool\n    {\n        return !empty($this->oauthFacebookId);\n    }\n\n    public function githubEnabled(): bool\n    {\n        return !empty($this->oauthGithubId);\n    }\n\n    public function privacyPortalEnabled(): bool\n    {\n        return !empty($this->oauthPrivacyPortalId);\n    }\n\n    public function keycloakEnabled(): bool\n    {\n        return !empty($this->oauthKeycloakId);\n    }\n\n    public function simpleloginEnabled(): bool\n    {\n        return !empty($this->oauthSimpleLoginId);\n    }\n\n    public function zitadelEnabled(): bool\n    {\n        return !empty($this->oauthZitadelId);\n    }\n\n    public function authentikEnabled(): bool\n    {\n        return !empty($this->oauthAuthentikId);\n    }\n\n    public function azureEnabled(): bool\n    {\n        return !empty($this->oauthAzureId);\n    }\n}\n"
  },
  {
    "path": "src/Twig/Components/MagazineBoxComponent.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Twig\\Components;\n\nuse App\\Entity\\Magazine;\nuse Symfony\\UX\\TwigComponent\\Attribute\\AsTwigComponent;\n\n#[AsTwigComponent('magazine_box')]\nfinal class MagazineBoxComponent\n{\n    public Magazine $magazine;\n    public bool $showCover = true;\n    public bool $showDescription = true;\n    public bool $showRules = true;\n    public bool $showSubscribeButton = true;\n    public bool $showInfo = true;\n    public bool $showMeta = true;\n    public bool $showSectionTitle = false;\n    public bool $stretchedLink = true;\n    public bool $showTags = true;\n}\n"
  },
  {
    "path": "src/Twig/Components/MagazineInlineComponent.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Twig\\Components;\n\nuse App\\Entity\\Magazine;\nuse Symfony\\UX\\TwigComponent\\Attribute\\AsTwigComponent;\n\n#[AsTwigComponent('magazine_inline')]\nfinal class MagazineInlineComponent\n{\n    public Magazine $magazine;\n    public bool $showTitle = true;\n    public bool $fullName = false;\n    public bool $stretchedLink = false;\n    public bool $showAvatar = false;\n    public bool $showNewIcon = true;\n}\n"
  },
  {
    "path": "src/Twig/Components/MagazineSubComponent.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Twig\\Components;\n\nuse App\\Entity\\Magazine;\nuse Symfony\\UX\\TwigComponent\\Attribute\\AsTwigComponent;\n\n#[AsTwigComponent('magazine_sub')]\nfinal class MagazineSubComponent\n{\n    public Magazine $magazine;\n}\n"
  },
  {
    "path": "src/Twig/Components/MonitoringTwigRenderComponent.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Twig\\Components;\n\nuse App\\Entity\\MonitoringTwigRender;\nuse Symfony\\UX\\TwigComponent\\Attribute\\AsTwigComponent;\n\n#[AsTwigComponent('monitoring_twig_render')]\nclass MonitoringTwigRenderComponent\n{\n    public MonitoringTwigRender $render;\n\n    public bool $compareToParent = true;\n}\n"
  },
  {
    "path": "src/Twig/Components/NotificationSwitch.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Twig\\Components;\n\nuse App\\Entity\\Entry;\nuse App\\Entity\\Magazine;\nuse App\\Entity\\Post;\nuse App\\Entity\\User;\nuse App\\Enums\\ENotificationStatus;\nuse App\\Repository\\NotificationSettingsRepository;\nuse Symfony\\Bundle\\SecurityBundle\\Security;\nuse Symfony\\UX\\TwigComponent\\Attribute\\AsTwigComponent;\nuse Symfony\\UX\\TwigComponent\\Attribute\\PostMount;\n\n#[AsTwigComponent('notification_switch')]\nclass NotificationSwitch\n{\n    public ENotificationStatus $status = ENotificationStatus::Default;\n\n    public Entry|Post|User|Magazine $target;\n\n    public function __construct(\n        private readonly Security $security,\n        private readonly NotificationSettingsRepository $repository,\n    ) {\n    }\n\n    #[PostMount]\n    public function postMount(): void\n    {\n        $user = $this->security->getUser();\n        if ($user instanceof User) {\n            $this->status = $this->repository->findOneByTarget($user, $this->target)?->getStatus() ?? ENotificationStatus::Default;\n        }\n    }\n}\n"
  },
  {
    "path": "src/Twig/Components/PostCombinedComponent.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Twig\\Components;\n\nuse Symfony\\Component\\Security\\Core\\Authorization\\AuthorizationCheckerInterface;\nuse Symfony\\UX\\TwigComponent\\Attribute\\AsTwigComponent;\n\n#[AsTwigComponent('post_combined')]\nclass PostCombinedComponent extends PostComponent\n{\n    public function __construct(AuthorizationCheckerInterface $authorizationChecker)\n    {\n        parent::__construct($authorizationChecker);\n    }\n}\n"
  },
  {
    "path": "src/Twig/Components/PostCommentCombinedComponent.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Twig\\Components;\n\nuse App\\Entity\\Contracts\\VisibilityInterface;\nuse App\\Entity\\PostComment;\nuse App\\Repository\\Criteria;\nuse Symfony\\Component\\Security\\Core\\Authorization\\AuthorizationCheckerInterface;\nuse Symfony\\UX\\TwigComponent\\Attribute\\AsTwigComponent;\n\n#[AsTwigComponent('post_comment_combined')]\nfinal class PostCommentCombinedComponent\n{\n    public function __construct(\n        private readonly AuthorizationCheckerInterface $authorizationChecker,\n    ) {\n    }\n\n    public PostComment $comment;\n    public bool $dateAsUrl = true;\n    public bool $showNested = false;\n    public bool $withPost = false;\n    public int $level = 1;\n    public bool $canSeeTrash = false;\n    public Criteria $criteria;\n\n    public function postMount(array $attr): array\n    {\n        $this->canSeeTrashed();\n\n        return $attr;\n    }\n\n    public function canSeeTrashed(): bool\n    {\n        if (VisibilityInterface::VISIBILITY_VISIBLE === $this->comment->visibility) {\n            return true;\n        }\n\n        if (VisibilityInterface::VISIBILITY_TRASHED === $this->comment->visibility\n            && $this->authorizationChecker->isGranted(\n                'moderate',\n                $this->comment\n            )\n            && $this->canSeeTrash) {\n            return true;\n        }\n\n        $this->comment->image = null;\n\n        return false;\n    }\n}\n"
  },
  {
    "path": "src/Twig/Components/PostCommentComponent.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Twig\\Components;\n\nuse App\\Controller\\User\\ThemeSettingsController;\nuse App\\Entity\\Contracts\\VisibilityInterface;\nuse App\\Entity\\PostComment;\nuse App\\Repository\\Criteria;\nuse Symfony\\Component\\HttpFoundation\\RequestStack;\nuse Symfony\\Component\\Security\\Core\\Authorization\\AuthorizationCheckerInterface;\nuse Symfony\\UX\\TwigComponent\\Attribute\\AsTwigComponent;\n\n#[AsTwigComponent('post_comment')]\nfinal class PostCommentComponent\n{\n    public function __construct(\n        private readonly RequestStack $requestStack,\n        private readonly AuthorizationCheckerInterface $authorizationChecker,\n    ) {\n    }\n\n    public PostComment $comment;\n    public bool $dateAsUrl = true;\n    public bool $showNested = false;\n    public bool $withPost = false;\n    public int $level = 1;\n    public bool $canSeeTrash = false;\n    public Criteria $criteria;\n\n    public function postMount(array $attr): array\n    {\n        $this->canSeeTrashed();\n\n        return $attr;\n    }\n\n    public function getLevel(): int\n    {\n        if (ThemeSettingsController::CLASSIC === $this->requestStack->getMainRequest()->cookies->get(\n            ThemeSettingsController::POST_COMMENTS_VIEW\n        )) {\n            return min($this->level, 2);\n        }\n\n        return min($this->level, 10);\n    }\n\n    public function canSeeTrashed(): bool\n    {\n        if (VisibilityInterface::VISIBILITY_VISIBLE === $this->comment->visibility) {\n            return true;\n        }\n\n        if (VisibilityInterface::VISIBILITY_TRASHED === $this->comment->visibility\n            && $this->authorizationChecker->isGranted(\n                'moderate',\n                $this->comment\n            )\n            && $this->canSeeTrash) {\n            return true;\n        }\n\n        $this->comment->image = null;\n\n        return false;\n    }\n}\n"
  },
  {
    "path": "src/Twig/Components/PostCommentInlineComponent.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Twig\\Components;\n\nuse App\\Entity\\PostComment;\nuse Symfony\\UX\\TwigComponent\\Attribute\\AsTwigComponent;\n\n#[AsTwigComponent('post_comment_inline_md')]\nfinal class PostCommentInlineComponent\n{\n    public PostComment $comment;\n}\n"
  },
  {
    "path": "src/Twig/Components/PostCommentsNestedComponent.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Twig\\Components;\n\nuse App\\Controller\\User\\ThemeSettingsController;\nuse App\\Entity\\PostComment;\nuse App\\Repository\\Criteria;\nuse Symfony\\UX\\TwigComponent\\Attribute\\AsTwigComponent;\n\n#[AsTwigComponent('post_comments_nested')]\nfinal class PostCommentsNestedComponent\n{\n    public PostComment $comment;\n    public int $level;\n    public string $view = ThemeSettingsController::TREE;\n    public Criteria $criteria;\n}\n"
  },
  {
    "path": "src/Twig/Components/PostCommentsPreviewComponent.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Twig\\Components;\n\nuse App\\Entity\\Post;\nuse Symfony\\Bundle\\SecurityBundle\\Security;\nuse Symfony\\Component\\HttpFoundation\\RequestStack;\nuse Symfony\\Contracts\\Cache\\CacheInterface;\nuse Symfony\\Contracts\\Cache\\ItemInterface;\nuse Symfony\\UX\\TwigComponent\\Attribute\\AsTwigComponent;\nuse Symfony\\UX\\TwigComponent\\ComponentAttributes;\nuse Twig\\Environment;\nuse Twig\\Runtime\\EscaperRuntime;\n\n#[AsTwigComponent('post_comments_preview', template: 'components/_cached.html.twig')]\nfinal class PostCommentsPreviewComponent\n{\n    public Post $post;\n\n    public function __construct(\n        private readonly Environment $twig,\n        private readonly Security $security,\n        private readonly CacheInterface $cache,\n        private readonly RequestStack $requestStack,\n    ) {\n    }\n\n    public function getHtml(ComponentAttributes $attributes): string\n    {\n        $postId = $this->post->getId();\n        $userId = $this->security->getUser()?->getId();\n\n        return $this->cache->get(\n            \"post_comment_preview_{$postId}_{$userId}_{$this->requestStack->getCurrentRequest()?->getLocale()}\",\n            function (ItemInterface $item) use ($postId, $userId, $attributes) {\n                $item->expiresAfter(3600);\n                $item->tag(['post_comments_user_'.$userId]);\n                $item->tag(['post_'.$postId]);\n\n                return $this->twig->render(\n                    'components/post_comments_preview.html.twig',\n                    [\n                        'attributes' => new ComponentAttributes($attributes->all(), new EscaperRuntime()),\n                        'post' => $this->post,\n                        'comments' => $this->post->lastActive < (new \\DateTime('-4 hours'))\n                            ? $this->post->getBestComments($this->security->getUser())\n                            : $this->post->getLastComments($this->security->getUser()),\n                    ]\n                );\n            }\n        );\n    }\n}\n"
  },
  {
    "path": "src/Twig/Components/PostComponent.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Twig\\Components;\n\nuse App\\Entity\\Contracts\\VisibilityInterface;\nuse App\\Entity\\Post;\nuse Symfony\\Component\\Security\\Core\\Authorization\\AuthorizationCheckerInterface;\nuse Symfony\\UX\\TwigComponent\\Attribute\\AsTwigComponent;\nuse Symfony\\UX\\TwigComponent\\Attribute\\PostMount;\n\n#[AsTwigComponent('post')]\nclass PostComponent\n{\n    public Post $post;\n    public bool $isSingle = false;\n    public bool $showMagazineName = true;\n    public bool $dateAsUrl = true;\n    public bool $showCommentsPreview = false;\n    public bool $showExpand = true;\n    public bool $canSeeTrash = false;\n\n    public function __construct(\n        private readonly AuthorizationCheckerInterface $authorizationChecker,\n    ) {\n    }\n\n    #[PostMount]\n    public function postMount(array $attr): array\n    {\n        $this->canSeeTrashed();\n\n        if ($this->isSingle) {\n            $this->showMagazineName = false;\n\n            if (isset($attr['class'])) {\n                $attr['class'] = trim('post--single '.$attr['class']);\n            } else {\n                $attr['class'] = 'post--single';\n            }\n        }\n\n        return $attr;\n    }\n\n    public function canSeeTrashed(): bool\n    {\n        if (VisibilityInterface::VISIBILITY_VISIBLE === $this->post->visibility) {\n            return true;\n        }\n\n        if (VisibilityInterface::VISIBILITY_TRASHED === $this->post->visibility\n            && $this->authorizationChecker->isGranted(\n                'moderate',\n                $this->post\n            )\n            && $this->canSeeTrash) {\n            return true;\n        }\n\n        $this->post->image = null;\n\n        return false;\n    }\n}\n"
  },
  {
    "path": "src/Twig/Components/PostInlineMdComponent.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Twig\\Components;\n\nuse App\\Entity\\Post;\nuse Symfony\\UX\\TwigComponent\\Attribute\\AsTwigComponent;\n\n#[AsTwigComponent('post_inline_md')]\nfinal class PostInlineMdComponent\n{\n    public Post $post;\n\n    public bool $userFullName = false;\n\n    public bool $magazineFullName = false;\n}\n"
  },
  {
    "path": "src/Twig/Components/RelatedEntriesComponent.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Twig\\Components;\n\nuse App\\Entity\\Entry;\nuse App\\Entity\\User;\nuse App\\Repository\\EntryRepository;\nuse App\\Service\\MentionManager;\nuse App\\Service\\SettingsManager;\nuse Symfony\\Bundle\\SecurityBundle\\Security;\nuse Symfony\\Contracts\\Cache\\CacheInterface;\nuse Symfony\\Contracts\\Cache\\ItemInterface;\nuse Symfony\\UX\\TwigComponent\\Attribute\\AsTwigComponent;\n\n#[AsTwigComponent('related_entries')]\nfinal class RelatedEntriesComponent\n{\n    public const TYPE_TAG = 'tag';\n    public const TYPE_MAGAZINE = 'magazine';\n    public const TYPE_RANDOM = 'random';\n\n    public int $limit = 4;\n    public ?string $type = self::TYPE_RANDOM;\n    public ?Entry $entry = null;\n    public string $title = 'random_entries';\n\n    /** @var Entry[] */\n    public array $entries = [];\n\n    public function __construct(\n        private readonly EntryRepository $repository,\n        private readonly CacheInterface $cache,\n        private readonly SettingsManager $settingsManager,\n        private readonly MentionManager $mentionManager,\n        private readonly Security $security,\n    ) {\n    }\n\n    public function mount(?string $magazine, ?string $tag): void\n    {\n        if ($tag) {\n            $this->title = 'related_entries';\n            $this->type = self::TYPE_TAG;\n        }\n\n        if ($magazine) {\n            $this->title = 'related_entries';\n            $this->type = self::TYPE_MAGAZINE;\n        }\n\n        $entryId = $this->entry?->getId();\n        $magazine = str_replace('@', '', $magazine ?? '');\n        /** @var User|null $user */\n        $user = $this->security->getUser();\n\n        $cacheKey = \"related_entries_{$magazine}_{$tag}_{$entryId}_{$this->type}_{$this->settingsManager->getLocale()}_{$user?->getId()}\";\n        $entryIds = $this->cache->get(\n            $cacheKey,\n            function (ItemInterface $item) use ($magazine, $tag, $user) {\n                $item->expiresAfter(60 * 5); // 5 minutes\n\n                $entries = match ($this->type) {\n                    self::TYPE_TAG => $this->repository->findRelatedByMagazine($tag, $this->limit + 20, user: $user),\n                    self::TYPE_MAGAZINE => $this->repository->findRelatedByTag(\n                        $this->mentionManager->getUsername($magazine),\n                        $this->limit + 20,\n                        user: $user,\n                    ),\n                    default => $this->repository->findLast($this->limit + 150, user: $user),\n                };\n\n                $entries = array_filter($entries, fn (Entry $e) => !$e->isAdult && !$e->magazine->isAdult);\n\n                if (\\count($entries) > $this->limit) {\n                    shuffle($entries); // randomize the order\n                    $entries = \\array_slice($entries, 0, $this->limit);\n                }\n\n                return array_map(fn (Entry $entry) => $entry->getId(), $entries);\n            }\n        );\n\n        $this->entries = $this->repository->findBy(['id' => $entryIds]);\n    }\n}\n"
  },
  {
    "path": "src/Twig/Components/RelatedMagazinesComponent.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Twig\\Components;\n\nuse App\\Entity\\Magazine;\nuse App\\Entity\\User;\nuse App\\Repository\\MagazineRepository;\nuse App\\Service\\SettingsManager;\nuse Symfony\\Bundle\\SecurityBundle\\Security;\nuse Symfony\\Contracts\\Cache\\CacheInterface;\nuse Symfony\\Contracts\\Cache\\ItemInterface;\nuse Symfony\\UX\\TwigComponent\\Attribute\\AsTwigComponent;\n\n#[AsTwigComponent('related_magazines')]\nfinal class RelatedMagazinesComponent\n{\n    public const TYPE_TAG = 'tag';\n    public const TYPE_MAGAZINE = 'magazine';\n    public const TYPE_RANDOM = 'random';\n\n    public int $limit = 4;\n    public ?string $type = self::TYPE_RANDOM;\n    public string $title = 'random_magazines';\n    /** @var Magazine[] */\n    public array $magazines = [];\n\n    public function __construct(\n        private readonly MagazineRepository $repository,\n        private readonly CacheInterface $cache,\n        private readonly SettingsManager $settingsManager,\n        private readonly Security $security,\n    ) {\n    }\n\n    public function mount(?string $magazine, ?string $tag): void\n    {\n        if ($tag) {\n            $this->title = 'related_magazines';\n            $this->type = self::TYPE_TAG;\n        }\n\n        if ($magazine) {\n            $this->title = 'related_magazines';\n            $this->type = self::TYPE_MAGAZINE;\n        }\n\n        $magazine = str_replace('@', '', $magazine ?? '');\n        /** @var User|null $user */\n        $user = $this->security->getUser();\n\n        $magazineIds = $this->cache->get(\n            \"related_magazines_{$magazine}_{$tag}_{$this->type}_{$this->settingsManager->getLocale()}_{$user?->getId()}\",\n            function (ItemInterface $item) use ($magazine, $tag, $user) {\n                $item->expiresAfter(60 * 5); // 5 minutes\n\n                $magazines = match ($this->type) {\n                    self::TYPE_TAG => $this->repository->findRelated($tag, user: $user),\n                    self::TYPE_MAGAZINE => $this->repository->findRelated($magazine, user: $user),\n                    default => $this->repository->findRandom(user: $user),\n                };\n\n                $magazines = array_filter($magazines, fn ($m) => $m->name !== $magazine);\n\n                return array_map(fn (Magazine $magazine) => $magazine->getId(), $magazines);\n            }\n        );\n\n        $this->magazines = $this->repository->findBy(['id' => $magazineIds]);\n    }\n}\n"
  },
  {
    "path": "src/Twig/Components/RelatedPostsComponent.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Twig\\Components;\n\nuse App\\Entity\\Post;\nuse App\\Entity\\User;\nuse App\\Repository\\PostRepository;\nuse App\\Service\\MentionManager;\nuse App\\Service\\SettingsManager;\nuse Symfony\\Bundle\\SecurityBundle\\Security;\nuse Symfony\\Contracts\\Cache\\CacheInterface;\nuse Symfony\\Contracts\\Cache\\ItemInterface;\nuse Symfony\\UX\\TwigComponent\\Attribute\\AsTwigComponent;\n\n#[AsTwigComponent('related_posts')]\nfinal class RelatedPostsComponent\n{\n    public const TYPE_TAG = 'tag';\n    public const TYPE_MAGAZINE = 'magazine';\n    public const TYPE_RANDOM = 'random';\n\n    public int $limit = 4;\n    public ?string $type = self::TYPE_RANDOM;\n    public ?Post $post = null;\n    public string $title = 'random_posts';\n    /** @var Post[] */\n    public array $posts = [];\n\n    public function __construct(\n        private readonly PostRepository $repository,\n        private readonly CacheInterface $cache,\n        private readonly SettingsManager $settingsManager,\n        private readonly MentionManager $mentionManager,\n        private readonly Security $security,\n    ) {\n    }\n\n    public function mount(?string $magazine, ?string $tag): void\n    {\n        if ($tag) {\n            $this->title = 'related_posts';\n            $this->type = self::TYPE_TAG;\n        }\n\n        if ($magazine) {\n            $this->title = 'related_posts';\n            $this->type = self::TYPE_MAGAZINE;\n        }\n\n        /** @var User|null $user */\n        $user = $this->security->getUser();\n\n        $postId = $this->post?->getId();\n        $magazine = str_replace('@', '', $magazine ?? '');\n\n        $postIds = $this->cache->get(\n            \"related_posts_{$magazine}_{$tag}_{$postId}_{$this->type}_{$this->settingsManager->getLocale()}_{$user?->getId()}\",\n            function (ItemInterface $item) use ($magazine, $tag, $user) {\n                $item->expiresAfter(60 * 5); // 5 minutes\n\n                $posts = match ($this->type) {\n                    self::TYPE_TAG => $this->repository->findRelatedByMagazine($tag, $this->limit + 20, user: $user),\n                    self::TYPE_MAGAZINE => $this->repository->findRelatedByTag(\n                        $this->mentionManager->getUsername($magazine),\n                        $this->limit + 20,\n                        user: $user\n                    ),\n                    default => $this->repository->findLast($this->limit + 150, user: $user),\n                };\n\n                $posts = array_filter($posts, fn (Post $p) => !$p->isAdult && !$p->magazine->isAdult);\n\n                if (\\count($posts) > $this->limit) {\n                    shuffle($posts); // randomize the order\n                    $posts = \\array_slice($posts, 0, $this->limit);\n                }\n\n                return array_map(fn (Post $post) => $post->getId(), $posts);\n            }\n        );\n\n        $this->posts = $this->repository->findBy(['id' => $postIds]);\n    }\n}\n"
  },
  {
    "path": "src/Twig/Components/ReportListComponent.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Twig\\Components;\n\nuse Pagerfanta\\PagerfantaInterface;\nuse Symfony\\UX\\TwigComponent\\Attribute\\AsTwigComponent;\n\n#[AsTwigComponent('report_list', template: 'components/report_list.html.twig')]\nfinal class ReportListComponent\n{\n    public PagerfantaInterface $reports;\n    public string $routeName = 'admin_reports';\n    public ?string $magazineName = null;\n}\n"
  },
  {
    "path": "src/Twig/Components/SettingsRowEnumComponent.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Twig\\Components;\n\nuse Symfony\\UX\\TwigComponent\\Attribute\\AsTwigComponent;\n\n#[AsTwigComponent('settings_row_enum', template: 'components/_settings_row_enum.html.twig')]\nclass SettingsRowEnumComponent\n{\n    public string $label;\n    public string $help = '';\n    public string $class = '';\n    public string $settingsKey;\n    public array $values;\n    public ?string $defaultValue = null;\n    public bool $reloadRequired = true;\n}\n"
  },
  {
    "path": "src/Twig/Components/SettingsRowSwitchComponent.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Twig\\Components;\n\nuse Symfony\\UX\\TwigComponent\\Attribute\\AsTwigComponent;\n\n#[AsTwigComponent('settings_row_switch', template: 'components/_settings_row_switch.html.twig')]\nclass SettingsRowSwitchComponent\n{\n    public string $label;\n    public string $help = '';\n    public string $settingsKey;\n    public bool $defaultValue = false;\n    public bool $reloadRequired = true;\n}\n"
  },
  {
    "path": "src/Twig/Components/SidebarSubscriptionComponent.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Twig\\Components;\n\nuse App\\Controller\\User\\ThemeSettingsController;\nuse App\\Entity\\Magazine;\nuse App\\Entity\\User;\nuse App\\Repository\\MagazineRepository;\nuse App\\Utils\\SubscriptionSort;\nuse Symfony\\UX\\TwigComponent\\Attribute\\AsTwigComponent;\nuse Symfony\\UX\\TwigComponent\\Attribute\\PostMount;\n\n#[AsTwigComponent('sidebar_subscriptions', 'layout/sidebar_subscriptions.html.twig')]\nclass SidebarSubscriptionComponent\n{\n    public User $user;\n    public ?Magazine $openMagazine;\n    public bool $tooManyMagazines = false;\n\n    /**\n     * @var Magazine[]\n     */\n    public array $magazines;\n\n    public ?string $sort;\n\n    public function __construct(private readonly MagazineRepository $magazineRepository)\n    {\n    }\n\n    #[PostMount]\n    public function PostMount(): void\n    {\n        $max = 50;\n        $this->magazines = [];\n        if (ThemeSettingsController::ALPHABETICALLY === $this->sort) {\n            $this->magazines = $this->magazineRepository->findMagazineSubscriptionsOfUser($this->user, SubscriptionSort::Alphabetically, $max);\n        } else {\n            $this->magazines = $this->magazineRepository->findMagazineSubscriptionsOfUser($this->user, SubscriptionSort::LastActive, $max);\n        }\n        if (\\sizeof($this->magazines) === $max) {\n            $this->tooManyMagazines = true;\n        }\n    }\n}\n"
  },
  {
    "path": "src/Twig/Components/TagActionComponent.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Twig\\Components;\n\nuse Symfony\\UX\\TwigComponent\\Attribute\\AsTwigComponent;\n\n#[AsTwigComponent('tag_actions')]\nclass TagActionComponent\n{\n    public string $tag;\n}\n"
  },
  {
    "path": "src/Twig/Components/UserActionsComponent.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Twig\\Components;\n\nuse App\\Entity\\User;\nuse Symfony\\UX\\TwigComponent\\Attribute\\AsTwigComponent;\n\n#[AsTwigComponent('user_actions')]\nfinal class UserActionsComponent\n{\n    public User $user;\n}\n"
  },
  {
    "path": "src/Twig/Components/UserAvatarComponent.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Twig\\Components;\n\nuse App\\Entity\\User;\nuse Symfony\\UX\\TwigComponent\\Attribute\\AsTwigComponent;\n\n#[AsTwigComponent('user_avatar')]\nfinal class UserAvatarComponent\n{\n    public int $width = 32;\n    public int $height = 32;\n    public User $user;\n    public bool $asLink = false;\n}\n"
  },
  {
    "path": "src/Twig/Components/UserBoxComponent.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Twig\\Components;\n\nuse App\\Entity\\User;\nuse Symfony\\UX\\TwigComponent\\Attribute\\AsTwigComponent;\n\n#[AsTwigComponent('user_box')]\nfinal class UserBoxComponent\n{\n    public User $user;\n    public bool $stretchedLink = true;\n}\n"
  },
  {
    "path": "src/Twig/Components/UserFormActionsComponent.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Twig\\Components;\n\nuse Symfony\\UX\\TwigComponent\\Attribute\\AsTwigComponent;\n\n#[AsTwigComponent('user_form_actions')]\nfinal class UserFormActionsComponent\n{\n    public bool $showLogin = false;\n    public bool $showRegister = false;\n    public bool $showPasswordReset = false;\n    public bool $showResendEmail = false;\n}\n"
  },
  {
    "path": "src/Twig/Components/UserImageComponent.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Twig\\Components;\n\nuse App\\Entity\\User;\nuse Symfony\\UX\\TwigComponent\\Attribute\\AsTwigComponent;\n\n#[AsTwigComponent('user_image_component')]\nfinal class UserImageComponent\n{\n    public User $user;\n    public bool $showAvatar = true;\n}\n"
  },
  {
    "path": "src/Twig/Components/UserInlineBoxComponent.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Twig\\Components;\n\nuse App\\Entity\\User;\nuse Symfony\\UX\\TwigComponent\\Attribute\\AsTwigComponent;\n\n#[AsTwigComponent('user_inline_box')]\nfinal class UserInlineBoxComponent\n{\n    public User $user;\n    public bool $showAvatar = true;\n    public bool $showNewIcon = true;\n    public bool $fullName;\n}\n"
  },
  {
    "path": "src/Twig/Components/UserInlineComponent.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Twig\\Components;\n\nuse App\\Entity\\User;\nuse Symfony\\UX\\TwigComponent\\Attribute\\AsTwigComponent;\n\n#[AsTwigComponent('user_inline')]\nfinal class UserInlineComponent\n{\n    public User $user;\n    public bool $showAvatar = true;\n    public bool $showNewIcon = true;\n    public bool $fullName;\n}\n"
  },
  {
    "path": "src/Twig/Components/VoteComponent.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Twig\\Components;\n\nuse App\\Entity\\Contracts\\VotableInterface;\nuse App\\Entity\\Entry;\nuse App\\Entity\\EntryComment;\nuse App\\Entity\\Post;\nuse App\\Entity\\PostComment;\nuse Symfony\\UX\\TwigComponent\\Attribute\\AsTwigComponent;\nuse Symfony\\UX\\TwigComponent\\Attribute\\PostMount;\n\n#[AsTwigComponent('vote')]\nfinal class VoteComponent\n{\n    public VotableInterface $subject;\n    public string $formDest;\n    public bool $showDownvote = true;\n\n    #[PostMount]\n    public function postMount(array $attr): array\n    {\n        $this->formDest = match (true) {\n            $this->subject instanceof Entry => 'entry',\n            $this->subject instanceof EntryComment => 'entry_comment',\n            $this->subject instanceof Post => 'post',\n            $this->subject instanceof PostComment => 'post_comment',\n            default => throw new \\LogicException(),\n        };\n\n        return $attr;\n    }\n}\n"
  },
  {
    "path": "src/Twig/Components/VotersInlineComponent.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Twig\\Components;\n\nuse App\\Entity\\Contracts\\VotableInterface;\nuse App\\Service\\CacheService;\nuse Doctrine\\Common\\Collections\\Collection;\nuse Doctrine\\Common\\Collections\\Criteria;\nuse Doctrine\\Common\\Collections\\Order;\nuse Symfony\\Contracts\\Cache\\CacheInterface;\nuse Symfony\\Contracts\\Cache\\ItemInterface;\nuse Symfony\\UX\\TwigComponent\\Attribute\\AsTwigComponent;\nuse Symfony\\UX\\TwigComponent\\ComponentAttributes;\nuse Twig\\Environment;\nuse Twig\\Runtime\\EscaperRuntime;\n\n#[AsTwigComponent('voters_inline', template: 'components/_cached.html.twig')]\nfinal class VotersInlineComponent\n{\n    public VotableInterface $subject;\n    public string $url;\n\n    public function __construct(\n        private readonly Environment $twig,\n        private readonly CacheInterface $cache,\n        private readonly CacheService $cacheService,\n    ) {\n    }\n\n    public function getHtml(ComponentAttributes $attributes): string\n    {\n        return $this->cache->get(\n            $this->cacheService->getVotersCacheKey($this->subject),\n            function (ItemInterface $item) use ($attributes) {\n                $item->expiresAfter(3600);\n                /**\n                 * @var Collection $votes\n                 */\n                $votes = $this->subject->votes;\n                $votes = $votes->matching(\n                    new Criteria(\n                        Criteria::expr()->eq('choice', VotableInterface::VOTE_UP),\n                        ['createdAt' => Order::Descending]\n                    )\n                )->slice(0, 4);\n\n                return $this->twig->render(\n                    'components/voters_inline.html.twig',\n                    [\n                        'attributes' => new ComponentAttributes($attributes->all(), new EscaperRuntime()),\n                        'voters' => array_map(fn ($vote) => $vote->user->username, $votes),\n                        'count' => $this->subject->countUpVotes(),\n                        'url' => $this->url,\n                    ]\n                );\n            }\n        );\n    }\n}\n"
  },
  {
    "path": "src/Twig/Extension/AdminExtension.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Twig\\Extension;\n\nuse App\\Twig\\Runtime\\AdminExtensionRuntime;\nuse Twig\\Extension\\AbstractExtension;\nuse Twig\\TwigFunction;\n\nfinal class AdminExtension extends AbstractExtension\n{\n    public function getFunctions(): array\n    {\n        return [\n            new TwigFunction('is_admin_panel_page', [AdminExtensionRuntime::class, 'isAdminPanelPage']),\n            new TwigFunction('is_tag_banned', [AdminExtensionRuntime::class, 'isTagBanned']),\n            new TwigFunction('do_new_users_need_approval', [AdminExtensionRuntime::class, 'doNewUsersNeedApproval']),\n            new TwigFunction('is_monitoring_enabled', [AdminExtensionRuntime::class, 'isMonitoringEnabled']),\n        ];\n    }\n}\n"
  },
  {
    "path": "src/Twig/Extension/BookmarkExtension.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Twig\\Extension;\n\nuse App\\Twig\\Runtime\\BookmarkExtensionRuntime;\nuse Twig\\Extension\\AbstractExtension;\nuse Twig\\TwigFunction;\n\nclass BookmarkExtension extends AbstractExtension\n{\n    public function getFunctions(): array\n    {\n        return [\n            new TwigFunction('is_bookmarked', [BookmarkExtensionRuntime::class, 'isContentBookmarked']),\n            new TwigFunction('is_bookmarked_in_list', [BookmarkExtensionRuntime::class, 'isContentBookmarkedInList']),\n            new TwigFunction('get_bookmark_lists', [BookmarkExtensionRuntime::class, 'getUsersBookmarkLists']),\n            new TwigFunction('get_bookmark_list_entry_count', [BookmarkExtensionRuntime::class, 'getBookmarkListEntryCount']),\n        ];\n    }\n}\n"
  },
  {
    "path": "src/Twig/Extension/ContextExtension.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Twig\\Extension;\n\nuse App\\Twig\\Runtime\\ContextExtensionRuntime;\nuse Twig\\Extension\\AbstractExtension;\nuse Twig\\TwigFunction;\n\nfinal class ContextExtension extends AbstractExtension\n{\n    public function getFunctions(): array\n    {\n        return [\n            new TwigFunction('is_route_name', [ContextExtensionRuntime::class, 'isRouteName']),\n            new TwigFunction('is_route_name_contains', [ContextExtensionRuntime::class, 'isRouteNameContains']),\n            new TwigFunction('is_route_name_starts_with', [ContextExtensionRuntime::class, 'isRouteNameStartsWith']),\n            new TwigFunction('is_route_name_end_with', [ContextExtensionRuntime::class, 'isRouteNameEndWith']),\n            new TwigFunction('route_has_param', [ContextExtensionRuntime::class, 'routeHasParam']),\n            new TwigFunction('route_param_exists', [ContextExtensionRuntime::class, 'routeParamExists']),\n            new TwigFunction('get_active_sort_option', [ContextExtensionRuntime::class, 'getActiveSortOption']),\n            new TwigFunction('get_active_sort_option_for_comments', [ContextExtensionRuntime::class, 'getActiveSortOptionForComments']),\n            new TwigFunction('get_default_sort_option', [ContextExtensionRuntime::class, 'getDefaultSortOption']),\n            new TwigFunction('get_default_sort_option_for_comments', [ContextExtensionRuntime::class, 'getDefaultSortOptionForComments']),\n            new TwigFunction('is_route_params_contains', [ContextExtensionRuntime::class, 'isRouteParamsContains']),\n            new TwigFunction('get_route_param', [ContextExtensionRuntime::class, 'getRouteParam']),\n            new TwigFunction('get_time_param_translated', [ContextExtensionRuntime::class, 'getTimeParamTranslated']),\n            new TwigFunction('now', [ContextExtensionRuntime::class, 'now']),\n        ];\n    }\n}\n"
  },
  {
    "path": "src/Twig/Extension/CounterExtension.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Twig\\Extension;\n\nuse App\\Twig\\Runtime\\CounterExtensionRuntime;\nuse Twig\\Extension\\AbstractExtension;\nuse Twig\\TwigFunction;\n\nclass CounterExtension extends AbstractExtension\n{\n    public function getFunctions(): array\n    {\n        return [\n            new TwigFunction('count_user_boosts', [CounterExtensionRuntime::class, 'countUserBoosts']),\n            new TwigFunction('count_user_moderated', [CounterExtensionRuntime::class, 'countUserModerated']),\n        ];\n    }\n}\n"
  },
  {
    "path": "src/Twig/Extension/DomainExtension.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Twig\\Extension;\n\nuse App\\Twig\\Runtime\\DomainExtensionRuntime;\nuse Twig\\Extension\\AbstractExtension;\nuse Twig\\TwigFunction;\n\nclass DomainExtension extends AbstractExtension\n{\n    public function getFunctions(): array\n    {\n        return [\n            new TwigFunction('is_domain_subscribed', [DomainExtensionRuntime::class, 'isSubscribed']),\n            new TwigFunction('is_domain_blocked', [DomainExtensionRuntime::class, 'isBlocked']),\n        ];\n    }\n}\n"
  },
  {
    "path": "src/Twig/Extension/EmailExtension.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Twig\\Extension;\n\nuse App\\Twig\\Runtime\\EmailExtensionRuntime;\nuse Twig\\Extension\\AbstractExtension;\nuse Twig\\TwigFunction;\n\nfinal class EmailExtension extends AbstractExtension\n{\n    public function getFunctions(): array\n    {\n        return [\n            new TwigFunction('encore_entry_css_source', [EmailExtensionRuntime::class, 'getEncoreEntryCssSource']),\n        ];\n    }\n}\n"
  },
  {
    "path": "src/Twig/Extension/FormattingExtension.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Twig\\Extension;\n\nuse App\\Twig\\Runtime\\FormattingExtensionRuntime;\nuse Twig\\Extension\\AbstractExtension;\nuse Twig\\TwigFilter;\nuse Twig\\TwigFunction;\n\nfinal class FormattingExtension extends AbstractExtension\n{\n    public function getFilters(): array\n    {\n        return [\n            new TwigFilter('markdown', [FormattingExtensionRuntime::class, 'convertToHtml']),\n            new TwigFilter('bool', fn ($value) => (bool) $value),\n            new TwigFilter('abbreviateNumber', [FormattingExtensionRuntime::class, 'abbreviateNumber']),\n            new TwigFilter('uuidEnd', [FormattingExtensionRuntime::class, 'uuidEnd']),\n            new TwigFilter('formatQuery', [FormattingExtensionRuntime::class, 'formatQuery']),\n        ];\n    }\n\n    public function getFunctions(): array\n    {\n        return [\n            new TwigFunction('get_short_sentence', [FormattingExtensionRuntime::class, 'getShortSentence']),\n        ];\n    }\n}\n"
  },
  {
    "path": "src/Twig/Extension/FrontExtension.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Twig\\Extension;\n\nuse App\\Twig\\Runtime\\FrontExtensionRuntime;\nuse Twig\\Extension\\AbstractExtension;\nuse Twig\\TwigFunction;\n\nfinal class FrontExtension extends AbstractExtension\n{\n    public function getFunctions(): array\n    {\n        return [\n            new TwigFunction('front_options_url', [FrontExtensionRuntime::class, 'frontOptionsUrl']),\n            new TwigFunction('get_class', [FrontExtensionRuntime::class, 'getClass']),\n            new TwigFunction('get_subject_type', [FrontExtensionRuntime::class, 'getSubjectType']),\n            new TwigFunction('get_notification_settings_subject_type', [FrontExtensionRuntime::class, 'getNotificationSettingSubjectType']),\n        ];\n    }\n}\n"
  },
  {
    "path": "src/Twig/Extension/LinkExtension.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Twig\\Extension;\n\nuse App\\Twig\\Runtime\\LinkExtensionRuntime;\nuse Twig\\Extension\\AbstractExtension;\nuse Twig\\TwigFunction;\n\nclass LinkExtension extends AbstractExtension\n{\n    public function getFunctions(): array\n    {\n        return [\n            new TwigFunction('get_rel', [LinkExtensionRuntime::class, 'getRel']),\n            new TwigFunction('get_url_fragment', [LinkExtensionRuntime::class, 'getHtmlClass']),\n            new TwigFunction('get_url_domain', [LinkExtensionRuntime::class, 'getLinkDomain']),\n        ];\n    }\n}\n"
  },
  {
    "path": "src/Twig/Extension/MagazineExtension.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Twig\\Extension;\n\nuse App\\Twig\\Runtime\\MagazineExtensionRuntime;\nuse Twig\\Extension\\AbstractExtension;\nuse Twig\\TwigFunction;\n\nclass MagazineExtension extends AbstractExtension\n{\n    public function getFunctions(): array\n    {\n        return [\n            new TwigFunction('is_magazine_subscribed', [MagazineExtensionRuntime::class, 'isSubscribed']),\n            new TwigFunction('is_magazine_blocked', [MagazineExtensionRuntime::class, 'isBlocked']),\n            new TwigFunction('magazine_has_local_subscribers', [MagazineExtensionRuntime::class, 'hasLocalSubscribers']),\n            new TwigFunction('get_instance_of_magazine', [MagazineExtensionRuntime::class, 'getInstanceOfMagazine']),\n            new TwigFunction('is_instance_of_magazine_blocked', [MagazineExtensionRuntime::class, 'isInstanceOfMagazineBanned']),\n        ];\n    }\n}\n"
  },
  {
    "path": "src/Twig/Extension/MediaExtension.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Twig\\Extension;\n\nuse App\\Twig\\Runtime\\MediaExtensionRuntime;\nuse Twig\\Extension\\AbstractExtension;\nuse Twig\\TwigFunction;\n\nclass MediaExtension extends AbstractExtension\n{\n    public function getFunctions(): array\n    {\n        return [\n            new TwigFunction('uploaded_asset', [MediaExtensionRuntime::class, 'getPublicPath']),\n        ];\n    }\n}\n"
  },
  {
    "path": "src/Twig/Extension/MonitorExtension.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Twig\\Extension;\n\nuse App\\Service\\Monitor;\nuse Psr\\Log\\LoggerInterface;\nuse Twig\\Extension\\AbstractExtension;\nuse Twig\\Profiler\\NodeVisitor\\ProfilerNodeVisitor;\nuse Twig\\Profiler\\Profile;\n\n/**\n * Heavily inspired by https://github.com/inspector-apm/inspector-symfony/blob/master/src/Twig/TwigTracer.php.\n */\nclass MonitorExtension extends AbstractExtension\n{\n    protected array $runningTemplates = [];\n\n    public function __construct(\n        private readonly Monitor $monitor,\n        private readonly LoggerInterface $logger,\n    ) {\n    }\n\n    /**\n     * This method is called before the execution of a block, a macro or a\n     * template.\n     *\n     * @param Profile $profile The profiling data\n     */\n    public function enter(Profile $profile): void\n    {\n        if (!$this->monitor->shouldRecordTwigRenders() || null === $this->monitor->currentContext) {\n            return;\n        }\n\n        $profile->enter();\n\n        $label = $this->getLabelTitle($profile);\n        $this->monitor->startTwigRendering($label, $profile->getType());\n        $this->runningTemplates[] = $label;\n    }\n\n    /**\n     * This method is called when the execution of a block, a macro or a\n     * template is finished.\n     *\n     * @param Profile $profile The profiling data\n     */\n    public function leave(Profile $profile): void\n    {\n        if (!$this->monitor->shouldRecordTwigRenders() || null === $this->monitor->currentContext) {\n            return;\n        }\n\n        $profile->leave();\n\n        $key = $this->getLabelTitle($profile);\n        $popped = array_pop($this->runningTemplates);\n        if ($popped !== $key) {\n            $this->logger->warning('Trying to leave a node, but the last entered one is of a different template: {popped} !== {key}', ['popped' => $popped, 'key' => $key]);\n\n            return;\n        }\n\n        $this->monitor->endTwigRendering($key, $profile->getMemoryUsage(), $profile->getPeakMemoryUsage(), $profile->getName(), $profile->getType(), $profile->getDuration() * 1000);\n    }\n\n    public function getNodeVisitors(): array\n    {\n        return [new ProfilerNodeVisitor(self::class)];\n    }\n\n    /**\n     * Gets a short description for the segment.\n     *\n     * @param Profile $profile The profiling data\n     */\n    private function getLabelTitle(Profile $profile): string\n    {\n        switch (true) {\n            case $profile->isRoot():\n                return $profile->getName();\n\n            case $profile->isTemplate():\n                return $profile->getTemplate();\n\n            default:\n                return \\sprintf('%s::%s(%s)', $profile->getTemplate(), $profile->getType(), $profile->getName());\n        }\n    }\n}\n"
  },
  {
    "path": "src/Twig/Extension/NavbarExtension.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Twig\\Extension;\n\nuse App\\Twig\\Runtime\\NavbarExtensionRuntime;\nuse Twig\\Extension\\AbstractExtension;\nuse Twig\\TwigFunction;\n\nclass NavbarExtension extends AbstractExtension\n{\n    public function getFunctions(): array\n    {\n        return [\n            new TwigFunction('navbar_threads_url', [NavbarExtensionRuntime::class, 'navbarThreadsUrl']),\n            new TwigFunction('navbar_posts_url', [NavbarExtensionRuntime::class, 'navbarPostsUrl']),\n            new TwigFunction('navbar_people_url', [NavbarExtensionRuntime::class, 'navbarPeopleUrl']),\n            new TwigFunction('navbar_combined_url', [NavbarExtensionRuntime::class, 'navbarCombinedUrl']),\n        ];\n    }\n}\n"
  },
  {
    "path": "src/Twig/Extension/SettingsExtension.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Twig\\Extension;\n\nuse App\\Twig\\Runtime\\SettingsExtensionRuntime;\nuse Twig\\Extension\\AbstractExtension;\nuse Twig\\TwigFunction;\n\nfinal class SettingsExtension extends AbstractExtension\n{\n    public function getFunctions(): array\n    {\n        return [\n            new TwigFunction('kbin_domain', [SettingsExtensionRuntime::class, 'kbinDomain']),\n            new TwigFunction('kbin_title', [SettingsExtensionRuntime::class, 'kbinTitle']),\n            new TwigFunction('kbin_meta_title', [SettingsExtensionRuntime::class, 'kbinMetaTitle']),\n            new TwigFunction('kbin_meta_description', [SettingsExtensionRuntime::class, 'kbinDescription']),\n            new TwigFunction('kbin_meta_keywords', [SettingsExtensionRuntime::class, 'kbinKeywords']),\n            new TwigFunction('kbin_default_lang', [SettingsExtensionRuntime::class, 'kbinDefaultLang']),\n            new TwigFunction('mbin_default_theme', [SettingsExtensionRuntime::class, 'mbinDefaultTheme']),\n            new TwigFunction('kbin_registrations_enabled', [SettingsExtensionRuntime::class, 'kbinRegistrationsEnabled']),\n            new TwigFunction('mbin_sso_registrations_enabled', [SettingsExtensionRuntime::class, 'mbinSsoRegistrationsEnabled']),\n            new TwigFunction('mbin_sso_only_mode', [SettingsExtensionRuntime::class, 'mbinSsoOnlyMode']),\n            new TwigFunction('kbin_header_logo', [SettingsExtensionRuntime::class, 'kbinHeaderLogo']),\n            new TwigFunction('kbin_captcha_enabled', [SettingsExtensionRuntime::class, 'kbinCaptchaEnabled']),\n            new TwigFunction('kbin_mercure_enabled', [SettingsExtensionRuntime::class, 'kbinMercureEnabled']),\n            new TwigFunction('kbin_federation_page_enabled', [SettingsExtensionRuntime::class, 'kbinFederationPageEnabled']),\n            new TwigFunction('mbin_downvotes_mode', [SettingsExtensionRuntime::class, 'mbinDownvotesMode']),\n            new TwigFunction('mbin_current_version', [SettingsExtensionRuntime::class, 'mbinCurrentVersion']),\n            new TwigFunction('mbin_restrict_magazine_creation', [SettingsExtensionRuntime::class, 'mbinRestrictMagazineCreation']),\n            new TwigFunction('mbin_private_instance', [SettingsExtensionRuntime::class, 'mbinPrivateInstance']),\n            new TwigFunction('mbin_sso_show_first', [SettingsExtensionRuntime::class, 'mbinSsoShowFirst']),\n            new TwigFunction('mbin_lang', [SettingsExtensionRuntime::class, 'mbinLang']),\n        ];\n    }\n}\n"
  },
  {
    "path": "src/Twig/Extension/SubjectExtension.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Twig\\Extension;\n\nuse App\\Entity\\Entry;\nuse App\\Entity\\EntryComment;\nuse App\\Entity\\Magazine;\nuse App\\Entity\\Post;\nuse App\\Entity\\PostComment;\nuse App\\Entity\\User;\nuse Twig\\Extension\\AbstractExtension;\nuse Twig\\TwigTest;\n\nclass SubjectExtension extends AbstractExtension\n{\n    public function getTests(): array\n    {\n        return [\n            new TwigTest(\n                'entry', function ($subject) {\n                    return $subject instanceof Entry;\n                }\n            ),\n            new TwigTest(\n                'entry_comment', function ($subject) {\n                    return $subject instanceof EntryComment;\n                }\n            ),\n            new TwigTest(\n                'post', function ($subject) {\n                    return $subject instanceof Post;\n                }\n            ),\n            new TwigTest(\n                'post_comment', function ($subject) {\n                    return $subject instanceof PostComment;\n                }\n            ),\n            new TwigTest(\n                'magazine', function ($subject) {\n                    return $subject instanceof Magazine;\n                }\n            ),\n            new TwigTest(\n                'user', function ($subject) {\n                    return $subject instanceof User;\n                }\n            ),\n        ];\n    }\n}\n"
  },
  {
    "path": "src/Twig/Extension/UrlExtension.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Twig\\Extension;\n\nuse App\\Twig\\Runtime\\UrlExtensionRuntime;\nuse Twig\\Extension\\AbstractExtension;\nuse Twig\\TwigFunction;\n\nfinal class UrlExtension extends AbstractExtension\n{\n    public function getFunctions(): array\n    {\n        return [\n            new TwigFunction('entry_url', [UrlExtensionRuntime::class, 'entryUrl']),\n            new TwigFunction('entry_edit_url', [UrlExtensionRuntime::class, 'entryEditUrl']),\n            new TwigFunction('entry_favourites_url', [UrlExtensionRuntime::class, 'entryFavouritesUrl']),\n            new TwigFunction('entry_voters_url', [UrlExtensionRuntime::class, 'entryVotersUrl']),\n            new TwigFunction('entry_moderate_url', [UrlExtensionRuntime::class, 'entryModerateUrl']),\n            new TwigFunction('entry_delete_url', [UrlExtensionRuntime::class, 'entryDeleteUrl']),\n            new TwigFunction('entry_comment_create_url', [UrlExtensionRuntime::class, 'entryCommentCreateUrl']),\n            new TwigFunction('entry_comment_view_url', [UrlExtensionRuntime::class, 'entryCommentViewUrl']),\n            new TwigFunction('entry_comment_edit_url', [UrlExtensionRuntime::class, 'entryCommentEditUrl']),\n            new TwigFunction('entry_comment_delete_url', [UrlExtensionRuntime::class, 'entryCommentDeleteUrl']),\n            new TwigFunction('entry_comment_voters_url', [UrlExtensionRuntime::class, 'entryCommentVotersUrl']),\n            new TwigFunction('entry_comment_favourites_url', [UrlExtensionRuntime::class, 'entryCommentFavouritesUrl']),\n            new TwigFunction('entry_comment_moderate_url', [UrlExtensionRuntime::class, 'entryCommentModerateUrl']),\n            new TwigFunction('post_url', [UrlExtensionRuntime::class, 'postUrl']),\n            new TwigFunction('post_edit_url', [UrlExtensionRuntime::class, 'postEditUrl']),\n            new TwigFunction('post_voters_url', [UrlExtensionRuntime::class, 'postVotersUrl']),\n            new TwigFunction('post_favourites_url', [UrlExtensionRuntime::class, 'postFavouritesUrl']),\n            new TwigFunction('post_moderate_url', [UrlExtensionRuntime::class, 'postModerateUrl']),\n            new TwigFunction('post_delete_url', [UrlExtensionRuntime::class, 'postDeleteUrl']),\n            new TwigFunction('post_comment_create_url', [UrlExtensionRuntime::class, 'postCommentReplyUrl']),\n            new TwigFunction('post_comment_edit_url', [UrlExtensionRuntime::class, 'postCommentEditUrl']),\n            new TwigFunction('post_comment_moderate_url', [UrlExtensionRuntime::class, 'postCommentModerateUrl']),\n            new TwigFunction('post_comment_voters_url', [UrlExtensionRuntime::class, 'postCommentVotersUrl']),\n            new TwigFunction('post_comment_favourites_url', [UrlExtensionRuntime::class, 'postCommentFavouritesUrl']),\n            new TwigFunction('post_comment_delete_url', [UrlExtensionRuntime::class, 'postCommentDeleteUrl']),\n            new TwigFunction('options_url', [UrlExtensionRuntime::class, 'optionsUrl']),\n            new TwigFunction('mention_url', [UrlExtensionRuntime::class, 'mentionUrl']),\n            new TwigFunction('get_cursor_url_value', [UrlExtensionRuntime::class, 'getCursorUrlValue']),\n        ];\n    }\n}\n"
  },
  {
    "path": "src/Twig/Extension/UserExtension.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Twig\\Extension;\n\nuse App\\Twig\\Runtime\\UserExtensionRuntime;\nuse Twig\\Extension\\AbstractExtension;\nuse Twig\\TwigFilter;\nuse Twig\\TwigFunction;\n\nclass UserExtension extends AbstractExtension\n{\n    public function getFunctions(): array\n    {\n        return [\n            new TwigFunction('is_user_followed', [UserExtensionRuntime::class, 'isFollowed']),\n            new TwigFunction('is_user_blocked', [UserExtensionRuntime::class, 'isBlocked']),\n            new TwigFunction('get_reputation_total', [UserExtensionRuntime::class, 'getReputationTotal']),\n            new TwigFunction('get_instance_of_user', [UserExtensionRuntime::class, 'getInstanceOfUser']),\n            new TwigFunction('get_user_attitude', [UserExtensionRuntime::class, 'getUserAttitude']),\n            new TwigFunction('is_instance_of_user_banned', [UserExtensionRuntime::class, 'isInstanceOfUserBanned']),\n        ];\n    }\n\n    public function getFilters(): array\n    {\n        return [\n            new TwigFilter('username', [UserExtensionRuntime::class, 'username'], ['is_safe' => ['html']]),\n            new TwigFilter('apDomain', [UserExtensionRuntime::class, 'apDomain'], ['is_safe' => ['html']]),\n        ];\n    }\n}\n"
  },
  {
    "path": "src/Twig/Runtime/AdminExtensionRuntime.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Twig\\Runtime;\n\nuse App\\Repository\\TagRepository;\nuse App\\Repository\\UserRepository;\nuse App\\Service\\SettingsManager;\nuse Symfony\\Bundle\\SecurityBundle\\Security;\nuse Symfony\\Component\\Security\\Core\\Exception\\AccessDeniedException;\nuse Twig\\Extension\\RuntimeExtensionInterface;\n\nreadonly class AdminExtensionRuntime implements RuntimeExtensionInterface\n{\n    public function __construct(\n        private Security $security,\n        private TagRepository $tagRepository,\n        private SettingsManager $settingsManager,\n        private UserRepository $userRepository,\n        private bool $monitoringEnabled,\n    ) {\n    }\n\n    public function isTagBanned(string $tag): bool\n    {\n        if (!$this->security->isGranted('ROLE_ADMIN')) {\n            throw new AccessDeniedException();\n        }\n\n        $hashtag = $this->tagRepository->findOneBy(['tag' => $tag]);\n        if (null === $hashtag) {\n            return false;\n        }\n\n        return $hashtag->banned;\n    }\n\n    public function doNewUsersNeedApproval(): bool\n    {\n        // show the signup requests page even if they are deactivated if there are any remaining\n        return $this->settingsManager->getNewUsersNeedApproval() || $this->userRepository->findAllSignupRequestsPaginated()->count() > 0;\n    }\n\n    public function isMonitoringEnabled(): bool\n    {\n        return $this->monitoringEnabled;\n    }\n}\n"
  },
  {
    "path": "src/Twig/Runtime/BookmarkExtensionRuntime.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Twig\\Runtime;\n\nuse App\\Entity\\BookmarkList;\nuse App\\Entity\\Entry;\nuse App\\Entity\\EntryComment;\nuse App\\Entity\\Post;\nuse App\\Entity\\PostComment;\nuse App\\Entity\\User;\nuse App\\Repository\\BookmarkListRepository;\nuse App\\Service\\BookmarkManager;\nuse Twig\\Extension\\RuntimeExtensionInterface;\n\nclass BookmarkExtensionRuntime implements RuntimeExtensionInterface\n{\n    public function __construct(\n        private readonly BookmarkListRepository $bookmarkListRepository,\n        private readonly BookmarkManager $bookmarkManager,\n    ) {\n    }\n\n    /**\n     * @return BookmarkList[]\n     */\n    public function getUsersBookmarkLists(User $user): array\n    {\n        return $this->bookmarkListRepository->findByUser($user);\n    }\n\n    public function getBookmarkListEntryCount(BookmarkList $list): int\n    {\n        return $list->entities->count();\n    }\n\n    public function isContentBookmarked(User $user, Entry|EntryComment|Post|PostComment $content): bool\n    {\n        return $this->bookmarkManager->isBookmarked($user, $content);\n    }\n\n    public function isContentBookmarkedInList(User $user, BookmarkList $list, Entry|EntryComment|Post|PostComment $content): bool\n    {\n        return $this->bookmarkManager->isBookmarkedInList($user, $list, $content);\n    }\n}\n"
  },
  {
    "path": "src/Twig/Runtime/ContextExtensionRuntime.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Twig\\Runtime;\n\nuse App\\Entity\\User;\nuse App\\Repository\\Criteria;\nuse Symfony\\Bundle\\SecurityBundle\\Security;\nuse Symfony\\Component\\HttpFoundation\\RequestStack;\nuse Symfony\\Contracts\\Translation\\TranslatorInterface;\nuse Twig\\Extension\\RuntimeExtensionInterface;\n\nclass ContextExtensionRuntime implements RuntimeExtensionInterface\n{\n    public function __construct(\n        private readonly RequestStack $requestStack,\n        private readonly TranslatorInterface $translator,\n        private readonly Security $security,\n    ) {\n    }\n\n    public function isRouteNameContains(string $needle): bool\n    {\n        return str_contains($this->getCurrentRouteName(), $needle);\n    }\n\n    public function isRouteNameStartsWith(string $needle): bool\n    {\n        return str_starts_with($this->getCurrentRouteName(), $needle);\n    }\n\n    public function isRouteNameEndWith(string $needle): bool\n    {\n        return str_ends_with($this->getCurrentRouteName(), $needle);\n    }\n\n    public function isRouteName(string $needle): bool\n    {\n        return $this->getCurrentRouteName() === $needle;\n    }\n\n    public function isRouteParamsContains(string $paramName, $value): bool\n    {\n        return $this->requestStack->getMainRequest()->get($paramName) === $value;\n    }\n\n    public function routeHasParam(string $name, string $needle): bool\n    {\n        return $this->requestStack->getCurrentRequest()->get($name) === $needle;\n    }\n\n    public function routeParamExists(string $name): bool\n    {\n        return (bool) $this->requestStack->getCurrentRequest()->get($name);\n    }\n\n    private function getCurrentRouteName(): string\n    {\n        return $this->requestStack->getCurrentRequest()->get('_route') ?? 'front';\n    }\n\n    public function getActiveSortOption(): string\n    {\n        $defaultSort = $this->getDefaultSortOption();\n        $requestSort = $this->requestStack->getCurrentRequest()->get('sortBy');\n\n        return 'default' !== $requestSort ? ($requestSort ?? $defaultSort) : $defaultSort;\n    }\n\n    public function getDefaultSortOption(): string\n    {\n        $defaultSort = 'hot';\n        $user = $this->security->getUser();\n        if ($user instanceof User) {\n            $defaultSort = $user->frontDefaultSort;\n        }\n\n        return $defaultSort;\n    }\n\n    public function getActiveSortOptionForComments(): string\n    {\n        $defaultSort = $this->getDefaultSortOptionForComments();\n        $requestSort = $this->requestStack->getCurrentRequest()->get('sortBy');\n\n        return 'default' !== $requestSort ? ($requestSort ?? $defaultSort) : $defaultSort;\n    }\n\n    public function getDefaultSortOptionForComments(): string\n    {\n        $defaultSort = 'hot';\n        $user = $this->security->getUser();\n        if ($user instanceof User) {\n            $defaultSort = $user->commentDefaultSort;\n        }\n\n        return $defaultSort;\n    }\n\n    public function getRouteParam(string $name): ?string\n    {\n        return $this->requestStack->getCurrentRequest()->get($name);\n    }\n\n    public function getTimeParamTranslated(): string\n    {\n        $paramValue = $this->getRouteParam('time');\n        if (!\\in_array($paramValue, Criteria::TIME_ROUTES_EN)\n            || '∞' === $paramValue\n            || 'all' === $paramValue\n        ) {\n            return $this->translator->trans('all_time');\n        }\n\n        return $this->translator->trans($paramValue);\n    }\n\n    public function now(): \\DateTimeImmutable\n    {\n        return new \\DateTimeImmutable('now');\n    }\n}\n"
  },
  {
    "path": "src/Twig/Runtime/CounterExtensionRuntime.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Twig\\Runtime;\n\nuse App\\Entity\\User;\nuse App\\Repository\\SearchRepository;\nuse Twig\\Extension\\RuntimeExtensionInterface;\n\nclass CounterExtensionRuntime implements RuntimeExtensionInterface\n{\n    public function __construct(private readonly SearchRepository $searchRepository)\n    {\n    }\n\n    public function countUserBoosts(User $user): int\n    {\n        return $this->searchRepository->countBoosts($user);\n    }\n\n    public function countUserModerated(User $user): int\n    {\n        return $this->searchRepository->countModerated($user);\n    }\n}\n"
  },
  {
    "path": "src/Twig/Runtime/DomainExtensionRuntime.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Twig\\Runtime;\n\nuse App\\Entity\\Domain;\nuse Symfony\\Bundle\\SecurityBundle\\Security;\nuse Twig\\Extension\\RuntimeExtensionInterface;\n\nclass DomainExtensionRuntime implements RuntimeExtensionInterface\n{\n    public function __construct(private readonly Security $security)\n    {\n    }\n\n    public function isSubscribed(Domain $domain): bool\n    {\n        if (!$this->security->getUser()) {\n            return false;\n        }\n\n        return $domain->isSubscribed($this->security->getUser());\n    }\n\n    public function isBlocked(Domain $domain): bool\n    {\n        if (!$this->security->getUser()) {\n            return false;\n        }\n\n        return $this->security->getUser()->isBlockedDomain($domain);\n    }\n}\n"
  },
  {
    "path": "src/Twig/Runtime/EmailExtensionRuntime.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Twig\\Runtime;\n\nuse Psr\\Container\\ContainerInterface;\nuse Symfony\\Contracts\\Service\\ServiceSubscriberInterface;\nuse Symfony\\WebpackEncoreBundle\\Asset\\EntrypointLookupInterface;\nuse Twig\\Extension\\RuntimeExtensionInterface;\n\nclass EmailExtensionRuntime implements RuntimeExtensionInterface, ServiceSubscriberInterface\n{\n    public function __construct(\n        private readonly ContainerInterface $container,\n        private readonly EntrypointLookupInterface $entrypointLookupInterface,\n        private readonly string $publicDir)\n    {\n    }\n\n    public static function getSubscribedServices(): array\n    {\n        return [\n            EntrypointLookupInterface::class,\n        ];\n    }\n\n    /**\n     * Loops through all entries with the provided name and outputs their contents into a single string.\n     *\n     * Used to return a single string containing all css (which may have been split into multiple css files as part of\n     * webpack)\n     */\n    public function getEncoreEntryCssSource(string $entryName): string\n    {\n        // ensure interface is reset, else subsequent queries will fail\n        $this->entrypointLookupInterface->reset();\n        $entry = $this->container->get(EntrypointLookupInterface::class);\n        $source = '';\n        $files = $entry->getCssFiles($entryName);\n        foreach ($files as $file) {\n            $source .= file_get_contents($this->publicDir.'/'.$file);\n        }\n\n        return $source;\n    }\n}\n"
  },
  {
    "path": "src/Twig/Runtime/FormattingExtensionRuntime.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Twig\\Runtime;\n\nuse App\\Markdown\\MarkdownConverter;\nuse Doctrine\\SqlFormatter\\NullHighlighter;\nuse Doctrine\\SqlFormatter\\SqlFormatter;\nuse Symfony\\Component\\Uid\\Uuid;\nuse Twig\\Extension\\RuntimeExtensionInterface;\n\nclass FormattingExtensionRuntime implements RuntimeExtensionInterface\n{\n    public function __construct(\n        private readonly MarkdownConverter $markdownConverter,\n        // private readonly SqlFormatter $sqlFormatter,\n    ) {\n    }\n\n    public function convertToHtml(?string $value, string $sourceType = ''): string\n    {\n        return $value ? $this->markdownConverter->convertToHtml($value, $sourceType) : '';\n    }\n\n    public function getShortSentence(?string $val, $length = 330, $striptags = false, bool $onlyFirstParagraph = true): string\n    {\n        if (!$val) {\n            return '';\n        }\n\n        $body = $striptags ? strip_tags(html_entity_decode($val)) : $val;\n\n        if ($onlyFirstParagraph) {\n            $body = wordwrap(trim($body), $length);\n            $lines = explode(\"\\n\", $body);\n\n            $shortened = trim($lines[0]);\n            $ellipsis = isset($lines[1]) ? ' ...' : '';\n        } elseif (\\strlen($body) <= $length) {\n            $shortened = $body;\n            $ellipsis = '';\n        } else {\n            $sentenceTolerance = 12;\n            $limit = $length - 1;\n            $sentenceDelimiters = ['. ', ', ', '; ', \"\\n\", \"\\t\", \"\\f\", \"\\v\"];\n            $sentencePreLimit = self::strrposMulti($body, $sentenceDelimiters, $limit);\n            if ($sentencePreLimit > -1 && $sentencePreLimit >= $length - $sentenceTolerance) {\n                $limit = $sentencePreLimit;\n                $ellipsis = ' ...';\n            } else {\n                $ellipsis = '...';\n            }\n\n            $shortened = trim(substr($body, 0, $limit + 1));\n        }\n\n        return $shortened.$ellipsis;\n    }\n\n    private static function strrposMulti(string $haystack, array $needle, int $offset): int\n    {\n        $offset = $offset - \\strlen($haystack);\n        $pos = -1;\n        foreach ($needle as $n) {\n            $idx = strrpos($haystack, $n, $offset);\n            if (false !== $idx) {\n                $pos = max($pos, $idx);\n            }\n        }\n\n        return $pos;\n    }\n\n    public function abbreviateNumber(int|float $value): string\n    {\n        // this implementation is offly simple, but therefore fast\n        if ($value < 1000) {\n            return ''.$value;\n        } elseif ($value < 1000000) {\n            return round($value / 1000, 2).'K';\n        } elseif ($value < 1000000000) {\n            return round($value / 1000000, 2).'M';\n        } else {\n            return round($value / 1000000000, 2).'B';\n        }\n    }\n\n    public function uuidEnd(?Uuid $uuid): string\n    {\n        $string = $uuid->toString();\n        $parts = explode('-', $string);\n\n        return end($parts);\n    }\n\n    public function formatQuery(string $query): string\n    {\n        $formatter = new SqlFormatter(new NullHighlighter());\n\n        return $formatter->format($query);\n    }\n}\n"
  },
  {
    "path": "src/Twig/Runtime/FrontExtensionRuntime.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Twig\\Runtime;\n\nuse App\\Entity\\Entry;\nuse App\\Entity\\EntryComment;\nuse App\\Entity\\Magazine;\nuse App\\Entity\\Post;\nuse App\\Entity\\PostComment;\nuse App\\Entity\\User;\nuse Symfony\\Component\\HttpFoundation\\RequestStack;\nuse Symfony\\Component\\Routing\\Generator\\UrlGeneratorInterface;\nuse Twig\\Extension\\RuntimeExtensionInterface;\n\nclass FrontExtensionRuntime implements RuntimeExtensionInterface\n{\n    public function __construct(\n        private readonly UrlGeneratorInterface $urlGenerator,\n        private readonly RequestStack $requestStack,\n    ) {\n    }\n\n    // effectively a specialized version of UrlExtensionRuntime::optionsUrl for front routes\n    // used for filtering link generation\n    public function frontOptionsUrl(\n        string $name,\n        ?string $value,\n        ?string $routeName = null,\n        array $additionalParams = [],\n    ): string {\n        $request = $this->requestStack->getCurrentRequest();\n        $attrs = $request->attributes;\n        $route = $routeName ?? $attrs->get('_route');\n\n        $params = array_merge($attrs->get('_route_params', []), $request->query->all());\n        $params = array_replace($params, $additionalParams);\n        $params = array_filter($params, fn ($v) => null !== $v);\n\n        $params[$name] = $value;\n\n        if (str_starts_with($route, 'front') && !str_contains($route, '_magazine')) {\n            $route = $this->getFrontRoute($route, $params);\n        }\n\n        return $this->urlGenerator->generate($route, $params);\n    }\n\n    /**\n     * Upgrades shorter `front_*` routes to a front route that can fit all specified params.\n     */\n    private function getFrontRoute(string $currentRoute, array $params): string\n    {\n        $content = $params['content'] ?? null;\n        $subscription = $params['subscription'] ?? null;\n\n        if ('home' === $subscription) {\n            $subscription = null;\n        }\n\n        if ($content && $subscription) {\n            return 'front';\n        } elseif ($subscription) {\n            return 'front_sub';\n        } elseif ('all' !== $content) {\n            return 'front_content';\n        } else {\n            return 'front_short';\n        }\n    }\n\n    public function getClass(mixed $object): string\n    {\n        return \\get_class($object);\n    }\n\n    public function getSubjectType(mixed $object): string\n    {\n        if ($object instanceof Entry) {\n            return 'entry';\n        } elseif ($object instanceof EntryComment) {\n            return 'entry_comment';\n        } elseif ($object instanceof Post) {\n            return 'post';\n        } elseif ($object instanceof PostComment) {\n            return 'post_comment';\n        } else {\n            throw new \\LogicException('unknown class '.\\get_class($object));\n        }\n    }\n\n    public function getNotificationSettingSubjectType(mixed $object): string\n    {\n        if ($object instanceof Entry) {\n            return 'entry';\n        } elseif ($object instanceof Post) {\n            return 'post';\n        } elseif ($object instanceof User) {\n            return 'user';\n        } elseif ($object instanceof Magazine) {\n            return 'magazine';\n        } else {\n            throw new \\LogicException('unknown class '.\\get_class($object));\n        }\n    }\n}\n"
  },
  {
    "path": "src/Twig/Runtime/LinkExtensionRuntime.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Twig\\Runtime;\n\nuse App\\Entity\\Contracts\\ContentInterface;\nuse App\\Service\\GenerateHtmlClassService;\nuse App\\Service\\SettingsManager;\nuse Twig\\Extension\\RuntimeExtensionInterface;\n\nclass LinkExtensionRuntime implements RuntimeExtensionInterface\n{\n    public function __construct(\n        private readonly SettingsManager $settingsManager,\n        private readonly GenerateHtmlClassService $generateHtmlClassService,\n    ) {\n    }\n\n    public function getRel(string $url): string\n    {\n        if (null === parse_url($url, PHP_URL_HOST) || $this->settingsManager->get('KBIN_DOMAIN') === parse_url($url, PHP_URL_HOST)) {\n            return 'follow';\n        }\n\n        return 'nofollow noopener noreferrer';\n    }\n\n    public function getHtmlClass(ContentInterface $content): string\n    {\n        $service = $this->generateHtmlClassService;\n\n        return $service->fromEntity($content);\n    }\n\n    public function getLinkDomain(string $url): string\n    {\n        $domain = parse_url($url, PHP_URL_HOST);\n\n        if (null === $domain) {\n            return $this->settingsManager->get('KBIN_DOMAIN');\n        }\n\n        return $domain;\n    }\n}\n"
  },
  {
    "path": "src/Twig/Runtime/MagazineExtensionRuntime.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Twig\\Runtime;\n\nuse App\\Entity\\Instance;\nuse App\\Entity\\Magazine;\nuse App\\Repository\\InstanceRepository;\nuse App\\Repository\\MagazineSubscriptionRepository;\nuse App\\Service\\SettingsManager;\nuse Symfony\\Bundle\\SecurityBundle\\Security;\nuse Twig\\Extension\\RuntimeExtensionInterface;\n\nclass MagazineExtensionRuntime implements RuntimeExtensionInterface\n{\n    public function __construct(\n        private readonly InstanceRepository $instanceRepository,\n        private readonly Security $security,\n        private readonly MagazineSubscriptionRepository $magazineSubscriptionRepository,\n        private readonly SettingsManager $settingsManager,\n    ) {\n    }\n\n    public function isSubscribed(Magazine $magazine): bool\n    {\n        if (!$this->security->getUser()) {\n            return false;\n        }\n\n        return $magazine->isSubscribed($this->security->getUser());\n    }\n\n    public function isBlocked(Magazine $magazine): bool\n    {\n        if (!$this->security->getUser()) {\n            return false;\n        }\n\n        return $this->security->getUser()->isBlockedMagazine($magazine);\n    }\n\n    public function hasLocalSubscribers(Magazine $magazine): bool\n    {\n        $subscribers = $this->magazineSubscriptionRepository->findMagazineSubscribers(1, $magazine);\n\n        return $subscribers->getNbResults() > 0;\n    }\n\n    public function getInstanceOfMagazine(Magazine $magazine): ?Instance\n    {\n        return $this->instanceRepository->getInstanceOfMagazine($magazine);\n    }\n\n    public function isInstanceOfMagazineBanned(Magazine $magazine): bool\n    {\n        if (null === $magazine->apId) {\n            return false;\n        }\n\n        return $this->settingsManager->isBannedInstance($magazine->apProfileId);\n    }\n}\n"
  },
  {
    "path": "src/Twig/Runtime/MediaExtensionRuntime.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Twig\\Runtime;\n\nuse App\\Entity\\Image;\nuse Twig\\Extension\\RuntimeExtensionInterface;\n\nclass MediaExtensionRuntime implements RuntimeExtensionInterface\n{\n    public function __construct(\n        private readonly string $storageUrl,\n    ) {\n    }\n\n    public function getPublicPath(Image $image): ?string\n    {\n        if ($image->filePath) {\n            return $this->storageUrl.'/'.$image->filePath;\n        }\n\n        return $image->sourceUrl;\n    }\n}\n"
  },
  {
    "path": "src/Twig/Runtime/NavbarExtensionRuntime.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Twig\\Runtime;\n\nuse App\\Entity\\Magazine;\nuse Symfony\\Component\\HttpFoundation\\RequestStack;\nuse Symfony\\Component\\Routing\\Generator\\UrlGeneratorInterface;\nuse Twig\\Extension\\RuntimeExtensionInterface;\n\nclass NavbarExtensionRuntime implements RuntimeExtensionInterface\n{\n    public function __construct(\n        private readonly RequestStack $requestStack,\n        private readonly UrlGeneratorInterface $urlGenerator,\n        private readonly FrontExtensionRuntime $frontExtension,\n    ) {\n    }\n\n    public function navbarThreadsUrl(?Magazine $magazine): string\n    {\n        if ($this->isRouteNameStartsWith('front')) {\n            return $this->frontExtension->frontOptionsUrl(\n                'content', 'threads',\n                $magazine instanceof Magazine ? 'front_magazine' : 'front',\n                ['name' => $magazine?->name, 'p' => null, 'cursor' => null, 'cursor2' => null],\n            );\n        }\n\n        if ($magazine instanceof Magazine) {\n            return $this->urlGenerator->generate('front_magazine', [\n                'name' => $magazine->name,\n                ...$this->getActiveOptions(),\n            ]);\n        }\n\n        if ($domain = $this->requestStack->getCurrentRequest()->get('domain')) {\n            return $this->urlGenerator->generate('domain_entries', [\n                'name' => $domain->name,\n                ...$this->getActiveOptions(),\n            ]);\n        }\n\n        if ($this->isRouteNameStartsWith('tag')) {\n            return $this->urlGenerator->generate(\n                'tag_entries',\n                ['name' => $this->requestStack->getCurrentRequest()->get('name')]\n            );\n        }\n\n        return $this->urlGenerator->generate('front_content', [\n            ...$this->getActiveOptions(),\n            'content' => 'threads',\n        ]);\n    }\n\n    public function navbarCombinedUrl(?Magazine $magazine): string\n    {\n        if ($this->isRouteNameStartsWith('front')) {\n            return $this->frontExtension->frontOptionsUrl(\n                'content', 'combined',\n                $magazine instanceof Magazine ? 'front_magazine' : 'front',\n                ['name' => $magazine?->name, 'p' => null, 'cursor' => null, 'cursor2' => null],\n            );\n        }\n\n        if ($magazine instanceof Magazine) {\n            return $this->urlGenerator->generate('front_magazine', [\n                'name' => $magazine->name,\n                ...$this->getActiveOptions(),\n                'content' => 'combined',\n            ]);\n        }\n\n        if ($domain = $this->requestStack->getCurrentRequest()->get('domain')) {\n            return $this->urlGenerator->generate('domain_entries', [\n                'name' => $domain->name,\n                ...$this->getActiveOptions(),\n            ]);\n        }\n\n        if ($this->isRouteNameStartsWith('tag')) {\n            return $this->urlGenerator->generate(\n                'tag_entries',\n                ['name' => $this->requestStack->getCurrentRequest()->get('name')]\n            );\n        }\n\n        return $this->urlGenerator->generate('front_content', [\n            ...$this->getActiveOptions(),\n            'content' => 'combined',\n        ]);\n    }\n\n    public function navbarPostsUrl(?Magazine $magazine): string\n    {\n        if ($this->isRouteNameStartsWith('front')) {\n            return $this->frontExtension->frontOptionsUrl(\n                'content', 'microblog',\n                $magazine instanceof Magazine ? 'front_magazine' : 'front',\n                ['name' => $magazine?->name, 'p' => null, 'cursor' => null, 'cursor2' => null, 'type' => null],\n            );\n        }\n\n        if ($magazine instanceof Magazine) {\n            return $this->urlGenerator->generate('magazine_posts', [\n                'name' => $magazine->name,\n                ...$this->getActiveOptions(),\n            ]);\n        }\n\n        if ($this->isRouteNameStartsWith('tag')) {\n            return $this->urlGenerator->generate(\n                'tag_posts',\n                ['name' => $this->requestStack->getCurrentRequest()->get('name')]\n            );\n        }\n\n        if ($this->isRouteNameEndWith('_subscribed')) {\n            return $this->urlGenerator->generate('posts_subscribed', $this->getActiveOptions());\n        }\n\n        if ($this->isRouteNameEndWith('_favourite')) {\n            return $this->urlGenerator->generate('posts_favourite', $this->getActiveOptions());\n        }\n\n        if ($this->isRouteNameEndWith('_moderated')) {\n            return $this->urlGenerator->generate('posts_moderated', $this->getActiveOptions());\n        }\n\n        return $this->urlGenerator->generate('posts_front', $this->getActiveOptions());\n    }\n\n    public function navbarPeopleUrl(?Magazine $magazine): string\n    {\n        if ($this->isRouteNameStartsWith('tag')) {\n            return $this->urlGenerator->generate(\n                'tag_people',\n                ['name' => $this->requestStack->getCurrentRequest()->get('name')]\n            );\n        }\n\n        if ($magazine instanceof Magazine) {\n            return $this->urlGenerator->generate('magazine_people', ['name' => $magazine->name]);\n        }\n\n        return $this->urlGenerator->generate('people_front');\n    }\n\n    private function getCurrentRouteName(): string\n    {\n        return $this->requestStack->getCurrentRequest()->get('_route') ?? 'front';\n    }\n\n    private function getActiveOptions(): array\n    {\n        $options = [];\n\n        // don't use sortBy or time options on comment pages\n        // for the navbar links, so sorting comments by new does not mean\n        // changing the entry and microblog views to newest\n        if (!$this->isRouteName('root')\n            && !$this->isRouteNameStartsWith('front')\n            && !$this->isRouteNameStartsWith('posts')\n            && !$this->isRouteName('magazine_posts')\n        ) {\n            return $options;\n        }\n\n        $sortOption = $this->getActiveSortOption();\n        $timeOption = $this->getActiveTimeOption();\n        $subscriptionOption = $this->getActiveSubscriptionOption();\n        $contentOption = $this->getActiveContentOption();\n\n        // don't add the current options if they are the defaults.\n        // this isn't bad, but keeps urls shorter for instance\n        // showing /microblog rather than /microblog/hot/∞\n        // which would be equivalent anyways\n        if ('hot' !== $sortOption) {\n            $options['sortBy'] = $sortOption;\n        }\n        if ('∞' !== $timeOption) {\n            $options['time'] = $timeOption;\n        }\n        if ('default' !== $contentOption) {\n            $options['content'] = $contentOption;\n        }\n        if (!\\in_array($subscriptionOption, [null, 'home'])) {\n            $options['subscription'] = $subscriptionOption;\n        }\n\n        return $options;\n    }\n\n    private function getActiveSubscriptionOption(): ?string\n    {\n        return $this->requestStack->getCurrentRequest()->get('subscription');\n    }\n\n    private function getActiveSortOption(): string\n    {\n        return $this->requestStack->getCurrentRequest()->get('sortBy') ?? 'hot';\n    }\n\n    private function getActiveTimeOption(): string\n    {\n        return $this->requestStack->getCurrentRequest()->get('time') ?? '∞';\n    }\n\n    private function getActiveContentOption(): string\n    {\n        return $this->requestStack->getCurrentRequest()->get('content') ?? 'default';\n    }\n\n    private function isRouteNameStartsWith(string $needle): bool\n    {\n        return str_starts_with($this->getCurrentRouteName(), $needle);\n    }\n\n    private function isRouteNameEndWith(string $needle): bool\n    {\n        return str_ends_with($this->getCurrentRouteName(), $needle);\n    }\n\n    private function isRouteName(string $needle): bool\n    {\n        return $this->getCurrentRouteName() === $needle;\n    }\n}\n"
  },
  {
    "path": "src/Twig/Runtime/SettingsExtensionRuntime.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Twig\\Runtime;\n\nuse App\\Service\\ProjectInfoService;\nuse App\\Service\\SettingsManager;\nuse App\\Utils\\DownvotesMode;\nuse JetBrains\\PhpStorm\\Pure;\nuse Twig\\Extension\\RuntimeExtensionInterface;\n\nclass SettingsExtensionRuntime implements RuntimeExtensionInterface\n{\n    public function __construct(\n        private readonly SettingsManager $settings,\n        private readonly ProjectInfoService $projectInfo,\n    ) {\n    }\n\n    #[Pure]\n    public function kbinDomain(): string\n    {\n        return $this->settings->get('KBIN_DOMAIN');\n    }\n\n    public function kbinTitle(): string\n    {\n        return $this->settings->get('KBIN_TITLE');\n    }\n\n    #[Pure]\n    public function kbinMetaTitle(): string\n    {\n        return $this->settings->get('KBIN_META_TITLE');\n    }\n\n    #[Pure]\n    public function kbinDescription(): string\n    {\n        return $this->settings->get('KBIN_META_DESCRIPTION');\n    }\n\n    #[Pure]\n    public function kbinKeywords(): string\n    {\n        return $this->settings->get('KBIN_META_KEYWORDS');\n    }\n\n    #[Pure]\n    public function kbinRegistrationsEnabled(): bool\n    {\n        return $this->settings->get('KBIN_REGISTRATIONS_ENABLED');\n    }\n\n    #[Pure]\n    public function mbinSsoRegistrationsEnabled(): bool\n    {\n        return $this->settings->get('MBIN_SSO_REGISTRATIONS_ENABLED');\n    }\n\n    public function mbinSsoOnlyMode(): bool\n    {\n        return $this->settings->get('MBIN_SSO_ONLY_MODE');\n    }\n\n    public function kbinDefaultLang(): string\n    {\n        return $this->settings->get('KBIN_DEFAULT_LANG');\n    }\n\n    #[Pure]\n    public function mbinDefaultTheme(): string\n    {\n        return $this->settings->get('MBIN_DEFAULT_THEME');\n    }\n\n    public function kbinHeaderLogo(): bool\n    {\n        return $this->settings->get('KBIN_HEADER_LOGO');\n    }\n\n    public function kbinCaptchaEnabled(): bool\n    {\n        return $this->settings->get('KBIN_CAPTCHA_ENABLED');\n    }\n\n    public function kbinMercureEnabled(): bool\n    {\n        return $this->settings->get('KBIN_MERCURE_ENABLED');\n    }\n\n    public function kbinFederationPageEnabled(): bool\n    {\n        return $this->settings->get('KBIN_FEDERATION_PAGE_ENABLED');\n    }\n\n    public function kbinFederatedSearchOnlyLoggedIn(): bool\n    {\n        return $this->settings->get('KBIN_FEDERATED_SEARCH_ONLY_LOGGEDIN');\n    }\n\n    public function mbinDownvotesMode(): DownvotesMode\n    {\n        return $this->settings->getDownvotesMode();\n    }\n\n    public function mbinCurrentVersion(): string\n    {\n        return $this->projectInfo->getVersion();\n    }\n\n    public function mbinRestrictMagazineCreation(): bool\n    {\n        return $this->settings->get('MBIN_RESTRICT_MAGAZINE_CREATION');\n    }\n\n    public function mbinPrivateInstance(): bool\n    {\n        return $this->settings->get('MBIN_PRIVATE_INSTANCE');\n    }\n\n    public function mbinSsoShowFirst(): bool\n    {\n        return $this->settings->get('MBIN_SSO_SHOW_FIRST');\n    }\n\n    public function mbinLang(): string\n    {\n        return $this->settings->getLocale();\n    }\n}\n"
  },
  {
    "path": "src/Twig/Runtime/SubjectExtensionRuntime.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Twig\\Runtime;\n\nuse Twig\\Extension\\RuntimeExtensionInterface;\n\nclass SubjectExtensionRuntime implements RuntimeExtensionInterface\n{\n    public function __construct()\n    {\n        // Inject dependencies if needed\n    }\n\n    public function doSomething($value)\n    {\n        // ...\n    }\n}\n"
  },
  {
    "path": "src/Twig/Runtime/UrlExtensionRuntime.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Twig\\Runtime;\n\nuse App\\Entity\\Entry;\nuse App\\Entity\\EntryComment;\nuse App\\Entity\\Post;\nuse App\\Entity\\PostComment;\nuse App\\Service\\MentionManager;\nuse Symfony\\Component\\HttpFoundation\\RequestStack;\nuse Symfony\\Component\\Routing\\Generator\\UrlGeneratorInterface;\nuse Twig\\Extension\\RuntimeExtensionInterface;\n\nclass UrlExtensionRuntime implements RuntimeExtensionInterface\n{\n    public function __construct(\n        private readonly UrlGeneratorInterface $urlGenerator,\n        private readonly RequestStack $requestStack,\n        private readonly MentionManager $mentionManager,\n    ) {\n    }\n\n    public function entryUrl(Entry $entry): string\n    {\n        return $this->urlGenerator->generate('entry_single', [\n            'magazine_name' => $entry->magazine->name,\n            'entry_id' => $entry->getId(),\n            'slug' => empty($entry->slug) ? '-' : $entry->slug,\n        ]);\n    }\n\n    public function entryFavouritesUrl(Entry $entry): string\n    {\n        return $this->urlGenerator->generate('entry_fav', [\n            'magazine_name' => $entry->magazine->name,\n            'entry_id' => $entry->getId(),\n            'slug' => empty($entry->slug) ? '-' : $entry->slug,\n        ]);\n    }\n\n    public function entryVotersUrl(Entry $entry, string $type): string\n    {\n        return $this->urlGenerator->generate('entry_voters', [\n            'magazine_name' => $entry->magazine->name,\n            'entry_id' => $entry->getId(),\n            'slug' => empty($entry->slug) ? '-' : $entry->slug,\n            'type' => $type,\n        ]);\n    }\n\n    public function entryEditUrl(Entry $entry): string\n    {\n        return $this->urlGenerator->generate('entry_edit', [\n            'magazine_name' => $entry->magazine->name,\n            'entry_id' => $entry->getId(),\n            'slug' => empty($entry->slug) ? '-' : $entry->slug,\n        ]);\n    }\n\n    public function entryModerateUrl(Entry $entry): string\n    {\n        return $this->urlGenerator->generate('entry_moderate', [\n            'magazine_name' => $entry->magazine->name,\n            'entry_id' => $entry->getId(),\n            'slug' => empty($entry->slug) ? '-' : $entry->slug,\n        ]);\n    }\n\n    public function entryDeleteUrl(Entry $entry): string\n    {\n        return $this->urlGenerator->generate('entry_delete', [\n            'magazine_name' => $entry->magazine->name,\n            'entry_id' => $entry->getId(),\n            'slug' => empty($entry->slug) ? '-' : $entry->slug,\n        ]);\n    }\n\n    public function entryCommentCreateUrl(EntryComment $comment): string\n    {\n        return $this->urlGenerator->generate('entry_comment_create', [\n            'magazine_name' => $comment->magazine->name,\n            'entry_id' => $comment->entry->getId(),\n            'slug' => empty($comment->entry->slug) ? '-' : $comment->entry->slug,\n            'parent_comment_id' => $comment->getId(),\n        ]);\n    }\n\n    public function entryCommentViewUrl(EntryComment $comment): string\n    {\n        return $this->urlGenerator->generate('entry_comment_view', [\n            'magazine_name' => $comment->magazine->name,\n            'entry_id' => $comment->entry->getId(),\n            'slug' => empty($comment->entry->slug) ? '-' : $comment->entry->slug,\n            'comment_id' => $comment->getId(),\n        ]);\n    }\n\n    public function entryCommentEditUrl(EntryComment $comment): string\n    {\n        return $this->urlGenerator->generate('entry_comment_edit', [\n            'magazine_name' => $comment->magazine->name,\n            'entry_id' => $comment->entry->getId(),\n            'comment_id' => $comment->getId(),\n            'slug' => empty($comment->entry->slug) ? '-' : $comment->entry->slug,\n        ]);\n    }\n\n    public function entryCommentDeleteUrl(EntryComment $comment): string\n    {\n        return $this->urlGenerator->generate('entry_comment_delete', [\n            'magazine_name' => $comment->magazine->name,\n            'entry_id' => $comment->entry->getId(),\n            'comment_id' => $comment->getId(),\n            'slug' => empty($comment->entry->slug) ? '-' : $comment->entry->slug,\n        ]);\n    }\n\n    public function entryCommentVotersUrl(EntryComment $comment, string $type): string\n    {\n        return $this->urlGenerator->generate('entry_comment_voters', [\n            'magazine_name' => $comment->magazine->name,\n            'entry_id' => $comment->entry->getId(),\n            'comment_id' => $comment->getId(),\n            'slug' => empty($comment->entry->slug) ? '-' : $comment->entry->slug,\n            'type' => $type,\n        ]);\n    }\n\n    public function entryCommentFavouritesUrl(EntryComment $comment): string\n    {\n        return $this->urlGenerator->generate('entry_comment_favourites', [\n            'magazine_name' => $comment->magazine->name,\n            'entry_id' => $comment->entry->getId(),\n            'comment_id' => $comment->getId(),\n            'slug' => empty($comment->entry->slug) ? '-' : $comment->entry->slug,\n        ]);\n    }\n\n    public function entryCommentModerateUrl(EntryComment $comment): string\n    {\n        return $this->urlGenerator->generate('entry_comment_moderate', [\n            'magazine_name' => $comment->magazine->name,\n            'entry_id' => $comment->entry->getId(),\n            'comment_id' => $comment->getId(),\n            'slug' => empty($comment->entry->slug) ? '-' : $comment->entry->slug,\n        ]);\n    }\n\n    public function postUrl(Post $post): string\n    {\n        return $this->urlGenerator->generate('post_single', [\n            'magazine_name' => $post->magazine->name,\n            'post_id' => $post->getId(),\n            'slug' => empty($post->slug) ? '-' : $post->slug,\n        ]);\n    }\n\n    public function postEditUrl(Post $post): string\n    {\n        return $this->urlGenerator->generate('post_edit', [\n            'magazine_name' => $post->magazine->name,\n            'post_id' => $post->getId(),\n            'slug' => empty($post->slug) ? '-' : $post->slug,\n        ]);\n    }\n\n    public function postFavouritesUrl(Post $post): string\n    {\n        return $this->urlGenerator->generate('post_favourites', [\n            'magazine_name' => $post->magazine->name,\n            'post_id' => $post->getId(),\n            'slug' => empty($post->slug) ? '-' : $post->slug,\n        ]);\n    }\n\n    public function postVotersUrl(Post $post, string $type): string\n    {\n        return $this->urlGenerator->generate('post_voters', [\n            'magazine_name' => $post->magazine->name,\n            'post_id' => $post->getId(),\n            'slug' => empty($post->slug) ? '-' : $post->slug,\n            'type' => $type,\n        ]);\n    }\n\n    public function postModerateUrl(Post $post): string\n    {\n        return $this->urlGenerator->generate('post_moderate', [\n            'magazine_name' => $post->magazine->name,\n            'post_id' => $post->getId(),\n            'slug' => empty($post->slug) ? '-' : $post->slug,\n        ]);\n    }\n\n    public function postDeleteUrl(Post $post): string\n    {\n        return $this->urlGenerator->generate('post_delete', [\n            'magazine_name' => $post->magazine->name,\n            'post_id' => $post->getId(),\n            'slug' => empty($post->slug) ? '-' : $post->slug,\n        ]);\n    }\n\n    public function postCommentReplyUrl(PostComment $comment): string\n    {\n        return $this->urlGenerator->generate('post_comment_create', [\n            'magazine_name' => $comment->magazine->name,\n            'post_id' => $comment->post->getId(),\n            'slug' => empty($comment->post->slug) ? '-' : $comment->post->slug,\n            'parent_comment_id' => $comment->getId(),\n        ]);\n    }\n\n    public function postCommentEditUrl(PostComment $comment): string\n    {\n        return $this->urlGenerator->generate('post_comment_edit', [\n            'magazine_name' => $comment->magazine->name,\n            'post_id' => $comment->post->getId(),\n            'comment_id' => $comment->getId(),\n            'slug' => empty($comment->post->slug) ? '-' : $comment->post->slug,\n        ]);\n    }\n\n    public function postCommentModerateUrl(PostComment $comment): string\n    {\n        return $this->urlGenerator->generate('post_comment_moderate', [\n            'magazine_name' => $comment->magazine->name,\n            'post_id' => $comment->post->getId(),\n            'comment_id' => $comment->getId(),\n            'slug' => empty($comment->post->slug) ? '-' : $comment->post->slug,\n        ]);\n    }\n\n    public function postCommentVotersUrl(PostComment $comment): string\n    {\n        return $this->urlGenerator->generate('post_comment_voters', [\n            'magazine_name' => $comment->magazine->name,\n            'post_id' => $comment->post->getId(),\n            'comment_id' => $comment->getId(),\n            'slug' => empty($comment->post->slug) ? '-' : $comment->post->slug,\n        ]);\n    }\n\n    public function postCommentFavouritesUrl(PostComment $comment): string\n    {\n        return $this->urlGenerator->generate('post_comment_favourites', [\n            'magazine_name' => $comment->magazine->name,\n            'post_id' => $comment->post->getId(),\n            'comment_id' => $comment->getId(),\n            'slug' => empty($comment->post->slug) ? '-' : $comment->post->slug,\n        ]);\n    }\n\n    public function postCommentDeleteUrl(PostComment $comment): string\n    {\n        return $this->urlGenerator->generate('post_comment_delete', [\n            'magazine_name' => $comment->magazine->name,\n            'post_id' => $comment->post->getId(),\n            'comment_id' => $comment->getId(),\n            'slug' => empty($comment->post->slug) ? '-' : $comment->post->slug,\n        ]);\n    }\n\n    // $additionalParams indicates extra parameters to set in addition to [$name] = $value\n    // Set $value to null to indicate deleting a parameter\n    // TODO: It'd be better to have just a single $params which is an associative array\n    public function optionsUrl(string $name, ?string $value, ?string $routeName = null, array $additionalParams = []): string\n    {\n        $route = $routeName ?? $this->requestStack->getCurrentRequest()->attributes->get('_route');\n        $params = $this->requestStack->getCurrentRequest()->attributes->get('_route_params', []);\n\n        $queryParams = $this->requestStack->getCurrentRequest()->query->all();\n        if (\\is_array($queryParams)) {\n            $params = array_merge($params, $queryParams);\n        }\n\n        // Apply logic for additionalParams: set if value is not null, unset if value is null\n        foreach ($additionalParams as $key => $val) {\n            if (null !== $val) {\n                // Set or update the parameter\n                $params[$key] = $val;\n            } else {\n                // Unset the parameter if value is null\n                unset($params[$key]);\n            }\n        }\n\n        $params[$name] = $value;\n\n        return $this->urlGenerator->generate($route, $params);\n    }\n\n    public function mentionUrl(string $username): string\n    {\n        return $this->mentionManager->getRoute([$username])[0];\n    }\n\n    public function getCursorUrlValue(mixed $cursor): mixed\n    {\n        if ($cursor instanceof \\DateTime || $cursor instanceof \\DateTimeImmutable) {\n            return $cursor->format(DATE_ATOM);\n        }\n\n        return $cursor;\n    }\n}\n"
  },
  {
    "path": "src/Twig/Runtime/UserExtensionRuntime.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Twig\\Runtime;\n\nuse App\\Entity\\Instance;\nuse App\\Entity\\User;\nuse App\\Repository\\InstanceRepository;\nuse App\\Repository\\ReputationRepository;\nuse App\\Service\\MentionManager;\nuse App\\Service\\SettingsManager;\nuse App\\Service\\UserManager;\nuse Symfony\\Bundle\\SecurityBundle\\Security;\nuse Twig\\Extension\\RuntimeExtensionInterface;\n\nclass UserExtensionRuntime implements RuntimeExtensionInterface\n{\n    public function __construct(\n        private readonly Security $security,\n        private readonly MentionManager $mentionManager,\n        private readonly InstanceRepository $instanceRepository,\n        private readonly UserManager $userManager,\n        private readonly SettingsManager $settingsManager,\n        private readonly ReputationRepository $reputationRepository,\n    ) {\n    }\n\n    public function isFollowed(User $followed)\n    {\n        if (!$this->security->getUser()) {\n            return false;\n        }\n\n        return $this->security->getUser()->isFollower($followed);\n    }\n\n    public function isBlocked(User $blocked)\n    {\n        if (!$this->security->getUser()) {\n            return false;\n        }\n\n        return $this->security->getUser()->isBlocked($blocked);\n    }\n\n    public function username(string $value, ?bool $withApPostfix = false): string\n    {\n        return $this->mentionManager->getUsername($value, $withApPostfix);\n    }\n\n    public function apDomain(string $value): string\n    {\n        return $this->mentionManager->getDomain($value);\n    }\n\n    public function getReputationTotal(User $user): int\n    {\n        return $this->userManager->getReputationTotal($user);\n    }\n\n    public function getInstanceOfUser(User $user): ?Instance\n    {\n        return $this->instanceRepository->getInstanceOfUser($user);\n    }\n\n    public function isInstanceOfUserBanned(User $user): bool\n    {\n        if (null === $user->apId) {\n            return false;\n        }\n\n        return $this->settingsManager->isBannedInstance($user->apProfileId);\n    }\n\n    public function getUserAttitude(User $user): float\n    {\n        $attitude = $this->reputationRepository->getUserAttitudes($user->getId());\n\n        return $attitude[$user->getId()] ?? -1;\n    }\n}\n"
  },
  {
    "path": "src/Utils/AddErrorDetailsStampListener.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Utils;\n\nuse Symfony\\Component\\EventDispatcher\\EventSubscriberInterface;\nuse Symfony\\Component\\Messenger\\Event\\WorkerMessageFailedEvent;\nuse Symfony\\Component\\Messenger\\Exception\\HandlerFailedException;\nuse Symfony\\Component\\Messenger\\Stamp\\ErrorDetailsStamp;\n\n/**\n * This class is meant to be used instead of \\Symfony\\Component\\Messenger\\EventListener\\AddErrorDetailsStampListener.\n * The difference is that the ErrorDetailsStamp will be created without FlattenException.\n * This is important because FlattenException contains stack trace which can be quite large,\n * potentially causing AmqpSender to throw \"Library error: table too large for buffer\".\n *\n * @source https://github.com/symfony/symfony/issues/45944\n *\n * @author https://github.com/enumag\n */\nfinal class AddErrorDetailsStampListener implements EventSubscriberInterface\n{\n    public function onMessageFailed(WorkerMessageFailedEvent $event): void\n    {\n        $throwable = $event->getThrowable();\n        if ($throwable instanceof HandlerFailedException) {\n            $throwable = $throwable->getPrevious();\n        }\n\n        if (null === $throwable) {\n            return;\n        }\n\n        $stamp = new ErrorDetailsStamp($throwable::class, $throwable->getCode(), $throwable->getMessage());\n\n        $previousStamp = $event->getEnvelope()->last(ErrorDetailsStamp::class);\n\n        // Do not append duplicate information\n        if (null === $previousStamp || !$previousStamp->equals($stamp)) {\n            $event->addStamps($stamp);\n        }\n    }\n\n    public static function getSubscribedEvents(): array\n    {\n        return [\n            // must have higher priority than SendFailedMessageForRetryListener\n            WorkerMessageFailedEvent::class => ['onMessageFailed', 200],\n        ];\n    }\n}\n"
  },
  {
    "path": "src/Utils/ArrayUtils.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Utils;\n\nclass ArrayUtils\n{\n    public static function numCompareAscending(int $a, int $b): int\n    {\n        if ($a === $b) {\n            return 0;\n        }\n\n        return ($a < $b) ? -1 : 1;\n    }\n\n    public static function numCompareDescending(int $a, int $b): int\n    {\n        if ($a === $b) {\n            return 0;\n        }\n\n        return ($a < $b) ? 1 : -1;\n    }\n\n    /**\n     * @template-covariant T\n     *\n     * @param T[] $a\n     *\n     * @return T[][]\n     */\n    public static function sliceArrayIntoEqualPieces(array $a, int $size): array\n    {\n        $arraySize = \\sizeof($a);\n        $steps = $arraySize / $size;\n        $result = [];\n        for ($i = 0; $i < $steps; ++$i) {\n            $result[] = \\array_slice($a, $i * $size, $size);\n        }\n\n        return $result;\n    }\n}\n"
  },
  {
    "path": "src/Utils/DownvotesMode.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Utils;\n\nenum DownvotesMode: string\n{\n    case Disabled = 'disabled';\n    case Hidden = 'hidden';\n    case Enabled = 'enabled';\n\n    public static function GetChoices(): array\n    {\n        return [\n            self::Enabled->name => self::Enabled->value,\n            self::Hidden->name => self::Hidden->value,\n            self::Disabled->name => self::Disabled->value,\n        ];\n    }\n}\n"
  },
  {
    "path": "src/Utils/Embed.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Utils;\n\nuse App\\Entity\\Entry;\nuse App\\Event\\ActivityPub\\CurlRequestBeginningEvent;\nuse App\\Event\\ActivityPub\\CurlRequestFinishedEvent;\nuse App\\Service\\ImageManager;\nuse App\\Service\\SettingsManager;\nuse Embed\\Embed as BaseEmbed;\nuse Embed\\Extractor;\nuse Psr\\Log\\LoggerInterface;\nuse Symfony\\Component\\EventDispatcher\\EventDispatcherInterface;\nuse Symfony\\Contracts\\Cache\\CacheInterface;\nuse Symfony\\Contracts\\Cache\\ItemInterface;\n\nclass Embed\n{\n    public ?string $url = null;\n    public ?string $title = null;\n    public ?string $description = null;\n    public ?string $image = null;\n    public ?string $html = null;\n\n    public function __construct(\n        private CacheInterface $cache,\n        private SettingsManager $settings,\n        private LoggerInterface $logger,\n        private EventDispatcherInterface $dispatcher,\n    ) {\n    }\n\n    public function __clone(): void\n    {\n        unset($this->cache);\n        unset($this->settings);\n        unset($this->logger);\n        unset($this->dispatcher);\n    }\n\n    public function fetch($url): self\n    {\n        if ($this->settings->isLocalUrl($url)) {\n            if (ImageManager::isImageUrl($url)) {\n                return $this->createLocalImage($url);\n            }\n\n            return $this;\n        }\n\n        $this->logger->debug('[Embed::fetch] leftover data', [\n            'url' => $this->url,\n            'title' => $this->title,\n            'description' => $this->description,\n            'image' => $this->image,\n            'html' => $this->html,\n        ]);\n\n        return $this->cache->get(\n            'embed_'.md5($url),\n            function (ItemInterface $item) use ($url) {\n                $item->expiresAfter(3600);\n                $this->dispatcher->dispatch(new CurlRequestBeginningEvent($url));\n\n                try {\n                    $embed = $this->fetchEmbed($url);\n                    $oembed = $embed->getOEmbed();\n                    $this->dispatcher->dispatch(new CurlRequestFinishedEvent($url, true));\n                } catch (\\Exception $e) {\n                    $this->dispatcher->dispatch(new CurlRequestFinishedEvent($url, false, exception: $e));\n                    $this->logger->info('[Embed::fetch] Fetch failed: '.$e->getMessage());\n                    $c = clone $this;\n\n                    return $c;\n                }\n\n                $c = clone $this;\n\n                $c->url = $url;\n                $c->title = $embed->title;\n                $c->description = $embed->description;\n                $c->image = (string) $embed->image;\n                $c->html = $this->cleanIframe($oembed->html('html'));\n\n                try {\n                    if (!$c->html && $embed->code) {\n                        $c->html = $this->cleanIframe($embed->code->html);\n                    }\n                } catch (\\TypeError $e) {\n                    $this->logger->info('[Embed::fetch] HTML prepare failed: '.$e->getMessage());\n                }\n\n                $this->logger->debug('[Embed::fetch] Fetch success, returning', [\n                    'url' => $c->url,\n                    'title' => $c->title,\n                    'description' => $c->description,\n                    'image' => $c->image,\n                    'html' => $c->html,\n                ]);\n\n                return $c;\n            }\n        );\n    }\n\n    private function fetchEmbed(string $url): Extractor\n    {\n        $fetcher = new BaseEmbed();\n        $embed = $fetcher->get($url);\n\n        if ($this->detectFaultyRedirectEmbed($embed)) {\n            $this->logger->debug('[Embed::fetch] Suspecting faulty redirect, refetching', [\n                'requestUrl' => $url,\n                'responseUrl' => $embed->getUri(),\n            ]);\n\n            $embed = $fetcher->get((string) $embed->getUri());\n        }\n\n        return $embed;\n    }\n\n    private function detectFaultyRedirectEmbed(Extractor $embed): bool\n    {\n        $request = $embed->getRequest();\n        $response = $embed->getResponse();\n\n        $isRedirected = $embed->getUri() !== $request->getUri()\n            && !\\in_array($response->getStatusCode(), [301, 302])\n            && $response->getHeaderLine('location');\n\n        $isEmptyEmbed = !(\n            $embed->title\n            || $embed->description\n            || $embed->image\n            || $embed->code?->html\n        );\n\n        return $isRedirected && $isEmptyEmbed;\n    }\n\n    private function cleanIframe(?string $html): ?string\n    {\n        if (!$html || str_contains($html, 'wp-embedded-content')) {\n            return null;\n        }\n\n        return $html;\n    }\n\n    private function createLocalImage(string $url): self\n    {\n        $c = clone $this;\n        $c->url = $url;\n        $c->html = \\sprintf('<img src=\"%s\">', $url);\n\n        return $c;\n    }\n\n    public function getType(): string\n    {\n        if ($this->isImageUrl()) {\n            return Entry::ENTRY_TYPE_IMAGE;\n        }\n\n        if ($this->isVideoUrl()) {\n            return Entry::ENTRY_TYPE_IMAGE;\n        }\n\n        if ($this->isVideoEmbed()) {\n            return Entry::ENTRY_TYPE_VIDEO;\n        }\n\n        return Entry::ENTRY_TYPE_LINK;\n    }\n\n    public function isImageUrl(): bool\n    {\n        if (!$this->url) {\n            return false;\n        }\n\n        return ImageManager::isImageUrl($this->url);\n    }\n\n    private function isVideoUrl(): bool\n    {\n        return false;\n    }\n\n    private function isVideoEmbed(): bool\n    {\n        if (!$this->html) {\n            return false;\n        }\n\n        return str_contains($this->html, 'video')\n            || str_contains($this->html, 'youtube')\n            || str_contains($this->html, 'vimeo')\n            || str_contains($this->html, 'streamable'); // @todo\n    }\n}\n"
  },
  {
    "path": "src/Utils/ExifCleanMode.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Utils;\n\nenum ExifCleanMode: string\n{\n    case None = 'none';\n    case Sanitize = 'sanitize';\n    case Scrub = 'scrub';\n}\n"
  },
  {
    "path": "src/Utils/ExifCleaner.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Utils;\n\nuse Psr\\Log\\LoggerInterface;\nuse Symfony\\Component\\DependencyInjection\\ParameterBag\\ContainerBagInterface;\nuse Symfony\\Component\\Process\\Exception\\ProcessFailedException;\nuse Symfony\\Component\\Process\\ExecutableFinder;\nuse Symfony\\Component\\Process\\Process;\n\nclass ExifCleaner\n{\n    protected const EXIFTOOL_COMMAND_NAME = 'exiftool';\n    protected const EXIFTOOL_ARGS_COMMON = [\n        '-overwrite_original', '-ignoreminorerrors',\n    ];\n    protected const EXIFTOOL_ARGS_SANITIZE = [\n        '-GPS*=', '-*Serial*=',\n    ];\n    protected const EXIFTOOL_ARGS_SCRUB = [\n        '-all=',\n        '-tagsfromfile', '@',\n        '-colorspacetags', '-commonifd0', '-orientation', '-icc_profile',\n        '-XMP-dc:all', '-XMP-iptcCore:all', '-XMP-iptcExt:all',\n        '-IPTC:all',\n    ];\n    protected const EXIFTOOL_TIMEOUT_SECONDS = 10;\n\n    private readonly ?string $exiftoolPath;\n    private readonly ?string $exiftool;\n    private readonly int $timeout;\n\n    public function __construct(\n        private readonly ContainerBagInterface $params,\n        private readonly LoggerInterface $logger,\n    ) {\n        $this->exiftoolPath = $params->get('exif_exiftool_path');\n        $this->timeout = $params->get('exif_exiftool_timeout') ?? self::EXIFTOOL_TIMEOUT_SECONDS;\n        $this->exiftool = $this->getExifToolBinary();\n    }\n\n    public function cleanImage(string $filePath, ExifCleanMode $mode)\n    {\n        if (ExifCleanMode::None === $mode) {\n            $this->logger->debug(\"ExifCleaner:cleanImage: cleaning mode is 'None', nothing will be done.\");\n\n            return;\n        }\n\n        if (!$this->exiftool) {\n            $this->logger->info('ExifCleaner:cleanImage: exiftool binary was not found, nothing will be done.');\n\n            return;\n        }\n\n        try {\n            $ps = $this->buildProcess($mode, $filePath, $this->exiftool);\n            $ps->mustRun();\n            $this->logger->debug(\n                'ExifCleaner:cleanImage: exiftool success:',\n                ['stdout' => $ps->getOutput()],\n            );\n        } catch (ProcessFailedException $e) {\n            $this->logger->warning('ExifCleaner:cleanImage: exiftool failed: '.$e->getMessage());\n        }\n    }\n\n    private function getExifToolBinary(): ?string\n    {\n        if ($this->exiftoolPath && is_executable($this->exiftoolPath)) {\n            return $this->exiftoolPath;\n        }\n\n        $which = new ExecutableFinder();\n        $cmdpath = $which->find(self::EXIFTOOL_COMMAND_NAME);\n\n        return $cmdpath;\n    }\n\n    private function getCleaningArguments(ExifCleanMode $mode): array\n    {\n        return match ($mode) {\n            ExifCleanMode::None => [],\n            ExifCleanMode::Sanitize => self::EXIFTOOL_ARGS_SANITIZE,\n            ExifCleanMode::Scrub => self::EXIFTOOL_ARGS_SCRUB,\n        };\n    }\n\n    private function buildProcess(ExifCleanMode $mode, string $filePath, string $exiftool): Process\n    {\n        $ps = new Process(array_merge(\n            [$exiftool, $filePath],\n            self::EXIFTOOL_ARGS_COMMON,\n            $this->getCleaningArguments($mode),\n        ));\n        $ps->setTimeout($this->timeout);\n\n        return $ps;\n    }\n}\n"
  },
  {
    "path": "src/Utils/GeneralUtil.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Utils;\n\nuse Symfony\\Component\\Console\\Helper\\ProgressBar;\n\nclass GeneralUtil\n{\n    public static function shouldPathBeIgnored(array $ignoredPaths, string $path): bool\n    {\n        $isIgnored = false;\n        foreach ($ignoredPaths as $ignoredPath) {\n            if (str_starts_with($path, $ignoredPath) || str_starts_with('/'.$path, $ignoredPath)) {\n                $isIgnored = true;\n                break;\n            }\n        }\n\n        return $isIgnored;\n    }\n\n    public static function useProgressbarFormatsWithMessage(): void\n    {\n        ProgressBar::setFormatDefinition(ProgressBar::FORMAT_NORMAL, ProgressBar::getFormatDefinition(ProgressBar::FORMAT_NORMAL).' - %message%');\n        ProgressBar::setFormatDefinition(ProgressBar::FORMAT_VERBOSE, ProgressBar::getFormatDefinition(ProgressBar::FORMAT_VERBOSE).' - %message%');\n        ProgressBar::setFormatDefinition(ProgressBar::FORMAT_VERY_VERBOSE, ProgressBar::getFormatDefinition(ProgressBar::FORMAT_VERY_VERBOSE).' - %message%');\n        ProgressBar::setFormatDefinition(ProgressBar::FORMAT_DEBUG, ProgressBar::getFormatDefinition(ProgressBar::FORMAT_DEBUG).' - %message%');\n    }\n}\n"
  },
  {
    "path": "src/Utils/ImageOrigin.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Utils;\n\nenum ImageOrigin: string\n{\n    case Uploaded = 'uploaded';\n    case External = 'external';\n}\n"
  },
  {
    "path": "src/Utils/IriGenerator.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Utils;\n\nuse App\\Entity\\Contracts\\ApiResourceInterface;\nuse Symfony\\Component\\String\\Inflector\\EnglishInflector;\n\nclass IriGenerator\n{\n    public static function getIriFromResource(ApiResourceInterface $apiResource): string\n    {\n        $inflector = new EnglishInflector();\n\n        $classNameParts = explode('\\\\', \\get_class($apiResource));\n\n        $shortClassName = end($classNameParts);\n\n        $pluralName = strtolower($inflector->pluralize($shortClassName)[0]);\n\n        return strtolower(\"/api/{$pluralName}/{$apiResource->getId()}\");\n    }\n}\n"
  },
  {
    "path": "src/Utils/JsonldUtils.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Utils;\n\nclass JsonldUtils\n{\n    public static function getArrayValue(array $object, string $key): array\n    {\n        if (!\\array_key_exists($key, $object)) {\n            return [];\n        }\n        if (\\is_array($object[$key])) {\n            return $object[$key];\n        }\n\n        return [$object[$key]];\n    }\n}\n"
  },
  {
    "path": "src/Utils/RegPatterns.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Utils;\n\nclass RegPatterns\n{\n    public const MAGAZINE_NAME = '/^[a-zA-Z0-9_]{2,25}$/';\n    public const USERNAME = '/^[a-zA-Z0-9_\\-]{1,30}$/';\n    public const LOCAL_MAGAZINE = '/^@\\w{2,25}\\b/';\n    public const LOCAL_USER = '/^@[a-zA-Z0-9_-]{1,30}\\b/';\n    public const AP_MAGAZINE = '/^(!\\w{2,25})(@)(([a-z0-9|-]+\\.)*[a-z0-9|-]+\\.[a-z]+)/';\n    public const AP_USER = '/^(@\\w{1,30})(@)(([a-z0-9|-]+\\.)*[a-z0-9|-]+\\.[a-z]+)/';\n    public const LOCAL_TAG_REGEX = '\\B#([\\w][\\w\\p{M}·・]+)';\n    public const LOCAL_TAG = '/'.self::LOCAL_TAG_REGEX.'/u';\n    public const COMMUNITY_REGEX = '\\B!(\\w{1,30})(?:@)?((?:[\\pL\\pN\\pS\\pM\\-\\_]++\\.)+[\\pL\\pN\\pM]++|[a-z0-9\\-\\_]++)?';\n    public const MENTION_REGEX = '\\B@([a-zA-Z0-9\\-\\_]{1,30})(?:@)?((?:[\\pL\\pN\\pS\\pM\\-\\_]++\\.)+[\\pL\\pN\\pM]++|[a-z0-9\\-\\_]++)?';\n    public const LOCAL_USER_REGEX = '/(?<!\\/)\\B@([a-zA-Z0-9_-]{1,30}@?)/u';\n    public const REMOTE_USER_REGEX = '/(?<!\\/)\\B@([a-zA-Z0-9._-]+@?)(@)(([\\pL\\pN\\pS\\pM\\-\\_]++\\.)+[\\pL\\pN\\pM]++|[a-z0-9\\-\\_]++)/u';\n    public const INVALID_TAG_CHARACTERS = '/[(){}\\/:@]/';\n    public const URL_SEPARATOR_REGEX = '/[ \\n\\[\\]()]/';\n}\n"
  },
  {
    "path": "src/Utils/Slugger.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Utils;\n\nuse Symfony\\Component\\String\\Slugger\\AsciiSlugger;\n\nclass Slugger\n{\n    public static function camelCase(string $value): string\n    {\n        return lcfirst(static::studly($value));\n    }\n\n    private static function studly(string $value): string\n    {\n        $value = ucwords(str_replace(['-', '_'], ' ', $value));\n\n        return str_replace(' ', '', $value);\n    }\n\n    public function slug(string $val): string\n    {\n        return substr((new AsciiSlugger())->slug($this->getWords($val), '-', 'en')->toString(), 0, 255);\n    }\n\n    private function getWords(string $sentence, int $count = 10): string\n    {\n        preg_match(\"/(?:\\S+(?:\\W+|$)){0,$count}/\", $sentence, $matches);\n\n        return $matches[0];\n    }\n}\n"
  },
  {
    "path": "src/Utils/SqlHelpers.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Utils;\n\nuse App\\Entity\\MagazineBlock;\nuse App\\Entity\\User;\nuse App\\Entity\\UserBlock;\nuse Doctrine\\Bundle\\DoctrineBundle\\Repository\\ServiceEntityRepository;\nuse Doctrine\\DBAL\\Exception;\nuse Doctrine\\DBAL\\ParameterType;\nuse Doctrine\\DBAL\\Types\\Types;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Psr\\Cache\\InvalidArgumentException;\nuse Psr\\Log\\LoggerInterface;\nuse Symfony\\Component\\HttpKernel\\KernelInterface;\nuse Symfony\\Contracts\\Cache\\CacheInterface;\nuse Symfony\\Contracts\\Cache\\ItemInterface;\n\nclass SqlHelpers\n{\n    public const string USER_FOLLOWS_KEY = 'cached_user_follows_';\n    public const string USER_MAGAZINE_SUBSCRIPTION_KEY = 'cached_user_magazine_subscription_';\n    public const string USER_MAGAZINE_MODERATION_KEY = 'cached_user_magazine_moderation_';\n    public const string USER_DOMAIN_SUBSCRIPTION_KEY = 'cached_user_domain_subscription_';\n    public const string USER_BLOCKS_KEY = 'cached_user_blocks_';\n    public const string USER_MAGAZINE_BLOCKS_KEY = 'cached_user_magazine_block_';\n    public const string USER_DOMAIN_BLOCKS_KEY = 'cached_user_domain_block_';\n\n    public function __construct(\n        private readonly EntityManagerInterface $entityManager,\n        private readonly KernelInterface $kernel,\n        private readonly CacheInterface $cache,\n        private readonly LoggerInterface $logger,\n    ) {\n    }\n\n    public static function makeWhereString(array $whereClauses): string\n    {\n        if (empty($whereClauses)) {\n            return '';\n        }\n\n        $where = 'WHERE ';\n        $i = 0;\n        foreach ($whereClauses as $whereClause) {\n            if (empty($whereClause)) {\n                continue;\n            }\n\n            if ($i > 0) {\n                $where .= ' AND ';\n            }\n            $where .= \"($whereClause)\";\n            ++$i;\n        }\n\n        return $where;\n    }\n\n    /**\n     * This method rewrites the parameter array and the native sql string to make use of array parameters\n     * which are not supported by sql directly. Keep in mind that postgresql has a limit of 65k parameters\n     * and each one of the array values counts as one parameter (because it only works that way).\n     *\n     * @return array{sql: string, parameters: array}>\n     */\n    public static function rewriteArrayParameters(array $parameters, string $sql): array\n    {\n        $newParameters = [];\n        $newSql = $sql;\n        foreach ($parameters as $name => $value) {\n            if (\\is_array($value)) {\n                $size = \\sizeof($value);\n                $newParameterNames = [];\n                for ($i = 0; $i < $size; ++$i) {\n                    $newParameters[\"$name$i\"] = $value[$i];\n                    $newParameterNames[] = \":$name$i\";\n                }\n                if (\\sizeof($newParameterNames) > 0) {\n                    $newParameterName = join(',', $newParameterNames);\n                    $newSql = str_replace(\":$name\", $newParameterName, $newSql);\n                } else {\n                    // for dealing with empty array parameters we put a -1 in there,\n                    // because just an empty `IN ()` will throw a syntax error\n                    $newParameters[$name] = -1;\n                }\n            } else {\n                $newParameters[$name] = $value;\n            }\n        }\n\n        return [\n            'parameters' => $newParameters,\n            'sql' => $newSql,\n        ];\n    }\n\n    public static function getSqlType(mixed $value): string|int\n    {\n        if ($value instanceof \\DateTimeImmutable) {\n            return Types::DATETIMETZ_IMMUTABLE;\n        } elseif ($value instanceof \\DateTime) {\n            return Types::DATETIMETZ_MUTABLE;\n        } elseif (\\is_int($value)) {\n            return Types::INTEGER;\n        }\n\n        return Types::STRING;\n    }\n\n    public static function invertOrderings(array $orderings): array\n    {\n        $newOrderings = [];\n        foreach ($orderings as $ordering) {\n            if (str_contains($ordering, 'DESC')) {\n                $newOrderings[] = str_replace('DESC', 'ASC', $ordering);\n            } elseif (str_contains($ordering, 'ASC')) {\n                $newOrderings[] = str_replace('ASC', 'DESC', $ordering);\n            } else {\n                // neither ASC nor DESC means ASC\n                $newOrderings[] = $ordering.' DESC';\n            }\n        }\n\n        return $newOrderings;\n    }\n\n    public function getBlockedMagazinesDql(User $user): string\n    {\n        return $this->entityManager->createQueryBuilder()\n            ->select('bm')\n            ->from(MagazineBlock::class, 'bm')\n            ->where('bm.magazine = m')\n            ->andWhere('bm.user = :user')\n            ->setParameter('user', $user)\n            ->getDQL();\n    }\n\n    public function getBlockedUsersDql(User $user): string\n    {\n        return $this->entityManager->createQueryBuilder()\n            ->select('ub')\n            ->from(UserBlock::class, 'ub')\n            ->where('ub.blocker = :user')\n            ->andWhere('ub.blocked = u')\n            ->setParameter('user', $user)\n            ->getDql();\n    }\n\n    /**\n     * @return int[] the ids of the users $user follows\n     */\n    public function getCachedUserFollows(User $user): array\n    {\n        try {\n            $sql = 'SELECT following_id FROM user_follow WHERE follower_id = :uId';\n            if ('test' === $this->kernel->getEnvironment()) {\n                return $this->fetchSingleColumnAsArray($sql, $user);\n            }\n\n            return $this->cache->get(self::USER_FOLLOWS_KEY.$user->getId(), function (ItemInterface $item) use ($user, $sql) {\n                return $this->fetchSingleColumnAsArray($sql, $user);\n            });\n        } catch (InvalidArgumentException|Exception $exception) {\n            $this->logger->error('There was an error getting the cached magazine blocks of user \"{u}\": {e} - {m}', ['u' => $user->username, 'e' => \\get_class($exception), 'm' => $exception->getMessage()]);\n\n            return [];\n        }\n    }\n\n    public function clearCachedUserFollows(User $user): void\n    {\n        $this->logger->debug('Clearing cached user follows for user {u}', ['u' => $user->username]);\n        try {\n            $this->cache->delete(self::USER_FOLLOWS_KEY.$user->getId());\n        } catch (InvalidArgumentException $exception) {\n            $this->logger->warning('There was an error clearing the cached user follows of user \"{u}\": {m}', ['u' => $user->username, 'm' => $exception->getMessage()]);\n        }\n    }\n\n    /**\n     * @return int[] the ids of the magazines $user is subscribed to\n     */\n    public function getCachedUserSubscribedMagazines(User $user): array\n    {\n        try {\n            $sql = 'SELECT magazine_id FROM magazine_subscription WHERE user_id = :uId';\n            if ('test' === $this->kernel->getEnvironment()) {\n                return $this->fetchSingleColumnAsArray($sql, $user);\n            }\n\n            return $this->cache->get(self::USER_MAGAZINE_SUBSCRIPTION_KEY.$user->getId(), function (ItemInterface $item) use ($user, $sql) {\n                return $this->fetchSingleColumnAsArray($sql, $user);\n            });\n        } catch (InvalidArgumentException|Exception $exception) {\n            $this->logger->error('There was an error getting the cached magazine blocks of user \"{u}\": {e} - {m}', ['u' => $user->username, 'e' => \\get_class($exception), 'm' => $exception->getMessage()]);\n\n            return [];\n        }\n    }\n\n    public function clearCachedUserSubscribedMagazines(User $user): void\n    {\n        $this->logger->debug('Clearing cached magazine subscriptions for user {u}', ['u' => $user->username]);\n        try {\n            $this->cache->delete(self::USER_MAGAZINE_SUBSCRIPTION_KEY.$user->getId());\n        } catch (InvalidArgumentException $exception) {\n            $this->logger->warning('There was an error clearing the cached subscribed Magazines of user \"{u}\": {m}', ['u' => $user->username, 'm' => $exception->getMessage()]);\n        }\n    }\n\n    /**\n     * @return int[] the ids of the magazines $user moderates\n     */\n    public function getCachedUserModeratedMagazines(User $user): array\n    {\n        try {\n            $sql = 'SELECT magazine_id FROM moderator WHERE user_id = :uId';\n            if ('test' === $this->kernel->getEnvironment()) {\n                return $this->fetchSingleColumnAsArray($sql, $user);\n            }\n\n            return $this->cache->get(self::USER_MAGAZINE_MODERATION_KEY.$user->getId(), function (ItemInterface $item) use ($user, $sql) {\n                return $this->fetchSingleColumnAsArray($sql, $user);\n            });\n        } catch (InvalidArgumentException|Exception $exception) {\n            $this->logger->error('There was an error getting the cached magazine blocks of user \"{u}\": {e} - {m}', ['u' => $user->username, 'e' => \\get_class($exception), 'm' => $exception->getMessage()]);\n\n            return [];\n        }\n    }\n\n    public function clearCachedUserModeratedMagazines(User $user): void\n    {\n        $this->logger->debug('Clearing cached moderated magazines for user {u}', ['u' => $user->username]);\n        try {\n            $this->cache->delete(self::USER_MAGAZINE_MODERATION_KEY.$user->getId());\n        } catch (InvalidArgumentException $exception) {\n            $this->logger->warning('There was an error clearing the cached moderated magazines of user \"{u}\": {m}', ['u' => $user->username, 'm' => $exception->getMessage()]);\n        }\n    }\n\n    /**\n     * @return int[] the ids of the domains $user is subscribed to\n     */\n    public function getCachedUserSubscribedDomains(User $user): array\n    {\n        try {\n            $sql = 'SELECT domain_id FROM domain_subscription WHERE user_id = :uId';\n            if ('test' === $this->kernel->getEnvironment()) {\n                return $this->fetchSingleColumnAsArray($sql, $user);\n            }\n\n            return $this->cache->get(self::USER_DOMAIN_SUBSCRIPTION_KEY.$user->getId(), function (ItemInterface $item) use ($user, $sql) {\n                return $this->fetchSingleColumnAsArray($sql, $user);\n            });\n        } catch (InvalidArgumentException|Exception $exception) {\n            $this->logger->error('There was an error getting the cached magazine blocks of user \"{u}\": {e} - {m}', ['u' => $user->username, 'e' => \\get_class($exception), 'm' => $exception->getMessage()]);\n\n            return [];\n        }\n    }\n\n    public function clearCachedUserSubscribedDomains(User $user): void\n    {\n        $this->logger->debug('Clearing cached domain subscriptions for user {u}', ['u' => $user->username]);\n        try {\n            $this->cache->delete(self::USER_DOMAIN_SUBSCRIPTION_KEY.$user->getId());\n        } catch (InvalidArgumentException $exception) {\n            $this->logger->warning('There was an error clearing the cached subscribed domains of user \"{u}\": {m}', ['u' => $user->username, 'm' => $exception->getMessage()]);\n        }\n    }\n\n    /**\n     * @return int[] the ids of the domains $user is subscribed to\n     */\n    public function getCachedUserBlocks(User $user): array\n    {\n        try {\n            $sql = 'SELECT blocked_id FROM user_block WHERE blocker_id = :uId';\n            if ('test' === $this->kernel->getEnvironment()) {\n                return $this->fetchSingleColumnAsArray($sql, $user);\n            }\n\n            return $this->cache->get(self::USER_BLOCKS_KEY.$user->getId(), function (ItemInterface $item) use ($user, $sql) {\n                return $this->fetchSingleColumnAsArray($sql, $user);\n            });\n        } catch (InvalidArgumentException|Exception $exception) {\n            $this->logger->error('There was an error getting the cached magazine blocks of user \"{u}\": {e} - {m}', ['u' => $user->username, 'e' => \\get_class($exception), 'm' => $exception->getMessage()]);\n\n            return [];\n        }\n    }\n\n    public function clearCachedUserBlocks(User $user): void\n    {\n        $this->logger->debug('Clearing cached user blocks for user {u}', ['u' => $user->username]);\n        try {\n            $this->cache->delete(self::USER_BLOCKS_KEY.$user->getId());\n        } catch (InvalidArgumentException $exception) {\n            $this->logger->warning('There was an error clearing the cached blocked user of user \"{u}\": {m}', ['u' => $user->username, 'm' => $exception->getMessage()]);\n        }\n    }\n\n    /**\n     * @return int[] the ids of the domains $user is subscribed to\n     */\n    public function getCachedUserMagazineBlocks(User $user): array\n    {\n        try {\n            $sql = 'SELECT magazine_id FROM magazine_block WHERE user_id = :uId';\n            if ('test' === $this->kernel->getEnvironment()) {\n                return $this->fetchSingleColumnAsArray($sql, $user);\n            }\n\n            return $this->cache->get(self::USER_MAGAZINE_BLOCKS_KEY.$user->getId(), function (ItemInterface $item) use ($user, $sql) {\n                return $this->fetchSingleColumnAsArray($sql, $user);\n            });\n        } catch (InvalidArgumentException|Exception $exception) {\n            $this->logger->error('There was an error getting the cached magazine blocks of user \"{u}\": {e} - {m}', ['u' => $user->username, 'e' => \\get_class($exception), 'm' => $exception->getMessage()]);\n\n            return [];\n        }\n    }\n\n    public function clearCachedUserMagazineBlocks(User $user): void\n    {\n        $this->logger->debug('Clearing cached magazine blocks for user {u}', ['u' => $user->username]);\n        try {\n            $this->cache->delete(self::USER_MAGAZINE_BLOCKS_KEY.$user->getId());\n        } catch (InvalidArgumentException $exception) {\n            $this->logger->warning('There was an error clearing the cached blocked magazines of user \"{u}\": {m}', ['u' => $user->username, 'm' => $exception->getMessage()]);\n        }\n    }\n\n    /**\n     * @return int[] the ids of the domains $user is subscribed to\n     */\n    public function getCachedUserDomainBlocks(User $user): array\n    {\n        try {\n            $sql = 'SELECT domain_id FROM domain_block WHERE user_id = :uId';\n            if ('test' === $this->kernel->getEnvironment()) {\n                return $this->fetchSingleColumnAsArray($sql, $user);\n            }\n\n            return $this->cache->get(self::USER_DOMAIN_BLOCKS_KEY.$user->getId(), function (ItemInterface $item) use ($user, $sql) {\n                return $this->fetchSingleColumnAsArray($sql, $user);\n            });\n        } catch (InvalidArgumentException|Exception $exception) {\n            $this->logger->error('There was an error getting the cached magazine blocks of user \"{u}\": {e} - {m}', ['u' => $user->username, 'e' => \\get_class($exception), 'm' => $exception->getMessage()]);\n\n            return [];\n        }\n    }\n\n    public function clearCachedUserDomainBlocks(User $user): void\n    {\n        $this->logger->debug('Clearing cached domain blocks for user {u}', ['u' => $user->username]);\n        try {\n            $this->cache->delete(self::USER_DOMAIN_BLOCKS_KEY.$user->getId());\n        } catch (InvalidArgumentException $exception) {\n            $this->logger->warning('There was an error clearing the cached blocked domains of user \"{u}\": {m}', ['u' => $user->username, 'm' => $exception->getMessage()]);\n        }\n    }\n\n    /**\n     * @param string $sql the sql to fetch the single column, should contain a 'uId' Parameter\n     *\n     * @return int[]\n     *\n     * @throws Exception\n     */\n    public function fetchSingleColumnAsArray(string $sql, User $user): array\n    {\n        $conn = $this->entityManager->getConnection();\n        $stmt = $conn->prepare($sql);\n        $stmt->bindValue('uId', $user->getId(), ParameterType::INTEGER);\n        $result = $stmt->executeQuery();\n        $rows = $result->fetchAllAssociative();\n        $result = [];\n        foreach ($rows as $row) {\n            $result[] = $row[array_key_first($row)];\n        }\n\n        $this->logger->debug('Fetching single column row from {sql}: {res}', ['sql' => $sql, 'res' => $result]);\n\n        return $result;\n    }\n\n    public static function getRealClassName(EntityManagerInterface $entityManager, mixed $object): string\n    {\n        return $entityManager->getClassMetadata(\\get_class($object))->getName();\n    }\n\n    /**\n     * This method is useful for gathering more entities than the parameter limit allows for.\n     *\n     * @template-covariant T\n     *\n     * @param ServiceEntityRepository<T> $repository\n     *\n     * @return T[]\n     */\n    public static function findByAdjusted(ServiceEntityRepository $repository, string $columnName, array $values): array\n    {\n        $split = ArrayUtils::sliceArrayIntoEqualPieces($values, 65000);\n        $results = [];\n        foreach ($split as $part) {\n            $results[] = $repository->findBy([$columnName => $part]);\n        }\n\n        return array_merge(...$results);\n    }\n}\n"
  },
  {
    "path": "src/Utils/SubscriptionSort.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Utils;\n\nenum SubscriptionSort\n{\n    case Alphabetically;\n    case LastActive;\n}\n"
  },
  {
    "path": "src/Utils/UrlCleaner.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Utils;\n\nuse App\\Exception\\BadUrlException;\n\nclass UrlCleaner\n{\n    // https://gist.github.com/htsign/455bd76d107be1f810c5caa4072c8275\n    public const TRACKING_TAGS = [\n        'utm_source',\n        'utm_medium',\n        'utm_term',\n        'utm_content',\n        'utm_campaign',\n        'utm_reader',\n        'utm_place',\n        'utm_userid',\n        'utm_cid',\n        'utm_name',\n        'utm_pubreferrer',\n        'utm_swu',\n        'utm_viz_id',\n        'utm_int',\n        'ga_source',\n        'ga_medium',\n        'ga_term',\n        'ga_content',\n        'ga_campaign',\n        'ga_place',\n        'yclid, _openstat',\n        'fb_action_ids',\n        'fb_action_types',\n        'fb_ref',\n        'fb_source',\n        'action_object_map',\n        'action_type_map',\n        'action_ref_map',\n        'gs_l',\n        'pd_rd_*@amazon.*',\n        '_encoding@amazon.*',\n        'psc@amazon.*',\n        'ei@google.*',\n        'bi?@google.*',\n        'client@google.*',\n        'dpr@google.*',\n        'gws_rd@google.*',\n        'oq@google.*',\n        'sa@google.*',\n        'sei@google.*',\n        'source@google.*',\n        'tbm@google.*',\n        'ved@google.*',\n        'cvid@bing.com',\n        'form@bing.com',\n        'sk@bing.com',\n        'sp@bing.com',\n        'sc@bing.com',\n        'qs@bing.com',\n        'pq@bing.com',\n        'feature@youtube.com',\n        'gclid@youtube.com',\n        'kw@youtube.com',\n        'gws_rd',\n        'hmb_campaign',\n        'hmb_medium',\n        'hmb_source',\n        '_hsmi',\n        'ref_src',\n        'ref_url',\n        'source@sourceforge.net',\n        'position@sourceforge.net',\n        'callback@bilibili.com',\n        'ref@www.asahi.com',\n        'iref@www.asahi.com',\n        'rm@digital.asahi.com',\n        'word_result@nhk.or.jp',\n        'algorithm@www.change.org',\n        'grid_position@www.change.org',\n        'j@www.change.org',\n        'jb@www.change.org',\n        'mid@www.change.org',\n        'l@www.change.org',\n        'original_footer_petition_id@www.change.org',\n        'placement@www.change.org',\n        'pt@www.change.org',\n        'sfmc_sub@www.change.org',\n        'source_location@www.change.org',\n        'u@www.change.org',\n        'n_cid@nikkeibp.co.jp',\n        'fbclid@itmedia.co.jp',\n        'ref@*.nicovideo.jp',\n        '#?utm_medium',\n        '#?utm_source',\n        '#?utm_campaign',\n        '#?utm_content',\n        '#?utm_int',\n        'fbclid',\n    ];\n\n    public function __invoke(string $url): string\n    {\n        foreach (self::TRACKING_TAGS as $tag) {\n            $url = $this->removeVar($url, $tag);\n        }\n\n        return $url;\n    }\n\n    private function removeVar(string $url, string $var): string\n    {\n        [$urlPart, $qsPart] = array_pad(explode('?', $url), 2, '');\n        parse_str($qsPart, $qsVars);\n        unset($qsVars[$var]);\n        $newQs = http_build_query($qsVars);\n\n        return $this->validate(trim($urlPart.'?'.$newQs, '?'));\n    }\n\n    private function validate(string $url): string\n    {\n        // @todo checkdnsrr?\n        if (!filter_var($url, FILTER_VALIDATE_URL)) {\n            throw new BadUrlException($url);\n        }\n\n        return $url;\n    }\n}\n"
  },
  {
    "path": "src/Utils/UrlUtils.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Utils;\n\nuse Symfony\\Component\\HttpFoundation\\Request;\n\nclass UrlUtils\n{\n    public static function isActivityPubRequest(?Request $request): bool\n    {\n        if (null === $request) {\n            return true;\n        }\n        $acceptValue = $request->headers->get('Accept', default: 'html');\n\n        return str_contains($acceptValue, 'application/activity+json')\n            || str_contains($acceptValue, 'application/ld+json');\n    }\n\n    public static function getCacheKeyForMarkdownUrl(string $url): string\n    {\n        $key = preg_replace(RegPatterns::INVALID_TAG_CHARACTERS, '_', $url);\n\n        return \"markdown_url_$key\";\n    }\n\n    public static function getCacheKeyForMarkdownUserMention(string $url): string\n    {\n        $key = preg_replace(RegPatterns::INVALID_TAG_CHARACTERS, '_', $url);\n\n        return \"markdown_user_mention_$key\";\n    }\n\n    public static function getCacheKeyForMarkdownMagazineMention(string $url): string\n    {\n        $key = preg_replace(RegPatterns::INVALID_TAG_CHARACTERS, '_', $url);\n\n        return \"markdown_magazine_mention_$key\";\n    }\n\n    public static function extractUrlsFromString(string $text): array\n    {\n        $words = preg_split(RegPatterns::URL_SEPARATOR_REGEX, $text);\n        $urls = [];\n        foreach ($words as $word) {\n            if (filter_var($word, FILTER_VALIDATE_URL)) {\n                $urls[] = $word;\n            }\n        }\n\n        return $urls;\n    }\n}\n"
  },
  {
    "path": "src/Validator/NoSurroundingWhitespace.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Validator;\n\nuse Symfony\\Component\\Validator\\Attribute\\HasNamedArguments;\nuse Symfony\\Component\\Validator\\Constraint;\n\n#[\\Attribute(\\Attribute::TARGET_PROPERTY)]\nclass NoSurroundingWhitespace extends Constraint\n{\n    public const string NOT_UNIQUE_ERROR = '492764ab-760d-48da-8d2e-1d5f3e5fac4c';\n\n    protected const array ERROR_NAMES = [\n        self::NOT_UNIQUE_ERROR => 'NO_SURROUNDING_WHITESPACE_ERROR',\n    ];\n\n    public string $message = 'The value must not have whitespaces at the beginning or end.';\n\n    #[HasNamedArguments]\n    public function __construct(\n        public bool $allowEmpty = false,\n    ) {\n        parent::__construct();\n    }\n\n    public function getTargets(): array\n    {\n        return [Constraint::PROPERTY_CONSTRAINT];\n    }\n}\n"
  },
  {
    "path": "src/Validator/NoSurroundingWhitespaceValidator.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Validator;\n\nuse Symfony\\Component\\Validator\\Constraint;\nuse Symfony\\Component\\Validator\\ConstraintValidator;\nuse Symfony\\Component\\Validator\\Exception\\UnexpectedTypeException;\n\nclass NoSurroundingWhitespaceValidator extends ConstraintValidator\n{\n    public function validate($value, Constraint $constraint): void\n    {\n        if (!\\is_string($value) && null !== $value) {\n            throw new UnexpectedTypeException($value, 'string');\n        }\n\n        if (!$constraint instanceof NoSurroundingWhitespace) {\n            throw new UnexpectedTypeException($constraint, NoSurroundingWhitespace::class);\n        }\n\n        if (null === $value) {\n            return;\n        }\n\n        if ('' === $value) {\n            if ($constraint->allowEmpty) {\n                return;\n            }\n            $this->context->buildViolation($constraint->message)\n                ->setCode(NoSurroundingWhitespace::NOT_UNIQUE_ERROR)\n                ->addViolation();\n        }\n\n        if (trim($value) !== $value) {\n            $this->context->buildViolation($constraint->message)\n                ->setCode(NoSurroundingWhitespace::NOT_UNIQUE_ERROR)\n                ->addViolation();\n        }\n    }\n}\n"
  },
  {
    "path": "src/Validator/Unique.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Validator;\n\nuse Symfony\\Component\\Validator\\Attribute\\HasNamedArguments;\nuse Symfony\\Component\\Validator\\Constraint;\nuse Symfony\\Component\\Validator\\Exception\\InvalidOptionsException;\n\n/**\n * For this to work when editing something, the DTO must hold the ID\n * of the entity being edited, and the ID mapped using `$idFields`.\n */\n#[\\Attribute(\\Attribute::TARGET_CLASS | \\Attribute::IS_REPEATABLE)]\nclass Unique extends Constraint\n{\n    public const NOT_UNIQUE_ERROR = 'eec1b008-c55b-4d91-b5ad-f0b201eb8ada';\n\n    protected const ERROR_NAMES = [\n        self::NOT_UNIQUE_ERROR => 'NOT_UNIQUE_ERROR',\n    ];\n\n    public string $message = 'This value is already used.';\n\n    /**\n     * @param non-empty-array<int|string, string> $fields      DTO -> entity field mapping\n     * @param array<int|string, string>           $idFields    DTO -> entity ID field mapping\n     * @param class-string                        $entityClass\n     */\n    #[HasNamedArguments]\n    public function __construct(\n        public string $entityClass,\n        public string $errorPath,\n        public array $fields,\n        public array $idFields = [],\n    ) {\n        parent::__construct();\n\n        if (0 === \\count($fields)) {\n            throw new InvalidOptionsException('`fields` option must have at least one field', ['fields']);\n        }\n\n        if (null === $entityClass || '' === $entityClass) {\n            throw new InvalidOptionsException('Bad entity class', ['entityClass']);\n        }\n    }\n\n    public function getTargets(): array\n    {\n        return [Constraint::CLASS_CONSTRAINT];\n    }\n}\n"
  },
  {
    "path": "src/Validator/UniqueValidator.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Validator;\n\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Symfony\\Component\\PropertyAccess\\PropertyAccess;\nuse Symfony\\Component\\Validator\\Constraint;\nuse Symfony\\Component\\Validator\\ConstraintValidator;\nuse Symfony\\Component\\Validator\\Exception\\UnexpectedTypeException;\n\nfinal class UniqueValidator extends ConstraintValidator\n{\n    public function __construct(private readonly EntityManagerInterface $entityManager)\n    {\n    }\n\n    public function validate($value, Constraint $constraint): void\n    {\n        if (!\\is_object($value)) {\n            throw new UnexpectedTypeException($value, 'object');\n        }\n\n        if (!$constraint instanceof Unique) {\n            throw new UnexpectedTypeException($constraint, Unique::class);\n        }\n\n        $qb = $this->entityManager->createQueryBuilder()\n            ->select('COUNT(e)')\n            ->from($constraint->entityClass, 'e');\n\n        $propertyAccessor = PropertyAccess::createPropertyAccessor();\n\n        foreach ($constraint->fields as $dtoField => $entityField) {\n            if (\\is_int($dtoField)) {\n                $dtoField = $entityField;\n            }\n\n            $fieldValue = $propertyAccessor->getValue($value, $dtoField);\n\n            if (\\is_string($fieldValue)) {\n                $qb->andWhere($qb->expr()->eq(\"LOWER(e.$entityField)\", \":f_$entityField\"));\n                $qb->setParameter(\"f_$entityField\", mb_strtolower($fieldValue));\n            } else {\n                $qb->andWhere($qb->expr()->eq(\"e.$entityField\", \":f_$entityField\"));\n                $qb->setParameter(\"f_$entityField\", $fieldValue);\n            }\n        }\n\n        foreach ($constraint->idFields as $dtoField => $entityField) {\n            if (\\is_int($dtoField)) {\n                $dtoField = $entityField;\n            }\n\n            $fieldValue = $propertyAccessor->getValue($value, $dtoField);\n\n            if (null !== $fieldValue) {\n                $qb->andWhere($qb->expr()->neq(\"e.$entityField\", \":i_$entityField\"));\n                $qb->setParameter(\"i_$entityField\", $fieldValue);\n            }\n        }\n\n        $count = $qb->getQuery()->getSingleScalarResult();\n\n        if ($count > 0) {\n            $this->context->buildViolation($constraint->message)\n                ->setCode(Unique::NOT_UNIQUE_ERROR)\n                ->atPath($constraint->errorPath)\n                ->addViolation();\n        }\n    }\n}\n"
  },
  {
    "path": "templates/_email/application_approved.html.twig",
    "content": "{% extends '_email/email_base.html.twig' %}\n\n{%- block title -%}\n    {{- 'email_application_approved_title'|trans }}\n{%- endblock -%}\n\n{% block body %}\n    <p>\n        {{ 'email_application_approved_body'|trans({\n            '%link%': url('app_login'),\n            '%siteName%': kbin_domain(),\n        })|raw }}\n    </p>\n    {% if user.isVerified is same as false %}\n        <p>\n            {{ 'email_verification_pending'|trans }}\n        </p>\n    {% endif %}\n{% endblock %}\n"
  },
  {
    "path": "templates/_email/application_rejected.html.twig",
    "content": "{% extends '_email/email_base.html.twig' %}\n\n{%- block title -%}\n    {{- 'email_application_rejected_title'|trans }}\n{%- endblock -%}\n\n{% block body %}\n    <p>\n        {{ 'email_application_rejected_body'|trans }}\n    </p>\n{% endblock %}\n"
  },
  {
    "path": "templates/_email/confirmation_email.html.twig",
    "content": "{% extends \"_email/email_base.html.twig\" %}\n\n{%- block title -%}\n    {{- 'email_confirm_header'|trans }}\n{%- endblock -%}\n\n{% block body %}\n    <h1>{{ 'email_confirm_header'|trans }}</h1>\n    <p>{{ 'email_confirm_content'|trans }} </p>\n    <p>\n        <a class=\"btn btn__primary\" href=\"{{ signedUrl|raw }}\">{{ 'email_verify'|trans }}</a>\n    </p>\n    {% if user.getApplicationStatus() is not same as enum('App\\\\Enums\\\\EApplicationStatus').Approved %}\n        <p>\n            {{ 'email_application_pending'|trans }}\n        </p>\n    {% endif %}\n    <p>{{ 'email_confirm_expire'|trans }}</p>\n    <p>Cheers!</p>\n{% endblock %}\n"
  },
  {
    "path": "templates/_email/contact.html.twig",
    "content": "{% extends \"_email/email_base.html.twig\" %}\n\n{%- block title -%}\n    {{- 'contact'|trans }}\n{%- endblock -%}\n\n{% block body %}\n    <h1>{{ 'contact'|trans }}</h1>\n    <div>\n        <p><strong>Name: </strong>{{ name }}</p>\n        <p><strong>Email: </strong>{{ senderEmail }}</p>\n        <p><strong>Message: </strong>{{ message }}</p>\n    </div>\n{% endblock %}\n"
  },
  {
    "path": "templates/_email/delete_account_request.html.twig",
    "content": "{% extends \"_email/email_base.html.twig\" %}\n\n{%- block title -%}\n    {{- 'email.delete.title'|trans }} \n{%- endblock -%}\n\n{% block body %}\n    <h1>{{'email.delete.title'|trans}}</h1>\n    <p>{{'email.delete.description'|trans}}</p>\n    <div>\n        <p><strong>Username: </strong> {{ username }}</p>\n        <p><strong>Email: </strong> {{ mail }}</p>\n    </div>\n{% endblock %}"
  },
  {
    "path": "templates/_email/email_base.html.twig",
    "content": "{% apply inline_css(encore_entry_css_source('email')) %}\n<!doctype html>\n<html>\n<head>\n    <meta http-equiv=\"Content-Type\" content=\"text/html; charset=utf-8\">\n    <meta name=\"viewport\" content=\"width=device-width\">\n    <title>{%- block title -%}{{ kbin_meta_title() }}{%- endblock -%}</title>\n</head>\n<body> \n    <div class=\"header container\">\n        <a href=\"{{absolute_url(path('front'))}}\">\n             <img class=\"logo\" src=\"{{ absolute_url(asset('mbin_logo_white.png')) }}\" alt=\"{{ kbin_title() }}\">\n         </a>\n    </div>\n    <div class=\"body container\">\n        {% block body %}{% endblock %}\n    </div>\n    <div class=\"footer container\">\n         <h3>© {{ 'now'|date('Y') }} {{ kbin_domain() }}</h3>\n         <div>Powered by <a href=\"https://github.com/MbinOrg/mbin\">Mbin</a> &#8729; <a href=\"https://github.com/MbinOrg/mbin/issues\" rel=\"no-referrer\">{{ 'report_issue'|trans }}</a></div>\n    </div>\n</body>\n</html>\n{% endapply %} \n"
  },
  {
    "path": "templates/_email/reset_pass_confirm.html.twig",
    "content": "{% extends \"_email/email_base.html.twig\" %}\n\n{%- block title -%}\n    {{- 'password_confirm_header'|trans }}\n{%- endblock -%}\n\n{% block body %}\n    <h1>{{ 'password_confirm_header'|trans }}</h1>\n    <p>{{ 'email_confirm_expire'|trans }} </p>\n    <p>\n        <a class=\"btn btn__primary\" href=\"{{ url('app_reset_password', {token: resetToken.token}) }}\">{{'email_confirm_button_text'|trans}}</a>\n    </p>\n    <p>Cheers!</p>\n    <p>\n        <small>{{'email_confirm_link_help'|trans}}: {{ url('app_reset_password', {token: resetToken.token}) }}</small>\n    </p>\n{% endblock %}\n"
  },
  {
    "path": "templates/admin/_options.html.twig",
    "content": "{%- set TYPE_GENERAL = constant('App\\\\Repository\\\\StatsRepository::TYPE_GENERAL') -%}\n{%- set TYPE_CONTENT = constant('App\\\\Repository\\\\StatsRepository::TYPE_CONTENT') -%}\n{%- set TYPE_VOTES = constant('App\\\\Repository\\\\StatsRepository::TYPE_VOTES') -%}\n{%- set STATUS_PENDING = constant('App\\\\Entity\\\\Report::STATUS_PENDING') -%}\n<aside class=\"options options--top\" id=\"options\">\n    <div></div>\n    <menu class=\"options__main\">\n        <li>\n            <a href=\"{{ path('admin_dashboard') }}\"\n               class=\"{{ html_classes({'active': is_route_name('admin_dashboard')}) }}\">\n                {{ 'dashboard'|trans }}\n            </a>\n        </li>\n        <li>\n            <a href=\"{{ path('admin_settings') }}\"\n               class=\"{{ html_classes({'active': is_route_name('admin_settings')}) }}\">\n                {{ 'settings'|trans }}\n            </a>\n        </li>\n        <li>\n            <a href=\"{{ path('admin_users_active') }}\"\n               class=\"{{ html_classes({'active': is_route_name('admin_users_active') or is_route_name('admin_users_inactive') or is_route_name('admin_users_suspended') or is_route_name('admin_users_banned')}) }}\">\n                {{ 'users'|trans }}\n            </a>\n        </li>\n        <li>\n            <a href=\"{{ path('admin_reports', {status: STATUS_PENDING}) }}\"\n               class=\"{{ html_classes({'active': is_route_name('admin_reports')}) }}\">\n                {{ 'reports'|trans }}\n            </a>\n        </li>\n        <li>\n            <a href=\"{{ path('admin_moderators') }}\"\n               class=\"{{ html_classes({'active': is_route_name('admin_moderators')}) }}\">\n                {{ 'moderators'|trans }}\n            </a>\n        </li>\n        <li>\n            <a href=\"{{ path('admin_magazine_ownership_requests') }}\"\n               class=\"{{ html_classes({'active': is_route_name('admin_magazine_ownership_requests') }) }}\">\n                {{ 'ownership_requests'|trans }}\n            </a>\n        </li>\n        {% if do_new_users_need_approval() %}\n            <li>\n                <a href=\"{{ path('admin_signup_requests') }}\"\n                   class=\"{{ html_classes({'active': is_route_name('admin_signup_requests') }) }}\">\n                    {{ 'signup_requests'|trans }}\n                </a>\n            </li>\n        {% endif %}\n        <li>\n            <a href=\"{{ path('admin_pages', {page: 'announcement'}) }}\"\n               class=\"{{ html_classes({'active': is_route_name('admin_pages')}) }}\">\n                {{ 'pages'|trans }}\n            </a>\n        </li>\n        <li>\n            <a href=\"{{ path('admin_federation') }}\"\n               class=\"{{ html_classes({'active': is_route_name('admin_federation')}) }}\">\n                {{ 'federation'|trans }}\n            </a>\n        </li>\n        <li>\n            <a href=\"{{ path('admin_deletion_users') }}\"\n               class=\"{{ html_classes({'active': is_route_name('admin_deletion_users') or is_route_name('admin_deletion_magazines')}) }}\">\n                {{ 'deletion'|trans }}\n            </a>\n        </li>\n        {% if is_monitoring_enabled() %}\n            <li>\n                <a href=\"{{ path('admin_monitoring') }}\"\n                    class=\"{{ html_classes({'active': is_route_name('admin_monitoring')}) }}\">\n                    {{ 'monitoring'|trans }}\n                </a>\n            </li>\n        {% endif %}\n    </menu>\n</aside>\n"
  },
  {
    "path": "templates/admin/dashboard.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    {{- 'dashboard'|trans }} - {{ parent() -}}\n{%- endblock -%}\n\n{% block mainClass %}page-admin-dashboard{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n{% endblock %}\n\n{% block body %}\n    {% include 'admin/_options.html.twig' %}\n    <div class=\"section\">\n        <div class=\"flex\" style=\"margin-bottom: 1rem;\">\n            {% include 'stats/_filters.html.twig' %}\n        </div>\n        <div class=\"stats-count\">\n            <div>\n                <h3>{{ 'users'|trans|upper }}</h3>\n                <p>{{ users }}</p>\n            </div>\n            <div>\n                <h3>{{ 'magazines'|trans|upper }}</h3>\n                <p>{{ magazines }}</p>\n            </div>\n            <div>\n                <h3>{{ 'votes'|trans|upper }}</h3>\n                <p>{{ votes }}</p>\n            </div>\n            <div>\n                <h3>{{ 'threads'|trans|upper }}</h3>\n                <p>{{ entries }}</p>\n            </div>\n            <div>\n                <h3>{{ 'comments'|trans|upper }}</h3>\n                <p>{{ comments }}</p>\n            </div>\n            <div>\n                <h3>{{ 'posts'|trans|upper }}</h3>\n                <p>{{ posts }}</p>\n            </div>\n        </div>\n    </div>\n{% endblock %}\n"
  },
  {
    "path": "templates/admin/deletion_magazines.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    {{- 'deletion'|trans }} - {{ parent() -}}\n{%- endblock -%}\n\n{% block mainClass %}page-admin-deletion{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n{% endblock %}\n\n{% block body %}\n    {% include 'admin/_options.html.twig' %}\n    <div class=\"pills\">\n        <menu>\n            <li>\n                <a href=\"{{ path('admin_deletion_users') }}\"\n                   class=\"{{ html_classes({'active': is_route_name('admin_deletion_users')}) }}\">\n                    {{ 'users'|trans }}\n                </a>\n            </li>\n            <li>\n                <a href=\"{{ path('admin_deletion_magazines') }}\"\n                   class=\"{{ html_classes({'active': is_route_name('admin_deletion_magazines')}) }}\">\n                    {{ 'magazines'|trans }}\n                </a>\n            </li>\n        </menu>\n    </div>\n    {% if  magazines|length %}\n        <div class=\"section\" id=\"content\">\n            <table>\n                <thead>\n                <tr>\n                    <th>{{ 'name'|trans }}</th>\n                    <th>{{ 'threads'|trans }}</th>\n                    <th>{{ 'comments'|trans }}</th>\n                    <th>{{ 'posts'|trans }}</th>\n                    <th>{{ 'marked_for_deletion'|trans }}</th>\n                </tr>\n                </thead>\n                <tbody>\n                {% for magazine in magazines %}\n                    <tr>\n                        <td>{{ component('magazine_inline', { magazine: magazine, stretchedLink: true, showAvatar: true, showNewIcon: true}) }}</td>\n                        <td>{{ magazine.entryCount }}</td>\n                        <td>{{ magazine.entryCommentCount }}</td>\n                        <td>{{ magazine.postCount + magazine.postCommentCount }}</td>\n                        <td>{{ component('date', {date: magazine.markedForDeletionAt}) }}</td>\n                    </tr>\n                {% endfor %}\n                </tbody>\n            </table>\n        </div>\n    {% else %}\n        <aside class=\"section section--muted\">\n            <p>{{ 'empty'|trans }}</p>\n        </aside>\n    {% endif %}\n    {% if(magazines.haveToPaginate is defined and magazines.haveToPaginate) %}\n        {{ pagerfanta(magazines, null, {'pageParameter':'[p]'}) }}\n    {% endif %}\n{% endblock %}\n"
  },
  {
    "path": "templates/admin/deletion_users.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    {{- 'deletion'|trans }} - {{ parent() -}}\n{%- endblock -%}\n\n{% block mainClass %}page-admin-deletion{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n{% endblock %}\n\n{% block body %}\n    {% include 'admin/_options.html.twig' %}\n    <div class=\"pills\">\n        <menu>\n            <li>\n                <a href=\"{{ path('admin_deletion_users') }}\"\n                   class=\"{{ html_classes({'active': is_route_name('admin_deletion_users')}) }}\">\n                    {{ 'users'|trans }}\n                </a>\n            </li>\n            <li>\n                <a href=\"{{ path('admin_deletion_magazines') }}\"\n                   class=\"{{ html_classes({'active': is_route_name('admin_deletion_magazines')}) }}\">\n                    {{ 'magazines'|trans }}\n                </a>\n            </li>\n        </menu>\n    </div>\n    {% if users|length %}\n        <div class=\"section\" id=\"content\">\n            <table>\n                <thead>\n                <tr>\n                    <th>{{ 'username'|trans }}</th>\n                    <th>{{ 'email'|trans }}</th>\n                    <th>{{ 'created_at'|trans }}</th>\n                    <th>{{ 'marked_for_deletion'|trans }}</th>\n                </tr>\n                </thead>\n                <tbody>\n                {% for user in users %}\n                    <tr>\n                        <td>{{ component('user_inline', {user: user, showNewIcon: true}) }}</td>\n                        <td>{{ user.email }}</td>\n                        <td>{{ component('date', {date: user.createdAt}) }}</td>\n                        <td>{{ component('date', {date: user.markedForDeletionAt}) }}</td>\n                    </tr>\n                {% endfor %}\n                </tbody>\n            </table>\n        </div>\n    {% else %}\n        <aside class=\"section section--muted\">\n            <p>{{ 'empty'|trans }}</p>\n        </aside>\n    {% endif %}\n    {% if(users.haveToPaginate is defined and users.haveToPaginate) %}\n        {{ pagerfanta(users, null, {'pageParameter':'[p]'}) }}\n    {% endif %}\n{% endblock %}\n"
  },
  {
    "path": "templates/admin/federation.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    {{- 'federation'|trans }} - {{ parent() -}}\n{%- endblock -%}\n\n{% block mainClass %}page-admin-federation page-settings{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n{% endblock %}\n\n{% block body %}\n    {% include 'admin/_options.html.twig' %}\n    <div id=\"content\">\n        <div class=\"section\">\n            <div class=\"container\">\n                {{ form_start(form) }}\n                <div class=\"checkbox\">\n                    {{ form_label(form.federationEnabled, 'federation_enabled') }}\n                    {{ form_widget(form.federationEnabled) }}\n                </div>\n                <div class=\"checkbox\">\n                    {{ form_label(form.federationPageEnabled, 'federation_page_enabled') }}\n                    {{ form_widget(form.federationPageEnabled) }}\n                </div>\n                <div class=\"checkbox\">\n                    {{ form_label(form.federationUsesAllowList, 'federation_uses_allowlist') }}\n                    {{ form_widget(form.federationUsesAllowList) }}\n                </div>\n                <div class=\"checkbox help-text\">\n                    {{ form_help(form.federationUsesAllowList) }}\n                </div>\n                <div class=\"actions\">\n                    {{ form_row(form.submit, { 'label': 'save'|trans, attr: {class: 'btn btn__primary'} }) }}\n                </div>\n                {{ form_end(form) }}\n            </div>\n        </div>\n        <div class=\"section\">\n            <div class=\"container\">\n                {% if useAllowList %}\n                    <form action=\"{{ url('admin_federation_allow_instance') }}\">\n                        <div class=\"row\">\n                            <label for=\"allow-instance-domain\">{{ 'allow_instance'|trans }}</label>\n                        </div>\n                        <div>\n                            <input id=\"allow-instance-domain\" type=\"text\" name=\"instanceDomain\" />\n                        </div>\n                        <div class=\"actions row\">\n                            <div>\n                                <button type=\"submit\" class=\"btn btn__primary\">{{ 'btn_allow'|trans }}</button>\n                            </div>\n                        </div>\n                    </form>\n                {% else %}\n                    <form action=\"{{ url('admin_federation_ban_instance') }}\">\n                        <div class=\"row\">\n                            <label for=\"ban-instance-domain\">{{ 'ban_instance'|trans }}</label>\n                        </div>\n                        <div>\n                            <input id=\"ban-instance-domain\" type=\"text\" name=\"instanceDomain\" />\n                        </div>\n                        <div class=\"actions row\">\n                            <div>\n                                <button type=\"submit\" class=\"btn btn__primary\">{{ 'ban'|trans }}</button>\n                            </div>\n                        </div>\n                    </form>\n                {% endif %}\n            </div>\n        </div>\n        <div class=\"section\">\n            <h3>\n                {% if useAllowList %}\n                    {{ 'allowed_instances'|trans }}\n                {% else %}\n                    {{ 'banned_instances'|trans }}\n                {% endif %}\n            </h3>\n\n            {{ component('instance_list', {'instances': instances, 'showDenyButton': useAllowList, 'showUnBanButton': not useAllowList}) }}\n        </div>\n        <div class=\"section\">\n            <h3>{{ 'instances'|trans }}</h3>\n\n            {{ component('instance_list', {\n                'instances': allInstances,\n                'showDenyButton': useAllowList,\n                'showUnBanButton': not useAllowList,\n                'showAllowButton': useAllowList,\n                'showBanButton': not useAllowList\n            }) }}\n        </div>\n    </div>\n{% endblock %}\n"
  },
  {
    "path": "templates/admin/federation_defederate_instance.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    {{- 'defederating_instance'|trans }} {{ instance.domain }} - {{ parent() -}}\n{%- endblock -%}\n\n{% block mainClass %}page-settings page-federation page-defederation{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n{% endblock %}\n\n{% block body %}\n    {% include 'admin/_options.html.twig' %}\n    {% include 'layout/_flash.html.twig' %}\n    <div class=\"section\">\n        <h3>{{ 'defederating_instance'|trans({'%i': instance.domain}) }}</h3>\n        <div class=\"container\">\n            <table>\n                <tbody>\n                    <tr>\n                        <td>{{ 'magazines'|trans }}</td>\n                        <td>{{ counts['magazines'] }}</td>\n                    </tr>\n                    <tr>\n                        <td>{{ 'users'|trans }}</td>\n                        <td>{{ counts['users'] }}</td>\n                    </tr>\n                    <tr>\n                        <td>{{ 'their_user_follows'|trans }}</td>\n                        <td>{{ counts['ourUserFollows'] }}</td>\n                    </tr>\n                    <tr>\n                        <td>{{ 'our_user_follows'|trans }}</td>\n                        <td>{{ counts['theirUserFollows'] }}</td>\n                    </tr>\n                    <tr>\n                        <td>{{ 'their_magazine_subscriptions'|trans }}</td>\n                        <td>{{ counts['ourSubscriptions'] }}</td>\n                    </tr>\n                    <tr>\n                        <td>{{ 'our_magazine_subscriptions'|trans }}</td>\n                        <td>{{ counts['theirSubscriptions'] }}</td>\n                    </tr>\n                </tbody>\n            </table>\n\n            {{ form_start(form) }}\n            {{ form_errors(form) }}\n                <div class=\"errors\">\n                    {{ form_errors(form.confirm) }}\n                </div>\n                <div class=\"checkbox\">\n                    {{ form_label(form.confirm, 'confirm_defederation') }}\n                    {{ form_widget(form.confirm) }}\n                </div>\n                <div class=\"actions\">\n                    {{ form_row(form.submit, { 'label': useAllowList ? 'btn_deny'|trans : 'ban'|trans, attr: {class: 'btn btn__primary'}}) }}\n                </div>\n            {{ form_end(form) }}\n        </div>\n    </div>\n{% endblock %}\n"
  },
  {
    "path": "templates/admin/magazine_ownership.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    {{- 'ownership_requests'|trans }} - {{ parent() -}}\n{%- endblock -%}\n\n{% block mainClass %}page-admin-ownership-requests{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n{% endblock %}\n\n{% block body %}\n    {% include 'admin/_options.html.twig' %}\n    {% if requests|length %}\n        <div class=\"section\" id=\"content\">\n            <table>\n                <thead>\n                <tr>\n                    <th>{{ 'magazine'|trans }}</th>\n                    <th>{{ 'user'|trans }}</th>\n                    <th>{{ 'reputation_points'|trans }}</th>\n                    <th>{{ 'action'|trans }}</th>\n                </tr>\n                </thead>\n                <tbody>\n                {% for request in requests %}\n                    <tr>\n                        <td>{{ component('magazine_inline', {magazine: request.magazine, showNewIcon: true}) }}</td>\n                        <td>{{ component('user_inline', {user: request.user, showNewIcon: true}) }}</td>\n                        <td>{{ get_reputation_total(request.user) }}</td>\n                        <td>\n                            <aside class=\"magazine__subscribe\">\n                                <form action=\"{{ path('admin_magazine_ownership_requests_accept', {name: request.magazine.name, username: request.user.username}) }}\"\n                                      name=\"ownership_requests_accept\"\n                                      data-action=\"confirmation#ask\" data-confirmation-message-param=\"{{ 'are_you_sure'|trans }}\"\n                                      method=\"post\">\n                                    <button type=\"submit\"\n                                            title=\"{{ 'accept'|trans }}\"\n                                            class=\"btn btn__secondary\">\n                                        {{ 'accept'|trans }}\n                                    </button>\n                                    <input type=\"hidden\" name=\"token\" value=\"{{ csrf_token('admin_magazine_ownership_requests_accept') }}\">\n                                </form>\n                                <form action=\"{{ path('admin_magazine_ownership_requests_reject', {name: request.magazine.name, username: request.user.username}) }}\"\n                                      name=\"ownership_requests_reject\"\n                                      data-action=\"confirmation#ask\" data-confirmation-message-param=\"{{ 'are_you_sure'|trans }}\"\n                                      method=\"post\">\n                                    <button type=\"submit\"\n                                            class=\"btn btn__secondary\"\n                                            title=\"{{ 'reject'|trans }}\">\n                                        <i class=\"fa-solid fa-ban\" aria-hidden=\"true\"></i>\n                                            <span>{{ 'reject'|trans }}</span>\n                                    </button>\n                                    <input type=\"hidden\" name=\"token\"\n                                           value=\"{{ csrf_token('admin_magazine_ownership_requests_reject') }}\">\n                                </form>\n                            </aside>\n                        </td>\n                    </tr>\n                {% endfor %}\n                </tbody>\n            </table>\n        </div>\n    {% else %}\n        <aside class=\"section section--muted\">\n            <p>{{ 'empty'|trans }}</p>\n        </aside>\n    {% endif %}\n    {% if(requests.haveToPaginate is defined and requests.haveToPaginate) %}\n        {{ pagerfanta(requests, null, {'pageParameter':'[p]'}) }}\n    {% endif %}\n{% endblock %}\n"
  },
  {
    "path": "templates/admin/moderators.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    {{- 'moderators'|trans }} - {{ parent() -}}\n{%- endblock -%}\n\n{% block mainClass %}page-magazine-panel page-magazine-moderators{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n{% endblock %}\n\n{% block body %}\n    {% include 'admin/_options.html.twig' %}\n    {% if moderators|length %}\n        <div id=\"content\" class=\"section users users-columns\">\n            <ul>\n                {% for moderator in moderators %}\n                    <li>\n                        {% if moderator.avatar %}\n                            {{ component('user_avatar', {user: moderator}) }}\n                        {% endif %}\n                        <div>\n                            <a href=\"{{ path('user_overview', {username: moderator.username}) }}\">\n                                {{ moderator.username|username(true) }}\n                            </a>\n                            <small>{{ component('date', {date: moderator.createdAt}) }}</small>\n                        </div>\n                        <div class=\"actions\">\n                            <form method=\"post\"\n                                  action=\"{{ path('admin_moderator_purge', {username: moderator.username}) }}\"\n                                  data-action=\"confirmation#ask\" data-confirmation-message-param=\"{{ 'are_you_sure'|trans }}\">\n                                <input type=\"hidden\" name=\"token\" value=\"{{ csrf_token('remove_moderator') }}\">\n                                <button type=\"submit\" class=\"btn btn__secondary\">{{ 'delete'|trans }}</button>\n                            </form>\n                        </div>\n                    </li>\n                {% endfor %}\n            </ul>\n        </div>\n        {% if(moderators.haveToPaginate is defined and moderators.haveToPaginate) %}\n            {{ pagerfanta(moderators, null, {'pageParameter':'[p]'}) }}\n        {% endif %}\n    {% else %}\n        <aside class=\"section section--muted\">\n            <p>{{ 'empty'|trans }}</p>\n        </aside>\n    {% endif %}\n    <div class=\"section moderator-add\">\n        <div class=\"container\">\n            {{ form_start(form) }}\n            <div class=\"row\">\n                {{ form_errors(form.user) }}\n            </div>\n            <div>\n                {{ form_label(form.user, 'username') }}\n                {{ form_widget(form.user) }}\n            </div>\n            <div class=\"actions row\">\n                {{ form_row(form.submit, { 'label': 'add_moderator', attr: {class: 'btn btn__primary'} }) }}\n            </div>\n            {{ form_end(form) }}\n        </div>\n    </div>\n{% endblock %}\n"
  },
  {
    "path": "templates/admin/monitoring/_monitoring_single_options.html.twig",
    "content": "<div class=\"pills\">\n    <menu>\n        <li>\n            <a href=\"{{ path('admin_monitoring') }}\">\n                <i class=\"fa fa-solid fa-arrow-left-long\" title=\"{{ 'back'|trans }}\"></i>\n            </a>\n        </li>\n        <li>\n            <a href=\"{{ path('admin_monitoring_single_context', {page: 'overview', id: context.uuid.toString()}) }}\"\n               class=\"{{ html_classes({'active': route_has_param('page', 'overview')}) }}\">\n                {{ 'overview'|trans }}\n            </a>\n        </li>\n        {% if context.queries|length %}\n            <li>\n                <a href=\"{{ path('admin_monitoring_single_context', {page: 'queries', id: context.uuid.toString()}) }}\"\n                   class=\"{{ html_classes({'active': route_has_param('page', 'queries')}) }}\">\n                    {{ 'monitoring_queries'|trans({'%count%': 2}) }}\n                </a>\n            </li>\n        {% endif %}\n        {% if context.twigRenders|length %}\n            <li>\n                <a href=\"{{ path('admin_monitoring_single_context', {page: 'twig', id: context.uuid.toString()}) }}\"\n                   class=\"{{ html_classes({'active': route_has_param('page', 'twig')}) }}\">\n                    {{ 'monitoring_twig_renders'|trans }}\n                </a>\n            </li>\n        {% endif %}\n        {% if context.curlRequests|length %}\n            <li>\n                <a href=\"{{ path('admin_monitoring_single_context', {page: 'requests', id: context.uuid.toString()}) }}\"\n                   class=\"{{ html_classes({'active': route_has_param('page', 'requests')}) }}\">\n                    {{ 'monitoring_curl_requests'|trans({'%count%': 2}) }}\n                </a>\n            </li>\n        {% endif %}\n    </menu>\n</div>\n"
  },
  {
    "path": "templates/admin/monitoring/_monitoring_single_overview.html.twig",
    "content": "{% set queryDuration = context.queryDurationMilliseconds %}\n{% set twigDuration = context.twigRenderDurationMilliseconds %}\n{% set curlRequestDuration = context.curlRequestDurationMilliseconds %}\n<table>\n    <tbody>\n    <tr><td>{{ 'monitoring_user_type'|trans }}</td><td>{{ context.userType }}</td></tr>\n    <tr><td>{{ 'monitoring_path'|trans }}</td><td>{{ context.path }}</td></tr>\n    <tr><td>{{ 'monitoring_handler'|trans }}</td><td>{{ context.handler }}</td></tr>\n    <tr><td>{{ 'monitoring_started'|trans }}</td><td>{{ component('date', {'date': context.startedAt}) }}</td></tr>\n    <tr><td>{{ 'monitoring_duration'|trans }}</td><td>{{ context.duration|round(2) }}ms</td></tr>\n    <tr><td>{{ 'monitoring_queries'|trans({'%count%': 2}) }}</td><td>{{ context.queries.count() }} in {{ queryDuration|round(2) }}ms ({{ (100 / context.duration * queryDuration)|round }}%)</td></tr>\n    <tr><td>{{ 'monitoring_twig_renders'|trans() }}</td><td>{{ context.twigRenders.count() }} in {{ twigDuration|round(2) }}ms ({{ (100 / context.duration * twigDuration)|round }}%)</td></tr>\n    <tr><td>{{ 'monitoring_curl_requests'|trans() }}</td><td>{{ context.curlRequests.count() }} in {{ curlRequestDuration|round(2) }}ms ({{ (100 / context.duration * curlRequestDuration)|round }}%)</td></tr>\n    <tr><td>{{ 'monitoring_duration_sending_response'|trans() }}</td><td>{{ context.responseSendingDurationMilliseconds|round(2) }}ms ({{ (100 / context.duration * context.responseSendingDurationMilliseconds)|round }}%)</td></tr>\n    </tbody>\n</table>\n"
  },
  {
    "path": "templates/admin/monitoring/_monitoring_single_queries.html.twig",
    "content": "{{ 'monitoring_queries'|trans({'%count%': 2}) }}\n<a href=\"{{ path('admin_monitoring_single_context', {'id': context.uuid.toString, 'page': page, 'groupSimilar': not groupSimilar, 'formatQuery': formatQuery, 'showParameters': showParameters}) }}\" class=\"btn btn-link\">\n    {% if groupSimilar %}\n        {{ 'monitoring_dont_group_similar'|trans }}\n    {% else %}\n        {{ 'monitoring_group_similar'|trans }}\n    {% endif %}\n</a>\n<a href=\"{{ path('admin_monitoring_single_context', {'id': context.uuid.toString, 'page': page, 'groupSimilar': groupSimilar, 'formatQuery': not formatQuery, 'showParameters': showParameters}) }}\" class=\"btn btn-link\">\n    {% if formatQuery %}\n        {{ 'monitoring_dont_format_query'|trans }}\n    {% else %}\n        {{ 'monitoring_format_query'|trans }}\n    {% endif %}\n</a>\n<a href=\"{{ path('admin_monitoring_single_context', {'id': context.uuid.toString, 'page': page, 'groupSimilar': groupSimilar, 'formatQuery': formatQuery, 'showParameters': not showParameters}) }}\" class=\"btn btn-link\">\n    {% if showParameters %}\n        {{ 'monitoring_dont_show_parameters'|trans }}\n    {% else %}\n        {{ 'monitoring_show_parameters'|trans }}\n    {% endif %}\n</a>\n\n<table>\n    <thead>\n    <tr>\n        <th>{{ 'monitoring_queries'|trans({'%count%': 1}) }}</th>\n        {% if groupSimilar is same as false %}\n            <th>{{ 'monitoring_duration'|trans }}</th>\n        {% else %}\n            <th>{{ 'monitoring_duration_min'|trans }}</th>\n            <th>{{ 'monitoring_duration_mean'|trans }}</th>\n            <th>{{ 'monitoring_duration_max'|trans }}</th>\n            <th>{{ 'monitoring_query_count'|trans }}</th>\n            <th>{{ 'monitoring_query_total'|trans }}</th>\n        {% endif %}\n    </tr>\n    </thead>\n    <tbody>\n    {% if groupSimilar is same as false %}\n        {% for query in context.getQueriesSorted() %}\n            <tr>\n                <td class=\"query\">\n                    <div data-controller=\"collapsable\">\n                        {% if formatQuery %}\n                            <pre data-collapsable-target=\"content\">{{ query.queryString.query|formatQuery }}</pre>\n                        {% else %}\n                            <div data-collapsable-target=\"content\">\n                                {{ query.queryString.query }}\n                            </div>\n                        {% endif %}\n                        <div data-collapsable-target=\"button\"></div>\n                    </div>\n                    {% if showParameters %}\n                        <div data-controller=\"collapsable\">\n                            <pre data-collapsable-target=\"content\">{{ 'parameters'|trans }} = {{ query.parameters|json_encode(constant('JSON_PRETTY_PRINT')) }}</pre>\n                        </div>\n                        <div data-collapsable-target=\"button\"></div>\n                    {% endif %}\n                </td>\n                <td>{{ query.duration|round(2) }}ms</td>\n            </tr>\n        {% endfor %}\n    {% else %}\n        {% for query in context.getGroupedQueries() %}\n            <tr>\n                <td class=\"query\">\n                    <div data-controller=\"collapsable\">\n                        {% if formatQuery %}\n                            <pre data-collapsable-target=\"content\" class=\"content\">{{ query.query|formatQuery }}</pre>\n                        {% else %}\n                            <div data-collapsable-target=\"content\" class=\"content\">{{ query.query }}</div>\n                        {% endif %}\n                        <div data-collapsable-target=\"button\"></div>\n                    </div>\n                </td>\n                <td>{{ query.minExecutionTime|round(2) }}ms</td>\n                <td>{{ query.maxExecutionTime|round(2) }}ms</td>\n                <td>{{ query.meanExecutionTime|round(2) }}ms</td>\n                <td>{{ query.count }}</td>\n                <td>{{ query.totalExecutionTime|round(2) }}ms</td>\n            </tr>\n        {% endfor %}\n    {% endif %}\n    </tbody>\n</table>\n"
  },
  {
    "path": "templates/admin/monitoring/_monitoring_single_requests.html.twig",
    "content": "<table>\n    <thead>\n    <tr>\n        <th>{{ 'monitoring_http_method'|trans }}</th>\n        <th>{{ 'monitoring_url'|trans }}</th>\n        <th>{{ 'monitoring_request_successful'|trans }}</th>\n        <th>{{ 'monitoring_duration'|trans }}</th>\n    </tr>\n    </thead>\n    <tbody>\n    {% for request in context.getRequestsSorted() %}\n        <tr>\n            <td>{{ request.method }}</td>\n            <td>{{ request.url }}</td>\n            <td>{{ request.wasSuccessful ? 'yes'|trans : 'no'|trans }}</td>\n            <td>{{ request.duration|round(2) }}ms</td>\n        </tr>\n    {% endfor %}\n    </tbody>\n</table>\n"
  },
  {
    "path": "templates/admin/monitoring/_monitoring_single_twig.html.twig",
    "content": "<a href=\"{{ path('admin_monitoring_single_context', {'id': context.uuid.toString, 'page': page, 'compareToParent': not compareToParent}) }}\" class=\"btn btn-link\">\n    {% if compareToParent %}\n        {{ 'monitoring_twig_compare_to_total'|trans }}\n    {% else %}\n        {{ 'monitoring_twig_compare_to_parent'|trans }}\n    {% endif %}\n</a>\n{% for render in context.getRootTwigRenders() %}\n    {{ component('monitoring_twig_render', {'render': render, 'compareToParent': compareToParent}) }}\n{% endfor %}\n"
  },
  {
    "path": "templates/admin/monitoring/monitoring.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    {{- 'pages'|trans }} - {{ parent() -}}\n{%- endblock -%}\n\n{% block mainClass %}page-admin-monitoring{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n{% endblock %}\n\n{% block body %}\n    {% include 'admin/_options.html.twig' %}\n\n    <div class=\"section\" id=\"content\">\n        <div class=\"container\">\n            {% if chart is not same as null %}\n                <h2>{{ 'monitoring_route_overview'|trans }}</h2>\n                <p>\n                    {% if configuration['monitoringEnabled'] is not same as true %}\n                        <span>{{ 'monitoring_disabled'|trans }}</span>\n                    {% else %}\n                        {% if configuration['monitoringQueriesEnabled'] is same as true %}\n                            {% if configuration['monitoringQueriesPersistingEnabled'] is same as true %}\n                                <span>{{ 'monitoring_queries_enabled_persisted'|trans }}</span>\n                            {% else %}\n                                <span>{{ 'monitoring_queries_enabled_not_persisted'|trans }}</span>\n                            {% endif %}\n                        {% else %}\n                            <span>{{ 'monitoring_queries_disabled'|trans }}</span>\n                        {% endif %}\n                        {% if configuration['monitoringTwigRendersEnabled'] is same as true %}\n                            {% if configuration['monitoringTwigRendersPersistingEnabled'] is same as true %}\n                                <span>{{ 'monitoring_twig_renders_enabled_persisted'|trans }}</span>\n                            {% else %}\n                                <span>{{ 'monitoring_twig_renders_enabled_not_persisted'|trans }}</span>\n                            {% endif %}\n                        {% else %}\n                            <span>{{ 'monitoring_twig_renders_disabled'|trans }}</span>\n                        {% endif %}\n                        {% if configuration['monitoringCurlRequestsEnabled'] is same as true %}\n                            {% if configuration['monitoringCurlRequestPersistingEnabled'] is same as true %}\n                                <span>{{ 'monitoring_curl_requests_enabled_persisted'|trans }}</span>\n                            {% else %}\n                                <span>{{ 'monitoring_curl_requests_enabled_not_persisted'|trans }}</span>\n                            {% endif %}\n                        {% else %}\n                            <span>{{ 'monitoring_curl_requests_disabled'|trans }}</span>\n                        {% endif %}\n                    {% endif %}\n                </p>\n                <p>{{ 'monitoring_route_overview_description'|trans }}</p>\n                {{ render_chart(chart, {'data-chart-unit-value': 'ms'}) }}\n            {% endif %}\n            {{ form_start(form) }}\n            <div class=\"row\">\n                <div class=\"col\">\n                    {{ form_row(form.executionType) }}\n                </div>\n                <div class=\"col\">\n                    {{ form_row(form.userType) }}\n                </div>\n                <div class=\"col\">\n                    {{ form_row(form.path) }}\n                </div>\n                <div class=\"col\">\n                    {{ form_row(form.handler) }}\n                </div>\n            </div>\n            <div class=\"row\">\n                <div class=\"col\">\n                    {{ form_row(form.durationMinimum) }}\n                </div>\n                <div class=\"col\">\n                    {{ form_row(form.hasException) }}\n                </div>\n                <div class=\"col\">\n                    {{ form_row(form.createdFrom) }}\n                </div>\n                <div class=\"col\">\n                    {{ form_row(form.createdTo) }}\n                </div>\n            </div>\n            <div class=\"row\">\n                <div class=\"col\">\n                    {{ form_row(form.chartOrdering) }}\n                </div>\n                <div class=\"col\">\n                </div>\n                <div class=\"col\">\n                </div>\n                <div class=\"col btn-col\">\n                    {{ form_row(form.submit, {'attr': {'class': 'btn btn__primary'}}) }}\n                </div>\n            </div>\n            {{ form_end(form) }}\n            <table>\n                <thead>\n                    <tr>\n                        <th></th>\n                        <th>{{ 'monitoring_user_type'|trans }}</th>\n                        <th>{{ 'monitoring_path'|trans }}</th>\n                        <th>{{ 'monitoring_handler'|trans }}</th>\n                        <th>{{ 'monitoring_started'|trans }}</th>\n                        <th>{{ 'monitoring_duration'|trans }}</th>\n                    </tr>\n                </thead>\n                <tbody>\n                    {% for context in executionContexts %}\n                        <tr>\n                            <td>\n                                <a style=\"word-wrap: normal\" href=\"{{ path('admin_monitoring_single_context', {'id': context.uuid.__toString()}) }}\">\n                                    {{ context.uuid|uuidEnd }}\n                                </a>\n                            </td>\n                            {% if context.executionType is same as 'messenger' %}\n                                <td>messenger</td>\n                            {% else %}\n                                <td>{{ context.userType }}</td>\n                            {% endif %}\n                            <td>{{ context.path }}</td>\n                            <td>{{ context.handler }}</td>\n                            <td>{{ context.startedAt|date }}</td>\n                            <td>{{ context.duration|round(2) }}ms</td>\n                        </tr>\n                    {% endfor %}\n                </tbody>\n            </table>\n        </div>\n    </div>\n\n    {% if(executionContexts.haveToPaginate is defined and executionContexts.haveToPaginate) %}\n        {{ pagerfanta(executionContexts, null, {'pageParameter':'[p]'}) }}\n    {% endif %}\n\n{% endblock %}\n"
  },
  {
    "path": "templates/admin/monitoring/monitoring_single.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    {{- 'pages'|trans }} - {{ parent() -}}\n{%- endblock -%}\n\n{% block mainClass %}page-admin-monitoring{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n{% endblock %}\n\n{% block body %}\n    {% include 'admin/_options.html.twig' %}\n    {% include 'admin/monitoring/_monitoring_single_options.html.twig' %}\n\n    <div class=\"section\" id=\"content\">\n        <div class=\"container\">\n            {% if page is same as 'overview' %}\n                {{ include('admin/monitoring/_monitoring_single_overview.html.twig') }}\n            {% elseif page is same as 'queries' %}\n                {{ include('admin/monitoring/_monitoring_single_queries.html.twig') }}\n            {% elseif page is same as 'twig' %}\n                {{ include('admin/monitoring/_monitoring_single_twig.html.twig') }}\n            {% elseif page is same as 'requests' %}\n                {{ include('admin/monitoring/_monitoring_single_requests.html.twig') }}\n            {% endif %}\n        </div>\n    </div>\n\n{% endblock %}\n"
  },
  {
    "path": "templates/admin/pages.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    {{- 'pages'|trans }} - {{ parent() -}}\n{%- endblock -%}\n\n{% block mainClass %}page-admin-settings page-settings{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n{% endblock %}\n\n{% block body %}\n    {% include 'admin/_options.html.twig' %}\n    <div class=\"pills\">\n        <menu>\n            <li>\n                <a href=\"{{ path('admin_pages', {page: 'announcement'}) }}\"\n                   class=\"{{ html_classes({'active': route_has_param('page', 'announcement')}) }}\">\n                    {{ 'announcement'|trans }}\n                </a>\n            </li>\n            <li>\n                <a href=\"{{ path('admin_pages', {page: 'about'}) }}\"\n                   class=\"{{ html_classes({'active': route_has_param('page', 'about')}) }}\">\n                    {{ 'about_instance'|trans }}\n                </a>\n            </li>\n            <li>\n                <a href=\"{{ path('admin_pages', {page: 'faq'}) }}\"\n                   class=\"{{ html_classes({'active': route_has_param('page', 'faq')}) }}\">\n                    {{ 'FAQ'|trans }}\n                </a>\n            </li>\n            <li>\n                <a href=\"{{ path('admin_pages', {page: 'contact'}) }}\"\n                   class=\"{{ html_classes({'active': route_has_param('page', 'contact')}) }}\">\n                    {{ 'contact'|trans }}\n                </a>\n            </li>\n            <li>\n                <a href=\"{{ path('admin_pages', {page: 'terms'}) }}\"\n                   class=\"{{ html_classes({'active': route_has_param('page', 'terms')}) }}\">\n                    {{ 'terms'|trans }}\n                </a>\n            </li>\n            <li>\n                <a href=\"{{ path('admin_pages', {page: 'privacyPolicy'}) }}\"\n                   class=\"{{ html_classes({'active': route_has_param('page', 'privacyPolicy')}) }}\">\n                    {{ 'privacy_policy'|trans }}\n                </a>\n            </li>\n        </menu>\n    </div>\n    <div class=\"section\" id=\"content\">\n        <div class=\"container\">\n            {{ form_start(form) }}\n            {{ component('editor_toolbar', {id: 'page_body'}) }}\n            {{ form_row(form.body, {label: false, attr: {placeholder: 'body', 'data-controller': 'rich-textarea autogrow', 'data-entry-link-create-target': 'admin_pages'}}) }}\n            <div class=\"row actions\">\n                {{ form_row(form.submit, {label: 'save', attr: {class: 'btn btn__primary'}}) }}\n            </div>\n            {{ form_end(form) }}\n        </div>\n    </div>\n{% endblock %}\n"
  },
  {
    "path": "templates/admin/reports.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    {{- 'reports'|trans }} - {{ parent() -}}\n{%- endblock -%}\n\n{% block mainClass %}page-admin-federation{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n{% endblock %}\n\n{% block body %}\n    {# global mods can see this page, but not navigate to any other menu option, so hiding it for now #}\n    {% if is_granted('ROLE_ADMIN') %}\n        {% include 'admin/_options.html.twig' %}\n    {% endif %}\n    {{ component('report_list', {reports: reports}) }}\n{% endblock %}\n"
  },
  {
    "path": "templates/admin/settings.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    {{- 'settings'|trans }} - {{ parent() -}}\n{%- endblock -%}\n\n{% block mainClass %}page-admin-settings page-settings{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n{% endblock %}\n\n{% block body %}\n    {% include 'admin/_options.html.twig' %}\n    <div class=\"section\" id=\"content\">\n        <div class=\"container\">\n            <h1 hidden>{{ 'settings'|trans }}</h1>\n            {{ form_start(form) }}\n            <h2>{{ 'general'|trans }}</h2>\n            {{ form_row(form.KBIN_DOMAIN, {label: 'domain'}) }}\n            {{ form_row(form.KBIN_CONTACT_EMAIL, {label: 'contact_email'}) }}\n            <div class=\"select-flex\">\n                {{ form_label(form.MBIN_DEFAULT_THEME, 'default_theme') }}\n                {{ form_widget(form.MBIN_DEFAULT_THEME, {attr: {'aria-label': 'change_theme'|trans}}) }}\n            </div>\n            <h2>{{ 'meta'|trans }}</h2>\n            {{ form_row(form.KBIN_META_TITLE, {label: 'title'}) }}\n            {{ form_row(form.KBIN_META_DESCRIPTION, {label: 'description'}) }}\n            {{ form_row(form.KBIN_META_KEYWORDS, {label: 'keywords'}) }}\n            <h2>{{ 'instance'|trans }}</h2>\n            {{ form_row(form.KBIN_TITLE, {label: 'title'}) }}\n            <div class=\"select-flex\">\n                {{ form_label(form.MBIN_DOWNVOTES_MODE, 'downvotes_mode') }}\n                {{ form_widget(form.MBIN_DOWNVOTES_MODE, {attr: {'aria-label': 'change_downvotes_mode'|trans}}) }}\n            </div>\n            <div class=\"checkbox\">\n                {{ form_label(form.KBIN_HEADER_LOGO, 'header_logo') }}\n                {{ form_widget(form.KBIN_HEADER_LOGO) }}\n            </div>\n            <div class=\"checkbox\">\n                {{ form_label(form.KBIN_REGISTRATIONS_ENABLED, 'registrations_enabled') }}\n                {{ form_widget(form.KBIN_REGISTRATIONS_ENABLED) }}\n            </div>\n            <div class=\"checkbox\">\n                {{ form_label(form.MBIN_SSO_REGISTRATIONS_ENABLED, 'sso_registrations_enabled') }}\n                {{ form_widget(form.MBIN_SSO_REGISTRATIONS_ENABLED) }}\n            </div>\n            <div class=\"checkbox\">\n                {{ form_label(form.MBIN_SSO_ONLY_MODE, 'sso_only_mode') }}\n                {{ form_widget(form.MBIN_SSO_ONLY_MODE) }}\n            </div>\n            <div class=\"checkbox\">\n                {{ form_label(form.KBIN_CAPTCHA_ENABLED, 'captcha_enabled') }}\n                {{ form_widget(form.KBIN_CAPTCHA_ENABLED) }}\n            </div>\n            <div class=\"checkbox\">\n                {{ form_label(form.KBIN_MERCURE_ENABLED, 'mercure_enabled') }}\n                {{ form_widget(form.KBIN_MERCURE_ENABLED) }}\n            </div>\n            <div class=\"checkbox\">\n                {{ form_label(form.KBIN_ADMIN_ONLY_OAUTH_CLIENTS, 'restrict_oauth_clients') }}\n                {{ form_widget(form.KBIN_ADMIN_ONLY_OAUTH_CLIENTS) }}\n            </div>\n            <div class=\"checkbox\">\n                {{ form_label(form.MBIN_PRIVATE_INSTANCE, 'private_instance') }}\n                {{ form_widget(form.MBIN_PRIVATE_INSTANCE) }}\n            </div>\n            <div class=\"checkbox\">\n                {{ form_label(form.KBIN_FEDERATED_SEARCH_ONLY_LOGGEDIN, 'federated_search_only_loggedin') }}\n                {{ form_widget(form.KBIN_FEDERATED_SEARCH_ONLY_LOGGEDIN) }}\n            </div>\n            <div class=\"checkbox\">\n                {{ form_label(form.MBIN_SIDEBAR_SECTIONS_RANDOM_LOCAL_ONLY, 'sidebar_sections_random_local_only') }}\n                {{ form_widget(form.MBIN_SIDEBAR_SECTIONS_RANDOM_LOCAL_ONLY) }}\n            </div>\n            {% if form.MBIN_SIDEBAR_SECTIONS_RANDOM_LOCAL_ONLY.vars.errors|length > 0 %}\n                <div class=\"alert__info section section-sm\">\n                    {% for error in form.MBIN_SIDEBAR_SECTIONS_RANDOM_LOCAL_ONLY.vars.errors %}\n                        {{ error.message }}\n                    {% endfor %}\n                </div>\n            {% endif %}\n            <div class=\"checkbox\">\n                {{ form_label(form.MBIN_SIDEBAR_SECTIONS_USERS_LOCAL_ONLY, 'sidebar_sections_users_local_only') }}\n                {{ form_widget(form.MBIN_SIDEBAR_SECTIONS_USERS_LOCAL_ONLY) }}\n            </div>\n            <div class=\"checkbox\">\n                {{ form_label(form.MBIN_RESTRICT_MAGAZINE_CREATION, 'restrict_magazine_creation') }}\n                {{ form_widget(form.MBIN_RESTRICT_MAGAZINE_CREATION) }}\n            </div>\n            <div class=\"checkbox\">\n                {{ form_label(form.MBIN_SSO_SHOW_FIRST, 'sso_show_first') }}\n                {{ form_widget(form.MBIN_SSO_SHOW_FIRST) }}\n            </div>\n            <div class=\"checkbox\">\n                {{ form_label(form.MBIN_NEW_USERS_NEED_APPROVAL, 'new_users_need_approval') }}\n                {{ form_widget(form.MBIN_NEW_USERS_NEED_APPROVAL) }}\n            </div>\n            <div class=\"row actions\">\n                {{ form_row(form.submit, {label: 'save', attr: {class: 'btn btn__primary'}}) }}\n            </div>\n            {{ form_end(form) }}\n        </div>\n    </div>\n{% endblock %}\n"
  },
  {
    "path": "templates/admin/signup_requests.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    {{- 'signup_requests'|trans }} - {{ parent() -}}\n{%- endblock -%}\n\n{% block mainClass %}page-admin-federation{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n{% endblock %}\n\n{% block body %}\n    {# global mods can see this page, but not navigate to any other menu option, so hiding it for now #}\n    {% if is_granted('ROLE_ADMIN') %}\n        {% include 'admin/_options.html.twig' %}\n    {% endif %}\n    <div class=\"section\">\n        <h3>{{ 'signup_requests_header'|trans }}</h3>\n        <p>{{ 'signup_requests_paragraph'|trans }}</p>\n    </div>\n    {% if username is defined and username is not same as null %}\n        <div class=\"alert alert__info\">\n            <p>{{ 'viewing_one_signup_request'|trans({'%username%': username}) }}</p>\n            <p><a href=\"{{ path('admin_signup_requests') }}\"><i class=\"fa-solid fa-arrow-left\" aria-hidden=\"true\"></i> {{ 'return'|trans }}</a></p>\n        </div>\n    {% endif %}\n    {% if requests|length %}\n        {% for request in requests %}\n            <div class=\"section\">\n                <div>\n                    <small class=\"meta\">{{ component('user_inline', {user: request, showNewIcon: true}) }},\n                        {{ component('date', {date: request.createdAt}) }}</small>\n                </div>\n                <div>\n                    {{ request.applicationText }}\n                </div>\n                <div class=\"actions\">\n                    <form method=\"post\"\n                          action=\"{{ path('admin_signup_requests_reject', { page: page, id: request.id }) }}\"\n                          data-action=\"confirmation#ask\" data-confirmation-message-param=\"{{ 'are_you_sure'|trans }}\">\n                        <input type=\"hidden\" name=\"token\" value=\"{{ csrf_token('application_deny') }}\">\n                        <button type=\"submit\" class=\"btn btn__secondary\">{{ 'reject'|trans }}</button>\n                    </form>\n                    <form method=\"post\"\n                          action=\"{{ path('admin_signup_requests_approve', { page: page, id: request.id }) }}\"\n                          data-action=\"confirmation#ask\" data-confirmation-message-param=\"{{ 'are_you_sure'|trans }}\">\n                        <input type=\"hidden\" name=\"token\" value=\"{{ csrf_token('application_approve') }}\">\n                        <button type=\"submit\" class=\"btn btn__secondary\">{{ 'approve'|trans }}</button>\n                    </form>\n                </div>\n            </div>\n        {% endfor %}\n    {% else %}\n        <aside class=\"section section--muted\">\n            <p>{{ 'empty'|trans }}</p>\n        </aside>\n    {% endif %}\n{% endblock %}\n"
  },
  {
    "path": "templates/admin/users.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    {{- 'users'|trans }} - {{ parent() -}}\n{%- endblock -%}\n\n{% block mainClass %}page-admin-users{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n{% endblock %}\n\n{% block body %}\n    {% include 'admin/_options.html.twig' %}\n\n    <div class=\"pills\">\n        <menu>\n            <li>\n                <a href=\"{{ path('admin_users_active') }}\"\n                    class=\"{{ html_classes({'active': is_route_name('admin_users_active')}) }}\">\n                    {{ 'admin_users_active'|trans }}\n                </a>\n            </li>\n            <li>\n                <a href=\"{{ path('admin_users_inactive') }}\"\n                    class=\"{{ html_classes({'active': is_route_name('admin_users_inactive')}) }}\">\n                    {{ 'admin_users_inactive'|trans }}\n                </a>\n            </li>\n            <li>\n                <a href=\"{{ path('admin_users_suspended') }}\"\n                    class=\"{{ html_classes({'active': is_route_name('admin_users_suspended')}) }}\">\n                    {{ 'admin_users_suspended'|trans }}\n                </a>\n            </li>\n            <li>\n                <a href=\"{{ path('admin_users_banned') }}\"\n                    class=\"{{ html_classes({'active': is_route_name('admin_users_banned')}) }}\">\n                    {{ 'admin_users_banned'|trans }}\n                </a>\n            </li>\n        </menu>\n    </div>\n\n    {% if(users.haveToPaginate is defined and users.haveToPaginate) %}\n        {{ pagerfanta(users, null, {'pageParameter':'[p]'}) }}\n    {% endif %}\n    <div class=\"section\" id=\"content\">\n        <div class=\"flex\">\n            <div class=\"\">\n                {% if searchTerm is defined %}\n                    <form action=\"{{ options_url('search', '') }}\">\n                        <label for=\"adminUserSearch\" class=\"hidden\">{{ 'search'|trans }}</label>\n                        <div class=\"search-container flex\" style=\"align-items: center\">\n                            <input id=\"adminUserSearch\" class=\"form-control\" type=\"text\" placeholder=\"{{ 'search'|trans }}\" name=\"search\" value=\"{{ searchTerm }}\">\n\n                            <button class=\"btn btn__primary ignore-edges small\" type=\"submit\" title=\"{{ 'search'|trans }}\" aria-label=\"{{ 'search'|trans }}\">\n                                <i class=\"fa-solid fa-magnifying-glass\" aria-hidden=\"true\"></i>\n                            </button>\n                        </div>\n\n                    </form>\n                {% endif %}\n            </div>\n            <div class=\"\">\n                {% if withFederated is defined %}\n                    <div class=\"flex\" data-controller=\"selection\">\n                        <label class=\"select\">\n                            <select data-action=\"selection#changeLocation\">\"\n                                <option value=\"{{ options_url('withFederated', false) }}\">{{ 'local'|trans }}</option>\n                                <option value=\"{{ options_url('withFederated', true) }}\" {{ withFederated is same as true ? 'selected' : '' }}>{{ 'federated'|trans }}</option>\n                            </select>\n                        </label>\n                    </div>\n                {% endif %}\n            </div>\n        </div>\n\n        {% if not users|length %}\n            <aside class=\"section section--muted\">\n                <p>{{ 'empty'|trans }}</p>\n            </aside>\n        {% else %}\n            <table>\n                <thead>\n                <tr>\n                    {% for name, field in {'username': 'username', 'email': 'email', 'created_at': 'createdAt', 'last_active': 'lastActive'} %}\n                        <th>\n                            <a\n                                {% if sortField is defined and sortField is same as field and order is not same as 'DESC' %}\n                                    href=\"{{ options_url('sort', 'DESC') }}\"\n                                {% elseif sortField is defined and sortField is same as field and order is same as 'DESC' %}\n                                    href=\"{{ options_url('sort', 'ASC') }}\"\n                                {% elseif sortField is defined %}\n                                    href=\"{{ options_url('field', field) }}\"\n                                {% else %}\n                                    href=\"#\"\n                                {% endif %}\n                            >\n                                {{ name|trans }}\n                                {% if sortField is defined and field is same as sortField %}\n                                    {% if order is defined and order is same as 'ASC' %}\n                                        <i class=\"fa-solid fa-sort-amount-asc\" aria-hidden=\"true\"></i>\n                                    {% elseif order is defined and order is same as 'DESC' %}\n                                        <i class=\"fa-solid fa-sort-amount-desc\" aria-hidden=\"true\"></i>\n                                    {% endif %}\n                                {% endif %}\n                            </a>\n                        </th>\n                    {% endfor %}\n                    {% if attitudes is defined %}\n                        <th>{{ 'attitude'|trans }}</th>\n                    {% endif %}\n                </tr>\n                </thead>\n                <tbody>\n                {% for user in users %}\n                    <tr>\n                        <td>{{ component('user_inline', {user: user, showNewIcon: true}) }}</td>\n                        <td>{{ user.apId ? '-' : user.email }}</td>\n                        <td>{{ component('date', {date: user.createdAt}) }}</td>\n                        <td>{{ component('date', {date: user.lastActive}) }}</td>\n                        {% if attitudes is defined %}\n                            <td>\n                                {% if attitudes[user.id] is defined %}\n                                    {% set attitude = attitudes[user.id] %}\n                                    {% if attitude < 0 %}\n                                        -\n                                    {% else %}\n                                        {{ attitudes[user.id]|number_format(2) }}%\n                                    {% endif %}\n                                {% else %}\n                                    -\n                                {% endif %}\n                            </td>\n                        {% endif %}\n                    </tr>\n                {% endfor %}\n                </tbody>\n            </table>\n        {% endif %}\n    </div>\n    {% if(users.haveToPaginate is defined and users.haveToPaginate) %}\n        {{ pagerfanta(users, null, {'pageParameter':'[p]'}) }}\n    {% endif %}\n{% endblock %}\n"
  },
  {
    "path": "templates/base.html.twig",
    "content": "{%- set V_TRUE = constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::TRUE') -%}\n{%- set V_FALSE = constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::FALSE') -%}\n{%- set V_LEFT = constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::LEFT') -%}\n{%- set V_RIGHT = constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::RIGHT') -%}\n{%- set V_FIXED = constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::FIXED') -%}\n{%- set V_LAST_ACTIVE = constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::LAST_ACTIVE') -%}\n\n{%- set FONT_SIZE = app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_FONT_SIZE'), '100') -%}\n{%- set THEME = app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_THEME'), mbin_default_theme()) -%}\n{%- set PAGE_WIDTH = app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_PAGE_WIDTH'), V_FIXED) -%}\n{%- set ROUNDED_EDGES = app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_GENERAL_ROUNDED_EDGES'), V_TRUE) -%}\n{%- set TOPBAR = app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_GENERAL_TOPBAR'), V_FALSE) -%}\n{%- set FIXED_NAVBAR = app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_GENERAL_FIXED_NAVBAR'), V_FALSE) -%}\n{%- set SIDEBAR_POSITION = app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_GENERAL_SIDEBAR_POSITION'), V_RIGHT) -%}\n{%- set COMPACT = app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_ENTRIES_COMPACT'), V_FALSE) -%}\n\n{%- set SUBSCRIPTIONS_SHOW = app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_SUBSCRIPTIONS_SHOW'), V_TRUE) -%}\n{%- set SUBSCRIPTIONS_SEPARATE = app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_SUBSCRIPTIONS_IN_SEPARATE_SIDEBAR'), V_FALSE) -%}\n{%- set SUBSCRIPTIONS_SAME_SIDE = app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_SUBSCRIPTIONS_SIDEBARS_SAME_SIDE'), V_FALSE) -%}\n{%- set SUBSCRIPTIONS_SORT = app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_SUBSCRIPTIONS_SORT'), V_LAST_ACTIVE) -%}\n<!DOCTYPE html>\n<html lang=\"{{ app.request.locale }}\"\n      style=\"font-size: {{ FONT_SIZE~'%' }}\">\n<head>\n    <meta charset=\"UTF-8\">\n    <title>{%- block title -%}{{ kbin_meta_title() }}{%- endblock -%}</title>\n\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n    <meta name=\"keywords\" content=\"{{ kbin_meta_keywords() }}\">\n    <meta name=\"description\" content=\"{% block description %}{{ kbin_meta_description() }}{% endblock %}\">\n\n    <meta property=\"og:url\" content=\"{{ app.request.uri }}\">\n    <meta property=\"og:type\" content=\"article\">\n    <meta property=\"og:title\" content=\"{{ block('title')|trim }}\">\n    <meta property=\"og:description\" content=\"{{ block('description')|trim }}\">\n    <meta property=\"og:image\" content=\"{% block image %}{{ absolute_url(asset('mbin-og.png')) }}{% endblock %}\">\n\n    {% if magazine is defined and magazine and magazine.apIndexable is same as false %}\n        <meta name=\"robots\" content=\"noindex\">\n    {% elseif user is defined and user and user.apIndexable is same as false %}\n        <meta name=\"robots\" content=\"noindex\">\n    {% elseif entry is defined and entry and entry.user and entry.user.apIndexable is same as false %}\n        <meta name=\"robots\" content=\"noindex\">\n    {% elseif post is defined and post and post.user and post.user.apIndexable is same as false %}\n        <meta name=\"robots\" content=\"noindex\">\n    {% endif %}\n\n    <link rel=\"icon\" href=\"{{ asset('favicon.ico') }}\" sizes=\"any\">\n    <link rel=\"apple-touch-icon\" href=\"{{ asset('assets/icons/apple-touch-icon.png') }}\">\n    <link rel=\"icon\" href=\"{{ asset('favicon.svg') }}\" type=\"image/svg+xml\">\n    {% if kbin_header_logo() %}\n    <link rel=\"preload\" href=\"{{ asset('mbin_logo.svg') }}\" as=\"image\">\n    {% endif %}\n\n    <link rel=\"manifest\" href=\"{{ asset('manifest.json') }}\"/>\n\n    {% block stylesheets %}\n        {{ encore_entry_link_tags('app') }}\n        <link rel=\"stylesheet\" href=\"{{ path('custom_style', {magazine: magazine.name|default(null)}) }}\">\n    {% endblock %}\n\n    {% block javascripts %}\n        {{ encore_entry_script_tags('app') }}\n    {% endblock %}\n</head>\n<body class=\"{{ html_classes('theme--'~THEME, {\n        'rounded-edges': ROUNDED_EDGES is same as V_TRUE,\n        'topbar': TOPBAR is same as V_TRUE,\n        'fixed-navbar': FIXED_NAVBAR is same as V_TRUE,\n        'sidebar-left': SIDEBAR_POSITION is same as V_LEFT,\n        'subs-show': app.user is defined and app.user is not same as null and SUBSCRIPTIONS_SHOW is not same as V_FALSE and SUBSCRIPTIONS_SEPARATE is same as V_TRUE,\n        'sidebars-same-side': app.user is defined and app.user is not same as null and SUBSCRIPTIONS_SEPARATE is same as V_TRUE and SUBSCRIPTIONS_SAME_SIDE is same as V_TRUE,\n    }) }}\"\n        data-controller=\"mbin notifications\"\n        data-notifications-endpoint-value=\"{{ kbin_mercure_enabled() ? 'https://'~kbin_domain()~'/.well-known/mercure' : null }}\"\n        data-notifications-user-value=\"{{ app.user ? app.user.id : null }}\"\n        data-notifications-magazine-value=\"{{ magazine is defined and magazine ? magazine.id : null }}\"\n        data-notifications-entry-id-value=\"{{ entry is defined and entry ? entry.id : null }}\"\n        data-notifications-post-id-value=\"{{ post is defined and post ? post.id : null }}\"\n    >\n{% include 'layout/_header.html.twig' with {header_nav: block('header_nav')} %}\n{{ component('announcement') }}\n<div id=\"middle\" class=\"{%- block mainClass -%}page{%- endblock %}\">\n    <div class=\"mbin-container {{ html_classes('width--'~PAGE_WIDTH) }}\">\n        <main id=\"main\"\n              data-controller=\"lightbox timeago confirmation\"\n              class=\"{{ html_classes({'view-compact': COMPACT is same as V_TRUE}) }}\">\n            {% block body %}{% endblock %}\n        </main>\n        {% if not mbin_private_instance() or (mbin_private_instance() and app.user is defined and app.user is not same as null) %}\n            <aside id=\"sidebar\">\n                {% block sidebar %}\n                    {% include 'layout/_sidebar.html.twig' with {sidebar_top: block('sidebar_top'), header_nav: block('header_nav')} %}\n                {% endblock %}\n            </aside>\n        {% endif %}\n        {% if app.user is defined and app.user is not same as null and\n            SUBSCRIPTIONS_SHOW is not same as V_FALSE and\n            SUBSCRIPTIONS_SEPARATE is same as V_TRUE\n        %}\n            {{ component('sidebar_subscriptions', { openMagazine: magazine is defined ? magazine : null, user: app.user, sort: SUBSCRIPTIONS_SORT }) }}\n        {% endif %}\n    </div>\n</div>\n\n{% include 'layout/_topbar.html.twig' %}\n<div id=\"popover\" class=\"popover js-popover section section--small\" role=\"dialog\"></div>\n<div id=\"scroll-top\" data-controller=\"scroll-top\" style=\"display: none;\" data-action=\"click->scroll-top#scrollTop\" aria-hidden=\"true\">\n    <i class=\"fa-solid fa-arrow-up\" aria-hidden=\"true\"></i> <small class=\"hidden\">0</small>\n</div>\n</body>\n</html>\n"
  },
  {
    "path": "templates/bookmark/_form_edit.html.twig",
    "content": "{{ form_start(form, {attr: {class: 'bookmark_edit'}}) }}\n\n{{ form_row(form.name, {label: 'bookmark_list_create_label'}) }}\n\n<div class=\"row actions\">\n    {{ form_row(form.isDefault, {label: 'bookmark_list_make_default', row_attr: {class: 'checkbox'}}) }}\n</div>\n\n<div class=\"row params\">\n    {% set btn_label = is_create ? 'bookmark_list_create' : 'bookmark_list_edit' %}\n    {{ form_row(form.submit, {label: btn_label, attr: {class: 'btn btn__primary'}}) }}\n</div>\n\n{{ form_end(form) }}\n"
  },
  {
    "path": "templates/bookmark/_options.html.twig",
    "content": "{% set showFilterLabels = app.request.cookies.get('kbin_general_filter_labels')|default('on') %}\n\n<aside class=\"options options--top\" id=\"options\">\n    <div></div>\n    <menu class=\"options__filter\">\n        <li class=\"dropdown\">\n            <button aria-label=\"{{ 'sort_by'|trans }}\"\n                    title=\"{{ 'sort_by'|trans }}\"><i\n                    class=\"fa-solid fa-sort\" aria-hidden=\"true\"></i>\n                <span>{{ criteria.getOption('sort')|trans }}</span>\n            </button>\n            <ul class=\"dropdown__menu\">\n                <li>\n                    <a href=\"{{ front_options_url('sortBy', 'top', null, {'p': null}) }}\"\n                       class=\"{{ html_classes({'active': criteria.getOption('sort') == 'top'}) }}\">\n                        {{ 'top'|trans }}\n                    </a>\n                </li>\n                <li>\n                    <a href=\"{{ front_options_url('sortBy', 'hot', null, {'p': null}) }}\"\n                       class=\"{{ html_classes({'active': criteria.getOption('sort') == 'hot'}) }}\">\n                        {{ 'hot'|trans }}\n                    </a>\n                </li>\n                <li>\n                    <a href=\"{{ front_options_url('sortBy', 'newest', null, {'p': null}) }}\"\n                       class=\"{{ html_classes({'active': criteria.getOption('sort') == 'newest'}) }}\">\n                        {{ 'newest'|trans }}\n                    </a>\n                </li>\n                <li>\n                    <a href=\"{{ front_options_url('sortBy', 'oldest', null, {'p': null}) }}\"\n                       class=\"{{ html_classes({'active': criteria.getOption('sort') == 'oldest'}) }}\">\n                        {{ 'oldest'|trans }}\n                    </a>\n                </li>\n            </ul>\n        </li>\n        <li class=\"dropdown\">\n            <button aria-label=\"{{ 'filter_by_time'|trans }}\"\n                    title=\"{{ 'filter_by_time'|trans }}\">\n                <i aria-hidden=\"true\" class=\"fa-solid fa-clock\"></i>\n                {% if showFilterLabels == 'on' or (showFilterLabels == 'auto' and criteria.getOption('time') != 'all') %}\n                    <span>{{ criteria.getOption('time')|trans }}</span>\n                {% endif %}\n            </button>\n            <ul class=\"dropdown__menu\">\n                <li>\n                    <a href=\"{{ front_options_url('time', '∞', null, {'p': null}) }}\"\n                       class=\"{{ html_classes({'active': criteria.getOption('time') == 'all'}) }}\">\n                        {{ 'all_time'|trans }}\n                    </a>\n                </li>\n                <li>\n                    <a href=\"{{ front_options_url('time', '3h', null, {'p': null}) }}\"\n                       class=\"{{ html_classes({'active': criteria.getOption('time') == '3h' }) }}\">\n                        {{ '3h'|trans }}\n                    </a></li>\n                <li>\n                    <a href=\"{{ front_options_url('time', '6h', null, {'p': null}) }}\"\n                       class=\"{{ html_classes({'active': criteria.getOption('time') == '6h' }) }}\">\n                        {{ '6h'|trans }}\n                    </a>\n                </li>\n                <li>\n                    <a href=\"{{ front_options_url('time', '12h', null, {'p': null}) }}\"\n                       class=\"{{ html_classes({'active': criteria.getOption('time') == '12h' }) }}\">\n                        {{ '12h'|trans }}\n                    </a>\n                </li>\n                <li>\n                    <a href=\"{{ front_options_url('time', '1d', null, {'p': null}) }}\"\n                       class=\"{{ html_classes({'active': criteria.getOption('time') == '1d' }) }}\">\n                        {{ '1d'|trans }}\n                    </a>\n                </li>\n                <li>\n                    <a href=\"{{ front_options_url('time', '1w', null, {'p': null}) }}\"\n                       class=\"{{ html_classes({'active': criteria.getOption('time') == '1w' }) }}\">\n                        {{ '1w'|trans }}\n                    </a>\n                </li>\n                <li>\n                    <a href=\"{{ front_options_url('time', '1m', null, {'p': null}) }}\"\n                       class=\"{{ html_classes({'active': criteria.getOption('time') == '1m' }) }}\">\n                        {{ '1m'|trans }}\n                    </a>\n                </li>\n                <li>\n                    <a href=\"{{ front_options_url('time', '1y', null, {'p': null}) }}\"\n                       class=\"{{ html_classes({'active': criteria.getOption('time') == '1y' }) }}\">\n                        {{ '1y'|trans }}\n                    </a>\n                </li>\n            </ul>\n        </li>\n        <li class=\"dropdown\">\n            <button aria-label=\"{{ 'filter_by_type'|trans }}\"\n                    title=\"{{ 'filter_by_type'|trans }}\">\n                <i aria-hidden=\"true\" class=\"\n                    {% if criteria.getOption('type') == 'all' %}\n                        fa-solid fa-file\n                    {% elseif criteria.getOption('type') == 'links' %}\n                        fa-regular fa-file-code\n                    {% elseif criteria.getOption('type') == 'threads' %}\n                        fa-regular fa-file-lines\n                    {% elseif criteria.getOption('type') == 'photos' %}\n                        fa-regular fa-file-image\n                    {% elseif criteria.getOption('type') == 'videos' %}\n                        fa-regular fa-file-video\n                    {% else %}\n                        fa-solid fa-question\n                    {% endif %}\">\n                </i>\n                {% if showFilterLabels == 'on' or (showFilterLabels == 'auto' and criteria.getOption('type') != 'all') %}\n                    <span class=\"hide-on-mobile\">{{ criteria.getOption('type')|trans }}</span>\n                {% endif %}\n            </button>\n            <ul class=\"dropdown__menu\">\n                <li>\n                    <a href=\"{{ front_options_url('type', null, null, {'p': null}) }}\" class=\"{{ html_classes({'active': criteria.getOption('type') == 'all' }) }}\">\n                        <i class=\"fa-solid fa-file\" aria-hidden=\"true\"></i> &nbsp; {{ 'all'|trans }}\n                    </a>\n                </li>\n                <li>\n                    <a href=\"{{ front_options_url('type', 'links', null, {'p': null}) }}\" class=\"{{ html_classes({'active': criteria.getOption('type') == 'links' }) }}\">\n                        <i class=\"fa-regular fa-file-code\" aria-hidden=\"true\"></i> &nbsp; {{ 'links'|trans }}\n                    </a>\n                </li>\n                <li>\n                    <a href=\"{{ front_options_url('type', 'articles', null, {'p': null}) }}\" class=\"{{ html_classes({'active': criteria.getOption('type') == 'threads' }) }}\">\n                        <i class=\"fa-regular fa-file-lines\" aria-hidden=\"true\"></i> &nbsp; {{ 'threads'|trans }}\n                    </a>\n                </li>\n                <li>\n                    <a href=\"{{ front_options_url('type', 'photos', null, {'p': null}) }}\" class=\"{{ html_classes({'active': criteria.getOption('type') == 'photos' }) }}\">\n                        <i class=\"fa-regular fa-file-image\" aria-hidden=\"true\"></i> &nbsp; {{ 'photos'|trans }}\n                    </a>\n                </li>\n                <li>\n                    <a href=\"{{ front_options_url('type', 'videos', null, {'p': null}) }}\" class=\"{{ html_classes({'active': criteria.getOption('type') == 'videos'}) }}\">\n                        <i class=\"fa-regular fa-file-video\" aria-hidden=\"true\"></i> &nbsp; {{ 'videos'|trans }}\n                    </a>\n                </li>\n            </ul>\n        </li>\n        <li class=\"dropdown\">\n            <button\n                aria-label=\"{{ 'filter_by_federation'|trans }}\"\n                title=\"{{ 'filter_by_federation'|trans }}\">\n                <i aria-hidden=\"true\" class=\"fa-solid\n                    {% if criteria.getOption('federation') == 'all' %}\n                        fa-circle-nodes\n                    {% elseif criteria.getOption('federation') == 'local' %}\n                        fa-house-chimney\n                    {% elseif criteria.getOption('federation') == 'federated' %}\n                        fa-network-wired\n                    {% else %}\n                        fa-question\n                    {% endif %}\">\n                </i>\n                {% if showFilterLabels == 'on' or (showFilterLabels == 'auto' and criteria.getOption('federation') != 'all') %}\n                    <span class=\"hide-on-mobile\">{{ criteria.federation|trans }}</span>\n                {% endif %}\n            </button>\n            <ul class=\"dropdown__menu\">\n                <li>\n                    <a href=\"{{ front_options_url('federation', 'all', null, {'p': null}) }}\" class=\"{{ html_classes({'active': criteria.getOption('federation') == 'all' }) }}\">\n                        <i class=\"fa-solid fa-circle-nodes\" aria-hidden=\"true\"></i> &nbsp; {{ 'all'|trans }}\n                    </a>\n                </li>\n                <li>\n                    <a href=\"{{ front_options_url('federation', 'local', null, {'p': null}) }}\" class=\"{{ html_classes({'active': criteria.getOption('federation') == 'local' }) }}\">\n                        <i class=\"fa-solid fa-house-chimney\" aria-hidden=\"true\"></i> &nbsp; {{ 'local'|trans }}\n                    </a>\n                </li>\n                {#\n                <li>\n                    <a href=\"{{ front_options_url('federation', 'federated', null, {'p': null}) }}\" class=\"{{ html_classes({'active': criteria.getOption('federation') == 'federated' }) }}\">\n                        <i class=\"fa-solid fa-network-wired\" aria-hidden=\"true\"></i> &nbsp; {{ 'federated'|trans }}\n                    </a>\n                </li>\n                #}\n            </ul>\n        </li>\n        {% if lists is defined and lists is not empty %}\n            <li class=\"dropdown\">\n                <button\n                    aria-label=\"{{ 'bookmark_list_selected_list'|trans }}\"\n                    title=\"{{ 'bookmark_list_selected_list'|trans }}\">\n                    <i aria-hidden=\"true\" class=\"fa-solid fa-bookmark\">\n                    </i>\n                    {% if showFilterLabels == 'on' or showFilterLabels == 'auto' %}\n                        <span class=\"hide-on-mobile\">{{ list.name }}</span>\n                    {% endif %}\n                </button>\n                <ul class=\"dropdown__menu\">\n                    {% for currList in lists %}\n                        <li>\n                            <a href=\"{{ front_options_url('list', currList.name, null, {'p': null}) }}\" class=\"{{ html_classes({'active': currList.id == list.id}) }}\">\n                                {{ currList.name }}\n                            </a>\n                        </li>\n                    {% endfor %}\n                </ul>\n            </li>\n        {% endif %}\n    </menu>\n    <menu class=\"options__view\">\n        <li class=\"dropdown\">\n            <button aria-label=\"{{ 'change_view'|trans }}\"\n                    title=\"{{ 'change_view'|trans }}\"><i\n                    class=\"fa-solid fa-layer-group\" aria-hidden=\"true\"></i>\n            </button>\n            <ul class=\"dropdown__menu\">\n                <li>\n                    <a class=\"{{ html_classes({'active': not app.request.cookies.has(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_ENTRIES_COMPACT')) or app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_ENTRIES_COMPACT')) is same as 'false'}) }}\"\n                       href=\"{{ path('theme_settings', {key: constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_ENTRIES_COMPACT'), value: 'false'}) }}\">\n                        {{ 'classic_view'|trans }}\n                    </a>\n                </li>\n                <li>\n                    <a class=\"{{ html_classes({'active': app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_ENTRIES_COMPACT')) is same as 'true'}) }}\"\n                       href=\"{{ path('theme_settings', {key: constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_ENTRIES_COMPACT'), value: 'true'}) }}\">\n                        {{ 'compact_view'|trans }}\n                    </a>\n                </li>\n            </ul>\n        </li>\n    </menu>\n</aside>\n"
  },
  {
    "path": "templates/bookmark/edit.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    {{- 'bookmarks_list'|trans({'%list%': list.name}) }} - {{ parent() -}}\n{%- endblock -%}\n\n{% block mainClass %}page-bookmarks{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n{% endblock %}\n\n{% block body %}\n    <h1 hidden>{{ 'bookmarks_list_edit'|trans }}</h1>\n\n    <div class=\"container\">\n        <div class=\"section section--top\">\n            {% include 'bookmark/_form_edit.html.twig' with {is_create: false} %}\n        </div>\n    </div>\n\n{% endblock %}\n"
  },
  {
    "path": "templates/bookmark/front.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    {{- 'bookmarks_list'|trans({'%list%': list.name}) }} - {{ parent() -}}\n{%- endblock -%}\n\n{% block mainClass %}page-bookmarks{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n{% endblock %}\n\n{% block body %}\n    <h1 hidden>{{ 'bookmarks'|trans }}</h1>\n\n    {% include 'bookmark/_options.html.twig' %}\n    <div id=\"content\" class=\"{{ html_classes('overview', {\n        'show-comment-avatar': app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_COMMENTS_SHOW_USER_AVATAR')) is same as 'true' or not app.request.cookies.has(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_COMMENTS_SHOW_USER_AVATAR')),\n        'show-post-avatar': app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_POSTS_SHOW_USERS_AVATARS')) is same as 'true' or not app.request.cookies.has(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_POSTS_SHOW_USERS_AVATARS'))\n    }) }}\">\n        {% include 'layout/_subject_list.html.twig' %}\n    </div>\n{% endblock %}\n"
  },
  {
    "path": "templates/bookmark/overview.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    {{- 'bookmark_lists'|trans }} - {{ parent() -}}\n{%- endblock -%}\n\n{% block mainClass %}page-bookmark-lists{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n{% endblock %}\n\n{% block body %}\n    <h1 hidden>{{ 'bookmark_lists'|trans }}</h1>\n\n    <div class=\"section section--top\">\n        <div class=\"container\">\n            {% include('bookmark/_form_edit.html.twig') with {is_create: true} %}\n        </div>\n    </div>\n\n    {% if lists|length %}\n        <div class=\"section\">\n            <div class=\"container\">\n                <table>\n                    <thead>\n                    <tr>\n                        <th style=\"width: 2em;\"></th>\n                        <th>{{ 'name'|trans }}</th>\n                        <th>{{ 'count'|trans }}</th>\n                        <th></th>\n                    </tr>\n                    </thead>\n                    <tbody>\n                    {% for list in lists %}\n                        <tr>\n                            <td>\n                                {% if list.isDefault %}\n                                    <i class=\"fa-solid fa-star active\" title=\"{{ 'bookmark_list_is_default'|trans }}\" aria-description=\"{{ 'bookmark_list_is_default'|trans }}\"></i>\n                                {% endif %}\n                            </td>\n                            <td><a href=\"{{ path('bookmark_front', {'list': list.name}) }}\">{{ list.name }}</a></td>\n                            <td>{{ get_bookmark_list_entry_count(list) }}</td>\n                            <td>\n                                {% if not list.isDefault %}\n                                    <form action=\"{{ path('bookmark_lists_make_default') }}\" method=\"get\" style=\"display: inline;\">\n                                        <input type=\"hidden\" name=\"makeDefault\" value=\"{{ list.id }}\">\n                                        <button type=\"submit\" class=\"btn btn__secondary\" title=\"{{ 'bookmark_list_make_default'|trans }}\">\n                                            <i class=\"fa-regular fa-star\" aria-description=\"{{ 'bookmark_list_make_default'|trans }}\" title=\"{{ 'bookmark_list_make_default'|trans }}\"></i>\n                                        </button>\n                                    </form>\n                                {% endif %}\n                                <a href=\"{{ path('bookmark_lists_edit_list', {'list': list.id}) }}\">\n                                    <button type=\"submit\" class=\"btn btn__secondary\" title=\"{{ 'edit'|trans }}\">\n                                        <i class=\"fa-solid fa-pencil\" aria-description=\"{{ 'edit'|trans }}\" title=\"{{ 'edit'|trans }}\"></i>\n                                    </button>\n                                </a>\n                                <a href=\"{{ path('bookmark_lists_delete_list', {'list': list.id}) }}\">\n                                    <button type=\"submit\" class=\"btn btn__secondary\" title=\"{{ 'delete'|trans }}\">\n                                        <i class=\"fa-solid fa-trash\" aria-description=\"{{ 'delete'|trans }}\" title=\"{{ 'delete'|trans }}\"></i>\n                                    </button>\n                                </a>\n                            </td>\n                        </tr>\n                    {% endfor %}\n                    </tbody>\n                </table>\n            </div>\n        </div>\n    {% else %}\n        <aside class=\"section section--muted\">\n            <p>{{ 'empty'|trans }}</p>\n        </aside>\n    {% endif %}\n{% endblock %}\n"
  },
  {
    "path": "templates/bundles/NelmioApiDocBundle/SwaggerUi/index.html.twig",
    "content": "{% extends '@!NelmioApiDoc/SwaggerUi/index.html.twig' %}\n\n{% block stylesheets %}\n    {{ parent() }}\n    <style>\n    header::before {\n        display: none;\n    }\n    </style>\n    {{ encore_entry_link_tags('app') }}\n    <style>\n    .swagger-ui code {\n        color: inherit;\n        display: inherit;\n        background: inherit;\n        padding: inherit;\n        overflow: inherit;\n    }\n    header #logo {\n        position: inherit;\n        transform: unset;\n    }\n    </style>\n{% endblock %}\n\n{% block header_block %}\n    <header id=\"header\" class=\"header\">\n        <div class=\"mbin-container\">\n            <nav class=\"head-nav\">\n                <div class=\"brand\">\n                    <a href=\"{{ '/' }}\">\n                        {% if kbin_header_logo() %}\n                            <img id=\"logo\" src=\"{{ asset('mbin_logo.svg') }}\" alt=\"{{ kbin_title() }}\">\n                        {% else %}\n                            <span>{{ kbin_title() }}</span>\n                        {% endif %}\n                    </a>\n                </div>\n            </nav>\n        </div>\n    </header>\n{% endblock %}\n"
  },
  {
    "path": "templates/bundles/TwigBundle/Exception/error.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    {{- 'login'|trans }} - {{ parent() -}}\n{%- endblock -%}\n\n{% block mainClass %}page-error{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n{% endblock %}\n\n{% block body %}\n    <div id=\"content\" class=\"section section--top section--muted\">\n        <p>{{ 'error'|trans }}</p>\n    </div>\n{% endblock %}\n"
  },
  {
    "path": "templates/bundles/TwigBundle/Exception/error403.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    {{- 'error'|trans }} 403 - {{ parent() -}}\n{%- endblock -%}\n\n{% block mainClass %}page-error{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n{% endblock %}\n\n{% block body %}\n    <div id=\"content\" class=\"section section--muted\">\n        <p>{{'errors.server403.title'|trans}}</p>\n    </div>\n{% endblock %}\n"
  },
  {
    "path": "templates/bundles/TwigBundle/Exception/error404.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    {{- 'error'|trans }} 404 - {{ parent() -}}\n{%- endblock -%}\n\n{% block mainClass %}page-error{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n{% endblock %}\n\n{% block body %}\n    <div id=\"content\" class=\"section section--top section--muted\">\n        <p>{{'errors.server404.title'|trans}}</p>\n    </div>\n{% endblock %}\n"
  },
  {
    "path": "templates/bundles/TwigBundle/Exception/error429.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    {{- 'login'|trans }} 429 - {{ parent() -}}\n{%- endblock -%}\n\n{% block mainClass %}page-error{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n{% endblock %}\n\n{% block body %}\n    <div id=\"content\" class=\"section section--top section--muted\">\n        <p>{{'errors.server429.title'|trans}}</p>\n    </div>\n{% endblock %}\n"
  },
  {
    "path": "templates/bundles/TwigBundle/Exception/error500.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    {{- 'login'|trans }} 500 - {{ parent() -}}\n{%- endblock -%}\n\n{% block mainClass %}page-error{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n{% endblock %}\n\n{% block body %}\n    <div id=\"content\" class=\"section section--top section--muted\">\n        <p>{{'errors.server500.title'|trans}}</p>\n        <small>{{ 'errors.server500.description'|trans({\n                '%link_start%': '<a href=\"https://joinmbin.org/servers/\">',\n                '%link_end%': '</a>'\n            })|raw }}</small>\n    </div>\n{% endblock %}\n"
  },
  {
    "path": "templates/components/_ajax.html.twig",
    "content": "{{ component(component, attributes) }}"
  },
  {
    "path": "templates/components/_cached.html.twig",
    "content": "{{ this.getHtml(attributes)|raw }}\n"
  },
  {
    "path": "templates/components/_comment_collapse_button.html.twig",
    "content": "<aside class=\"comment-collapse\">\n    <a data-action=\"comment-collapse#toggleCollapse\">\n        <span data-comment-collapse-target=\"counter\"></span>\n        {% if showNested and comment.children|length > 0 %}\n            <span class=\"collapse-label\" aria-label=\"{{ 'collapse'|trans }}\" title=\"{{ 'collapse'|trans }}\">\n                <i class=\"fa-solid fa-angles-up\"></i>\n            </span>\n            <span class=\"expand-label\" aria-label=\"{{ 'expand'|trans }}\" title=\"{{ 'expand'|trans }}\">\n                <i class=\"fa-solid fa-angles-down\"></i>\n            </span>\n        {% else %}\n            <span class=\"collapse-label\" aria-label=\"{{ 'hide'|trans }}\" title=\"{{ 'hide'|trans }}\">\n                <i class=\"fa-solid fa-angle-up\"></i>\n            </span>\n            <span class=\"expand-label\" aria-label=\"{{ 'show'|trans }}\" title=\"{{ 'show'|trans }}\">\n                <i class=\"fa-solid fa-angle-down\"></i>\n            </span>\n        {% endif %}\n    </a>\n</aside>\n"
  },
  {
    "path": "templates/components/_details_label.css.twig",
    "content": ":root {\n    --mbin-details-detail-label: \"{{ 'details'|trans|e('css') }}\";\n    --mbin-details-spoiler-label: \"{{ 'spoiler'|trans|e('css') }}\";\n}\n"
  },
  {
    "path": "templates/components/_entry_comments_nested_hidden_private_threads.html.twig",
    "content": "{% if app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::ENTRY_COMMENTS_VIEW')) is same as constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::CLASSIC') %}\n    {% for reply in comment.nested %}\n        {% if reply.visibility is same as 'private' %}\n            {% if app.user and reply.user.isFollower(app.user) %}\n                {% if not app.user.isBlocked(reply.user) %}\n                    {{ component('entry_comment', {comment: reply, showNested: false, level: 3, showEntryTitle:false, showMagazineName:false}) }}\n                {% endif %}\n            {% else %}\n                <div class=\"section\">\n                    {{ 'Private' }}\n                </div>\n            {% endif %}\n        {% else %}\n            {% if not app.user or (app.user and not app.user.isBlocked(reply.user)) %}\n                {{ component('entry_comment', {comment: reply, showNested: false, level: 3, showEntryTitle:false, showMagazineName:false}) }}\n            {% endif %}\n        {% endif %}\n    {% endfor %}\n{% else %}\n    {% for reply in comment.children %}\n        {% if reply.visibility is same as 'private' %}\n            {% if app.user and reply.user.isFollower(app.user) %}\n                {% if not app.user.isBlocked(reply.user) %}\n                    {{ component('entry_comment', {comment: reply, showNested:true, level: level + 1, showEntryTitle:false, showMagazineName:false}) }}\n                {% endif %}\n            {% else %}\n                <div class=\"section\">\n                    {{ 'Private' }}\n                </div>\n            {% endif %}\n        {% else %}\n            {% if not app.user or (app.user and not app.user.isBlocked(reply.user)) %}\n                {{ component('entry_comment', {comment: reply, showNested:true, level: level + 1, showEntryTitle:false, showMagazineName:false}) }}\n            {% endif %}\n        {% endif %}\n    {% endfor %}\n{% endif %}"
  },
  {
    "path": "templates/components/_figure_entry.html.twig",
    "content": "{# this fragment is only meant to be used in entry component #}\n{% with {is_single: is_route_name('entry_single'), image: entry.image} %}\n    {% set sensitive_id = 'sensitive-check-%s-%s'|format(entry.id, image.id) %}\n    {% set lightbox_alt_id = 'thumb-alt-%s-%s'|format(entry.id, image.id) %}\n    {% set image_path = image.filePath ? asset(image.filePath)|imagine_filter('entry_thumb') : image.sourceUrl %}\n\n    {%- set V_TRUE = constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::TRUE') -%}\n    {%- set LIST_LIGHTBOX = app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::MBIN_LIST_IMAGE_LIGHTBOX'), V_TRUE) -%}\n\n    {% if LIST_LIGHTBOX is same as V_TRUE %}\n        {% set route = uploaded_asset(image) %}\n    {% elseif type is same as 'image' %}\n        {% set route = is_single ? uploaded_asset(image) : entry_url(entry) %}\n    {% elseif type is same as 'link' %}\n        {% set route = is_single ? entry.url : entry_url(entry) %}\n    {% endif %}\n\n    {% set is_single_image = is_single and type is same as 'image' %}\n\n    <figure>\n        {% if image.altText %}\n            <figcaption class=\"{{ html_classes('hidden', 'glightbox-desc', lightbox_alt_id) }}\">\n                {{ image.altText|nl2br }}\n            </figcaption>\n        {% endif %}\n        <div class=\"image-filler\" aria-hidden=\"true\">\n            {% if image.blurhash %}\n                {{ component('blurhash_image', {blurhash: image.blurhash}) }}\n            {% endif %}\n        </div>\n        {% if entry.isAdult %}\n            <input id=\"{{ sensitive_id }}\"\n                   type=\"checkbox\"\n                   class=\"sensitive-state\"\n                   aria-label=\"{{ 'sensitive_toggle'|trans }}\">\n        {% endif %}\n        <a href=\"{{ route }}\"\n           class=\"{{ html_classes('sensitive-checked--show', {'thumb': is_single_image or LIST_LIGHTBOX is same as V_TRUE}) }}\"\n           rel=\"{{ (type is same as 'link') and route is not same as null ? get_rel(route) : '' }}\"\n           data-gallery=\"entry-{{ entry.id }}\"\n           data-description=\"{{ is_single_image and image.altText ? '.'~lightbox_alt_id : '' }}\">\n            <img class=\"thumb-subject\"\n                 loading=\"lazy\"\n                 src=\"{{ image_path }}\"\n                 title=\"{{ image.altText }}\"\n                 alt=\"{{ image.altText }}\">\n        </a>\n        <div class=\"figure-badge sensitive-checked--show\">\n            {% if image_path|lower ends with '.gif' %}\n                <div class=\"figure-badge-label\">GIF</div>\n            {% endif %}\n            {% if image.altText %}\n                <div class=\"figure-badge-label\">ALT</div>\n            {% endif %}\n        </div>\n        {% if entry.isAdult %}\n            <label for=\"{{ sensitive_id }}\"\n                   class=\"sensitive-button sensitive-button-show sensitive-checked--hide\"\n                   title=\"{{ 'sensitive_show'|trans }}\"\n                   aria-label=\"{{ 'sensitive_show'|trans }}\">\n                <div class=\"sensitive-button-label\">\n                    <i class=\"fa-solid fa-eye\" aria-hidden=\"true\"></i>\n                </div>\n            </label>\n            <label for=\"{{ sensitive_id }}\"\n                   class=\"sensitive-button sensitive-button-hide sensitive-checked--show\"\n                   title=\"{{ 'sensitive_hide'|trans }}\"\n                   aria-label=\"{{ 'sensitive_hide'|trans }}\">\n                <div class=\"sensitive-button-label\">\n                    <i class=\"fa-solid fa-eye-slash\" aria-hidden=\"true\"></i>\n                </div>\n            </label>\n        {% endif %}\n    </figure>\n{% endwith %}\n"
  },
  {
    "path": "templates/components/_figure_image.html.twig",
    "content": "{% with {\n    sensitive_id: 'sensitive-check-%s-%s'|format(parent_id, image.id),\n    lightbox_alt_id: 'thumb-alt-%s-%s'|format(parent_id, image.id),\n    image_path: (image.filePath ? asset(image.filePath)|imagine_filter(thumb_filter) : image.sourceUrl)\n} %}\n    <figure>\n        {% if image.altText %}\n            <figcaption class=\"{{ html_classes('hidden', 'glightbox-desc', lightbox_alt_id) }}\">\n                {{ image.altText|nl2br }}\n            </figcaption>\n        {% endif %}\n        <div class=\"figure-container\">\n            <div class=\"figure-thumb\">\n                <a\n                    href=\"{{ uploaded_asset(image) }}\"\n                    class=\"thumb\"\n                    data-gallery=\"{{ gallery_name }}\"\n                    data-description=\"{{ image.altText ? '.'~lightbox_alt_id : ''}}\">\n                    <img loading=\"lazy\"\n                         src=\"{{ image_path }}\"\n                         title=\"{{ image.altText }}\"\n                         alt=\"{{ image.altText }}\">\n                </a>\n            </div>\n            <div class=\"figure-badge\">\n                {% if image_path|lower ends with '.gif' %}\n                    <div class=\"figure-badge-label\">GIF</div>\n                {% endif %}\n                {% if image.altText %}\n                    <div class=\"figure-badge-label\">ALT</div>\n                {% endif %}\n            </div>\n            {% if is_adult %}\n                <input id=\"{{ sensitive_id }}\"\n                       type=\"checkbox\"\n                       class=\"sensitive-state\"\n                       aria-label=\"{{ 'sensitive_toggle'|trans }}\">\n                <label for=\"{{ sensitive_id }}\"\n                       class=\"sensitive-button sensitive-button-show sensitive-checked--hide\"\n                       title=\"{{ 'sensitive_show'|trans }}\">\n                    <div class=\"figure-blur\" aria-hidden=\"true\">\n                        {{ component('blurhash_image', {blurhash: image.blurhash, width: 32, height: 32}) }}\n                    </div>\n                    <div class=\"sensitive-button-label\">\n                        {{ 'sensitive_warning'|trans }} <br>\n                        {{ 'sensitive_show'|trans }}\n                    </div>\n                </label>\n                <label for=\"{{ sensitive_id }}\"\n                       class=\"sensitive-button sensitive-button-hide sensitive-checked--show\"\n                       title=\"{{ 'sensitive_hide'|trans }}\"\n                       aria-label=\"{{ 'sensitive_hide'|trans }}\">\n                    <div class=\"sensitive-button-label\" >\n                        <i class=\"fa-solid fa-eye-slash\" aria-hidden=\"true\"></i>\n                    </div>\n                </label>\n            {% endif %}\n        </div>\n    </figure>\n{% endwith %}\n"
  },
  {
    "path": "templates/components/_loading_icon.html.twig",
    "content": ""
  },
  {
    "path": "templates/components/_post_comments_nested_hidden_private_threads.html.twig",
    "content": "{% if app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::ENTRY_COMMENTS_VIEW')) is same as constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::CLASSIC') %}\n    {% for reply in comment.nested %}\n        {% if reply.visibility is same as 'private' %}\n            {% if app.user and reply.user.isFollower(app.user) %}\n                {% if not app.user.isBlocked(reply.user) %}\n                    {{ component('post_comment', {comment: reply, showNested:false, level: 3}) }}\n                {% endif %}\n            {% else %}\n                <div class=\"section\">\n                    {{ 'Private' }}\n                </div>\n            {% endif %}\n        {% else %}\n            {% if not app.user or (app.user and not app.user.isBlocked(reply.user)) %}\n                {{ component('post_comment', {comment: reply, showNested:false, level: 3}) }}\n            {% endif %}\n        {% endif %}\n    {% endfor %}\n{% else %}\n    {% for reply in comment.children %}\n        {% if reply.visibility is same as 'private' %}\n            {% if app.user and reply.user.isFollower(app.user) %}\n                {% if not app.user.isBlocked(reply.user) %}\n                    {{ component('entry_comment', {comment: reply, showNested:true, level: level + 1, showEntryTitle:false, showMagazineName:false}) }}\n                {% endif %}\n            {% else %}\n                <div class=\"section\">\n                    {{ 'Private' }}\n                </div>\n            {% endif %}\n        {% else %}\n            {% if not app.user or (app.user and not app.user.isBlocked(reply.user)) %}\n                {{ component('post_comment', {comment: reply, showNested:true, level: level + 1}) }}\n            {% endif %}\n        {% endif %}\n    {% endfor %}\n{% endif %}"
  },
  {
    "path": "templates/components/_settings_row_enum.html.twig",
    "content": "<div class=\"settings-row {{ class }}\" data-controller=\"settings-row-enum\">\n    <span class=\"label\" title=\"{{ help }}\" id=\"settings-row-label-{{ settingsKey }}\">{{ label }}</span>\n    <div class=\"value-container\">\n        <div class=\"enum\" role=\"radiogroup\" aria-labelledby=\"settings-row-label-{{ settingsKey }}\"\n             aria-describedby=\"settings-row-help-{{ settingsKey }}\">\n            {% for value in values %}\n                <label class=\"value\">\n                    <input type=\"radio\"\n                           name=\"{{ settingsKey }}\"\n                           value=\"{{ value.value }}\"\n                            {{ (app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::' ~ settingsKey)) is same as constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::' ~ value.value) or (defaultValue and constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::' ~ defaultValue) is same as constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::' ~ value.value) and not app.request.cookies.has(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::' ~ settingsKey)))) ? 'checked' : '' }}\n                           data-action=\"change->settings-row-enum#change\"\n                           data-settings-row-enum-action-path-param=\"{{ path('theme_settings', {key: constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::' ~ settingsKey), value: constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::' ~ value.value)}) }}\"\n                           data-settings-row-enum-reload-required-param=\"{{ reloadRequired }}\"\n                    />\n                    <span>{{ value.name }}</span>\n                </label>\n            {% endfor %}\n        </div>\n    </div>\n</div>\n"
  },
  {
    "path": "templates/components/_settings_row_switch.html.twig",
    "content": "<div class=\"settings-row\" data-controller=\"settings-row-switch\">\n    <span class=\"label\" title=\"{{ help }}\" id=\"settings-row-label-{{ settingsKey }}\">{{ label }}</span>\n    <div class=\"value-container\">\n        <label class=\"switch\" role=\"switch\">\n            <input type=\"checkbox\"\n                   aria-labelledby=\"settings-row-label-{{ settingsKey }}\"\n                   aria-describedby=\"settings-row-label-{{ settingsKey }}\"\n                    {{ (app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::' ~ settingsKey)) is same as 'true' or (defaultValue and not app.request.cookies.has(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::' ~ settingsKey)))) ? 'checked' : '' }}\n                   data-action=\"change->settings-row-switch#toggle\"\n                   data-settings-row-switch-true-path-param=\"{{ path('theme_settings', {key: constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::' ~ settingsKey), value: 'true'}) }}\"\n                   data-settings-row-switch-false-path-param=\"{{ path('theme_settings', {key: constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::' ~ settingsKey), value: 'false'}) }}\"\n                   data-settings-row-switch-reload-required-param=\"{{ reloadRequired }}\"\n            />\n            <span class=\"slider\"></span>\n        </label>\n    </div>\n</div>"
  },
  {
    "path": "templates/components/active_users.html.twig",
    "content": "{% if users|length %}\n    <section class=\"section active-users\">\n        <h3>{{ 'active_users'|trans }}</h3>\n        <div>\n            {% for user in users %}\n                {{ component('user_avatar', {user: user, width: 65, height: 65, asLink: true}) }}\n            {% endfor %}\n        </div>\n    </section>\n{% endif %}"
  },
  {
    "path": "templates/components/announcement.html.twig",
    "content": "{% if content is not empty %}\n<div class=\"announcement announcement__info\">\n    {{ content|markdown|raw }}\n</div>\n{% endif %}\n"
  },
  {
    "path": "templates/components/blurhash_image.html.twig",
    "content": "<img src=\"{{ this.createImage(blurhash, width, height) }}\" />\n"
  },
  {
    "path": "templates/components/bookmark_list.html.twig",
    "content": "<li class=\"bookmark-list\">\n    {% if is_bookmarked_in_list(app.user, list, subject) %}\n        <a href=\"{{ path('subject_remove_bookmark_from_list', { 'subject_id': subject.id, 'subject_type': get_subject_type(subject), 'list': list.id }) }}\"\n           data-html-refresh-cssclass-param=\"bookmark-list\" data-html-refresh-refreshselector-param=\".bookmark-standard\"\n           data-html-refresh-refreshlink-param=\"{{ path('subject_bookmark_refresh_status', { subject_id: subject.id, subject_type: get_subject_type(subject) }) }}\"\n           data-action=\"html-refresh#linkCallback\">\n            <i class=\"fa-solid fa-bookmark active\"></i>\n            {{ 'bookmark_remove_from_list'|trans({'%list%': list.name}) }}\n        </a>\n    {% else %}\n        <a href=\"{{ path('subject_bookmark_to_list', { 'subject_id': subject.id, 'subject_type': get_subject_type(subject), 'list': list.id }) }}\"\n           data-html-refresh-cssclass-param=\"bookmark-list\" data-html-refresh-refreshselector-param=\".bookmark-standard\"\n           data-html-refresh-refreshlink-param=\"{{ path('subject_bookmark_refresh_status', { subject_id: subject.id, subject_type: get_subject_type(subject) }) }}\"\n           data-action=\"html-refresh#linkCallback\">\n            <i class=\"fa-regular fa-bookmark\"></i>\n            {{ 'bookmark_add_to_list'|trans({'%list%': list.name}) }}\n        </a>\n    {% endif %}\n</li>\n"
  },
  {
    "path": "templates/components/bookmark_menu_list.html.twig",
    "content": "<div class=\"bookmark-menu-list\">\n    {% for list in bookmarkLists %}\n        {{ component('bookmark_list', { subject: subject, list: list }) }}\n    {% endfor %}\n</div>\n"
  },
  {
    "path": "templates/components/bookmark_standard.html.twig",
    "content": "<li class=\"bookmark-standard\">\n    {% if is_bookmarked(app.user, subject) %}\n        <a href=\"{{ path('subject_remove_bookmarks', { 'subject_id': subject.id, 'subject_type': get_subject_type(subject) }) }}\"\n           data-html-refresh-cssclass-param=\"bookmark-standard\" data-html-refresh-refreshselector-param=\".bookmark-menu-list\"\n           data-html-refresh-refreshlink-param=\"{{ path('bookmark_lists_menu_refresh_status', { 'subject_id': subject.id, 'subject_type': get_subject_type(subject) }) }}\"\n           data-action=\"html-refresh#linkCallback\">\n            <i class=\"fa-solid fa-bookmark active\" aria-description=\"{{ 'bookmark_remove_all'|trans }}\" title=\"{{ 'bookmark_remove_all'|trans }}\"></i>\n        </a>\n    {% else %}\n        <a href=\"{{ path('subject_bookmark_standard', { 'subject_id': subject.id, 'subject_type': get_subject_type(subject) }) }}\"\n           data-html-refresh-cssclass-param=\"bookmark-standard\" data-html-refresh-refreshselector-param=\".bookmark-menu-list\"\n           data-html-refresh-refreshlink-param=\"{{ path('bookmark_lists_menu_refresh_status', { 'subject_id': subject.id, 'subject_type': get_subject_type(subject) }) }}\"\n           data-action=\"html-refresh#linkCallback\">\n            <i class=\"fa-regular fa-bookmark\" aria-description=\"{{ 'bookmark_add_to_default_list'|trans }}\" title=\"{{ 'bookmark_add_to_default_list'|trans }}\"></i>\n        </a>\n    {% endif %}\n</li>\n"
  },
  {
    "path": "templates/components/boost.html.twig",
    "content": "{%- set VOTE_UP = constant('App\\\\Entity\\\\Contracts\\\\VotableInterface::VOTE_UP') -%}\n{%- set user_choice = is_granted('ROLE_USER') ? subject.userChoice(app.user) : null -%}\n<form method=\"post\"\n      action=\"{{ path(formDest~'_boost', {id: subject.id}) }}\">\n    <button class=\"{{ html_classes('boost-link', 'stretched-link', {'active': app.user and user_choice is same as(VOTE_UP) }) }}\"\n            type=\"submit\"\n            data-action=\"subject#favourite\">\n        {{ 'up_vote'|trans }} <span class=\"{{ html_classes({'hidden': not subject.apShareCount and not subject.countUpvotes}) }}\"\n                                          data-subject-target=\"upvoteCounter\">({{ (subject.apShareCount ?? subject.countUpvotes)|abbreviateNumber }})</span>\n    </button>\n</form>\n"
  },
  {
    "path": "templates/components/cursor_pagination.html.twig",
    "content": "<div class=\"cursor-pagination\">\n    <div class=\"section\">\n        <div class=\"container\">\n            <div class=\"col\">\n                {% if pagination.hasPreviousPage() %}\n                    {% set cursors = pagination.getPreviousPage() %}\n                    <a class=\"btn btn__primary btn-link\" href=\"{{ options_url('cursor', get_cursor_url_value(cursors[0]), null, {'cursor2': get_cursor_url_value(cursors[1])}) }}\">\n                        <i class=\"fa fa-solid fa-angle-left\" title=\"{{ 'previous_page'|trans|capitalize }}\"></i>\n                    </a>\n                {% else %}\n                    <div></div>\n                {% endif %}\n            </div>\n\n            {% if app.request.query.get('cursor') %}\n                <div class=\"col col-auto\">\n                    <a class=\"btn btn__secondary btn-link small\" href=\"{{ options_url('cursor', null, null, {'cursor2': null}) }}\">\n                        <i class=\"fa fa-solid fa-home\" title=\"{{ 'first_page'|trans|capitalize }}\"></i>\n                    </a>\n                </div>\n            {% endif %}\n\n            <div class=\"col\">\n                {% if pagination.hasNextPage() %}\n                    {% set cursors = pagination.getNextPage() %}\n                    <a class=\"btn btn__primary btn-link next\" href=\"{{ options_url('cursor', get_cursor_url_value(cursors[0]), null, {'cursor2': get_cursor_url_value(cursors[1])}) }}\">\n                        <i class=\"fa fa-solid fa-angle-right\" title=\"{{ 'next_page'|trans|capitalize }}\"></i>\n                    </a>\n                {% endif %}\n            </div>\n        </div>\n    </div>\n    {% if not pagination.hasNextPage() %}\n        <aside class=\"section section--muted\">\n            <p>{{ 'reached_end'|trans }}</p>\n        </aside>\n    {% endif %}\n</div>\n"
  },
  {
    "path": "templates/components/date.html.twig",
    "content": "<time class=\"timeago\"\n      title=\"{{ date|date('c') }}\"\n      datetime=\"{{ date|date('c') }}\">{{ date|ago }}   \n</time>"
  },
  {
    "path": "templates/components/date_edited.html.twig",
    "content": "{% if editedAt %}\n    {% if editedAt|date('U') - createdAt|date('U') > 300 %}\n        <span class=\"edited\">({{ 'edited'|trans }}\n            <time class=\"timeago\"\n                  title=\"{{ editedAt|date('c') }}\"\n                  datetime=\"{{ editedAt|date('c') }}\">{{ editedAt|ago }}</time>)</span>\n    {% endif %}\n{% endif %}"
  },
  {
    "path": "templates/components/domain.html.twig",
    "content": "<div{{ attributes.defaults({class: 'domain section'}) }}>\n    <h3>{{ 'domain'|trans }}</h3>\n    <div class=\"row\">\n        <header>\n            <h4><a href=\"\" class=\"stretched-link\">{{ domain.name }}</a></h4>\n        </header>\n    </div>\n    {{ component('domain_sub', {domain: domain}) }}\n</div>\n"
  },
  {
    "path": "templates/components/domain_sub.html.twig",
    "content": "<aside{{ attributes.defaults({class: 'domain__subscribe', 'data-controller': 'subs'}) }}>\n    <div class=\"action\"\n        title=\"{{ domain.subscriptionsCount ~ ' ' ~ 'subscribers_count'|trans({'%count%': domain.subscriptionsCount}) }}\"\n        aria-label=\"{{ domain.subscriptionsCount ~ ' ' ~ 'subscribers_count'|trans({'%count%': domain.subscriptionsCount}) }}\">\n        <i class=\"fa-solid fa-users\" aria-hidden=\"true\"></i> <span>{{ domain.subscriptionsCount }}</span>\n    </div>\n    <form action=\"{{ path('domain_' ~ (is_domain_subscribed(domain) ? 'unsubscribe' : 'subscribe'), {name: domain.name}) }}\"\n          name=\"domain_subscribe\"\n          method=\"post\">\n        <button type=\"submit\"\n                class=\"{{ html_classes('btn btn__secondary', {'active': is_domain_subscribed(domain)}) }}\"\n                data-action=\"subs#send\">\n            {{ is_domain_subscribed(domain) ? 'unsubscribe'|trans : 'subscribe'|trans }}\n        </button>\n    </form>\n    <form action=\"{{ path('domain_' ~ (is_domain_blocked(domain) ? 'unblock' : 'block'), {name: domain.name}) }}\"\n          name=\"domain_block\"\n          method=\"post\">\n        <button type=\"submit\"\n                class=\"{{ html_classes('btn btn__secondary', {'active danger': is_domain_blocked(domain)}) }}\"\n                data-action=\"subs#send\">\n            <i class=\"fa-solid fa-ban\" aria-hidden=\"true\"></i><span>{{ is_domain_blocked(domain) ? 'unblock'|trans : 'block'|trans  }}</span>\n        </button>\n    </form>\n</aside>\n"
  },
  {
    "path": "templates/components/editor_toolbar.html.twig",
    "content": "<markdown-toolbar for=\"{{ id }}\" class=\"markdown meta\" data-controller=\"markdown-toolbar\">\n    <md-bold title=\"{{ 'toolbar.bold'|trans }}\" aria-label=\"{{ 'toolbar.bold'|trans }}\">\n        <i class=\"fa-solid fa-bold\" aria-hidden=\"true\"></i>\n    </md-bold>\n    <md-italic title=\"{{ 'toolbar.italic'|trans }}\" aria-label=\"{{ 'toolbar.italic'|trans }}\">\n        <i class=\"fa-solid fa-italic\" aria-hidden=\"true\"></i>\n    </md-italic>\n    <md-strikethrough title=\"{{ 'toolbar.strikethrough'|trans }}\" aria-label=\"{{ 'toolbar.strikethrough'|trans }}\">\n        <i class=\"fa-solid fa-strikethrough\" aria-hidden=\"true\"></i>\n    </md-strikethrough>\n    <md-header title=\"{{ 'toolbar.header'|trans }}\" aria-label=\"{{ 'toolbar.header'|trans }}\">\n        <i class=\"fa-solid fa-heading\" aria-hidden=\"true\"></i>\n    </md-header>\n    <md-quote title=\"{{ 'toolbar.quote'|trans }}\" aria-label=\"{{ 'toolbar.quote'|trans }}\">\n        <i class=\"fa-solid fa-quote-left\" aria-hidden=\"true\"></i>\n    </md-quote>\n    <md-code title=\"{{ 'toolbar.code'|trans }}\" aria-label=\"{{ 'toolbar.code'|trans }}\">\n        <i class=\"fa-solid fa-code\" aria-hidden=\"true\"></i>\n    </md-code>\n    <md-link title=\"{{ 'toolbar.link'|trans }}\" aria-label=\"{{ 'toolbar.link'|trans }}\">\n        <i class=\"fa-solid fa-link\" aria-hidden=\"true\"></i>\n    </md-link>\n    <md-image title=\"{{ 'toolbar.image'|trans }}\" aria-label=\"{{ 'toolbar.image'|trans }}\">\n        <i class=\"fa-solid fa-image\" aria-hidden=\"true\"></i>\n    </md-image>\n    <md-unordered-list title=\"{{ 'toolbar.unordered_list'|trans }}\" aria-label=\"{{ 'toolbar.unordered_list'|trans }}\">\n        <i class=\"fa-solid fa-list\" aria-hidden=\"true\"></i>\n    </md-unordered-list>\n    <md-ordered-list title=\"{{ 'toolbar.ordered_list'|trans }}\" aria-label=\"{{ 'toolbar.ordered_list'|trans }}\">\n        <i class=\"fa-solid fa-list-ol\" aria-hidden=\"true\"></i>\n    </md-ordered-list>\n    <md-mention title=\"{{ 'toolbar.mention'|trans }}\" aria-label=\"{{ 'toolbar.mention'|trans }}\">\n        <i class=\"fa-solid fa-at\" aria-hidden=\"true\"></i>\n    </md-mention>\n    <md-spoiler title=\"{{ 'toolbar.spoiler'|trans }}\" aria-label=\"{{ 'toolbar.spoiler'|trans }}\" data-action=\"click->markdown-toolbar#addSpoiler\">\n        <i class=\"fa-solid fa-triangle-exclamation\" aria-hidden=\"true\"></i>\n    </md-spoiler>\n    <md-emoji title=\"{{ 'toolbar.emoji'|trans }}\" aria-label=\"{{ 'toolbar.emoji'|trans }}\" data-action=\"click->markdown-toolbar#toggleEmojiPicker\">\n        <i class=\"fa-solid fa-face-smile\" aria-hidden=\"true\"></i>\n    </md-emoji>\n</markdown-toolbar>\n\n<div id=\"tooltip\" class=\"tooltip\" role=\"tooltip\">\n    <emoji-picker id=\"emoji-picker\"></emoji-picker>\n</div>"
  },
  {
    "path": "templates/components/entries_cross.html.twig",
    "content": "{% if entries|length %}\n    {% set batch_size = entries|length % 2 == 0 ? 2 : 3 %}\n    {% for entry_batch in entries|batch(batch_size) %}\n        <div class=\"{{ html_classes('entries-cross', {\n            'entries-cross-2': entry_batch|length == 2,\n            'entries-cross-3': entry_batch|length == 3,\n        }) }}\">\n        {% for entry in entry_batch %}\n            {{ component('entry_cross', {entry: entry}) }}\n        {% endfor %}\n        </div>\n    {% endfor %}\n{% endif %}\n"
  },
  {
    "path": "templates/components/entry.html.twig",
    "content": "{%- set V_TRUE = constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::TRUE') -%}\n{%- set V_FALSE = constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::FALSE') -%}\n\n{%- set SHOW_PREVIEW = app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_ENTRIES_SHOW_PREVIEW'), V_FALSE) -%}\n{%- set SHOW_THUMBNAILS = app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_ENTRIES_SHOW_THUMBNAILS'), V_TRUE) -%}\n{%- set SHOW_USER_AVATARS = app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_ENTRIES_SHOW_USERS_AVATARS'), V_TRUE) -%}\n{%- set SHOW_MAGAZINE_ICONS = app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_ENTRIES_SHOW_MAGAZINES_ICONS'), V_TRUE) -%}\n{%- set SHOW_USER_FULLNAME = app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::MBIN_SHOW_USER_DOMAIN'), V_FALSE) -%}\n{%- set SHOW_MAGAZINE_FULLNAME = app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::MBIN_SHOW_MAGAZINE_DOMAIN'), V_FALSE) -%}\n\n{% if not app.user or (app.user and not app.user.isBlocked(entry.user)) %}\n    {% if entry.visibility is same as 'private' and (not app.user or not app.user.isFollower(entry.user)) %}\n        <div class=\"section section--small\"\n             style=\"z-index:3; position:relative;\">\n            Private\n        </div>\n    {% elseif entry.cross %}\n        {{ component('entry_cross', {entry: entry}) }}\n    {% else %}\n        <article{{ attributes.defaults({\n            class: html_classes('entry section subject', {\n                'no-image': SHOW_THUMBNAILS is same as V_FALSE,\n                'own': app.user and entry.isAuthor(app.user),\n                'show-preview': SHOW_PREVIEW is same as V_TRUE and not entry.isAdult,\n                'isSingle': isSingle is same as true\n            })}).without('id') }}\n                id=\"entry-{{ entry.id }}\"\n                data-controller=\"subject preview mentions html-refresh\"\n                data-action=\"notifications:Notification@window->subject#notification\">\n            <header>\n                {% if entry.visibility in ['visible', 'private'] or (entry.visibility is same as 'trashed' and this.canSeeTrashed) %}\n                    {% if isSingle %}\n                        <h1>\n                            {% if entry.isAdult %}<small class=\"badge danger\">18+</small>{% endif %}\n                            {% if entry.isOc %}<small class=\"badge kbin-bg\">OC</small>{% endif %}\n                            {% if entry.url %}\n                                <a rel=\"{{ get_rel(entry.url) }}\"\n                                   href=\"{{ entry.url }}\">{{ entry.title }}</a>\n                            {% else %}\n                                {{ entry.title }}\n                            {% endif %}\n\n                            {% if entry.url %}\n                                <span class=\"entry__domain\">\n                                    ( <a rel=\"{{ get_rel(entry.url) }}\" href=\"{{ entry.url }}\" target=\"_blank\">\n                                        <span>{{ get_url_domain(entry.url) }}</span> <i class=\"fa-solid fa-external-link\" aria-hidden=\"true\"></i>\n                                    </a> )\n                                </span>\n                            {% endif %}\n\n                            {% if entry.lang is not same as app.request.locale and entry.lang is not same as kbin_default_lang() %}\n                                <small class=\"badge-lang\">{{ entry.lang|language_name }}</small>\n                            {% endif %}\n                        </h1>\n                    {% else %}\n                        <h2>\n                            {% if entry.isAdult %}<small class=\"badge danger\">18+</small>{% endif %}\n                            {% if entry.isOc %}<small class=\"badge kbin-bg\">OC</small>{% endif %}\n                            <a href=\"{{ entry_url(entry) }}\">{{ entry.title }}</a>\n\n                            {% if entry.url %}\n                                <span class=\"entry__domain\">\n                                    ( <a rel=\"{{ get_rel(entry.url) }}\" href=\"{{ entry.url }}\" target=\"_blank\">\n                                        <span>{{ get_url_domain(entry.url) }}</span> <i class=\"fa-solid fa-external-link\" aria-hidden=\"true\"></i>\n                                    </a> )\n                                </span>\n                            {% endif %}\n\n                            {% if entry.lang is not same as app.request.locale and entry.lang is not same as kbin_default_lang() %}\n                                <small class=\"badge-lang kbin-bg\">{{ entry.lang|language_name }}</small>\n                            {% endif %}\n                        </h2>\n                    {% endif %}\n                {% elseif(entry.visibility is same as 'trashed') %}\n                    <p class=\"text-muted\">&lsqb;<i>{{ 'deleted_by_moderator'|trans }}</i>&rsqb;</p>\n                {% elseif(entry.visibility is same as 'soft_deleted') %}\n                    <p class=\"text-muted\">&lsqb;<i>{{ 'deleted_by_author'|trans }}</i>&rsqb;</p>\n                {% endif %}\n            </header>\n            {% if entry.visibility in ['visible', 'private'] or (entry.visibility is same as 'trashed' and this.canSeeTrashed) %}\n                {% if entry.body and showShortSentence %}\n                    <div class=\"content short-desc\">\n                        <p>{{ get_short_sentence(entry.body|markdown|raw, striptags = true) }}</p>\n                    </div>\n                {% endif %}\n                {% if entry.body and showBody %}\n                    <div data-controller=\"collapsable\" class=\"entry__body\">\n                        <div data-collapsable-target=\"content\" class=\"content formatted\">\n                            {{ entry.body|markdown(\"entry\")|raw }}\n                        </div>\n                        <div data-collapsable-target=\"button\"></div>\n                    </div>\n                {% endif %}\n            {% endif %}\n            <aside class=\"meta entry__meta\">\n                <span>{{ component('user_inline', {user: entry.user, showAvatar: SHOW_USER_AVATARS is same as V_TRUE, showNewIcon: true, fullName: SHOW_USER_FULLNAME is same as V_TRUE}) -}}</span>\n\n                {% if (entry.user.type) == \"Service\" %}\n                    <span class=\"user-badge\">{{ 'user_badge_bot'|trans }}</span>\n                {% endif %}\n\n                {% if entry.user.admin() %}\n                    <span class=\"user-badge\">{{ 'user_badge_admin'|trans }}</span>\n                {% elseif entry.user.moderator() %}\n                    <span class=\"user-badge\">{{ 'user_badge_global_moderator'|trans }}</span>\n                {% elseif entry.magazine.userIsModerator(entry.user) %}\n                    <span class=\"user-badge\">{{ 'user_badge_moderator'|trans }}</span>\n                {% endif %}\n                <span>, </span>\n                <span>{{ component('date', {date: entry.createdAt}) }}</span>\n                <span>{{ component('date_edited', {createdAt: entry.createdAt, editedAt: entry.editedAt}) }}</span>\n                {% if showMagazineName %}\n                    <span>{{ 'to'|trans }} {{ component('magazine_inline', {magazine: entry.magazine, showAvatar: SHOW_MAGAZINE_ICONS is same as V_TRUE, showNewIcon: true, fullName: SHOW_MAGAZINE_FULLNAME is same as V_TRUE}) }}</span>\n                {% endif %}\n            </aside>\n            {% if SHOW_THUMBNAILS is same as V_TRUE %}\n                {% if entry.image %}\n                    {% if entry.type is same as 'link' or entry.type is same as 'video' %}\n                        {{ include('components/_figure_entry.html.twig', {entry: entry, type: 'link'}) }}\n                    {% elseif entry.type is same as 'image' or entry.type is same as 'article' %}\n                        {{ include('components/_figure_entry.html.twig', {entry: entry, type: 'image'}) }}\n                    {% endif %}\n                {% else %}\n                    <div class=\"no-image-placeholder\">\n                        <a href=\"{{ is_route_name('entry_single') ? entry.url : entry_url(entry) }}\"\n                                {%- if entry.type is same as 'link' or entry.type is same as 'video' %} rel=\"{{ get_rel(is_route_name('entry_single') ? entry.url : entry_url(entry)) }}\" {% endif -%}>\n                            <i class=\"fa-solid {% if entry.type is same as 'link' %}fa-link{% else %}fa-message{% endif %}\"></i>\n                        </a>\n                    </div>\n                {% endif %}\n            {% endif %}\n            {% if entry.visibility in ['visible', 'private'] %}\n                {{ component('vote', {\n                    subject: entry,\n                }) }}\n            {% endif %}\n            <aside class=\"entry__preview hidden\" data-preview-target=\"container\"></aside>\n            <footer>\n                {% if entry.visibility in ['visible', 'private'] %}\n                    <menu>\n                        {% if entry.sticky %}\n                            <li>\n                                <span aria-label=\"{{ 'pinned'|trans }}\">\n                                    <i class=\"fa-solid fa-thumbtack\" aria-hidden=\"true\"></i>\n                                </span>\n                            </li>\n                        {% endif %}\n                        {% if entry.type is same as 'article' %}\n                            <li class=\"meta-link\">\n                                <span aria-label=\"{{ 'article'|trans }}\">\n                                    <i class=\"fa-regular fa-newspaper\" aria-hidden=\"true\"></i>\n                                </span>\n                            </li>\n                        {% elseif entry.type is same as 'link' and entry.hasEmbed is same as false %}\n                            <li class=\"meta-link\">\n                                <span aria-label=\"{{ 'link'|trans }}\">\n                                    <i class=\"fa-solid fa-link\" aria-hidden=\"true\"></i>\n                                </span>\n                            </li>\n                        {% endif %}\n                        {% if entry.hasEmbed %}\n                            {% set preview_url = entry.type is same as 'image' and entry.image ? uploaded_asset(entry.image) : entry.url %}\n                            <li>\n                                <button class=\"show-preview\"\n                                        data-action=\"preview#show\"\n                                        aria-label=\"{{ 'preview'|trans }}\"\n                                        data-preview-url-param=\"{{ preview_url }}\"\n                                        data-preview-ratio-param=\"{{ entry.domain and entry.domain.shouldRatio ? true : false }}\">\n                                    <i class=\"fa-solid fa-photo-film\" aria-hidden=\"true\"></i>\n                                </button>\n                            </li>\n                        {% endif %}\n                        <li>\n                            {% if not entry.isLocked %}\n                                <a class=\"stretched-link\"\n                                   href=\"{{ entry_url(entry) ~ '#comments' }}\">\n                                    <span data-subject-target=\"commentsCounter\">{{ entry.commentCount|abbreviateNumber }}</span> {{ 'comments_count'|trans({'%count%': entry.commentCount}) }}\n                                </a>\n                            {% else %}\n                                <span class=\"stretched-link\">\n                                    <i class=\"fa fa-lock\" aria-hidden=\"true\"></i>\n                                    <span data-subject-target=\"commentsCounter\">{{ entry.commentCount|abbreviateNumber }}</span> {{ 'comments_count'|trans({'%count%': entry.commentCount}) }}\n                                </span>\n                            {% endif %}\n                        </li>\n                        <li>\n                            {{ component('boost', {\n                                subject: entry\n                            }) }}\n                        </li>\n                        {% if app.user is defined and app.user is not same as null %}\n                            {{ component('bookmark_standard', { subject: entry }) }}\n                        {% endif %}\n                        {% include 'entry/_menu.html.twig' %}\n\n                        {% if app.user is defined and app.user is not same as null and not showShortSentence %}\n                            {{ component('notification_switch', {target: entry}) }}\n                        {% endif %}\n\n                        <li data-subject-target=\"loader\" style=\"display:none\">\n                            <div class=\"loader\" role=\"status\">\n                                <span class=\"visually-hidden\">Loading...</span>\n                            </div>\n                        </li>\n                    </menu>\n                {% elseif (entry.visibility is same as 'trashed' and this.canSeeTrashed) %}\n                    <menu>\n                        <li>\n                            <form method=\"post\"\n                                  action=\"{{ path('entry_restore', {magazine_name: entry.magazine.name, entry_id: entry.id}) }}\"\n                                  data-action=\"confirmation#ask\" data-confirmation-message-param=\"{{ 'are_you_sure'|trans }}\">\n                                <input type=\"hidden\" name=\"token\" value=\"{{ csrf_token('entry_restore') }}\">\n                                <button type=\"submit\">{{ 'restore'|trans }}</button>\n                            </form>\n                        </li>\n                        {% if app.user is defined and app.user is not same as null and is_bookmarked(app.user, entry) %}\n                            {{ component('bookmark_standard', { subject: entry }) }}\n                        {% endif %}\n                        <li data-subject-target=\"loader\" style=\"display:none\">\n                            <div class=\"loader\" role=\"status\">\n                                <span class=\"visually-hidden\">Loading...</span>\n                            </div>\n                        </li>\n                    </menu>\n                {% else %}\n                    <menu>\n                        {% if app.user is defined and app.user is not same as null and is_bookmarked(app.user, entry) %}\n                            {{ component('bookmark_standard', { subject: entry }) }}\n                        {% endif %}\n                        <li data-subject-target=\"loader\" style=\"display:none\">\n                            <div class=\"loader\" role=\"status\">\n                                <span class=\"visually-hidden\">Loading...</span>\n                            </div>\n                        </li>\n                    </menu>\n                {% endif %}\n                <div data-subject-target=\"container\" class=\"js-container\">\n                </div>\n            </footer>\n        </article>\n    {% endif %}\n{% endif %}\n"
  },
  {
    "path": "templates/components/entry_comment.html.twig",
    "content": "{%- set V_TRUE = constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::TRUE') -%}\n{%- set V_FALSE = constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::FALSE') -%}\n{%- set V_TREE = constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::TREE') -%}\n\n{%- set SHOW_PREVIEW = app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_ENTRIES_SHOW_PREVIEW'), V_FALSE) -%}\n{%- set DYNAMIC_LISTS = app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_GENERAL_DYNAMIC_LISTS'), V_FALSE) -%}\n{%- set VIEW_STYLE = app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::ENTRY_COMMENTS_VIEW'), V_TREE) -%}\n{%- set SHOW_USER_FULLNAME = app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::MBIN_SHOW_USER_DOMAIN'), V_FALSE)  -%}\n{%- set SHOW_MAGAZINE_FULLNAME = app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::MBIN_SHOW_MAGAZINE_DOMAIN'), V_FALSE)  -%}\n\n{% if not app.user or (app.user and not app.user.isBlocked(comment.user)) %}\n    {% if comment.visibility is same as 'private' and (not app.user or not app.user.isFollower(comment.user)) %}\n        <div class=\"section section--small {{ 'comment-level--' ~ this.getLevel() }}\">\n            Private\n        </div>\n    {% else %}\n        <blockquote{{ attributes.defaults({\n            class: html_classes('section comment entry-comment subject', 'comment-level--' ~ this.getLevel(), {\n                'own': app.user and comment.isAuthor(app.user),\n                'author': comment.isAuthor(comment.entry.user),\n                'show-preview': SHOW_PREVIEW is same as V_TRUE and not comment.isAdult,\n            })}).without('id') }}\n                id=\"entry-comment-{{ comment.id }}\"\n                data-controller=\"comment subject mentions comment-collapse html-refresh\"\n                data-comment-collapse-depth-value=\"{{ level }}\"\n                data-subject-parent-value=\"{{ comment.parent ? comment.parent.id : '' }}\"\n                data-action=\"{{- DYNAMIC_LISTS is same as V_TRUE ? 'notifications:Notification@window->subject#notification' : '' -}}\">\n            <header>\n                {% if comment.isAdult %}<span class=\"badge danger\">18+</span>{% endif %}\n                {{ component('user_inline', {user: comment.user, showAvatar: false, showNewIcon: true, fullName: SHOW_USER_FULLNAME is same as V_TRUE}) }}\n\n                {% if comment.entry.user.id() is same as comment.user.id() %}\n                    <span class=\"user-badge\">{{ 'user_badge_op'|trans }}</span>\n                {% endif %}\n\n                {% if (comment.user.type) == \"Service\" %}\n                    <span class=\"user-badge\">{{ 'user_badge_bot'|trans }}</span>\n                {% endif %}\n\n                {% if comment.user.admin() %}\n                    <span class=\"user-badge\">{{ 'user_badge_admin'|trans }}</span>\n                {% elseif comment.user.moderator() %}\n                    <span class=\"user-badge\">{{ 'user_badge_global_moderator'|trans }}</span>\n                {% elseif comment.magazine.userIsModerator(comment.user) %}\n                    <span class=\"user-badge\">{{ 'user_badge_moderator'|trans }}</span>\n                {% endif %}\n                <span>, </span>\n\n                {% if dateAsUrl %}\n                    <a href=\"{{ entry_comment_view_url(comment) }}#{{ get_url_fragment(comment) }}\"\n                       class=\"link-muted\">{{ component('date', {date: comment.createdAt}) }}</a>\n                {% else %}\n                    {{ component('date', {date: comment.createdAt}) }}\n                {% endif %}\n                {{ component('date_edited', {createdAt: comment.createdAt, editedAt: comment.editedAt}) }}\n                {% if showMagazineName %}{{ 'to'|trans }} {{ component('magazine_inline', {magazine: comment.magazine, fullName: SHOW_MAGAZINE_FULLNAME is same as V_TRUE}) }}{% endif %}\n                {% if showEntryTitle %}{{ 'in'|trans }} {{ component('entry_inline', {entry: comment.entry}) }}{% endif %}\n                {% if comment.lang is not same as app.request.locale and comment.lang is not same as kbin_default_lang() %}\n                    <small hidden class=\"badge-lang\">{{ comment.lang|language_name }}</small>\n                {% endif %}\n            </header>\n\n            {{ component('user_avatar', {\n                user: comment.user,\n                width: 40,\n                height: 40,\n                asLink: true\n            }) }}\n\n            <div data-controller=\"collapsable\">\n                <div data-collapsable-target=\"content\" class=\"content\">\n                    {% if comment.visibility in ['visible', 'private'] or (comment.visibility is same as 'trashed' and this.canSeeTrashed) %}\n                        {{ comment.body|markdown(\"entry\")|raw }}\n                    {% elseif(comment.visibility is same as 'trashed') %}\n                        <p class=\"text-muted\">&lsqb;<i>{{ 'deleted_by_moderator'|trans }}</i>&rsqb;</p>\n                    {% elseif(comment.visibility is same as 'soft_deleted') %}\n                        <p class=\"text-muted\">&lsqb;<i>{{ 'deleted_by_author'|trans }}</i>&rsqb;</p>\n                    {% endif %}\n                </div>\n                <div data-collapsable-target=\"button\"></div>\n            </div>\n            <div class=\"aside\">\n                {% if comment.visibility in ['visible', 'private'] %}\n                    {{ component('vote', { subject: comment }) }}\n                {% endif %}\n                {{ include('components/_comment_collapse_button.html.twig', {\n                    comment: comment,\n                    showNested: showNested,\n                }) }}\n            </div>\n            <footer>\n                {% if (comment.visibility in ['visible', 'private'] or comment.visibility is same as 'trashed' and this.canSeeTrashed) and comment.image %}\n                    {{ include('components/_figure_image.html.twig', {\n                        image: comment.image,\n                        parent_id: comment.id,\n                        is_adult: comment.isAdult,\n                        thumb_filter: 'post_thumb',\n                        gallery_name: 'ec-%d'|format(comment.id),\n                    }) }}\n                {% endif %}\n                {% if comment.visibility in ['visible', 'private'] %}\n                    <menu>\n                        <li>\n                            <a class=\"stretched-link\"\n                               href=\"{{ entry_comment_create_url(comment) }}#add-comment\"\n                               data-action=\"subject#getForm\">{{ 'reply'|trans }}</a>\n                        </li>\n                        <li>\n                            {{ component('boost', {subject: comment}) }}\n                        </li>\n                        {% if app.user is defined and app.user is not same as null %}\n                            {{ component('bookmark_standard', { subject: comment }) }}\n                        {% endif %}\n                        {% include 'entry/comment/_menu.html.twig' %}\n                        <li data-subject-target=\"loader\" style=\"display:none\">\n                            <div class=\"loader\" role=\"status\">\n                                <span class=\"visually-hidden\">Loading...</span>\n                            </div>\n                        </li>\n                    </menu>\n                {% elseif(comment.visibility is same as 'trashed' and this.canSeeTrashed) %}\n                    <menu>\n                        <li>\n                            <form method=\"post\"\n                                  action=\"{{ path('entry_comment_restore', {magazine_name: comment.magazine.name, entry_id: comment.entry.id, comment_id: comment.id}) }}\"\n                                  data-action=\"confirmation#ask\" data-confirmation-message-param=\"{{ 'are_you_sure'|trans }}\">\n                                <input type=\"hidden\" name=\"token\" value=\"{{ csrf_token('entry_comment_restore') }}\">\n                                <button type=\"submit\">{{ 'restore'|trans }}</button>\n                            </form>\n                        </li>\n                        {% if app.user is defined and app.user is not same as null and is_bookmarked(app.user, comment) %}\n                            {{ component('bookmark_standard', { subject: comment }) }}\n                        {% endif %}\n                        <li data-subject-target=\"loader\" style=\"display:none\">\n                            <div class=\"loader\" role=\"status\">\n                                <span class=\"visually-hidden\">Loading...</span>\n                            </div>\n                        </li>\n                    </menu>\n                {% else %}\n                    <menu>\n                        {% if app.user is defined and app.user is not same as null and is_bookmarked(app.user, comment) %}\n                            {{ component('bookmark_standard', { subject: comment }) }}\n                        {% endif %}\n                        <li data-subject-target=\"loader\" style=\"display:none\">\n                            <div class=\"loader\" role=\"status\">\n                                <span class=\"visually-hidden\">Loading...</span>\n                            </div>\n                        </li>\n                    </menu>\n                {% endif %}\n                <div data-subject-target=\"container\" class=\"js-container\">\n                </div>\n            </footer>\n        </blockquote>\n    {% endif %}\n    {% if showNested %}\n        {{ component('entry_comments_nested', {\n            comment: comment,\n            level: level,\n            showNested: true,\n            view: VIEW_STYLE,\n            criteria: criteria,\n        }) }}\n    {% endif %}\n{% endif %}\n"
  },
  {
    "path": "templates/components/entry_comment_inline_md.html.twig",
    "content": "{% if rich is same as true %}\n    <span class=\"entry-comment-inline\">\n        {{ component('user_inline', {user: comment.user, fullName: userFullName, showNewIcon: true}) }}:\n        <a href=\"{{ path('entry_comment_view', {magazine_name: comment.magazine.name, entry_id: comment.entry.id, comment_id: comment.id, slug: comment.entry.slug|length ? comment.entry.slug : '-'}) }}\">\n            {% if comment.image is not same as null %}\n                <i class=\"fa-solid fa-photo-film\"></i>\n            {% endif %}\n            {{ comment.getShortTitle() }}\n        </a>\n        {{ 'answered'|trans }}\n        {% if comment.parent is not same as null %}\n            <a href=\"{{ path('entry_comment_view', {magazine_name: comment.magazine.name, entry_id: comment.entry.id, comment_id: comment.parent.id, slug: comment.entry.slug|length ? comment.entry.slug : '-'}) }}\">\n                {{ comment.parent.getShortTitle() }}\n            </a>\n            {{ 'by'|trans }}\n            {{ component('user_inline', {user: comment.parent.user, fullName: userFullName, showNewIcon: true}) }}\n        {% else %}\n            <a href=\"{{ path('entry_single', {magazine_name: comment.magazine.name, entry_id: comment.entry.id, slug: comment.entry.slug|length ? comment.entry.slug : '-'}) }}\">\n                {% if comment.entry.image is not same as null %}\n                    <i class=\"fa-solid fa-photo-film\"></i>\n                {% endif %}\n                {{ comment.entry.getShortTitle() }}\n            </a>\n            {{ 'by'|trans }}\n            {{ component('user_inline', {user: comment.entry.user, fullName: userFullName, showNewIcon: true}) }}\n        {% endif %}\n        {% if comment.magazine.name is not same as 'random' %}\n            {{ 'in'|trans }}\n            {{ component('magazine_inline', {magazine: comment.magazine, fullName: magazineFullName, showNewIcon: true}) }}\n        {% endif %}\n    </span>\n{% else %}\n    <a href=\"{{ path('entry_comment_view', {magazine_name: comment.magazine.name, entry_id: comment.entry.id, comment_id: comment.id, slug: comment.entry.slug|length ? comment.entry.slug : '-'}) }}\">\n        {% if comment.apId is same as null %}\n            {{ url('entry_comment_view', {magazine_name: comment.magazine.name, entry_id: comment.entry.id, comment_id: comment.id, slug: '-'}) }}\n        {% else %}\n            {{ comment.apId }}\n        {% endif %}\n    </a>\n{% endif %}\n"
  },
  {
    "path": "templates/components/entry_comments_nested.html.twig",
    "content": "{% if view is same as constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::CLASSIC') %}\n    {% for reply in comment.nested %}\n        {{ component('entry_comment', {\n            comment: reply,\n            showNested: false,\n            level: 3,\n            showEntryTitle: false,\n            showMagazineName: false\n        }) }}\n    {% endfor %}\n{% else %}\n    {% for reply in comment.getChildrenByCriteria(criteria, mbin_downvotes_mode(), app.user, 'comments') %}\n        {{ component('entry_comment', {\n            comment: reply,\n            showNested: true,\n            level: level + 1,\n            showEntryTitle: false,\n            showMagazineName: false,\n            criteria: criteria,\n        }) }}\n    {% endfor %}\n{% endif %}\n"
  },
  {
    "path": "templates/components/entry_cross.html.twig",
    "content": "{%- set V_TRUE = constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::TRUE') -%}\n{%- set V_FALSE = constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::FALSE') -%}\n{%- set SHOW_USER_FULLNAME = app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::MBIN_SHOW_USER_DOMAIN'), V_FALSE) -%}\n{%- set SHOW_MAGAZINE_FULLNAME = app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::MBIN_SHOW_MAGAZINE_DOMAIN'), V_FALSE) -%}\n<article{{ attributes.defaults({\n    class: html_classes('entry entry-cross section section--small subject', {\n        'own': app.user and entry.isAuthor(app.user),\n    })}).without('id') }}\n        id=\"entry-{{ entry.id }}\"\n        data-controller=\"subject preview mentions\"\n        data-action=\"notifications:Notification@window->subject#notification\">\n    <aside class=\"meta entry__meta\">\n        {% if entry.visibility in ['visible', 'private'] or (entry.visibility is same as 'trashed' and this.canSeeTrashed) %}\n            <div>\n                {{- 'related_entry'|trans }}: {% if entry.isAdult %}<small class=\"danger\">18+</small>{% endif %}\n                <a href=\"{{ entry_url(entry) }}\" title=\"{{ entry.title }}\">{{ entry.title }}</a>\n            </div>\n        {% elseif(entry.visibility is same as 'trashed') %}\n            <p class=\"text-muted\">&lsqb;<i>{{ 'deleted_by_moderator'|trans }}</i>&rsqb;</p>\n        {% elseif(entry.visibility is same as 'soft_deleted') %}\n            <p class=\"text-muted\">&lsqb;<i>{{ 'deleted_by_author'|trans }}</i>&rsqb;</p>\n        {% endif %}\n        <div>\n            {{ component('user_inline', {user: entry.user, showAvatar: false, showNewIcon: true, fullName: SHOW_USER_FULLNAME is same as V_TRUE}) -}}\n            ,\n            {{ component('date', {date: entry.createdAt}) }}\n            {{ component('date_edited', {createdAt: entry.createdAt, editedAt: entry.editedAt}) }}\n            {{ 'to'|trans }} {{ component('magazine_inline', {magazine: entry.magazine, showAvatar: false, showNewIcon: true, fullName: SHOW_MAGAZINE_FULLNAME is same as V_TRUE}) }}\n        </div>\n        <footer>\n            {% if entry.visibility in ['visible', 'private'] %}\n                <menu>\n                    <li>\n                        <a class=\"stretched-link\"\n                           href=\"{{ entry_url(entry) ~ '#comments' }}\">\n                            <span data-subject-target=\"commentsCounter\">{{ entry.commentCount }}</span> {{ 'comments_count'|trans({'%count%': entry.commentCount}) }}\n                        </a>\n                    </li>\n                    <li>\n                        {{ component('boost', {\n                            subject: entry\n                        }) }}\n                    </li>\n                    {% include 'entry/_menu.html.twig' %}\n                    <li data-subject-target=\"loader\" style=\"display:none\">\n                        <div class=\"loader\" role=\"status\">\n                            <span class=\"visually-hidden\">Loading...</span>\n                        </div>\n                    </li>\n                </menu>\n            {% elseif(entry.visibility is same as 'trashed' and this.canSeeTrashed) %}\n                <menu>\n                    <li>\n                        <form method=\"post\"\n                              action=\"{{ path('entry_restore', {magazine_name: entry.magazine.name, entry_id: entry.id}) }}\"\n                              data-action=\"confirmation#ask\" data-confirmation-message-param=\"{{ 'are_you_sure'|trans }}\">\n                            <input type=\"hidden\" name=\"token\" value=\"{{ csrf_token('entry_restore') }}\">\n                            <button type=\"submit\">{{ 'restore'|trans|lower }}</button>\n                        </form>\n                    </li>\n                    <li data-subject-target=\"loader\" style=\"display:none\">\n                        <div class=\"loader\" role=\"status\">\n                            <span class=\"visually-hidden\">Loading...</span>\n                        </div>\n                    </li>\n                </menu>\n            {% else %}\n                <menu>\n                    <li data-subject-target=\"loader\" style=\"display:none\">\n                        <div class=\"loader\" role=\"status\">\n                            <span class=\"visually-hidden\">Loading...</span>\n                        </div>\n                    </li>\n                </menu>\n            {% endif %}\n            <div data-subject-target=\"container\" class=\"js-container\">\n            </div>\n        </footer>\n    </aside>\n    <aside class=\"meta entry__preview hidden\" data-preview-target=\"container\"></aside>\n    {% if entry.visibility in ['visible', 'private'] %}\n        {{ component('vote', {\n            subject: entry,\n        }) }}\n    {% endif %}\n</article>\n"
  },
  {
    "path": "templates/components/entry_inline.html.twig",
    "content": "<a href=\"{{ path('entry_single', {magazine_name: entry.magazine.name, entry_id: entry.id, slug: entry.slug|length ? entry.slug : '-'}) }}\">{{ entry.title }}</a>"
  },
  {
    "path": "templates/components/entry_inline_md.html.twig",
    "content": "{% if rich is same as true %}\n    <span class=\"entry-inline\">\n        {{ component('user_inline', {user: entry.user, fullName: userFullName, showNewIcon: true}) }}:\n        <a href=\"{{ path('entry_single', {magazine_name: entry.magazine.name, entry_id: entry.id, slug: entry.slug|length ? entry.slug : '-'}) }}\">\n            {% if entry.image is not same as null %}\n                <i class=\"fa-solid fa-photo-film\"></i>\n            {% endif %}\n            {{ entry.title }}\n        </a>\n        {% if entry.magazine.name is not same as 'random' %}\n            {{ 'in'|trans }}\n            {{ component('magazine_inline', {magazine: entry.magazine, fullName: magazineFullName, showNewIcon: true}) }}\n        {% endif %}\n    </span>\n{% else %}\n    <a href=\"{{ path('entry_single', {magazine_name: entry.magazine.name, entry_id: entry.id, slug: entry.slug|length ? entry.slug : '-'}) }}\">\n        {% if entry.apId is same as null %}\n            {{ url('entry_single', {magazine_name: entry.magazine.name, entry_id: entry.id, slug: '-'}, ) }}\n        {% else %}\n            {{ entry.apId }}\n        {% endif %}\n    </a>\n{% endif %}\n"
  },
  {
    "path": "templates/components/favourite.html.twig",
    "content": "<form method=\"post\"\n      action=\"{{ path(formDest~'_favourite', {id: subject.id}) }}\">\n    <button class=\"{{ html_classes('stretched-link', {'active': app.user and subject.isFavored(app.user) }) }}\"\n            type=\"submit\"\n            data-action=\"subject#favourite\">\n        {{ 'favourites'|trans }} <span class=\"{{ html_classes({'hidden': not subject.favouriteCount}) }}\">\n            (<span data-subject-target=\"favCounter\">{{ subject.favouriteCount }}</span>)\n        </span>\n    </button>\n</form>\n"
  },
  {
    "path": "templates/components/featured_magazines.html.twig",
    "content": "<menu>\n    {% for mag in magazines %}\n        <li class=\"{{ html_classes({'active': magazine and magazine.name is same as mag}) }}\">\n            <a class=\"stretched-link\" href=\"{{ path('front_magazine', {name: mag}) }}\">{{ mag }}</a>\n        </li>\n    {% endfor %}\n</menu>\n"
  },
  {
    "path": "templates/components/filter_list.html.twig",
    "content": "<div class=\"filter-list\">\n    <div class=\"section\">\n        <h3>\n            <a href=\"{{ path('user_settings_filter_lists_edit', {id: list.id}) }}\">{{ list.name }}{% if list.isExpired %} ({{ 'expired'|trans|lower }}){% endif %}</a>\n        </h3>\n        <div>\n            {{ 'filter_lists_filter_words'|trans }}:\n            <span>\n                {{ list.words|map(x => x.word)|join(', ') }}\n            </span>\n        </div>\n        <div>\n            {{ 'filter_lists_filter_location'|trans }}:\n            <span>\n                {{ list.getRealmStrings()|map(location => location|trans)|join(', ') }}\n            </span>\n        </div>\n        <div class=\"flex flex-reverse\">\n            <div class=\"flex-item flex-item-auto\">\n                <a href=\"{{ path('user_settings_filter_lists_delete', {id: list.id}) }}\">\n                    <i class=\"fas fa-trash\"></i>\n                    {{ 'delete'|trans }}\n                </a>\n            </div>\n            <div class=\"flex-item flex-item-auto\">\n                <a href=\"{{ path('user_settings_filter_lists_edit', {id: list.id}) }}\">\n                    <i class=\"fa fa-edit\"></i>\n                    {{ 'edit'|trans }}\n                </a>\n            </div>\n        </div>\n    </div>\n</div>\n"
  },
  {
    "path": "templates/components/instance_list.html.twig",
    "content": "<table>\n    <thead>\n    <tr>\n        <th>{{ 'domain'|trans }}</th>\n        <th>{{ 'server_software'|trans }}</th>\n        <th>{{ 'version'|trans }}</th>\n        {% if app.user is defined and app.user is not same as null and app.user.admin %}\n            <th>{{ 'last_successful_deliver'|trans }}</th>\n            <th>{{ 'last_successful_receive'|trans }}</th>\n            {% if showUnBanButton or showBanButton or showDenyButton or showAllowButton %}\n                <th></th>\n            {% endif %}\n        {% endif %}\n    </tr>\n    </thead>\n    <tbody>\n    {% for instance in instances %}\n        <tr>\n            <td><a href=\"https://{{ instance.domain }}\" rel=\"noopener noreferrer nofollow\">{{instance.domain}}</a></td>\n            <td>{{ instance.software ?? '' }}</td>\n            <td>{{ instance.version ?? '' }}</td>\n            {% if app.user is defined and app.user is not same as null and app.user.admin %}\n                <td>\n                    {% if instance.lastSuccessfulDeliver is not same as null %}\n                        {{ component('date', { date: instance.lastSuccessfulDeliver }) }}\n                    {% endif %}\n                </td>\n                <td>\n                    {% if instance.lastSuccessfulReceive is not same as null %}\n                        {{ component('date', { date: instance.lastSuccessfulReceive }) }}\n                    {% endif %}\n                </td>\n                {% if showUnBanButton or showBanButton or showDenyButton or showAllowButton %}\n                    <td>\n                        <div class=\"actions row\" style=\"width: max-content\" data-controller=\"\">\n                            {% if showUnBanButton and instance.isBanned %}\n                                <form action=\"{{ url('admin_federation_unban_instance') }}\">\n                                    <input type=\"hidden\" name=\"instanceDomain\" value=\"{{ instance.domain }}\">\n                                    <button data-action=\"confirmation#ask\" data-confirmation-message-param=\"{{ 'are_you_sure'|trans }}\"\n                                        class=\"btn btn__primary\" type=\"submit\">\n                                        {{ 'unban'|trans }}\n                                    </button>\n                                </form>\n                            {% endif %}\n                            {% if showBanButton and not instance.isBanned %}\n                                <form action=\"{{ url('admin_federation_ban_instance') }}\">\n                                    <input type=\"hidden\" name=\"instanceDomain\" value=\"{{ instance.domain }}\">\n                                    <button class=\"btn btn__primary\" type=\"submit\">\n                                        {{ 'ban'|trans }}\n                                    </button>\n                                </form>\n                            {% endif %}\n                            {% if showDenyButton and instance.isExplicitlyAllowed %}\n                                <form action=\"{{ url('admin_federation_deny_instance') }}\">\n                                    <input type=\"hidden\" name=\"instanceDomain\" value=\"{{ instance.domain }}\">\n                                    <button class=\"btn btn__primary\" type=\"submit\">\n                                        {{ 'btn_deny'|trans }}\n                                    </button>\n                                </form>\n                            {% endif %}\n                            {% if showAllowButton and not instance.isExplicitlyAllowed %}\n                                <form action=\"{{ url('admin_federation_allow_instance') }}\">\n                                    <input type=\"hidden\" name=\"instanceDomain\" value=\"{{ instance.domain }}\">\n                                    <button data-action=\"confirmation#ask\" data-confirmation-message-param=\"{{ 'are_you_sure'|trans }}\"\n                                        class=\"btn btn__primary\" type=\"submit\">\n                                        {{ 'btn_allow'|trans }}\n                                    </button>\n                                </form>\n                            {% endif %}\n                        </div>\n                    </td>\n                {% endif %}\n            {% endif %}\n        </tr>\n    {% endfor %}\n    </tbody>\n</table>\n"
  },
  {
    "path": "templates/components/loader.html.twig",
    "content": "<div{{ attributes.defaults({class: 'loading'}) }}>\n    <div class=\"loader\" role=\"status\">\n        <span class=\"visually-hidden\">Loading...</span>\n    </div>\n</div>\n"
  },
  {
    "path": "templates/components/login_socials.html.twig",
    "content": "{# @var this App\\Twig\\Components\\LoginSocialsComponent #}\n{%- set HAS_ANY_SOCIAL = this.googleEnabled or this.facebookEnabled or this.discordEnabled or this.githubEnabled or this.keycloakEnabled or this.simpleloginEnabled or this.zitadelEnabled or this.authentikEnabled or this.azureEnabled or this.privacyPortalEnabled -%}\n{% if HAS_ANY_SOCIAL %}\n    {% if not mbin_sso_only_mode() and not mbin_sso_show_first() %}\n    <div class=\"separator separator-show\"></div>\n    {% endif %}\n    <div class=\"social\">\n        {% if this.googleEnabled %}\n            <a href=\"{{ path('oauth_google_connect') }}\" class=\"btn btn__secondary\"><i class=\"fa-brands fa-google\" aria-hidden=\"true\"></i>\n                <span>{{ 'continue_with'|trans }} Google</span></a>\n        {% endif %}\n        {% if this.facebookEnabled %}\n            <a href=\"{{ path('oauth_facebook_connect') }}\" class=\"btn btn__secondary\"><i class=\"fa-brands fa-facebook\" aria-hidden=\"true\"></i>\n                <span>{{ 'continue_with'|trans }} Facebook</span></a>\n        {% endif %}\n        {% if this.githubEnabled %}\n            <a href=\"{{ path('oauth_github_connect') }}\" class=\"btn btn__secondary\"><i class=\"fa-brands fa-github\" aria-hidden=\"true\"></i>\n                <span>{{ 'continue_with'|trans }} GitHub</span></a>\n        {% endif %}\n        {% if this.discordEnabled %}\n            <a href=\"{{ path('oauth_discord_connect') }}\" class=\"btn btn__secondary\"><i class=\"fa-brands fa-discord\" aria-hidden=\"true\"></i>\n                <span>{{ 'continue_with'|trans }} Discord</span></a>\n        {% endif %}\n        {% if this.keycloakEnabled %}\n            <a href=\"{{ path('oauth_keycloak_connect') }}\" class=\"btn btn__secondary\"><i class=\"fa-solid fa-lock\" aria-hidden=\"true\"></i>\n                <span>{{ 'continue_with'|trans }} Keycloak</span></a>\n        {% endif %}\n        {% if this.simpleloginEnabled %}\n            <a href=\"{{ path('oauth_simplelogin_connect') }}\" class=\"btn btn__secondary\"><i class=\"si si-simplelogin\" aria-hidden=\"true\"></i>\n                <span>{{ 'continue_with'|trans }} SimpleLogin</span></a>\n        {% endif %}\n        {% if this.zitadelEnabled %}\n            <a href=\"{{ path('oauth_zitadel_connect') }}\" class=\"btn btn__secondary\"><i class=\"fa-solid fa-lock\" aria-hidden=\"true\"></i>\n                <span>{{ 'continue_with'|trans }} Zitadel</span></a>\n        {% endif %}\n        {% if this.authentikEnabled %}\n            <a href=\"{{ path('oauth_authentik_connect') }}\" class=\"btn btn__secondary\"><i class=\"si si-authentik\" aria-hidden=\"true\"></i>\n                <span>{{ 'continue_with'|trans }} Authentik</span></a>\n        {% endif %}\n        {% if this.azureEnabled %}\n            <a href=\"{{ path('oauth_azure_connect') }}\" class=\"btn btn__secondary\"><i class=\"fa-brands fa-microsoft\" aria-hidden=\"true\"></i>\n                <span>{{ 'continue_with'|trans }} Microsoft</span></a>\n        {% endif %}\n        {% if this.privacyPortalEnabled %}\n            <a href=\"{{ path('oauth_privacyportal_connect') }}\" class=\"btn btn__secondary\"><i class=\"fa-brands fa-privacyportal\" aria-hidden=\"true\"></i>\n                <span>{{ 'continue_with'|trans }} Privacy Portal</span></a>\n        {% endif %}\n    </div>\n    {% if not mbin_sso_only_mode() and mbin_sso_show_first() %}\n    <div class=\"separator separator-show\"></div>\n    {% endif %}\n{% endif %}\n"
  },
  {
    "path": "templates/components/magazine_box.html.twig",
    "content": "<section{{ attributes.defaults({class: 'magazine section'}) }}>\n    {% if showSectionTitle %}\n        <h3>{{ 'magazine'|trans }}</h3>\n    {% endif %}\n    {% if app.user and (magazine.userIsOwner(app.user) or is_granted('ROLE_ADMIN') or is_granted('ROLE_MODERATOR')) and not is_route_name_contains('magazine_panel') %}\n        <div class=\"panel\">\n            <a href=\"{{ path('magazine_panel_general', {name: magazine.name}) }}\" class=\"btn btn__primary\">Magazine panel</a>\n        </div>\n    {% endif %}\n    <div class=\"row\">\n        {% if computed.magazine.icon and showCover and (app.user or magazine.isAdult is same as false) %}\n            <figure>\n                <img class=\"image-inline {{ magazine.isAdult ? 'image-adult' : '' }}\"\n                     loading=\"lazy\"\n                     src=\"{{ computed.magazine.icon.filePath ? (asset(computed.magazine.icon.filePath)|imagine_filter('post_thumb')) : computed.magazine.icon.sourceUrl }}\"\n                     {% if magazine.isAdult %}data-controller=\"thumb\" data-action=\"mouseover->thumb#adultImageHover mouseout->thumb#adultImageHoverOut\"{% endif %}\n                     alt=\"{{ computed.magazine.name ~ ' ' ~ 'icon'|trans|lower }}\" width=\"260\">\n            </figure>\n        {% endif %}\n        <header>\n            <h4>\n                <a href=\"{{ path('front_magazine', {name: magazine.name}) }}\" class=\"{{ html_classes({'stretched-link': false}) }}\">\n                    {{ computed.magazine.title }}\n                </a>\n                {% if magazine.postingRestrictedToMods %}\n                    <i class=\"fa-solid fa-lock\"\n                       aria-description=\"{{ 'magazine_posting_restricted_to_mods_warning'|trans }}\"\n                       title=\"{{ 'magazine_posting_restricted_to_mods_warning'|trans }}\"\n                       aria-describedby=\"{{ 'magazine_posting_restricted_to_mods_warning'|trans }}\"></i>\n                {% endif %}\n                {% if magazine.isNew() %}\n                    {% set days = constant('App\\\\Entity\\\\Magazine::NEW_FOR_DAYS') %}\n                    <i class=\"fa-solid fa-leaf new-magazine-icon\" title=\"{{ 'new_magazine_description'|trans({ '%days%': days }) }}\" aria-description=\"{{ 'new_magazine_description'|trans({ '%days%': days }) }}\"></i>\n                {% endif %}\n            </h4>\n            <p class=\"magazine__name\">\n                <span>{{ ('@'~magazine.name)|username(true) }} {% if magazine.isAdult %}<small class=\"badge danger\">18+</small>{% endif %}</span>\n                {% if magazine.apId %}\n                    <a href=\"{{ magazine.apProfileId }}\" rel=\"noopener noreferrer nofollow\" target=\"_blank\" title=\"{{ 'go_to_original_instance'|trans }}\" aria-label=\"{{ 'go_to_original_instance'|trans }}\">\n                    <i class=\"fa-solid fa-external-link\" aria-hidden=\"true\"></i></a>\n                {% endif %}\n            </p>\n        </header>\n    </div>\n    {{ component('magazine_sub', {magazine: magazine}) }}\n\n    {% if app.user is defined and app.user is not same as null %}\n        <div class=\"notification-switch-container\" data-controller=\"html-refresh\">\n            {{ component('notification_switch', {target: magazine}) }}\n        </div>\n    {% endif %}\n\n    {% if computed.magazine.description and showDescription %}\n        <div class=\"content magazine__description\">{{ computed.magazine.description|markdown|raw }}</div>\n    {% endif %}\n    {% if computed.magazine.rules and showRules %}\n        <h3 class=\"mt-3\">{{ 'rules'|trans }}</h3>\n        <div class=\"content magazine__rules\">{{ computed.magazine.rules|markdown|raw }}</div>\n    {% endif %}\n    {% if showInfo %}\n        <ul class=\"info\">\n            <li>{{ 'created_at'|trans }}:\n                {{ component('date', {date: computed.magazine.createdAt}) }}\n            </li>\n            {% if app.user is defined and app.user is not null and app.user.admin() and computed.magazine.apId is not null and computed.magazine.apFetchedAt is not same as null %}\n                <li>\n                    {{ 'last_updated'|trans }}: {{ component('date', {date: computed.magazine.apFetchedAt}) }}\n                </li>\n            {% endif %}\n            <li>{{ 'subscribers'|trans }}: <span>{{ computed.magazine.subscriptionsCount|abbreviateNumber }}</span></li>\n            {% set instance = get_instance_of_magazine(computed.magazine) %}\n            {% if instance is not same as null %}\n                <li>{{ 'server_software'|trans }}: <div><span>{{ instance.software }}{% if instance.version is not same as null and app.user is defined and app.user is not null and app.user.admin() %} v{{ instance.version }}{% endif %}</span></div></li>\n            {% endif %}\n        </ul>\n    {% endif %}\n    {% if showMeta %}\n        <ul class=\"meta\">\n            {{ _self.meta_item( 'threads'|trans, path('front_magazine', {'name': computed.magazine.name }), computed.magazine.entryCount|abbreviateNumber) }}\n            {{ _self.meta_item( 'comments'|trans, path('magazine_entry_comments', {'name': computed.magazine.name}), computed.magazine.entryCommentCount|abbreviateNumber) }}\n            {{ _self.meta_item( 'posts'|trans, path('magazine_posts', {'name': computed.magazine.name}), computed.magazine.postCount|abbreviateNumber) }}\n            {{ _self.meta_item( 'replies'|trans, path('magazine_posts', {'name': computed.magazine.name}), computed.magazine.postCommentCount|abbreviateNumber) }}\n            {{ _self.meta_item( 'moderators'|trans, path('magazine_moderators', {'name': computed.magazine.name}), computed.magazine.getModeratorCount()) }}\n            {{ _self.meta_item( 'mod_log'|trans, path('magazine_modlog', {'name': computed.magazine.name}), computed.magazine.logs|length) }}\n        </ul>\n    {% endif %}\n    {% macro meta_item(name, url, count) %}\n        <li><a href=\"{{ url }}\" class=\"{{ html_classes({'stretched-link': false}) }}\">{{ name }}</a> <span>{{ count }}</span></li>\n    {% endmacro %}\n    {% if showTags and magazine.tags %}\n        <h3 class=\"mt-3\">{{ 'tags'|trans }}</h3>\n        <div class=\"mt-2\">\n            {% for tag in magazine.tags %}\n                <small><a class=\"badge\" href=\"{{ path('tag_overview', {name: tag}) }}\">#{{ tag }}</a></small>\n            {% endfor %}\n        </div>\n    {% endif %}\n</section>\n"
  },
  {
    "path": "templates/components/magazine_inline.html.twig",
    "content": "<a href=\"{{ path('front_magazine', {name: magazine.name}) }}\"\n   class=\"{{ html_classes('magazine-inline', {'stretched-link': stretchedLink }) }}\"\n   title=\"{{ ('@'~magazine.name)|username(true) }}\">\n    {% if magazine.icon and showAvatar and (app.user or magazine.isAdult is same as false) %}\n        <img class=\"image-inline {{ magazine.isAdult ? 'image-adult' : '' }}\"\n             width=\"30\" height=\"30\"\n             loading=\"lazy\"\n             style=\"max-width: 30px; max-height: 30px;\"\n             {% if magazine.isAdult %}data-controller=\"thumb\" data-action=\"mouseover->thumb#adultImageHover mouseout->thumb#adultImageHoverOut\"{% endif %}\n             src=\"{{ magazine.icon.filePath ? (asset(magazine.icon.filePath)|imagine_filter('avatar_thumb')) : magazine.icon.sourceUrl }}\"\n             alt=\"{{ magazine.name ~ ' ' ~ 'icon'|trans|lower }}\">\n    {% endif %}\n    <span>{{ magazine.title -}}</span>\n    {%- if fullName -%}\n        <span>@{{- magazine.name|apDomain -}}</span>\n    {%- endif -%}\n    {% if magazine.isAdult %} <small class=\"badge danger\">18+</small>{% endif %}\n    {% if magazine.postingRestrictedToMods %}\n        <i class=\"fa-solid fa-lock\"\n           aria-description=\"{{ 'magazine_posting_restricted_to_mods_warning'|trans }}\"\n           title=\"{{ 'magazine_posting_restricted_to_mods_warning'|trans }}\"\n           aria-describedby=\"{{ 'magazine_posting_restricted_to_mods_warning'|trans }}\"></i>\n    {% endif %}\n    {% if magazine.isNew() and showNewIcon %}\n        {% set days = constant('App\\\\Entity\\\\Magazine::NEW_FOR_DAYS') %}\n        <i class=\"fa-solid fa-leaf new-magazine-icon\" title=\"{{ 'new_magazine_description'|trans({ '%days%': days }) }}\" aria-description=\"{{ 'new_magazine_description'|trans({ '%days%': days }) }}\"></i>\n    {% endif %}\n</a>\n"
  },
  {
    "path": "templates/components/magazine_inline_md.html.twig",
    "content": "{% if rich is same as true %}\n    {{ component('magazine_inline', {\n        magazine: magazine,\n        stretchedLink: stretchedLink,\n        fullName: fullName,\n        showAvatar: showAvatar,\n    }) }}\n{% else %}\n    <a href=\"{{ path('front_magazine', {name: magazine.name}) }}\" title=\"{{ magazine.name|username(true) }}\">\n        !{{- magazine.name|username -}}\n        {%- if fullName -%}\n            @{{- magazine.name|apDomain -}}\n        {%- endif -%}\n    </a>\n{% endif %}\n"
  },
  {
    "path": "templates/components/magazine_sub.html.twig",
    "content": "<aside{{ attributes.defaults({class: 'magazine__subscribe', 'data-controller': 'subs'}) }}>\n    <div class=\"action\"\n        title=\"{{ magazine.subscriptionsCount ~ ' ' ~ 'subscribers_count'|trans({'%count%': magazine.subscriptionsCount}) }}\"\n        aria-label=\"{{ magazine.subscriptionsCount ~ ' ' ~ 'subscribers_count'|trans({'%count%': magazine.subscriptionsCount}) }}\">\n        <i class=\"fa-solid fa-users\" aria-hidden=\"true\"></i><span>{{ magazine.subscriptionsCount|abbreviateNumber }}</span>\n    </div>\n    {% if not is_instance_of_magazine_blocked(magazine) %}\n        <form action=\"{{ path('magazine_' ~ (is_magazine_subscribed(magazine) ? 'unsubscribe' : 'subscribe'), {name: magazine.name}) }}\"\n              name=\"magazine_subscribe\"\n              method=\"post\">\n            <button type=\"submit\"\n                    class=\"{{ html_classes('btn btn__secondary action', {'active': is_magazine_subscribed(magazine)}) }}\"\n                    data-action=\"subs#send\">\n                {% if is_magazine_subscribed(magazine) %}\n                    <i class=\"fa-sharp fa-solid fa-folder-minus\" aria-hidden=\"true\"></i><span>{{ 'unsubscribe'|trans }}</span>\n                {% else %}\n                    <i class=\"fa-sharp fa-solid fa-folder-plus\" aria-hidden=\"true\"></i><span>{{ 'subscribe'|trans }}</span>\n                {% endif %}\n            </button>\n        </form>\n        <form action=\"{{ path('magazine_' ~ (is_magazine_blocked(magazine) ? 'unblock' : 'block'), {name: magazine.name}) }}\"\n              name=\"magazine_block\"\n              method=\"post\">\n            <button type=\"submit\"\n                    class=\"{{ html_classes('btn btn__secondary action', {'active danger': is_magazine_blocked(magazine)}) }}\"\n                    data-action=\"subs#send\">\n                <i class=\"fa-solid fa-ban\" aria-hidden=\"true\"></i><span>{{ is_magazine_blocked(magazine) ? 'unblock'|trans : 'block'|trans }}</span>\n            </button>\n        </form>\n    {% endif %}\n</aside>\n"
  },
  {
    "path": "templates/components/monitoring_twig_render.html.twig",
    "content": "<div class=\"monitoring-twig-render\">\n    {% if compareToParent %}\n        <div style=\"background-color: {{ render.getColorBasedOnPercentageDuration() }}; color: #e4e4e7;\">\n            {{ render.shortDescription }} | {{ render.profilerDuration|round(2) }}ms / {{ render.getPercentageOfParentDuration()|round }}%\n        </div>\n    {% else %}\n        <div style=\"background-color: {{ render.getColorBasedOnPercentageDuration(false) }}; color: #e4e4e7;\">\n            {{ render.shortDescription }} | {{ render.profilerDuration|round(2) }}ms / {{ render.getPercentageOfTotalDuration()|round }}%\n        </div>\n    {% endif %}\n    <div class=\"children\">\n        {% for child in render.children %}\n            {{ component('monitoring_twig_render', {'render': child, 'compareToParent': compareToParent}) }}\n        {% endfor %}\n    </div>\n</div>\n"
  },
  {
    "path": "templates/components/notification_switch.html.twig",
    "content": "<aside class=\"notification-switch\">\n    <a class=\"notification-setting {{ html_classes({\n        active: status is defined and status is same as enum(\"App\\\\Enums\\\\ENotificationStatus\").Muted\n    }) }}\"\n       title=\"{{ 'contentnotification.muted' | trans }}\"\n        {% if status is not defined or status is not same as enum(\"App\\\\Enums\\\\ENotificationStatus\").Muted %}\n            data-html-refresh-cssclass-param=\"notification-switch\" data-action=\"html-refresh#linkCallback\"\n            href=\"{{ path('change_notification_setting', { 'subject_id': target.id, 'subject_type': get_notification_settings_subject_type(target), 'status': enum(\"App\\\\Enums\\\\ENotificationStatus\").Muted.value }) }}\"\n        {% endif %}\n    >\n        <i class=\"fa-solid fa-bell-slash\"></i>\n    </a>\n    <a class=\"notification-setting {{ html_classes({\n        active: status is not defined or status is same as enum(\"App\\\\Enums\\\\ENotificationStatus\").Default\n    }) }}\"\n       title=\"{{ 'contentnotification.default' | trans }}\"\n        {% if status is not defined or status is not same as enum(\"App\\\\Enums\\\\ENotificationStatus\").Default %}\n            data-html-refresh-cssclass-param=\"notification-switch\" data-action=\"html-refresh#linkCallback\"\n            href=\"{{ path('change_notification_setting', { 'subject_id': target.id, 'subject_type': get_notification_settings_subject_type(target), 'status': enum(\"App\\\\Enums\\\\ENotificationStatus\").Default.value }) }}\"\n       {% endif %}\n    >\n        <i class=\"fa-solid fa-bell\"></i>\n    </a>\n    <a class=\"notification-setting {{ html_classes({\n        active: status is defined and status is same as enum(\"App\\\\Enums\\\\ENotificationStatus\").Loud\n    }) }}\"\n       title=\"{{ 'contentnotification.loud' | trans }}\"\n       {% if status is not defined or status is not same as enum(\"App\\\\Enums\\\\ENotificationStatus\").Loud %}\n            data-html-refresh-cssclass-param=\"notification-switch\" data-action=\"html-refresh#linkCallback\"\n            href=\"{{ path('change_notification_setting', { 'subject_id': target.id, 'subject_type': get_notification_settings_subject_type(target), 'status': enum(\"App\\\\Enums\\\\ENotificationStatus\").Loud.value }) }}\"\n       {% endif %}\n    >\n        <i class=\"fa-solid fa-bullhorn\"></i>\n    </a>\n</aside>\n"
  },
  {
    "path": "templates/components/post.html.twig",
    "content": "{%- set V_TRUE = constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::TRUE') -%}\n{%- set V_FALSE = constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::FALSE') -%}\n\n{%- set SHOW_PREVIEW = app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_POSTS_SHOW_PREVIEW'), V_FALSE) -%}\n{%- set SHOW_USER_FULLNAME = app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::MBIN_SHOW_USER_DOMAIN'), V_FALSE) -%}\n{%- set SHOW_MAGAZINE_FULLNAME = app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::MBIN_SHOW_MAGAZINE_DOMAIN'), V_FALSE) -%}\n\n{% if not app.user or (app.user and not app.user.isBlocked(post.user)) %}\n<div data-controller=\"post\" class=\"post-container\">\n    {% if post.visibility is same as 'private' and (not app.user or not app.user.isFollower(post.user)) %}\n        <div class=\"section section--small\">\n            Private\n        </div>\n    {% else %}\n        <blockquote{{ attributes.defaults({\n            class: html_classes('section post subject ', {\n                'own': app.user and post.isAuthor(app.user),\n                'show-preview': SHOW_PREVIEW is same as V_TRUE and not post.isAdult,\n                'isSingle': isSingle is same as true\n            })}).without('id') }}\n                id=\"post-{{ post.id }}\"\n                data-controller=\"subject mentions html-refresh\"\n                data-action=\"notifications:Notification@window->subject#notification\"\n                data-post-target=\"main\">\n\n            {% if post.visibility in ['visible', 'private'] %}\n                {{ component('vote', {\n                    subject: post,\n                    showDownvote: false\n                }) }}\n            {% endif %}\n\n            <header>\n                {% if post.isAdult %}<span class=\"badge danger\">18+</span>{% endif %}\n                {{ component('user_inline', {user: post.user, showAvatar: true, showNewIcon: true, fullName: SHOW_USER_FULLNAME is same as V_TRUE}) }}\n\n                {% if (post.user.type) == \"Service\" %}\n                    <span class=\"user-badge\">{{ 'user_badge_bot'|trans }}</span>\n                {% endif %}\n\n                {% if post.user.admin() %}\n                    <span class=\"user-badge\">{{ 'user_badge_admin'|trans }}</span>\n                {% elseif post.user.moderator() %}\n                    <span class=\"user-badge\">{{ 'user_badge_global_moderator'|trans }}</span>\n                {% elseif post.magazine.userIsModerator(post.user) %}\n                    <span class=\"user-badge\">{{ 'user_badge_moderator'|trans }}</span>\n                {% endif %}\n                <span>, </span>\n                {% if dateAsUrl %}\n                    <a href=\"{{ post_url(post) }}\"\n                       class=\"link-muted\">{{ component('date', {date: post.createdAt}) }}</a>\n                {% else %}\n                    {{ component('date', {date: post.createdAt}) }}\n                {% endif %}\n                {{ component('date_edited', {createdAt: post.createdAt, editedAt: post.editedAt}) }}\n                {% if showMagazineName %}{{ 'to'|trans }} {{ component('magazine_inline', {magazine: post.magazine, fullName: SHOW_MAGAZINE_FULLNAME is same as V_TRUE, showNewIcon: true}) }}{% endif %}\n                {% if post.lang is not same as app.request.locale and post.lang is not same as kbin_default_lang() %}\n                    <small class=\"badge-lang kbin-bg\">{{ post.lang|language_name }}</small>\n                {% endif %}\n            </header>\n\n            <!-- body -->\n            <div data-controller=\"collapsable\">\n                <div data-collapsable-target=\"content\" class=\"content\">\n                    {% if post.visibility in ['visible', 'private'] or (post.visibility is same as 'trashed' and this.canSeeTrashed) %}\n                        {{ post.body|markdown(\"post\")|raw }}\n                    {% elseif(post.visibility is same as 'trashed') %}\n                        <p class=\"text-muted\">&lsqb;<i>{{ 'deleted_by_moderator'|trans }}</i>&rsqb;</p>\n                    {% elseif(post.visibility is same as 'soft_deleted') %}\n                        <p class=\"text-muted\">&lsqb;<i>{{ 'deleted_by_author'|trans }}</i>&rsqb;</p>\n                    {% endif %}\n\n                    {% if post.image %}\n                        {{ include('components/_figure_image.html.twig', {\n                            image: post.image,\n                            parent_id: post.id,\n                            is_adult: post.isAdult,\n                            thumb_filter: 'post_thumb',\n                            gallery_name: 'post-%d'|format(post.id),\n                        }) }}\n                    {% endif %}\n                </div>\n                <div data-collapsable-target=\"button\"></div>\n            </div>\n\n            <footer>\n                {% if post.visibility in ['visible', 'private'] %}\n                    <menu>\n                        {% if post.sticky %}\n                            <li>\n                                <span aria-label=\"{{ 'pinned'|trans }}\">\n                                    <i class=\"fa-solid fa-thumbtack\" aria-hidden=\"true\"></i>\n                                </span>\n                            </li>\n                        {% endif %}\n                        <li>\n                            {% if not post.isLocked %}\n                                <a class=\"stretched-link\"\n                                   href=\"{{ path('post_comment_create', {magazine_name: post.magazine.name, post_id: post.id, slug: post.slug|length ? post.slug : '-'}) }}\"\n                                   data-action=\"subject#getForm\">\n                                    {{ 'reply'|trans }}\n                                </a>\n                            {% else %}\n                                <span class=\"stretched-link\">\n                                    <i class=\"fa fa-lock\" aria-hidden=\"true\"></i>\n                                    {{ 'reply'|trans }}\n                                </span>\n                            {% endif %}\n                        </li>\n                        {% if not is_route_name('post_single', true) and ((not showCommentsPreview and post.commentCount > 0) or post.commentCount > 2) %}\n                            <li data-post-target=\"expand\">\n                                <a class=\"stretched-link\"\n                                   href=\"#{{ get_url_fragment(post) }}\"\n                                   data-action=\"post#expandComments\">{{ 'expand'|trans }} (<span\n                                            data-subject-target=\"commentsCounter\">{{ post.commentCount|abbreviateNumber }}</span>)</a>\n                            </li>\n                            <li data-post-target=\"collapse\"\n                                style=\"display: none\">\n                                <a class=\"stretched-link\"\n                                   href=\"#{{ get_url_fragment(post) }}\"\n                                   data-action=\"post#collapseComments\">{{ 'collapse'|trans }}\n                                    ({{ post.commentCount|abbreviateNumber }})\n                                </a>\n                            </li>\n                        {% endif %}\n                        <li>\n                            {{ component('boost', {\n                                subject: post\n                            }) }}\n                        </li>\n                        {% if app.user is defined and app.user is not same as null %}\n                            {{ component('bookmark_standard', { subject: post }) }}\n                        {% endif %}\n                        {% include 'post/_menu.html.twig' %}\n                        {% if app.user is defined and app.user is not same as null and isSingle is defined and isSingle %}\n                            {{ component('notification_switch', {target: post}) }}\n                        {% endif %}\n                        <li data-subject-target=\"loader\" style=\"display:none\">\n                            <div class=\"loader\" role=\"status\">\n                                <span class=\"visually-hidden\">Loading...</span>\n                            </div>\n                        </li>\n                    </menu>\n                    {{ component('voters_inline', {\n                        subject: post,\n                        url: post_voters_url(post, 'up'),\n                        'data-post-target': 'voters'\n                    }) }}\n                {% elseif(post.visibility is same as 'trashed' and this.canSeeTrashed) %}\n                    <menu>\n                        <li>\n                            <form method=\"post\"\n                                  action=\"{{ path('post_restore', {magazine_name: post.magazine.name, post_id: post.id}) }}\"\n                                  data-action=\"confirmation#ask\" data-confirmation-message-param=\"{{ 'are_you_sure'|trans }}\">\n                                <button type=\"submit\">{{ 'restore'|trans }}</button>\n                            </form>\n                        </li>\n                        {% if app.user is defined and app.user is not same as null and is_bookmarked(app.user, post) %}\n                            {{ component('bookmark_standard', { subject: post }) }}\n                        {% endif %}\n                        <li data-subject-target=\"loader\" style=\"display:none\">\n                            <div class=\"loader\" role=\"status\">\n                                <span class=\"visually-hidden\">Loading...</span>\n                            </div>\n                        </li>\n                    </menu>\n                {% else %}\n                    <menu>\n                        {% if app.user is defined and app.user is not same as null and is_bookmarked(app.user, post) %}\n                            {{ component('bookmark_standard', { subject: post }) }}\n                        {% endif %}\n                        <li data-subject-target=\"loader\" style=\"display:none\">\n                            <div class=\"loader\" role=\"status\">\n                                <span class=\"visually-hidden\">Loading...</span>\n                            </div>\n                        </li>\n                    </menu>\n                {% endif %}\n                <div data-subject-target=\"container\" class=\"js-container\">\n                </div>\n            </footer>\n\n        </blockquote>\n    {% endif %}\n\n    <div data-post-target=\"comments\" class=\"comments post-comments post-comments-preview comments-tree\">\n        {% if(showCommentsPreview and post.commentCount) %}\n            {{ component('post_comments_preview', {post: post}) }}\n        {% endif %}\n    </div>\n</div>\n{% endif %}\n"
  },
  {
    "path": "templates/components/post_combined.html.twig",
    "content": "{%- set V_TRUE = constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::TRUE') -%}\n{%- set V_FALSE = constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::FALSE') -%}\n\n{%- set SHOW_PREVIEW = app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_ENTRIES_SHOW_PREVIEW'), V_FALSE) -%}\n{%- set SHOW_THUMBNAILS = app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_ENTRIES_SHOW_THUMBNAILS'), V_TRUE) -%}\n{%- set SHOW_USER_AVATARS = app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_ENTRIES_SHOW_USERS_AVATARS'), V_TRUE) -%}\n{%- set SHOW_MAGAZINE_ICONS = app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_ENTRIES_SHOW_MAGAZINES_ICONS'), V_TRUE) -%}\n{%- set SHOW_USER_FULLNAME = app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::MBIN_SHOW_USER_DOMAIN'), V_FALSE) -%}\n{%- set SHOW_MAGAZINE_FULLNAME = app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::MBIN_SHOW_MAGAZINE_DOMAIN'), V_FALSE) -%}\n\n{% if not app.user or (app.user and not app.user.isBlocked(post.user)) %}\n    {% if post.visibility is same as 'private' and (not app.user or not app.user.isFollower(post.user)) %}\n        <div class=\"section section--small\"\n             style=\"z-index:3; position:relative;\">\n            Private\n        </div>\n    {% else %}\n        <article {{ attributes.defaults({\n            class: html_classes('post section subject', {\n                'no-image': SHOW_THUMBNAILS is same as V_FALSE,\n                'own': app.user and post.isAuthor(app.user),\n                'show-preview': SHOW_PREVIEW is same as V_TRUE and not post.isAdult,\n                'isSingle': false\n            })}).without('id') }}\n            id=\"post-{{ post.id }}\"\n            data-controller=\"subject preview mentions html-refresh\"\n            data-action=\"notifications:Notification@window->subject#notification\"\n            data-subject-is-on-combined-value=\"true\">\n            {% with %}\n                {% set hasTitle = post.visibility in ['visible', 'private'] or (post.visibility is same as 'trashed' and this.canSeeTrashed) %}\n                {% set isAdult = post.isAdult %}\n                {% set hasLang = post.lang is not same as app.request.locale and post.lang is not same as kbin_default_lang() %}\n                {% set isModDeleted = post.visibility is same as 'trashed' %}\n                {% set isUserDeleted = post.visibility is same as 'soft_deleted' %}\n                {% set needsHeader = (hasTitle and (isAdult or hasLang)) or isModDeleted or isUserDeleted %}\n\n                <header {% if not needsHeader %} style=\"margin: 0 0 var(--kbin-entry-element-spacing) 0;\" {% endif %}>\n                    {% if hasTitle %}\n                        <h2>\n                            {% if isAdult %}<small class=\"badge danger\">18+</small>{% endif %}\n\n                            {% if hasLang %}\n                                <small class=\"badge-lang kbin-bg\">{{ post.lang|language_name }}</small>\n                            {% endif %}\n                        </h2>\n                    {% elseif isModDeleted %}\n                        <p class=\"text-muted\">&lsqb;<i>{{ 'deleted_by_moderator'|trans }}</i>&rsqb;</p>\n                    {% elseif isUserDeleted %}\n                        <p class=\"text-muted\">&lsqb;<i>{{ 'deleted_by_author'|trans }}</i>&rsqb;</p>\n                    {% endif %}\n                </header>\n            {% endwith %}\n            {% if post.visibility in ['visible', 'private'] or (post.visibility is same as 'trashed' and this.canSeeTrashed) %}\n                {% if post.body %}\n                    <div class=\"content short-desc\">\n                        <p>{{ get_short_sentence(post.body|markdown|raw, striptags = true, onlyFirstParagraph = false) }}</p>\n                    </div>\n                {% endif %}\n            {% endif %}\n            <aside class=\"meta entry__meta\">\n                <span>{{ component('user_inline', {user: post.user, showAvatar: SHOW_USER_AVATARS is same as V_TRUE, showNewIcon: true, fullName: SHOW_USER_FULLNAME is same as V_TRUE}) -}}</span>\n\n                {% if (post.user.type) == \"Service\" %}\n                    <span class=\"user-badge\">{{ 'user_badge_bot'|trans }}</span>\n                {% endif %}\n\n                {% if post.user.admin() %}\n                    <span class=\"user-badge\">{{ 'user_badge_admin'|trans }}</span>\n                {% elseif post.user.moderator() %}\n                    <span class=\"user-badge\">{{ 'user_badge_global_moderator'|trans }}</span>\n                {% elseif post.magazine.userIsModerator(post.user) %}\n                    <span class=\"user-badge\">{{ 'user_badge_moderator'|trans }}</span>\n                {% endif %}\n                <span>, </span>\n                <span>{{ component('date', {date: post.createdAt}) }}</span>\n                <span>{{ component('date_edited', {createdAt: post.createdAt, editedAt: post.editedAt}) }}</span>\n                {% if showMagazineName %}\n                    <span>{{ 'to'|trans }} {{ component('magazine_inline', {magazine: post.magazine, showAvatar: SHOW_MAGAZINE_ICONS is same as V_TRUE, showNewIcon: true, fullName: SHOW_MAGAZINE_FULLNAME is same as V_TRUE}) }}</span>\n                {% endif %}\n            </aside>\n            {% if SHOW_THUMBNAILS is same as V_TRUE %}\n                {% if post.image %}\n                    {{ include('components/_figure_entry.html.twig', {entry: post, type: 'image'}) }}\n                {% else %}\n                    <div class=\"no-image-placeholder\">\n                        <a href=\"{{ post_url(post) }}\">\n                            <i class=\"fa-solid fa-pen-nib\"></i>\n                        </a>\n                    </div>\n                {% endif %}\n            {% endif %}\n            {% if post.visibility in ['visible', 'private'] %}\n                {{ component('vote', {\n                    subject: post,\n                    showDownvote: false\n                }) }}\n            {% endif %}\n            <aside class=\"entry__preview hidden\" data-preview-target=\"container\"></aside>\n            <footer>\n                {% if post.visibility in ['visible', 'private'] %}\n                    <menu>\n                        {% if post.sticky %}\n                            <li>\n                                <span aria-label=\"{{ 'pinned'|trans }}\">\n                                    <i class=\"fa-solid fa-thumbtack\" aria-hidden=\"true\"></i>\n                                </span>\n                            </li>\n                        {% endif %}\n\n                        <li class=\"meta-link\">\n                                <span aria-label=\"{{ 'post'|trans }}\">\n                                    <i class=\"fa-solid fa-pen-nib\" aria-hidden=\"true\"></i>\n                                </span>\n                        </li>\n\n                        {% if post.image %}\n                            <li>\n                                <button class=\"show-preview\"\n                                        data-action=\"preview#show\"\n                                        aria-label=\"{{ 'preview'|trans }}\"\n                                        data-preview-url-param=\"{{ uploaded_asset(post.image) }}\">\n                                    <i class=\"fa-solid fa-photo-film\" aria-hidden=\"true\"></i>\n                                </button>\n                            </li>\n                        {% endif %}\n\n                        <li>\n                            <a class=\"stretched-link\"\n                               href=\"{{ post_url(post) ~ '#comments' }}\">\n                                <span data-subject-target=\"commentsCounter\">{{ post.commentCount }}</span> {{ 'comments_count'|trans({'%count%': post.commentCount}) }}\n                            </a>\n                        </li>\n                        <li>\n                            {{ component('boost', {\n                                subject: post\n                            }) }}\n                        </li>\n                        {% if app.user is defined and app.user is not same as null %}\n                            {{ component('bookmark_standard', { subject: post }) }}\n                        {% endif %}\n                        {% include 'post/_menu.html.twig' %}\n\n                        <li data-subject-target=\"loader\" style=\"display:none\">\n                            <div class=\"loader\" role=\"status\">\n                                <span class=\"visually-hidden\">Loading...</span>\n                            </div>\n                        </li>\n                    </menu>\n                {% elseif (post.visibility is same as 'trashed' and this.canSeeTrashed) %}\n                    <menu>\n                        <li>\n                            <form method=\"post\"\n                                  action=\"{{ path('post_restore', {magazine_name: post.magazine.name, post_id: post.id}) }}\"\n                                  data-action=\"confirmation#ask\" data-confirmation-message-param=\"{{ 'are_you_sure'|trans }}\">\n                                <button type=\"submit\">{{ 'restore'|trans }}</button>\n                            </form>\n                        </li>\n                        {% if app.user is defined and app.user is not same as null and is_bookmarked(app.user, post) %}\n                            {{ component('bookmark_standard', { subject: post }) }}\n                        {% endif %}\n                        <li data-subject-target=\"loader\" style=\"display:none\">\n                            <div class=\"loader\" role=\"status\">\n                                <span class=\"visually-hidden\">Loading...</span>\n                            </div>\n                        </li>\n                    </menu>\n                {% else %}\n                    <menu>\n                        {% if app.user is defined and app.user is not same as null and is_bookmarked(app.user, post) %}\n                            {{ component('bookmark_standard', { subject: post }) }}\n                        {% endif %}\n                        <li data-subject-target=\"loader\" style=\"display:none\">\n                            <div class=\"loader\" role=\"status\">\n                                <span class=\"visually-hidden\">Loading...</span>\n                            </div>\n                        </li>\n                    </menu>\n                {% endif %}\n                <div data-subject-target=\"container\" class=\"js-container\">\n                </div>\n            </footer>\n        </article>\n    {% endif %}\n{% endif %}\n"
  },
  {
    "path": "templates/components/post_comment.html.twig",
    "content": "{%- set V_TRUE = constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::TRUE') -%}\n{%- set V_FALSE = constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::FALSE') -%}\n{%- set V_TREE = constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::TREE') -%}\n\n{%- set SHOW_PREVIEW = app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_POSTS_SHOW_PREVIEW'), V_FALSE) -%}\n{%- set VIEW_STYLE = app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::POST_COMMENTS_VIEW'), V_TREE) -%}\n{%- set SHOW_USER_FULLNAME = app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::MBIN_SHOW_USER_DOMAIN'), V_FALSE) -%}\n\n{% if withPost %}\n    {{ component('post', {post: comment.post}) }}\n{% endif %}\n{% if not app.user or (app.user and not app.user.isBlocked(comment.user)) %}\n    {% if comment.visibility is same as 'private' and (not app.user or not app.user.isFollower(comment.user)) %}\n        <div class=\"section section--small {{ 'comment-level--' ~ this.getLevel() }}\">\n            Private\n        </div>\n    {% else %}\n        <blockquote{{ attributes.defaults({\n            class: html_classes('section comment post-comment subject', 'comment-level--' ~ this.getLevel(), {\n                'own': app.user and comment.isAuthor(app.user),\n                'author': comment.isAuthor(comment.post.user),\n                'show-preview': SHOW_PREVIEW is same as V_TRUE and not comment.isAdult,\n            })}).without('id') }}\n                id=\"post-comment-{{ comment.id }}\"\n                data-controller=\"comment subject mentions comment-collapse html-refresh\"\n                data-comment-collapse-depth-value=\"{{ level }}\"\n                data-subject-parent-value=\"{{ comment.parent ? comment.parent.id : '' }}\"\n                data-action=\"notifications:Notification@window->subject#notification\">\n            <header>\n                {% if comment.isAdult %}<span class=\"badge danger\">18+</span>{% endif %}\n                {{ component('user_inline', {user: comment.user, showAvatar: false, showNewIcon: true, fullName: SHOW_USER_FULLNAME is same as V_TRUE}) }}\n\n                {% if comment.post.user.id() is same as comment.user.id() %}\n                    <span class=\"user-badge\">{{ 'user_badge_op'|trans }}</span>\n                {% endif %}\n\n                {% if (comment.user.type) == \"Service\" %}\n                    <span class=\"user-badge\">{{ 'user_badge_bot'|trans }}</span>\n                {% endif %}\n\n                {% if comment.user.admin() %}\n                    <span class=\"user-badge\">{{ 'user_badge_admin'|trans }}</span>\n                {% elseif comment.user.moderator() %}\n                    <span class=\"user-badge\">{{ 'user_badge_global_moderator'|trans }}</span>\n                {% elseif comment.magazine.userIsModerator(comment.user) %}\n                    <span class=\"user-badge\">{{ 'user_badge_moderator'|trans }}</span>\n                {% endif %}\n                <span>, </span>\n\n                {% if dateAsUrl %}\n                    <a href=\"{{ post_url(comment.post) ~ '#post-comment-' ~ comment.id }}\"\n                       class=\"link-muted\">{{ component('date', {date: comment.createdAt}) }}</a>\n                {% else %}\n                    {{ component('date', {date: comment.createdAt}) }}\n                {% endif %}\n                {{ component('date_edited', {createdAt: comment.createdAt, editedAt: comment.editedAt}) }}\n                {% if comment.lang is not same as app.request.locale and comment.lang is not same as kbin_default_lang() %}\n                    <small hidden class=\"badge-lang\">{{ comment.lang|language_name }}</small>\n                {% endif %}\n            </header>\n            {{ component('user_avatar', {\n                user: comment.user,\n                width: 40,\n                height: 40,\n                asLink: true\n            }) }}\n            <div data-controller=\"collapsable\">\n                <div data-collapsable-target=\"content\" class=\"content\">\n                    {% if comment.visibility in ['visible', 'private'] or (comment.visibility is same as 'trashed' and this.canSeeTrashed) %}\n                        {{ comment.body|markdown(\"post\")|raw }}\n                    {% elseif(comment.visibility is same as 'trashed') %}\n                        <p class=\"text-muted\">&lsqb;<i>{{ 'deleted_by_moderator'|trans }}</i>&rsqb;</p>\n                    {% elseif(comment.visibility is same as 'soft_deleted') %}\n                        <p class=\"text-muted\">&lsqb;<i>{{ 'deleted_by_author'|trans }}</i>&rsqb;</p>\n                    {% endif %}\n                </div>\n                <div data-collapsable-target=\"button\"></div>\n            </div>\n            <div class=\"aside\">\n                {% if comment.visibility in ['visible', 'private'] %}\n                    {{ component('vote', { subject: comment, showDownvote: false }) }}\n                {% endif %}\n                {{ include('components/_comment_collapse_button.html.twig', {\n                    comment: comment,\n                    showNested: showNested,\n                }) }}\n            </div>\n            <footer>\n                {% if (comment.visibility in ['visible', 'private'] or comment.visibility is same as 'trashed' and this.canSeeTrashed) and comment.image %}\n                    {{ include('components/_figure_image.html.twig', {\n                        image: comment.image,\n                        parent_id: comment.id,\n                        is_adult: comment.isAdult,\n                        thumb_filter: 'post_thumb',\n                        gallery_name: 'pc-%d'|format(comment.id),\n                    }) }}\n                {% endif %}\n                {% if comment.visibility in ['visible', 'private'] %}\n                    <menu>\n                        <li>\n                            <a class=\"stretched-link\"\n                               href=\"{{ post_comment_create_url(comment) }}#add-comment\"\n                               data-action=\"subject#getForm\">{{ 'reply'|trans }}</a>\n                        </li>\n                        <li>\n                            {{ component('boost', {\n                                subject: comment\n                            }) }}\n                        </li>\n                        {% if app.user is defined and app.user is not same as null %}\n                            {{ component('bookmark_standard', { subject: comment }) }}\n                        {% endif %}\n                        {% include 'post/comment/_menu.html.twig' %}\n                        <li data-subject-target=\"loader\" style=\"display:none\">\n                            <div class=\"loader\" role=\"status\">\n                                <span class=\"visually-hidden\">Loading...</span>\n                            </div>\n                        </li>\n                    </menu>\n                {% elseif(comment.visibility is same as 'trashed' and this.canSeeTrashed) %}\n                    <menu>\n                        <li>\n                            <form method=\"post\"\n                                  action=\"{{ path('post_comment_restore', {magazine_name: comment.magazine.name, post_id: comment.post.id, comment_id: comment.id}) }}\"\n                                  data-action=\"confirmation#ask\" data-confirmation-message-param=\"{{ 'are_you_sure'|trans }}\">\n                                <input type=\"hidden\" name=\"token\" value=\"{{ csrf_token('post_comment_restore') }}\">\n                                <button type=\"submit\">{{ 'restore'|trans }}</button>\n                            </form>\n                        </li>\n                        {% if app.user is defined and app.user is not same as null and is_bookmarked(app.user, comment) %}\n                            {{ component('bookmark_standard', { subject: comment }) }}\n                        {% endif %}\n                        <li data-subject-target=\"loader\" style=\"display:none\">\n                            <div class=\"loader\" role=\"status\">\n                                <span class=\"visually-hidden\">Loading...</span>\n                            </div>\n                        </li>\n                    </menu>\n                {% else %}\n                    <menu>\n                        {% if app.user is defined and app.user is not same as null and is_bookmarked(app.user, comment) %}\n                            {{ component('bookmark_standard', { subject: comment }) }}\n                        {% endif %}\n                        <li data-subject-target=\"loader\" style=\"display:none\">\n                            <div class=\"loader\" role=\"status\">\n                                <span class=\"visually-hidden\">Loading...</span>\n                            </div>\n                        </li>\n                    </menu>\n                {% endif %}\n                {{ component('voters_inline', {\n                    subject: comment,\n                    url: post_comment_voters_url(comment, 'up')\n                }) }}\n                <div data-subject-target=\"container\" class=\"js-container\">\n                </div>\n            </footer>\n        </blockquote>\n    {% endif %}\n    {% if showNested %}\n        {{ component('post_comments_nested', {\n            comment: comment,\n            level: level,\n            showNested: true,\n            view: VIEW_STYLE,\n            criteria: criteria,\n        }) }}\n    {% endif %}\n{% endif %}\n"
  },
  {
    "path": "templates/components/post_comment_combined.html.twig",
    "content": "{%- set V_TRUE = constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::TRUE') -%}\n{%- set V_FALSE = constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::FALSE') -%}\n\n{%- set SHOW_PREVIEW = app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_ENTRIES_SHOW_PREVIEW'), V_FALSE) -%}\n{%- set SHOW_THUMBNAILS = app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_ENTRIES_SHOW_THUMBNAILS'), V_TRUE) -%}\n{%- set SHOW_USER_AVATARS = app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_ENTRIES_SHOW_USERS_AVATARS'), V_TRUE) -%}\n{%- set SHOW_MAGAZINE_ICONS = app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_ENTRIES_SHOW_MAGAZINES_ICONS'), V_TRUE) -%}\n{%- set SHOW_USER_FULLNAME = app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::MBIN_SHOW_USER_DOMAIN'), V_FALSE) -%}\n{%- set SHOW_MAGAZINE_FULLNAME = app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::MBIN_SHOW_MAGAZINE_DOMAIN'), V_FALSE) -%}\n\n{% if not app.user or (app.user and not app.user.isBlocked(comment.user)) %}\n    {% if comment.visibility is same as 'private' and (not app.user or not app.user.isFollower(comment.user)) %}\n        <div class=\"section section--small\"\n             style=\"z-index:3; position:relative;\">\n            Private\n        </div>\n    {% else %}\n        <article {{ attributes.defaults({\n            class: html_classes('post section subject', {\n                'no-image': SHOW_THUMBNAILS is same as V_FALSE,\n                'own': app.user and comment.isAuthor(app.user),\n                'show-preview': SHOW_PREVIEW is same as V_TRUE and not comment.isAdult,\n            })}).without('id') }}\n                id=\"post-comment-{{ comment.id }}\"\n                data-controller=\"subject preview mentions html-refresh\"\n                data-action=\"notifications:Notification@window->subject#notification\"\n                data-subject-is-on-combined-value=\"true\">\n            {% with %}\n                {% set hasTitle = comment.visibility in ['visible', 'private'] or (comment.visibility is same as 'trashed' and this.canSeeTrashed) %}\n                {% set isAdult = comment.isAdult %}\n                {% set hasLang = comment.lang is not same as app.request.locale and comment.lang is not same as kbin_default_lang() %}\n                {% set isModDeleted = comment.visibility is same as 'trashed' %}\n                {% set isUserDeleted = comment.visibility is same as 'soft_deleted' %}\n                {% set needsHeader = (hasTitle and (isAdult or hasLang)) or isModDeleted or isUserDeleted %}\n\n                <header {% if not needsHeader %} style=\"margin: 0 0 var(--kbin-entry-element-spacing) 0;\" {% endif %}>\n                    {% if hasTitle %}\n                        <h2>\n                            {% if isAdult %}<small class=\"badge danger\">18+</small>{% endif %}\n\n                            {% if hasLang %}\n                                <small class=\"badge-lang kbin-bg\">{{ comment.lang|language_name }}</small>\n                            {% endif %}\n                        </h2>\n                    {% elseif isModDeleted %}\n                        <p class=\"text-muted\">&lsqb;<i>{{ 'deleted_by_moderator'|trans }}</i>&rsqb;</p>\n                    {% elseif isUserDeleted %}\n                        <p class=\"text-muted\">&lsqb;<i>{{ 'deleted_by_author'|trans }}</i>&rsqb;</p>\n                    {% endif %}\n                </header>\n            {% endwith %}\n            {% if comment.visibility in ['visible', 'private'] or (comment.visibility is same as 'trashed' and this.canSeeTrashed) %}\n                {% if comment.body %}\n                    <div class=\"content short-desc\">\n                        <p>{{ get_short_sentence(comment.body|markdown|raw, striptags = true, onlyFirstParagraph = false) }}</p>\n                    </div>\n                {% endif %}\n            {% endif %}\n            <aside class=\"meta entry__meta\">\n                <span>{{ component('user_inline', {user: comment.user, showAvatar: SHOW_USER_AVATARS is same as V_TRUE, showNewIcon: true, fullName: SHOW_USER_FULLNAME is same as V_TRUE}) -}}</span>\n\n                {% if (comment.user.type) == \"Service\" %}\n                    <span class=\"user-badge\">{{ 'user_badge_bot'|trans }}</span>\n                {% endif %}\n\n                {% if comment.user.admin() %}\n                    <span class=\"user-badge\">{{ 'user_badge_admin'|trans }}</span>\n                {% elseif comment.user.moderator() %}\n                    <span class=\"user-badge\">{{ 'user_badge_global_moderator'|trans }}</span>\n                {% endif %}\n                <span>, </span>\n                <span>{{ component('date', {date: comment.createdAt}) }}</span>\n                <span>{{ component('date_edited', {createdAt: comment.createdAt, editedAt: comment.editedAt}) }}</span>\n            </aside>\n            {% if SHOW_THUMBNAILS is same as V_TRUE %}\n                {% if comment.image %}\n                    {{ include('components/_figure_entry.html.twig', {entry: comment, type: 'image'}) }}\n                {% else %}\n                    <div class=\"no-image-placeholder\">\n                        <a href=\"{{ post_url(comment.post) ~ '#post-comment-' ~ comment.id }}\">\n                            <i class=\"fa-solid fa-pen-nib\"></i>\n                        </a>\n                    </div>\n                {% endif %}\n            {% endif %}\n            {% if comment.visibility in ['visible', 'private'] %}\n                {{ component('vote', {\n                    subject: comment,\n                    showDownvote: false\n                }) }}\n            {% endif %}\n            <aside class=\"entry__preview hidden\" data-preview-target=\"container\"></aside>\n            <footer>\n                {% if comment.visibility in ['visible', 'private'] %}\n                    <menu>\n                        <li class=\"meta-link\">\n                                <span aria-label=\"{{ 'comment'|trans }}\">\n                                    <i class=\"fa-solid fa-pen-nib\" aria-hidden=\"true\"></i>\n                                </span>\n                        </li>\n\n                        {% if comment.image %}\n                            <li>\n                                <button class=\"show-preview\"\n                                        data-action=\"preview#show\"\n                                        aria-label=\"{{ 'preview'|trans }}\"\n                                        data-preview-url-param=\"{{ uploaded_asset(comment.image) }}\">\n                                    <i class=\"fa-solid fa-photo-film\" aria-hidden=\"true\"></i>\n                                </button>\n                            </li>\n                        {% endif %}\n\n                        <li>\n                            <a class=\"stretched-link\"\n                               href=\"{{ post_url(comment.post) ~ '#post-comment-' ~ comment.id }}\">\n                                <span data-subject-x=\"subjectLink\">\n                                    {{ 'parent_post'|trans }}\n                                </span>\n                            </a>\n                        </li>\n                        <li>\n                            {{ component('boost', {\n                                subject: comment\n                            }) }}\n                        </li>\n                        {% if app.user is defined and app.user is not same as null %}\n                            {{ component('bookmark_standard', { subject: comment }) }}\n                        {% endif %}\n                        {% include 'post/comment/_menu.html.twig' %}\n\n                        <li data-subject-target=\"loader\" style=\"display:none\">\n                            <div class=\"loader\" role=\"status\">\n                                <span class=\"visually-hidden\">Loading...</span>\n                            </div>\n                        </li>\n                    </menu>\n                {% elseif (comment.visibility is same as 'trashed' and this.canSeeTrashed) %}\n                    <menu>\n                        <li>\n                            <form method=\"post\"\n                                  action=\"{{ path('post_comment_restore', {magazine_name: comment.magazine.name, post_id: comment.post.id, comment_id: comment.id}) }}\"\n                                  data-action=\"confirmation#ask\" data-confirmation-message-param=\"{{ 'are_you_sure'|trans }}\">\n                                <input type=\"hidden\" name=\"token\" value=\"{{ csrf_token('post_comment_restore') }}\">\n                                <button type=\"submit\">{{ 'restore'|trans }}</button>\n                            </form>\n                        </li>\n                        {% if app.user is defined and app.user is not same as null and is_bookmarked(app.user, comment) %}\n                            {{ component('bookmark_standard', { subject: comment }) }}\n                        {% endif %}\n                        <li data-subject-target=\"loader\" style=\"display:none\">\n                            <div class=\"loader\" role=\"status\">\n                                <span class=\"visually-hidden\">Loading...</span>\n                            </div>\n                        </li>\n                    </menu>\n                {% else %}\n                    <menu>\n                        {% if app.user is defined and app.user is not same as null and is_bookmarked(app.user, comment) %}\n                            {{ component('bookmark_standard', { subject: comment }) }}\n                        {% endif %}\n                        <li data-subject-target=\"loader\" style=\"display:none\">\n                            <div class=\"loader\" role=\"status\">\n                                <span class=\"visually-hidden\">Loading...</span>\n                            </div>\n                        </li>\n                    </menu>\n                {% endif %}\n                <div data-subject-target=\"container\" class=\"js-container\">\n                </div>\n            </footer>\n        </article>\n    {% endif %}\n{% endif %}\n"
  },
  {
    "path": "templates/components/post_comment_inline_md.html.twig",
    "content": "{% if rich is same as true %}\n<span class=\"post-comment-inline\">\n    {{ component('user_inline', {user: comment.user, fullName: userFullName, showNewIcon: true}) }}:\n    <a href=\"{{ path('post_single', {magazine_name: comment.magazine.name, post_id: comment.post.id, slug: comment.post.slug|length ? comment.post.slug : '-'}) }}#post-comment-{{ comment.id }}\">\n        {% if comment.image is not same as null %}\n            <i class=\"fa-solid fa-photo-film\"></i>\n        {% endif %}\n        {{ comment.getShortTitle() }}\n    </a>\n    {{ 'answered'|trans }}\n    {% if comment.parent is not same as null %}\n        <a href=\"{{ path('post_single', {magazine_name: comment.magazine.name, post_id: comment.post.id, slug: comment.post.slug|length ? comment.post.slug : '-'}) }}#post-comment-{{ comment.parent.id }}\">\n            {{ comment.parent.getShortTitle() }}\n        </a>\n        {{ 'by'|trans }}\n        {{ component('user_inline', {user: comment.parent.user, fullName: userFullName, showNewIcon: true}) }}\n    {% else %}\n        <a href=\"{{ path('post_single', {magazine_name: comment.magazine.name, post_id: comment.post.id, slug: comment.post.slug|length ? comment.post.slug : '-'}) }}\">\n            {% if comment.post.image is not same as null %}\n                <i class=\"fa-solid fa-photo-film\"></i>\n            {% endif %}\n            {{ comment.post.getShortTitle() }}\n        </a>\n        {{ 'by'|trans }}\n        {{ component('user_inline', {user: comment.post.user, fullName: userFullName, showNewIcon: true}) }}\n    {% endif %}\n    {% if comment.magazine.name is not same as 'random' %}\n        {{ 'in'|trans }}\n        {{ component('magazine_inline', {magazine: comment.magazine, fullName: magazineFullName, showNewIcon: true}) }}\n    {% endif %}\n</span>\n{% else %}\n    <a href=\"{{ path('post_single', {magazine_name: comment.magazine.name, post_id: comment.post.id, slug: comment.post.slug|length ? comment.post.slug : '-'}) }}#post-comment-{{ comment.id }}\">\n        {% if comment.apId is same as null %}\n            {{ url('post_single', {magazine_name: comment.magazine.name, post_id: comment.post.id, slug: '-'}) }}#post-comment-{{ comment.id }}\n        {% else %}\n            {{ comment.apId }}\n        {% endif %}\n    </a>\n{% endif %}\n"
  },
  {
    "path": "templates/components/post_comments_nested.html.twig",
    "content": "{% if view is same as constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::CLASSIC') %}\n    {% for reply in comment.nested %}\n        {{ component('post_comment', {\n            comment: reply,\n            showNested: false,\n            level: 3,\n            criteria: criteria,\n        }) }}\n    {% endfor %}\n{% else %}\n    {% for reply in comment.getChildrenByCriteria(criteria, app.user, 'comments') %}\n        {{ component('post_comment', {\n            comment: reply,\n            showNested: true,\n            level: level + 1,\n            criteria: criteria,\n        }) }}\n    {% endfor %}\n{% endif %}\n"
  },
  {
    "path": "templates/components/post_comments_preview.html.twig",
    "content": "{% for comment in comments %}\n    {{ component('post_comment', {\n        comment: comment,\n        showNested: false,\n        level: 2,\n    }) }}\n{% endfor %}\n"
  },
  {
    "path": "templates/components/post_inline_md.html.twig",
    "content": "{% if rich is same as true %}\n    <span class=\"post-inline\">\n        {{ component('user_inline', {user: post.user, fullName: userFullName, showNewIcon: true}) }}:\n        <a href=\"{{ path('post_single', {magazine_name: post.magazine.name, post_id: post.id, slug: post.slug|length ? post.slug : '-'}) }}\">\n            {% if post.image is not same as null %}\n                <i class=\"fa-solid fa-photo-film\"></i>\n            {% endif %}\n            {{ post.getShortTitle() }}\n        </a>\n        {% if post.magazine.name is not same as 'random' %}\n            {{ 'in'|trans }}\n            {{ component('magazine_inline', {magazine: post.magazine, fullName: magazineFullName}) }}\n        {% endif %}\n    </span>\n{% else %}\n    <a href=\"{{ path('post_single', {magazine_name: post.magazine.name, post_id: post.id, slug: post.slug|length ? post.slug : '-'}) }}\">\n        {% if post.apId is same as null %}\n            {{ url('post_single', {magazine_name: post.magazine.name, post_id: post.id, slug: '-'}) }}\n        {% else %}\n            {{ post.apId }}\n        {% endif %}\n    </a>\n{% endif %}\n\n"
  },
  {
    "path": "templates/components/related_entries.html.twig",
    "content": "{%- set V_TRUE = constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::TRUE') -%}\n{%- set V_FALSE = constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::FALSE') -%}\n{%- set SHOW_MAGAZINE_FULLNAME = app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::MBIN_SHOW_MAGAZINE_DOMAIN'), V_FALSE) -%}\n{% if entries|length %}\n    <section{{ attributes.defaults({class: 'entries section'}) }}>\n        <h3>{{ title|trans }}</h3>\n        <div class=\"container\">\n            {% for entry in entries %}\n                <figure>\n                    <div class=\"row\">\n                        {% if entry.image %}\n                            <img loading=\"lazy\"\n                                src=\"{{ entry.image.filePath ? (asset(entry.image.filePath)|imagine_filter('entry_thumb')) : entry.image.sourceUrl }}\"\n                                alt=\"{{ entry.image.alt|default('') }}\">\n                        {% endif %}\n                        <blockquote class=\"content\">\n                            <p>{{ entry.title }}</p>\n                        </blockquote>\n                        <a href=\"{{ entry_url(entry) }}\" class=\"stretched-link more\">{{ 'show_more'|trans }}</a>\n                    </div>\n                    <figcaption>\n                        {{ component('date', {date: entry.createdAt}) }} {{ 'to'|trans }} {{ component('magazine_inline', {magazine: entry.magazine, fullName: SHOW_MAGAZINE_FULLNAME is same as V_TRUE}) }}\n                    </figcaption>\n                </figure>\n            {% endfor %}\n        </div>\n    </section>\n{% endif %}\n"
  },
  {
    "path": "templates/components/related_magazines.html.twig",
    "content": "{%- set V_TRUE = constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::TRUE') -%}\n{%- set V_FALSE = constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::FALSE') -%}\n{%- set SHOW_MAGAZINE_FULLNAME = app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::MBIN_SHOW_MAGAZINE_DOMAIN'), V_FALSE) -%}\n{% if magazines|length %}\n    <section{{ attributes.defaults({class: 'magazines section related-magazines'}) }}>\n        <h3>{{ title|trans }}</h3>\n        <div class=\"container\">\n            <ul class=\"meta\">\n                {% for magazine in magazines %}\n                    <li>\n                        {{ component('magazine_inline', {magazine: magazine, showAvatar: true, showNewIcon: true, fullName:false, stretchedLink:true, fullName: SHOW_MAGAZINE_FULLNAME is same as V_TRUE}) }}\n                    </li>\n                {% endfor %}\n            </ul>\n        </div>\n    </section>\n{% endif %}\n"
  },
  {
    "path": "templates/components/related_posts.html.twig",
    "content": "{%- set V_TRUE = constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::TRUE') -%}\n{%- set V_FALSE = constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::FALSE') -%}\n{%- set SHOW_MAGAZINE_FULLNAME = app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::MBIN_SHOW_MAGAZINE_DOMAIN'), V_FALSE) -%}\n{% if posts|length %}\n    <section{{ attributes.defaults({class: 'posts section'}) }}>\n        <h3>{{ title|trans }}</h3>\n        <div class=\"container\">\n            {% for post in posts %}\n                <figure>\n                    <div class=\"row\">\n                        {% if post.image %}\n                                <img loading=\"lazy\"\n                                    src=\"{{ post.image.filePath ? (asset(post.image.filePath)|imagine_filter('post_thumb')) : post.image.sourceUrl }}\"\n                                    alt=\"{{ post.image.alt|default('') }}\">\n                        {% endif %}\n                        {% if post.body %}\n                            <blockquote class=\"content\">\n                                {{ get_short_sentence(post.body)|markdown|raw }}\n                            </blockquote>\n                        {% endif %}\n                        <a href=\"{{ post_url(post) }}\" class=\"stretched-link more\">{{ 'show_more'|trans }}</a>\n                    </div>\n                    <figcaption>\n                        {{ component('date', {date: post.createdAt}) }} {{ 'to'|trans }} {{ component('magazine_inline', {magazine: post.magazine, fullName: SHOW_MAGAZINE_FULLNAME is same as V_TRUE, showNewIcon: true}) }}\n                    </figcaption>\n                </figure>\n            {% endfor %}\n        </div>\n    </section>\n{% endif %}\n"
  },
  {
    "path": "templates/components/report_list.html.twig",
    "content": "{%- set REPORT_ANY = constant('App\\\\Entity\\\\Report::STATUS_ANY') -%}\n{%- set REPORT_PENDING = constant('App\\\\Entity\\\\Report::STATUS_PENDING') -%}\n{%- set REPORT_APPROVED = constant('App\\\\Entity\\\\Report::STATUS_APPROVED') -%}\n{%- set REPORT_REJECTED = constant('App\\\\Entity\\\\Report::STATUS_REJECTED') -%}\n{%- set REPORT_CLOSED = constant('App\\\\Entity\\\\Report::STATUS_CLOSED') -%}\n\n<div class=\"pills\">\n    <menu>\n        <li>\n            <a href=\"{{ path(routeName, {status: REPORT_PENDING, name: magazineName}) }}\"\n               class=\"{{ html_classes({'active': route_has_param('status', REPORT_PENDING)}) }}\">\n                {{ 'pending'|trans }}\n            </a>\n        </li>\n        <li>\n            <a href=\"{{ path(routeName, {status: REPORT_APPROVED, name: magazineName}) }}\"\n               class=\"{{ html_classes({'active': route_has_param('status', REPORT_APPROVED)}) }}\">\n                {{ 'approved'|trans }}\n            </a>\n        </li>\n        <li>\n            <a href=\"{{ path(routeName, {status: REPORT_REJECTED, name: magazineName}) }}\"\n               class=\"{{ html_classes({'active': route_has_param('status', REPORT_REJECTED)}) }}\">\n                {{ 'rejected'|trans }}\n            </a>\n        </li>\n        <li>\n            <a href=\"{{ path(routeName, {name: magazineName}) }}\"\n               class=\"{{ html_classes({'active': route_has_param('status', REPORT_ANY)}) }}\">\n                {{ 'all'|trans }}\n            </a>\n        </li>\n    </menu>\n</div>\n\n{% for report in reports %}\n    <div class=\"{{ html_classes('section section--small report') }}\" id=\"report-id-{{ report.id }}\">\n        <div>\n            <small class=\"meta\">{{ component('user_inline', {user: report.reporting, showNewIcon: true}) }},\n                {{ component('date', {date: report.createdAt}) }}</small>\n        </div>\n        <div>\n            <small class=\"meta\">{% include 'layout/_subject_link.html.twig' with {subject: report.subject} -%}</small>\n        </div>\n        <div>\n            {{ report.reason }}\n        </div>\n        <div class=\"actions\">\n            {% if app.request.get('status') is same as REPORT_ANY %}\n                <small class=\"text-muted\">{{ report.status }}</small>\n            {% endif %}\n            {% if report.status is not same as REPORT_CLOSED %}\n                {% if report.status is not same as REPORT_REJECTED %}\n                    <form method=\"post\"\n                          action=\"{{ path('magazine_panel_report_reject', {'magazine_name': report.subject.magazine.name, 'report_id': report.id}) }}\"\n                          data-action=\"confirmation#ask\" data-confirmation-message-param=\"{{ 'are_you_sure'|trans }}\">\n                        <input type=\"hidden\" name=\"token\" value=\"{{ csrf_token('report_decline') }}\">\n                        <button type=\"submit\" class=\"btn btn__secondary\">{{ 'reject'|trans }}</button>\n                    </form>\n                {% endif %}\n                {% if report.status is not same as REPORT_APPROVED %}\n                    <form method=\"post\"\n                          action=\"{{ path('magazine_panel_report_approve', {'magazine_name': report.subject.magazine.name, 'report_id': report.id} ) }}\"\n                          data-action=\"confirmation#ask\" data-confirmation-message-param=\"{{ 'are_you_sure'|trans }}\">\n                        <input type=\"hidden\" name=\"token\" value=\"{{ csrf_token('report_approve') }}\">\n                        <button type=\"submit\" class=\"btn btn__secondary\">{{ 'approve'|trans }}</button>\n                    </form>\n                {% endif %}\n            {% endif %}\n            <a href=\"{{ path('magazine_panel_ban', {'name': report.subject.magazine.name, 'username': report.reported.username}) }}\"\n               class=\"btn btn__secondary\">{{ 'ban'|trans }} ({{ report.reported.username|username(true) }})</a>\n        </div>\n    </div>\n{% endfor %}\n{% if(reports.haveToPaginate is defined and reports.haveToPaginate) %}\n    {{ pagerfanta(reports, null, {'pageParameter':'[p]'}) }}\n{% endif %}\n{% if not reports|length %}\n    <aside class=\"section section--muted\">\n        <p>{{ 'empty'|trans }}</p>\n    </aside>\n{% endif %}\n"
  },
  {
    "path": "templates/components/tag_actions.html.twig",
    "content": "<aside class=\"tag__actions\">\n</aside>"
  },
  {
    "path": "templates/components/user_actions.html.twig",
    "content": "<aside{{ attributes.defaults({class: 'user__actions', 'data-controller': 'subs'}) }}>\n    <div class=\"action\"\n        title=\"{{ user.followersCount ~ ' ' ~ 'followers_count'|trans({'%count%': user.followersCount}) }}\"\n        aria-label=\"{{ user.followersCount ~ ' ' ~ 'followers_count'|trans({'%count%': user.followersCount}) }}\">\n        <i class=\"fa-solid fa-users\" aria-hidden=\"true\"></i><span>{{ user.followersCount|abbreviateNumber }}</span>\n    </div>\n    {% if not app.user or app.user is not same as user and not is_instance_of_user_banned(user) %}\n        <form action=\"{{ path('user_' ~ (is_user_followed(user) ? 'unfollow' : 'follow'), {username: user.username}) }}\"\n              name=\"user_follow\"\n              method=\"post\">\n            <button type=\"submit\"\n                    class=\"{{ html_classes('btn btn__secondary action', {'active': is_user_followed(user)}) }}\"\n                    data-action=\"subs#send\">\n            {% if is_user_followed(user) %}\n                    <i class=\"fa-solid fa-user-minus\" aria-hidden=\"true\"></i> <span>{{'unfollow'|trans}}</span>\n            {% else %}\n                    <i class=\"fa-solid fa-user-plus\" aria-hidden=\"true\"></i> <span>{{'follow'|trans}}</span>\n            {% endif %}\n\n            </button>\n        </form>\n        <form action=\"{{ path('user_' ~ (is_user_blocked(user) ? 'unblock' : 'block'), {username: user.username}) }}\"\n              name=\"user_block\"\n              method=\"post\">\n            <button type=\"submit\"\n                    class=\"{{ html_classes('btn btn__secondary action', {'active': is_user_blocked(user)}) }}\"\n                    data-action=\"subs#send\">\n\n            {% if is_user_blocked(user) %}\n                    <i class=\"fa-solid fa-user\" aria-hidden=\"true\"></i> <span>{{'unblock'|trans}}</span>\n            {% else %}\n                    <i class=\"fa-solid fa-user-slash\" aria-hidden=\"true\"></i> <span>{{'block'|trans}}</span>\n            {% endif %}\n\n            </button>\n        </form>\n        <a href=\"{{ path('messages_create', {username: user.username}) }}\" title=\"{{ 'direct_message'|trans }}\" aria-label=\"{{ 'direct_message'|trans }}\">\n            <button class=\"btn btn__secondary\">\n                <i class=\"fa-solid fa-envelope\" aria-hidden=\"true\"></i>\n            </button>\n        </a>\n    {% elseif app.user is same as user and is_route_name_starts_with('user') and not is_route_name_contains('settings') %}\n        <a href=\"{{ path('user_settings_profile') }}\" title=\"{{ 'edit_my_profile'|trans }}\" aria-label=\"{{ 'edit_my_profile'|trans }}\">\n            <button class=\"btn btn__secondary\">{{ 'edit_my_profile'|trans }}</button>\n        </a>\n    {% endif %}\n\n    {% if user.markedForDeletionAt and is_granted(\"ROLE_ADMIN\") %}\n        <div class=\"action\"\n             title=\"{{ 'marked_for_deletion_at'|trans({ '%date%': user.markedForDeletionAt|date }) }}\"\n             aria-label=\"{{ 'marked_for_deletion_at'|trans({ '%date%': user.markedForDeletionAt|date }) }}\">\n            <i class=\"fa-solid fa-trash\" aria-hidden=\"true\"></i>\n            <span>\n                {{ 'marked_for_deletion'|trans }}\n                <time datetime=\"{{ user.markedForDeletionAt|date('Y-m-d h:i:s') }}\" class=\"timeago\">{{ user.markedForDeletionAt|date }}</time>\n            </span>\n        </div>\n    {% endif %}\n</aside>\n"
  },
  {
    "path": "templates/components/user_avatar.html.twig",
    "content": "<figure{{ attributes }}>\n    {% if asLink %}\n    <a data-action=\"mouseover->mentions#userPopup mouseout->mentions#userPopupOut\" data-mentions-username-param=\"{{ mention_url(user.username) }}\"\n       href=\"{{ path('user_overview', {username: user.username}) }}\">\n        {% endif %}\n        {% if user.avatar %}\n            <img class=\"image-inline\"\n                 loading=\"lazy\"\n                 width=\"{{ width }}\" height=\"{{ height }}\"\n                 style=\"max-width: {{ width }}px; max-height: {{ height }}px;\"\n                 src=\"{{ user.avatar.filePath ? (asset(user.avatar.filePath)|imagine_filter('avatar_thumb')) : user.avatar.sourceUrl }}\"\n                 alt=\"{{ user.username ~' '~ 'avatar'|trans|lower }}\">\n        {% else %}\n            <div class=\"no-avatar\"></div>\n        {% endif %}\n        {% if asLink %}\n    </a>\n    {% endif %}\n</figure>\n"
  },
  {
    "path": "templates/components/user_box.html.twig",
    "content": "<div{{ attributes.defaults({class: 'user-box'}) }}>\n    <div class=\"{{ html_classes({'with-cover': user.cover, 'with-avatar': user.avatar }) }}\">\n        {% if user.cover %}\n          {% if app.user is same as user and is_route_name_starts_with('user') and not is_route_name_contains('settings') %}\n            <a href=\"{{ path('user_settings_profile') }}\" title=\"{{ 'change_my_cover'|trans }}\" aria-label=\"{{ 'change_my_cover'|trans }}\">\n          {% endif %}\n            <img class=\"cover image-inline\"\n                 height=\"220\"\n                 width=\"100%\"\n                 loading=\"lazy\"\n                 src=\"{{ user.cover.filePath ? (asset(user.cover.filePath)|imagine_filter('user_cover')) : user.cover.sourceUrl }}\"\n                 alt=\"{{ user.username ~' '~ 'cover'|trans|lower  }}\">\n          {% if app.user is same as user and is_route_name_starts_with('user') and not is_route_name_contains('settings') %}\n            </a>\n          {% endif %}\n        {% endif %}\n        <div class=\"user-main\" id=\"content\">\n            <div>\n                <div class=\"row\">\n                    {% if user.avatar %}\n                      {% if app.user is same as user and is_route_name_starts_with('user') and not is_route_name_contains('settings') %}\n                        <a href=\"{{ path('user_settings_profile') }}\" title=\"{{ 'change_my_avatar'|trans }}\" aria-label=\"{{ 'change_my_avatar'|trans }}\">\n                      {% endif %}\n                        {{ component('user_avatar', {\n                            user: user,\n                            width: 100,\n                            height: 100\n                        }) }}\n                      {% if app.user is same as user and is_route_name_starts_with('user') and not is_route_name_contains('settings') %}\n                        </a>\n                      {% endif %}\n                    {% endif %}\n                    {% if stretchedLink %}\n                        <h1>\n                            <a class=\"link-muted stretched-link\"\n                              href=\"{{ path('user_overview', {'username': user.username}) }}\">{{ user.title ?? user.username|username(false) }}</a>\n\n                            {% if (user.type) == \"Service\" %}\n                              <code title=\"{{ 'user_badge_bot'|trans }}\">{{ 'user_badge_bot'|trans }}</code>\n                            {% endif %}\n                            {% if user.isNew() %}\n                                {% set days = constant('App\\\\Entity\\\\User::NEW_FOR_DAYS') %}\n                                <i class=\"fa-solid fa-leaf new-user-icon\" title=\"{{ 'new_user_description'|trans({ '%days%': days }) }}\" aria-description=\"{{ 'new_user_description'|trans({ '%days%': days }) }}\"></i>\n                            {% endif %}\n                            {% if user.isCakeDay() %}\n                                <i class=\"fa-solid fa-cake-candles\" title=\"Cake Day\" aria-description=\"Cake Day\"></i>\n                            {% endif %}\n\n                            {% if user.admin() %}\n                              <code title=\"{{ 'user_badge_admin'|trans }}\">{{ 'user_badge_admin'|trans }}</code>\n                            {% elseif user.moderator() %}\n                              <code title=\"{{ 'user_badge_global_moderator'|trans }}\">{{ 'user_badge_global_moderator'|trans }}</code>\n                            {% endif %}\n                        </h1>\n                    {% else %}\n                        <h1>\n                            {{ user.title ?? user.apPreferredUsername ?? user.username|username(false) }}\n\n                            {% if (user.type) == \"Service\" %}\n                                <code title=\"{{ 'user_badge_bot'|trans }}\">{{ 'user_badge_bot'|trans }}</code>\n                            {% endif %}\n                            {% if user.isNew() %}\n                                {% set days = constant('App\\\\Entity\\\\User::NEW_FOR_DAYS') %}\n                                <i class=\"fa-solid fa-leaf new-user-icon\" title=\"{{ 'new_user_description'|trans({ '%days%': days }) }}\" aria-description=\"{{ 'new_user_description'|trans({ '%days%': days }) }}\"></i>\n                            {% endif %}\n                            {% if user.isCakeDay() %}\n                                <i class=\"fa-solid fa-cake-candles\" title=\"Cake Day\" aria-description=\"Cake Day\"></i>\n                            {% endif %}\n\n                            {% if user.admin() %}\n                                <code title=\"{{ 'user_badge_admin'|trans }}\">{{ 'user_badge_admin'|trans }}</code>\n                            {% elseif user.moderator() %}\n                                <code title=\"{{ 'user_badge_global_moderator'|trans }}\">{{ 'user_badge_global_moderator'|trans }}</code>\n                            {% endif %}\n                        </h1>\n                    {% endif %}\n                    <small>\n                        {{ user.username|username(true) }}\n                        {% if user.apManuallyApprovesFollowers is same as true %}\n                            <i class=\"fa-solid fa-lock\" aria-description=\"{{ 'manually_approves_followers'|trans }}\" title=\"{{ 'manually_approves_followers'|trans }}\" aria-describedby=\"{{ 'manually_approves_followers'|trans }}\"></i>\n                        {% endif %}\n                    </small>\n                </div>\n                {{ component('user_actions', {user: user}) }}\n                {% if app.user is defined and app.user is not same as null and app.user is not same as user %}\n                    <div class=\"notification-switch-container\" data-controller=\"html-refresh\">\n                        {{ component('notification_switch', {target: user}) }}\n                    </div>\n                {% endif %}\n            </div>\n        </div>\n        {% if user.about|length %}\n            <div class=\"about\">\n                <div class=\"content\">\n                    {{ user.about|markdown|raw }}\n                </div>\n            </div>\n        {% endif %}\n    </div>\n</div>\n"
  },
  {
    "path": "templates/components/user_form_actions.html.twig",
    "content": "<div class=\"separator\"></div>\n<div class=\"actions\">\n    {% if showLogin %}\n        <p>{{ 'already_have_account'|trans }}\n            <a class=\"font-weight-bold\" href=\"{{ path('app_login') }}\">{{ 'login'|trans }}</a>\n        </p>\n    {% endif %}\n    {% if kbin_registrations_enabled() %}\n        {% if showRegister %}\n            <p>{{ 'dont_have_account'|trans }}\n                <a class=\"font-weight-bold\" href=\"{{ path('app_register') }}\">{{ 'register'|trans }}</a>\n            </p>\n        {% endif %}\n    {% endif %}\n    {% if showPasswordReset %}\n        <p>{{ 'you_cant_login'|trans }}\n            <a class=\"font-weight-bold\" href=\"{{ path('app_forgot_password_request') }}\">{{ 'reset_password'|trans }}</a>\n        </p>\n    {% endif %}\n{#    {% if showResendEmail %}#}\n{#        <p>{{ 'resend_account_activation_email_question'|trans }} #}\n{#            <a class=\"font-weight-bold\" href=\"{{ path('app_resend_email_activation') }}\">{{ 'resend_account_activation_email'|trans }}</a>#}\n{#        </p>#}\n{#    {% endif %}#}\n</div>\n"
  },
  {
    "path": "templates/components/user_image_component.html.twig",
    "content": "{% if user.avatar and showAvatar %}\n  <img\n      class=\"user-avatar image-inline\"\n      width=\"20\" height=\"20\"\n      loading=\"lazy\"\n      src=\"{{ user.avatar.filePath ? (asset(user.avatar.filePath)|imagine_filter('avatar_thumb')) : user.avatar.sourceUrl }}\"\n      alt=\"{{ user.username ~ ' avatar' }}\"\n  >\n{% endif %}\n"
  },
  {
    "path": "templates/components/user_inline.html.twig",
    "content": "<a href=\"{{ path('user_overview', {username: user.username}) }}\"\n   data-action=\"mouseover->mentions#userPopup mouseout->mentions#userPopupOut\"\n   data-mentions-username-param=\"{{ user.username }}\"\n   class=\"user-inline\"\n   title=\"{{ user.username|username(true) }}\">\n    {% if user.avatar and showAvatar %}\n        <img class=\"image-inline\"\n             width=\"30\" height=\"30\"\n             loading=\"lazy\"\n             src=\"{{ user.avatar.filePath ? (asset(user.avatar.filePath) | imagine_filter('avatar_thumb')) : user.avatar.sourceUrl }}\"\n             alt=\"{{ user.username ~' '~ 'avatar'|trans|lower }}\">\n    {% endif %}\n    {{ user.title ?? user.apPreferredUsername ?? user.username|username -}}\n    {%- if fullName is defined and fullName is same as true -%}\n        @{{- user.username|apDomain -}}\n    {%- endif -%}\n    {% if user.isNew() and showNewIcon %}\n        {% set days = constant('App\\\\Entity\\\\User::NEW_FOR_DAYS') %}\n        <i class=\"fa-solid fa-leaf new-user-icon\" title=\"{{ 'new_user_description'|trans({ '%days%': days }) }}\" aria-description=\"{{ 'new_user_description'|trans({ '%days%': days }) }}\"></i>\n    {% endif %}\n    {% if user.isCakeDay() %}\n        <i class=\"fa-solid fa-cake-candles\" title=\"Cake Day\" aria-description=\"Cake Day\"></i>\n    {% endif %}\n</a>\n"
  },
  {
    "path": "templates/components/user_inline_box.html.twig",
    "content": "<div {{ attributes.defaults({class: 'user-box-inline section'}) }}>\n    <div class=\"{{ html_classes({'with-cover': user.cover, 'with-avatar': user.avatar }) }}\">\n        {% if user.cover %}\n            <img class=\"cover image-inline\"\n                 height=\"220\"\n                 width=\"100%\"\n                 loading=\"lazy\"\n                 src=\"{{ user.cover.filePath ? (asset(user.cover.filePath)|imagine_filter('user_cover')) : user.cover.sourceUrl }}\"\n                 alt=\"{{ user.username ~' '~ 'cover'|trans|lower  }}\" />\n        {% endif %}\n        <div class=\"user-box-info\">\n            <div class=\"user-main\">\n                <div>\n                    <a href=\"{{ path('user_overview', {username: user.username}) }}\" class=\"row\">\n                        {% if user.avatar %}\n                          {% if app.user is same as user and is_route_name_starts_with('user') and not is_route_name_contains('settings') %}\n                            <a href=\"{{ path('user_settings_profile') }}\" title=\"{{ 'change_my_avatar'|trans }}\" aria-label=\"{{ 'change_my_avatar'|trans }}\">\n                          {% endif %}\n                            {{ component('user_avatar', {\n                                user: user,\n                                width: 100,\n                                height: 100\n                            }) }}\n                          {% if app.user is same as user and is_route_name_starts_with('user') and not is_route_name_contains('settings') %}\n                            </a>\n                          {% endif %}\n                        {% endif %}\n                        <h1>\n                            {{ user.title ?? user.apPreferredUsername ?? user.username|username(false) }}\n\n                            {% if (user.type) == \"Service\" %}\n                                <code title=\"{{ 'user_badge_bot'|trans }}\">{{ 'user_badge_bot'|trans }}</code>\n                            {% endif %}\n                            {% if user.isNew() %}\n                                {% set days = constant('App\\\\Entity\\\\User::NEW_FOR_DAYS') %}\n                                <i class=\"fa-solid fa-leaf new-user-icon\" title=\"{{ 'new_user_description'|trans({ '%days%': days }) }}\" aria-description=\"{{ 'new_user_description'|trans({ '%days%': days }) }}\"></i>\n                            {% endif %}\n                            {% if user.isCakeDay() %}\n                                <i class=\"fa-solid fa-cake-candles\" title=\"Cake Day\" aria-description=\"Cake Day\"></i>\n                            {% endif %}\n\n                            {% if user.admin() %}\n                                <code title=\"{{ 'user_badge_admin'|trans }}\">{{ 'user_badge_admin'|trans }}</code>\n                            {% elseif user.moderator() %}\n                                <code title=\"{{ 'user_badge_global_moderator'|trans }}\">{{ 'user_badge_global_moderator'|trans }}</code>\n                            {% endif %}\n                        </h1>\n                        <small>\n                            {{ user.username|username(true) }}\n                            {% if user.apManuallyApprovesFollowers is same as true %}\n                                <i class=\"fa-solid fa-lock\" aria-description=\"{{ 'manually_approves_followers'|trans }}\" title=\"{{ 'manually_approves_followers'|trans }}\" aria-describedby=\"{{ 'manually_approves_followers'|trans }}\"></i>\n                            {% endif %}\n                        </small>\n                    </a>\n                    {{ component('user_actions', {user: user}) }}\n                    {% if app.user is defined and app.user is not same as null and app.user is not same as user %}\n                        <div class=\"notification-switch-container\" data-controller=\"html-refresh\">\n                            {{ component('notification_switch', {target: user}) }}\n                        </div>\n                    {% endif %}\n                </div>\n            </div>\n            {% if user.about|length %}\n                <div class=\"about\">\n                    <div class=\"content\">\n                        {{ user.about|markdown|raw }}\n                    </div>\n                </div>\n            {% endif %}\n        </div>\n    </div>\n</div>\n"
  },
  {
    "path": "templates/components/user_inline_md.html.twig",
    "content": "{% if rich is same as true %}\n    {{ component('user_inline', {\n        user: user,\n        showAvatar: showAvatar,\n        showNewIcon: showNewIcon,\n        fullName: fullName,\n    }) }}\n{% else %}\n    <a href=\"{{ path('user_overview', {username: user.username}) }}\" title=\"{{ user.username|username(true) }}\">\n        @{{- user.title ?? user.username|username -}}\n        {%- if fullName -%}\n            @{{- user.username|apDomain -}}\n        {%- endif -%}\n    </a>\n{% endif %}\n"
  },
  {
    "path": "templates/components/vote.html.twig",
    "content": "{%- set VOTE_NONE = constant('App\\\\Entity\\\\Contracts\\\\VotableInterface::VOTE_NONE') -%}\n{%- set VOTE_UP = constant('App\\\\Entity\\\\Contracts\\\\VotableInterface::VOTE_UP') -%}\n{%- set VOTE_DOWN = constant('App\\\\Entity\\\\Contracts\\\\VotableInterface::VOTE_DOWN') -%}\n{%- set DOWNVOTES_HIDDEN = constant('App\\\\Utils\\\\DownvotesMode::Hidden') %}\n{%- set DOWNVOTES_DISABLED = constant('App\\\\Utils\\\\DownvotesMode::Disabled') %}\n{% if app.user %}\n    {%- set user_choice = is_granted('ROLE_USER') ? subject.userChoice(app.user) : null -%}\n\n    {% set upUrl = path(formDest~'_favourite', {id: subject.id, choice: VOTE_UP}) %}\n    {% set downUrl = path(formDest~'_vote', {id: subject.id, choice: VOTE_DOWN}) %}\n\n    {% if(user_choice is same as(VOTE_UP)) %}\n        {% set choice = VOTE_UP %}\n    {% elseif(user_choice is same as(VOTE_DOWN)) %}\n        {% set choice = VOTE_DOWN %}\n    {% else %}\n        {% set choice = VOTE_NONE %}\n    {% endif %}\n{% else %}\n    {% set choice = VOTE_NONE %}\n    {% set upUrl = path(formDest~'_favourite', {id: subject.id, choice: VOTE_NONE}) %}\n    {% set downUrl = path(formDest~'_vote', {id: subject.id, choice: VOTE_NONE}) %}\n{% endif %}\n<aside{{ attributes.defaults({class: 'vote'}) }}>\n    <form method=\"post\"\n          action=\"{{ upUrl }}\"\n          class=\"{{ html_classes('vote__up',{\n              'active': app.user and subject.isFavored(app.user),\n          }) }}\">\n        <button type=\"submit\"\n                title=\"{{ 'favourite'|trans }}\"\n                aria-label=\"{{ 'favourite'|trans }}\"\n                data-action=\"subject#vote\">\n            <span data-subject-target=\"favCounter\">{{ (subject.apLikeCount ?? subject.favouriteCount)|abbreviateNumber }}</span> <span><i class=\"fa-solid fa-arrow-up\" aria-hidden=\"true\"></i></span>\n        </button>\n    </form>\n    {% set downvoteMode = mbin_downvotes_mode() %}\n    {% if showDownvote and downvoteMode is not same as DOWNVOTES_DISABLED %}\n        <form method=\"post\"\n              action=\"{{ downUrl }}\"\n              class=\"{{ html_classes('vote__down', {\n                  'active': choice is same as(VOTE_DOWN),\n              }) }}\">\n            <button type=\"submit\"\n                    title=\"{{ 'down_vote'|trans }}\"\n                    aria-label=\"{{ 'down_vote'|trans }}\"\n                    data-action=\"subject#vote\">\n                {% if downvoteMode is not same as DOWNVOTES_HIDDEN %}\n                    <span data-subject-target=\"downvoteCounter\">{{ (subject.apDislikeCount ?? subject.countDownvotes)|abbreviateNumber }}</span>\n                {% endif %}\n                <span><i class=\"fa-solid fa-arrow-down\" aria-hidden=\"true\"></i></span>\n            </button>\n        </form>\n    {% endif %}\n</aside>\n"
  },
  {
    "path": "templates/components/voters_inline.html.twig",
    "content": "{% if voters|length %}\n    <div{{ attributes.defaults({class: 'boosts'}) }}>\n        <span aria-label=\"{{ 'boosts'|trans }}\">+</span>\n        {% for voter in voters %}\n            <a href=\"{{ path('user_overview', {username: voter}) }}\">{{ voter|username }}</a>\n            {%- if not loop.last %},{% endif %}\n        {% endfor %}\n        {% if count > 4 %}\n            <a data-action=\"post#expandVoters\" href=\"{{ url }}\">+{{ count - 4 }} {{ 'more'|trans }}</a>\n        {% endif %}\n    </div>\n{% endif %}"
  },
  {
    "path": "templates/content/_list.html.twig",
    "content": "{%- set V_TRUE = constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::TRUE') -%}\n{%- set V_FALSE = constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::FALSE') -%}\n\n{%- set DYNAMIC_LISTS = app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_GENERAL_DYNAMIC_LISTS'), V_FALSE) -%}\n{%- set INFINITE_SCROLL = app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_GENERAL_INFINITE_SCROLL'), V_FALSE) -%}\n{%- set SHOW_COMMENTS_AVATAR = app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_COMMENTS_SHOW_USER_AVATAR')) -%}\n{%- set SHOW_POST_AVATAR = app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_POSTS_SHOW_USERS_AVATARS')) -%}\n\n{% if criteria is defined and criteria.content is same as constant('APP\\\\Repository\\\\Criteria::CONTENT_THREADS') %}\n    {% set data_action = DYNAMIC_LISTS is same as V_TRUE ? 'notifications:EntryCreatedNotification@window->subject-list#addMainSubject' : 'notifications:EntryCreatedNotification@window->subject-list#increaseCounter' %}\n{% elseif criteria is defined and criteria.content is same as constant('APP\\\\Repository\\\\Criteria::CONTENT_MICROBLOG') %}\n    {% set data_action = DYNAMIC_LISTS is same as V_TRUE ? 'notifications:PostCreatedNotification@window->subject-list#addMainSubject' : 'notifications:PostCreatedNotification@window->subject-list#increaseCounter' %}\n{% else %}\n    {% set data_action = DYNAMIC_LISTS is same as V_TRUE ?\n        'notifications:EntryCreatedNotification@window->subject-list#addMainSubject notifications:PostCreatedNotification@window->subject-list#addMainSubject' :\n        'notifications:EntryCreatedNotification@window->subject-list#increaseCounter notifications:PostCreatedNotification@window->subject-list#increaseCounter'\n    %}\n{% endif %}\n\n<div data-controller=\"subject-list\"\n     class=\"{{ html_classes({\n        'show-comment-avatar': SHOW_COMMENTS_AVATAR is same as V_TRUE or not SHOW_COMMENTS_AVATAR,\n        'show-post-avatar': SHOW_POST_AVATAR is same as V_TRUE or not SHOW_POST_AVATAR\n     }) }}\"\n     data-action=\"{{- data_action -}}\">\n    {% if magazine is defined and magazine %}\n        {% include 'layout/_subject_list.html.twig' with {postAttributes: {showMagazineName: false}, entryAttributes: {showMagazineName: false}} %}\n    {% else %}\n        {% include 'layout/_subject_list.html.twig' %}\n    {% endif %}\n</div>\n"
  },
  {
    "path": "templates/content/front.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    {%- if magazine is defined and magazine -%}\n        {% if get_active_sort_option('sortBy') is not same as get_default_sort_option() %}\n            {{- get_active_sort_option('sortBy')|trans|capitalize }} - {{ magazine.title }} - {{ parent() -}}\n        {% else %}\n            {{- magazine.title }} - {{ parent() -}}\n        {% endif %}\n    {%- else -%}\n        {% if criteria.getOption('content') == 'threads' %}\n            {% if get_active_sort_option('sortBy') is not same as get_default_sort_option() %}\n                {{- get_active_sort_option('sortBy')|trans|capitalize }} - {{ 'thread'|trans }} - {{ parent() }}\n            {% else %}\n                {{- 'thread'|trans }} - {{ parent() -}}\n            {% endif %}\n        {% elseif criteria.getOption('content') == 'microblog' %}\n            {% if get_active_sort_option('sortBy') is not same as get_default_sort_option() %}\n                {{- get_active_sort_option('sortBy')|trans|capitalize }} - {{ 'microblog'|trans }} - {{ parent() }}\n            {% else %}\n                {{- 'microblog'|trans }} - {{ parent() -}}\n            {% endif %}\n        {% else %}\n            {% if get_active_sort_option('sortBy') is not same as get_default_sort_option() %}\n                {{- get_active_sort_option('sortBy')|trans|capitalize }} - {{ parent() }} - {{ kbin_meta_description() -}}\n            {% else %}\n                {{- parent() }} - {{ kbin_meta_description() -}}\n            {% endif %}\n        {% endif %}\n    {%- endif -%}\n{%- endblock -%}\n\n{% block description %}\n    {%- if magazine is defined and magazine -%}\n        {{- magazine.description ? get_short_sentence(magazine.description) : '' -}}\n    {%- else -%}\n        {{- parent() -}}\n    {%- endif -%}\n{% endblock %}\n\n{% block image %}\n    {%- if magazine is defined and magazine and magazine.icon -%}\n        {{- uploaded_asset(magazine.icon) -}}\n    {%- else -%}\n        {{- parent() -}}\n    {%- endif -%}\n{% endblock %}\n\n{% block mainClass %}page-entry-front{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n{% endblock %}\n\n{% block body %}\n    <header>\n        {% if magazine is defined and magazine %}\n            <h1 hidden>{{ magazine.title }}</h1>\n            <h2 hidden>{{ get_active_sort_option()|trans }}</h2>\n            {% if magazine.banner is not same as null %}\n                <div class=\"magazine-banner\">\n                    <img loading=\"lazy\" class=\"cover\"\n                        src=\"{{ magazine.banner.filePath ? (asset(magazine.banner.filePath)|imagine_filter('magazine_banner')) : magazine.banner.sourceUrl }}\"\n                        alt=\"{{ magazine.name ~ ' ' ~ 'banner'|trans|lower }}\"\n                        />\n                </div>\n            {% endif %}\n        {% else %}\n            <h1 hidden>{{ get_active_sort_option()|trans }}</h1>\n        {% endif %}\n    </header>\n    {% if criteria is defined %}\n        {% if criteria.getOption('content') == 'microblog' %}\n            <section class=\"section section--top\">\n                {% include 'post/_form_post.html.twig' %}\n            </section>\n            {% include 'post/_options.html.twig' %}\n        {% else %}\n            {% include 'entry/_options.html.twig' %}\n        {% endif %}\n    {% endif %}\n    {% include 'layout/_flash.html.twig' %}\n    {% if magazine is defined and magazine %}\n        {% include 'magazine/_restricted_info.html.twig' %}\n        {% include 'magazine/_federated_info.html.twig' %}\n        {% include 'magazine/_visibility_info.html.twig' %}\n    {% endif %}\n    <div id=\"content\">\n        {{ include('content/_list.html.twig') }}\n    </div>\n{% endblock %}\n\n"
  },
  {
    "path": "templates/domain/_header_nav.html.twig",
    "content": "<li>\n    <a href=\"{{ path('domain_entries', {name: domain.name}) }}\"\n       class=\"{{ html_classes({'active': is_route_name('domain_entries') }) }}\">\n        {{ 'threads'|trans }}\n    </a>\n</li>\n<li>\n    <a href=\"{{ path('domain_comments', {name: domain.name}) }}\"\n       class=\"{{ html_classes({'active': is_route_name('domain_comments') }) }}\">\n        {{ 'comments'|trans }}\n    </a>\n</li>\n"
  },
  {
    "path": "templates/domain/_list.html.twig",
    "content": "{% if domains|length %}\n    <div class=\"domains section\">\n        <table>\n            <thead>\n            <tr>\n                <td>{{ 'name'|trans }}</td>\n                <td>{{ 'threads'|trans }}</td>\n                <td></td>\n            </tr>\n            </thead>\n            <tbody>\n            {% for domain in domains %}\n                <tr>\n                    <td><a href=\"{{ path('domain_entries', {name: domain.name}) }}\">{{ domain.name }}</a></td>\n                    <td>{{ domain.entries|length }}</td>\n                    <td>{{ component('domain_sub', {domain: domain}) }}</td>\n                </tr>\n            {% endfor %}\n            </tbody>\n        </table>\n    </div>\n{% else %}\n    <aside class=\"section section--muted\">\n        <p>{{ 'empty'|trans }}</p>\n    </aside>\n{% endif %}\n"
  },
  {
    "path": "templates/domain/_options.html.twig",
    "content": "{% set showFilterLabels = app.request.cookies.get('kbin_general_filter_labels')|default('on') %}\n\n<aside class=\"options options--top\" id=\"options\">\n    <div></div>\n    <menu class=\"options__filter\">\n        <li class=\"dropdown\">\n            <button aria-label=\"{{ 'sort_by'|trans }}\"\n                    title=\"{{ 'sort_by'|trans }}\"><i\n                        class=\"fa-solid fa-sort\" aria-hidden=\"true\"></i>\n                <span>{{ criteria.getOption('sort')|trans }}</span>\n            </button>\n            <ul class=\"dropdown__menu\">\n                <li>\n                    <a href=\"{{ options_url('sortBy', 'top', null, {'p': null}) }}\"\n                    class=\"{{ html_classes({'active': criteria.getOption('sort') == 'top'}) }}\">\n                        {{ 'top'|trans }}\n                    </a>\n                </li>\n                <li>\n                    <a href=\"{{ options_url('sortBy', 'hot', null, {'p': null}) }}\"\n                    class=\"{{ html_classes({'active': criteria.getOption('sort') == 'hot'}) }}\">\n                        {{ 'hot'|trans }}\n                    </a>\n                </li>\n                <li>\n                    <a href=\"{{ options_url('sortBy', 'newest', null, {'p': null}) }}\"\n                    class=\"{{ html_classes({'active': criteria.getOption('sort') == 'newest'}) }}\">\n                        {{ 'newest'|trans }}\n                    </a>\n                </li>\n                <li>\n                    <a href=\"{{ options_url('sortBy', 'active', null, {'p': null}) }}\"\n                    class=\"{{ html_classes({'active': criteria.getOption('sort') == 'active'}) }}\">\n                        {{ 'active'|trans }}\n                    </a>\n                </li>\n                <li>\n                    <a href=\"{{ options_url('sortBy', 'commented', null, {'p': null}) }}\"\n                    class=\"{{ html_classes({'active': criteria.getOption('sort') == 'commented'}) }}\">\n                        {{ 'commented'|trans }}\n                    </a>\n                </li>\n            </ul>\n        </li>\n        <li class=\"dropdown\">\n            <button aria-label=\"{{ 'filter_by_time'|trans }}\"\n                    title=\"{{ 'filter_by_time'|trans }}\">\n                <i aria-hidden=\"true\" class=\"fa-solid fa-clock\"></i>\n                {% if showFilterLabels == 'on' or (showFilterLabels == 'auto' and criteria.getOption('time') != 'all') %}\n                    <span>{{ criteria.getOption('time')|trans }}</span>\n                {% endif %}\n            </button>\n            <ul class=\"dropdown__menu\">\n                <li>\n                    <a href=\"{{ options_url('time', '∞', null, {'p': null}) }}\"\n                       class=\"{{ html_classes({'active': criteria.getOption('time') == 'all'}) }}\">\n                        {{ 'all_time'|trans }}\n                    </a>\n                </li>\n                <li>\n                    <a href=\"{{ options_url('time', '3h', null, {'p': null}) }}\"\n                       class=\"{{ html_classes({'active': criteria.getOption('time') == '3h' }) }}\">\n                        {{ '3h'|trans }}\n                    </a></li>\n                <li>\n                    <a href=\"{{ options_url('time', '6h', null, {'p': null}) }}\"\n                       class=\"{{ html_classes({'active': criteria.getOption('time') == '6h' }) }}\">\n                        {{ '6h'|trans }}\n                    </a>\n                </li>\n                <li>\n                    <a href=\"{{ options_url('time', '12h', null, {'p': null}) }}\"\n                       class=\"{{ html_classes({'active': criteria.getOption('time') == '12h' }) }}\">\n                        {{ '12h'|trans }}\n                    </a>\n                </li>\n                <li>\n                    <a href=\"{{ options_url('time', '1d', null, {'p': null}) }}\"\n                       class=\"{{ html_classes({'active': criteria.getOption('time') == '1d' }) }}\">\n                        {{ '1d'|trans }}\n                    </a>\n                </li>\n                <li>\n                    <a href=\"{{ options_url('time', '1w', null, {'p': null}) }}\"\n                       class=\"{{ html_classes({'active': criteria.getOption('time') == '1w' }) }}\">\n                        {{ '1w'|trans }}\n                    </a>\n                </li>\n                <li>\n                    <a href=\"{{ options_url('time', '1m', null, {'p': null}) }}\"\n                       class=\"{{ html_classes({'active': criteria.getOption('time') == '1m' }) }}\">\n                        {{ '1m'|trans }}\n                    </a>\n                </li>\n                <li>\n                    <a href=\"{{ options_url('time', '1y', null, {'p': null}) }}\"\n                       class=\"{{ html_classes({'active': criteria.getOption('time') == '1y' }) }}\">\n                        {{ '1y'|trans }}\n                    </a>\n                </li>\n            </ul>\n        </li>\n        <li class=\"dropdown\">\n            <button aria-label=\"{{ 'filter_by_type'|trans }}\"\n                    title=\"{{ 'filter_by_type'|trans }}\">\n                <i aria-hidden=\"true\" class=\"\n                    {% if criteria.getOption('type') == 'all' %}\n                        fa-solid fa-file\n                    {% elseif criteria.getOption('type') == 'links' %}\n                        fa-regular fa-file-code\n                    {% elseif criteria.getOption('type') == 'threads' %}\n                        fa-regular fa-file-lines\n                    {% elseif criteria.getOption('type') == 'photos' %}\n                        fa-regular fa-file-image\n                    {% elseif criteria.getOption('type') == 'videos' %}\n                        fa-regular fa-file-video\n                    {% else %}\n                        fa-solid fa-question\n                    {% endif %}\">\n                </i>\n                {% if showFilterLabels == 'on' or (showFilterLabels == 'auto' and criteria.getOption('type') != 'all') %}\n                    <span class=\"hide-on-mobile\">{{ criteria.getOption('type')|trans }}</span>\n                {% endif %}\n            </button>\n            <ul class=\"dropdown__menu\">\n                <li>\n                    <a href=\"{{ options_url('type', null, null, {'p': null}) }}\" class=\"{{ html_classes({'active': criteria.getOption('type') == 'all' }) }}\">\n                        <i class=\"fa-solid fa-file\" aria-hidden=\"true\"></i> &nbsp; {{ 'all'|trans }}\n                    </a>\n                </li>\n                <li>\n                    <a href=\"{{ options_url('type', 'links', null, {'p': null}) }}\" class=\"{{ html_classes({'active': criteria.getOption('type') == 'links' }) }}\">\n                        <i class=\"fa-regular fa-file-code\" aria-hidden=\"true\"></i> &nbsp; {{ 'links'|trans }}\n                    </a>\n                </li>\n                <li>\n                    <a href=\"{{ options_url('type', 'articles', null, {'p': null}) }}\" class=\"{{ html_classes({'active': criteria.getOption('type') == 'threads' }) }}\">\n                        <i class=\"fa-regular fa-file-lines\" aria-hidden=\"true\"></i> &nbsp; {{ 'threads'|trans }}\n                    </a>\n                </li>\n                <li>\n                    <a href=\"{{ options_url('type', 'photos', null, {'p': null}) }}\" class=\"{{ html_classes({'active': criteria.getOption('type') == 'photos' }) }}\">\n                        <i class=\"fa-regular fa-file-image\" aria-hidden=\"true\"></i> &nbsp; {{ 'photos'|trans }}\n                    </a>\n                </li>\n                <li>\n                    <a href=\"{{ options_url('type', 'videos', null, {'p': null}) }}\" class=\"{{ html_classes({'active': criteria.getOption('type') == 'videos'}) }}\">\n                        <i class=\"fa-regular fa-file-video\" aria-hidden=\"true\"></i> &nbsp; {{ 'videos'|trans }}\n                    </a>\n                </li>\n            </ul>\n        </li>\n    </menu>\n    <menu class=\"options__view\">\n        <li class=\"dropdown\">\n            <button aria-label=\"{{ 'change_view'|trans }}\"\n                    title=\"{{ 'change_view'|trans }}\"><i\n                        class=\"fa-solid fa-layer-group\" aria-hidden=\"true\"></i>\n            </button>\n            <ul class=\"dropdown__menu\">\n                <li>\n                    <a class=\"{{ html_classes({'active': not app.request.cookies.has(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_ENTRIES_COMPACT')) or app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_ENTRIES_COMPACT')) is same as 'false'}) }}\"\n                       href=\"{{ path('theme_settings', {key: constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_ENTRIES_COMPACT'), value: 'false'}) }}\">\n                        {{ 'classic_view'|trans }}\n                    </a>\n                </li>\n                <li>\n                    <a class=\"{{ html_classes({'active': app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_ENTRIES_COMPACT')) is same as 'true'}) }}\"\n                       href=\"{{ path('theme_settings', {key: constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_ENTRIES_COMPACT'), value: 'true'}) }}\">\n                        {{ 'compact_view'|trans }}\n                    </a>\n                </li>\n            </ul>\n        </li>\n    </menu>\n</aside>\n"
  },
  {
    "path": "templates/domain/comment/front.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    title\n{%- endblock -%}\n\n{% block mainClass %}page-domain-comments-front{% endblock %}\n\n{% block header_nav %}\n    {% include 'domain/_header_nav.html.twig' %}\n{% endblock %}\n\n{% block sidebar_top %}\n    {{ component('domain', {domain: domain}) }}\n{% endblock %}\n\n{% block body %}\n    <h1 hidden>{{ domain.name }}</h1>\n    <h2 hidden>{{ get_active_sort_option_for_comments()|trans }}</h2>\n    {% include 'entry/comment/_options.html.twig' %}\n    <div id=\"content\" class=\"comments-tree\">\n        {% include 'entry/comment/_list.html.twig' %}\n    </div>\n{% endblock %}\n\n"
  },
  {
    "path": "templates/domain/front.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    {{ domain.name }} - {{ parent() -}}\n{%- endblock -%}\n\n{% block mainClass %}page-domain-entry-front{% endblock %}\n\n{% block header_nav %}\n    {% include 'domain/_header_nav.html.twig' %}\n{% endblock %}\n\n{% block sidebar_top %}\n    {{ component('domain', {domain: domain}) }}\n{% endblock %}\n\n{% block body %}\n    <header>\n        <h1 hidden>{{ domain.name }}</h1>\n        <h2 hidden>{{ get_active_sort_option()|trans }}</h2>\n    </header>\n    {% include 'domain/_options.html.twig' %}\n    <div id=\"content\">\n        {% include 'entry/_list.html.twig' %}\n    </div>\n{% endblock %}\n"
  },
  {
    "path": "templates/entry/_create_options.html.twig",
    "content": "<aside class=\"options options--top options-activity\">\n    <div class=\"options__title\">\n        <h2>{{ 'add_new'|trans }}</h2>\n    </div>\n    <menu class=\"options__main\">\n        {% if magazine is defined and magazine %}\n            <li>\n                <a href=\"{{ path('magazine_entry_create', {name: magazine.name}) }}\"\n                   class=\"{{ html_classes({'active': is_route_name('magazine_entry_create')}) }}\">\n                    {{ 'type.article'|trans }}\n                </a>\n            </li>\n            <li>\n                <a href=\"{{ path('magazine_posts', {name: magazine.name}) }}\">\n                    {{ 'post'|trans }}\n                </a>\n            </li>\n            <li>\n                <a href=\"{{ path('magazine_create') }}\"\n                   class=\"{{ html_classes({'active': is_route_name('magazine_create')}) }}\">\n                    {{ 'type.magazine'|trans }}\n                </a>\n            </li>\n        {% else %}\n            <li>\n                <a href=\"{{ path('entry_create', {type: 'article'}) }}\"\n                   class=\"{{ html_classes({'active': is_route_name('entry_create')}) }}\">\n                    {{ 'type.article'|trans }}\n                </a>\n            </li>\n            <li>\n                <a href=\"{{ path('posts_front') }}\">\n                    {{ 'post'|trans }}\n                </a>\n            </li>\n            <li>\n                <a href=\"{{ path('magazine_create') }}\"\n                   class=\"{{ html_classes({'active': is_route_name('magazine_create')}) }}\">\n                    {{ 'type.magazine'|trans }}\n                </a>\n            </li>\n        {% endif %}\n    </menu>\n</aside>\n"
  },
  {
    "path": "templates/entry/_form_edit.html.twig",
    "content": "{% form_theme form.lang 'form/lang_select.html.twig' %}\n\n{{ form_start(form, {attr: {class: 'entry_edit'}}) }}\n\n    <div>\n        {% set label %}\n            URL\n            <div data-entry-link-create-target='loader' class=\"loader small\" role=\"status\">\n                <span class=\"visually-hidden\">Loading...</span>\n            </div>\n        {% endset %}\n        {{ form_label(form.url, label, {'label_html': true}) }}\n        {{ form_errors(form.url) }}\n        {{ form_widget(form.url, {attr: {'data-action': 'entry-link-create#fetchLink', 'data-entry-link-create-target': 'url'}}) }}\n    </div>\n\n    {{ form_row(form.title, {\n        label: 'title', attr: {\n            'data-controller' : \"input-length autogrow\",\n            'data-entry-link-create-target': 'title',\n            'data-action' : 'input-length#updateDisplay',\n            'data-input-length-max-value' : constant('App\\\\Entity\\\\Entry::MAX_TITLE_LENGTH')\n        }}) }}\n    {{ component('editor_toolbar', {id: 'entry_edit_body'}) }}\n    {{ form_row(form.body, {\n        label: false, attr: {\n            placeholder: 'body',\n            'data-controller': 'rich-textarea input-length autogrow',\n            'data-entry-link-create-target': 'description',\n            'data-action' : 'input-length#updateDisplay',\n            'data-input-length-max-value' : constant('App\\\\Entity\\\\Entry::MAX_BODY_LENGTH')\n        }}) }}\n    {{ form_row(form.magazine, {label: false}) }}\n    {{ form_row(form.tags, {label: 'tags'}) }}\n    {# form_row(form.badges, {label: 'badges'}) #}\n<div class=\"row params\">\n    {{ form_row(form.isAdult, {label: 'is_adult', row_attr: {class: 'checkbox'}}) }}\n    {{ form_row(form.isOc, {label: 'oc', row_attr: {class: 'checkbox'}}) }}\n</div>\n<div class=\"row actions\">\n    <ul>\n        {% if entry.image is not same as null %}\n            <img width=\"40\"\n                 height=\"40\"\n                 src=\"{{ entry.image.filePath ? (asset(entry.image.filePath)|imagine_filter('entry_thumb')) : entry.image.sourceUrl }}\"\n                 alt=\"{{ entry.image.altText }}\">\n            <button formaction=\"{{ path('entry_image_delete', {magazine_name: entry.magazine.name, entry_id: entry.id}) }}\"\n                    class=\"btn-link\"\n                    aria-label=\"{{ 'remove_media'|trans }}\"\n                    title=\"{{ 'remove_media'|trans }}\"\n                    data-action=\"confirmation#ask\" data-confirmation-message-param=\"{{ 'are_you_sure'|trans }}\">\n                <i class=\"fa-solid fa-xmark\" aria-hidden=\"true\"></i>\n            </button>\n        {% endif %}\n        <li class=\"{{ html_classes('dropdown') }}\">\n            <button type=\"button\"\n                    class=\"btn btn__secondary\"\n                    aria-label=\"{{ 'add_media'|trans }}\"\n                    title=\"{{ 'add_media'|trans }}\">\n                <i class=\"fa-solid fa-photo-film\" aria-hidden=\"true\"></i>\n            </button>\n            <div class=\"dropdown__menu\">\n                {% include 'layout/_form_media.html.twig' %}\n            </div>\n        </li>\n        <li class=\"select\">\n            {{ form_row(form.lang, {label: false}) }}\n        </li>\n        <li>\n            {{ form_row(form.submit, {label: 'edit_entry', attr: {class: 'btn btn__primary'}}) }}\n        </li>\n    </ul>\n</div>\n{{ form_end(form) }}\n"
  },
  {
    "path": "templates/entry/_form_entry.html.twig",
    "content": "{% form_theme form.lang 'form/lang_select.html.twig' %}\n\n{% set hasImage = false %}\n{% if edit is not defined %}\n    {% set edit = false %}\n{% elseif entry.image %}\n    {% set hasImage = true %}\n{% endif %}\n\n{{ form_start(form, {attr: {class: edit ? 'entry_edit' : 'entry-create'}}) }}\n\n    <div>\n        {% set label %}\n            URL\n            <div data-entry-link-create-target='loader' class=\"loader small\" role=\"status\">\n                <span class=\"visually-hidden\">Loading...</span>\n            </div>\n        {% endset %}\n        {{ form_label(form.url, label, {'label_html': true}) }}\n        {{ form_errors(form.url) }}\n        {{ form_widget(form.url, {attr: {'data-action': 'entry-link-create#fetchLink','data-entry-link-create-target': 'url'}}) }}\n    </div>\n\n    {{ form_row(form.title, {\n        label: 'title', attr: {\n            'data-controller' : \"input-length autogrow\",\n            'data-entry-link-create-target': 'title',\n            'data-action' : 'input-length#updateDisplay',\n            'data-input-length-max-value' : constant('App\\\\Entity\\\\Entry::MAX_TITLE_LENGTH')\n       }})\n    }}\n\n    {{ component('editor_toolbar', {id: 'entry_body'}) }}\n    {{ form_row(form.body, {\n        label: false, attr: {\n            placeholder: 'body',\n            'data-controller': 'rich-textarea input-length autogrow',\n            'data-entry-link-create-target': 'description',\n            'data-action' : 'input-length#updateDisplay',\n            'data-input-length-max-value' : constant('App\\\\Entity\\\\Entry::MAX_BODY_LENGTH')\n        }})\n    }}\n\n    {{ form_row(form.magazine, {label: false}) }}\n    {{ form_row(form.tags, {label: 'tags'}) }}\n    {# form_row(form.badges, {label: 'badges'}) #}\n    <div class=\"row params\">\n        {{ form_row(form.isAdult, {label: 'is_adult', row_attr: {class: 'checkbox'}}) }}\n        {{ form_row(form.isOc, {label: 'oc', row_attr: {class: 'checkbox'}}) }}\n    </div>\n\n    <div class=\"row actions\">\n        <ul>\n            {% if hasImage %}\n                <img width=\"40\"\n                     height=\"40\"\n                     src=\"{{ entry.image.filePath ? (asset(entry.image.filePath)|imagine_filter('entry_thumb')) : entry.image.sourceUrl }}\"\n                     alt=\"{{ entry.image.altText }}\">\n                <button formaction=\"{{ path('entry_image_delete', {magazine_name: entry.magazine.name, entry_id: entry.id}) }}\"\n                        class=\"btn-link\"\n                        aria-label=\"{{ 'remove_media'|trans }}\"\n                        title=\"{{ 'remove_media'|trans }}\"\n                        data-action=\"confirmation#ask\" data-confirmation-message-param=\"{{ 'are_you_sure'|trans }}\">\n                    <i class=\"fa-solid fa-xmark\" aria-hidden=\"true\"></i>\n                </button>\n            {% endif %}\n            <li class=\"{{ html_classes('dropdown', {'hidden': hasImage}) }}\">\n                <button type=\"button\"\n                        class=\"btn btn__secondary\"\n                        aria-label=\"{{ 'add_media'|trans }}\"\n                        title=\"{{ 'add_media'|trans }}\">\n                    <i class=\"fa-solid fa-photo-film\" aria-hidden=\"true\"></i>\n                </button>\n                <div class=\"dropdown__menu\">\n                    {% include 'layout/_form_media.html.twig' %}\n                </div>\n            </li>\n            <li class=\"select\">\n                {{ form_row(form.lang, {label: false}) }}\n            </li>\n            <li>\n                {{ form_row(form.submit, {label: edit ? 'edit_article' : 'add_new_article', attr: {class: 'btn btn__primary'}}) }}\n            </li>\n        </ul>\n    </div>\n{{ form_end(form) }}\n"
  },
  {
    "path": "templates/entry/_info.html.twig",
    "content": "<section class=\"section entry-info\">\n    <h3>{{ 'thread'|trans }}</h3>\n    <div class=\"row\">\n        {% if entry.user.avatar %}\n            <figure>\n                <img class=\"image-inline\"\n                     width=\"100\" height=\"100\"\n                     loading=\"lazy\"\n                     src=\"{{ entry.user.avatar.filePath ? (asset(entry.user.avatar.filePath)|imagine_filter('avatar_thumb')) : entry.user.avatar.sourceUrl }}\"\n                     alt=\"{{ entry.user.username ~' '~ 'avatar'|trans|lower }}\">\n            </figure>\n        {% endif %}\n        <h4><a href=\"{{ path('user_overview', {username:entry.user.username}) }}\">{{ entry.user.username|username(false) }}</a></h4>\n        <p class=\"user__name\">\n            <span>\n                {{ entry.user.username|username(true) }}\n                {% if entry.user.apManuallyApprovesFollowers is same as true %}\n                    <i class=\"fa-solid fa-lock\" aria-description=\"{{ 'manually_approves_followers'|trans }}\" title=\"{{ 'manually_approves_followers'|trans }}\" aria-describedby=\"{{ 'manually_approves_followers'|trans }}\"></i>\n                {% endif %}\n            </span>\n            {% if entry.user.apProfileId %}\n                <a href=\"{{ entry.user.apProfileId }}\" rel=\"noopener noreferrer nofollow\" target=\"_blank\" title=\"{{ 'go_to_original_instance'|trans }}\" aria-label=\"{{ 'go_to_original_instance'|trans }}\">\n                <i class=\"fa-solid fa-external-link\" aria-hidden=\"true\"></i></a>\n            {% endif %}\n        </p>\n    </div>\n    {{ component('user_actions', {user: entry.user}) }}\n    {% if app.user is defined and app.user is not same as null and app.user is not same as entry.user %}\n        <div class=\"notification-switch-container\" data-controller=\"html-refresh\">\n            {{ component('notification_switch', {target: entry.user}) }}\n        </div>\n    {% endif %}\n    <ul class=\"info\">\n        <li>{{ 'added'|trans }}: {{ component('date', {date: entry.createdAt}) }}</li>\n        {% if entry.editedAt %}\n            <li>{{ 'edited'|trans }}: {{ component('date', {date: entry.editedAt}) }}</li>\n        {% endif %}\n    </ul>\n    {% if entry.hashtags is not empty %}\n        <h3 class=\"mt-3\">{{ 'tags'|trans }}</h3>\n        <div class=\"mt-2\">\n            {% for link in entry.hashtags %}\n                <small><a class=\"badge\" href=\"{{ path('tag_overview', {name: link.hashtag.tag}) }}\">#{{ link.hashtag.tag }}</a></small>\n            {% endfor %}\n        </div>\n    {% endif %}\n</section>\n"
  },
  {
    "path": "templates/entry/_list.html.twig",
    "content": "{%- set V_TRUE = constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::TRUE') -%}\n{%- set V_FALSE = constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::FALSE') -%}\n\n{%- set DYNAMIC_LISTS = app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_GENERAL_DYNAMIC_LISTS'), V_FALSE) -%}\n{%- set INFINITE_SCROLL = app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_GENERAL_INFINITE_SCROLL'), V_FALSE) -%}\n\n<div data-controller=\"subject-list\"\n     data-action=\"{{- DYNAMIC_LISTS is same as V_TRUE ? 'notifications:EntryCreatedNotification@window->subject-list#addMainSubject' : 'notifications:EntryCreatedNotification@window->subject-list#increaseCounter' -}}\">\n    {% for entry in entries %}\n        {{ component('entry', {\n            entry: entry,\n            showMagazineName: magazine is not defined or not magazine\n        }) }}\n    {% endfor %}\n    {% if(entries.haveToPaginate is defined and entries.haveToPaginate) %}\n        {% if INFINITE_SCROLL is same as V_TRUE %}\n            <div data-controller=\"infinite-scroll\" class=\"infinite-scroll\">\n                {{ component('loader', {'data-infinite-scroll-target': 'loader'}) }}\n                <div data-infinite-scroll-target=\"pagination\" class=\"visually-hidden\">\n                    {{ pagerfanta(entries, null, {'pageParameter':'[p]'}) }}\n                </div>\n            </div>\n        {% else %}\n            {{ pagerfanta(entries, null, {'pageParameter':'[p]'}) }}\n        {% endif %}\n    {% endif %}\n    {% if not entries|length %}\n        <aside class=\"section section--muted\">\n            <p>{{ 'empty'|trans }}</p>\n        </aside>\n        {% if magazine is defined and magazine.postCount > 0 %}\n            <aside class=\"section section--muted\">\n                <i class=\"fa-solid fa-comments text-muted\" aria-hidden=\"true\"></i>\n                <a class=\"btn \" href=\"{{ path('magazine_posts', {name: magazine.name}) }}\">\n                    {{ 'microblog'|trans }} ({{ magazine.postCount + magazine.postCommentCount }})</a>\n            </aside>\n        {% endif %}\n    {% endif %}\n</div>\n"
  },
  {
    "path": "templates/entry/_menu.html.twig",
    "content": "<li class=\"dropdown\">\n  <button class=\"stretched-link\" data-subject-target=\"more\">{{ 'more'|trans }}</button>\n  <ul class=\"dropdown__menu\" data-controller=\"clipboard\">\n    {% if app.user is defined and app.user %}\n      <li>\n        <a href=\"{{ path('entry_report', {id: entry.id}) }}\"\n          class=\"{{ html_classes({'active': is_route_name('entry_report')}) }}\" data-action=\"subject#getForm\">\n          {{ 'report'|trans }}\n        </a>\n      </li>\n      <li>\n        <a href=\"{{ path('entry_crosspost', {id: entry.id}) }}\">\n          {{ 'crosspost'|trans }}\n        </a>\n      </li>\n    {% endif %}\n    <li>\n      <a href=\"{{ entry_voters_url(entry, 'up') }}\"\n        class=\"{{ html_classes({'active': is_route_name('entry_fav') or is_route_name('entry_voters')}) }}\">\n        {{ 'activity'|trans }}\n      </a>\n    </li>\n\n    {% if entry.domain %}\n    <li>\n      <a href=\"{{ path('domain_entries', {name: entry.domain.name}) }}\">{{ 'more_from_domain'|trans }}</a>\n    </li>\n    {% endif %}\n\n    {% if app.user is defined and app.user is not same as null %}\n        {% set bookmarkLists = get_bookmark_lists(app.user) %}\n        {% if bookmarkLists|length %}\n            <li class=\"dropdown__separator\"></li>\n            {{ component('bookmark_menu_list', { bookmarkLists: bookmarkLists, subject: entry }) }}\n        {% endif %}\n    {% endif %}\n\n    <li class=\"dropdown__separator\"></li>\n    <li>\n      <a target=\"_blank\"\n        rel=\"{{ get_rel(entry.apId ?? path('ap_entry', {magazine_name: entry.magazine.name, entry_id: entry.id})) }}\"\n        href=\"{{ entry.apId ?? path('ap_entry', {magazine_name: entry.magazine.name, entry_id: entry.id}) }}\">\n        {{ 'open_url_to_fediverse'|trans }}\n      </a>\n    </li>\n    <li>\n      <a data-action=\"clipboard#copy\"\n        rel=\"{{ get_rel(entry.apId ?? path('ap_entry', {magazine_name: entry.magazine.name, entry_id: entry.id})) }}\"\n        href=\"{{ entry.apId ?? path('ap_entry', {magazine_name: entry.magazine.name, entry_id: entry.id}) }}\">\n        {{ 'copy_url_to_fediverse'|trans }}\n      </a>\n    </li>\n    <li>\n      <a data-action=\"clipboard#copy\" href=\"{{ entry_url(entry) }}\">{{ 'copy_url'|trans }}</a>\n    </li>\n\n    {% if is_granted('edit', entry) or (app.user and entry.isAuthor(app.user)) or is_granted('moderate', entry) %}\n    <li class=\"dropdown__separator\"></li>\n    {% endif %}\n    {% if is_granted('edit', entry) %}\n    <li>\n      <a href=\"{{ entry_edit_url(entry) }}\" class=\"{{ html_classes({'active': is_route_name('entry_edit')}) }}\">\n        {{ 'edit'|trans }}\n      </a>\n    </li>\n    {% endif %}\n    {% if app.user and entry.isAuthor(app.user) %}\n    <li>\n      <form method=\"post\" action=\"{{ entry_delete_url(entry) }}\" data-action=\"confirmation#ask\"\n        data-confirmation-message-param=\"{{ 'are_you_sure'|trans }}\">\n        <input type=\"hidden\" name=\"token\" value=\"{{ csrf_token('entry_delete') }}\">\n        <button type=\"submit\">{{ 'delete'|trans }}</button>\n      </form>\n    </li>\n    {% endif %}\n    {% if is_granted('lock', entry) %}\n      <li>\n        <form method=\"post\" action=\"{{ path('entry_lock', {'magazine_name': entry.magazine.name, 'entry_id': entry.id, 'slug': entry.slug}) }}\" data-action=\"confirmation#ask\"\n          data-confirmation-message-param=\"{{ 'are_you_sure'|trans }}\">\n          <input type=\"hidden\" name=\"token\" value=\"{{ csrf_token('entry_lock') }}\">\n          {% if entry.isLocked %}\n            <button type=\"submit\"><i class=\"fa fa-lock-open\" aria-hidden=\"true\" style=\"margin-right: .5em;\"></i>{{ 'unlock'|trans }}</button>\n          {% else %}\n            <button type=\"submit\"><i class=\"fa fa-lock\" aria-hidden=\"true\" style=\"margin-right: .5em;\"></i>{{ 'lock'|trans }}</button>\n          {% endif %}\n        </form>\n      </li>\n    {% endif %}\n    {% if is_granted('moderate', entry) %}\n    <li>\n      <a href=\"{{ entry_moderate_url(entry) }}\" class=\"{{ html_classes({'active': is_route_name('entry_moderate')}) }}\"\n        data-action=\"subject#showModPanel\">\n        {{ 'moderate'|trans }}\n      </a>\n    </li>\n    {% endif %}\n  </ul>\n</li>\n"
  },
  {
    "path": "templates/entry/_moderate_panel.html.twig",
    "content": "<div class=\"moderate-panel\">\n    <menu>\n        <li>\n            <form action=\"{{ path('entry_pin', {'magazine_name': entry.magazine.name, 'entry_id': entry.id}) }}\"\n                  method=\"post\">\n                <input type=\"hidden\" name=\"token\" value=\"{{ csrf_token('entry_pin') }}\">\n                <button type=\"submit\" class=\"btn btn__secondary\">\n                    <i class=\"fa fa-thumbtack\" aria-hidden=\"true\"></i> <span>{{ entry.sticky ? 'unpin'|trans : 'pin'|trans }}</span>\n                </button>\n            </form>\n        </li>\n        <li>\n            <form action=\"{{ path('entry_lock', {'magazine_name': entry.magazine.name, 'entry_id': entry.id}) }}\"\n                  method=\"post\">\n                <input type=\"hidden\" name=\"token\" value=\"{{ csrf_token('entry_lock') }}\">\n                <button type=\"submit\" class=\"btn btn__secondary\">\n                    {% if entry.isLocked %}\n                        <i class=\"fa fa-lock-open\" aria-hidden=\"true\"></i>\n                    {% else %}\n                        <i class=\"fa fa-lock\" aria-hidden=\"true\"></i>\n                    {% endif %}\n                    <span>{{ entry.isLocked ? 'unlock'|trans : 'lock'|trans }}</span>\n                </button>\n            </form>\n        </li>\n        <li>\n            <form action=\"{{ path('entry_change_adult', {magazine_name: magazine.name, entry_id: entry.id}) }}\"\n                  method=\"post\">\n                <input type=\"hidden\" name=\"token\" value=\"{{ csrf_token('change_adult') }}\">\n                <input name=\"adult\"\n                       type=\"hidden\" value=\"{{ entry.isAdult ? 'off' : 'on' }}\">\n                <button type=\"submit\" class=\"btn btn__secondary\">\n                    <i class=\"fa fa-{{ entry.isAdult ? 'eye' : 'eye-slash' }}\" aria-hidden=\"true\"></i> <span>{{ entry.isAdult ? 'unmark_as_adult'|trans : 'mark_as_adult'|trans }}</span>\n                </button>\n            </form>\n        </li>\n        <li>\n            <form action=\"{{ path('magazine_panel_ban', {'name': entry.magazine.name, 'username': entry.user.username}) }}\"\n                  method=\"get\">\n                <button type=\"submit\" class=\"btn btn__secondary\">\n                    <i class=\"fa fa-ban\" aria-hidden=\"true\"></i> <span>{{ 'ban'|trans }}</span>\n                </button>\n            </form>\n        </li>\n        <li>\n            <form action=\"{{ entry_delete_url(entry) }}\"\n                  method=\"post\"\n                  data-action=\"confirmation#ask\" data-confirmation-message-param=\"{{ 'are_you_sure'|trans }}\">\n                <input type=\"hidden\" name=\"token\" value=\"{{ csrf_token('entry_delete') }}\">\n                <button type=\"submit\" class=\"btn btn__secondary\">\n                    <i class=\"fa fa-dumpster\" aria-hidden=\"true\"></i> <span>{{ 'delete'|trans }}</span>\n                </button>\n            </form>\n        </li>\n        {% if is_granted('purge', entry) %}\n            <li>\n                <form action=\"{{ path('entry_purge', {magazine_name: entry.magazine.name,entry_id: entry.id,}) }}\"\n                      method=\"post\"\n                      data-action=\"confirmation#ask\" data-confirmation-message-param=\"{{ 'are_you_sure'|trans }}\">\n                    <input type=\"hidden\" name=\"token\" value=\"{{ csrf_token('entry_purge') }}\">\n                    <button type=\"submit\" class=\"btn btn__danger\">\n                        <i class=\"fa fa-dumpster\" aria-hidden=\"true\"></i> <span>{{ 'purge'|trans }}</span>\n                    </button>\n                </form>\n            </li>\n        {% endif %}\n        {% if is_granted('ROLE_ADMIN') or is_granted('ROLE_MODERATOR') %}\n            <li class=\"actions\">\n                <form name=\"change_magazine\"\n                      action=\"{{ path('entry_change_magazine', {magazine_name: entry.magazine.name, entry_id: entry.id}) }}\"\n                      method=\"post\"\n                      data-action=\"confirmation#ask\" data-confirmation-message-param=\"{{ 'are_you_sure'|trans }}\">\n                    <input type=\"hidden\" name=\"token\" value=\"{{ csrf_token('change_magazine') }}\">\n                    <input id=\"change_magazine_new_magazine\" required=\"required\" placeholder=\"{{ entry.magazine.name }}\" name=\"change_magazine[new_magazine]\">\n                    <button type=\"submit\" class=\"btn btn__secondary\">\n                        {{ 'change_magazine'|trans }}\n                    </button>\n                </form>\n            </li>\n        {% endif %}\n        <li class=\"actions\">\n            {{ form_start(form, {action: path('entry_change_lang', {magazine_name: magazine.name, entry_id: entry.id})}) }}\n            {{ form_row(form.lang, {label: false, row_attr: {class: 'checkbox'}}) }}\n            {{ form_row(form.submit, {label: 'change_language'|trans, attr: {class: 'btn btn__secondary'}}) }}\n            {{ form_end(form) }}\n        </li>\n    </menu>\n</div>\n"
  },
  {
    "path": "templates/entry/_options.html.twig",
    "content": "{% set showFilterLabels = app.request.cookies.get('kbin_general_filter_labels')|default('on') %}\n\n<aside class=\"options options--top\" id=\"options\">\n    <div></div>\n    <menu class=\"options__filter\">\n        <li class=\"dropdown\">\n            <button aria-label=\"{{ 'sort_by'|trans }}\"\n                    title=\"{{ 'sort_by'|trans }}\"><i\n                        class=\"fa-solid fa-sort\" aria-hidden=\"true\"></i>\n                <span>{{ criteria.getOption('sort')|trans }}</span>\n            </button>\n            <ul class=\"dropdown__menu\">\n                <li>\n                    <a href=\"{{ front_options_url('sortBy', 'top', null, {'p': null, 'cursor': null, 'cursor2': null}) }}\"\n                    class=\"{{ html_classes({'active': criteria.getOption('sort') == 'top'}) }}\">\n                        {{ 'top'|trans }}\n                    </a>\n                </li>\n                <li>\n                    <a href=\"{{ front_options_url('sortBy', 'hot', null, {'p': null, 'cursor': null, 'cursor2': null}) }}\"\n                    class=\"{{ html_classes({'active': criteria.getOption('sort') == 'hot'}) }}\">\n                        {{ 'hot'|trans }}\n                    </a>\n                </li>\n                <li>\n                    <a href=\"{{ front_options_url('sortBy', 'newest', null, {'p': null, 'cursor': null, 'cursor2': null}) }}\"\n                    class=\"{{ html_classes({'active': criteria.getOption('sort') == 'newest'}) }}\">\n                        {{ 'newest'|trans }}\n                    </a>\n                </li>\n                <li>\n                    <a href=\"{{ front_options_url('sortBy', 'active', null, {'p': null, 'cursor': null, 'cursor2': null}) }}\"\n                    class=\"{{ html_classes({'active': criteria.getOption('sort') == 'active'}) }}\">\n                        {{ 'active'|trans }}\n                    </a>\n                </li>\n                <li>\n                    <a href=\"{{ front_options_url('sortBy', 'commented', null, {'p': null, 'cursor': null, 'cursor2': null}) }}\"\n                    class=\"{{ html_classes({'active': criteria.getOption('sort') == 'commented'}) }}\">\n                        {{ 'commented'|trans }}\n                    </a>\n                </li>\n            </ul>\n        </li>\n        <li class=\"dropdown\">\n            <button aria-label=\"{{ 'filter_by_time'|trans }}\"\n                    title=\"{{ 'filter_by_time'|trans }}\">\n                <i aria-hidden=\"true\" class=\"fa-solid fa-clock\"></i>\n                {% if showFilterLabels == 'on' or (showFilterLabels == 'auto' and criteria.getOption('time') != 'all') %}\n                    <span>{{ criteria.getOption('time')|trans }}</span>\n                {% endif %}\n            </button>\n            <ul class=\"dropdown__menu\">\n                <li>\n                    <a href=\"{{ front_options_url('time', '∞', null, {'p': null, 'cursor': null, 'cursor2': null}) }}\"\n                       class=\"{{ html_classes({'active': criteria.getOption('time') == 'all'}) }}\">\n                        {{ 'all_time'|trans }}\n                    </a>\n                </li>\n                <li>\n                    <a href=\"{{ front_options_url('time', '3h', null, {'p': null, 'cursor': null, 'cursor2': null}) }}\"\n                       class=\"{{ html_classes({'active': criteria.getOption('time') == '3h' }) }}\">\n                        {{ '3h'|trans }}\n                    </a></li>\n                <li>\n                    <a href=\"{{ front_options_url('time', '6h', null, {'p': null, 'cursor': null, 'cursor2': null}) }}\"\n                       class=\"{{ html_classes({'active': criteria.getOption('time') == '6h' }) }}\">\n                        {{ '6h'|trans }}\n                    </a>\n                </li>\n                <li>\n                    <a href=\"{{ front_options_url('time', '12h', null, {'p': null, 'cursor': null, 'cursor2': null}) }}\"\n                       class=\"{{ html_classes({'active': criteria.getOption('time') == '12h' }) }}\">\n                        {{ '12h'|trans }}\n                    </a>\n                </li>\n                <li>\n                    <a href=\"{{ front_options_url('time', '1d', null, {'p': null, 'cursor': null, 'cursor2': null}) }}\"\n                       class=\"{{ html_classes({'active': criteria.getOption('time') == '1d' }) }}\">\n                        {{ '1d'|trans }}\n                    </a>\n                </li>\n                <li>\n                    <a href=\"{{ front_options_url('time', '1w', null, {'p': null, 'cursor': null, 'cursor2': null}) }}\"\n                       class=\"{{ html_classes({'active': criteria.getOption('time') == '1w' }) }}\">\n                        {{ '1w'|trans }}\n                    </a>\n                </li>\n                <li>\n                    <a href=\"{{ front_options_url('time', '1m', null, {'p': null, 'cursor': null, 'cursor2': null}) }}\"\n                       class=\"{{ html_classes({'active': criteria.getOption('time') == '1m' }) }}\">\n                        {{ '1m'|trans }}\n                    </a>\n                </li>\n                <li>\n                    <a href=\"{{ front_options_url('time', '1y', null, {'p': null, 'cursor': null, 'cursor2': null}) }}\"\n                       class=\"{{ html_classes({'active': criteria.getOption('time') == '1y' }) }}\">\n                        {{ '1y'|trans }}\n                    </a>\n                </li>\n            </ul>\n        </li>\n        <li class=\"dropdown\">\n            <button aria-label=\"{{ 'filter_by_type'|trans }}\"\n                    title=\"{{ 'filter_by_type'|trans }}\">\n                <i aria-hidden=\"true\" class=\"\n                    {% if criteria.getOption('type') == 'all' %}\n                        fa-solid fa-file\n                    {% elseif criteria.getOption('type') == 'links' %}\n                        fa-regular fa-file-code\n                    {% elseif criteria.getOption('type') == 'threads' %}\n                        fa-regular fa-file-lines\n                    {% elseif criteria.getOption('type') == 'photos' %}\n                        fa-regular fa-file-image\n                    {% elseif criteria.getOption('type') == 'videos' %}\n                        fa-regular fa-file-video\n                    {% else %}\n                        fa-solid fa-question\n                    {% endif %}\">\n                </i>\n                {% if showFilterLabels == 'on' or (showFilterLabels == 'auto' and criteria.getOption('type') != 'all') %}\n                    <span class=\"hide-on-mobile\">{{ criteria.getOption('type')|trans }}</span>\n                {% endif %}\n            </button>\n            <ul class=\"dropdown__menu\">\n                <li>\n                    <a href=\"{{ front_options_url('type', null, null, {'p': null, 'cursor': null, 'cursor2': null}) }}\" class=\"{{ html_classes({'active': criteria.getOption('type') == 'all' }) }}\">\n                        <i class=\"fa-solid fa-file\" aria-hidden=\"true\"></i> &nbsp; {{ 'all'|trans }}\n                    </a>\n                </li>\n                <li>\n                    <a href=\"{{ front_options_url('type', 'links', null, {'p': null, 'cursor': null, 'cursor2': null}) }}\" class=\"{{ html_classes({'active': criteria.getOption('type') == 'links' }) }}\">\n                        <i class=\"fa-regular fa-file-code\" aria-hidden=\"true\"></i> &nbsp; {{ 'links'|trans }}\n                    </a>\n                </li>\n                <li>\n                    <a href=\"{{ front_options_url('type', 'articles', null, {'p': null, 'cursor': null, 'cursor2': null}) }}\" class=\"{{ html_classes({'active': criteria.getOption('type') == 'threads' }) }}\">\n                        <i class=\"fa-regular fa-file-lines\" aria-hidden=\"true\"></i> &nbsp; {{ 'threads'|trans }}\n                    </a>\n                </li>\n                <li>\n                    <a href=\"{{ front_options_url('type', 'photos', null, {'p': null, 'cursor': null, 'cursor2': null}) }}\" class=\"{{ html_classes({'active': criteria.getOption('type') == 'photos' }) }}\">\n                        <i class=\"fa-regular fa-file-image\" aria-hidden=\"true\"></i> &nbsp; {{ 'photos'|trans }}\n                    </a>\n                </li>\n                <li>\n                    <a href=\"{{ front_options_url('type', 'videos', null, {'p': null, 'cursor': null, 'cursor2': null}) }}\" class=\"{{ html_classes({'active': criteria.getOption('type') == 'videos'}) }}\">\n                        <i class=\"fa-regular fa-file-video\" aria-hidden=\"true\"></i> &nbsp; {{ 'videos'|trans }}\n                    </a>\n                </li>\n            </ul>\n        </li>\n        {% if app.user %}\n            <li class=\"dropdown\">\n                <button\n                        aria-label=\"{{ 'filter_by_subscription'|trans }}\"\n                        title=\"{{ 'filter_by_subscription'|trans }}\">\n                    <i aria-hidden=\"true\" class=\"fa-solid\n                        {% if criteria.favourite %}\n                            fa-heart\n                        {% elseif criteria.subscribed %}\n                            fa-folder-plus\n                        {% elseif criteria.moderated %}\n                            fa-shield-halved\n                        {% else %}\n                            fa-earth-americas\n                        {% endif %}\">\n                    </i>\n                    {% if showFilterLabels == 'on' or (showFilterLabels == 'auto' and (criteria.favourite or criteria.moderated or criteria.subscribed)) %}\n                        <span class=\"hide-on-mobile\">{{ criteria.resolveSubscriptionFilter()|trans }}</span>\n                    {% endif %}\n                </button>\n                <ul class=\"dropdown__menu\">\n                    <li>\n                        <a href=\"{{ front_options_url('subscription', 'all', null, {'p': null, 'cursor': null, 'cursor2': null}) }}\" class=\"{{ html_classes({'active': not criteria.favourite and not criteria.moderated and not criteria.subscribed}) }}\">\n                            <i class=\"fa-solid fa-earth-americas\" aria-hidden=\"true\"></i> &nbsp; {{ 'all'|trans }}\n                        </a>\n                    </li>\n                    {% if not is_route_name_contains('_magazine') %}\n                        <li>\n                            <a href=\"{{ front_options_url('subscription', 'sub', null, {'p': null, 'cursor': null, 'cursor2': null}) }}\" class=\"{{ html_classes({'active': criteria.subscribed }) }}\">\n                                <i class=\"fa-solid fa-folder-plus\" aria-hidden=\"true\"></i> &nbsp; {{ 'subscribed'|trans }}\n                            </a>\n                        </li>\n                        <li>\n                            <a href=\"{{ front_options_url('subscription', 'mod', null, {'p': null, 'cursor': null, 'cursor2': null}) }}\" class=\"{{ html_classes({'active': criteria.moderated }) }}\">\n                                <i class=\"fa-solid fa-shield-halved\" aria-hidden=\"true\"></i> &nbsp;{{ 'moderated'|trans }}\n                            </a>\n                        </li>\n                    {% endif %}\n                    <li>\n                        <a href=\"{{ front_options_url('subscription', 'fav', null, {'p': null, 'cursor': null, 'cursor2': null}) }}\" class=\"{{ html_classes({'active': criteria.favourite }) }}\">\n                            <i class=\"fa-solid fa-heart\" aria-hidden=\"true\"></i> &nbsp; {{ 'favourites'|trans }}\n                        </a>\n                    </li>\n                </ul>\n            </li>\n        {% endif %}\n        <li class=\"dropdown\">\n            <button\n                    aria-label=\"{{ 'filter_by_federation'|trans }}\"\n                    title=\"{{ 'filter_by_federation'|trans }}\">\n                <i aria-hidden=\"true\" class=\"fa-solid\n                    {% if criteria.getOption('federation') == 'all' %}\n                        fa-circle-nodes\n                    {% elseif criteria.getOption('federation') == 'local' %}\n                        fa-house-chimney\n                    {% elseif criteria.getOption('federation') == 'federated' %}\n                        fa-network-wired\n                    {% else %}\n                        fa-question\n                    {% endif %}\">\n                </i>\n                {% if showFilterLabels == 'on' or (showFilterLabels == 'auto' and criteria.getOption('federation') != 'all') %}\n                    <span class=\"hide-on-mobile\">{{ criteria.federation|trans }}</span>\n                {% endif %}\n            </button>\n            <ul class=\"dropdown__menu\">\n                <li>\n                    <a href=\"{{ front_options_url('federation', 'all', null, {'p': null, 'cursor': null, 'cursor2': null}) }}\" class=\"{{ html_classes({'active': criteria.getOption('federation') == 'all' }) }}\">\n                        <i class=\"fa-solid fa-circle-nodes\" aria-hidden=\"true\"></i> &nbsp; {{ 'all'|trans }}\n                    </a>\n                </li>\n                <li>\n                    <a href=\"{{ front_options_url('federation', 'local', null, {'p': null, 'cursor': null, 'cursor2': null}) }}\" class=\"{{ html_classes({'active': criteria.getOption('federation') == 'local' }) }}\">\n                        <i class=\"fa-solid fa-house-chimney\" aria-hidden=\"true\"></i> &nbsp; {{ 'local'|trans }}\n                    </a>\n                </li>\n                {#\n                <li>\n                    <a href=\"{{ front_options_url('federation', 'federated', null, {'p': null, 'cursor': null}) }}\" class=\"{{ html_classes({'active': criteria.getOption('federation') == 'federated' }) }}\">\n                        <i class=\"fa-solid fa-network-wired\" aria-hidden=\"true\"></i> &nbsp; {{ 'federated'|trans }}\n                    </a>\n                </li>\n                #}\n            </ul>\n        </li>\n    </menu>\n    <menu class=\"options__view\">\n        <li class=\"dropdown\">\n            <button aria-label=\"{{ 'change_view'|trans }}\"\n                    title=\"{{ 'change_view'|trans }}\"><i\n                        class=\"fa-solid fa-layer-group\" aria-hidden=\"true\"></i>\n            </button>\n            <ul class=\"dropdown__menu\">\n                <li>\n                    <a class=\"{{ html_classes({'active': not app.request.cookies.has(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_ENTRIES_COMPACT')) or app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_ENTRIES_COMPACT')) is same as 'false'}) }}\"\n                       href=\"{{ path('theme_settings', {key: constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_ENTRIES_COMPACT'), value: 'false'}) }}\">\n                        {{ 'classic_view'|trans }}\n                    </a>\n                </li>\n                <li>\n                    <a class=\"{{ html_classes({'active': app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_ENTRIES_COMPACT')) is same as 'true'}) }}\"\n                       href=\"{{ path('theme_settings', {key: constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_ENTRIES_COMPACT'), value: 'true'}) }}\">\n                        {{ 'compact_view'|trans }}\n                    </a>\n                </li>\n            </ul>\n        </li>\n    </menu>\n</aside>\n"
  },
  {
    "path": "templates/entry/_options_activity.html.twig",
    "content": "{%- set downvoteMode = mbin_downvotes_mode() %}\n{%- set DOWNVOTES_ENABLED = constant('App\\\\Utils\\\\DownvotesMode::Enabled') %}\n<aside id=\"activity\" class=\"options options-activity\">\n    <div class=\"options__title\">\n        <h2>{{ 'activity'|trans }} ({{ (entry.countVotes + entry.favouriteCount)|abbreviateNumber }})</h2>\n    </div>\n    <menu class=\"options__main\">\n        <li>\n            <a href=\"{{ entry_voters_url(entry, 'up') }}\"\n               class=\"{{ html_classes({'active': is_route_name('entry_voters') and route_has_param('type', 'up')}) }}\">\n                {{ 'up_votes'|trans }} ({{ entry.countUpVotes|abbreviateNumber }})\n            </a>\n        </li>\n        <li>\n            <a href=\"{{ entry_favourites_url(entry) }}\"\n               class=\"{{ html_classes({'active': is_route_name('entry_fav')}) }}\">\n                {{ 'favourites'|trans }} ({{ (entry.apLikeCount ?? entry.favouriteCount)|abbreviateNumber }})\n            </a>\n        </li>\n        {% if downvoteMode is same as DOWNVOTES_ENABLED %}\n            <li>\n                <span class=\"options__nolink\">{{ 'down_votes'|trans }} ({{ entry.countDownVotes|abbreviateNumber }})</span>\n            </li>\n        {% endif %}\n    </menu>\n</aside>\n"
  },
  {
    "path": "templates/entry/comment/_form_comment.html.twig",
    "content": "{% form_theme form.lang 'form/lang_select.html.twig' %}\n\n{% set hasImage = false %}\n{% if comment is defined and comment is not null and comment.image %}\n    {% set hasImage = true %}\n{% endif %}\n{% if edit is not defined %}\n    {% set edit = false %}\n{% endif %}\n{% if edit %}\n    {% set title = 'edit_comment'|trans %}\n    {% set action = path('entry_comment_edit', {magazine_name: entry.magazine.name, entry_id: entry.id, comment_id: comment.id}) %}\n{% else %}\n    {% set title = 'add_comment'|trans %}\n    {% set action = path('entry_comment_create', {magazine_name: entry.magazine.name, entry_id: entry.id, parent_comment_id: parent is defined and parent ? parent.id : null}) %}\n{% endif %}\n\n<h3 hidden>{{ title }}</h3>\n{{ form_start(form, {action: action, attr: {class: edit ? 'comment-edit replace' : 'comment-add'}}) }}\n{{ component('editor_toolbar', {id: form.body.vars.id}) }}\n{{ form_row(form.body, {label: false, attr: {\n    'data-controller': 'input-length rich-textarea autogrow',\n    'data-action' : 'input-length#updateDisplay',\n    'data-input-length-max-value': constant('App\\\\DTO\\\\EntryCommentDto::MAX_BODY_LENGTH')\n}}) }}<div class=\"row actions\">\n    <ul>\n        {% if hasImage %}\n            <img width=\"40\"\n                 height=\"40\"\n                 src=\"{{ comment.image.filePath ? (asset(comment.image.filePath)|imagine_filter('post_thumb')) : comment.image.sourceUrl }}\"\n                 alt=\"{{ comment.image.altText }}\">\n            <button formaction=\"{{ path('entry_comment_image_delete', {magazine_name: comment.magazine.name, entry_id: comment.entry.id, comment_id: comment.id}) }}\"\n                    class=\"btn-link\"\n                    aria-label=\"{{ 'remove_media'|trans }}\"\n                    title=\"{{ 'remove_media'|trans }}\"\n                    data-action=\"confirmation#ask subject#removeImage\"\n                    data-confirmation-message-param=\"{{ 'are_you_sure'|trans }}\">\n                <i class=\"fa-solid fa-xmark\" aria-hidden=\"true\"></i>\n            </button>\n        {% endif %}\n        <li class=\"{{ html_classes('dropdown', {'hidden': hasImage}) }}\">\n            <button type=\"button\"\n                    class=\"btn btn__secondary\"\n                    aria-label=\"{{ 'add_media'|trans }}\"\n                    title=\"{{ 'add_media'|trans }}\">\n                <i class=\"fa-solid fa-photo-film\" aria-hidden=\"true\"></i>\n            </button>\n            <div class=\"dropdown__menu\">\n                {% include 'layout/_form_media.html.twig' %}\n            </div>\n        </li>\n        <li class=\"select\">\n            {{ form_row(form.lang, {label: false}) }}\n        </li>\n        <li>\n            {{ form_row(form.submit, {label: edit ? 'update_comment' : 'add_comment' , attr: {class: 'btn btn__primary', 'data-action': 'subject#sendForm'}}) }}\n        </li>\n    </ul>\n</div>\n{{ form_end(form) }}\n"
  },
  {
    "path": "templates/entry/comment/_list.html.twig",
    "content": "{%- set V_TRUE = constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::TRUE') -%}\n{%- set V_FALSE = constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::FALSE') -%}\n{%- set V_CHAT = constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::CHAT') -%}\n{%- set V_TREE = constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::TREE') -%}\n\n{%- set DYNAMIC_LISTS = app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_GENERAL_DYNAMIC_LISTS'), V_FALSE) -%}\n{%- set SHOW_COMMENT_USER_AVATARS = app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_COMMENTS_SHOW_USER_AVATAR'), V_TRUE) -%}\n{%- set VIEW_STYLE = app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::ENTRY_COMMENTS_VIEW'), V_TREE) -%}\n\n{% if showNested is not defined %}\n    {% if VIEW_STYLE is same as V_CHAT %}\n        {% set showNested = false %}\n    {% else %}\n        {% set showNested = true %}\n    {% endif %}\n{% endif %}\n{% set autoAction = 'notifications:EntryCommentCreatedNotification@window->subject-list#addComment' %}\n{% set manualAction = 'notifications:EntryCommentCreatedNotification@window->subject-list#increaseCounter' %}\n<div class=\"{{ html_classes('comments entry-comments comments-tree', {\n        'show-comment-avatar' : SHOW_COMMENT_USER_AVATARS is same as V_TRUE },\n        'comments-view-style--'~VIEW_STYLE\n    ) }}\"\n    data-controller=\"subject-list\"\n    data-action=\"{{- DYNAMIC_LISTS is same as V_TRUE ? autoAction : manualAction -}}\">\n    {% for comment in comments %}\n    {{ component('entry_comment', {\n            comment: comment,\n            showNested: showNested,\n            dateAsUrl: dateAsUrl is defined ? dateAsUrl : true,\n            showMagazineName: magazine is not defined or not magazine,\n            showEntryTitle: entry is not defined or not entry,\n            criteria: criteria,\n        }) }}\n    {% endfor %}\n\n    {% if(comments.haveToPaginate is defined and comments.haveToPaginate) %}\n        {% if app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_GENERAL_INFINITE_SCROLL')) is same as 'true' %}\n            <div data-controller=\"infinite-scroll\" class=\"infinite-scroll\">\n                {{ component('loader', {'data-infinite-scroll-target': 'loader'}) }}\n                <div data-infinite-scroll-target=\"pagination\" class=\"visually-hidden\">\n                    {{ pagerfanta(comments, null, {'pageParameter':'[p]'}) }}\n                </div>\n            </div>\n        {% else %}\n            {{ pagerfanta(comments, null, {'pageParameter':'[p]'}) }}\n        {% endif %}\n    {% endif %}\n    {% if not comments|length %}\n        <aside class=\"section section--muted\">\n            <p>{{ 'no_comments'|trans }}</p>\n        </aside>\n    {% elseif VIEW_STYLE is same as V_TREE %}\n        <div class=\"comment-line--2\"></div>\n        <div class=\"comment-line--3\"></div>\n        <div class=\"comment-line--4\"></div>\n        <div class=\"comment-line--5\"></div>\n        <div class=\"comment-line--6\"></div>\n        <div class=\"comment-line--7\"></div>\n        <div class=\"comment-line--8\"></div>\n        <div class=\"comment-line--9\"></div>\n    {% endif %}\n</div>\n"
  },
  {
    "path": "templates/entry/comment/_menu.html.twig",
    "content": "<li class=\"dropdown\">\n    <button class=\"stretched-link\" data-subject-target=\"more\">{{ 'more'|trans }}</button>\n    <ul class=\"dropdown__menu\" data-controller=\"clipboard\">\n        <li>\n            <a href=\"{{ path('entry_comment_report', {id: comment.id}) }}\"\n                class=\"{{ html_classes({'active': is_route_name('entry_comment_report')}) }}\"\n                data-action=\"subject#getForm\">\n                {{ 'report'|trans }}\n            </a>\n        </li>\n        <li>\n            <a href=\"{{ entry_comment_voters_url(comment, 'up') }}\"\n                class=\"{{ html_classes({'active': is_route_name('entry_comment_favourites') or is_route_name('entry_comment_voters')}) }}\">\n                {{ 'activity'|trans }}\n            </a>\n        </li>\n\n        {% if app.user is defined and app.user is not same as null %}\n            {% set bookmarkLists = get_bookmark_lists(app.user) %}\n            {% if bookmarkLists|length %}\n                <li class=\"dropdown__separator\"></li>\n                {% for list in bookmarkLists %}\n                    {{ component('bookmark_list', { subject: comment, subjectType: 'entry_comment', list: list }) }}\n                {% endfor %}\n            {% endif %}\n        {% endif %}\n\n        <li class=\"dropdown__separator\"></li>\n        <li>\n            <a target=\"_blank\"\n                rel=\"{{ get_rel(comment.apId ?? path('ap_entry_comment', {magazine_name: comment.magazine.name, entry_id: comment.entry.id, comment_id: comment.id})) }}\"\n                href=\"{{ comment.apId ?? path('ap_entry_comment', {magazine_name: comment.magazine.name, entry_id: comment.entry.id, comment_id: comment.id}) }}\">\n                {{ 'open_url_to_fediverse'|trans }}\n            </a>\n        </li>\n        <li>\n            <a data-action=\"clipboard#copy\"\n                rel=\"{{ get_rel(comment.apId ?? path('ap_entry_comment', {magazine_name: comment.magazine.name, entry_id: comment.entry.id, comment_id: comment.id})) }}\"\n                href=\"{{ comment.apId ?? path('ap_entry_comment', {magazine_name: comment.magazine.name, entry_id: comment.entry.id, comment_id: comment.id}) }}\">\n                {{ 'copy_url_to_fediverse'|trans }}\n            </a>\n        </li>\n        <li>\n            <a data-action=\"clipboard#copy\"\n                href=\"{{ entry_comment_view_url(comment) }}#{{ get_url_fragment(comment) }}\">\n                {{ 'copy_url'|trans }}\n            </a>\n        </li>\n        {% if is_granted('edit', comment) or (app.user and comment.isAuthor(app.user)) or is_granted('moderate', comment) %}\n            <li class=\"dropdown__separator\"></li>\n        {% endif %}\n        {% if is_granted('edit', comment) %}\n            <li>\n                <a href=\"{{ entry_comment_edit_url(comment) }}\"\n                    class=\"{{ html_classes({'active': is_route_name('entry_comment_edit')}) }}\"\n                    data-action=\"subject#getForm\">\n                    {{ 'edit'|trans }}\n                </a>\n            </li>\n        {% endif %}\n        {% if app.user and comment.isAuthor(app.user) %}\n            <li>\n                <form method=\"post\"\n                        action=\"{{ entry_comment_delete_url(comment) }}\"\n                        data-action=\"confirmation#ask\" data-confirmation-message-param=\"{{ 'are_you_sure'|trans }}\">\n                    <input type=\"hidden\" name=\"token\"\n                            value=\"{{ csrf_token('entry_comment_delete') }}\">\n                    <button type=\"submit\">{{ 'delete'|trans }}</button>\n                </form>\n            </li>\n        {% endif %}\n        {% if is_granted('moderate', comment) %}\n            <li>\n                <a href=\"{{ entry_comment_moderate_url(comment) }}\"\n                    class=\"{{ html_classes({'active': is_route_name('entry_comment_moderate')}) }}\"\n                    data-action=\"subject#showModPanel\">\n                    {{ 'moderate'|trans }}\n                </a>\n            </li>\n        {% endif %}\n    </ul>\n</li>\n"
  },
  {
    "path": "templates/entry/comment/_moderate_panel.html.twig",
    "content": "<div class=\"moderate-panel\">\n    <menu>\n        <li>\n            <form action=\"{{ path('entry_comment_change_adult', {magazine_name: magazine.name, entry_id: entry.id, comment_id: comment.id}) }}\"\n                  method=\"post\">\n                <input type=\"hidden\" name=\"token\" value=\"{{ csrf_token('change_adult') }}\">\n                <input name=\"adult\"\n                       type=\"hidden\" value=\"{{ comment.isAdult ? 'off' : 'on' }}\">\n                <button type=\"submit\" class=\"btn btn__secondary\">\n                    <i class=\"fa fa-{{ comment.isAdult ? 'eye' : 'eye-slash' }}\" aria-hidden=\"true\"></i> <span>{{ comment.isAdult ? 'unmark_as_adult'|trans : 'mark_as_adult'|trans }}</span>\n                </button>\n            </form>\n        </li>\n        <li>\n            <form action=\"{{ path('magazine_panel_ban', {'name': comment.magazine.name, 'username': comment.user.username}) }}\"\n                  method=\"get\">\n                <button type=\"submit\" class=\"btn btn__secondary\">\n                    <i class=\"fa fa-ban\" aria-hidden=\"true\"></i> <span>{{ 'ban'|trans }}</span>\n                </button>\n            </form>\n        </li>\n        <li>\n            <form action=\"{{ entry_comment_delete_url(comment) }}\"\n                  method=\"post\"\n                  data-action=\"confirmation#ask\" data-confirmation-message-param=\"{{ 'are_you_sure'|trans }}\">\n                <input type=\"hidden\" name=\"token\" value=\"{{ csrf_token('entry_comment_delete') }}\">\n                <button type=\"submit\" class=\"btn btn__secondary\">\n                    <i class=\"fa fa-dumpster\" aria-hidden=\"true\"></i> <span>{{ 'delete'|trans }}</span>\n                </button>\n            </form>\n        </li>\n        {% if is_granted('purge', comment) %}\n            <li>\n                <form action=\"{{ path('entry_comment_purge', {magazine_name: comment.magazine.name, entry_id: comment.entry.id, comment_id: comment.id}) }}\"\n                      method=\"post\"\n                      data-action=\"confirmation#ask\" data-confirmation-message-param=\"{{ 'are_you_sure'|trans }}\">\n                    <input type=\"hidden\" name=\"token\" value=\"{{ csrf_token('entry_comment_purge') }}\">\n                    <button type=\"submit\" class=\"btn btn__danger\">\n                        <i class=\"fa fa-dumpster\" aria-hidden=\"true\"></i> <span>{{ 'purge'|trans }}</span>\n                    </button>\n                </form>\n            </li>\n        {% endif %}\n        <li class=\"actions\">\n            {{ form_start(form, {action: path('entry_comment_change_lang', {magazine_name: magazine.name, entry_id: entry.id, comment_id: comment.id})}) }}\n            {{ form_row(form.lang, {label: false, row_attr: {class: 'checkbox'}}) }}\n            {{ form_row(form.submit, {label: 'change_language'|trans, attr: {class: 'btn btn__secondary'}}) }}\n            {{ form_end(form) }}\n        </li>\n    </menu>\n</div>\n"
  },
  {
    "path": "templates/entry/comment/_no_comments.html.twig",
    "content": "<aside class=\"section section--muted\">\n    <p>{{ 'no_comments'|trans }}</p>\n</aside>\n\n"
  },
  {
    "path": "templates/entry/comment/_options.html.twig",
    "content": "{% set showFilterLabels = app.request.cookies.get('kbin_general_filter_labels')|default('on') %}\n\n<aside class=\"options options--top\" id=\"options\">\n    {% if app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::ENTRY_COMMENTS_VIEW')) is not same as constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::CHAT') %}\n    <div></div>\n    <menu class=\"options__filter\">\n        <li class=\"dropdown\">\n            <button aria-label=\"{{ 'sort_by'|trans }}\"\n                    title=\"{{ 'sort_by'|trans }}\"><i\n                    class=\"fa-solid fa-sort\" aria-hidden=\"true\"></i>\n                <span>{{ criteria.getOption('sort')|trans }}</span>\n            </button>\n            <ul class=\"dropdown__menu\">\n                <li>\n                    <a href=\"{{ options_url('sortBy', 'top') }}\"\n                       class=\"{{ html_classes({'active': criteria.getOption('sort') == 'top'}) }}\">\n                        {{ 'top'|trans }}\n                    </a>\n                </li>\n                <li>\n                    <a href=\"{{ options_url('sortBy', 'hot') }}\"\n                       class=\"{{ html_classes({'active': criteria.getOption('sort') == 'hot'}) }}\">\n                        {{ 'hot'|trans }}\n                    </a>\n                </li>\n                <li>\n                    <a href=\"{{ options_url('sortBy', 'newest') }}\"\n                       class=\"{{ html_classes({'active': criteria.getOption('sort') == 'newest'}) }}\">\n                        {{ 'newest'|trans }}\n                    </a>\n                </li>\n                <li>\n                    <a href=\"{{ options_url('sortBy', 'active') }}\"\n                       class=\"{{ html_classes({'active': criteria.getOption('sort') == 'active'}) }}\">\n                        {{ 'active'|trans }}\n                    </a>\n                </li>\n                <li>\n                    <a href=\"{{ options_url('sortBy', 'oldest') }}\"\n                       class=\"{{ html_classes({'active': criteria.getOption('sort') == 'oldest'}) }}\">\n                        {{ 'oldest'|trans }}\n                    </a>\n                </li>\n            </ul>\n        </li>\n        <li class=\"dropdown\">\n            <button aria-label=\"{{ 'filter_by_time'|trans }}\"\n                    title=\"{{ 'filter_by_time'|trans }}\">\n                <i aria-hidden=\"true\" class=\"fa-solid fa-clock\"></i>\n                {% if showFilterLabels == 'on' or (showFilterLabels == 'auto' and criteria.getOption('time') != 'all') %}\n                    <span>{{ criteria.getOption('time')|trans }}</span>\n                {% endif %}\n            </button>\n            <ul class=\"dropdown__menu\">\n                <li>\n                    <a href=\"{{ options_url('time', '∞') }}\"\n                       class=\"{{ html_classes({'active': criteria.getOption('time') == 'all'}) }}\">\n                        {{ 'all_time'|trans }}\n                    </a>\n                </li>\n                <li>\n                    <a href=\"{{ options_url('time', '3h') }}\"\n                       class=\"{{ html_classes({'active': criteria.getOption('time') == '3h' }) }}\">\n                        {{ '3h'|trans }}\n                    </a></li>\n                <li>\n                    <a href=\"{{ options_url('time', '6h') }}\"\n                       class=\"{{ html_classes({'active': criteria.getOption('time') == '6h' }) }}\">\n                        {{ '6h'|trans }}\n                    </a>\n                </li>\n                <li>\n                    <a href=\"{{ options_url('time', '12h') }}\"\n                       class=\"{{ html_classes({'active': criteria.getOption('time') == '12h' }) }}\">\n                        {{ '12h'|trans }}\n                    </a>\n                </li>\n                <li>\n                    <a href=\"{{ options_url('time', '1d') }}\"\n                       class=\"{{ html_classes({'active': criteria.getOption('time') == '1d' }) }}\">\n                        {{ '1d'|trans }}\n                    </a>\n                </li>\n                <li>\n                    <a href=\"{{ options_url('time', '1w') }}\"\n                       class=\"{{ html_classes({'active': criteria.getOption('time') == '1w' }) }}\">\n                        {{ '1w'|trans }}\n                    </a>\n                </li>\n                <li>\n                    <a href=\"{{ options_url('time', '1m') }}\"\n                       class=\"{{ html_classes({'active': criteria.getOption('time') == '1m' }) }}\">\n                        {{ '1m'|trans }}\n                    </a>\n                </li>\n                <li>\n                    <a href=\"{{ options_url('time', '1y') }}\"\n                       class=\"{{ html_classes({'active': criteria.getOption('time') == '1y' }) }}\">\n                        {{ '1y'|trans }}\n                    </a>\n                </li>\n            </ul>\n        </li>\n        <li class=\"dropdown\">\n            <button\n                aria-label=\"{{ 'filter_by_federation'|trans }}\"\n                title=\"{{ 'filter_by_federation'|trans }}\">\n                <i aria-hidden=\"true\" class=\"fa-solid\n                    {% if criteria.getOption('federation') == 'all' %}\n                        fa-circle-nodes\n                    {% elseif criteria.getOption('federation') == 'local' %}\n                        fa-house-chimney\n                    {% elseif criteria.getOption('federation') == 'federated' %}\n                        fa-network-wired\n                    {% else %}\n                        fa-question\n                    {% endif %}\">\n                </i>\n                {% if showFilterLabels == 'on' or (showFilterLabels == 'auto' and criteria.getOption('federation') != 'all') %}\n                    <span class=\"hide-on-mobile\">{{ criteria.federation|trans }}</span>\n                {% endif %}\n            </button>\n            <ul class=\"dropdown__menu\">\n                <li>\n                    <a href=\"{{ options_url('federation', 'all') }}\" class=\"{{ html_classes({'active': criteria.getOption('federation') == 'all' }) }}\">\n                        <i class=\"fa-solid fa-circle-nodes\" aria-hidden=\"true\"></i> &nbsp; {{ 'all'|trans }}\n                    </a>\n                </li>\n                <li>\n                    <a href=\"{{ options_url('federation', 'local') }}\" class=\"{{ html_classes({'active': criteria.getOption('federation') == 'local' }) }}\">\n                        <i class=\"fa-solid fa-house-chimney\" aria-hidden=\"true\"></i> &nbsp; {{ 'local'|trans }}\n                    </a>\n                </li>\n            </ul>\n        </li>\n    </menu>\n    {% else %}\n        <div class=\"options__title\"><h2>{{ 'comments'|trans }}</h2></div>\n    {% endif %}\n    <menu class=\"options__view\">\n        <li class=\"dropdown\">\n            <button aria-label=\"{{ 'change_view'|trans }}\"\n                    title=\"{{ 'change_view'|trans }}\"><i\n                    class=\"fa-solid fa-layer-group\" aria-hidden=\"true\"></i>\n            </button>\n            <ul class=\"dropdown__menu\">\n                <li>\n                    <a class=\"{{ html_classes({'active': app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::ENTRY_COMMENTS_VIEW')) is same as constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::CLASSIC')}) }}\"\n                       href=\"{{ path('theme_settings', {key: constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::ENTRY_COMMENTS_VIEW'), value: constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::CLASSIC')}) }}\">{{ 'classic_view'|trans }}</a>\n                </li>\n                <li>\n                    <a class=\"{{ html_classes({'active': app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::ENTRY_COMMENTS_VIEW')) is same as constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::CHAT')}) }}\"\n                       href=\"{{ path('theme_settings', {key: constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::ENTRY_COMMENTS_VIEW'), value: constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::CHAT')}) }}\">{{ 'chat_view'|trans }}</a>\n                </li>\n                <li>\n                    <a class=\"{{ html_classes({'active': app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::ENTRY_COMMENTS_VIEW')) is same as null or app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::ENTRY_COMMENTS_VIEW')) is same as constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::TREE')}) }}\"\n                       href=\"{{ path('theme_settings', {key: constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::ENTRY_COMMENTS_VIEW'), value: constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::TREE')}) }}\">{{ 'tree_view'|trans }}</a>\n                </li>\n            </ul>\n        </li>\n    </menu>\n</aside>\n"
  },
  {
    "path": "templates/entry/comment/_options_activity.html.twig",
    "content": "{% set downvoteMode = mbin_downvotes_mode() %}\n{%- set DOWNVOTES_ENABLED = constant('App\\\\Utils\\\\DownvotesMode::Enabled') %}\n<aside id=\"activity\" class=\"options options-activity\">\n    <div class=\"options__title\">\n        <h2>{{ 'activity'|trans }} ({{ comment.countVotes + comment.favouriteCount }})</h2>\n    </div>\n    <menu class=\"options__main\">\n        <li>\n            <a href=\"{{ entry_comment_voters_url(comment, 'up') }}\"\n               class=\"{{ html_classes({'active': is_route_name('entry_comment_voters') and route_has_param('type', 'up')}) }}\">\n                {{ 'up_votes'|trans }} ({{ comment.countUpVotes }})\n            </a>\n        </li>\n        <li>\n            <a href=\"{{ entry_comment_favourites_url(comment) }}\"\n               class=\"{{ html_classes({'active': is_route_name('entry_comment_favourites')}) }}\">\n                {{ 'favourites'|trans }} ({{ comment.favouriteCount }})\n            </a>\n        </li>\n        {% if downvoteMode is same as DOWNVOTES_ENABLED %}\n            <li>\n                <span class=\"options__nolink\">{{ 'down_votes'|trans }} ({{ comment.countDownVotes }})</span>\n            </li>\n        {% endif %}\n    </menu>\n</aside>\n"
  },
  {
    "path": "templates/entry/comment/create.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    {{- 'add_comment'|trans }} - {{ entry.title }} - {{ parent() -}}\n{%- endblock -%}\n\n{% block mainClass %}page-entry-comment-create{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n{% endblock %}\n\n{% block body %}\n    {{ component('entry', {\n        entry: entry,\n        isSingle: true,\n        showShortSentence: false,\n        showBody:false\n    }) }}\n    <div class=\"alert alert__info\">\n        <p>{{ 'browsing_one_thread'|trans }}</p>\n        <p><a href=\"{{ entry_url(entry) }}\"><i class=\"fa-solid fa-arrow-left\" aria-hidden=\"true\"></i> {{ 'return'|trans }}</a></p>\n    </div>\n    {% if parent is defined and parent %}\n        {{ component('entry_comment', {\n            comment: parent,\n            showEntryTitle: false,\n            showNested: false\n        }) }}\n    {% endif %}\n    {% include 'layout/_flash.html.twig' %}\n\n    {% if user.visibility is same as 'visible' %}\n    <section id=\"content\" class=\"section\">\n        {% include 'entry/comment/_form_comment.html.twig' %}\n    </section>\n    {% endif %}\n{% endblock %}\n"
  },
  {
    "path": "templates/entry/comment/edit.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    {{- 'edit_comment'|trans }} - {{ get_short_sentence(comment.body, 80) }} - {{ parent() -}}\n{%- endblock -%}\n\n{% block mainClass %}page-entry-comment-edit{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n{% endblock %}\n\n{% block body %}\n    {{ component('entry_comment', {\n        comment: comment,\n        dateAsUrl: false,\n        showEntryTitle: false,\n        showMagazineName: false,\n    }) }}\n    {% include 'layout/_flash.html.twig' %}\n    <div class=\"alert alert__info\">\n        <p>{{ 'browsing_one_thread'|trans }}</p>\n        <p><a href=\"{{ entry_url(comment.entry) }}\"><i class=\"fa-solid fa-arrow-left\" aria-hidden=\"true\"></i> {{ 'return'|trans }}</a></p>\n    </div>\n    <section id=\"content\" class=\"section\">\n        {% include 'entry/comment/_form_comment.html.twig' with {edit: true} %}\n    </section>\n{% endblock %}\n"
  },
  {
    "path": "templates/entry/comment/favourites.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    {{- 'favourites'|trans }} - {{ get_short_sentence(comment.body, 80) }} - {{ parent() -}}\n{%- endblock -%}\n\n{% block mainClass %}page-entry-comment-favourites{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n{% endblock %}\n\n{% block body %}\n    {{ component('entry_comment', {\n        comment: comment,\n        showEntryTitle: false,\n        showMagazineName: false\n    }) }}\n    {% include 'layout/_flash.html.twig' %}\n    {% include 'entry/comment/_options_activity.html.twig' %}\n    <div id=\"content\">\n        {% include 'layout/_user_activity_list.html.twig' with {list: favourites} %}\n    </div>\n{% endblock %}\n"
  },
  {
    "path": "templates/entry/comment/front.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    {% if magazine is defined and magazine %}\n        {{- get_active_sort_option('sortBy')|trans|capitalize }} - {{ 'comments'|trans }} - {{ magazine.title }} - {{ parent() -}}\n    {% else %}\n        {{- get_active_sort_option('sortBy')|trans|capitalize }} - {{ 'comments'|trans }} - {{ parent() -}}\n    {% endif %}\n{%- endblock -%}\n\n\n{% block mainClass %}page-entry-comments-front{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n{% endblock %}\n\n{% block body %}\n    {% if magazine is defined and magazine %}\n        <h1 hidden>{{ magazine.title }}</h1>\n        <h2 hidden>{{ get_active_sort_option()|trans }}</h2>\n    {% else %}\n        <h1 hidden>{{ get_active_sort_option()|trans }}</h1>\n    {% endif %}\n    {% include 'entry/comment/_options.html.twig' %}\n    {% include 'layout/_flash.html.twig' %}\n    {% if magazine is defined and magazine %}\n        {% include 'magazine/_federated_info.html.twig' %}\n        {% include 'magazine/_visibility_info.html.twig' %}\n    {% endif %}\n    <div id=\"content\" class=\"comments-tree\">\n        {% include 'entry/comment/_list.html.twig' %}\n    </div>\n{% endblock %}\n\n"
  },
  {
    "path": "templates/entry/comment/moderate.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    {{- 'moderate'|trans }} - {{ get_short_sentence(comment.body, 80) }} - {{ magazine.title }} - {{ parent() -}}\n{%- endblock -%}\n\n{% block mainClass %}page-entry-moderate{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n{% endblock %}\n\n{% block body %}\n    <div id=\"content\">\n        {{ component('entry_comment', {\n            comment: comment,\n            dateAsUrl: false,\n        }) }}\n        {% include 'layout/_flash.html.twig' %}\n        <div class=\"section section--small\">\n          {% include 'entry/comment/_moderate_panel.html.twig' %}\n        </div>\n    </div>\n{% endblock %}\n"
  },
  {
    "path": "templates/entry/comment/view.html.twig",
    "content": "{%- set V_TRUE = constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::TRUE') -%}\n{%- set V_FALSE = constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::FALSE') -%}\n\n{%- set DYNAMIC_LISTS = app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_GENERAL_DYNAMIC_LISTS'), V_FALSE) -%}\n{%- set SHOW_COMMENT_USER_AVATARS = app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_COMMENTS_SHOW_USER_AVATAR'), V_TRUE) -%}\n\n{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    {{- 'comments'|trans }} - {{ entry.title }} - {{ parent() -}}\n{%- endblock -%}\n\n{% block stylesheets %}\n    {{ parent() }}\n\n    <style>\n        #entry-comment-{{comment.id}} {\n            border-top: var(--kbin-alert-danger-border);\n            border-bottom: var(--kbin-alert-danger-border);\n            border-right: var(--kbin-alert-danger-border);\n        }\n    </style>\n{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n{% endblock %}\n\n{% block body %}\n    {{ component('entry', {\n        entry: entry,\n        isSingle: true,\n        showShortSentence: false,\n        showBody:false\n    }) }}\n    <div class=\"alert alert__info\">\n        <p>{{ 'browsing_one_thread'|trans }}</p>\n        <p><a href=\"{{ entry_url(entry) }}\"><i class=\"fa-solid fa-arrow-left\" aria-hidden=\"true\"></i> {{ 'return'|trans }}</a></p>\n    </div>\n    {% set autoAction = 'notifications:EntryCommentCreatedNotification@window->subject-list#addComment' %}\n    {% set manualAction = 'notifications:EntryCommentCreatedNotification@window->subject-list#increaseCounter' %}\n    <div class=\"{{ html_classes('comments entry-comments comments-tree', {\n        'show-comment-avatar' : SHOW_COMMENT_USER_AVATARS is same as V_TRUE\n        }) }}\"\n        data-controller=\"subject-list\"\n        data-action=\"{{- DYNAMIC_LISTS is same as V_TRUE ? autoAction : manualAction -}}\">\n        {% set entryComment = comment.root ?? comment %}\n        {% if entryComment is defined and entryComment is not null %}\n            {{ component('entry_comment', {\n                comment: entryComment,\n                showEntryTitle: false,\n                showMagazineName: false,\n                showNested: true,\n                criteria: criteria,\n            }) }}\n        {% else %}\n        <div class=\"section alert alert__danger\">\n            <p>\n                <a href=\"{{ entry_url(entry) }}\" class=\"btn btn-link btn__danger\" style=\"margin-right: 10px;\">\n                    <i class=\"fa-solid fa-arrow-left-long\" title=\"{{ 'back'|trans }}\"></i> {{ 'back'|trans }}\n                </a>\n                {{ 'comment_not_found'|trans }}\n            </p>\n        </div>\n        {% endif %}\n    </div>\n{% endblock %}\n"
  },
  {
    "path": "templates/entry/comment/voters.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    {{- 'activity'|trans }} - {{ get_short_sentence(comment.body, 80) }} - {{ parent() -}}\n{%- endblock -%}\n\n{% block mainClass %}page-entry-comment-voters{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n{% endblock %}\n\n{% block body %}\n    {{ component('entry_comment', {\n        comment: comment,\n        showEntryTitle: false,\n        showMagazineName: false\n    }) }}\n    {% include 'layout/_flash.html.twig' %}\n    {% include 'entry/comment/_options_activity.html.twig' %}\n    <div id=\"content\">\n        {% include 'layout/_user_activity_list.html.twig' with {list: votes} %}\n    </div>\n{% endblock %}\n"
  },
  {
    "path": "templates/entry/create_entry.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    {{- 'add_new_article'|trans }} - {{ parent() -}}\n{%- endblock -%}\n\n{% block mainClass %}page-entry-create page-entry-create-article{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n{% endblock %}\n\n{% block body %}\n    {% include 'entry/_create_options.html.twig' %}\n    <header>\n        <h1 hidden>{{ 'add_new_article'|trans }}</h1>\n    </header>\n    {% include 'layout/_flash.html.twig' %}\n    {% include('user/_visibility_info.html.twig') %}\n\n    {% if user.visibility is same as 'visible' %}\n    <div id=\"content\" class=\"section\">\n        <div class=\"container\" data-controller=\"entry-link-create\">\n            {% include 'entry/_form_entry.html.twig' %}\n        </div>\n    </div>\n    {% endif %}\n{% endblock %}\n"
  },
  {
    "path": "templates/entry/edit_entry.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    {{- 'edit_entry'|trans }} - {{ parent() -}}\n{%- endblock -%}\n\n{% block mainClass %}page-entry-create page-entry-edit-article{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n{% endblock %}\n\n{% block body %}\n    <header>\n        <h1 hidden>{{ 'edit_entry'|trans }}</h1>\n    </header>\n    {% include 'layout/_flash.html.twig' %}\n    <div id=\"content\" class=\"section section--top\">\n        <div class=\"container\" data-controller=\"entry-link-create\">\n            {% include 'entry/_form_edit.html.twig' %}\n        </div>\n    </div>\n{% endblock %}\n"
  },
  {
    "path": "templates/entry/favourites.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    {{- 'favourites'|trans }} - {{ entry.title}} - {{ parent() -}}\n{%- endblock -%}\n\n{% block mainClass %}page-entry-favourites{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n{% endblock %}\n\n{% block body %}\n    <div id=\"content\">\n        {{ component('entry', {\n            entry: entry,\n            isSingle: true,\n            showBody: false\n        }) }}\n        {% include 'layout/_flash.html.twig' %}\n        {% include 'entry/_options_activity.html.twig' %}\n        <div id=\"content\">\n            {% include 'layout/_user_activity_list.html.twig' with {list: favourites} %}\n        </div>\n    </div>\n{% endblock %}\n"
  },
  {
    "path": "templates/entry/moderate.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    {{- 'moderate'|trans }} - {{ entry.title }} - {{ magazine.title }} - {{ parent() -}}\n{%- endblock -%}\n\n{% block mainClass %}page-entry-moderate{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n{% endblock %}\n\n{% block body %}\n    <div id=\"content\">\n        {{ component('entry', {\n            entry: entry,\n            isSingle: true,\n            showShortSentence: false,\n            showBody:true,\n            moderate:true,\n            class: 'section--top'\n        }) }}\n        {% include 'layout/_flash.html.twig' %}\n        <div class=\"section section--small\">\n          {% include 'entry/_moderate_panel.html.twig' %}\n        </div>\n    </div>\n{% endblock %}\n"
  },
  {
    "path": "templates/entry/single.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    {{- entry.title }} - {{ magazine.title }} - {{ parent() -}}\n{%- endblock -%}\n\n{% block description %}\n    {{- entry.body ? get_short_sentence(entry.body) : '' -}}\n{% endblock %}\n\n{% block image %}\n    {%- if entry.image -%}\n        {{- uploaded_asset(entry.image) -}}\n    {%- elseif entry.magazine.icon -%}\n        {{- uploaded_asset(entry.magazine.icon) -}}\n    {%- else -%}\n        {{- parent() -}}\n    {%- endif -%}\n{% endblock %}\n\n{% block mainClass %}page-entry-single{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n{% endblock %}\n\n{% block body %}\n    <div id=\"content\">\n        {{ component('entry', {\n            entry: entry,\n            isSingle: true,\n            showShortSentence: false,\n            showBody:true\n        }) }}\n        {{ component('entries_cross', {entry: entry}) }}\n        {% include 'layout/_flash.html.twig' %}\n        {% include('user/_visibility_info.html.twig') %}\n\n        {% if user is defined and user and user.visibility is same as 'visible' and not entry.isLocked and (user_settings.comment_reply_position == constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::TOP')) %}\n            <div id=\"comment-add\" class=\"section\">\n                {% include 'entry/comment/_form_comment.html.twig' %}\n            </div>\n        {% endif %}\n\n        {% include 'entry/comment/_options.html.twig' %}\n\n        {% if entry.isLocked %}\n            <div class=\"alert alert__info\">\n                <p>\n                    {{ 'comments_locked'|trans }}\n                </p>\n            </div>\n        {% endif %}\n\n        <div id=\"comments\">\n            {% include 'entry/comment/_list.html.twig' %}\n        </div>\n\n        {% if user is defined and user and user.visibility is same as 'visible' and not entry.isLocked and (user_settings.comment_reply_position == constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::BOTTOM'))  %}\n            <div id=\"comment-add\" class=\"section\">\n                {% include 'entry/comment/_form_comment.html.twig' %}\n            </div>\n        {% endif %}\n\n        {% include 'entry/_options_activity.html.twig' %}\n    </div>\n{% endblock %}\n"
  },
  {
    "path": "templates/entry/voters.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    {{- route_has_param('type', 'up') ? 'up_votes'|trans : 'down_votes'|trans }} - {{ entry.title}} - {{ parent() -}}\n{%- endblock -%}\n\n{% block mainClass %}page-entry-voters{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n{% endblock %}\n\n{% block body %}\n    <div id=\"content\">\n        {{ component('entry', {entry: entry, isSingle: true, showBody: false}) }}\n        {% include 'layout/_flash.html.twig' %}\n        {% include 'entry/_options_activity.html.twig' %}\n        <div id=\"content\">\n            {% include 'layout/_user_activity_list.html.twig' with {list: votes} %}\n        </div>\n    </div>\n{% endblock %}\n"
  },
  {
    "path": "templates/form/lang_select.html.twig",
    "content": "{# This block needed as the default one (of which this is a very close copy) does not respect preferred_choices like it should #}\n{%- block choice_widget_options -%}\n    {% for group_label, choice in options %}\n        {%- if choice is iterable -%}\n            <optgroup label=\"{{ group_label }}\">\n                {% set options = choice %}\n                {{- block('choice_widget_options') -}}\n            </optgroup>\n        {%- elseif render_preferred_choices|default(false) or (not render_preferred_choices|default(false) and choice not in preferred_choices) -%}\n            <option value=\"{{ choice.value }}\"{% if choice.attr %}{% with { attr: choice.attr } %}{{ block('attributes') }}{% endwith %}{% endif %}{% if choice is selectedchoice(value) %} selected=\"selected\"{% endif %}>{{ choice.label }}</option>\n        {%- endif -%}\n    {% endfor %}\n{%- endblock choice_widget_options -%}\n"
  },
  {
    "path": "templates/layout/_domain_activity_list.html.twig",
    "content": "{% if actor is not defined %}\n    {% set actor = 'magazine' %}\n{% endif %}\n\n{% if list|length %}\n    <div class=\"section domains domains-columns\">\n        <ul>\n            {% for subject in list %}\n                <li>\n                    <div>\n                        <a href=\"{{ path('domain_entries', {name: attribute(subject, actor).name}) }}\"\n                           class=\"stretched-link\">{{ attribute(subject, actor).name }}</a>\n                        <small>{{ component('date', {date: subject.createdAt}) }}</small>\n                    </div>\n                </li>\n            {% endfor %}\n        </ul>\n    </div>\n    {% if(list.haveToPaginate is defined and list.haveToPaginate) %}\n        {{ pagerfanta(list, null, {'pageParameter':'[p]'}) }}\n    {% endif %}\n{% else %}\n    <aside class=\"section section--muted\">\n        <p>{{ 'empty'|trans }}</p>\n    </aside>\n{% endif %}"
  },
  {
    "path": "templates/layout/_flash.html.twig",
    "content": "{% for flash_error in app.flashes('error') %}\n    <div class=\"alert alert__danger\">{{ flash_error|trans }}</div>\n{% endfor %}\n{% for flash_success in app.flashes('success') %}\n    <div class=\"alert alert__success\">{{ flash_success|trans }}</div>\n{% endfor %}\n"
  },
  {
    "path": "templates/layout/_form_media.html.twig",
    "content": "<div class=\"media\" data-controller=\"image-upload\">\n    <div class=\"image-preview-container\">\n        <img class=\"image-preview\" src=\"#\">\n        <button type=\"button\" class=\"image-preview-clear\" data-action=\"image-upload#clearPreview\">x</button>\n    </div>\n    <div class=\"image-form\">\n        {% if form.image is defined %}\n            {{ form_row(form.image, {label: 'image', attr: {class: 'image-input'}}) }}\n        {% endif %}\n        {% if maxSize is defined %}\n            <div>{{ 'max_image_size'|trans }}: {{ maxSize }}</div>\n        {% endif %}\n        <div hidden>\n            {{ form_row(form.imageUrl, {label: 'url'}) }}\n        </div>\n        {{ form_row(form.imageAlt, {label: 'image_alt'}) }}\n    </div>\n</div>\n"
  },
  {
    "path": "templates/layout/_generic_subject_list.html.twig",
    "content": "<div data-controller=\"subject-list\">\n    {{ include('layout/_subject_list.html.twig') }}\n</div>\n"
  },
  {
    "path": "templates/layout/_header.html.twig",
    "content": "{%- set STATUS_PENDING = constant('App\\\\Entity\\\\Report::STATUS_PENDING') -%}\n<header id=\"header\" class=\"header\">\n    <div class=\"mbin-container\n          {{ html_classes(app.request.cookies.has(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_PAGE_WIDTH'))\n            ? 'width--'~app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_PAGE_WIDTH'))\n            : '') }}\">\n        <div class=\"sr-nav\">\n            <a href=\"#content\">{{ 'go_to_content'|trans }}</a>\n            <a href=\"#options\">{{ 'go_to_filters'|trans }}</a>\n            <a href=\"{{ path('search') }}\">{{ 'go_to_search'|trans }}</a>\n        </div>\n        <nav class=\"head-nav\">\n            <div class=\"brand\">\n                <div id=\"nav-toggle\" data-action=\"click->mbin#handleNavToggleClick\" aria-label=\"{{ 'menu'|trans }}\"><i class=\"fa-solid fa-bars\" aria-hidden=\"true\"></i></div>\n                <a href=\"/\">\n                    {% if kbin_header_logo() %}\n                        <img id=\"logo\" src=\"{{ asset('mbin_logo.svg') }}\" alt=\"{{ 'homepage'|trans }}\" title=\"{{ 'homepage'|trans}}\">\n                    {% else %}\n                        <span>{{ kbin_title() }}</span>\n                    {% endif %}\n                </a>\n            </div>\n            <menu class=\"head-nav__menu\">\n                <li>\n                    {% include 'layout/_header_bread.html.twig' %}\n                </li>\n                {% include 'layout/_header_nav.html.twig' %}\n            </menu>\n            <menu class=\"head-nav__mobile-menu\">\n                <li>\n                    {% include 'layout/_header_bread.html.twig' %}\n                </li>\n            </menu>\n        </nav>\n        <menu>\n            <li>\n                <a href=\"{{ path('search') }}\"\n                   class=\"icon\"\n                   aria-label=\"{{ 'search'|trans }}\"\n                   title=\"{{ 'search'|trans }}\">\n                    <i class=\"fa-solid fa-magnifying-glass\" aria-hidden=\"true\"></i>\n                </a>\n            </li>\n            <li class=\"dropdown\">\n                <a href=\"{{ path('entry_create') }}\"\n                   class=\"icon\"\n                   aria-label=\"{{ 'add'|trans }}\"\n                   title=\"{{ 'add'|trans }}\">\n                    <i class=\"fa-solid fa-plus\" aria-hidden=\"true\"></i>\n                </a>\n                <ul class=\"dropdown__menu\">\n                    {% if magazine is defined and magazine %}\n                        <li>\n                            <a href=\"{{ path('magazine_entry_create', {name: magazine.name}) }}\"\n                               class=\"{{ html_classes({'active': is_route_name('magazine_entry_create')}) }}\">\n                                {{ 'add_new_article'|trans }}\n                            </a>\n                        </li>\n                        <li>\n                            <a class=\"{{ html_classes({'active': is_route_name('post_create')}) }}\"\n                               href=\"{{ path('magazine_posts', {name: magazine.name}) }}\">{{ 'add_new_post'|trans }}\n                            </a>\n                        </li>\n                    {% else %}\n                        <li>\n                            <a href=\"{{ path('entry_create', {}) }}\"\n                               class=\"{{ html_classes({'active': is_route_name('entry_create')}) }}\">\n                                {{ 'add_new_article'|trans }}\n                            </a>\n                        </li>\n                        <li>\n                            <a class=\"{{ html_classes({'active': is_route_name('post_create')}) }}\"\n                               href=\"{{ path('posts_front') }}\">{{ 'add_new_post'|trans }}\n                            </a>\n                        </li>\n                    {% endif %}\n                    {% if mbin_restrict_magazine_creation() is same as false or (app.user and (app.user.admin() or app.user.moderator())) %}\n                    <li><a class=\"{{ html_classes({'active': is_route_name('magazine_create')}) }}\"\n                           href=\"{{ path('magazine_create') }}\">{{ 'create_new_magazine'|trans }}</a></li>\n                    {% endif %}\n                </ul>\n            </li>\n            {% if app.user %}\n                <li class=\"counter\" id=\"header-notification-count\" style=\"{{ app.user.countNewNotifications ? \"\" : \"display: none;\" }}\">\n                    <a href=\"{{ path('notifications_front') }}\">\n                        <span class=\"badge secondary-bg\">{{ app.user.countNewNotifications }}</span>\n                    </a>\n                </li>\n                <li class=\"counter\" id=\"header-messages-count\" style=\"{{ app.user.countNewMessages ? \"\" : \"display: none;\" }}\">\n                    <a href=\"{{ path('messages_front') }}\">\n                        <span class=\"badge danger-bg\">{{ app.user.countNewMessages }}</span>\n                    </a>\n                </li>\n                <li class=\"dropdown\">\n                    <a class=\"{{ app.user.avatar ? 'has-avatar' : '' }} {{ html_classes('login', {'active': is_route_name_contains('settings')}) }}\"\n                       href=\"{{ path('user_overview', {username: app.user.username}) }}\">\n                        {% if app.user.avatar %}\n                            {{ component('user_image_component', {user: app.user}) }}\n                        {% endif %}\n                        <span class='user-name'>{{ app.user.username }}</span>\n                    </a>\n                    <ul class=\"dropdown__menu\">\n                        <li>\n                            <a href=\"{{ path('user_overview', {username: app.user.username}) }}\"\n                               class=\"{{ html_classes({'active': is_route_name_contains('user_overview') and user is same as app.user}) }}\">\n                                {{ 'profile'|trans }}\n                            </a>\n                        </li>\n                        <li>\n                            <a href=\"{{ path('user_settings_general') }}\"\n                               class=\"{{ html_classes({'active': is_route_name_contains('user_settings')}) }}\">\n                                {{ 'settings'|trans }}\n                            </a>\n                        </li>\n                        <li id=\"dropdown-messages-count\">\n                            <a href=\"{{ path('messages_front') }}\"\n                               class=\"{{ html_classes({'active': is_route_name_contains('messages')}) }}\">\n                                {{ 'messages'|trans }}\n                                <span class=\"badge danger-bg ms-1\" style=\"{{ app.user.countNewMessages ? \"\" : \"display: none;\" }}\">{{ app.user.countNewMessages }}</span>\n                            </a>\n                        </li>\n                        <li id=\"dropdown-notifications-count\">\n                            <a href=\"{{ path('notifications_front') }}\"\n                               class=\"{{ html_classes({'active': is_route_name_contains('notifications')}) }}\">\n                                {{ 'notifications'|trans }}\n                                <span class=\"badge secondary-bg ms-1\" style=\"{{ app.user.countNewNotifications ? \"\" : \"display: none;\" }}\">{{ app.user.countNewNotifications }}</span>\n                            </a>\n                        </li>\n                        <li>\n                            <a href=\"{{ path('bookmark_lists') }}\"\n                               class=\"{{ html_classes({'active': is_route_name_contains('bookmark')}) }}\">\n                                {{ 'bookmark_lists'|trans }}\n                            </a>\n                        </li>\n                        {% if is_granted('ROLE_ADMIN') %}\n                            <li>\n                                <a href=\"{{ path('admin_dashboard') }}\"\n                                   class=\"{{ html_classes({'active': is_route_name_starts_with('admin')}) }}\">\n                                    {{ 'admin_panel'|trans }}\n                                </a>\n                            </li>\n                        {% endif %}\n                        {% if is_granted('ROLE_MODERATOR') %}\n                            <li>\n                                <a href=\"{{ path('admin_reports', {status: STATUS_PENDING}) }}\"\n                                   class=\"{{ html_classes({'active': is_route_name_starts_with('admin_reports')}) }}\">\n                                    {{ 'reports'|trans }}\n                                </a>\n                            </li>\n                        {% endif %}\n                        {% if is_granted('ROLE_MODERATOR') and do_new_users_need_approval() %}\n                            <li>\n                                <a href=\"{{ path('admin_signup_requests') }}\"\n                                   class=\"{{ html_classes({'active': is_route_name_starts_with('admin_signup_requests')}) }}\">\n                                    {{ 'signup_requests'|trans }}\n                                </a>\n                            </li>\n                        {% endif %}\n                        <li><a href=\"{{ logout_path() }}\">{{ 'logout'|trans }}</a></li>\n                    </ul>\n                </li>\n            {% else %}\n                <li>\n                    <a class=\"{{ html_classes('login', {'active': is_route_name('app_login')}) }}\"\n                       href=\"{{ path('app_login') }}\">{{ 'login'|trans }}</a>\n                </li>\n            {% endif %}\n        </menu>\n    </div>\n</header>\n"
  },
  {
    "path": "templates/layout/_header_bread.html.twig",
    "content": "{% set filter_option = criteria is defined ? criteria.getOption('subscription') : null %}\n\n{% if magazine is defined and magazine %}\n    <div class=\"head-title\">\n        <span>/m/</span><a href=\"{{ path('front_magazine', {name: magazine.name}) }}\">{{ magazine.name }}</a>\n    </div>\n{% elseif is_route_name_starts_with('domain_') %}\n    <div class=\"head-title\">\n        <span>/d/</span><a href=\"{{ path('domain_entries', {name: domain.name}) }}\">{{ domain.name }}</a>\n    </div>\n{% elseif tag is defined and tag %}\n    <div class=\"head-title\">\n        <span>#</span><a href=\"{{ path('tag_overview', {name: tag}) }}\">{{ tag }}</a>\n    </div>\n{% elseif filter_option == 'subscribed' or is_route_name_end_with('_subscribed') %}\n    <div class=\"head-title\">\n        <span>/</span><a href=\"{{ '/sub/' }}\">sub</a>\n    </div>\n{% elseif filter_option == 'favourites' or is_route_name_end_with('_favourite') %}\n    <div class=\"head-title\">\n        <span>/</span><a href=\"{{ '/fav/' }}\">fav</a>\n    </div>\n{% elseif filter_option == 'moderated' or is_route_name_end_with('_moderated') %}\n    <div class=\"head-title\">\n        <span>/</span><a href=\"{{ '/mod/' }}\">mod</a>\n    </div>\n{% endif %}\n"
  },
  {
    "path": "templates/layout/_header_nav.html.twig",
    "content": "{% set activeLink = '' %}\n\n{% if (is_route_name_contains('people') or is_route_name_starts_with('user')) and not is_route_name_contains('settings') %}\n    {% set activeLink = 'people' %}\n{% elseif is_route_name('magazine_list_all') %}\n    {% set activeLink = 'magazines' %}\n{% elseif criteria is defined %}\n    {% if criteria.getOption('content') == 'threads' %}\n        {% set activeLink = 'threads' %}\n    {% elseif criteria.getOption('content') == 'microblog' %}\n        {% set activeLink = 'microblog' %}\n    {% elseif criteria.getOption('content') == 'combined' %}\n        {% set activeLink = 'combined' %}\n    {% endif %}\n{% elseif entry is defined and entry %}\n    {% set activeLink = 'threads' %}\n{% elseif post is defined and post %}\n    {% set activeLink = 'microblog' %}\n{% endif %}\n\n{% if header_nav is empty %}\n    <li>\n        <a href=\"{{ navbar_combined_url(magazine ?? null) }}\" class=\"{{ html_classes({'active': activeLink == 'combined'}) }}\">\n            {{ 'combined'|trans }}\n        </a>\n    </li>\n    <li>\n        <a href=\"{{ navbar_threads_url(magazine ?? null) }}\" class=\"{{ html_classes({'active': activeLink == 'threads'}) }}\">\n            {{ 'threads'|trans }} {% if magazine is defined and magazine %}({{ magazine.entryCount }}){% endif %}\n        </a>\n    </li>\n    <li>\n        <a href=\"{{ navbar_posts_url(magazine ?? null) }}\" class=\"{{ html_classes({'active': activeLink == 'microblog'}) }}\">\n            {{ 'microblog'|trans }} {% if magazine is defined and magazine %}({{ magazine.postCount }}){% endif %}\n        </a>\n    </li>\n    <li>\n        <a href=\"{{ navbar_people_url(magazine ?? null) }}\"\n           class=\"{{ html_classes({'active': activeLink == 'people'}) }}\">\n            {{ 'people'|trans }}\n        </a>\n    </li>\n    {% if app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_GENERAL_TOPBAR')) is not same as 'true' %}\n        <li>\n            <a href=\"{{ path('magazine_list_all') }}\"\n               class=\"{{ html_classes({'active': activeLink == 'magazines'}) }}\">\n                {{ 'magazines'|trans }}\n            </a>\n        </li>\n    {% endif %}\n{% else %}\n    {{ header_nav|raw }}\n{% endif %}\n"
  },
  {
    "path": "templates/layout/_magazine_activity_list.html.twig",
    "content": "{%- set V_TRUE = constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::TRUE') -%}\n{%- set V_FALSE = constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::FALSE') -%}\n{%- set SHOW_MAGAZINE_FULLNAME = app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::MBIN_SHOW_MAGAZINE_DOMAIN'), V_FALSE) -%}\n{% if actor is not defined %}\n    {% set actor = 'magazine' %}\n{% endif %}\n\n{% if list|length %}\n    <div class=\"section magazines magazines-columns\">\n        <ul>\n            {% for subject in list %}\n                <li>\n                    {% if attribute(subject, actor).icon and attribute(subject, actor).icon.filePath and (app.user or attribute(subject, actor).isAdult is same as false) %}\n                        <figure>\n                            <img width=\"32\" height=\"32\"\n                                 loading=\"lazy\"\n                                 class=\"image-inline {{ attribute(subject, actor).isAdult ? 'image-adult' : '' }}\"\n                                 src=\"{{ asset(attribute(subject, actor).icon.filePath) | imagine_filter('avatar_thumb') }}\"\n                                 {% if attribute(subject, actor).isAdult %}data-controller=\"thumb\" data-action=\"mouseover->thumb#adultImageHover mouseout->thumb#adultImageHoverOut\"{% endif %}\n                                 alt=\"{{ attribute(subject, actor).name ~' '~ 'icon'|trans|lower }}\">\n                        </figure>\n                    {% endif %}\n                    <div>\n                        <a href=\"{{ path('front_magazine', {name: attribute(subject, actor).name}) }}\"\n                           class=\"stretched-link\">\n                            {{ attribute(subject, actor).name }}\n                            {%- if SHOW_MAGAZINE_FULLNAME is same as V_TRUE -%}\n                                @{{- attribute(subject, actor).name|apDomain -}}\n                            {%- endif -%}\n                            {% if attribute(subject, actor).isAdult %}<small class=\"badge danger\">18+</small>{% endif %}\n                        </a>\n                        <small>{{ component('date', {date: subject.createdAt}) }}</small>\n                    </div>\n                </li>\n            {% endfor %}\n        </ul>\n    </div>\n    {% if(list.haveToPaginate is defined and list.haveToPaginate) %}\n        {{ pagerfanta(list, null, {'pageParameter':'[p]'}) }}\n    {% endif %}\n{% else %}\n    <aside class=\"section section--muted\">\n        <p>{{ 'empty'|trans }}</p>\n    </aside>\n{% endif %}\n"
  },
  {
    "path": "templates/layout/_options_appearance.html.twig",
    "content": "<div class=\"settings-list\">\n    <div class=\"reload-required-section\">\n        <button data-action=\"options#appearanceReloadRequired:prevent\" class=\"btn btn-link\">\n            <i class=\"fa fa-refresh\" aria-hidden=\"true\"></i>{{ 'reload_to_apply'|trans }}\n        </button>\n    </div>\n    <strong>{{ 'general'|trans }}</strong>\n    <div class=\"settings-section\">\n        {{ component('settings_row_enum', {label: 'sidebar_position'|trans, settingsKey: 'KBIN_GENERAL_SIDEBAR_POSITION', values: [ {name: 'left'|trans , value: 'LEFT'}, {name: 'right'|trans , value: 'RIGHT' } ], defaultValue: 'RIGHT' } ) }}\n        {{ component('settings_row_enum', {label: 'page_width'|trans, settingsKey: 'KBIN_PAGE_WIDTH', values: [ {name: 'page_width_max'|trans , value: 'MAX'}, {name: 'page_width_auto'|trans , value: 'AUTO' }, {name: 'page_width_fixed'|trans , value: 'FIXED' } ], defaultValue: 'FIXED', class: 'width-setting' } ) }}\n        {{ component('settings_row_enum', {label: 'filter_labels'|trans, settingsKey: 'KBIN_GENERAL_FILTER_LABELS', values: [ {name: 'on'|trans , value: 'ON'}, {name: 'auto'|trans , value: 'AUTO' }, {name: 'off'|trans , value: 'OFF' } ], defaultValue: 'ON' } ) }}\n        {% if kbin_mercure_enabled() %}\n            {{ component('settings_row_switch', {label: 'dynamic_lists'|trans, settingsKey: 'KBIN_GENERAL_DYNAMIC_LISTS'}) }}\n        {% endif %}\n        {{ component('settings_row_switch', {label: 'rounded_edges'|trans, settingsKey: 'KBIN_GENERAL_ROUNDED_EDGES', defaultValue: true}) }}\n        {{ component('settings_row_switch', {label: 'infinite_scroll'|trans, help: 'infinite_scroll_help'|trans ,  settingsKey: 'KBIN_GENERAL_INFINITE_SCROLL'}) }}\n        {{ component('settings_row_switch', {label: 'sticky_navbar'|trans, help: 'sticky_navbar_help'|trans,  settingsKey: 'KBIN_GENERAL_FIXED_NAVBAR'}) }}\n        {{ component('settings_row_switch', {label: 'show_top_bar'|trans, settingsKey: 'KBIN_GENERAL_TOPBAR'}) }}\n        {{ component('settings_row_switch', {label: 'show_related_magazines'|trans, settingsKey: 'MBIN_GENERAL_SHOW_RELATED_MAGAZINES', defaultValue: 'true'}) }}\n        {{ component('settings_row_switch', {label: 'show_related_entries'|trans, settingsKey: 'MBIN_GENERAL_SHOW_RELATED_ENTRIES', defaultValue: 'true'}) }}\n        {{ component('settings_row_switch', {label: 'show_related_posts'|trans, settingsKey: 'MBIN_GENERAL_SHOW_RELATED_POSTS', defaultValue: 'true'}) }}\n        {{ component('settings_row_switch', {label: 'show_active_users'|trans, settingsKey: 'MBIN_GENERAL_SHOW_ACTIVE_USERS', defaultValue: 'true'}) }}\n        {{ component('settings_row_switch', {label: 'show_user_domains'|trans, settingsKey: 'MBIN_SHOW_USER_DOMAIN', defaultValue: false}) }}\n        {{ component('settings_row_switch', {label: 'show_magazine_domains'|trans, settingsKey: 'MBIN_SHOW_MAGAZINE_DOMAIN', defaultValue: false}) }}\n    </div>\n    {% if app.user is defined and app.user is not same as null %}\n        <strong>{{ 'subscriptions'|trans }}</strong>\n        <div class=\"settings-section\">\n            {{ component('settings_row_switch', {label: 'show_subscriptions'|trans, settingsKey: 'KBIN_SUBSCRIPTIONS_SHOW', defaultValue: 'true'}) }}\n            {{ component('settings_row_switch', {label: 'show_magazines_icons'|trans, settingsKey: 'KBIN_SUBSCRIPTIONS_SHOW_MAGAZINE_ICON', defaultValue: 'true'}) }}\n            {% if app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_SUBSCRIPTIONS_SHOW')) is not same as 'false' %}\n                {{ component('settings_row_enum', {label: 'subscription_sort'|trans, settingsKey: 'KBIN_SUBSCRIPTIONS_SORT', values: [ {name: 'alphabetically'|trans , value: 'ALPHABETICALLY'}, {name: 'last_active'|trans , value: 'LAST_ACTIVE' } ], defaultValue: 'LAST_ACTIVE'}) }}\n                {{ component('settings_row_switch', {label: 'subscriptions_in_own_sidebar'|trans, settingsKey: 'KBIN_SUBSCRIPTIONS_IN_SEPARATE_SIDEBAR'}) }}\n                {% if app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_SUBSCRIPTIONS_IN_SEPARATE_SIDEBAR')) is same as 'true' %}\n                    {{ component('settings_row_switch', {label: 'sidebars_same_side'|trans, settingsKey: 'KBIN_SUBSCRIPTIONS_SIDEBARS_SAME_SIDE'}) }}\n                {% else %}\n                    {{ component('settings_row_switch', {label: 'subscription_panel_large'|trans, settingsKey: 'KBIN_SUBSCRIPTIONS_LARGE_PANEL'}) }}\n                {% endif %}\n            {% endif %}\n        </div>\n    {% endif %}\n    <strong>{{ 'threads'|trans }}</strong>\n    <div class=\"settings-section\">\n        {{ component('settings_row_switch', {label: 'auto_preview'|trans, help: 'auto_preview_help'|trans,  settingsKey: 'KBIN_ENTRIES_SHOW_PREVIEW'}) }}\n        {{ component('settings_row_switch', {label: 'compact_view'|trans, help: 'compact_view_help'|trans, settingsKey: 'KBIN_ENTRIES_COMPACT'}) }}\n        {{ component('settings_row_switch', {label: 'show_users_avatars'|trans, help: 'show_users_avatars_help'|trans, settingsKey: 'KBIN_ENTRIES_SHOW_USERS_AVATARS', defaultValue: true}) }}\n        {{ component('settings_row_switch', {label: 'show_magazines_icons'|trans, help: 'show_magazines_icons_help'|trans, settingsKey: 'KBIN_ENTRIES_SHOW_MAGAZINES_ICONS', defaultValue: true}) }}\n        {{ component('settings_row_switch', {label: 'show_thumbnails'|trans, help: 'show_thumbnails_help'|trans, settingsKey: 'KBIN_ENTRIES_SHOW_THUMBNAILS', defaultValue: true}) }}\n        {{ component('settings_row_switch', {label: 'image_lightbox_in_list'|trans, help: 'image_lightbox_in_list_help'|trans, settingsKey: 'MBIN_LIST_IMAGE_LIGHTBOX', defaultValue: true}) }}\n        {{ component('settings_row_switch', {label: 'show_rich_mention'|trans, help: 'show_rich_mention_help'|trans, settingsKey: 'MBIN_ENTRIES_SHOW_RICH_MENTION', defaultValue: true}) }}\n        {{ component('settings_row_switch', {label: 'show_rich_mention_magazine'|trans, help: 'show_rich_mention_magazine_help'|trans, settingsKey: 'MBIN_ENTRIES_SHOW_RICH_MENTION_MAGAZINE', defaultValue: true}) }}\n        {{ component('settings_row_switch', {label: 'show_rich_ap_link'|trans, help: 'show_rich_ap_link_help'|trans, settingsKey: 'MBIN_ENTRIES_SHOW_RICH_AP_LINK', defaultValue: true}) }}\n    </div>\n    <strong>{{ 'microblog'|trans }}</strong>\n    <div class=\"settings-section\">\n        {{ component('settings_row_switch', {label: 'auto_preview'|trans, help: 'auto_preview_help'|trans,  settingsKey: 'KBIN_POSTS_SHOW_PREVIEW'}) }}\n        {{ component('settings_row_switch', {label: 'show_users_avatars'|trans, help: 'show_users_avatars_help'|trans, settingsKey: 'KBIN_POSTS_SHOW_USERS_AVATARS', defaultValue: true}) }}\n        {{ component('settings_row_switch', {label: 'show_rich_mention'|trans, help: 'show_rich_mention_help'|trans, settingsKey: 'MBIN_POSTS_SHOW_RICH_MENTION', defaultValue: false}) }}\n        {{ component('settings_row_switch', {label: 'show_rich_mention_magazine'|trans, help: 'show_rich_mention_magazine_help'|trans, settingsKey: 'MBIN_POSTS_SHOW_RICH_MENTION_MAGAZINE', defaultValue: true}) }}\n        {{ component('settings_row_switch', {label: 'show_rich_ap_link'|trans, help: 'show_rich_ap_link_help'|trans, settingsKey: 'MBIN_POSTS_SHOW_RICH_AP_LINK', defaultValue: true}) }}\n    </div>\n    <strong>{{ 'single_settings'|trans }}</strong>\n    <div class=\"settings-section\">\n        {{ component('settings_row_enum', {label: 'comment_reply_position'|trans, help: 'comment_reply_position_help'|trans, settingsKey: 'KBIN_COMMENTS_REPLY_POSITION', values: [ {name: 'position_top'|trans , value: 'TOP'}, {name: 'position_bottom'|trans , value: 'BOTTOM' } ], defaultValue: 'TOP' } ) }}\n        {{ component('settings_row_switch', {label: 'show_avatars_on_comments'|trans, help: 'show_avatars_on_comments_help'|trans, settingsKey: 'KBIN_COMMENTS_SHOW_USER_AVATAR', defaultValue: true}) }}\n    </div>\n    <strong>{{ 'mod_log'|trans }}</strong>\n    <div class=\"settings-section\">\n        {{ component('settings_row_switch', {label: 'show_users_avatars'|trans, help: 'show_users_avatars_help'|trans, settingsKey: 'MBIN_MODERATION_LOG_SHOW_USER_AVATARS', defaultValue: false}) }}\n        {{ component('settings_row_switch', {label: 'show_magazines_icons'|trans, help: 'show_magazines_icons_help'|trans, settingsKey: 'MBIN_MODERATION_LOG_SHOW_MAGAZINE_ICONS', defaultValue: false}) }}\n        {{ component('settings_row_switch', {label: 'show_new_icons'|trans, help: 'show_new_icons_help'|trans, settingsKey: 'MBIN_MODERATION_LOG_SHOW_NEW_ICONS', defaultValue: true}) }}\n    </div>\n</div>\n"
  },
  {
    "path": "templates/layout/_options_font_size.html.twig",
    "content": "<div class=\"settings font-size-settings\">\n    <a href=\"{{ path('theme_settings', {key: constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_FONT_SIZE'), value: '150'}) }}\"\n       class=\"link-muted font-size {{ app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_FONT_SIZE')) is same as '150' ? 'active' : ''}}\"\n       title=\"150% {{ 'size'|trans }}\"\n       aria-label=\"150% {{ 'size'|trans }}\"\n       rel=\"nofollow\">\n        <i style=\"font-size:150%\" class=\"fa-solid fa-font\" aria-hidden=\"true\"></i>\n    </a>\n    <a href=\"{{ path('theme_settings', {key: constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_FONT_SIZE'), value: '120'}) }}\"\n       class=\"link-muted font-size {{ app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_FONT_SIZE')) is same as '120' ? 'active' : ''}}\"\n       title=\"120% {{ 'size'|trans }}\"\n       aria-label=\"120% {{ 'size'|trans }}\"\n       rel=\"nofollow\">\n        <i style=\"font-size:120%\" class=\"fa-solid fa-font\" aria-hidden=\"true\"></i>\n    </a>\n    <a href=\"{{ path('theme_settings', {key: constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_FONT_SIZE'), value: '100'}) }}\"\n       class=\"link-muted font-size {{ app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_FONT_SIZE')) is null or app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_FONT_SIZE')) is same as '100'? 'active' : ''}}\"\n       title=\"100% {{ 'size'|trans }}\"\n       aria-label=\"100% {{ 'size'|trans }}\"\n       rel=\"nofollow\">\n        <i class=\"fa-solid fa-font\" aria-hidden=\"true\"></i>\n    </a>\n    <a href=\"{{ path('theme_settings', {key: constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_FONT_SIZE'), value: '90'}) }}\"\n       class=\"link-muted font-size {{ app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_FONT_SIZE')) is same as '90' ? 'active' : ''}}\"\n       title=\"90 {{ 'size'|trans }}\"\n       aria-label=\"90% {{ 'size'|trans }}\"\n       rel=\"nofollow\">\n        <i style=\"font-size:90%\" class=\"fa-solid fa-font\" aria-hidden=\"true\"></i>\n    </a>\n</div>\n"
  },
  {
    "path": "templates/layout/_options_theme.html.twig",
    "content": "<div class=\"settings\">\n    {% set theme = app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_THEME')) %}\n    {% set theme_key = constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_THEME') %}\n    <a href=\"{{ path('theme_settings', {key: theme_key, value: 'kbin'}) }}\"\n       title=\"{{ 'kbin' }} {{ 'theme'|trans }}\"\n       aria-label=\"{{ 'kbin' }} {{ 'theme'|trans }}\"\n       rel=\"nofollow\">\n        <div class=\"theme kbin\"></div>\n    </a>\n    <a href=\"{{ path('theme_settings', {key: theme_key, value: 'light'}) }}\"\n       title=\"{{ 'light'|trans }} {{ 'theme'|trans }}\"\n       aria-label=\"{{ 'light'|trans }} {{ 'theme'|trans }}\"\n       rel=\"nofollow\">\n        <div class=\"theme light\"></div>\n    </a>\n    <a href=\"{{ path('theme_settings', {key: theme_key, value: 'dark'}) }}\"\n       title=\"{{ 'dark'|trans }} {{ 'theme'|trans }}\"\n       aria-label=\"{{ 'dark'|trans }} {{ 'theme'|trans }}\"\n       rel=\"nofollow\">\n        <div class=\"theme dark\"></div>\n    </a>\n    <a href=\"{{ path('theme_settings', {key: theme_key, value: 'solarized-light'}) }}\"\n       title=\"{{ 'solarized_light'|trans }} {{ 'theme'|trans }}\"\n       aria-label=\"{{ 'solarized_light'|trans }} {{ 'theme'|trans }}\"\n       rel=\"nofollow\">\n        <div class=\"theme solarized-light\"></div>\n    </a>\n    <a href=\"{{ path('theme_settings', {key: theme_key, value: 'solarized-dark'}) }}\"\n       title=\"{{ 'solarized_dark'|trans }} {{ 'theme'|trans }}\"\n       aria-label=\"{{ 'solarized_dark'|trans }} {{ 'theme'|trans }}\"\n       rel=\"nofollow\">\n        <div class=\"theme solarized-dark\"></div>\n    </a>\n    <a href=\"{{ path('theme_settings', {key: theme_key, value: 'tokyo-night'}) }}\"\n       title=\"{{ 'tokyo_night'|trans }} {{ 'theme'|trans }}\"\n       aria-label=\"{{ 'tokyo_night'|trans }} {{ 'theme'|trans }}\"\n       rel=\"nofollow\">\n        <div class=\"theme tokyo-night\"></div>\n    </a>\n</div>\n"
  },
  {
    "path": "templates/layout/_pagination.html.twig",
    "content": "{%- block pager_widget -%}\n    <nav class=\"pagination section\">\n        {{- block('pager') -}}\n    </nav>\n{%- endblock pager_widget -%}\n\n{%- block pager -%}\n    {# Previous Page Link #}\n    {%- if pagerfanta.hasPreviousPage() -%}\n        {%- set path = route_generator.route(pagerfanta.getPreviousPage()) -%}\n        {{- block('previous_page_link') -}}\n    {%- else -%}\n        {{- block('previous_page_link_disabled') -}}\n    {%- endif -%}\n\n    {# First Page Link #}\n    {%- if start_page > 1 -%}\n        {%- set page = 1 -%}\n        {%- set path = route_generator.route(page) -%}\n        {{- block('page_link') -}}\n    {%- endif -%}\n\n    {# Second Page Link, displays if we are on page 3 #}\n    {%- if start_page == 3 -%}\n        {%- set page = 2 -%}\n        {%- set path = route_generator.route(page) -%}\n        {{- block('page_link') -}}\n    {%- endif -%}\n\n    {# Separator, creates a \"...\" separator to limit the number of items if we are starting beyond page 3 #}\n    {%- if start_page > 3 -%}\n        {{- block('ellipsis') -}}\n    {%- endif -%}\n\n    {# Page Links #}\n    {%- for page in range(start_page, end_page) -%}\n        {%- set path = route_generator.route(page) -%}\n        {%- if page == current_page -%}\n            {{- block('current_page_link') -}}\n        {%- else -%}\n            {{- block('page_link') -}}\n        {%- endif -%}\n    {%- endfor -%}\n\n    {# Separator, creates a \"...\" separator to limit the number of items if we are over 3 pages away from the last page #}\n    {%- if end_page < (nb_pages - 2) -%}\n        {{- block('ellipsis') -}}\n    {%- endif -%}\n\n    {# Second to Last Page Link, displays if we are on the third from last page #}\n    {%- if end_page == (nb_pages - 2) -%}\n        {%- set page = (nb_pages - 1) -%}\n        {%- set path = route_generator.route(page) -%}\n        {{- block('page_link') -}}\n    {%- endif -%}\n\n    {# Last Page Link #}\n    {%- if nb_pages > end_page -%}\n        {%- set page = nb_pages -%}\n        {%- set path = route_generator.route(page) -%}\n        {{- block('page_link') -}}\n    {%- endif -%}\n\n    {# Next Page Link #}\n    {%- if pagerfanta.hasNextPage() -%}\n        {%- set path = route_generator.route(pagerfanta.getNextPage()) -%}\n        {{- block('next_page_link') -}}\n    {%- else -%}\n        {{- block('next_page_link_disabled') -}}\n    {%- endif -%}\n{%- endblock pager -%}\n\n{%- block page_link -%}\n    <a class=\"pagination__item\" href=\"{{- path -}}\">{{- page -}}</a>\n{%- endblock page_link -%}\n\n{%- block current_page_link -%}\n    <span class=\"pagination__item pagination__item--current-page\" aria-current=\"page\">{{- page -}}</span>\n{%- endblock current_page_link -%}\n\n{%- block previous_page_link -%}\n    <a class=\"pagination__item pagination__item--previous-page\" href=\"{{- path -}}\" rel=\"prev\">{{- block('previous_page_message') -}}</a>\n{%- endblock previous_page_link -%}\n\n{%- block previous_page_link_disabled -%}\n    <span class=\"pagination__item pagination__item--previous-page pagination__item--disabled\">{{- block('previous_page_message') -}}</span>\n{%- endblock previous_page_link_disabled -%}\n\n{%- block previous_page_message -%}\n    {%- if options['prev_message'] is defined -%}\n        {{- options['prev_message'] -}}\n    {%- else -%}\n        «\n    {%- endif -%}\n{%- endblock previous_page_message -%}\n\n{%- block next_page_link -%}\n    <a class=\"pagination__item pagination__item--next-page\" href=\"{{- path -}}\" rel=\"next\">{{- block('next_page_message') -}}</a>\n{%- endblock next_page_link -%}\n\n{%- block next_page_link_disabled -%}\n    <span class=\"pagination__item pagination__item--next-page pagination__item--disabled\">{{- block('next_page_message') -}}</span>\n{%- endblock next_page_link_disabled -%}\n\n{%- block next_page_message -%}\n    {%- if options['next_message'] is defined -%}\n        {{- options['next_message'] -}}\n    {%- else -%}\n        »\n    {%- endif -%}\n{%- endblock next_page_message -%}\n\n{%- block ellipsis -%}\n    <span class=\"pagination__item pagination__item--separator\">&hellip;</span>\n{%- endblock ellipsis -%}\n"
  },
  {
    "path": "templates/layout/_sidebar.html.twig",
    "content": "<div class=\"sidebar-options\" data-controller=\"options\" data-options-active-tab-value=\"none\">\n    <div class=\"section options--top options top-options\">\n        <menu>\n            <ul data-options-target=\"actions\">\n                <li class='close-button'>\n                    <a\n                            href=\"\"\n                            data-action=\"options#closeMobileSidebar:prevent\"\n                            title=\"{{ 'close'|trans }}\"\n                            aria-label=\"{{ 'close'|trans }}\">\n                        <i class=\"fa-solid fa-close\" aria-hidden=\"true\"></i>\n                    </a>\n                </li>\n                <li class='settings-button'>\n                    <a\n                    href=\"#settings\"\n                    data-action=\"options#toggleTab:prevent\"\n                    data-options-tab-param=\"settings\"\n                    title=\"{{ 'settings'|trans }}\"\n                    aria-label=\"{{ 'settings'|trans }}\">\n                        <i class=\"fa-solid fa-gear\" aria-hidden=\"true\"></i>\n                    </a>\n                </li>\n                <li class='home-button'>\n                    <a\n                            href=\"/\"\n                            title=\"{{ 'homepage'|trans }}\"\n                            aria-label=\"{{ 'homepage'|trans }}\">\n                        <i class=\"fa-solid fa-home\" aria-hidden=\"true\"></i>\n                    </a>\n                </li>\n            </ul>\n        </menu>\n    </div>\n    <div id=\"settings\" class=\"section\" data-options-target=\"settings\">\n        <h3>{{ 'theme'|trans }}</h3>\n        {% include 'layout/_options_theme.html.twig' %}\n        {% include 'layout/_options_font_size.html.twig' %}\n        {% include 'layout/_options_appearance.html.twig' %}\n    </div>\n    <div class=\"section mobile-nav\">\n        {% include 'layout/_header_bread.html.twig' %}\n        <menu class=\"info\">\n            {% include 'layout/_header_nav.html.twig' with {header_nav: header_nav} %}\n        </menu>\n    </div>\n    {% if not app.user and (is_route_name_contains('front') or is_route_name('root') or is_route_name('magazine_list_all')) %}\n        <div class=\"section intro\">\n            <div class=\"container\">\n                <h3>{{ 'kbin_intro_title'|trans }}</h3>\n                <p>{{ kbin_title() }} {{ 'kbin_intro_desc'|trans }}</p>\n            </div>\n\n            <div>\n                <a href=\"{{ path('entry_create') }}\" class=\"btn btn-link btn__primary\"\n                   type=\"submit\">{{ 'add_new_link'|trans }}</a>\n                {% if mbin_restrict_magazine_creation() is same as false %}\n                <a href=\"{{ path('magazine_create') }}\" class=\"btn btn-link btn__secondary\"\n                   type=\"submit\">{{ 'create_new_magazine'|trans }}</a>\n                {% endif %}\n            </div>\n        </div>\n    {% endif %}\n</div>\n\n{% set show_related_magazines = app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::MBIN_GENERAL_SHOW_RELATED_MAGAZINES')) %}\n{% set show_related_entries = app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::MBIN_GENERAL_SHOW_RELATED_ENTRIES')) %}\n{% set show_related_posts = app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::MBIN_GENERAL_SHOW_RELATED_POSTS')) %}\n{% set show_active_users = app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::MBIN_GENERAL_SHOW_ACTIVE_USERS')) %}\n{% set V_FALSE = constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::FALSE') %}\n\n{% if sidebar_top is empty %}\n{% else %}\n    {{ sidebar_top|raw }}\n{% endif %}\n{% if user is defined and user and is_route_name_starts_with('user') %}\n    {% include 'user/_info.html.twig' %}\n{% endif %}\n{% if app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_SUBSCRIPTIONS_SHOW')) is not same as 'false' and\n    app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_SUBSCRIPTIONS_IN_SEPARATE_SIDEBAR')) is not same as 'true' and\n    app.user is defined and app.user is not same as null %}\n    {{ component('sidebar_subscriptions', { openMagazine: magazine is defined ? magazine : null, user: app.user, sort: app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_SUBSCRIPTIONS_SORT')) }) }}\n{% endif %}\n{% if entry is defined and magazine %}\n    {% include 'entry/_info.html.twig' %}\n{% endif %}\n{% if post is defined and magazine %}\n    {% include 'post/_info.html.twig' %}\n{% endif %}\n{% if magazine is defined and magazine %}\n    {{ component('magazine_box', {\n        magazine: magazine,\n        showSectionTitle: true\n    }) }}\n    {% include 'magazine/_moderators_sidebar.html.twig' %}\n{% endif %}\n{% if tag is defined and tag %}\n    {% include 'tag/_panel.html.twig' %}\n{% endif %}\n{% if not is_route_name_contains('login') %}\n    {% if show_related_magazines is not same as V_FALSE %}\n        {{ component('related_magazines', {magazine: magazine is defined and magazine ? magazine.name : null, tag: tag is defined and tag ? tag : null}) }}\n    {% endif %}\n    {% if not is_route_name_contains('people') and show_active_users is not same as V_FALSE %}\n        {{ component('active_users', {magazine: magazine is defined and magazine ? magazine : null}) }}\n    {% endif %}\n    {% if show_related_posts is not same as V_FALSE %}\n        {{ component('related_posts', {magazine: magazine is defined and magazine ? magazine.name : null, tag: tag is defined and tag ? tag : null}) }}\n    {% endif%}\n    {% if show_related_entries is not same as V_FALSE %}\n        {{ component('related_entries', {magazine: magazine is defined and magazine ? magazine.name : null, tag: tag is defined and tag ? tag : null}) }}\n    {% endif%}\n{% endif %}\n<section class=\"about section\">\n    <h3>{{ kbin_domain() }}</h3>\n    <div class=\"container\">\n        <ul>\n            <li><a href=\"{{ path('about') }}\">{{ 'about_instance'|trans }}</a></li>\n            <li aria-hidden=\"true\">&#9679;</li>\n            <li><a href=\"{{ path('page_contact') }}\">{{ 'contact'|trans }}</a></li>\n            <li aria-hidden=\"true\">&#9679;</li>\n            <li><a href=\"{{ path('page_faq') }}\">{{ 'faq'|trans }}</a></li>\n            <li aria-hidden=\"true\">&#9679;</li>\n            <li><a href=\"{{ path('page_terms') }}\">{{ 'terms'|trans }}</a></li>\n            <li aria-hidden=\"true\">&#9679;</li>\n            <li><a href=\"{{ path('page_privacy_policy') }}\">{{ 'privacy_policy'|trans }}</a></li>\n            <li aria-hidden=\"true\">&#9679;</li>\n            {% if kbin_federation_page_enabled() %}\n                <li><a href=\"{{ path('page_federation') }}\">{{ 'federation'|trans }}</a></li>\n                <li aria-hidden=\"true\">&#9679;</li>\n            {% endif %}\n            <li><a href=\"{{ path('modlog') }}\">{{ 'mod_log'|trans }}</a></li>\n            <li aria-hidden=\"true\">&#9679;</li>\n            <li><a href=\"{{ path('stats') }}\">{{ 'stats'|trans }}</a></li>\n            <li aria-hidden=\"true\">&#9679;</li>\n            {% if magazine is defined and magazine %}\n                {% set args = {'magazine': magazine.name ?? '' } %}\n            {% elseif user is defined and user %}\n                {% set args = {'user': user.username ?? '' } %}\n            {% elseif tag is defined and tag %}\n                {% set args = {'tag': tag ?? '' } %}\n            {% elseif domain is defined and domain %}\n                {% set args = {'domain': domain.name ?? '' } %}\n            {% else %}\n                {% set args = {} %}\n            {% endif %}\n\n            {% if criteria is defined and criteria %}\n                {% if criteria.getOption('content') is same as 'threads' %}\n                    {% set args = {...args, 'content': 'threads'} %}\n                {% elseif criteria.getOption('content') is same as 'microblog' %}\n                    {% set args = {...args, 'content': 'microblog'} %}\n                {% elseif criteria.getOption('content') is same as 'combined' %}\n                    {% set args = {...args, 'content': 'combined'} %}\n                {% endif %}\n            {% endif %}\n            <li>\n                <a href=\"{{ path('feed_rss', args) }}\">{{ 'rss'|trans }}</a>\n            </li>\n        </ul>\n        <div class=\"about-seperator\"></div>\n        <ul class=\"about-mbin\">\n            <li>Powered by <a href=\"https://github.com/MbinOrg/mbin\" target=\"_blank\" rel=\"noopener noreferrer nofollow\">Mbin</a></li>\n            <li aria-hidden=\"true\">&#9679;</li>\n            <li>{{ mbin_current_version() }}</li>\n            <li aria-hidden=\"true\">&#9679;</li>\n            <li><a href=\"https://github.com/MbinOrg/mbin/issues\" target=\"_blank\" rel=\"noopener noreferrer nofollow\">{{ 'report_issue'|trans }}</a></li>\n        </ul>\n        <ul class=\"about-options\">\n            {% set header_accept_language = app.request.headers.has('accept_language')\n                ? app.request.headers.get('accept_language')|slice(0,2)\n                : null %}\n            {% set current = app.request.cookies.get('mbin_lang') ?? header_accept_language ?? kbin_default_lang() %}\n            <li>\n                <select data-action=\"mbin#changeLang\">\n                    {% for code in ['bg', 'ca', 'da', 'de', 'el', 'en', 'eo', 'es', 'fil', 'fr', 'gl', 'it', 'ja', 'nl', 'pl', 'pt', 'pt_BR', 'ru', 'tr', 'uk', 'zh_TW'] %}\n                        <option value=\"{{ code }}\" {{ code is same as current ? 'selected' : '' }}>{{ code|language_name(code) }}</option>\n                    {% endfor %}\n                </select>\n            </li>\n        </ul>\n    </div>\n</section>\n<div class=\"section section--no-bg kbin-promo\">\n    <img height=\"47\" loading=\"lazy\" src=\"{{ asset('favicon.svg') }}\" alt=\"Clone repo\">\n    <div>\n        <h4>{{ 'kbin_promo_title'|trans }}</h4>\n        <p>{{ 'kbin_promo_desc'|trans({\n                '%link_start%': '<a href=\"https://github.com/MbinOrg/mbin\" target=\"_blank\" rel=\"noopener noreferrer nofollow\" class=\"stretched-link\">',\n                '%link_end%': '</a>'\n            })|raw }}</p>\n    </div>\n</div>\n"
  },
  {
    "path": "templates/layout/_subject.html.twig",
    "content": "{% if attributes is not defined %}\n    {% set attributes = {} %}\n{% endif %}\n{% if entryCommentAttributes is not defined %}\n    {% set entryCommentAttributes = {} %}\n{% endif %}\n{% if entryAttributes is not defined %}\n    {% set entryAttributes = {} %}\n{% endif %}\n{% if postAttributes is not defined %}\n    {% set postAttributes = {} %}\n{% endif %}\n{% if postCommentAttributes is not defined %}\n    {% set postCommentAttributes = {} %}\n{% endif %}\n{% if magazineAttributes is not defined %}\n    {% set magazineAttributes = {} %}\n{% endif %}\n{% if userAttributes is not defined %}\n    {% set userAttributes = {} %}\n{% endif %}\n\n{% set forCombined = (route_param_exists('content') and get_route_param('content') is same as 'combined')\n    or (criteria is defined and criteria.getOption('content') is same as 'combined') %}\n\n{% if subject is entry %}\n    {{ component('entry', {entry: subject}|merge(attributes)|merge(entryAttributes)) }}\n{% elseif subject is entry_comment %}\n    {{ component('entry_comment', {comment: subject, showEntryTitle: forCombined is same as true}|merge(attributes)|merge(entryCommentAttributes)) }}\n{% elseif subject is post %}\n    {% if forCombined is same as true %}\n        {{ component('post_combined', {post: subject}|merge(attributes)|merge(postAttributes)) }}\n    {% else %}\n        {{ component('post', {post: subject}|merge(attributes)|merge(postAttributes)) }}\n    {% endif %}\n{% elseif subject is post_comment %}\n    {% if forCombined is same as true %}\n        {{ component('post_comment_combined', {comment: subject}|merge(attributes)|merge(postCommentAttributes)) }}\n    {% else %}\n        {{ component('post_comment', {comment: subject}|merge(attributes)|merge(postCommentAttributes)) }}\n    {% endif %}\n{% elseif subject is magazine %}\n    {{ component('magazine_box', {magazine: subject}|merge(attributes, magazineAttributes)) }}\n{% elseif subject is user %}\n    {{ component('user_inline_box', {user: subject}|merge(attributes, userAttributes)) }}\n{% endif %}\n"
  },
  {
    "path": "templates/layout/_subject_link.html.twig",
    "content": "{%- if subject is entry -%}\n    <a href=\"{{ entry_url(subject) }}\">{{ subject.shortTitle }}</a>\n{%- elseif subject is entry_comment -%}\n    <a href=\"{{ entry_comment_view_url(subject) }}#{{ get_url_fragment(subject) }}\">{{ subject.shortTitle }}</a>\n{%- elseif subject is post -%}\n    <a href=\"{{ post_url(subject) }}\">{{ subject.shortTitle }}</a>\n{%- elseif subject is post_comment -%}\n    <a href=\"{{ post_url(subject.post) }}#{{ get_url_fragment(subject) }}\">{{ subject.shortTitle }}</a>\n{%- endif -%}\n"
  },
  {
    "path": "templates/layout/_subject_list.html.twig",
    "content": "{% if attributes is not defined %}\n    {% set attributes = {} %}\n{% endif %}\n{% if entryCommentAttributes is not defined %}\n    {% set entryCommentAttributes = {} %}\n{% endif %}\n{% if entryAttributes is not defined %}\n    {% set entryAttributes = {} %}\n{% endif %}\n{% if postAttributes is not defined %}\n    {% set postAttributes = {} %}\n{% endif %}\n{% if postCommentAttributes is not defined %}\n    {% set postCommentAttributes = {} %}\n{% endif %}\n\n{% for subject in results %}\n    {% include 'layout/_subject.html.twig' %}\n{% endfor %}\n{% if pagination is defined and pagination %}\n    {% if(pagination.haveToPaginate is defined and pagination.haveToPaginate) %}\n        {% if app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_GENERAL_INFINITE_SCROLL')) is same as 'true' %}\n            <div data-controller=\"infinite-scroll\" class=\"infinite-scroll\">\n                {{ component('loader', {'data-infinite-scroll-target': 'loader'}) }}\n                <div data-infinite-scroll-target=\"pagination\" class=\"visually-hidden\">\n                    {{ pagerfanta(pagination, null, {'pageParameter':'[p]'}) }}\n                </div>\n            </div>\n        {% elseif pagination.getCurrentCursor is defined %}\n            {{ component('cursor_pagination', {'pagination': pagination}) }}\n        {% else %}\n            {{ pagerfanta(pagination, null, {'pageParameter':'[p]'}) }}\n        {% endif %}\n    {% endif %}\n{% else %}\n    {% if(results.haveToPaginate is defined and results.haveToPaginate) %}\n        {% if app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_GENERAL_INFINITE_SCROLL')) is same as 'true' %}\n            <div data-controller=\"infinite-scroll\" class=\"infinite-scroll\">\n                {{ component('loader', {'data-infinite-scroll-target': 'loader'}) }}\n                <div data-infinite-scroll-target=\"pagination\" class=\"visually-hidden\">\n                    {% if results.getCurrentCursor is defined %}\n                        {{ component('cursor_pagination', {'pagination': results}) }}\n                    {% else %}\n                        {{ pagerfanta(results, null, {'pageParameter':'[p]'}) }}\n                    {% endif %}\n                </div>\n            </div>\n        {% elseif results.getCurrentCursor is defined %}\n            {{ component('cursor_pagination', {'pagination': results}) }}\n        {% else %}\n            {{ pagerfanta(results, null, {'pageParameter':'[p]'}) }}\n        {% endif %}\n    {% endif %}\n{% endif %}\n{% if not results|length %}\n    <aside class=\"section section--muted\">\n        <p>{{ 'empty'|trans }}</p>\n    </aside>\n{% endif %}\n"
  },
  {
    "path": "templates/layout/_topbar.html.twig",
    "content": "{% set show_topbar = app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_GENERAL_TOPBAR')) %}\n{% set V_TRUE = constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::TRUE') %}\n{% if show_topbar is same as V_TRUE %}\n    <div id=\"topbar\">\n        <menu>\n            <li class=\"{{ html_classes({'active': (is_route_name('front') and not criteria.subscribed and not criteria.moderated and not criteria.favourite) or (is_route_name('root') and not criteria.subscribed and not criteria.moderated and not criteria.favourite) or is_route_name('posts_front') or is_route_name('people_front')}) }}\">\n                <a class=\"stretched-link\" href=\"/all\">{{ 'all'|trans }}</a>\n            </li>\n            <li class=\"{{ html_classes({'active': (is_route_name('front') and criteria.subscribed) or (is_route_name('root') and criteria.subscribed) }) }}\">\n                <a class=\"stretched-link\" href=\"/sub/\">{{ 'subscribed'|trans }}</a>\n            </li>\n            <li class=\"{{ html_classes({'active': (is_route_name('front') and criteria.moderated) or (is_route_name('root') and criteria.moderated) }) }}\">\n                <a class=\"stretched-link\" href=\"/mod/\">{{ 'moderated'|trans }}</a>\n            </li>\n            <li class=\"{{ html_classes({'active': (is_route_name('front') and criteria.favourite) or (is_route_name('root') and criteria.favourite) }) }}\">\n                <a class=\"stretched-link\" href=\"/fav/\">{{ 'favourites'|trans }}</a>\n            </li>\n            <li>\n                <a class=\"stretched-link\" href=\"#\">•</a>\n            </li>\n        </menu>\n        {{ component('featured_magazines', {magazine: magazine is defined and magazine ? magazine : null}) }}\n        <menu>\n            <li class=\"{{ html_classes({'active': is_route_name('magazine_list_all')}) }}\">\n                <a class=\"stretched-link\" href=\"{{ path('magazine_list_all') }}\">{{ 'all_magazines'|trans }}</a></li>\n        </menu>\n    </div>\n{% endif %}\n"
  },
  {
    "path": "templates/layout/_user_activity_list.html.twig",
    "content": "{%- set V_TRUE = constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::TRUE') -%}\n{%- set V_FALSE = constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::FALSE') -%}\n{%- set SHOW_USER_FULLNAME = app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::MBIN_SHOW_USER_DOMAIN'), V_FALSE) -%}\n{% if actor is not defined %}\n    {% set actor = 'user' %}\n{% endif %}\n\n{% if list|length %}\n    <div class=\"section users users-columns\">\n        <ul>\n            {% for subject in list %}\n                <li>\n                    {% if attribute(subject, actor).avatar %}\n                        {{ component('user_avatar', {user: attribute(subject, actor) }) }}\n                    {% endif %}\n                    <div>\n                        <a href=\"{{ path('user_overview', {username: attribute(subject, actor).username}) }}\"\n                           class=\"stretched-link\">\n                            {{ attribute(subject, actor).username|username }}\n                            {%- if SHOW_USER_FULLNAME is same as V_TRUE -%}\n                                @{{- attribute(subject, actor).username|apDomain -}}\n                            {%- endif -%}\n                        </a>\n                        <small>{{ component('date', {date: subject.createdAt}) }}</small>\n                    </div>\n                </li>\n            {% endfor %}\n        </ul>\n    </div>\n    {% if(list.haveToPaginate is defined and list.haveToPaginate) %}\n        {{ pagerfanta(list, null, {'pageParameter':'[p]'}) }}\n    {% endif %}\n{% else %}\n    <aside class=\"section section--muted\">\n        <p>{{ 'empty'|trans }}</p>\n    </aside>\n{% endif %}\n"
  },
  {
    "path": "templates/layout/sidebar_subscriptions.html.twig",
    "content": "{% with %}\r\n{% set V_TRUE = constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::TRUE') %}\r\n{% set V_FALSE = constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::FALSE') %}\r\n{% set V_LEFT = constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::LEFT') %}\r\n{% set KBIN_SUBSCRIPTIONS_IN_SEPARATE_SIDEBAR = constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_SUBSCRIPTIONS_IN_SEPARATE_SIDEBAR') %}\r\n{% set KBIN_SUBSCRIPTIONS_SIDEBARS_SAME_SIDE = constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_SUBSCRIPTIONS_SIDEBARS_SAME_SIDE') %}\r\n{% set KBIN_GENERAL_SIDEBAR_POSITION = constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_GENERAL_SIDEBAR_POSITION') %}\r\n{% set KBIN_SUBSCRIPTIONS_LARGE_PANEL = constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_SUBSCRIPTIONS_LARGE_PANEL') %}\r\n{% set KBIN_SUBSCRIPTIONS_SHOW_MAGAZINE_ICON = constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_SUBSCRIPTIONS_SHOW_MAGAZINE_ICON') %}\r\n{% set SHOW_MAGAZINE_FULLNAME = app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::MBIN_SHOW_MAGAZINE_DOMAIN'), V_FALSE) %}\r\n{# for whatever reason doing {% set TRUE = ... %} would crash #}\r\n<aside class=\"{{ html_classes('sidebar-subscriptions', { 'inline': app.request.cookies.get(KBIN_SUBSCRIPTIONS_IN_SEPARATE_SIDEBAR) is not same as V_TRUE }) }}\">\r\n    <section\r\n        class=\"{{ html_classes('section', app.request.cookies.get(KBIN_SUBSCRIPTIONS_IN_SEPARATE_SIDEBAR) is not same as V_TRUE ? 'inline' : 'section--top') }}\"\r\n        data-controller=\"subs-panel\"\r\n        data-subs-panel-sidebar-position-value=\"{{ app.request.cookies.get(KBIN_GENERAL_SIDEBAR_POSITION) }}\"\r\n    >\r\n        <h3>\r\n            {{ 'subscription_header'|trans }}\r\n            <span class=\"sidebar-subscriptions-icons\">\r\n                {% if app.request.cookies.get(KBIN_SUBSCRIPTIONS_IN_SEPARATE_SIDEBAR) is same as V_TRUE %}\r\n                    <a href=\"#\"\r\n                        style=\"margin-right: .5em;\"\r\n                        title=\"{{ 'subscription_sidebar_pop_in'|trans }}\"\r\n                        aria-label=\"{{ 'subscription_sidebar_pop_in'|trans }}\"\r\n                        data-action=\"subs-panel#reattach\"\r\n                    >\r\n                        {% if app.request.cookies.get(KBIN_GENERAL_SIDEBAR_POSITION) is same as V_LEFT\r\n                                and app.request.cookies.get(KBIN_SUBSCRIPTIONS_SIDEBARS_SAME_SIDE) is same as V_TRUE\r\n                            or app.request.cookies.get(KBIN_GENERAL_SIDEBAR_POSITION) is not same as V_LEFT\r\n                                and app.request.cookies.get(KBIN_SUBSCRIPTIONS_SIDEBARS_SAME_SIDE) is not same as V_TRUE %}\r\n                            <i class=\"fa-solid fa-arrow-right move-subscription-right\" aria-hidden=\"true\"></i>\r\n                        {% else %}\r\n                            <i class=\"fa-solid fa-arrow-left move-subscription-left\" aria-hidden=\"true\"></i>\r\n                        {% endif %}\r\n                    </a>\r\n                {% endif %}\r\n                {% if app.request.cookies.get(KBIN_SUBSCRIPTIONS_IN_SEPARATE_SIDEBAR) is not same as V_TRUE %}\r\n                    <a href=\"#\"\r\n                        title=\"{{ 'subscription_sidebar_pop_out_left'|trans }}\"\r\n                        aria-label=\"{{ 'subscription_sidebar_pop_out_left'|trans }}\"\r\n                        data-action=\"subs-panel#popLeft\"\r\n                    >\r\n                       <i class=\"fa-solid fa-arrow-left move-subscription-left\" aria-hidden=\"true\"></i>\r\n                    </a>\r\n                    <a href=\"#\"\r\n                        title=\"{{ 'subscription_sidebar_pop_out_right'|trans }}\"\r\n                        aria-label=\"{{ 'subscription_sidebar_pop_out_right'|trans }}\"\r\n                        data-action=\"subs-panel#popRight\"\r\n                    >\r\n                        <i class=\"fa-solid fa-arrow-right move-subscription-right\" aria-hidden=\"true\"></i>\r\n                    </a>\r\n                {% endif %}\r\n            </span>\r\n        </h3>\r\n        <div>\r\n            <ul class=\"{{ html_classes('subscription-list', 'meta', { 'lg': app.request.cookies.get(KBIN_SUBSCRIPTIONS_LARGE_PANEL) is same as V_TRUE }) }}\">\r\n                {% for magazine in magazines %}\r\n                    <li class=\"{{ html_classes('subscription', { 'active': openMagazine and openMagazine.name is same as magazine.name }) }}\">\r\n                        <a class=\"stretched-link\" href=\"{{ path('front_magazine', {name: magazine.name}) }}\">\r\n\r\n                            {% if magazine.icon %}\r\n                                <img loading=\"lazy\"\r\n                                    src=\"{{ magazine.icon.filePath ? (asset(magazine.icon.filePath)|imagine_filter('avatar_thumb')) : magazine.icon.sourceUrl }}\"\r\n                                    alt=\"{{ magazine.name }}'s icon\"\r\n                                    class=\"{{ html_classes('image-inline', 'magazine-subscription-avatar', { 'onlyMobile': app.request.cookies.get(KBIN_SUBSCRIPTIONS_SHOW_MAGAZINE_ICON) is same as V_FALSE }) }}\" />\r\n                            {% else %}\r\n                                <span class=\"{{ html_classes('magazine-subscription-avatar-placeholder', { 'onlyMobile': app.request.cookies.get(KBIN_SUBSCRIPTIONS_SHOW_MAGAZINE_ICON) is same as V_FALSE }) }}\"></span>\r\n                            {% endif %}\r\n\r\n                            <span class=\"{{ html_classes('magazine-name', { 'has-image': magazine.icon, 'onlyMobile': app.request.cookies.get(KBIN_SUBSCRIPTIONS_SHOW_MAGAZINE_ICON) is same as V_FALSE }) }}\">\r\n                                <span>{{ magazine.title ?? magazine.name }}</span>\r\n                                {%- if SHOW_MAGAZINE_FULLNAME is same as V_TRUE -%}\r\n                                    <span>@{{- magazine.name|apDomain -}}</span>\r\n                                {%- endif -%}\r\n                            </span>\r\n                        </a>\r\n                    </li>\r\n                {% endfor %}\r\n                {% if tooManyMagazines %}\r\n                    <div class=\"subscription\">\r\n                        <a href=\"{{ path('user_subscriptions', {username: app.user.username}) }}\">\r\n                            <button class=\"btn btn__secondary\">\r\n                                {{ 'show_more'|trans }}\r\n                            </button>\r\n                        </a>\r\n                    </div>\r\n                {% endif %}\r\n            </ul>\r\n        </div>\r\n    </section>\r\n</aside>\r\n{% endwith %}\r\n"
  },
  {
    "path": "templates/magazine/_federated_info.html.twig",
    "content": "{% if magazine.apId %}   {# I.e. if we're federated #}\n    {% if entries is defined and entries and not entries.hasNextPage %}\n        {# Then show a link to original if we're at the end of content #}\n        <div class=\"alert alert__info\">\n            <p>\n                {{ 'federated_magazine_info'|trans }} <a href=\"{{ magazine.apProfileId }}\" rel=\"noopener noreferrer nofollow\" target=\"_blank\"><span>{{ 'go_to_original_instance'|trans }}</span> <i class=\"fa-solid fa-external-link\" aria-hidden=\"true\"></i></a>\n            </p>\n        </div>\n    {% endif %}\n\n    {% if is_instance_of_magazine_blocked(magazine) %}\n        <div class=\"alert alert__info\">\n            {{ 'magazine_instance_defederated_info'|trans }}\n        </div>\n    {% elseif not magazine_has_local_subscribers(magazine) %}\n        {# Also show a warning if we're not actively receiving updates #}\n        {% set lastOriginUpdate = magazine.lastOriginUpdate %}\n        <div class=\"alert alert__info\">\n            {% if lastOriginUpdate is not null %}\n                {% set currentTime = \"now\"|date('U') %}\n                {% set secondsDifference = currentTime - (lastOriginUpdate|date('U')) %}\n                {% set daysDifference = (secondsDifference / 86400)|round(0, 'floor') %}\n                <p>\n                    {{ 'disconnected_magazine_info'|trans({'%days%': daysDifference}) }}\n                    {% if app.user %}\n                      {{ 'subscribe_for_updates'|trans }}\n                    {% endif %}\n                </p>\n            {% else %}\n                <p>\n                    {{ 'always_disconnected_magazine_info'|trans }}\n                    {% if app.user %}\n                        {{ 'subscribe_for_updates'|trans }}\n                    {% endif %}\n                </p>\n            {% endif %}\n        </div>\n    {% endif %}\n{% endif %}\n"
  },
  {
    "path": "templates/magazine/_list.html.twig",
    "content": "{%- set V_TRUE = constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::TRUE') -%}\n{%- set V_FALSE = constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::FALSE') -%}\n{%- set SHOW_MAGAZINE_FULLNAME = app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::MBIN_SHOW_MAGAZINE_DOMAIN'), V_FALSE) -%}\n{% if magazines|length %}\n    <div class=\"section\">\n        {% if view is same as 'cards'|trans|lower %}\n            <div class=\"magazines magazines-cards\">\n                {% for magazine in magazines %}\n                    {{ component('magazine_box', {magazine: magazine, showMeta: false, showInfo: false}) }}\n                {% endfor %}\n            </div>\n        {% elseif view is same as 'columns'|trans|lower %}\n            <div class=\"magazines magazines-columns\">\n                <ul>\n                    {% for magazine in magazines %}\n                        <li>\n                            {% if magazine.icon and (app.user or magazine.isAdult is same as false) %}\n                                <figure>\n                                    <img width=\"32\" height=\"32\"\n                                         class=\"image-inline {{ magazine.isAdult ? 'image-adult' : '' }}\"\n                                         src=\"{{ magazine.icon.filePath ? (asset(magazine.icon.filePath)|imagine_filter('avatar_thumb')) : magazine.icon.sourceUrl }}\"\n                                         {% if magazine.isAdult %}data-controller=\"thumb\" data-action=\"mouseover->thumb#adultImageHover mouseout->thumb#adultImageHoverOut\"{% endif %}\n                                         alt=\"{{ magazine.name ~' '~ 'icon'|trans|lower }}\">\n                                </figure>\n                            {% endif %}\n                            <div>\n                                <a href=\"{{ path('front_magazine', {name: magazine.name}) }}\"\n                                   class=\"stretched-link\">\n                                    {{ magazine.name }}\n                                    {%- if SHOW_MAGAZINE_FULLNAME is same as V_TRUE -%}\n                                        @{{- magazine.name|apDomain -}}\n                                    {%- endif -%}\n                                    {% if magazine.isAdult %}<small class=\"badge danger\">18+</small>{% endif %}\n                                </a>\n                                <small>{{ component('date', {date: magazine.createdAt}) }}</small>\n                            </div>\n                        </li>\n                    {% endfor %}\n                </ul>\n            </div>\n        {% else %}\n            {% set sortBy = criteria.sortOption %}\n            <div class=\"magazines table-responsive\">\n                <table>\n                    <thead>\n                    <tr>\n                        <th>{{ 'name'|trans }}</th>\n                        {% for column in ['threads', 'comments', 'posts'] %}\n                            <th>\n                                {% if sortBy is same as column %}\n                                    <span aria-sort=\"descending\">{{ column|trans }}</span>\n                                    <i class=\"fa-solid fa-sort-amount-desc\" aria-hidden=\"true\"></i>\n                                {% else %}\n                                    <a href=\"{{ options_url('sortBy', column) }}\">\n                                        <span>{{ column|trans }}</span>\n                                    </a>\n                                {% endif %}\n                            </th>\n                        {% endfor %}\n                        <th style=\"text-align: center\">\n                            {% if sortBy is same as 'hot' %}\n                                <span aria-sort=\"descending\">{{ 'subscriptions'|trans }}</span>\n                                <i class=\"fa-solid fa-sort-amount-desc\" aria-hidden=\"true\"></i>\n                            {% else %}\n                                <a href=\"{{ options_url('sortBy', 'hot') }}\">\n                                    <span>{{ 'subscriptions'|trans }}</span>\n                                </a>\n                            {% endif %}\n                        </th>\n                    </tr>\n                    </thead>\n                    <tbody>\n                    {% for magazine in magazines %}\n                        <tr>\n                            <td>\n                                {{ component('magazine_inline', { magazine: magazine, stretchedLink: true, showAvatar: true, showNewIcon: true, fullName: SHOW_MAGAZINE_FULLNAME is same as V_TRUE}) }}\n                                {% if magazine.isAdult %}<small class=\"badge danger\">18+</small>{% endif %}\n                            </td>\n                            <td>{{ magazine.entryCount|abbreviateNumber }}</td>\n                            <td>{{ magazine.entryCommentCount|abbreviateNumber }}</td>\n                            <td>{{ (magazine.postCount + magazine.postCommentCount)|abbreviateNumber }}</td>\n                            <td>{{ component('magazine_sub', {magazine: magazine}) }}</td>\n                        </tr>\n                    {% endfor %}\n                    </tbody>\n                </table>\n            </div>\n            <div class=\"magazines magazine-list-mobile\">\n                <div class=\"magazines__sortby flex-wrap\">\n                    {% for column in ['threads', 'comments', 'posts', 'hot'] %}\n                        <span>\n                            <a href=\"{{ options_url('sortBy', column) }}\" class=\"btn {{ (sortBy is same as column) ? 'btn__primary' : 'btn__secondary' }}\">\n\n                                {% if(column is same as 'hot')%}\n                                    {{ 'subscriptions'|trans }}\n                                {% else %}\n                                    {{ column|trans }}\n                                {% endif %}\n\n                                {% if sortBy is same as column %}\n                                    <i class=\"fa-solid fa-sort-amount-desc\" aria-hidden=\"true\"></i>\n                                {% endif %}\n                            </a>\n\n                        </span>\n                    {% endfor %}\n                </div>\n                {% for magazine in magazines %}\n                        <div class=\"magazine\">\n                            <div class=\"magazine__top\">\n                                <div class=\"magazine__inline\">{{ component('magazine_inline', { magazine: magazine, stretchedLink: true, showAvatar: true, showNewIcon: true, fullName: SHOW_MAGAZINE_FULLNAME is same as V_TRUE}) }} {% if magazine.isAdult %}<small class=\"badge danger\">18+</small>{% endif %}</div>\n                                <div class=\"magazine__information\">\n                                    <span class=\"magazine__info\"><span class=\"value\">{{ magazine.entryCount|abbreviateNumber }}</span><span class=\"label\">{{ 'threads'|trans }}</span></span>\n                                    <span class=\"magazine__info\"><span class=\"value\">{{ magazine.entryCommentCount|abbreviateNumber }}</span><span class=\"label\">{{ 'comments'|trans }}</span></span>\n                                    <span class=\"magazine__info\"><span class=\"value\">{{ (magazine.postCount + magazine.postCommentCount)|abbreviateNumber }}</span><span class=\"label\">{{ 'posts'|trans }}</span></span>\n                                </div>\n                                <div class=\"magazine__sub\">{{ component('magazine_sub', {magazine: magazine}) }}</div>\n                            </div>\n\n                        </div>\n                {% endfor %}\n            </div>\n        {% endif %}\n    </div>\n    {% if(magazines.haveToPaginate is defined and magazines.haveToPaginate) %}\n        {{ pagerfanta(magazines, null, {'pageParameter':'[p]'}) }}\n    {% endif %}\n{% else %}\n    <aside class=\"section section--muted\">\n        <p>{{ 'empty'|trans }}</p>\n    </aside>\n{% endif %}\n"
  },
  {
    "path": "templates/magazine/_moderators_list.html.twig",
    "content": "<div id=\"content\" class=\"section users users-columns\">\n    <ul>\n        {% for moderator in moderators %}\n            <li>\n                {% if moderator.user.avatar %}\n                    {{ component('user_avatar', {user: moderator.user}) }}\n                {% endif %}\n                <div>\n                    <a href=\"{{ path('user_overview', {username: moderator.user.username}) }}\">\n                        {{ moderator.user.username|username(true) }}\n                    </a>\n                    <small>{{ component('date', {date: moderator.createdAt}) }}</small>\n                </div>\n                {% if is_granted('edit', magazine) and not moderator.isOwner and (magazine.apId is same as null or moderator.user.apId is same as null) %}\n                    <div class=\"actions\">\n                        <form method=\"post\"\n                              action=\"{{ path('magazine_panel_moderator_purge', {magazine_name: magazine.name, moderator_id: moderator.id}) }}\"\n                              data-action=\"confirmation#ask\" data-confirmation-message-param=\"{{ 'are_you_sure'|trans }}\">\n                            <input type=\"hidden\" name=\"token\" value=\"{{ csrf_token('remove_moderator') }}\">\n                            <button type=\"submit\" class=\"btn btn__secondary\">{{ 'delete'|trans }}</button>\n                        </form>\n                    </div>\n                {% endif %}\n            </li>\n        {% endfor %}\n    </ul>\n</div>\n"
  },
  {
    "path": "templates/magazine/_moderators_sidebar.html.twig",
    "content": "<section class=\"user-list section\">\n    <h3 style=\"display: flex;justify-content: space-between;margin:0;\">\n        {{ 'moderators'|trans }}\n        {% if app.user and magazine.apId is same as null and app.user.visibility is same as 'visible' %}\n        <a href=\"{{ path('magazine_moderators', {name: magazine.name}) }}\" title=\"{{ 'apply_for_moderator'|trans }}\" aria-label=\"{{ 'apply_for_moderator'|trans }}\">\n            <i class=\"fa-solid fa-hand-point-up\" aria-hidden=\"true\"></i>\n                {% if is_granted('edit', magazine) and magazine.moderatorRequests|length %}\n                    <small>({{ magazine.moderatorRequests|length }})</small>\n                {% endif %}\n        </a>\n        {% endif %}\n    </h3>\n    <ul>\n        {% for moderator in magazine.moderators|slice(0, 5) %}\n            <li class=\"moderator-item\">\n                {{ component('user_inline', { user: moderator.user, showNewIcon: true }) }}\n            </li>\n        {% endfor %}\n    </ul>\n    {% if magazine.moderators|length > 5 %}\n        <footer>\n            <a href=\"{{ path('magazine_moderators', {name: magazine.name}) }}\"\n               class=\"stretched-link\">{{ 'show_more'|trans }}</a>\n        </footer>\n    {% endif %}\n</section>\n"
  },
  {
    "path": "templates/magazine/_options.html.twig",
    "content": "<aside class=\"options options--top\" id=\"options\">\n    <menu class=\"options__main\">\n        <li>\n            <a href=\"{{ options_url('sortBy', 'newest', 'magazine_list_all') }}\"\n               class=\"{{ html_classes({'active': route_has_param('sortBy', 'newest')}) }}\">\n                {{ 'newest'|trans }}\n            </a>\n        </li>\n        <li>\n            <a href=\"{{ options_url('sortBy', 'hot', 'magazine_list_all') }}\"\n               class=\"{{ html_classes({'active': route_has_param('sortBy', 'hot')}) }}\">\n                {{ 'hot'|trans }}\n            </a>\n        </li>\n        <li>\n            <a href=\"{{ options_url('sortBy', 'active', 'magazine_list_all') }}\"\n               class=\"{{ html_classes({'active': route_has_param('sortBy', 'active')}) }}\">\n                {{ 'active'|trans }}\n            </a>\n        </li>\n        {% if app.user %}\n        <li>\n            <a href=\"{{ path('magazine_abandoned') }}\"\n               class=\"{{ html_classes({'active': is_route_name('magazine_abandoned')}) }}\">\n                {{ 'abandoned'|trans }}\n            </a>\n        </li>\n        {% endif %}\n    </menu>\n    <menu class=\"options__view\">\n        <li class=\"dropdown\">\n            <button aria-label=\"{{ 'change_view'|trans }}\"\n                    title=\"{{ 'change_view'|trans }}\"><i\n                        class=\"fa-solid fa-layer-group\" aria-hidden=\"true\"></i>\n            </button>\n            <ul class=\"dropdown__menu\">\n                <li><a class=\"{{ html_classes({'active': route_has_param('view', 'table')}) }}\"\n                       href=\"{{ options_url('view', 'table') }}\">{{ 'table_view'|trans }}</a></li>\n                <li>\n                    <a class=\"{{ html_classes({'active': route_has_param('view', 'cards')}) }}\"\n                       href=\"{{ options_url('view', 'cards') }}\">{{ 'cards_view'|trans }}</a>\n                </li>\n            </ul>\n        </li>\n    </menu>\n</aside>\n"
  },
  {
    "path": "templates/magazine/_restricted_info.html.twig",
    "content": "{% if magazine.postingRestrictedToMods and (app.user is not defined or app.user is same as null or magazine.isActorPostingRestricted(app.user)) %}\n    <div class=\"alert alert__info\">\n        {{ 'magazine_posting_restricted_to_mods_warning'|trans }}\n    </div>\n{% endif %}\n"
  },
  {
    "path": "templates/magazine/_visibility_info.html.twig",
    "content": "{% if magazine.visibility is same as 'soft_deleted' %}\n    <div class=\"alert alert__danger\">\n        <p>{{ 'magazine_is_deleted'|trans({\n                '%link_target%': path('magazine_panel_general', {'name': magazine.name})\n            })|raw }}</p>\n    </div>\n{% endif %}"
  },
  {
    "path": "templates/magazine/create.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    {{- 'create_new_magazine'|trans }} - {{ parent() -}}\n{%- endblock -%}\n\n{% block mainClass %}page-entry-create page-entry-create-link{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n{% endblock %}\n\n{% block body %}\n    {% include 'entry/_create_options.html.twig' %}\n    {% include('user/_visibility_info.html.twig') %}\n\n    {% if user.visibility is same as 'visible' %}\n    <header>\n        <h1 hidden>{{ 'create_new_magazine'|trans }}</h1>\n    </header>\n    <div id=\"content\" class=\"section\">\n        <div class=\"container\">\n            {{ form_start(form) }}\n            {{ form_row(form.name, {label: 'name', attr: {\n                placeholder: '/m/',\n                'data-controller': 'input-length',\n                'data-action' : 'input-length#updateDisplay',\n                'data-input-length-max-value': constant('App\\\\DTO\\\\MagazineDto::MAX_NAME_LENGTH')\n                }}) }}\n            {{ form_row(form.title, {label: 'title', attr: {\n                'data-controller': 'input-length autogrow',\n                'data-action' : 'input-length#updateDisplay',\n                'data-input-length-max-value': constant('App\\\\DTO\\\\MagazineDto::MAX_TITLE_LENGTH')\n            }}) }}\n            {{ component('editor_toolbar', {id: 'magazine_description'}) }}\n            {{ form_row(form.description, {label: false, attr: {\n                placeholder: 'description',\n                'data-controller': 'input-length rich-textarea autogrow',\n                'data-action' : 'input-length#updateDisplay',\n                'data-input-length-max-value': constant('App\\\\Entity\\\\Magazine::MAX_DESCRIPTION_LENGTH')\n                }}) }}\n            {{ form_row(form.isAdult, {label:'is_adult', row_attr: {class: 'checkbox'}}) }}\n            {{ form_row(form.isPostingRestrictedToMods, {label:'magazine_posting_restricted_to_mods',row_attr: {class: 'checkbox'}}) }}\n            <div class=\"checkbox\">\n                {{ form_label(form.discoverable, 'discoverable') }}\n                {{ form_widget(form.discoverable) }}\n            </div>\n            <div class=\"checkbox help-text\">\n                {{ form_help(form.discoverable) }}\n            </div>\n            <div class=\"checkbox\">\n                {{ form_label(form.indexable, 'indexable_by_search_engines') }}\n                {{ form_widget(form.indexable) }}\n            </div>\n            <div class=\"checkbox help-text\">\n                {{ form_help(form.indexable) }}\n            </div>\n            <div class=\"checkbox\">\n                {{ form_label(form.nameAsTag, 'magazine_name_as_tag') }}\n                {{ form_widget(form.nameAsTag) }}\n            </div>\n            <div class=\"checkbox help-text\">\n                {{ form_help(form.nameAsTag) }}\n            </div>\n            <div class=\"row actions\">\n                {{ form_row(form.submit, {label: 'create_new_magazine', attr: {class: 'btn btn__primary'}, row_attr: {class: 'float-end'}}) }}\n            </div>\n            {{ form_end(form) }}\n        </div>\n    </div>\n    {% endif %}\n{% endblock %}\n"
  },
  {
    "path": "templates/magazine/list_abandoned.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    {{- 'magazines'|trans }} - {{ parent() -}}\n{%- endblock -%}\n\n{% block mainClass %}page-magazines page-settings{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n{% endblock %}\n\n{% block body %}\n    <header>\n        <h1 hidden>{{ 'magazines'|trans }}</h1>\n    </header>\n\n    {% include 'magazine/_options.html.twig' %}\n    <div id=\"content\">\n        {% if magazines|length %}\n            <div class=\"section\">\n                <div class=\"magazines table-responsive\">\n                    <table>\n                        <thead>\n                        <tr>\n                            <th>{{ 'name'|trans }}</th>\n                            {% for column in ['threads', 'comments', 'posts'] %}\n                                <th>{{ column|trans }}</th>\n                            {% endfor %}\n                            <th style=\"text-align: center\">\n                            </th>\n                        </tr>\n                        </thead>\n                        <tbody>\n                        {% for magazine in magazines %}\n                            <tr>\n                                <td>{{ component('magazine_inline', { magazine: magazine, stretchedLink: true, showAvatar: true, showNewIcon: true, fullName: true}) }}</td>\n                                <td>{{ magazine.entryCount }}</td>\n                                <td>{{ magazine.entryCommentCount }}</td>\n                                <td>{{ magazine.postCount + magazine.postCommentCount }}</td>\n                                <td>\n                                    <aside class=\"magazine__subscribe\">\n                                        <div class='action'>\n                                            <i class=\"fa-solid fa-users\" aria-hidden=\"true\"></i><span>{{ magazine.subscriptionsCount }}</span>\n                                        </div>\n                                        {% if app.user and not magazine.userIsOwner(app.user) and app.user.visibility is same as 'visible' %}\n                                            <form action=\"{{ path('magazine_ownership_request', {name: magazine.name}) }}\"\n                                                  name=\"magazine_request_ownership\"\n                                                  method=\"post\">\n                                                <button type=\"submit\"\n                                                        class=\"btn btn__secondary action\">\n                                                    <i class=\"fa-sharp fa-solid fa-hand-point-up\" aria-hidden=\"true\"></i>\n                                                    {% if not app.user.hasMagazineOwnershipRequest(magazine) %}\n                                                        <span>{{ 'request_magazine_ownership'|trans }}</span>\n                                                    {% else %}\n                                                        <span>{{ 'cancel_request'|trans }}</span>\n                                                    {% endif %}\n                                                </button>\n                                                <input type=\"hidden\" name=\"token\"\n                                                       value=\"{{ csrf_token('magazine_ownership_request') }}\">\n                                            </form>\n                                        {% endif %}\n                                    </aside>\n                                </td>\n                            </tr>\n                        {% endfor %}\n                        </tbody>\n                    </table>\n                </div>\n                <div class=\"magazines magazine-list-mobile\">\n                    {% for magazine in magazines %}\n                        <div class=\"magazine\">\n                            <div class=\"magazine__top\">\n                                <div class=\"magazine__inline\">{{ component('magazine_inline', { magazine: magazine, stretchedLink: true, showAvatar: true, showNewIcon: true, fullName: true}) }}</div>\n                                <div class=\"magazine__information\">\n                                    <span class=\"magazine__info\"><span\n                                                class=\"value\">{{ magazine.entryCount }}</span><span\n                                                class=\"label\">{{ 'threads'|trans }}</span></span>\n                                    <span class=\"magazine__info\"><span\n                                                class=\"value\">{{ magazine.entryCommentCount }}</span><span\n                                                class=\"label\">{{ 'comments'|trans }}</span></span>\n                                    <span class=\"magazine__info\"><span\n                                                class=\"value\">{{ magazine.postCount + magazine.postCommentCount }}</span><span\n                                                class=\"label\">{{ 'posts'|trans }}</span></span>\n                                </div>\n                                <div class=\"magazine__sub\">\n                                    <td>\n                                        <aside class=\"magazine__subscribe\">\n                                            <div class='action'>\n                                                <i class=\"fa-solid fa-users\" aria-hidden=\"true\"></i><span>{{ magazine.subscriptionsCount }}</span>\n                                            </div>\n                                            {% if not app.user or not magazine.userIsOwner(app.user) %}\n                                                <form action=\"{{ path('magazine_ownership_request', {name: magazine.name}) }}\"\n                                                      name=\"magazine_request_ownership\"\n                                                      method=\"post\">\n                                                    <button type=\"submit\"\n                                                            class=\"btn btn__secondary action\">\n                                                        <i class=\"fa-sharp fa-solid fa-hand-point-up\" aria-hidden=\"true\"></i>\n                                                        {% if not app.user or not app.user.hasMagazineOwnershipRequest(magazine) %}\n                                                            <span>{{ 'request_magazine_ownership'|trans }}</span>\n                                                        {% else %}\n                                                            <span>{{ 'cancel_request'|trans }}</span>\n                                                        {% endif %}\n                                                    </button>\n                                                    <input type=\"hidden\" name=\"token\"\n                                                           value=\"{{ csrf_token('magazine_ownership_request') }}\">\n                                                </form>\n                                            {% endif %}\n                                        </aside>\n                                    </td>\n                                </div>\n                            </div>\n                        </div>\n                    {% endfor %}\n                </div>\n            </div>\n            {% if(magazines.haveToPaginate is defined and magazines.haveToPaginate) %}\n                {{ pagerfanta(magazines, null, {'pageParameter':'[p]'}) }}\n            {% endif %}\n        {% else %}\n            <aside class=\"section section--muted\">\n                <p>{{ 'empty'|trans }}</p>\n            </aside>\n        {% endif %}\n\n    </div>\n{% endblock %}\n"
  },
  {
    "path": "templates/magazine/list_all.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    {{- 'magazines'|trans }} - {{ parent() -}}\n{%- endblock -%}\n\n{% block mainClass %}page-magazines page-settings{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n{% endblock %}\n\n{% block body %}\n    <header>\n        <h1 hidden>{{ 'magazines'|trans }}</h1>\n    </header>\n    {% include 'magazine/_options.html.twig' %}\n    <div class=\"section\">\n        <div class=\"container\">\n            {{ form_start(form) }}\n\n            <div class=\"flex search-container\" style=\"align-items: center\">\n                {{ form_widget(form.query, {'attr': {'class': 'form-control'}}) }}\n                <button class=\"btn btn__primary ignore-edges\" type=\"submit\" title=\"{{ 'search'|trans }}\" aria-label=\"{{ 'search'|trans }}\">\n                    <i class=\"fa-solid fa-magnifying-glass\" aria-hidden=\"true\"></i>\n                </button>\n            </div>\n            <div class=\"flex flex-wrap\">\n                {{ form_widget(form.fields, {attr: {'aria-label': 'filter.fields.label'|trans}}) }}\n                {{ form_widget(form.federation, {attr: {'aria-label': 'filter.origin.label'|trans}}) }}\n                {{ form_widget(form.adult, {attr: {'aria-label': 'filter.adult.label'|trans}}) }}\n            </div>\n\n            {{ form_end(form) }}\n        </div>\n    </div>\n    <div id=\"content\">\n        {% include 'magazine/_list.html.twig' %}\n    </div>\n{% endblock %}\n"
  },
  {
    "path": "templates/magazine/moderators.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    {{- 'moderators'|trans }} - {{ magazine.title }} - {{ parent() -}}\n{%- endblock -%}\n\n{% block mainClass %}page-magazine-panel page-magazine-moderators{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n{% endblock %}\n\n{% block body %}\n    <h1>{{ 'moderators'|trans }}</h1>\n    <div class=\"flex\">\n        {% if app.user and app.user.visibility is same as 'visible' %}\n        {% if magazine.apId is same as null and not magazine.userIsModerator(app.user) %}\n            <form action=\"{{ path('magazine_moderator_request', {name: magazine.name}) }}\"\n                  method=\"POST\"\n                  class=\"float-end mb-2\"\n                  data-action=\"confirmation#ask\" data-confirmation-message-param=\"{{ 'are_you_sure'|trans }}\">\n                <input type=\"hidden\" name=\"token\" value=\"{{ csrf_token('moderator_request') }}\">\n                <button type=\"submit\" class=\"btn btn__secondary\">\n                    <i class=\"fa-solid fa-hand-point-up\" aria-hidden=\"true\"></i>\n                    {% if not app.user.hasModeratorRequest(magazine) %}\n                        <span>{{ 'apply_for_moderator'|trans }}</span>\n                    {% else %}\n                        <span>{{ 'cancel_request'|trans }}</span>\n                    {% endif %}\n                </button>\n            </form>\n        {% endif %}\n        {% if magazine.isAbandoned() and not magazine.userIsOwner(app.user) %}\n            <form action=\"{{ path('magazine_ownership_request', {name: magazine.name}) }}\"\n                  name=\"magazine_request_ownership\"\n                  class=\"float-end mb-2\"\n                  method=\"post\">\n                <button type=\"submit\"\n                        class=\"btn btn__secondary\">\n                    <i class=\"fa-sharp fa-solid fa-hand-point-up\" aria-hidden=\"true\"></i>\n                    {% if not app.user or not app.user.hasMagazineOwnershipRequest(magazine) %}\n                        <span>{{ 'request_magazine_ownership'|trans }}</span>\n                    {% else %}\n                        <span>{{ 'cancel_request'|trans }}</span>\n                    {% endif %}\n                </button>\n                <input type=\"hidden\" name=\"token\"\n                       value=\"{{ csrf_token('magazine_ownership_request') }}\">\n            </form>\n        {% endif %}\n        {% endif %}\n    </div>\n    {% if moderators|length %}\n      {% include 'magazine/_moderators_list.html.twig' %}\n      {% if(moderators.haveToPaginate is defined and moderators.haveToPaginate) %}\n          {{ pagerfanta(moderators, null, {'pageParameter':'[p]'}) }}\n      {% endif %}\n    {% else %}\n        <aside class=\"section section--muted\">\n            <p>{{ 'empty'|trans }}</p>\n        </aside>\n    {% endif %}\n{% endblock %}\n"
  },
  {
    "path": "templates/magazine/panel/_options.html.twig",
    "content": "{%- set STATUS_PENDING = constant('App\\\\Entity\\\\Report::STATUS_PENDING') -%}\n<aside class=\"options options--top options-activity\" id=\"options\">\n    <div class=\"options__title\">\n        <h2>{{ 'magazine_panel'|trans }}</h2>\n    </div>\n    <menu class=\"options__main\">\n        <li>\n            <a href=\"{{ path('magazine_panel_general', {name: magazine.name}) }}\"\n               class=\"{{ html_classes({'active': is_route_name('magazine_panel_general')}) }}\">\n                {{ 'general'|trans }}\n            </a>\n        </li>\n        <li>\n            <a href=\"{{ path('magazine_panel_reports', {name: magazine.name, status: STATUS_PENDING}) }}\"\n               class=\"{{ html_classes({'active': is_route_name('magazine_panel_reports')}) }}\">\n                {{ 'reports'|trans }}\n            </a>\n        </li>\n        <li>\n            <a href=\"{{ path('magazine_panel_moderators', {name: magazine.name}) }}\"\n               class=\"{{ html_classes({'active': is_route_name('magazine_panel_moderators')}) }}\">\n                {{ 'moderators'|trans }}\n            </a>\n        </li>\n        <li>\n            <a href=\"{{ path('magazine_panel_moderator_requests', {name: magazine.name}) }}\"\n               class=\"{{ html_classes({'active': is_route_name('magazine_panel_moderator_requests')}) }}\">\n                {{ 'moderator_requests'|trans }}\n            </a>\n        </li>\n        <li>\n            <a href=\"{{ path('magazine_panel_badges', {name: magazine.name}) }}\"\n               class=\"{{ html_classes({'active': is_route_name('magazine_panel_badges')}) }}\">\n                {{ 'badges'|trans }}\n            </a>\n        </li>\n        <li>\n            <a href=\"{{ path('magazine_panel_tags', {name: magazine.name}) }}\"\n               class=\"{{ html_classes({'active': is_route_name('magazine_panel_tags')}) }}\">\n                {{ 'tags'|trans }}\n            </a>\n        </li>\n        <li>\n            <a href=\"{{ path('magazine_panel_bans', {name: magazine.name}) }}\"\n               class=\"{{ html_classes({'active': is_route_name_contains('_ban')}) }}\">\n                {{ 'bans'|trans }}\n            </a>\n        </li>\n        <li>\n            <a href=\"{{ path('magazine_panel_trash', {name: magazine.name}) }}\"\n               class=\"{{ html_classes({'active': is_route_name('magazine_panel_trash')}) }}\">\n                {{ 'trash'|trans }}\n            </a>\n        </li>\n        <li>\n            <a href=\"{{ path('magazine_panel_theme', {name: magazine.name}) }}\"\n               class=\"{{ html_classes({'active': is_route_name('magazine_panel_theme')}) }}\">\n                {{ 'appearance'|trans }}\n            </a>\n        </li>\n        <li>\n            <a href=\"{{ path('magazine_panel_stats', {name: magazine.name}) }}\"\n               class=\"{{ html_classes({'active': is_route_name('magazine_panel_stats')}) }}\">\n                {{ 'stats'|trans }}\n            </a>\n        </li>\n    </menu>\n</aside>\n"
  },
  {
    "path": "templates/magazine/panel/_stats_pills.html.twig",
    "content": "{%- set TYPE_CONTENT = constant('App\\\\Repository\\\\StatsRepository::TYPE_CONTENT') -%}\n{%- set TYPE_VOTES = constant('App\\\\Repository\\\\StatsRepository::TYPE_VOTES') -%}\n<div class=\"pills\">\n    <menu>\n        <li>\n            <a href=\"{{ path('magazine_panel_stats', {name: magazine.name, statsType: TYPE_CONTENT}) }}\"\n               class=\"{{ html_classes({'active': route_has_param('statsType', TYPE_CONTENT) or get_route_param('statsType') is same as null}) }}\">\n                {{ TYPE_CONTENT|trans }}\n            </a>\n        </li>\n        <li>\n            <a href=\"{{ path('magazine_panel_stats', {name: magazine.name, statsType: TYPE_VOTES}) }}\"\n               class=\"{{ html_classes({'active': route_has_param('statsType', TYPE_VOTES)}) }}\">\n                {{ TYPE_VOTES|trans }}\n            </a>\n        </li>\n    </menu>\n</div>"
  },
  {
    "path": "templates/magazine/panel/badges.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    {{- 'badges'|trans }} - {{ 'magazine_panel'|trans }} - {{ parent() -}}\n{%- endblock -%}\n\n{% block mainClass %}page-magazine-panel page-magazine-badges{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n{% endblock %}\n\n{% block body %}\n    {% include 'magazine/panel/_options.html.twig' %}\n    {% include 'magazine/_visibility_info.html.twig' %}\n\n    <h1 hidden>{{ 'badges'|trans }}</h1>\n    {% if badges|length %}\n        <div id=\"content\" class=\"section badges columns\">\n            <ul>\n                {% for badge in badges %}\n                    <li>\n                        <div>\n                            {{ badge.name }}\n                        </div>\n                        {% if is_granted('edit', magazine) %}\n                            <div class=\"actions\">\n                                <form method=\"post\"\n                                      action=\"{{ path('magazine_panel_badge_remove', {'magazine_name': badge.magazine.name, 'badge_id': badge.id} ) }}\"\n                                      data-action=\"confirmation#ask\" data-confirmation-message-param=\"{{ 'are_you_sure'|trans }}\">\n                                    <input type=\"hidden\" name=\"token\" value=\"{{ csrf_token('badge_remove') }}\">\n                                    <button type=\"submit\" class=\"btn btn__secondary\">{{ 'delete'|trans }}</button>\n                                </form>\n                            </div>\n                        {% endif %}\n                    </li>\n                {% endfor %}\n            </ul>\n        </div>\n    {% endif %}\n    {% if(badges.haveToPaginate is defined and badges.haveToPaginate) %}\n        {{ pagerfanta(badges, null, {'pageParameter':'[p]'}) }}\n    {% endif %}\n    {% if not badges|length %}\n        <aside class=\"section section--muted\">\n            <p>{{ 'empty'|trans }}</p>\n        </aside>\n    {% endif %}\n    <div class=\"section badge-add\">\n        <div class=\"container\">\n            {{ form_start(form) }}\n            <div class=\"row\">\n                {{ form_errors(form.name) }}\n            </div>\n            <div>\n                {{ form_label(form.name, 'name') }}\n                {{ form_widget(form.name) }}\n            </div>\n            <div class=\"actions\">\n                {{ form_row(form.submit, { 'label': 'add_badge', attr: {class: 'btn btn__primary'} }) }}\n            </div>\n            {{ form_end(form) }}\n        </div>\n    </div>\n{% endblock %}\n"
  },
  {
    "path": "templates/magazine/panel/ban.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    {{- 'bans'|trans }} - {{ 'magazine_panel'|trans }} - {{ parent() -}}\n{%- endblock -%}\n\n{% block mainClass %}page-magazine-panel page-magazine-bans{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n{% endblock %}\n\n{% block body %}\n    {% include 'magazine/panel/_options.html.twig' %}\n    {% include 'magazine/_visibility_info.html.twig' %}\n\n    <h1 hidden>{{ 'ban'|trans }}</h1>\n    <section class=\"section\">\n        {{ component('user_box', {user: user}) }}\n    </section>\n    <section class=\"section ban-add\">\n        <div class=\"container\">\n            {{ form_start(form) }}\n            <div>\n                {{ form_label(form.reason, 'reason') }}\n                {{ form_widget(form.reason) }}\n            </div>\n            <div>\n                {{ form_label(form.expiredAt, 'expired_at') }}\n                {{ form_widget(form.expiredAt) }}\n            </div>\n            <div class=\"actions\">\n                {{ form_row(form.submit, { 'label': 'ban', attr: {class: 'btn btn__primary'} }) }}\n            </div>\n            {{ form_end(form) }}\n        </div>\n    </section>\n{% endblock %}\n"
  },
  {
    "path": "templates/magazine/panel/bans.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    {{- 'bans'|trans }} - {{ 'magazine_panel'|trans }} - {{ parent() -}}\n{%- endblock -%}\n\n{% block mainClass %}page-magazine-panel page-magazine-bans{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n{% endblock %}\n\n{% block body %}\n    {% include 'magazine/panel/_options.html.twig' %}\n    {% include 'magazine/_visibility_info.html.twig' %}\n\n    <h1 hidden>{{ 'bans'|trans }}</h1>\n    {% if bans|length %}\n        <div id=\"content\" class=\"section bans bans-table table-responsive\">\n            <table>\n                <thead>\n                <tr>\n                    <th>{{ 'name'|trans }}</th>\n                    <th>{{ 'reason'|trans }}</th>\n                    <th>{{ 'created'|trans }}</th>\n                    <th>{{ 'expires'|trans }}</th>\n                    <th></th>\n                </tr>\n                </thead>\n                <tbody>\n                {% for ban in bans %}\n                    <tr>\n                        <td>{{ component('user_inline', {user: ban.user, showNewIcon: true}) }}</td>\n                        <td>{{ ban.reason }}</td>\n                        <td>{{ component('date', {date: ban.createdAt}) }}</td>\n                        <td>\n                            {% if ban.expiredAt %}\n                                {{ component('date', {date: ban.expiredAt}) }}\n                            {% else %}\n                                {{ 'perm'|trans }}\n                            {% endif %}\n                        </td>\n                        <td>\n                            <form method=\"post\"\n                                  action=\"{{ path('magazine_panel_unban', {name: ban.magazine.name, username: ban.user.username}) }}\"\n                                  data-action=\"confirmation#ask\" data-confirmation-message-param=\"{{ 'are_you_sure'|trans }}\">\n                                <input type=\"hidden\" name=\"token\" value=\"{{ csrf_token('magazine_unban') }}\">\n                                <button class=\"btn btn__secondary\">{{ 'delete'|trans }}</button>\n                            </form>\n                        </td>\n                    </tr>\n                {% endfor %}\n                </tbody>\n            </table>\n        </div>\n    {% endif %}\n    {% if(bans.haveToPaginate is defined and bans.haveToPaginate) %}\n        {{ pagerfanta(bans, null, {'pageParameter':'[p]'}) }}\n    {% endif %}\n    {% if not bans|length %}\n        <aside class=\"section section--muted\">\n            <p>{{ 'empty'|trans }}</p>\n        </aside>\n    {% endif %}\n    <section class=\"section ban-add\">\n        <div class=\"container\">\n            <h3 hidden>{{ 'add_ban'|trans }}</h3>\n            <form action=\"{{ path('magazine_panel_ban', {name: magazine.name}) }}\"\n                  method=\"get\"\n                  name=\"ban\">\n                <div>\n                    <label for=\"username\">{{ 'username'|trans }}</label>\n                    <input id=\"username\" type=\"text\" name=\"username\">\n                </div>\n                <div class=\"actions\">\n                    <button class=\"btn btn__primary\" type=\"submit\">{{ 'add_ban'|trans }}</button>\n                </div>\n            </form>\n        </div>\n    </section>\n{% endblock %}\n"
  },
  {
    "path": "templates/magazine/panel/general.html.twig",
    "content": "{% extends 'base.html.twig' %}\n{%- block title -%}\n    {{- 'general'|trans }} - {{ 'magazine_panel'|trans }} - {{ parent() -}}\n{%- endblock -%}\n\n{% block mainClass %}page-magazine-panel page-magazine-general{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n{% endblock %}\n\n{% block body %}\n    {% include 'magazine/panel/_options.html.twig' %}\n    {% include 'magazine/_visibility_info.html.twig' %}\n\n    <h1 hidden>{{ 'general'|trans }}</h1>\n    {% include 'layout/_flash.html.twig' %}\n    <div id=\"content\" class=\"section theme\">\n        <div class=\"container\">\n            {{ form_start(form) }}\n            <div>\n                {{ form_label(form.name) }}\n                {{ form_widget(form.name) }}\n            </div>\n            <div>\n                {{ form_label(form.title) }}\n                {{ form_widget(form.title) }}\n            </div>\n            <div>\n                {{ component('editor_toolbar', {id: 'magazine_description'}) }}\n                {{ form_row(form.description, {label: false, attr: {placeholder: 'description', 'data-entry-link-create-target': 'magazine_description'}}) }}\n            </div>\n            {% if form.rules is defined and form.rules %}\n                <div>\n                    {{ component('editor_toolbar', {id: 'magazine_rules'}) }}\n                    {{ form_row(form.rules, {label: false, attr: {placeholder: 'rules', 'data-entry-link-create-target': 'magazine_rules'}}) }}\n                </div>\n            {% endif %}\n            <div class=\"checkbox\">\n                {{ form_label(form.isAdult) }}\n                {{ form_widget(form.isAdult) }}\n            </div>\n            <div class=\"checkbox\">\n                {{ form_label(form.isPostingRestrictedToMods) }}\n                {{ form_widget(form.isPostingRestrictedToMods) }}\n            </div>\n            <div class=\"checkbox\">\n                {{ form_label(form.discoverable, 'discoverable') }}\n                {{ form_widget(form.discoverable) }}\n            </div>\n            <div class=\"checkbox help-text\">\n                {{ form_help(form.discoverable) }}\n            </div>\n            <div class=\"checkbox\">\n                {{ form_label(form.indexable, 'indexable_by_search_engines') }}\n                {{ form_widget(form.indexable) }}\n            </div>\n            <div class=\"checkbox help-text\">\n                {{ form_help(form.indexable) }}\n            </div>\n            <div class=\"actions\">\n                {{ form_row(form.submit, { 'label': 'done'|trans, 'attr': {'class': 'btn btn__primary'} }) }}\n            </div>\n            {{ form_end(form) }}\n        </div>\n    </div>\n    <div class=\"section\">\n        <div class=\"container\">\n            <h2>{{ 'magazine_deletion'|trans }}</h2>\n            <div>\n                <div class=\"mb-2\">\n                    {% if magazine.visibility is same as 'visible' %}\n                        <form action=\"{{ path('magazine_delete', {name: magazine.name}) }}\" method=\"POST\"\n                              data-action=\"confirmation#ask\" data-confirmation-message-param=\"{{ 'are_you_sure'|trans }}\">\n                            <input type=\"hidden\" name=\"token\" value=\"{{ csrf_token('magazine_delete') }}\">\n                            <button type=\"submit\" class=\"btn btn__primary\">\n                                <i class=\"fa-solid fa-dumpster\" aria-hidden=\"true\"></i> <span>{{ 'delete_magazine'|trans }}</span>\n                            </button>\n                        </form>\n                    {% else %}\n                        <form action=\"{{ path('magazine_restore', {name: magazine.name}) }}\" method=\"POST\"\n                              data-action=\"confirmation#ask\" data-confirmation-message-param=\"{{ 'are_you_sure'|trans }}\">\n                            <input type=\"hidden\" name=\"token\" value=\"{{ csrf_token('magazine_restore') }}\">\n                            <button type=\"submit\" class=\"btn btn__primary\">\n                                <i class=\"fa-solid fa-dumpster\" aria-hidden=\"true\"></i> <span>{{ 'restore_magazine'|trans }}</span>\n                            </button>\n                        </form>\n                    {% endif %}\n                </div>\n                {% if is_granted('ROLE_ADMIN') %}\n                <div class=\"mb-2\">\n                    <form action=\"{{ path('magazine_remove_subscriptions', {name: magazine.name}) }}\" method=\"POST\"\n                          data-action=\"confirmation#ask\" data-confirmation-message-param=\"{{ 'are_you_sure'|trans }}\">\n                        <input type=\"hidden\" name=\"token\" value=\"{{ csrf_token('magazine_remove_subscriptions') }}\">\n                        <button type=\"submit\" class=\"btn btn__danger\">\n                            <i class=\"fa-solid fa-users-slash\" aria-hidden=\"true\"></i> <span>{{ 'remove_subscriptions'|trans }}</span>\n                        </button>\n                    </form>\n                </div>\n                {% endif %}\n                {% if is_granted('purge', magazine) %}\n                    <div class=\"mb-2\">\n                        <form action=\"{{ path('magazine_purge_content', {name: magazine.name}) }}\" method=\"POST\"\n                              data-action=\"confirmation#ask\" data-confirmation-message-param=\"{{ 'are_you_sure'|trans }}\">\n                            <input type=\"hidden\" name=\"token\" value=\"{{ csrf_token('magazine_purge_content') }}\">\n                            <button type=\"submit\" class=\"btn btn__danger\">\n                                <i class=\"fa-solid fa-dumpster\" aria-hidden=\"true\"></i> <span>{{ 'purge_content'|trans }}</span>\n                            </button>\n                        </form>\n                    </div>\n                    <div>\n                        <form action=\"{{ path('magazine_purge', {name: magazine.name}) }}\" method=\"POST\"\n                              data-action=\"confirmation#ask\" data-confirmation-message-param=\"{{ 'are_you_sure'|trans }}\">\n                            <input type=\"hidden\" name=\"token\" value=\"{{ csrf_token('magazine_purge') }}\">\n                            <button type=\"submit\" class=\"btn btn__danger\">\n                                <i class=\"fa-solid fa-dumpster-fire\" aria-hidden=\"true\"></i> <span>{{ 'purge_magazine'|trans }}</span>\n                            </button>\n                        </form>\n                    </div>\n                {% endif %}\n            </div>\n        </div>\n    </div>\n{% endblock %}\n"
  },
  {
    "path": "templates/magazine/panel/moderator_requests.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    {{- 'moderators'|trans }} - {{ 'magazine_panel'|trans }} - {{ parent() -}}\n{%- endblock -%}\n\n{% block mainClass %}page-magazine-panel page-magazine-moderators{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n{% endblock %}\n\n{% block body %}\n    {% include 'magazine/panel/_options.html.twig' %}\n    {% include 'magazine/_visibility_info.html.twig' %}\n\n    <h1 hidden>{{ 'moderators'|trans }}</h1>\n    {% if requests|length %}\n        <div class=\"section\" id=\"content\">\n            <table>\n                <thead>\n                <tr>\n                    <td>{{ 'magazine'|trans }}</td>\n                    <td>{{ 'user'|trans }}</td>\n                    <td>{{ 'reputation_points'|trans }}</td>\n                    <td></td>\n                </tr>\n                </thead>\n                <tbody>\n                {% for request in requests %}\n                    <tr>\n                        <td>{{ component('magazine_inline', {magazine: request.magazine, showNewIcon: true}) }}</td>\n                        <td>{{ component('user_inline', {user: request.user, showNewIcon: true}) }}</td>\n                        <td>{{ get_reputation_total(request.user) }}</td>\n                        <td>\n                            <aside class=\"magazine__subscribe\">\n                                <form action=\"{{ path('magazine_panel_moderator_request_accept', {name: request.magazine.name, username: request.user.username}) }}\"\n                                      name=\"ownership_requests_accept\"\n                                      data-action=\"confirmation#ask\" data-confirmation-message-param=\"{{ 'are_you_sure'|trans }}\"\n                                      method=\"post\">\n                                    <button type=\"submit\"\n                                            title=\"{{ 'accept'|trans }}\"\n                                            class=\"btn btn__secondary\">\n                                        {{ 'accept'|trans }}\n                                    </button>\n                                    <input type=\"hidden\" name=\"token\" value=\"{{ csrf_token('magazine_panel_moderator_request_accept') }}\">\n                                </form>\n                                <form action=\"{{ path('magazine_panel_moderator_request_reject', {name: request.magazine.name, username: request.user.username}) }}\"\n                                      name=\"ownership_requests_reject\"\n                                      data-action=\"confirmation#ask\" data-confirmation-message-param=\"{{ 'are_you_sure'|trans }}\"\n                                      method=\"post\">\n                                    <button type=\"submit\"\n                                            class=\"btn btn__secondary\"\n                                            title=\"{{ 'reject'|trans }}\">\n                                        <i class=\"fa-solid fa-ban\" aria-hidden=\"true\"></i> <span>{{ 'reject'|trans }}</span>\n                                    </button>\n                                    <input type=\"hidden\" name=\"token\"\n                                           value=\"{{ csrf_token('magazine_panel_moderator_request_reject') }}\">\n                                </form>\n                            </aside>\n                        </td>\n                    </tr>\n                {% endfor %}\n                </tbody>\n            </table>\n        </div>\n    {% else %}\n        <aside class=\"section section--muted\">\n            <p>{{ 'empty'|trans }}</p>\n        </aside>\n    {% endif %}\n    {% if(requests.haveToPaginate is defined and requests.haveToPaginate) %}\n        {{ pagerfanta(requests, null, {'pageParameter':'[p]'}) }}\n    {% endif %}\n{% endblock %}\n"
  },
  {
    "path": "templates/magazine/panel/moderators.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    {{- 'moderators'|trans }} - {{ 'magazine_panel'|trans }} - {{ parent() -}}\n{%- endblock -%}\n\n{% block mainClass %}page-magazine-panel page-magazine-moderators{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n{% endblock %}\n\n{% block body %}\n    {% include 'magazine/panel/_options.html.twig' %}\n    {% include 'magazine/_visibility_info.html.twig' %}\n\n    <h1 hidden>{{ 'moderators'|trans }}</h1>\n    {% include 'magazine/_moderators_list.html.twig' %}\n    {% if(moderators.haveToPaginate is defined and moderators.haveToPaginate) %}\n        {{ pagerfanta(moderators, null, {'pageParameter':'[p]'}) }}\n    {% endif %}\n    {% if not moderators|length %}\n        <aside class=\"section section--muted\">\n            <p>{{ 'empty'|trans }}</p>\n        </aside>\n    {% endif %}\n    <div class=\"section moderator-add\">\n        <div class=\"container\">\n            {{ form_start(form) }}\n            <div class=\"row\">\n                {{ form_errors(form.user) }}\n            </div>\n            <div>\n                {{ form_label(form.user, 'username') }}\n                {{ form_widget(form.user) }}\n            </div>\n            <div class=\"actions row\">\n                {{ form_row(form.submit, { 'label': 'add_moderator', attr: {class: 'btn btn__primary'} }) }}\n            </div>\n            {{ form_end(form) }}\n        </div>\n    </div>\n{% endblock %}\n"
  },
  {
    "path": "templates/magazine/panel/reports.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    {{- 'reports'|trans }} - {{ 'magazine_panel'|trans }} - {{ parent() -}}\n{%- endblock -%}\n\n{% block mainClass %}page-magazine-panel page-magazine-reports{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n{% endblock %}\n\n{% block body %}\n    {% include 'magazine/panel/_options.html.twig' %}\n    {% include 'magazine/_visibility_info.html.twig' %}\n\n    <h1 hidden>{{ 'reports'|trans }}</h1>\n    {{ component('report_list', {reports: reports, routeName: 'magazine_panel_reports', magazineName: magazine.name}) }}\n{% endblock %}\n"
  },
  {
    "path": "templates/magazine/panel/stats.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    {{- 'stats'|trans }} - {{ 'magazine_panel'|trans }} - {{ parent() -}}\n{%- endblock -%}\n\n{% block mainClass %}page-magazine-panel page-magazine-stats{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n{% endblock %}\n\n{% block body %}\n    {% include 'magazine/panel/_options.html.twig' %}\n    {% include 'magazine/_visibility_info.html.twig' %}\n    {% include 'magazine/panel/_stats_pills.html.twig' %}\n\n    <div id=\"content\" class=\"section\">\n        {% include 'stats/_filters.html.twig' %}\n        {{ render_chart(chart) }}\n    </div>\n{% endblock %}\n"
  },
  {
    "path": "templates/magazine/panel/tags.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    {{- 'tags'|trans }} - {{ 'magazine_panel'|trans }} - {{ parent() -}}\n{%- endblock -%}\n\n{% block mainClass %}page-magazine-panel page-magazine-tags{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n{% endblock %}\n\n{% block body %}\n    {% include 'magazine/panel/_options.html.twig' %}\n    {% include 'magazine/_visibility_info.html.twig' %}\n\n    <h1 hidden>{{ 'tags'|trans }}</h1>\n    <div class=\"alert alert__info\">\n        {{ 'magazine_panel_tags_info'|trans }}\n    </div>\n    <div id=\"content\" class=\"section\">\n        <div class=\"container\">\n            {{ form_start(form) }}\n            <div class=\"actions\">\n                {{ form_row(form.submit, { 'label': 'save'|trans, attr: {class: 'btn btn__primary'} }) }}\n            </div>\n            {{ form_end(form) }}\n        </div>\n    </div>\n{% endblock %}\n"
  },
  {
    "path": "templates/magazine/panel/theme.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    {{- 'appearance'|trans }} - {{ 'magazine_panel'|trans }} - {{ parent() -}}\n{%- endblock -%}\n\n{% block mainClass %}page-magazine-panel page-magazine-theme{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n{% endblock %}\n\n{% block body %}\n    {% include 'magazine/panel/_options.html.twig' %}\n    {% include 'magazine/_visibility_info.html.twig' %}\n\n    <h1 hidden>{{ 'appearance'|trans }}</h1>\n    {% include 'layout/_flash.html.twig' %}\n    <div id=\"content\" class=\"section theme\">\n        <div class=\"container\">\n            {{ form_start(form) }}\n            <div>\n                {{ form_label(form.icon, 'icon') }}\n                {{ form_widget(form.icon) }}\n                {{ form_help(form.icon) }}\n                {{ form_errors(form.icon) }}\n            </div>\n\n            {% if magazine.icon is not same as null %}\n                <div class=\"actions\">\n                    <ul style=\"width: 100%\">\n                        <img width=\"40\"\n                             height=\"40\"\n                             src=\"{{ asset(magazine.icon.filePath)|imagine_filter('entry_thumb') }}\"\n                             alt=\"{{ magazine.icon.altText }}\" />\n                        <button formaction=\"{{ path('magazine_panel_theme_detach_icon', {'name': magazine.name}) }}\"\n                                class=\"btn-link\"\n                                aria-label=\"{{ 'delete_magazine_icon'|trans }}\"\n                                title=\"{{ 'delete_magazine_icon'|trans }}\"\n                                data-action=\"confirmation#ask\" data-confirmation-message-param=\"{{ 'are_you_sure'|trans }}\">\n                            <i class=\"fa-solid fa-xmark\" aria-hidden=\"true\"></i>\n                        </button>\n                    </ul>\n                </div>\n            {% endif %}\n            <div>\n                {{ form_label(form.banner, 'banner') }}\n                {{ form_widget(form.banner) }}\n                {{ form_help(form.banner) }}\n                {{ form_errors(form.banner) }}\n            </div>\n\n            {% if magazine.banner is not same as null %}\n                <div class=\"actions\">\n                    <ul style=\"width: 100%\">\n                        <img width=\"40\"\n                             height=\"40\"\n                             src=\"{{ asset(magazine.banner.filePath)|imagine_filter('magazine_banner') }}\"\n                             alt=\"{{ magazine.banner.altText }}\" />\n                        <button formaction=\"{{ path('magazine_panel_theme_detach_banner', {'name': magazine.name}) }}\"\n                                class=\"btn-link\"\n                                aria-label=\"{{ 'delete_magazine_banner'|trans }}\"\n                                title=\"{{ 'delete_magazine_banner'|trans }}\"\n                                data-action=\"confirmation#ask\" data-confirmation-message-param=\"{{ 'are_you_sure'|trans }}\">\n                            <i class=\"fa-solid fa-xmark\" aria-hidden=\"true\"></i>\n                        </button>\n                    </ul>\n                </div>\n            {% endif %}\n            <div>\n                {{ form_label(form.customCss, 'CSS') }}\n                {{ form_widget(form.customCss) }}\n                {{ form_help(form.customCss) }}\n                {{ form_errors(form.customCss) }}\n            </div>\n            <div class=\"radios\">\n                {{ form_label(form.backgroundImage, 'Background') }}\n                {{ form_widget(form.backgroundImage) }}\n                {{ form_help(form.backgroundImage) }}\n                {{ form_errors(form.backgroundImage) }}\n            </div>\n            <div class=\"actions\">\n                {{ form_row(form.submit, { 'label': 'done', attr: {class: 'btn btn__primary'} }) }}\n            </div>\n            {{ form_end(form) }}\n        </div>\n    </div>\n{% endblock %}\n"
  },
  {
    "path": "templates/magazine/panel/trash.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    {{- 'trash'|trans }} - {{ 'magazine_panel'|trans }} - {{ parent() -}}\n{%- endblock -%}\n\n{% block mainClass %}page-magazine-panel page-magazine-trash{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n{% endblock %}\n\n{% block body %}\n    {% include 'magazine/panel/_options.html.twig' %}\n    {% include 'magazine/_visibility_info.html.twig' %}\n\n    <h1 hidden>{{ 'trash'|trans }}</h1>\n    {% if results|length %}\n        {% for subject in results %}\n            {% include 'layout/_subject.html.twig' with {attributes: {canSeeTrash: true, showMagazineName: true, showEntryTitle: true}} %}\n        {% endfor %}\n    {% endif %}\n    {% if(results.haveToPaginate is defined and results.haveToPaginate) %}\n        {{ pagerfanta(results, null, {'pageParameter':'[p]'}) }}\n    {% endif %}\n    {% if not results|length %}\n        <aside class=\"section section--muted\">\n            <p>{{ 'empty'|trans }}</p>\n        </aside>\n    {% endif %}\n\n{% endblock %}\n"
  },
  {
    "path": "templates/messages/_form_create.html.twig",
    "content": "{{ form_start(form, {attr: {class: 'message-form'}}) }}\n<div class=\"col\">\n    <label for=\"message-input-box\" style=\"display: none;\">{{ field_name(form.body) }}</label>\n    <textarea id=\"message-input-box\" class=\"form-control message-input\" name=\"{{ field_name(form.body) }}\" data-controller=\"rich-textarea\">{{ field_value(form.body) }}</textarea>\n</div>\n<div class=\"col-auto\">\n    {{ form_row(form.submit, {attr: {class: 'btn btn__primary'}}) }}\n</div>\n{{ form_end(form) }}\n"
  },
  {
    "path": "templates/messages/front.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    {{- 'messages'|trans }} - {{ parent() -}}\n{%- endblock -%}\n\n{% block mainClass %}page-messages{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n{% endblock %}\n\n{% block body %}\n    <h1>{{ 'messages'|trans }}</h1>\n    {% for thread in threads %}\n        {% set lastMessage = thread.getLastMessage() %}\n        {% if lastMessage is not same as null %}\n            <div class=\"{{ html_classes('section section--small thread', {'opacity-50': not thread.countNewMessages(app.user) }) }}\">\n                <div>\n                    <div>\n                        <small>\n                            {% set i = 0 %}\n                            {% set participants = thread.participants|filter(p => p is not same as app.user) %}\n                            {% for user in participants %}\n                                {% if i > 0 and i is same as (participants|length - 1) %}\n                                    {{ 'and'|trans }}\n                                {% elseif i > 0 %}\n                                    ,\n                                {% endif %}\n                                {{ component('user_inline', {user: user, showAvatar: false, showNewIcon: true}) }}\n                                {% set i = i + 1 %}\n                            {% endfor %}\n                        </small>\n                    </div>\n                    <div>\n                        {{ component('user_inline', {user: lastMessage.sender, showAvatar: true, showNewIcon: true}) -}}<span>:</span>\n                        <a href=\"{{path('messages_single', {'id': thread.id}) }}\">\n                            {{ lastMessage.getTitle() }}\n                        </a>\n                    </div>\n                </div>\n                <span style=\"margin-top: auto\">{{ component('date', {date: thread.updatedAt}) }}</span>\n            </div>\n        {% endif %}\n    {% endfor %}\n    {% if threads|length == 0 %}\n        <aside class=\"section section--muted\">\n            <p>{{ 'empty'|trans }}</p>\n        </aside>\n    {% endif %}\n    {% if(threads.haveToPaginate is defined and threads.haveToPaginate) %}\n        {{ pagerfanta(threads, null, {'pageParameter':'[p]'}) }}\n    {% endif %}\n{% endblock %}\n"
  },
  {
    "path": "templates/messages/single.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    {{- 'message'|trans }} - {{ parent() -}}\n{%- endblock -%}\n\n{% block mainClass %}page-messages page-message{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n\n{% block javascripts %}\n    {{ encore_entry_script_tags('app') }}\n    <script>\n        window.addEventListener(\"load\", () => {\n            let view = document.getElementsByClassName('message-view')[0];\n            view.scrollTo({top: view.scrollHeight});\n        })\n    </script>\n{% endblock %}\n\n{% block sidebar_top %}\n{% endblock %}\n\n{% block body %}\n\n    <div class=\"message-view-container\">\n        <div class=\"section section--top\">\n            <button class=\"btn btn-link\">\n                <a href=\"{{ path('messages_front') }}\">\n                    <i class=\"fa-solid fa-arrow-left-long\" title=\"{{ 'back'|trans }}\"></i>\n                </a>\n            </button>\n            {% set i = 0 %}\n            {% set participants = thread.participants|filter(p => p is not same as app.user) %}\n            {% for user in participants %}\n                {% if i > 0 and i is same as (participants|length - 1) %}\n                    {{ 'and'|trans }}\n                {% elseif i > 0 %}\n                    ,\n                {% endif %}\n                {{ component('user_inline', {user: user, showNewIcon: true}) }}\n                {% set i = i + 1 %}\n            {% endfor %}\n        </div>\n\n        <div class=\"message-view\">\n            {% for message in thread.messages %}\n                <div id=\"message-{{ message.id }}\" class=\"{{ html_classes('message section content section--small', message.sender is same as app.user ? 'message-self' : 'message-other') }}\">\n                    <div class=\"meta\">{{ component('user_inline', {user: message.sender, showNewIcon: true}) }}</div>\n                    {{ message.body|markdown|raw }}\n                    <div class=\"text-right\">\n                        <small>\n                            {{ component('date', {date: message.createdAt}) }}\n                            {% if message.editedAt %}\n                                ({{ 'edited'|trans }} {{ component('date', {date: message.editedAt}) }})\n                            {% endif %}\n                        </small>\n                    </div>\n                </div>\n            {% endfor %}\n        </div>\n\n        {% include 'messages/_form_create.html.twig' %}\n    </div>\n{% endblock %}\n"
  },
  {
    "path": "templates/modlog/_blocks.html.twig",
    "content": "{% block log_entry_deleted %}\n    {{ component('user_inline', {user: log.user, showAvatar: showAvatars, showNewIcon: showNewIcons}) }} {{ 'removed_thread_by'|trans|lower }} {{ component('user_inline', {user: log.entry.user, showAvatar: showAvatars, showNewIcon: showNewIcons}) }}{% if showMagazine %} {{ 'in'|trans|lower }} {{ component('magazine_inline', {magazine: log.entry.magazine, showAvatar: showIcons, showNewIcon: showNewIcons}) }}{% endif %} -\n    <a href=\"{{ entry_url(log.entry) }}\">{{ log.entry.shortTitle(300) }}</a>\n{% endblock %}\n\n{% block log_entry_restored %}\n    {{ component('user_inline', {user: log.user, showAvatar: showAvatars, showNewIcon: showNewIcons}) }} {{ 'restored_thread_by'|trans|lower }} {{ component('user_inline', {user: log.entry.user, showAvatar: showAvatars, showNewIcon: showNewIcons}) }}{% if showMagazine %} {{ 'in'|trans|lower }} {{ component('magazine_inline', {magazine: log.entry.magazine, showAvatar: showIcons, showNewIcon: showNewIcons}) }}{% endif %} -\n    <a href=\"{{ entry_url(log.entry) }}\">{{ log.entry.shortTitle(300) }}</a>\n{% endblock %}\n\n{% block log_entry_comment_deleted %}\n    {{ component('user_inline', {user: log.user, showAvatar: showAvatars, showNewIcon: showNewIcons}) }} {{ 'removed_comment_by'|trans|lower }} {{ component('user_inline', {user: log.comment.user, showAvatar: showAvatars, showNewIcon: showNewIcons}) }}{% if showMagazine %} {{ 'in'|trans|lower }} {{ component('magazine_inline', {magazine: log.comment.magazine, showAvatar: showIcons, showNewIcon: showNewIcons}) }}{% endif %} -\n    <a href=\"{{ entry_comment_view_url(log.comment) }}#{{ get_url_fragment(log.comment) }}\">{{ log.comment.shortTitle(300) }}</a>\n{% endblock %}\n\n{% block log_entry_comment_restored %}\n    {{ component('user_inline', {user: log.user, showAvatar: showAvatars, showNewIcon: showNewIcons}) }} {{ 'restored_comment_by'|trans|lower }} {{ component('user_inline', {user: log.comment.user, showAvatar: showAvatars, showNewIcon: showNewIcons}) }}{% if showMagazine %} {{ 'in'|trans|lower }} {{ component('magazine_inline', {magazine: log.comment.magazine, showAvatar: showIcons, showNewIcon: showNewIcons}) }}{% endif %} -\n    <a href=\"{{ entry_comment_view_url(log.comment) }}#{{ get_url_fragment(log.comment) }}\">{{ log.comment.shortTitle(300) }}</a>\n{% endblock %}\n\n{% block log_post_deleted %}\n    {{ component('user_inline', {user: log.user, showAvatar: showAvatars, showNewIcon: showNewIcons}) }} {{ 'removed_post_by'|trans|lower }} {{ component('user_inline', {user: log.post.user, showAvatar: showAvatars, showNewIcon: showNewIcons}) }}{% if showMagazine %} {{ 'in'|trans|lower }} {{ component('magazine_inline', {magazine: log.post.magazine, showAvatar: showIcons, showNewIcon: showNewIcons}) }}{% endif %} -\n    <a href=\"{{ post_url(log.post) }}\">{{ log.post.shortTitle(300) }}</a>\n{% endblock %}\n\n{% block log_post_restored %}\n    {{ component('user_inline', {user: log.user, showAvatar: showAvatars, showNewIcon: showNewIcons}) }} {{ 'restored_post_by'|trans|lower }} {{ component('user_inline', {user: log.post.user, showAvatar: showAvatars, showNewIcon: showNewIcons}) }}{% if showMagazine %} {{ 'in'|trans|lower }} {{ component('magazine_inline', {magazine: log.post.magazine, showAvatar: showIcons, showNewIcon: showNewIcons}) }}{% endif %} -\n    <a href=\"{{ post_url(log.post) }}\">{{ log.post.shortTitle(300) }}</a>\n{% endblock %}\n\n{% block log_post_comment_deleted %}\n    {{ component('user_inline', {user: log.user, showAvatar: showAvatars, showNewIcon: showNewIcons}) }} {{ 'removed_comment_by'|trans|lower }} {{ component('user_inline', {user: log.comment.user, showAvatar: showAvatars, showNewIcon: showNewIcons}) }}{% if showMagazine %} {{ 'in'|trans|lower }} {{ component('magazine_inline', {magazine: log.comment.magazine, showAvatar: showIcons, showNewIcon: showNewIcons}) }}{% endif %} -\n    <a href=\"{{ post_url(log.comment.post) }}#{{ get_url_fragment(log.comment) }}\">{{ log.comment.shortTitle(300) }}</a>\n{% endblock %}\n\n{% block log_post_comment_restored %}\n    {{ component('user_inline', {user: log.user, showAvatar: showAvatars, showNewIcon: showNewIcons}) }} {{ 'restored_comment_by'|trans|lower }} {{ component('user_inline', {user: log.comment.user, showAvatar: showAvatars, showNewIcon: showNewIcons}) }}{% if showMagazine %} {{ 'in'|trans|lower }} {{ component('magazine_inline', {magazine: log.comment.magazine, showAvatar: showIcons, showNewIcon: showNewIcons}) }}{% endif %} -\n    <a href=\"{{ post_url(log.comment.post) }}#{{ get_url_fragment(log.comment) }}\">{{ log.comment.shortTitle(300) }}</a>\n{% endblock %}\n\n{% block log_ban %}\n    {{ component('user_inline', {user: log.user, showAvatar: showAvatars, showNewIcon: showNewIcons}) }}\n    {% if log.meta is same as 'ban' %}\n        {{ 'he_banned'|trans|lower }}\n    {% else %}\n        {{ 'he_unbanned'|trans|lower }}\n    {% endif %}\n    {{ component('user_inline', {user: log.ban.user, showAvatar: showAvatars, showNewIcon: showNewIcons}) }}\n    {% if showMagazine %} {{ 'in'|trans|lower }} {{ component('magazine_inline', {magazine: log.ban.magazine, showAvatar: showIcons, showNewIcon: showNewIcons}) }}{% endif %}{% if log.ban.reason %} - {{ log.ban.reason }}{% endif %}\n{% endblock %}\n\n{% block log_moderator_add %}\n    {% if log.actingUser is not same as null %}\n        {{ component('user_inline', {user: log.actingUser, showAvatar: showAvatars, showNewIcon: showNewIcons}) }}\n    {% else %}\n        {{ 'someone'|trans }}\n    {% endif %}\n    {{ 'magazine_log_mod_added'|trans -}}\n    {% if showMagazine %} {{ 'in'|trans|lower }} {{ component('magazine_inline', {magazine: log.magazine, showAvatar: showIcons, showNewIcon: showNewIcons}) -}}{%- endif -%}: {{ component('user_inline', {user: log.user, showAvatar: showAvatars, showNewIcon: showNewIcons}) }}\n{% endblock %}\n\n{% block log_moderator_remove %}\n    {% if log.actingUser is not same as null %}\n        {{ component('user_inline', {user: log.actingUser, showAvatar: showAvatars, showNewIcon: showNewIcons}) }}\n    {% else %}\n        {{ 'someone'|trans }}\n    {% endif %}\n    {{ 'magazine_log_mod_removed'|trans -}}\n    {% if showMagazine %} {{ 'from'|trans|lower }} {{ component('magazine_inline', {magazine: log.magazine, showAvatar: showIcons, showNewIcon: showNewIcons}) -}}{%- endif -%}: {{ component('user_inline', {user: log.user, showAvatar: showAvatars, showNewIcon: showNewIcons}) }}\n{% endblock %}\n\n{% block log_entry_pinned %}\n    {% if log.actingUser is not same as null %}\n        {{ component('user_inline', {user: log.actingUser, showAvatar: showAvatars, showNewIcon: showNewIcons}) }}\n    {% else %}\n        {{ 'someone'|trans }}\n    {% endif %}\n    {{ 'magazine_log_entry_pinned'|trans }}\n    <a href=\"{{ entry_url(log.entry) }}\">{{ log.entry.shortTitle(300) }}</a>\n    {% if showMagazine %} {{ 'in'|trans|lower }} {{ component('magazine_inline', {magazine: log.magazine, showAvatar: showIcons, showNewIcon: showNewIcons}) -}}{%- endif -%}\n{% endblock %}\n\n{% block log_entry_unpinned %}\n    {% if log.actingUser is not same as null %}\n        {{ component('user_inline', {user: log.actingUser, showAvatar: showAvatars, showNewIcon: showNewIcons}) }}\n    {% else %}\n        {{ 'someone'|trans }}\n    {% endif %}\n    {{ 'magazine_log_entry_unpinned'|trans }}\n    <a href=\"{{ entry_url(log.entry) }}\">{{ log.entry.shortTitle(300) }}</a>\n    {% if showMagazine %} {{ 'in'|trans|lower }} {{ component('magazine_inline', {magazine: log.magazine, showAvatar: showIcons, showNewIcon: showNewIcons}) -}}{%- endif -%}\n{% endblock %}\n\n{% block log_entry_locked %}\n    {{ component('user_inline', {user: log.user, showAvatar: showAvatars, showNewIcon: showNewIcons}) }}\n    {{ 'magazine_log_entry_locked'|trans }}\n    <a href=\"{{ entry_url(log.entry) }}\">{{ log.entry.shortTitle(300) }}</a>\n    {% if showMagazine %} {{ 'in'|trans|lower }} {{ component('magazine_inline', {magazine: log.magazine, showAvatar: showIcons, showNewIcon: showNewIcons}) -}}{%- endif -%}\n{% endblock %}\n\n{% block log_entry_unlocked %}\n    {{ component('user_inline', {user: log.user, showAvatar: showAvatars, showNewIcon: showNewIcons}) }}\n    {{ 'magazine_log_entry_unlocked'|trans }}\n    <a href=\"{{ entry_url(log.entry) }}\">{{ log.entry.shortTitle(300) }}</a>\n    {% if showMagazine %} {{ 'in'|trans|lower }} {{ component('magazine_inline', {magazine: log.magazine, showAvatar: showIcons, showNewIcon: showNewIcons}) -}}{%- endif -%}\n{% endblock %}\n\n{% block log_post_locked %}\n    {{ component('user_inline', {user: log.user, showAvatar: showAvatars, showNewIcon: showNewIcons}) }}\n    {{ 'magazine_log_entry_locked'|trans }}\n    <a href=\"{{ post_url(log.post) }}\">{{ log.post.shortTitle(300) }}</a>\n    {% if showMagazine %} {{ 'in'|trans|lower }} {{ component('magazine_inline', {magazine: log.magazine, showAvatar: showIcons, showNewIcon: showNewIcons}) -}}{%- endif -%}\n{% endblock %}\n\n{% block log_post_unlocked %}\n    {{ component('user_inline', {user: log.user, showAvatar: showAvatars, showNewIcon: showNewIcons}) }}\n    {{ 'magazine_log_entry_unlocked'|trans }}\n    <a href=\"{{ post_url(log.post) }}\">{{ log.post.shortTitle(300) }}</a>\n    {% if showMagazine %} {{ 'in'|trans|lower }} {{ component('magazine_inline', {magazine: log.magazine, showAvatar: showIcons, showNewIcon: showNewIcons}) -}}{%- endif -%}\n{% endblock %}\n"
  },
  {
    "path": "templates/modlog/front.html.twig",
    "content": "{% extends 'base.html.twig' %}\n{% set V_TRUE = constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::TRUE') %}\n{% set MBIN_MODERATION_LOG_SHOW_USER_AVATARS = constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::MBIN_MODERATION_LOG_SHOW_USER_AVATARS') %}\n{% set showAvatars = app.request.cookies.get(MBIN_MODERATION_LOG_SHOW_USER_AVATARS) is same as V_TRUE %}\n{% set MBIN_MODERATION_LOG_SHOW_MAGAZINE_ICONS = constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::MBIN_MODERATION_LOG_SHOW_MAGAZINE_ICONS') %}\n{% set showIcons = app.request.cookies.get(MBIN_MODERATION_LOG_SHOW_MAGAZINE_ICONS) is same as V_TRUE %}\n{% set MBIN_MODERATION_LOG_SHOW_NEW_ICONS = constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::MBIN_MODERATION_LOG_SHOW_NEW_ICONS') %}\n{% set showNewIcons = app.request.cookies.get(MBIN_MODERATION_LOG_SHOW_NEW_ICONS, V_TRUE) is same as V_TRUE %}\n{% use 'modlog/_blocks.html.twig' %}\n\n{% set hasMagazine = magazine is defined and magazine ? true : false %}\n\n{%- block title -%}\n    {% if hasMagazine %}\n        {{- 'mod_log'|trans }} - {{ magazine.title }} - {{ parent() -}}\n    {% else %}\n        {{- 'mod_log'|trans }} - {{ parent() -}}\n    {% endif %}\n{%- endblock -%}\n\n{% block mainClass %}page-modlog{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n{% endblock %}\n\n{% block body %}\n    <h1>{{ 'mod_log'|trans }}</h1>\n    <div class=\"alert alert__danger\">\n        <p>{{ 'mod_log_alert'|trans }}</p>\n    </div>\n\n    {{ form_start(form) }}\n    <div>\n        {{ form_widget(form.magazine, {'attr': {'onchange': '(function (e){ e.target.form.submit();})(event)'}}) }}\n    </div>\n    <div class=\"flex\">\n        <div class=\"flex-item\">\n            {{ form_widget(form.types) }}\n        </div>\n        <div>\n            <button class=\"btn btn__primary small\" type=\"submit\" title=\"{{ 'search'|trans }}\" aria-label=\"{{ 'search'|trans }}\">\n                <i class=\"fa-solid fa-magnifying-glass\" aria-hidden=\"true\"></i>\n            </button>\n        </div>\n    </div>\n    {{ form_end(form) }}\n\n    {% for log in logs %}\n        <div class=\"section section--small log\">\n            <div>\n                {%- with {\n                    log: log,\n                    showMagazine: not hasMagazine,\n                    showAvatars: showAvatars,\n                    showIcons: showIcons,\n                    showNewIcons: showNewIcons,\n                } only -%}\n                    {{ block(log.type) }}\n                {%- endwith -%}\n            </div>\n            <span>{{ component('date', {date: log.createdAt}) }}</span>\n        </div>\n    {% endfor %}\n    {% if(logs.haveToPaginate is defined and logs.haveToPaginate) %}\n        {{ pagerfanta(logs, null, {'pageParameter':'[p]'}) }}\n    {% endif %}\n    {% if not logs|length %}\n        <aside class=\"section section--muted\">\n            <p>{{ 'empty'|trans }}</p>\n        </aside>\n    {% endif %}\n{% endblock %}\n"
  },
  {
    "path": "templates/notifications/_blocks.html.twig",
    "content": "{% block entry_created_notification %}\n    {{ component('user_inline', {user: notification.entry.user, showNewIcon: true}) }} {{ 'added_new_thread'|trans|lower }} - <a\n        href=\"{{ entry_url(notification.entry) }}\">{{ notification.entry.shortTitle }}</a>\n{% endblock entry_created_notification %}\n\n{% block entry_edited_notification %}\n    {{ component('user_inline', {user: notification.entry.user, showNewIcon: true}) }} {{ 'edited_thread'|trans|lower }} - <a\n        href=\"{{ entry_url(notification.entry) }}\">{{ notification.entry.shortTitle }}</a>\n{% endblock entry_edited_notification %}\n\n{% block entry_deleted_notification %}\n    <a href=\"{{ entry_url(notification.entry) }}\">{{ notification.entry.shortTitle }}</a>\n    {% if notification.entry.isTrashed %}{{ 'removed'|trans|lower }}{% else %}{{ 'deleted'|trans|lower }}{% endif %}\n{% endblock entry_deleted_notification %}\n\n{% block entry_mentioned_notification %}\n    {{ component('user_inline', {user: notification.entry.user, showNewIcon: true}) }} {{ 'mentioned_you'|trans|lower }} - <a\n        href=\"{{ entry_url(notification.entry) }}\">{{ notification.entry.shortTitle }}</a>\n{% endblock entry_mentioned_notification %}\n\n{% block entry_comment_created_notification %}\n    {{ component('user_inline', {user: notification.comment.user, showNewIcon: true}) }} {{ 'added_new_comment'|trans|lower }} - <a\n        href=\"{{ entry_comment_view_url(notification.comment) }}#{{ get_url_fragment(notification.comment) }}\">{{ notification.comment.shortTitle }}</a>\n{% endblock entry_comment_created_notification %}\n\n{% block entry_comment_edited_notification %}\n    {{ component('user_inline', {user: notification.comment.user, showNewIcon: true}) }} {{ 'edited_comment'|trans|lower }} - <a\n        href=\"{{ entry_comment_view_url(notification.comment) }}#{{ get_url_fragment(notification.comment) }}\">{{ notification.comment.shortTitle }}</a>\n{% endblock entry_comment_edited_notification %}\n\n{% block entry_comment_reply_notification %}\n    {{ component('user_inline', {user: notification.comment.user, showNewIcon: true}) }} {{ 'replied_to_your_comment'|trans|lower }} - <a\n        href=\"{{ entry_comment_view_url(notification.comment) }}#{{ get_url_fragment(notification.comment) }}\">{{ notification.comment.shortTitle }}</a>\n{% endblock entry_comment_reply_notification %}\n\n{% block entry_comment_deleted_notification %}\n    {{ 'comment'|trans }} <a\n        href=\"{{ entry_url(notification.comment.entry) }}\">{{ notification.comment.shortTitle }}</a> -\n    {% if notification.comment.isTrashed %}{{ 'removed'|trans|lower }}{% else %}{{ 'deleted'|trans|lower }}{% endif %}\n{% endblock entry_comment_deleted_notification %}\n\n{% block entry_comment_mentioned_notification %}\n    {{ component('user_inline', {user: notification.comment.user, showNewIcon: true}) }} {{ 'mentioned_you'|trans|lower }} - <a\n        href=\"{{ entry_comment_view_url(notification.comment) }}#{{ get_url_fragment(notification.comment) }}\">{{ notification.comment.shortTitle }}</a>\n{% endblock entry_comment_mentioned_notification %}\n\n{% block post_created_notification %}\n    {{ component('user_inline', {user: notification.post.user, showNewIcon: true}) }} {{ 'added_new_post'|trans|lower }} - <a\n        href=\"{{ post_url(notification.post) }}\">{{ notification.post.shortTitle }}</a>\n{% endblock post_created_notification %}\n\n{% block post_edited_notification %}\n    {{ component('user_inline', {user: notification.post.user, showNewIcon: true}) }} {{ 'edit_post'|trans|lower }} - <a\n        href=\"{{ post_url(notification.post) }}\">{{ notification.post.shortTitle }}</a>\n{% endblock post_edited_notification %}\n\n{% block post_deleted_notification %}\n    {{ 'post'|trans }} <a\n        href=\"{{ post_url(notification.post) }}\">{{ notification.post.shortTitle }}</a> -\n    {% if notification.comment.isTrashed %}{{ 'removed'|trans|lower }}{% else %}{{ 'deleted'|trans|lower }}{% endif %}\n{% endblock post_deleted_notification %}\n\n{% block post_mentioned_notification %}\n    {{ component('user_inline', {user: notification.post.user, showNewIcon: true}) }} {{ 'mentioned_you'|trans|lower }} - <a\n        href=\"{{ post_url(notification.post) }}\">{{ notification.post.shortTitle }}</a>\n{% endblock post_mentioned_notification %}\n\n{% block post_comment_created_notification %}\n    {{ component('user_inline', {user: notification.comment.user, showNewIcon: true}) }} {{ 'added_new_comment'|trans|lower }} - <a\n        href=\"{{ post_url(notification.comment.post) }}#{{ get_url_fragment(notification.comment) }}\">{{ notification.comment.shortTitle }}</a>\n{% endblock post_comment_created_notification %}\n\n{% block post_comment_edited_notification %}\n    {{ component('user_inline', {user: notification.comment.user, showNewIcon: true}) }} {{ 'edited_comment'|trans|lower }} - <a\n        href=\"{{ post_url(notification.comment.post) }}#{{ get_url_fragment(notification.comment) }}\">{{ notification.comment.shortTitle }}</a>\n{% endblock post_comment_edited_notification %}\n\n{% block post_comment_reply_notification %}\n    {{ component('user_inline', {user: notification.comment.user, showNewIcon: true}) }} {{ 'replied_to_your_comment'|trans|lower }} - <a\n        href=\"{{ post_url(notification.comment.post) }}#{{ get_url_fragment(notification.comment) }}\">{{ notification.comment.shortTitle }}</a>\n{% endblock post_comment_reply_notification %}\n\n{% block post_comment_deleted_notification %}\n    {{ 'comment'|trans }} <a\n        href=\"{{ post_url(notification.comment.post) }}\">{{ notification.comment.shortTitle }}</a> -\n    {% if notification.comment.isTrashed %}{{ 'removed'|trans|lower }}{% else %}{{ 'deleted'|trans|lower }}{% endif %}\n{% endblock post_comment_deleted_notification %}\n\n{% block post_comment_mentioned_notification %}\n    {{ component('user_inline', {user: notification.comment.user, showNewIcon: true}) }} {{ 'mentioned_you'|trans|lower }} - <a\n        href=\"{{ post_url(notification.comment.post) }}#{{ get_url_fragment(notification.comment) }}\">{{ notification.comment.shortTitle }}</a>\n{% endblock post_comment_mentioned_notification %}\n\n{% block message_notification %}\n    {{ component('user_inline', {user: notification.message.sender, showNewIcon: true}) }} {{ 'wrote_message'|trans|lower }} <a\n        href=\"{{ path('messages_single', {'id': notification.message.thread.id}) }}\">{{ notification.message.title }}</a>\n{% endblock message_notification %}\n\n{% block magazine_ban_notification %}\n    {% if notification.ban.expiredAt is not same as null -%}\n        {{ 'you_have_been_banned_from_magazine'|trans({'%m': component('magazine_inline', {'magazine': notification.ban.magazine})})|raw }}\n        <div>\n            {% if now() < notification.ban.expiredAt %}\n                {{ 'ban_expires'|trans }}:\n            {% else %}\n                {{ 'ban_expired'|trans }}:\n            {% endif %}\n            {{ component('date', {date: notification.ban.expiredAt}) -}}.\n        </div>\n    {% else -%}\n        {{ 'you_have_been_banned_from_magazine_permanently'|trans({'%m': component('magazine_inline', {'magazine': notification.ban.magazine})})|raw }}\n    {% endif -%}\n    <div>\n        {{ 'reason'|trans }}: {{ notification.ban.reason }}\n    </div>\n{% endblock magazine_ban_notification %}\n\n{% block magazine_unban_notification %}\n    {{ 'you_are_no_longer_banned_from_magazine'|trans({'%m': component('magazine_inline', {'magazine': notification.ban.magazine})})|raw }}\n{% endblock magazine_unban_notification %}\n\n{% block reportlink %}\n    {% if notification.report.entry is defined and notification.report.entry is not same as null %}\n        {% set entry = notification.report.entry %}\n        <a href=\"{{ path('entry_single', { 'magazine_name': entry.magazine.name, 'entry_id': entry.id, 'slug': entry.slug }) }}\">\n            {{ entry.title }}\n        </a>\n    {% elseif notification.report.entryComment is defined and notification.report.entryComment is not same as null %}\n        {% set entryComment = notification.report.entryComment %}\n        <a href=\"{{ path('entry_comment_view', { 'magazine_name': entryComment.magazine.name, 'entry_id': entryComment.entry.id, 'slug': entryComment.entry.slug, 'comment_id': entryComment.id }) }}\">\n            {{ entryComment.getShortTitle() }}\n        </a>\n    {% elseif notification.report.post is defined and notification.report.post is not same as null %}\n        {% set post = notification.report.post %}\n        <a href=\"{{ path('post_single', { 'magazine_name': post.magazine.name, 'post_id': post.id, 'slug': post.slug }) }}\">\n            {{ post.getShortTitle() }}\n        </a>\n    {% elseif notification.report.postComment is defined and notification.report.postComment is not same as null %}\n        {% set postComment = notification.report.postComment %}\n        <a href=\"{{ path('post_single', { 'magazine_name': postComment.post.magazine.name, 'post_id': postComment.post.id, 'slug': postComment.post.slug }) }}#post-comment-{{ postComment.id }}\">\n            {{ postComment.getShortTitle() }}\n        </a>\n    {% endif %}\n{% endblock %}\n\n{% block report_created_notification %}\n    {{ component('user_inline', {user: notification.report.reporting, showNewIcon: true}) }} {{ 'reported'|trans|lower }} {{ component('user_inline', {user: notification.report.reported, showNewIcon: true}) }}<br />\n    {{ 'report_subject'|trans }}: {{ block('reportlink') }}<br />\n    {% if app.user.admin or app.user.moderator or notification.report.magazine.userIsModerator(app.user) %}\n        <a href=\"{{ path('magazine_panel_reports', { 'name': notification.report.magazine.name, 'status': 'pending' }) }}#report-id-{{ notification.report.id }}\">{{ 'open_report'|trans }}</a>\n    {% endif %}\n{% endblock report_created_notification %}\n\n{% block report_rejected_notification %}\n    {{ 'own_report_rejected'|trans }} <br />\n    {{ 'reported_user'|trans }}: {{ component('user_inline', {user: notification.report.reported, showNewIcon: true}) }}<br />\n    {{ 'report_subject'|trans }}: {{ block('reportlink') }}<br />\n    {% if app.user.admin or app.user.moderator or notification.report.magazine.userIsModerator(app.user) %}\n        <a href=\"{{ path('user_settings_reports', {'status': 'rejected' }) }}#report-id-{{ notification.report.id }}\">{{ 'open_report'|trans }}</a>\n    {% endif %}\n{% endblock report_rejected_notification %}\n\n{% block report_approved_notification %}\n    {% if notification.report.reporting.id is same as app.user.id %}\n        {{ 'own_report_accepted'|trans }}<br />\n    {% elseif notification.report.reported.id is same as app.user.id %}\n        {{ 'own_content_reported_accepted'|trans }}<br />\n    {% else %}\n        {{ 'report_accepted'|trans }}<br />\n        {{ 'reported_user'|trans }}: {{ component('user_inline', {user: notification.report.reported, showNewIcon: true}) }}<br />\n        {{ 'reporting_user'|trans }}: {{ component('user_inline', {user: notification.report.reported, showNewIcon: true}) }}<br />\n    {% endif %}\n    {{ 'report_subject'|trans }}: {{ block('reportlink') }}<br />\n    {% if app.user.admin or app.user.moderator or notification.report.magazine.userIsModerator(app.user) %}\n        <a href=\"{{ path('magazine_panel_reports', { 'name': notification.report.magazine.name, 'status': 'approved' }) }}#report-id-{{ notification.report.id }}\">{{ 'open_report'|trans }}</a>\n    {% endif %}\n{% endblock report_approved_notification %}\n\n{% block new_signup %}\n    {{ 'notification_title_new_signup'|trans }}<br />\n    {{ component('user_inline', { user: notification.newUser, showNewIcon: true } ) }}\n    {% if do_new_users_need_approval() and notification.newUser.applicationStatus is not same as enum('App\\\\Enums\\\\EApplicationStatus').Approved %}\n        <div>\n            <a href=\"{{ path('admin_signup_requests') }}?username={{ notification.newUser.username }}\">\n                {{ 'open_signup_request'|trans }}\n            </a>\n        </div>\n    {% endif %}\n{% endblock %}\n"
  },
  {
    "path": "templates/notifications/front.html.twig",
    "content": "{% extends 'base.html.twig' %}\n{% use 'notifications/_blocks.html.twig' %}\n\n{%- block title -%}\n    {{- 'notifications'|trans }} - {{ parent() -}}\n{%- endblock -%}\n\n{% block mainClass %}page-notifications{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n{% endblock %}\n\n{% block body %}\n    <h1>{{ 'notifications'|trans }}</h1>\n\n    <div class=\"pills\">\n        <div>\n            <form action=\"{{ path('notifications_read') }}\" method=\"POST\" class=\"me-2\">\n                <button class=\"btn btn__primary\" type=\"submit\">\n                    {{ 'read_all'|trans }}\n                </button>\n            </form>\n            <form method=\"post\" action=\"{{ path('notifications_clear') }}\"\n                  data-action=\"confirmation#ask\" data-confirmation-message-param=\"{{ 'are_you_sure'|trans }}\">\n                <button class=\"btn btn__secondary\" type=\"submit\">\n                    <i class=\"fa-solid fa-dumpster\" aria-hidden=\"true\"></i> <span>{{ 'purge'|trans }}</span>\n                </button>\n            </form>\n\n            <div data-controller=\"push\" id=\"push-subscription-div\" data-application-server-public-key=\"{{ applicationServerKey }}\" style=\"margin-left: auto;\">\n                <button id=\"push-subscription-test-btn\" data-action=\"push#testPush\" class=\"btn btn__secondary\" style=\"height: auto;\">\n                    {{ 'test_push_notifications_button'|trans }}\n                </button>\n                <button id=\"push-subscription-register-btn\" data-action=\"push#registerPush\" class=\"btn btn__primary\" style=\"height: auto;\">\n                    {{ 'register_push_notifications_button'|trans }}\n                </button>\n                <button id=\"push-subscription-unregister-btn\" data-action=\"push#unregisterPush\" class=\"btn btn__secondary\" style=\"height: auto;\">\n                    {{ 'unregister_push_notifications_button'|trans }}\n                </button>\n            </div>\n        </div>\n\n    </div>\n\n    {% for notification in notifications %}\n        <div class=\"{{ html_classes('section section--small notification', {'opacity-50': notification.status is not same as 'new' }) }}\">\n            <div>\n                {%- with {\n                    notification: notification,\n                    showMagazine: false,\n                } only -%}\n                    {{ block(notification.type) }}\n                {%- endwith -%}\n            </div>\n            <span>{{ component('date', {date: notification.createdAt}) }}</span>\n        </div>\n    {% endfor %}\n    {% if(notifications.haveToPaginate is defined and notifications.haveToPaginate) %}\n        {{ pagerfanta(notifications, null, {'pageParameter':'[p]'}) }}\n    {% endif %}\n    {% if not notifications|length %}\n        <aside class=\"section section--muted\">\n            <p>{{ 'empty'|trans }}</p>\n        </aside>\n    {% endif %}\n{% endblock %}\n"
  },
  {
    "path": "templates/page/about.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    {{- 'about_instance'|trans }} - {{ parent() -}}\n{%- endblock -%}\n\n{% block mainClass %}page-about{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n{% endblock %}\n\n{% block body %}\n    <h1>{{ 'about_instance'|trans }}</h1>\n    <div id=\"content\" class=\"section\">\n        {{ body|markdown|raw }}\n    </div>\n{% endblock %}\n"
  },
  {
    "path": "templates/page/agent.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    {{- 'kbin_bot'|trans }} - {{ parent() -}}\n{%- endblock -%}\n\n{% block mainClass %}page-bot{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n{% endblock %}\n\n{% block body %}\n    <h1>{{ 'kbin_bot'|trans }}</h1>\n    <div id=\"content\" class=\"section\">\n        {{- 'bot_body_content'|trans|nl2br }}\n    </div>\n{% endblock %}\n"
  },
  {
    "path": "templates/page/contact.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    {{- 'contact'|trans }} - {{ parent() -}}\n{%- endblock -%}\n\n{% block mainClass %}page-contact page-settings{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n{% endblock %}\n\n{% block body %}\n    <h1 hidden>{{ 'contact'|trans }}</h1>\n    {% include 'layout/_flash.html.twig' %}\n    <div id=\"content\" class=\"section section--top\">\n        <div class=\"container\">\n            {{ form_start(form) }}\n            {{ form_row(form.name, {label: 'firstname'}) }}\n            {{ form_row(form.email, {label: 'email'}) }}\n            {{ form_row(form.message, {label: 'message'}) }}\n            {{ form_row(form.surname, {label: false, attr: {style: 'display:none !important'}}) }}\n            {% if kbin_captcha_enabled() %}\n                    {{ form_row(form.captcha, {\n                        label: false\n                    }) }}\n                {% endif %}\n            <div class=\"actions\">\n                {{ form_row(form.submit, {label: 'send', attr: {class: 'btn btn__primary'}}) }}\n            </div>\n            {{ form_end(form) }}\n        </div>\n    </div>\n    {% if body %}\n        <div class=\"section\">\n            {{ body|markdown|raw }}\n        </div>\n    {% endif %}\n{% endblock %}\n"
  },
  {
    "path": "templates/page/faq.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    {{- 'faq'|trans }} - {{ parent() -}}\n{%- endblock -%}\n\n{% block mainClass %}page-faq{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n{% endblock %}\n\n{% block body %}\n    <h1>{{ 'faq'|trans }}</h1>\n    <div id=\"content\" class=\"section\">\n        {{ body|markdown|raw }}\n    </div>\n{% endblock %}\n"
  },
  {
    "path": "templates/page/federation.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    {{- 'federation'|trans }} - {{ parent() -}}\n{%- endblock -%}\n\n{% block mainClass %}page-federation{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n{% endblock %}\n\n{% block body %}\n\n    <h1>{{ 'federation'|trans }}</h1>\n    <div class=\"section\">\n        <h3 id=\"toc\" style=\"margin-top: 0\">{{ 'table_of_contents'|trans }}</h3>\n        <ol>\n            <li>\n                <a href=\"#allowed-instances\">{{ 'federation_page_allowed_description'|trans }}</a>\n            </li>\n            <li>\n                <a href=\"#banned-instances\">{{ 'federation_page_disallowed_description'|trans }}</a>\n            </li>\n            <li>\n                <a href=\"#dead-instances\">{{ 'federation_page_dead_title'|trans }}</a>\n            </li>\n        </ol>\n    </div>\n    <div class=\"section\">\n        <h3 id=\"allowed-instances\" style=\"margin-top: 0\">{{'federation_page_allowed_description'|trans}}</h3>\n        {% if allowedInstances is not empty %}\n            {{ component('instance_list', {'instances': allowedInstances}) }}\n        {% else %}\n            <aside class=\"section--muted\">\n                <p>{{ 'empty'|trans }}</p>\n            </aside>\n        {% endif %}\n    </div>\n    <div class=\"section\">\n        <h3 id=\"banned-instances\" style=\"margin-top: 0\">{{'federation_page_disallowed_description'|trans}}</h3>\n        {% if defederatedInstances is not empty %}\n            {{ component('instance_list', {'instances': defederatedInstances}) }}\n        {% else %}\n            <aside class=\"section--muted\">\n                <p>{{ 'empty'|trans }}</p>\n            </aside>\n        {% endif %}\n    </div>\n    <div class=\"section\">\n        <h3 id=\"dead-instances\" style=\"margin-top: 0\">{{'federation_page_dead_title'|trans}}</h3>\n        <p>{{ 'federation_page_dead_description'|trans }}</p>\n\n        {% if deadInstances is not empty %}\n            {{ component('instance_list', {'instances': deadInstances}) }}\n        {% else %}\n            <aside class=\"section--muted\">\n                <p>{{ 'empty'|trans }}</p>\n            </aside>\n        {% endif %}\n    </div>\n{% endblock %}\n"
  },
  {
    "path": "templates/page/privacy_policy.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    {{- 'privacy_policy'|trans }} - {{ parent() -}}\n{%- endblock -%}\n\n{% block mainClass %}page-privacy-policy{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n{% endblock %}\n\n{% block body %}\n    <h1>{{ 'privacy_policy'|trans }}</h1>\n    <div id=\"content\" class=\"section\">\n        {{ body|markdown|raw }}\n    </div>\n{% endblock %}\n"
  },
  {
    "path": "templates/page/terms.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    {{- 'terms'|trans }} - {{ parent() -}}\n{%- endblock -%}\n\n{% block mainClass %}page-terms{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n{% endblock %}\n\n{% block body %}\n    <h1>{{ 'terms'|trans }}</h1>\n    <div id=\"content\" class=\"section\">\n        {{ body|markdown|raw }}\n    </div>\n{% endblock %}\n"
  },
  {
    "path": "templates/people/front.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    {% if magazine is defined and magazine %}\n        {{- 'people'|trans }} - {{ magazine.title }} - {{ parent() -}}\n    {% else %}\n        {{- 'people'|trans }} - {{ parent() -}}\n    {% endif %}\n{%- endblock -%}\n\n{% block mainClass %}page-people{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n{% endblock %}\n\n{% block body %}\n    <aside class=\"options options--top\" id=\"options\">\n        <div></div>\n        <menu class=\"options__main\">\n            {% for mag in magazines %}\n                <li>\n                    <a href=\"{{ path('magazine_people', {name: mag.name}) }}\">\n                        {{ mag.name }}\n                    </a>\n                </li>\n            {% endfor %}\n        </menu>\n    </aside>\n\n    <div id=\"content\">\n        <h1 hidden>{{ 'people'|trans }}</h1>\n        <h2>{{ 'people_local'|trans }}</h2>\n        <div class=\"users users-cards section section--no-bg section--no-border\">\n            {% for user in local %}\n                <div class=\"section\">\n                    {{ component('user_box', {user: user}) }}\n                </div>\n            {% endfor %}\n        </div>\n        {% if not local|length %}\n            <aside class=\"section section--muted\">\n                <p>{{ 'empty'|trans }}</p>\n            </aside>\n        {% endif %}\n\n        <h2>{{ 'people_federated'|trans }}</h2>\n        <div class=\"users users-cards section section--no-bg section--no-border\">\n            {% for user in federated %}\n                <div class=\"section\">\n                    {{ component('user_box', {user: user}) }}\n                </div>\n            {% endfor %}\n        </div>\n        {% if not federated|length %}\n            <aside class=\"section section--muted\">\n                <p>{{ 'empty'|trans }}</p>\n            </aside>\n        {% endif %}\n    </div>\n{% endblock %}\n"
  },
  {
    "path": "templates/post/_form_post.html.twig",
    "content": "{% form_theme form.lang 'form/lang_select.html.twig' %}\n\n{% set hasImage = false %}\n{% if post is defined and post is not null and post.image %}\n    {% set hasImage = true %}\n{% endif %}\n{% if edit is not defined %}\n    {% set edit = false %}\n{% endif %}\n{% if edit %}\n    {% set title = 'edit_post'|trans %}\n    {% set action = path('post_edit', {magazine_name: post.magazine.name, post_id: post.id}) %}\n{% else %}\n    {% set title = 'add_post'|trans %}\n    {% set action = path('post_create') %}\n{% endif %}\n\n{% if attributes is not defined %}\n    {% set attributes = {} %}\n{% endif %}\n\n<h3 hidden>{{ title }}</h3>\n{{ form_start(form, {action: action, attr: {class: edit ? 'post-edit replace' : 'post-add'}|merge(attributes)}) }}\n<div class=\"row\">\n    {{ component('editor_toolbar', {id: 'post_body'}) }}\n    {{ form_row(form.body, {label: false, attr: {\n        'data-controller': 'input-length rich-textarea autogrow',\n        'data-action' : 'input-length#updateDisplay',\n        'data-input-length-max-value': constant('App\\\\DTO\\\\PostDto::MAX_BODY_LENGTH')\n        }}) }}\n</div>\n<div class=\"row params\">\n    {{ form_row(form.isAdult, {label:'is_adult'}) }}\n    {{ form_row(form.magazine, {label: false, attr: {placeholder: false}}) }}\n</div>\n<div class=\"row actions\">\n    <ul>\n        {% if hasImage %}\n            <img width=\"40\"\n                 height=\"40\"\n                 src=\"{{ post.image.filePath ? (asset(post.image.filePath)|imagine_filter('post_thumb')) : post.image.sourceUrl }}\"\n                 alt=\"{{ post.image.altText }}\">\n            <button formaction=\"{{ path('post_image_delete', {magazine_name: post.magazine.name, post_id: post.id}) }}\"\n                    class=\"btn-link\"\n                    aria-label=\"{{ 'remove_media'|trans }}\"\n                    title=\"{{ 'remove_media'|trans }}\"\n                    data-action=\"confirmation#ask subject#removeImage\"\n                    data-confirmation-message-param=\"{{ 'are_you_sure'|trans }}\">\n                <i class=\"fa-solid fa-xmark\" aria-hidden=\"true\"></i>\n            </button>\n        {% endif %}\n        <li class=\"{{ html_classes('dropdown', {'hidden': hasImage}) }}\">\n            <button type=\"button\"\n                    class=\"btn btn__secondary\"\n                    aria-label=\"{{ 'add_media'|trans }}\"\n                    title=\"{{ 'add_media'|trans }}\">\n                <i class=\"fa-solid fa-photo-film\" aria-hidden=\"true\"></i>\n            </button>\n            <div class=\"dropdown__menu\">\n                {% include 'layout/_form_media.html.twig' %}\n            </div>\n        </li>\n        <li class=\"select\">\n            {{ form_row(form.lang, {label: false}) }}\n        </li>\n        <li>\n            {{ form_row(form.submit, {label: edit ? 'edit_post' : 'add_post', attr: {class: 'btn btn__primary', 'data-action': 'subject#sendForm'}}) }}\n        </li>\n    </ul>\n</div>\n{{ form_end(form) }}\n"
  },
  {
    "path": "templates/post/_info.html.twig",
    "content": "<section class=\"section entry-info\">\n    <h3>{{ 'thread'|trans }}</h3>\n    <div class=\"row\">\n        {% if post.user.avatar %}\n            <figure>\n                <img class=\"image-inline\"\n                     width=\"100\" height=\"100\"\n                     loading=\"lazy\"\n                     src=\"{{ post.user.avatar.filePath ? (asset(post.user.avatar.filePath)|imagine_filter('avatar_thumb')) : post.user.avatar.sourceUrl }}\"\n                     alt=\"{{ post.user.username ~' '~ 'avatar'|trans|lower }}\">\n            </figure>\n        {% endif %}\n        <h4><a href=\"{{ path('user_overview', {username:post.user.username}) }}\">{{ post.user.username|username(false) }}</a></h4>\n        <p class=\"user__name\">\n            <span>\n                {{ post.user.username|username(true) }}\n                {% if post.user.apManuallyApprovesFollowers is same as true %}\n                    <i class=\"fa-solid fa-lock\" aria-description=\"{{ 'manually_approves_followers'|trans }}\" title=\"{{ 'manually_approves_followers'|trans }}\" aria-describedby=\"{{ 'manually_approves_followers'|trans }}\"></i>\n                {% endif %}\n            </span>\n            {% if post.user.apProfileId %}\n                <a href=\"{{ post.user.apProfileId }}\" rel=\"noopener noreferrer nofollow\" target=\"_blank\" title=\"{{ 'go_to_original_instance'|trans }}\" aria-label=\"{{ 'go_to_original_instance'|trans }}\">\n                <i class=\"fa-solid fa-external-link\" aria-hidden=\"true\"></i></a>\n            {% endif %}\n        </p>\n    </div>\n    {{ component('user_actions', {user: post.user}) }}\n    {% if app.user is defined and app.user is not same as null and app.user is not same as post.user %}\n        <div class=\"notification-switch-container\" data-controller=\"html-refresh\">\n            {{ component('notification_switch', {target: post.user}) }}\n        </div>\n    {% endif %}\n    <ul class=\"info\">\n        <li>{{ 'added'|trans }}: {{ component('date', {date: post.createdAt}) }}</li>\n        <li>{{ 'up_votes'|trans }}:\n            <span>{{ post.countUpvotes }}</span>\n        </li>\n    </ul>\n</section>\n"
  },
  {
    "path": "templates/post/_list.html.twig",
    "content": "{%- set V_TRUE = constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::TRUE') -%}\n{%- set V_FALSE = constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::FALSE') -%}\n\n{%- set SHOW_COMMENT_USER_AVATARS = app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_COMMENTS_SHOW_USER_AVATAR'), V_TRUE) -%}\n{%- set SHOW_POST_USER_AVATARS = app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_POSTS_SHOW_USERS_AVATARS'), V_TRUE) -%}\n{%- set DYNAMIC_LISTS = app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_GENERAL_DYNAMIC_LISTS'), V_FALSE) -%}\n{%- set INFINITE_SCROLL = app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_GENERAL_INFINITE_SCROLL'), V_FALSE) -%}\n\n<div data-controller=\"subject-list\"\n     class=\"{{ html_classes({\n         'show-comment-avatar': SHOW_COMMENT_USER_AVATARS is same as V_TRUE,\n         'show-post-avatar': SHOW_POST_USER_AVATARS is same as V_TRUE\n     }) }}\"\n     data-action=\"{{- DYNAMIC_LISTS is same as V_TRUE ? 'notifications:PostCreatedNotification@window->subject-list#addMainSubject notifications:PostCommentCreatedNotification@window->subject-list#addCommentOverview' : 'notifications:PostCreatedNotification@window->subject-list#increaseCounter' -}}\">\n    {% for post in posts %}\n        {{ component('post', {\n            post: post,\n            showMagazineName: magazine is not defined or not magazine,\n            showCommentsPreview: true\n        }) }}\n    {% endfor %}\n    {% if(posts.haveToPaginate is defined and posts.haveToPaginate) %}\n        {% if INFINITE_SCROLL is same as V_TRUE %}\n            <div data-controller=\"infinite-scroll\" class=\"infinite-scroll\">\n                {{ component('loader', {'data-infinite-scroll-target': 'loader'}) }}\n                <div data-infinite-scroll-target=\"pagination\" class=\"visually-hidden\">\n                    {{ pagerfanta(posts, null, {'pageParameter':'[p]'}) }}\n                </div>\n            </div>\n        {% else %}\n            {{ pagerfanta(posts, null, {'pageParameter':'[p]'}) }}\n        {% endif %}\n    {% endif %}\n    {% if not posts|length %}\n        <aside class=\"section section--muted\">\n            <p>{{ 'empty'|trans }}</p>\n        </aside>\n    {% endif %}\n</div>\n"
  },
  {
    "path": "templates/post/_menu.html.twig",
    "content": "<li class=\"dropdown\">\n    <button class=\"stretched-link\" data-subject-target=\"more\">{{ 'more'|trans }}</button>\n    <ul class=\"dropdown__menu\" data-controller=\"clipboard\">\n        <li>\n            <a href=\"{{ path('post_report', {id: post.id}) }}\"\n                class=\"{{ html_classes({'active': is_route_name('post_report')}) }}\"\n                data-action=\"subject#getForm\">\n                {{ 'report'|trans }}\n            </a>\n        </li>\n        <li>\n            <a href=\"{{ post_voters_url(post, 'up') }}\"\n                class=\"{{ html_classes({'active': is_route_name('post_favourites') or is_route_name('post_voters')}) }}\">\n                {{ 'activity'|trans }}\n            </a>\n        </li>\n\n        {% if app.user is defined and app.user is not same as null %}\n            {% set bookmarkLists = get_bookmark_lists(app.user) %}\n            {% if bookmarkLists|length %}\n                <li class=\"dropdown__separator\"></li>\n                {% for list in bookmarkLists %}\n                    {{ component('bookmark_list', { subject: post, subjectType: 'post', list: list }) }}\n                {% endfor %}\n            {% endif %}\n        {% endif %}\n\n        <li class=\"dropdown__separator\"></li>\n        <li>\n            <a target=\"_blank\"\n                rel=\"{{ get_rel(post.apId ?? path('ap_post', {magazine_name: post.magazine.name, post_id: post.id})) }}\"\n                href=\"{{ post.apId ?? path('ap_post', {magazine_name: post.magazine.name, post_id: post.id}) }}\">\n                {{ 'open_url_to_fediverse'|trans }}\n            </a>\n        </li>\n        <li>\n            <a data-action=\"clipboard#copy\"\n                rel=\"{{ get_rel(post.apId ?? path('ap_post', {magazine_name: post.magazine.name, post_id: post.id})) }}\"\n                href=\"{{ post.apId ?? path('ap_post', {magazine_name: post.magazine.name, post_id: post.id}) }}\">\n                {{ 'copy_url_to_fediverse'|trans }}\n            </a>\n        </li>\n        <li>\n            <a data-action=\"clipboard#copy\" href=\"{{ post_url(post) }}\">\n                {{ 'copy_url'|trans }}\n            </a>\n        </li>\n        {% if is_granted('edit', post) or (app.user and post.isAuthor(app.user)) or is_granted('moderate', post) %}\n            <li class=\"dropdown__separator\"></li>\n        {% endif %}\n        {% if is_granted('edit', post) %}\n            <li>\n                <a href=\"{{ post_edit_url(post) }}\"\n                    class=\"{{ html_classes({'active': is_route_name('post_edit')}) }}\"\n                    data-action=\"subject#getForm\">\n                    {{ 'edit'|trans }}\n                </a>\n            </li>\n        {% endif %}\n        {% if app.user and post.isAuthor(app.user) %}\n            <li>\n                <form method=\"post\"\n                        action=\"{{ post_delete_url(post) }}\"\n                        data-action=\"confirmation#ask\" data-confirmation-message-param=\"{{ 'are_you_sure'|trans }}\">\n                    <button type=\"submit\">{{ 'delete'|trans }}</button>\n                </form>\n            </li>\n        {% endif %}\n\n        {% if is_granted('lock', post) %}\n            <li>\n                <form method=\"post\" action=\"{{ path('post_lock', {'magazine_name': post.magazine.name, 'post_id': post.id, 'slug': post.slug}) }}\" data-action=\"confirmation#ask\"\n                    data-confirmation-message-param=\"{{ 'are_you_sure'|trans }}\">\n                    <input type=\"hidden\" name=\"token\" value=\"{{ csrf_token('post_lock') }}\">\n                    {% if post.isLocked %}\n                        <button type=\"submit\"><i class=\"fa fa-lock-open\" aria-hidden=\"true\" style=\"margin-right: .5em;\"></i>{{ 'unlock'|trans }}</button>\n                    {% else %}\n                        <button type=\"submit\"><i class=\"fa fa-lock\" aria-hidden=\"true\" style=\"margin-right: .5em;\"></i>{{ 'lock'|trans }}</button>\n                    {% endif %}\n                </form>\n            </li>\n        {% endif %}\n        {% if is_granted('moderate', post) %}\n            <li>\n                <a href=\"{{ post_moderate_url(post) }}\"\n                    class=\"{{ html_classes({'active': is_route_name('post_moderate')}) }}\"\n                    data-action=\"subject#showModPanel\">\n                    {{ 'moderate'|trans }}\n                </a>\n            </li>\n        {% endif %}\n    </ul>\n</li>\n"
  },
  {
    "path": "templates/post/_moderate_panel.html.twig",
    "content": "<div class=\"moderate-panel\">\n    <menu>\n        <li>\n            <form action=\"{{ path('post_pin', {'magazine_name': post.magazine.name, 'post_id': post.id}) }}\"\n                  method=\"post\">\n                <input type=\"hidden\" name=\"token\" value=\"{{ csrf_token('post_pin') }}\">\n                <button type=\"submit\" class=\"btn btn__secondary\">\n                    <i class=\"fa fa-thumbtack\" aria-hidden=\"true\"></i> <span>{{ post.sticky ? 'unpin'|trans : 'pin'|trans }}</span>\n                </button>\n            </form>\n        </li>\n        <li>\n            <form action=\"{{ path('post_lock', {'magazine_name': post.magazine.name, 'post_id': post.id}) }}\"\n                  method=\"post\">\n                <input type=\"hidden\" name=\"token\" value=\"{{ csrf_token('post_lock') }}\">\n                <button type=\"submit\" class=\"btn btn__secondary\">\n                    {% if post.isLocked %}\n                        <i class=\"fa fa-lock-open\" aria-hidden=\"true\"></i>\n                    {% else %}\n                        <i class=\"fa fa-lock\" aria-hidden=\"true\"></i>\n                    {% endif %}\n                    <span>{{ post.isLocked ? 'unlock'|trans : 'lock'|trans }}</span>\n                </button>\n            </form>\n        </li>\n        <li>\n            <form action=\"{{ path('post_change_adult', {magazine_name: magazine.name, post_id: post.id}) }}\"\n                  method=\"post\">\n                <input type=\"hidden\" name=\"token\" value=\"{{ csrf_token('change_adult') }}\">\n                <input name=\"adult\"\n                       type=\"hidden\" value=\"{{ post.isAdult ? 'off' : 'on' }}\">\n                <button type=\"submit\" class=\"btn btn__secondary\">\n                    <i class=\"fa fa-{{ post.isAdult ? 'eye' : 'eye-slash' }}\" aria-hidden=\"true\"></i> <span>{{ post.isAdult ? 'unmark_as_adult'|trans : 'mark_as_adult'|trans }}</span>\n                </button>\n            </form>\n        </li>\n        <li>\n            <form action=\"{{ path('magazine_panel_ban', {'name': post.magazine.name, 'username': post.user.username}) }}\"\n                  method=\"get\">\n                <button type=\"submit\" class=\"btn btn__secondary\">\n                    <i class=\"fa fa-ban\" aria-hidden=\"true\"></i> <span>{{ 'ban'|trans }}</span>\n                </button>\n            </form>\n        </li>\n        <li>\n            <form action=\"{{ post_delete_url(post) }}\"\n                  method=\"post\"\n                  data-action=\"confirmation#ask\" data-confirmation-message-param=\"{{ 'are_you_sure'|trans }}\">\n                <button type=\"submit\" class=\"btn btn__secondary\">\n                    <i class=\"fa fa-dumpster\" aria-hidden=\"true\"></i> <span>{{ 'delete'|trans }}</span>\n                </button>\n            </form>\n        </li>\n        {% if is_granted('purge', post) %}\n            <li>\n                <form action=\"{{ path('post_purge', {magazine_name: post.magazine.name, post_id: post.id,}) }}\"\n                      method=\"post\"\n                      data-action=\"confirmation#ask\" data-confirmation-message-param=\"{{ 'are_you_sure'|trans }}\">\n                    <button type=\"submit\" class=\"btn btn__danger\">\n                        <i class=\"fa fa-dumpster\" aria-hidden=\"true\"></i> <span>{{ 'purge'|trans }}</span>\n                    </button>\n                </form>\n            </li>\n        {% endif %}\n        {% if is_granted('ROLE_ADMIN') or is_granted('ROLE_MODERATOR') %}\n            <li class=\"actions\">\n                <form name=\"change_magazine\"\n                      action=\"{{ path('post_change_magazine', {magazine_name: post.magazine.name, post_id: post.id}) }}\"\n                      method=\"post\"\n                      data-action=\"confirmation#ask\" data-confirmation-message-param=\"{{ 'are_you_sure'|trans }}\">\n                    <input type=\"hidden\" name=\"token\" value=\"{{ csrf_token('change_magazine') }}\">\n                    <input id=\"change_magazine_new_magazine\" required=\"required\" placeholder=\"{{ post.magazine.name }}\" name=\"change_magazine[new_magazine]\">\n                    <button type=\"submit\" class=\"btn btn__secondary\">\n                        {{ 'change_magazine'|trans }}\n                    </button>\n                </form>\n            </li>\n        {% endif %}\n        <li class=\"actions\">\n            {{ form_start(form, {action: path('post_change_lang', {magazine_name: magazine.name, post_id: post.id})}) }}\n            {{ form_row(form.lang, {label: false, row_attr: {class: 'checkbox'}}) }}\n            {{ form_row(form.submit, {label: 'change_language'|trans, attr: {class: 'btn btn__secondary'}}) }}\n            {{ form_end(form) }}\n        </li>\n    </menu>\n</div>\n"
  },
  {
    "path": "templates/post/_options.html.twig",
    "content": "{% set showFilterLabels = app.request.cookies.get('kbin_general_filter_labels')|default('on') %}\n\n<aside class=\"options options--top\" id=\"options\">\n    <div></div>\n    <menu class=\"options__filter\">\n        <li class=\"dropdown\">\n            <button aria-label=\"{{ 'sort_by'|trans }}\"\n                    title=\"{{ 'sort_by'|trans }}\"><i\n                        class=\"fa-solid fa-sort\" aria-hidden=\"true\"></i>\n                <span>{{ criteria.getOption('sort')|trans }}</span>\n            </button>\n            <ul class=\"dropdown__menu\">\n                <li>\n                    <a href=\"{{ front_options_url('sortBy', 'top', null, {'p': null, 'cursor': null, 'cursor2': null}) }}\"\n                    class=\"{{ html_classes({'active': criteria.getOption('sort') == 'top'}) }}\">\n                        {{ 'top'|trans }}\n                    </a>\n                </li>\n                <li>\n                    <a href=\"{{ front_options_url('sortBy', 'hot', null, {'p': null, 'cursor': null, 'cursor2': null}) }}\"\n                    class=\"{{ html_classes({'active': criteria.getOption('sort') == 'hot'}) }}\">\n                        {{ 'hot'|trans }}\n                    </a>\n                </li>\n                <li>\n                    <a href=\"{{ front_options_url('sortBy', 'newest', null, {'p': null, 'cursor': null, 'cursor2': null}) }}\"\n                    class=\"{{ html_classes({'active': criteria.getOption('sort') == 'newest'}) }}\">\n                        {{ 'newest'|trans }}\n                    </a>\n                </li>\n                <li>\n                    <a href=\"{{ front_options_url('sortBy', 'active', null, {'p': null, 'cursor': null, 'cursor2': null}) }}\"\n                    class=\"{{ html_classes({'active': criteria.getOption('sort') == 'active'}) }}\">\n                        {{ 'active'|trans }}\n                    </a>\n                </li>\n                <li>\n                    <a href=\"{{ front_options_url('sortBy', 'commented', null, {'p': null, 'cursor': null, 'cursor2': null}) }}\"\n                    class=\"{{ html_classes({'active': criteria.getOption('sort') == 'commented'}) }}\">\n                        {{ 'commented'|trans }}\n                    </a>\n                </li>\n            </ul>\n        </li>\n        <li class=\"dropdown\">\n            <button aria-label=\"{{ 'filter_by_time'|trans }}\"\n                    title=\"{{ 'filter_by_time'|trans }}\">\n                <i aria-hidden=\"true\" class=\"fa-solid fa-clock\"></i>\n                {% if showFilterLabels == 'on' or (showFilterLabels == 'auto' and criteria.getOption('time') != 'all') %}\n                    <span>{{ criteria.getOption('time')|trans }}</span>\n                {% endif %}\n            </button>\n            <ul class=\"dropdown__menu\">\n                <li>\n                    <a href=\"{{ front_options_url('time', '∞', null, {'p': null, 'cursor': null, 'cursor2': null}) }}\"\n                       class=\"{{ html_classes({'active': criteria.getOption('time') == 'all'}) }}\">\n                        {{ 'all_time'|trans }}\n                    </a>\n                </li>\n                <li>\n                    <a href=\"{{ front_options_url('time', '3h', null, {'p': null, 'cursor': null, 'cursor2': null}) }}\"\n                       class=\"{{ html_classes({'active': criteria.getOption('time') == '3h' }) }}\">\n                        {{ '3h'|trans }}\n                    </a></li>\n                <li>\n                    <a href=\"{{ front_options_url('time', '6h', null, {'p': null, 'cursor': null, 'cursor2': null}) }}\"\n                       class=\"{{ html_classes({'active': criteria.getOption('time') == '6h' }) }}\">\n                        {{ '6h'|trans }}\n                    </a>\n                </li>\n                <li>\n                    <a href=\"{{ front_options_url('time', '12h', null, {'p': null, 'cursor': null, 'cursor2': null}) }}\"\n                       class=\"{{ html_classes({'active': criteria.getOption('time') == '12h' }) }}\">\n                        {{ '12h'|trans }}\n                    </a>\n                </li>\n                <li>\n                    <a href=\"{{ front_options_url('time', '1d', null, {'p': null, 'cursor': null, 'cursor2': null}) }}\"\n                       class=\"{{ html_classes({'active': criteria.getOption('time') == '1d' }) }}\">\n                        {{ '1d'|trans }}\n                    </a>\n                </li>\n                <li>\n                    <a href=\"{{ front_options_url('time', '1w', null, {'p': null, 'cursor': null, 'cursor2': null}) }}\"\n                       class=\"{{ html_classes({'active': criteria.getOption('time') == '1w' }) }}\">\n                        {{ '1w'|trans }}\n                    </a>\n                </li>\n                <li>\n                    <a href=\"{{ front_options_url('time', '1m', null, {'p': null, 'cursor': null, 'cursor2': null}) }}\"\n                       class=\"{{ html_classes({'active': criteria.getOption('time') == '1m' }) }}\">\n                        {{ '1m'|trans }}\n                    </a>\n                </li>\n                <li>\n                    <a href=\"{{ front_options_url('time', '1y', null, {'p': null, 'cursor': null, 'cursor2': null}) }}\"\n                       class=\"{{ html_classes({'active': criteria.getOption('time') == '1y' }) }}\">\n                        {{ '1y'|trans }}\n                    </a>\n                </li>\n            </ul>\n        </li>\n\n        {% if criteria and criteria.getOption('content') != 'microblog' %}\n            <li class=\"dropdown\">\n                <button aria-label=\"{{ 'filter_by_type'|trans }}\"\n                        title=\"{{ 'filter_by_type'|trans }}\">\n                    <i aria-hidden=\"true\" class=\"\n                        {% if criteria.getOption('type') == 'all' %}\n                            fa-solid fa-file\n                        {% elseif criteria.getOption('type') == 'links' %}\n                            fa-regular fa-file-code\n                        {% elseif criteria.getOption('type') == 'threads' %}\n                            fa-regular fa-file-lines\n                        {% elseif criteria.getOption('type') == 'photos' %}\n                            fa-regular fa-file-image\n                        {% elseif criteria.getOption('type') == 'videos' %}\n                            fa-regular fa-file-video\n                        {% else %}\n                            fa-solid fa-question\n                        {% endif %}\">\n                    </i>\n                    {% if showFilterLabels == 'on' or (showFilterLabels == 'auto' and criteria.getOption('type') != 'all') %}\n                        <span class=\"hide-on-mobile\">{{ criteria.getOption('type')|trans }}</span>\n                    {% endif %}\n                </button>\n                <ul class=\"dropdown__menu\">\n                    <li>\n                        <a href=\"{{ front_options_url('type', null, null, {'p': null, 'cursor': null, 'cursor2': null}) }}\" class=\"{{ html_classes({'active': criteria.getOption('type') == 'all' }) }}\">\n                            <i class=\"fa-solid fa-file\" aria-hidden=\"true\"></i> &nbsp; {{ 'all'|trans }}\n                        </a>\n                    </li>\n                    <li>\n                        <a href=\"{{ front_options_url('type', 'links', null, {'p': null, 'cursor': null, 'cursor2': null}) }}\" class=\"{{ html_classes({'active': criteria.getOption('type') == 'links' }) }}\">\n                            <i class=\"fa-regular fa-file-code\" aria-hidden=\"true\"></i> &nbsp; {{ 'links'|trans }}\n                        </a>\n                    </li>\n                    <li>\n                        <a href=\"{{ front_options_url('type', 'articles', null, {'p': null, 'cursor': null, 'cursor2': null}) }}\" class=\"{{ html_classes({'active': criteria.getOption('type') == 'threads' }) }}\">\n                            <i class=\"fa-regular fa-file-lines\" aria-hidden=\"true\"></i> &nbsp; {{ 'threads'|trans }}\n                        </a>\n                    </li>\n                    <li>\n                        <a href=\"{{ front_options_url('type', 'photos', null, {'p': null, 'cursor': null, 'cursor2': null}) }}\" class=\"{{ html_classes({'active': criteria.getOption('type') == 'photos' }) }}\">\n                            <i class=\"fa-regular fa-file-image\" aria-hidden=\"true\"></i> &nbsp; {{ 'photos'|trans }}\n                        </a>\n                    </li>\n                    <li>\n                        <a href=\"{{ front_options_url('type', 'videos', null, {'p': null, 'cursor': null, 'cursor2': null}) }}\" class=\"{{ html_classes({'active': criteria.getOption('type') == 'videos'}) }}\">\n                            <i class=\"fa-regular fa-file-video\" aria-hidden=\"true\"></i> &nbsp; {{ 'videos'|trans }}\n                        </a>\n                    </li>\n                </ul>\n            </li>\n        {% endif %}\n\n        {% if app.user %}\n            <li class=\"dropdown\">\n                <button\n                        aria-label=\"{{ 'filter_by_subscription'|trans }}\"\n                        title=\"{{ 'filter_by_subscription'|trans }}\">\n                    <i aria-hidden=\"true\" class=\"fa-solid\n                        {% if criteria.favourite %}\n                            fa-heart\n                        {% elseif criteria.subscribed %}\n                            fa-folder-plus\n                        {% elseif criteria.moderated %}\n                            fa-shield-halved\n                        {% else %}\n                            fa-earth-americas\n                        {% endif %}\">\n                    </i>\n                    {% if showFilterLabels == 'on' or (showFilterLabels == 'auto' and (criteria.favourite or criteria.moderated or criteria.subscribed)) %}\n                        <span class=\"hide-on-mobile\">{{ criteria.resolveSubscriptionFilter()|trans }}</span>\n                    {% endif %}\n                </button>\n                <ul class=\"dropdown__menu\">\n                    <li>\n                        <a href=\"{{ front_options_url('subscription', 'all', null, {'p': null, 'cursor': null, 'cursor2': null}) }}\" class=\"{{ html_classes({'active': not criteria.favourite and not criteria.moderated and not criteria.subscribed}) }}\">\n                            <i class=\"fa-solid fa-earth-americas\" aria-hidden=\"true\"></i> &nbsp; {{ 'all'|trans }}\n                        </a>\n                    </li>\n                    {% if not is_route_name_contains('_magazine') %}\n                        <li>\n                            <a href=\"{{ front_options_url('subscription', 'sub', null, {'p': null, 'cursor': null, 'cursor2': null}) }}\" class=\"{{ html_classes({'active': criteria.subscribed }) }}\">\n                                <i class=\"fa-solid fa-folder-plus\" aria-hidden=\"true\"></i> &nbsp; {{ 'subscribed'|trans }}\n                            </a>\n                        </li>\n                        <li>\n                            <a href=\"{{ front_options_url('subscription', 'mod', null, {'p': null, 'cursor': null, 'cursor2': null}) }}\" class=\"{{ html_classes({'active': criteria.moderated }) }}\">\n                                <i class=\"fa-solid fa-shield-halved\" aria-hidden=\"true\"></i> &nbsp;{{ 'moderated'|trans }}\n                            </a>\n                        </li>\n                    {% endif %}\n                    <li>\n                        <a href=\"{{ front_options_url('subscription', 'fav', null, {'p': null, 'cursor': null, 'cursor2': null}) }}\" class=\"{{ html_classes({'active': criteria.favourite }) }}\">\n                            <i class=\"fa-solid fa-heart\" aria-hidden=\"true\"></i> &nbsp; {{ 'favourites'|trans }}\n                        </a>\n                    </li>\n                </ul>\n            </li>\n        {% endif %}\n        <li class=\"dropdown\">\n            <button\n                    aria-label=\"{{ 'filter_by_federation'|trans }}\"\n                    title=\"{{ 'filter_by_federation'|trans }}\">\n                <i aria-hidden=\"true\" class=\"fa-solid\n                    {% if criteria.getOption('federation') == 'all' %}\n                        fa-circle-nodes\n                    {% elseif criteria.getOption('federation') == 'local' %}\n                        fa-house-chimney\n                    {% elseif criteria.getOption('federation') == 'federated' %}\n                        fa-network-wired\n                    {% else %}\n                        fa-question\n                    {% endif %}\">\n                </i>\n                {% if showFilterLabels == 'on' or (showFilterLabels == 'auto' and criteria.getOption('federation') != 'all') %}\n                    <span class=\"hide-on-mobile\">{{ criteria.federation|trans }}</span>\n                {% endif %}\n            </button>\n            <ul class=\"dropdown__menu\">\n                <li>\n                    <a href=\"{{ front_options_url('federation', 'all', null, {'p': null, 'cursor': null, 'cursor2': null}) }}\" class=\"{{ html_classes({'active': criteria.getOption('federation') == 'all' }) }}\">\n                        <i class=\"fa-solid fa-circle-nodes\" aria-hidden=\"true\"></i> &nbsp; {{ 'all'|trans }}\n                    </a>\n                </li>\n                <li>\n                    <a href=\"{{ front_options_url('federation', 'local', null, {'p': null, 'cursor': null, 'cursor2': null}) }}\" class=\"{{ html_classes({'active': criteria.getOption('federation') == 'local' }) }}\">\n                        <i class=\"fa-solid fa-house-chimney\" aria-hidden=\"true\"></i> &nbsp; {{ 'local'|trans }}\n                    </a>\n                </li>\n                {#\n                <li>\n                    <a href=\"{{ front_options_url('federation', 'federated', null, {'p': null}) }}\" class=\"{{ html_classes({'active': criteria.getOption('federation') == 'federated' }) }}\">\n                        <i class=\"fa-solid fa-network-wired\" aria-hidden=\"true\"></i> &nbsp; {{ 'federated'|trans }}\n                    </a>\n                </li>\n                #}\n            </ul>\n        </li>\n    </menu>\n    <menu class=\"options__view\">\n        <li class=\"dropdown\">\n            <button aria-label=\"{{ 'change_view'|trans }}\"\n                    title=\"{{ 'change_view'|trans }}\"><i\n                        class=\"fa-solid fa-layer-group\" aria-hidden=\"true\"></i>\n            </button>\n            <ul class=\"dropdown__menu\">\n                <li>\n                    <a class=\"{{ html_classes({'active': not app.request.cookies.has(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_ENTRIES_COMPACT')) or app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_ENTRIES_COMPACT')) is same as 'false'}) }}\"\n                       href=\"{{ path('theme_settings', {key: constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_ENTRIES_COMPACT'), value: 'false'}) }}\">\n                        {{ 'classic_view'|trans }}\n                    </a>\n                </li>\n                <li>\n                    <a class=\"{{ html_classes({'active': app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_ENTRIES_COMPACT')) is same as 'true'}) }}\"\n                       href=\"{{ path('theme_settings', {key: constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_ENTRIES_COMPACT'), value: 'true'}) }}\">\n                        {{ 'compact_view'|trans }}\n                    </a>\n                </li>\n            </ul>\n        </li>\n    </menu>\n</aside>\n"
  },
  {
    "path": "templates/post/_options_activity.html.twig",
    "content": "<aside id=\"activity\" class=\"options options-activity\">\n    <div class=\"options__title\">\n        <h2>{{ 'activity'|trans }} ({{ (post.countVotes + post.favouriteCount)|abbreviateNumber }})</h2>\n    </div>\n    <menu class=\"options__main\">\n        <li>\n            <a href=\"{{ post_voters_url(post, 'up') }}\"\n               class=\"{{ html_classes({'active': is_route_name('post_voters') and route_has_param('type', 'up')}) }}\">\n                {{ 'up_votes'|trans }} ({{ post.countUpVotes|abbreviateNumber }})\n            </a>\n        </li>\n        <li>\n            <a href=\"{{ post_favourites_url(post) }}\"\n               class=\"{{ html_classes({'active': is_route_name('post_favourites')}) }}\">\n                {{ 'favourites'|trans }} ({{ post.favouriteCount|abbreviateNumber }})\n            </a>\n        </li>\n    </menu>\n</aside>\n"
  },
  {
    "path": "templates/post/comment/_form_comment.html.twig",
    "content": "{% form_theme form.lang 'form/lang_select.html.twig' %}\n\n{% set hasImage = false %}\n{% if comment is defined and comment is not null and comment.image %}\n    {% set hasImage = true %}\n{% endif %}\n{% if edit is not defined %}\n    {% set edit = false %}\n{% endif %}\n{% if edit %}\n    {% set title = 'edit_comment'|trans %}\n    {% set action = path('post_comment_edit', {magazine_name: post.magazine.name, post_id: post.id, comment_id: comment.id}) %}\n{% else %}\n    {% set title = 'add_comment'|trans %}\n    {% set action = path('post_comment_create', {magazine_name: post.magazine.name, post_id: post.id, parent_comment_id: parent is defined and parent ? parent.id : null}) %}\n{% endif %}\n\n<h3 hidden>{{ title }}</h3>\n{{ form_start(form, {action: action, attr: {class: edit ? 'comment-edit replace' : 'comment-add'}}) }}\n<div class=\"row\">\n    {{ component('editor_toolbar', {id: form.body.vars.id}) }}\n    {{ form_row(form.body, {label: false, attr: {\n        'data-controller': 'input-length rich-textarea autogrow',\n        'data-action' : 'input-length#updateDisplay',\n        'data-input-length-max-value': constant('App\\\\DTO\\\\PostCommentDto::MAX_BODY_LENGTH')\n    }}) }}\n</div>\n<div class=\"row actions\">\n    <ul>\n        {% if hasImage %}\n            <img width=\"40\"\n                 height=\"40\"\n                 src=\"{{ comment.image.filePath ? (asset(comment.image.filePath)|imagine_filter('post_thumb')) : comment.image.sourceUrl }}\"\n                 alt=\"{{ comment.image.altText }}\">\n            <button formaction=\"{{ path('post_comment_image_delete', {magazine_name: comment.magazine.name, post_id: comment.post.id, comment_id: comment.id}) }}\"\n                    class=\"btn-link\"\n                    aria-label=\"{{ 'remove_media'|trans }}\"\n                    title=\"{{ 'remove_media'|trans }}\"\n                    data-action=\"confirmation#ask subject#removeImage\"\n                    data-confirmation-message-param=\"{{ 'are_you_sure'|trans }}\">\n                <i class=\"fa-solid fa-xmark\" aria-hidden=\"true\"></i>\n            </button>\n        {% endif %}\n        <li class=\"{{ html_classes('dropdown', {'hidden': hasImage}) }}\">\n            <button type=\"button\"\n                    class=\"btn btn__secondary\"\n                    aria-label=\"{{ 'add_media'|trans }}\"\n                    title=\"{{ 'add_media'|trans }}\">\n                <i class=\"fa-solid fa-photo-film\" aria-hidden=\"true\"></i>\n            </button>\n            <div class=\"dropdown__menu\">\n                {% include 'layout/_form_media.html.twig' %}\n            </div>\n        </li>\n        <li class=\"select\">\n            {{ form_row(form.lang, {label: false}) }}\n        </li>\n        <li>\n            {{ form_row(form.submit, {label: edit ? 'edit_comment' : 'add_comment', attr: {class: 'btn btn__primary', 'data-action': 'subject#sendForm'}}) }}\n        </li>\n    </ul>\n</div>\n{{ form_end(form) }}\n"
  },
  {
    "path": "templates/post/comment/_list.html.twig",
    "content": "{%- set V_TRUE = constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::TRUE') -%}\n{%- set V_FALSE = constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::FALSE') -%}\n{%- set V_CHAT = constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::CHAT') -%}\n{%- set V_TREE = constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::TREE') -%}\n\n{%- set SHOW_COMMENT_USER_AVATARS = app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_COMMENTS_SHOW_USER_AVATAR'), V_TRUE) -%}\n{%- set SHOW_POST_USER_AVATARS = app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_POSTS_SHOW_USERS_AVATARS'), V_TRUE) -%}\n{%- set DYNAMIC_LISTS = app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_GENERAL_DYNAMIC_LISTS'), V_FALSE) -%}\n{%- set VIEW_STYLE = app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::POST_COMMENTS_VIEW'), V_TREE) -%}\n\n{% if showNested is not defined %}\n    {% if VIEW_STYLE is same as V_CHAT %}\n        {% set showNested = false %}\n    {% else %}\n        {% set showNested = true %}\n    {% endif %}\n{% endif %}\n{% if level is not defined %}\n    {% set level = 1 %}\n{% endif %}\n{% set autoAction = is_route_name('post_single') ? 'notifications:PostCommentCreatedNotification@window->subject-list#addComment' : 'notifications:PostCommentCreatedNotification@window->subject-list#addCommentOverview' %}\n{% set manualAction = is_route_name('post_single') ? 'notifications:PostCommentCreatedNotification@scroll-top#increaseCounter' : 'notifications:PostCommentCreatedNotification@window->scroll_top#increaseCounter' %}\n<div class=\"{{ html_classes('comments post-comments comments-tree', {\n         'show-comment-avatar': SHOW_COMMENT_USER_AVATARS is same as V_TRUE,\n         'show-post-avatar': SHOW_POST_USER_AVATARS is same as V_TRUE },\n         'comments-view-style--'~VIEW_STYLE\n     ) }}\"\n     data-controller=\"subject-list\"\n     data-action=\"{{- DYNAMIC_LISTS is same as V_TRUE ? autoAction : manualAction -}}\">\n    {% for comment in comments %}\n        {{ component('post_comment', {\n            comment: comment,\n            showNested: showNested,\n            dateAsUrl: dateAsUrl is defined ? dateAsUrl : true,\n            level: level,\n            criteria: criteria,\n        }) }}\n    {% endfor %}\n    {% if(comments.haveToPaginate is defined and comments.haveToPaginate) %}\n        {% if app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_GENERAL_INFINITE_SCROLL')) is same as 'true' %}\n            <div data-controller=\"infinite-scroll\" class=\"infinite-scroll\">\n                {{ component('loader', {'data-infinite-scroll-target': 'loader'}) }}\n                <div data-infinite-scroll-target=\"pagination\" class=\"visually-hidden\">\n                    {{ pagerfanta(comments, null, {'pageParameter':'[p]'}) }}\n                </div>\n            </div>\n        {% else %}\n            {{ pagerfanta(comments, null, {'pageParameter':'[p]'}) }}\n        {% endif %}\n    {% endif %}\n    {% if not comments|length %}\n        <aside class=\"section section--muted\">\n            <p>{{ 'no_comments'|trans }}</p>\n        </aside>\n    {% elseif VIEW_STYLE is same as V_TREE %}\n        <div class=\"comment-line--2\"></div>\n        <div class=\"comment-line--3\"></div>\n        <div class=\"comment-line--4\"></div>\n        <div class=\"comment-line--5\"></div>\n        <div class=\"comment-line--6\"></div>\n        <div class=\"comment-line--7\"></div>\n        <div class=\"comment-line--8\"></div>\n        <div class=\"comment-line--9\"></div>\n    {% endif %}\n</div>\n"
  },
  {
    "path": "templates/post/comment/_menu.html.twig",
    "content": "<li class=\"dropdown\">\n    <button class=\"stretched-link\" data-subject-target=\"more\">{{ 'more'|trans }}</button>\n    <ul class=\"dropdown__menu\" data-controller=\"clipboard\">\n        <li>\n            <a href=\"{{ path('post_comment_report', {id: comment.id}) }}\"\n                class=\"{{ html_classes({'active': is_route_name('post_comment_report')}) }}\"\n                data-action=\"subject#getForm\">\n                {{ 'report'|trans }}\n            </a>\n        </li>\n        <li>\n            <a href=\"{{ post_comment_voters_url(comment) }}\"\n                class=\"{{ html_classes({'active': is_route_name('post_comment_favourites') or is_route_name('post_comment_voters')}) }}\">\n                {{ 'activity'|trans }}\n            </a>\n        </li>\n\n        {% if app.user is defined and app.user is not same as null %}\n            {% set bookmarkLists = get_bookmark_lists(app.user) %}\n            {% if bookmarkLists|length %}\n                <li class=\"dropdown__separator\"></li>\n                {% for list in bookmarkLists %}\n                    {{ component('bookmark_list', { subject: comment, subjectType: 'post_comment', list: list }) }}\n                {% endfor %}\n            {% endif %}\n        {% endif %}\n\n        <li class=\"dropdown__separator\"></li>\n        <li>\n            <a target=\"_blank\"\n                rel=\"{{ get_rel(comment.apId ?? path('ap_post_comment', {magazine_name: comment.magazine.name, post_id: comment.post.id, comment_id: comment.id})) }}\"\n                href=\"{{ comment.apId ?? path('ap_post_comment', {magazine_name: comment.magazine.name, post_id: comment.post.id, comment_id: comment.id}) }}\">\n                {{ 'open_url_to_fediverse'|trans }}\n            </a>\n        </li>\n        <li>\n            <a data-action=\"clipboard#copy\"\n                rel=\"{{ get_rel(comment.apId ?? path('ap_post_comment', {magazine_name: comment.magazine.name, post_id: comment.post.id, comment_id: comment.id})) }}\"\n                href=\"{{ comment.apId ?? path('ap_post_comment', {magazine_name: comment.magazine.name, post_id: comment.post.id, comment_id: comment.id}) }}\">\n                {{ 'copy_url_to_fediverse'|trans }}\n            </a>\n        </li>\n        <li>\n            <a data-action=\"clipboard#copy\"\n                href=\"{{ post_url(comment.post) }}#{{ get_url_fragment(comment) }}\">\n                {{ 'copy_url'|trans }}\n            </a>\n        </li>\n        {% if is_granted('edit', comment) or (app.user and comment.isAuthor(app.user)) or is_granted('moderate', comment) %}\n            <li class=\"dropdown__separator\"></li>\n        {% endif %}\n        {% if is_granted('edit', comment) %}\n            <li>\n                <a href=\"{{ post_comment_edit_url(comment) }}\"\n                    class=\"{{ html_classes({'active': is_route_name('post_comment_edit')}) }}\"\n                    data-action=\"subject#getForm\">\n                    {{ 'edit'|trans }}\n                </a>\n            </li>\n        {% endif %}\n        {% if app.user and comment.isAuthor(app.user) %}\n            <li>\n                <form method=\"post\"\n                      action=\"{{ post_comment_delete_url(comment) }}\"\n                      data-action=\"confirmation#ask\" data-confirmation-message-param=\"{{ 'are_you_sure'|trans }}\">\n                    <input type=\"hidden\" name=\"token\"\n                            value=\"{{ csrf_token('post_comment_delete') }}\">\n                    <button type=\"submit\">{{ 'delete'|trans }}</button>\n                </form>\n            </li>\n        {% endif %}\n        {% if is_granted('moderate', comment) %}\n            <li>\n                <a href=\"{{ post_comment_moderate_url(comment) }}\"\n                    class=\"{{ html_classes({'active': is_route_name('post_comment_moderate')}) }}\"\n                    data-action=\"subject#showModPanel\">\n                    {{ 'moderate'|trans }}\n                </a>\n            </li>\n        {% endif %}\n    </ul>\n</li>\n"
  },
  {
    "path": "templates/post/comment/_moderate_panel.html.twig",
    "content": "<div class=\"moderate-panel\">\n    <menu>\n        <li>\n            <form action=\"{{ path('post_comment_change_adult', {magazine_name: magazine.name, post_id: post.id, comment_id: comment.id}) }}\"\n                  method=\"post\">\n                <input type=\"hidden\" name=\"token\" value=\"{{ csrf_token('change_adult') }}\">\n                <input name=\"adult\"\n                       type=\"hidden\" value=\"{{ comment.isAdult ? 'off' : 'on' }}\">\n                <button type=\"submit\" class=\"btn btn__secondary\">\n                    <i class=\"fa fa-{{ comment.isAdult ? 'eye' : 'eye-slash' }}\" aria-hidden=\"true\"></i> <span>{{ comment.isAdult ? 'unmark_as_adult'|trans : 'mark_as_adult'|trans }}</span>\n                </button>\n            </form>\n        </li>\n        <li>\n            <form action=\"{{ path('magazine_panel_ban', {'name': comment.magazine.name, 'username': comment.user.username}) }}\"\n                  method=\"get\">\n                <button type=\"submit\" class=\"btn btn__secondary\">\n                    <i class=\"fa fa-ban\" aria-hidden=\"true\"></i> <span>{{ 'ban'|trans }}</span>\n                </button>\n            </form>\n        </li>\n        <li>\n            <form action=\"{{ post_comment_delete_url(comment) }}\"\n                  method=\"post\"\n                  data-action=\"confirmation#ask\" data-confirmation-message-param=\"{{ 'are_you_sure'|trans }}\">\n                <input type=\"hidden\" name=\"token\" value=\"{{ csrf_token('post_comment_delete') }}\">\n                <button type=\"submit\" class=\"btn btn__secondary\">\n                    <i class=\"fa fa-dumpster\" aria-hidden=\"true\"></i> <span>{{ 'delete'|trans }}</span>\n                </button>\n            </form>\n        </li>\n        {% if is_granted('purge', comment) %}\n            <li>\n                <form action=\"{{ path('post_comment_purge', {magazine_name: comment.magazine.name, post_id: post.id, comment_id: comment.id}) }}\"\n                      method=\"post\"\n                      data-action=\"confirmation#ask\" data-confirmation-message-param=\"{{ 'are_you_sure'|trans }}\">\n                    <input type=\"hidden\" name=\"token\" value=\"{{ csrf_token('post_comment_purge') }}\">\n                    <button type=\"submit\" class=\"btn btn__danger\">\n                        <i class=\"fa fa-dumpster\" aria-hidden=\"true\"></i> <span>{{ 'purge'|trans }}</span>\n                    </button>\n                </form>\n            </li>\n        {% endif %}\n        <li class=\"actions\">\n            {{ form_start(form, {action: path('post_comment_change_lang', {magazine_name: magazine.name, post_id: post.id, comment_id: comment.id})}) }}\n            {{ form_row(form.lang, {label: false, row_attr: {class: 'checkbox'}}) }}\n            {{ form_row(form.submit, {label: 'change_language'|trans, attr: {class: 'btn btn__secondary'}}) }}\n            {{ form_end(form) }}\n        </li>\n    </menu>\n</div>\n"
  },
  {
    "path": "templates/post/comment/_no_comments.html.twig",
    "content": "<aside class=\"section section--muted\">\n    <p>{{ 'no_comments'|trans }}</p>\n</aside>\n"
  },
  {
    "path": "templates/post/comment/_options.html.twig",
    "content": "<aside class=\"options\" id=\"options\">\n    {% if app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::POST_COMMENTS_VIEW')) is not same as constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::CHAT') %}\n        <div></div>\n        <menu class=\"options__main\">\n            <li>\n                <a href=\"{{ options_url('sortBy', 'top') }}\"\n                   class=\"{{ html_classes({'active': criteria.getOption('sort') == 'top'}) }}\">\n                    {{ 'top'|trans }}\n                </a>\n            </li>\n            <li>\n                <a href=\"{{ options_url('sortBy', 'hot') }}\"\n                   class=\"{{ html_classes({'active': criteria.getOption('sort') == 'hot'}) }}\">\n                    {{ 'hot'|trans }}\n                </a>\n            </li>\n            <li>\n                <a href=\"{{ options_url('sortBy', 'active') }}\"\n                   class=\"{{ html_classes({'active': criteria.getOption('sort') == 'active'}) }}\">\n                    {{ 'active'|trans }}\n                </a>\n            </li>\n            <li>\n                <a href=\"{{ options_url('sortBy', 'newest') }}\"\n                   class=\"{{ html_classes({'active': criteria.getOption('sort') == 'newest'}) }}\">\n                    {{ 'newest'|trans }}\n                </a>\n            </li>\n            <li>\n                <a href=\"{{ options_url('sortBy', 'oldest') }}\"\n                   class=\"{{ html_classes({'active': criteria.getOption('sort') == 'oldest'}) }}\">\n                    {{ 'oldest'|trans }}\n                </a>\n            </li>\n        </menu>\n    {% else %}\n        <div class=\"options__title\"><h2>{{ 'comments'|trans }} ({{ post.commentCount }})</h2></div>\n    {% endif %}\n    <menu class=\"options__view\">\n        <li class=\"dropdown\">\n            <button aria-label=\"{{ 'change_view'|trans }}\"\n                    title=\"{{ 'change_view'|trans }}\"><i\n                        class=\"fa-solid fa-layer-group\" aria-hidden=\"true\"></i>\n            </button>\n            <ul class=\"dropdown__menu\">\n                <li>\n                    <a class=\"{{ html_classes({'active': app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::POST_COMMENTS_VIEW')) is same as constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::CLASSIC')}) }}\"\n                       href=\"{{ path('theme_settings', {key: constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::POST_COMMENTS_VIEW'), value: constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::CLASSIC')}) }}\">{{ 'classic_view'|trans }}</a>\n                </li>\n                <li>\n                    <a class=\"{{ html_classes({'active': app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::POST_COMMENTS_VIEW')) is same as constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::CHAT')}) }}\"\n                       href=\"{{ path('theme_settings', {key: constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::POST_COMMENTS_VIEW'), value: constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::CHAT')}) }}\">{{ 'chat_view'|trans }}</a>\n                </li>\n                <li>\n                    <a class=\"{{ html_classes({'active': app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::POST_COMMENTS_VIEW')) is same as null or app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::POST_COMMENTS_VIEW')) is same as constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::TREE')}) }}\"\n                       href=\"{{ path('theme_settings', {key: constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::POST_COMMENTS_VIEW'), value: constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::TREE')}) }}\">{{ 'tree_view'|trans }}</a>\n                </li>\n            </ul>\n        </li>\n    </menu>\n</aside>\n"
  },
  {
    "path": "templates/post/comment/_options_activity.html.twig",
    "content": "<aside id=\"activity\" class=\"options options-activity\">\n    <div class=\"options__title\">\n        <h2>{{ 'activity'|trans }} ({{ comment.countVotes + comment.favouriteCount }})</h2>\n    </div>\n    <menu class=\"options__main\">\n        <li>\n            <a href=\"{{ post_comment_voters_url(comment, 'up') }}\"\n               class=\"{{ html_classes({'active': is_route_name('post_comment_voters')}) }}\">\n                {{ 'up_votes'|trans }} ({{ comment.countUpVotes }})\n            </a>\n        </li>\n        <li>\n            <a href=\"{{ post_comment_favourites_url(comment) }}\"\n               class=\"{{ html_classes({'active': is_route_name('post_comment_favourites')}) }}\">\n                {{ 'favourites'|trans }} ({{ comment.favouriteCount }})\n            </a>\n        </li>\n    </menu>\n</aside>\n"
  },
  {
    "path": "templates/post/comment/_preview.html.twig",
    "content": "<div class=\"comments post-comments post-comments-preview\">\n    {% for comment in comments %}\n        {{ component('post_comment', {\n            comment: comment,\n            showNested: true,\n            level: 2,\n            criteria: criteria,\n         }) }}\n    {% endfor %}\n    {% if(comments.haveToPaginate is defined and comments.haveToPaginate) %}\n        {{ pagerfanta(comments, null, {'pageParameter':'[p]'}) }}\n    {% endif %}\n    {% if not comments|length %}\n        <aside class=\"section section--muted\">\n            <p>{{ 'no_comments'|trans }}</p>\n        </aside>\n    {% endif %}\n    <div class=\"comment-line--2\"></div>\n    <div class=\"comment-line--3\"></div>\n    <div class=\"comment-line--4\"></div>\n    <div class=\"comment-line--5\"></div>\n    <div class=\"comment-line--6\"></div>\n    <div class=\"comment-line--7\"></div>\n    <div class=\"comment-line--8\"></div>\n    <div class=\"comment-line--9\"></div>\n</div>\n"
  },
  {
    "path": "templates/post/comment/create.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    {{- 'add_comment'|trans }} - {{ parent() -}}\n{%- endblock -%}\n\n{% block mainClass %}page-post-comment-create{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n{% endblock %}\n\n{% block body %}\n    {{ component('post', {\n        post: post,\n        isSingle: true,\n        showMagazineName: false\n    }) }}\n    <div class=\"alert alert__info\">\n        <p>{{ 'browsing_one_thread'|trans }}</p>\n        <p><a href=\"{{ post_url(post) }}\"><i class=\"fa-solid fa-arrow-left\" aria-hidden=\"true\"></i> {{ 'return'|trans }}</a></p>\n    </div>\n    {% if parent is defined and parent %}\n        {{ component('post_comment', {\n            comment: parent,\n            showEntryTitle: false,\n            showNested: false\n        }) }}\n    {% endif %}\n    {% include 'layout/_flash.html.twig' %}\n    {% if user.visibility is same as 'visible'%}\n    <div id=\"content\">\n        <section class=\"section\">\n            {% include 'post/comment/_form_comment.html.twig' %}\n        </section>\n    </div>\n    {% endif %}\n{% endblock %}\n"
  },
  {
    "path": "templates/post/comment/edit.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    {{- 'edit_comment'|trans }} - {{ get_short_sentence(comment.body, 80) }} - {{ parent() -}}\n{%- endblock -%}\n\n{% block mainClass %}page-post-comment-edit{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n{% endblock %}\n\n{% block body %}\n    {{ component('post_comment', {\n        comment: comment,\n        dateAsUrl: false,\n    }) }}\n    {% include 'layout/_flash.html.twig' %}\n    <div class=\"alert alert__info\">\n        <p>{{ 'browsing_one_thread'|trans }}</p>\n        <p><a href=\"{{ post_url(comment.post) }}\"><i class=\"fa-solid fa-arrow-left\" aria-hidden=\"true\"></i> {{ 'return'|trans }}</a></p>\n    </div>\n    <div id=\"content\">\n        <section class=\"section\">\n            {% include 'post/comment/_form_comment.html.twig' with {edit: true} %}\n        </section>\n    </div>\n{% endblock %}\n"
  },
  {
    "path": "templates/post/comment/favourites.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    {{- 'favourites'|trans }} - {{ get_short_sentence(comment.body, 80) }} - {{ parent() -}}\n{%- endblock -%}\n\n{% block mainClass %}page-post-comment-favourites{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n{% endblock %}\n\n{% block body %}\n    {{ component('post_comment', {\n        comment: comment\n    }) }}\n    {% include 'layout/_flash.html.twig' %}\n    {% include 'post/comment/_options_activity.html.twig' %}\n    <div id=\"content\">\n        {% include 'layout/_user_activity_list.html.twig' with {list: favourites} %}\n    </div>\n{% endblock %}\n"
  },
  {
    "path": "templates/post/comment/moderate.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    {{- 'moderate'|trans }} - {{ get_short_sentence(comment.body, 80) }} - {{ magazine.title }} - {{ parent() -}}\n{%- endblock -%}\n\n{% block mainClass %}page-post-moderate{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n{% endblock %}\n\n{% block body %}\n    <div id=\"content\">\n        {{ component('post_comment', {\n            comment: comment,\n            isSingle: true,\n            dateAsUrl: false,\n        }) }}\n        {% include 'layout/_flash.html.twig' %}\n        <div class=\"section section--small\">\n          {% include 'post/comment/_moderate_panel.html.twig' %}\n        </div>\n    </div>\n{% endblock %}\n"
  },
  {
    "path": "templates/post/comment/voters.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    {{- 'activity'|trans }} - {{ get_short_sentence(comment.body, 80) }} - {{ parent() -}}\n{%- endblock -%}\n\n{% block mainClass %}page-post-comment-voters{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n{% endblock %}\n\n{% block body %}\n    {{ component('post_comment', {\n        comment: comment\n    }) }}\n    {% include 'layout/_flash.html.twig' %}\n    {% include 'post/comment/_options_activity.html.twig' %}\n    <div id=\"content\">\n        {% include 'layout/_user_activity_list.html.twig' with {list: votes} %}\n    </div>\n{% endblock %}\n"
  },
  {
    "path": "templates/post/create.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    {{- 'add_new_post'|trans }} - {{ parent() -}}\n{%- endblock -%}\n\n{% block mainClass %}page-post-create{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n{% endblock %}\n\n{% block body %}\n    {% if magazine is defined and magazine %}\n        <h1 hidden>{{ magazine.title }}</h1>\n        <h2 hidden>{{ get_active_sort_option()|trans }}</h2>\n    {% else %}\n        <h1 hidden>{{ get_active_sort_option()|trans }}</h1>\n    {% endif %}\n    {% include 'layout/_flash.html.twig' %}\n    <section id=\"content\" class=\"section section--top\">\n        {% include 'post/_form_post.html.twig' %}\n    </section>\n{% endblock %}\n"
  },
  {
    "path": "templates/post/edit.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    {{- 'edit'|trans }} - {{ get_short_sentence(post.body, 80) }} - {{ parent() -}}\n{%- endblock -%}\n\n{% block mainClass %}page-post-edit{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n{% endblock %}\n\n{% block body %}\n    {% if magazine is defined and magazine %}\n        <h1 hidden>{{ magazine.title }}</h1>\n        <h2 hidden>{{ get_active_sort_option()|trans }}</h2>\n    {% else %}\n        <h1 hidden>{{ get_active_sort_option()|trans }}</h1>\n    {% endif %}\n    <div id=\"content\">\n        {{ component('post', {\n            post: post,\n            isSingle: true,\n            dateAsUrl: false,\n        }) }}\n        {% include 'layout/_flash.html.twig' %}\n        <div class=\"alert alert__info\">\n            <p>{{ 'browsing_one_thread'|trans }}</p>\n            <p><a href=\"{{ post_url(post) }}\"><i class=\"fa-solid fa-arrow-left\" aria-hidden=\"true\"></i> {{ 'return'|trans }}</a></p>\n        </div>\n        <section class=\"section\">\n            {% include 'post/_form_post.html.twig' with {edit: true} %}\n        </section>\n    </div>\n{% endblock %}\n"
  },
  {
    "path": "templates/post/favourites.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    {{- 'favourites'|trans }} - {{ get_short_sentence(post.body, 80) }} - {{ parent() -}}\n{%- endblock -%}\n\n\n{% block mainClass %}page-post-favourites{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n{% endblock %}\n\n{% block body %}\n    <div id=\"content\">\n        {{ component('post', {\n            post: post,\n            isSingle: true\n        }) }}\n        {% include 'layout/_flash.html.twig' %}\n        {% include 'post/_options_activity.html.twig' %}\n        <div id=\"content\">\n            {% include 'layout/_user_activity_list.html.twig' with {list: favourites} %}\n        </div>\n    </div>\n{% endblock %}\n"
  },
  {
    "path": "templates/post/moderate.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    {{- 'moderate'|trans }} - {{ get_short_sentence(post.body, 80) }} - {{ magazine.title }} - {{ parent() -}}\n{%- endblock -%}\n\n{% block mainClass %}page-post-moderate{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n{% endblock %}\n\n{% block body %}\n    <div id=\"content\">\n        {{ component('post', {\n            post: post,\n            isSingle: true,\n            dateAsUrl: false,\n            class: 'section--top',\n        }) }}\n        {% include 'layout/_flash.html.twig' %}\n        <div class=\"section section--small\">\n          {% include 'post/_moderate_panel.html.twig' %}\n        </div>\n    </div>\n{% endblock %}\n"
  },
  {
    "path": "templates/post/single.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    {{- get_short_sentence(post.body, 80) }} - {{ magazine.title }} - {{ parent() -}}\n{%- endblock -%}\n\n{% block description %}{% endblock %}\n\n{% block image %}\n    {%- if post.image -%}\n        {{- uploaded_asset(post.image) -}}\n    {%- elseif post.magazine.icon -%}\n        {{- uploaded_asset(post.magazine.icon) -}}\n    {%- else -%}\n        {{- parent() -}}\n    {%- endif -%}\n{% endblock %}\n\n{% block mainClass %}page-post-single{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n{% endblock %}\n\n{% block body %}\n    <div id=\"content\" class=\"{{ html_classes({\n        'show-comment-avatar': app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_COMMENTS_SHOW_USER_AVATAR')) is same as 'true' or not app.request.cookies.has(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_COMMENTS_SHOW_USER_AVATAR')),\n        'show-post-avatar': app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_POSTS_SHOW_USERS_AVATARS')) is same as 'true' or not app.request.cookies.has(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_POSTS_SHOW_USERS_AVATARS'))\n    }) }}\">\n        {{ component('post', {\n            post: post,\n            isSingle: true,\n            dateAsUrl: false,\n            class: 'section--top'\n        }) }}\n        {% include 'post/comment/_options.html.twig' %}\n        {% include 'layout/_flash.html.twig' %}\n\n        {% if user is defined and user and user.visibility is same as 'visible' and (user_settings.comment_reply_position == constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::TOP')) %}\n            <div id=\"comment-add\" class=\"section\">\n                {% include 'post/comment/_form_comment.html.twig' %}\n            </div>\n        {% endif %}\n\n        {% if post.isLocked %}\n            <div class=\"alert alert__info\">\n                <p>\n                    {{ 'comments_locked'|trans }}\n                </p>\n            </div>\n        {% endif %}\n\n        <div id=\"comments\">\n            {% include 'post/comment/_list.html.twig' %}\n        </div>\n\n        {% if user is defined and user and user.visibility is same as 'visible' and (user_settings.comment_reply_position == constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::BOTTOM')) %}\n            <div id=\"comment-add\" class=\"section\">\n                {% include 'post/comment/_form_comment.html.twig' %}\n            </div>\n        {% endif %}\n\n        {% include 'post/_options_activity.html.twig' %}\n    </div>\n{% endblock %}\n"
  },
  {
    "path": "templates/post/voters.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    {{- 'up_votes'|trans }} - {{ get_short_sentence(post.body, 80) }} - {{ parent() -}}\n{%- endblock -%}\n\n{% block mainClass %}page-post-voters{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n{% endblock %}\n\n{% block body %}\n    <div id=\"content\">\n        {{ component('post', {\n            post: post,\n            isSingle: true\n        }) }}\n        {% include 'layout/_flash.html.twig' %}\n        {% include 'post/_options_activity.html.twig' %}\n        {% include 'layout/_user_activity_list.html.twig' with {list: votes} %}\n    </div>\n{% endblock %}\n"
  },
  {
    "path": "templates/report/_form_report.html.twig",
    "content": "<h3 hidden>{{ 'report'|trans }}</h3>\n{% for flash in app.flashes('error') %}\n    <div class=\"alert alert__danger\">{{ flash }}</div>\n{% endfor %}\n{% for flash in app.flashes('info') %}\n    <div class=\"alert alert__info\">{{ flash }}</div>\n{% endfor %}\n{{ form_start(form) }}\n{{ form_row(form.reason, {label: 'reason'}) }}\n{{ form_row(form.submit, {label: 'report', attr: {class: 'btn btn__primary', 'data-action': 'subject#sendForm'}, row_attr: {class: 'float-end'}}) }}\n{{ form_end(form) }}\n\n"
  },
  {
    "path": "templates/report/create.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    {{- 'report'|trans }} - {{ parent() -}}\n{%- endblock -%}\n\n{% block mainClass %}page-report{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n{% endblock %}\n\n{% block body %}\n    {% include 'layout/_subject.html.twig' with {entryAttributes: {class: 'section--top'}, postAttributes: {class: 'post--single section--top'}} %}\n    <section class=\"section\">\n        {% include 'report/_form_report.html.twig' %}\n    </section>\n{% endblock %}\n"
  },
  {
    "path": "templates/resend_verification_email/resend.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    {{- 'resend_account_activation_email'|trans }} - {{ parent() -}}\n{%- endblock -%}\n\n{% block mainClass %}page-resend-activation-email{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n{% endblock %}\n\n{% block body %}\n    <h1>{{ 'resend_account_activation_email'|trans }}</h1>\n    <div id=\"content\" class=\"section\">\n        <div class=\"container\">\n            <p>{{ 'resend_account_activation_email_description'|trans }}</p>\n\n            {{ form_start(form) }}\n            {% for flash_error in app.flashes('error') %}\n                <div class=\"alert alert__danger\">{{ flash_error|trans }}</div>\n            {% endfor %}\n            {% for flash_success in app.flashes('success') %}\n                <div class=\"alert alert__success\">{{ flash_success|trans }}</div>\n            {% endfor %}\n\n            {{ form_row(form.email) }}\n            {{ form_row(form.submit, {\n                row_attr: {\n                    class: 'button-flex-hf'\n                }\n            }) }}\n            {{ form_end(form) }}\n\n            {{ component('user_form_actions', {showRegister: true, showPasswordReset: true, showResendEmail: true}) }}\n        </div>\n    </div>\n{% endblock %}\n"
  },
  {
    "path": "templates/reset_password/check_email.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    {{- 'reset_password'|trans }} - {{ parent() -}}\n{%- endblock -%}\n\n{% block mainClass %}page-reset-password-email-sent{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n{% endblock %}\n\n{% block body %}\n    <h1>{{ 'check_email'|trans }}</h1>\n    <div id=\"content\" class=\"section\">\n        <div class=\"container\">\n            <p>{{ 'reset_check_email_desc'|trans({'%expire%': resetToken.expirationMessageKey|trans(resetToken.expirationMessageData, 'ResetPasswordBundle')}) }}</p>\n            <p>{{ 'reset_check_email_desc2'|trans }}</p>\n            <p><a class=\"btn btn__secondary\" href=\"{{ path('app_forgot_password_request') }}\">{{ 'try_again'|trans }}</a></p>\n        </div>\n    </div>\n{% endblock %}\n"
  },
  {
    "path": "templates/reset_password/request.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    {{- 'reset_password'|trans }} - {{ parent() -}}\n{%- endblock -%}\n\n{% block mainClass %}page-reset-password{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n{% endblock %}\n\n{% block body %}\n    <h1>{{ 'reset_password'|trans }}</h1>\n    <div id=\"content\" class=\"section\">\n        <div class=\"container\">\n            {{ form_start(form) }}\n            {% for flash_error in app.flashes() %}\n                <div class=\"alert alert__danger\">{{ flash_error }}</div>\n            {% endfor %}\n            {{ form_row(form.email, {\n                label: 'email'\n            }) }}\n\n            <div class=\"button-flex-hf\">\n                <button class=\"btn btn__primary\" type=\"submit\">{{ 'reset_password'|trans }}</button>\n            </div>\n            {{ form_end(form) }}\n            {{ component('user_form_actions', {showLogin: true, showRegister: true, showResendEmail: true}) }}\n        </div>\n    </div>\n{% endblock %}\n"
  },
  {
    "path": "templates/reset_password/reset.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    {{- 'reset_password'|trans }} - {{ parent() -}}\n{%- endblock -%}\n\n{% block mainClass %}page-reset-password{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n{% endblock %}\n\n{% block body %}\n    <h1>{{ 'reset_password'|trans }}</h1>\n    <div id=\"content\" class=\"section\">\n        <div class=\"container\">\n            {{ form_start(form) }}\n            {% for flash_error in app.flashes() %}\n                <div class=\"alert alert__danger\">{{ flash_error }}</div>\n            {% endfor %}\n            {{ form_row(form.plainPassword) }}\n\n            <div class=\"button-flex-hf\">\n                <button class=\"btn btn__primary\" type=\"submit\">{{ 'reset_password'|trans }}</button>\n            </div>\n            {{ form_end(form) }}\n            {{ component('user_form_actions', {showLogin: true, showRegister: true , showResendEmail: true}) }}\n        </div>\n    </div>\n{% endblock %}\n"
  },
  {
    "path": "templates/search/_emoji_suggestion.html.twig",
    "content": "<div id=\"emoji-suggestions\" class=\"suggestions\">\n    {% for emoji in emojis %}\n        <div class=\"suggestion\" data-replace=\"{{ emoji.emoji }}\">\n            {{ emoji.shortCode }}\n            <span>{{ emoji.emoji }}</span>\n        </div>\n    {% endfor %}\n</div>\n"
  },
  {
    "path": "templates/search/_list.html.twig",
    "content": "<div data-controller=\"subject-list\">\n    {% include 'layout/_subject_list.html.twig' with {\n        entryCommentAttributes: {showMagazineName: true, showEntryTitle: true},\n        postCommentAttributes: {withPost: false},\n        magazineAttributes: {showMeta: false, showRules: false, showDescription: false, showInfo: false, stretchedLink: false, showTags: false},\n        userAttributes: {},\n    } %}\n</div>\n"
  },
  {
    "path": "templates/search/_user_suggestion.html.twig",
    "content": "<div id=\"user-suggestions\" class=\"suggestions\">\n    {% for user in users %}\n        <div class=\"suggestion\" data-replace=\"{{ user.username|username(true) }}\">{{ user.username|username(true) }}</div>\n    {% endfor %}\n</div>\n"
  },
  {
    "path": "templates/search/form.html.twig",
    "content": "{{ form_start(form, {'attr': {'class': 'search-form'}}) }}\n\n    <div class=\"search-container flex\" style=\"align-items: center\">\n        {{ form_widget(form.q, {label: false, 'attr': {'class': 'form-control'}}) }}\n\n        <button class=\"btn btn__primary ignore-edges\" type=\"submit\" title=\"{{ 'search'|trans }}\" aria-label=\"{{ 'search'|trans }}\">\n            <i class=\"fa-solid fa-magnifying-glass\" aria-hidden=\"true\"></i>\n        </button>\n    </div>\n\n    <div class=\"flex mobile\" style=\"align-items: end; margin-bottom: 0\">\n        {{ form_widget(form.magazine, {label: false, 'attr': {'class': 'form-control'}}) }}\n        {{ form_widget(form.user, {label: false, 'attr': {'class': 'form-control'}}) }}\n        <div class=\"form-control\">\n            {{ form_widget(form.type, {label: false, 'attr': {'class': 'form-control', 'style': 'padding: 1rem .5rem;'}}) }}\n        </div>\n        <div class=\"form-control\">\n            {{ form_label(form.since, 'created_since') }}\n            {{ form_widget(form.since, {'attr': {'class': 'form-control', 'style': 'padding: 1rem .5rem;', 'title': 'created_since'|trans}}) }}\n        </div>\n    </div>\n\n{{ form_end(form) }}\n"
  },
  {
    "path": "templates/search/front.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    {{- 'search'|trans }} - {{ parent() -}}\n{%- endblock -%}\n\n{% block mainClass %}page-search page-settings{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n{% endblock %}\n\n{% block body %}\n    <h1 hidden>{{ 'search'|trans }}</h1>\n    <div class=\"section section--top\">\n        {% include 'search/form.html.twig' %}\n    </div>\n    {% include 'layout/_flash.html.twig' %}\n    <div id=\"content\" class=\"{{ html_classes('overview subjects comments-tree comments', {\n        'show-comment-avatar': app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_COMMENTS_SHOW_USER_AVATAR')) is same as 'true' or not app.request.cookies.has(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_COMMENTS_SHOW_USER_AVATAR')),\n        'show-post-avatar': app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_POSTS_SHOW_USERS_AVATARS')) is same as 'true' or not app.request.cookies.has(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_POSTS_SHOW_USERS_AVATARS'))\n    }) }}\">\n        {% if objects|length %}\n            {% for object in objects %}\n                {% if object.type is defined and object.type is same as 'user' %}\n                    <div class=\"section\">\n                        {{ component('user_box', {user: object.object}) }}\n                    </div>\n                {% elseif object.type is defined and object.type is same as 'magazine' %}\n                    {{ component('magazine_box', {magazine: object.object, stretchedLink: false}) }}\n                {% else %}\n                    {% include 'layout/_subject_list.html.twig' with {results: [object.object], entryCommentAttributes: {showMagazineName: true, showEntryTitle: true}, postCommentAttributes: {withPost: false}} %}\n                {% endif %}\n            {% endfor %}\n        {% elseif q is defined and q %}\n            {% include 'search/_list.html.twig' %}\n        {% endif %}\n    </div>\n{% endblock %}\n"
  },
  {
    "path": "templates/stats/_filters.html.twig",
    "content": "<div class=\"flex\" data-controller=\"selection\">\n    <label class=\"select\">\n        <select data-action=\"selection#changeLocation\">\n            <option value=\"{{ options_url('statsPeriod', -1) }}\">{{ 'all'|trans }}</option>\n            <option value=\"{{ options_url('statsPeriod', 7) }}\" {{ period is same as 7 ? 'selected' : '' }}>{{ 'week'|trans }}</option>\n            <option value=\"{{ options_url('statsPeriod', 14) }}\" {{ period is same as 14 ? 'selected' : '' }}>\n                2 {{ 'weeks'|trans }}</option>\n            <option value=\"{{ options_url('statsPeriod', 31) }}\" {{ period is same as 31 ? 'selected' : '' }}>{{ 'month'|trans }}</option>\n            <option value=\"{{ options_url('statsPeriod', 183) }}\" {{ period is same as 183 ? 'selected' : '' }}>\n                6 {{ 'months'|trans }}</option>\n            <option value=\"{{ options_url('statsPeriod', 365) }}\" {{ period is same as 365 ? 'selected' : '' }}>{{ 'year'|trans }}</option>\n        </select>\n    </label>\n    <label class=\"select\">\n        <select data-action=\"selection#changeLocation\">\n            <option value=\"{{ options_url('withFederated', false) }}\">{{ 'local'|trans }}</option>\n            <option value=\"{{ options_url('withFederated', true) }}\" {{ withFederated is same as true ? 'selected' : '' }}>{{ 'federated'|trans }}</option>\n        </select>\n    </label>\n</div>\n"
  },
  {
    "path": "templates/stats/_options.html.twig",
    "content": "{%- set TYPE_GENERAL = constant('App\\\\Repository\\\\StatsRepository::TYPE_GENERAL') -%}\n{%- set TYPE_CONTENT = constant('App\\\\Repository\\\\StatsRepository::TYPE_CONTENT') -%}\n{%- set TYPE_VOTES = constant('App\\\\Repository\\\\StatsRepository::TYPE_VOTES') -%}\n<aside class=\"options options--top\" id=\"options\">\n    <div></div>\n    <menu class=\"options__main\">\n        <li>\n            <a href=\"{{ path('stats', {statsType: TYPE_GENERAL}) }}\"\n               class=\"{{ html_classes({'active': route_has_param('statsType', TYPE_GENERAL) or get_route_param('statsType') is same as null}) }}\">\n                {{ TYPE_GENERAL|trans }}\n            </a>\n        </li>\n        <li>\n            <a href=\"{{ path('stats', {statsType: TYPE_CONTENT}) }}\"\n               class=\"{{ html_classes({'active': route_has_param('statsType', TYPE_CONTENT)}) }}\">\n                {{ TYPE_CONTENT|trans }}\n            </a>\n        </li>\n        <li>\n            <a href=\"{{ path('stats', {statsType: TYPE_VOTES}) }}\"\n               class=\"{{ html_classes({'active': route_has_param('statsType', TYPE_VOTES)}) }}\">\n                {{ TYPE_VOTES|trans }}\n            </a>\n        </li>\n    </menu>\n</aside>\n"
  },
  {
    "path": "templates/stats/_stats_count.html.twig",
    "content": "<div id=\"content\" class=\"section\">\n    <div style=\"margin-bottom: 1rem;\">\n        {% include 'stats/_filters.html.twig' %}\n    </div>\n    <div class=\"stats-count\">\n        <div>\n            <h3>{{ 'users'|trans|upper }}</h3>\n            <p>{{ users|abbreviateNumber }}</p>\n        </div>\n        <div>\n            <h3>{{ 'magazines'|trans|upper }}</h3>\n            <p>{{ magazines|abbreviateNumber }}</p>\n        </div>\n        <div>\n            <h3>{{ 'votes'|trans|upper }}</h3>\n            <p>{{ votes|abbreviateNumber }}</p>\n        </div>\n        <div>\n            <h3>{{ 'threads'|trans|upper }}</h3>\n            <p>{{ entries|abbreviateNumber }}</p>\n        </div>\n        <div>\n            <h3>{{ 'comments'|trans|upper }}</h3>\n            <p>{{ comments|abbreviateNumber }}</p>\n        </div>\n        <div>\n            <h3>{{ 'posts'|trans|upper }}</h3>\n            <p>{{ posts|abbreviateNumber }}</p>\n        </div>\n    </div>\n</div>\n"
  },
  {
    "path": "templates/stats/front.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    {{- 'stats'|trans }} - {{ parent() -}}\n{%- endblock -%}\n\n{% block mainClass %}page-stats{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n{% endblock %}\n\n{% block body %}\n    {% include 'stats/_options.html.twig' %}\n    <h1 hidden>{{ 'stats'|trans }}</h1>\n    {% if route_has_param('statsType', 'general'|trans|lower) or chart is null %}\n        {% include 'stats/_stats_count.html.twig' %}\n    {% else %}\n        <div class=\"section\" id=\"content\">\n            {% include 'stats/_filters.html.twig' %}\n            {{ render_chart(chart) }}\n        </div>\n    {% endif %}\n{% endblock %}\n"
  },
  {
    "path": "templates/styles/custom.css.twig",
    "content": "{#\n    using |raw here should be somewhat safe\n    since the next thing you fight should be the browser's css parser\n#}\n/* site dynamic styles */\n{{ include('components/_details_label.css.twig') }}\n\n{% if not app.user or not app.user.ignoreMagazinesCustomCss %}\n    {% if magazine is defined and magazine and magazine.customCss %}\n        /* magazine styles */\n        {{ magazine.customCss|raw }}\n    {% endif %}\n{% endif %}\n\n{% if app.user is defined and app.user and app.user.customCss %}\n    /* user styles */\n    {{ app.user.customCss|raw }}\n{% endif %}\n"
  },
  {
    "path": "templates/tag/_list.html.twig",
    "content": "<div data-controller=\"subject-list\">\n    {% include 'layout/_subject_list.html.twig' with {entryCommentAttributes: {showMagazineName: true, showEntryTitle: true}, postCommentAttributes: {withPost: false}} %}\n</div>"
  },
  {
    "path": "templates/tag/_options.html.twig",
    "content": "<aside class=\"options options--top\" id=\"options\">\n\n</aside>\n"
  },
  {
    "path": "templates/tag/_panel.html.twig",
    "content": "<section class=\"tag section\">\n    <h3>{{ 'tag'|trans }}</h3>\n\n    <div class=\"row\">\n        <header>\n            <h4>\n                <a href=\"{{ path('tag_overview', {name: tag}) }}\" class=\"{{ html_classes({'stretched-link': false}) }}\">\n                    #{{ tag }}\n                </a>\n            </h4>\n        </header>\n    </div>\n\n    {{ component('tag_actions', {tag: tag}) }}\n\n    {% if false %}\n        {{ component('magazine_sub', {magazine: magazine}) }}\n\n        {% if showInfo %}\n            <ul class=\"info\">\n                <li>{{ 'subscribers'|trans }}: <span>{{ computed.magazine.subscriptionsCount }}</span></li>\n            </ul>\n        {% endif %}\n    {% endif %}\n\n    <ul class=\"meta\">\n        {{ _self.meta_item('threads'|trans, counts[\"entry\"]) }}\n        {{ _self.meta_item('comments'|trans, counts[\"entry_comment\"]) }}\n        {{ _self.meta_item('posts'|trans, counts[\"post\"]) }}\n        {{ _self.meta_item('replies'|trans, counts[\"post_comment\"]) }}\n    </ul>\n\n    {% macro meta_item(name, count) %}\n        <li>{{ name }}<span>{{ count }}</span></li>\n    {% endmacro %}\n</section>\n"
  },
  {
    "path": "templates/tag/comments.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    #{{ tag }} - {{ 'comments'|trans }} - {{ parent() -}}\n{%- endblock -%}\n\n\n{% block mainClass %}page-tag-comments{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n{% endblock %}\n\n{% block body %}\n    {% include 'tag/_options.html.twig' %}\n    <div class=\"comments entry-comments\">\n        {% include 'entry/comment/_list.html.twig' %}\n    </div>\n{% endblock %}\n"
  },
  {
    "path": "templates/tag/front.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    #{{ tag }} - {{ 'threads'|trans }} - {{ parent() -}}\n{%- endblock -%}\n\n\n{% block mainClass %}page-tag-entries{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n{% endblock %}\n\n{% block body %}\n    {% include 'tag/_options.html.twig' %}\n    <div id=\"content\" class=\"entries\">\n        {% include 'entry/_list.html.twig' %}\n    </div>\n{% endblock %}\n"
  },
  {
    "path": "templates/tag/overview.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    #{{ tag }} - {{ parent() -}}\n{%- endblock -%}\n\n\n{% block mainClass %}page-tag-overview{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n    {% if app.user and app.user.admin %}\n        <div class=\"section magazine\">\n            <h3>{{ 'admin_panel'|trans }}</h3>\n            <div class=\"panel\">\n                <form action=\"{{ path('tag_' ~ (is_tag_banned(tag) ? 'unban' : 'ban'), {name: tag}) }}\" name=\"tag_ban\" method=\"post\"\n                        {{ stimulus_controller('confirmation') }} data-action=\"confirmation#ask\" data-confirmation-message-param=\"{{ 'are_you_sure'|trans }}\">\n                    <button type=\"submit\"\n                            class=\"{{ html_classes('btn action', {'btn__secondary': is_tag_banned(tag)}, {'btn__danger': is_tag_banned(tag) is not same as true}) }}\"\n                            data-action=\"subs#send\"\n                            title=\"{{ is_tag_banned(tag) ? 'unban_hashtag_description'|trans : 'ban_hashtag_description'|trans }}\"\n                    >\n                        <i class=\"fa-solid fa-ban\" aria-hidden=\"true\"></i><span>{{ is_tag_banned(tag) ? 'unban_hashtag_btn'|trans : 'ban_hashtag_btn'|trans }}</span>\n                    </button>\n                    <input type=\"hidden\" name=\"token\" value=\"{{ csrf_token('ban') }}\">\n                </form>\n            </div>\n        </div>\n    {% endif %}\n{% endblock %}\n\n{% block body %}\n    {% include 'tag/_options.html.twig' %}\n    <div id=\"content\" class=\"{{ html_classes('overview subjects comments-tree comments', {\n        'show-comment-avatar': app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_COMMENTS_SHOW_USER_AVATAR')) is same as 'true' or not app.request.cookies.has(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_COMMENTS_SHOW_USER_AVATAR')),\n        'show-post-avatar': app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_POSTS_SHOW_USERS_AVATARS')) is same as 'true' or not app.request.cookies.has(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_POSTS_SHOW_USERS_AVATARS'))\n    }) }}\">\n        {% include 'tag/_list.html.twig' %}\n    </div>\n{% endblock %}\n"
  },
  {
    "path": "templates/tag/people.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    #{{ tag }} - {{ 'people'|trans }} - {{ parent() -}}\n{%- endblock -%}\n\n\n{% block mainClass %}page-tag-people page-people{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n{% endblock %}\n\n{% block body %}\n    {% include 'tag/_options.html.twig' %}\n    <div id=\"content\">\n        <h2>{{ 'people_local'|trans }}</h2>\n        <div class=\"users users-cards section section--no-bg section--no-border\">\n            {% for user in local %}\n                <div class=\"section\">\n                    {{ component('user_box', {user: user}) }}\n                </div>\n            {% endfor %}\n        </div>\n        {% if not local|length %}\n            <aside class=\"section section--muted\">\n                <p>{{ 'empty'|trans }}</p>\n            </aside>\n        {% endif %}\n\n        <h2>{{ 'people_federated'|trans }}</h2>\n        <div class=\"users users-cards section section--no-bg section--no-border\">\n            {% for user in federated %}\n                <div class=\"section\">\n                    {{ component('user_box', {user: user}) }}\n                </div>\n            {% endfor %}\n        </div>\n        {% if not federated|length %}\n            <aside class=\"section section--muted\">\n                <p>{{ 'empty'|trans }}</p>\n            </aside>\n        {% endif %}\n    </div>\n{% endblock %}\n"
  },
  {
    "path": "templates/tag/posts.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    #{{ tag }} - {{ 'posts'|trans }} - {{ parent() -}}\n{%- endblock -%}\n\n\n{% block mainClass %}page-tag-posts{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n{% endblock %}\n\n{% block body %}\n    {% include 'tag/_options.html.twig' %}\n    <div id=\"content\" class=\"{{ html_classes('overview subjects comments-tree comments', {\n        'show-comment-avatar': app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_COMMENTS_SHOW_USER_AVATAR')) is same as 'true' or not app.request.cookies.has(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_COMMENTS_SHOW_USER_AVATAR')),\n        'show-post-avatar': app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_POSTS_SHOW_USERS_AVATARS')) is same as 'true' or not app.request.cookies.has(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_POSTS_SHOW_USERS_AVATARS'))\n    }) }}\">\n        {% include 'post/_list.html.twig' %}\n    </div>\n{% endblock %}\n"
  },
  {
    "path": "templates/user/2fa.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    {{- 'Two factor authentication'|trans }} - {{ parent() -}}\n{%- endblock -%}\n\n{% block mainClass %}page-2fa{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n{% endblock %}\n\n{% block body %}\n    <h1>{{ 'two_factor_authentication'|trans }}</h1>\n    <div id=\"content\" class=\"section\">\n        <div class=\"container\">\n            <form class=\"form\" action=\"{{ checkPathUrl ? checkPathUrl: path(checkPathRoute) }}\" method=\"post\">\n                {% if authenticationError %}\n                <div class=\"alert alert__danger\">{{ '2fa.code_invalid'|trans }}</div>\n                {% endif %}\n                <div>\n                    <label for=\"auth_code\">{{ '2fa.authentication_code.label'|trans }}</label>\n                    <input id=\"auth_code\"\n                           type=\"text\"\n                           name=\"{{ authCodeParameterName }}\"\n                           autocomplete=\"one-time-code\"\n                           autofocus\n                           required\n                           inputmode=\"numeric\"\n                           pattern=\"[0-9]*\"/>\n                </div>\n                <input type=\"hidden\" name=\"{{ csrfParameterName }}\" value=\"{{ csrf_token(csrfTokenId) }}\">\n                <div class=\"row actions\">\n                    <div><span class=\"cancel\"><a href=\"{{ logout_path() }}\">{{ 'cancel'|trans }}</a></span></div>\n                    <div><button class=\"btn btn__primary\" type=\"submit\">{{ '2fa.verify'|trans }}</button></div>\n                </div>\n            </form>\n        </div>\n    </div>\n{% endblock %}\n"
  },
  {
    "path": "templates/user/_admin_panel.html.twig",
    "content": "{% if (is_granted('ROLE_ADMIN') or is_granted('ROLE_MODERATOR')) and app.user != user and not user.admin() %}\n    <div class=\"section magazine\">\n        <h3>{{ 'admin_panel'|trans }}</h3>\n        <div class=\"panel\">\n            {% if user.apId is same as null and not user.isVerified and is_granted('ROLE_ADMIN') %}\n                <form action=\"{{ path('user_verify', {username: user.username}) }}\"\n                        method=\"POST\" {{ stimulus_controller('confirmation') }}\n                        data-action=\"confirmation#ask\" data-confirmation-message-param=\"{{ 'are_you_sure'|trans }}\">\n                    <input type=\"hidden\" name=\"token\" value=\"{{ csrf_token('user_verify') }}\">\n                    <button type=\"submit\" class=\"btn btn__secondary\">\n                        <i class=\"fa fa-user-check\" aria-hidden=\"true\"></i> <span>{{ 'user_verify'|trans }}</span>\n                    </button>\n                </form>\n                <hr>\n            {% endif %}\n            {% if user.isTotpAuthenticationEnabled and is_granted('ROLE_ADMIN') %}\n                <form action=\"{{ path('user_2fa_remove', {username: user.username}) }}\"\n                        method=\"POST\" {{ stimulus_controller('confirmation') }}\n                        data-action=\"confirmation#ask\" data-confirmation-message-param=\"{{ 'are_you_sure'|trans }}\">\n                    <input type=\"hidden\" name=\"token\" value=\"{{ csrf_token('user_2fa_remove') }}\">\n                    <button type=\"submit\" class=\"btn btn__secondary\">\n                        <i class=\"fa fa-mobile\" aria-hidden=\"true\"></i> <span>{{ '2fa.remove'|trans }}</span>\n                    </button>\n                </form>\n                <hr>\n            {% endif %}\n            <form action=\"{{ path(user.isBanned ? 'user_unban' : 'user_ban', {username: user.username}) }}\"\n                method=\"POST\" {{ stimulus_controller('confirmation') }}\n                data-action=\"confirmation#ask\" data-confirmation-message-param=\"{{ 'are_you_sure'|trans }}\">\n                <input type=\"hidden\" name=\"token\" value=\"{{ csrf_token('user_ban') }}\">\n                <button type=\"submit\" class=\"btn btn__secondary\">\n                    <i class=\"fa fa-ban\" aria-hidden=\"true\"></i> <span>{{ user.isBanned ? 'unban_account'|trans : 'ban_account'|trans }}</span>\n                </button>\n            </form>\n            <hr>\n            <form action=\"{{ path(user.visibility is same as 'trashed' ? 'user_unsuspend' : 'user_suspend', {username: user.username}) }}\"\n                method=\"POST\" {{ stimulus_controller('confirmation') }}\n                data-action=\"confirmation#ask\" data-confirmation-message-param=\"{{ 'are_you_sure'|trans }}\">\n                <input type=\"hidden\" name=\"token\" value=\"{{ csrf_token('user_suspend') }}\">\n                <button type=\"submit\" class=\"btn btn__secondary\" title=\"{{ 'user_suspend_desc'|trans }}\">\n                    <i class=\"fa-solid fa-pause\" aria-hidden=\"true\"></i> <span>{{ user.visibility is same as 'trashed' ? 'unsuspend_account'|trans : 'suspend_account'|trans }}</span>\n                </button>\n            </form>\n\n            {% if is_granted('ROLE_ADMIN') %}\n                <form action=\"{{ path('user_remove_following', {username: user.username}) }}\"\n                        method=\"POST\" {{ stimulus_controller('confirmation') }}\n                        data-action=\"confirmation#ask\" data-confirmation-message-param=\"{{ 'are_you_sure'|trans }}\">\n                    <input type=\"hidden\" name=\"token\" value=\"{{ csrf_token('user_remove_following') }}\">\n                    <button type=\"submit\" class=\"btn btn__secondary\">\n                        <i class=\"fa fa-solid fa-users-slash\" aria-hidden=\"true\"></i> <span>{{ 'remove_following'|trans }}</span>\n                    </button>\n                </form>\n                <hr>\n                {% if user.id is not same as app.user.id and not user.admin %}\n                    {% if user.markedForDeletionAt is same as null and user.apId is same as null %}\n                        <form action=\"{{ path('schedule_user_delete_account', {username: user.username}) }}\"\n                                method=\"POST\" {{ stimulus_controller('confirmation') }}\n                                data-action=\"confirmation#ask\" data-confirmation-message-param=\"{{ 'are_you_sure'|trans }}\">\n                            <input type=\"hidden\" name=\"token\" value=\"{{ csrf_token('schedule_user_delete_account') }}\">\n                            <button type=\"submit\" class=\"btn btn__secondary\" title=\"{{ 'schedule_delete_account_desc'|trans }}\">\n                                <i class=\"fa fa-dumpster\" aria-hidden=\"true\"></i> <span>{{ 'schedule_delete_account'|trans }}</span>\n                            </button>\n                        </form>\n                    {% elseif user.markedForDeletionAt is not same as null and user.apId is same as null %}\n                        <form action=\"{{ path('remove_schedule_user_delete_account', {username: user.username}) }}\"\n                                method=\"POST\" {{ stimulus_controller('confirmation') }}\n                                data-action=\"confirmation#ask\" data-confirmation-message-param=\"{{ 'are_you_sure'|trans }}\">\n                            <input type=\"hidden\" name=\"token\" value=\"{{ csrf_token('remove_schedule_user_delete_account') }}\">\n                            <button type=\"submit\" class=\"btn btn__secondary\" title=\"{{ 'remove_schedule_delete_account_desc'|trans }}\">\n                                <i class=\"fa fa-dumpster\" aria-hidden=\"true\"></i> <span>{{ 'remove_schedule_delete_account'|trans }}</span>\n                            </button>\n                        </form>\n                    {% endif %}\n                    <form action=\"{{ path('user_delete_account', {username: user.username}) }}\"\n                            method=\"POST\"  {{ stimulus_controller('confirmation') }}\n                            data-action=\"confirmation#ask\" data-confirmation-message-param=\"{{ 'are_you_sure'|trans }}\">\n                        <input type=\"hidden\" name=\"token\" value=\"{{ csrf_token('user_delete_account') }}\">\n                        <button type=\"submit\" class=\"btn btn__danger\" title=\"{{ 'delete_account_desc'|trans }}\">\n                            <i class=\"fa fa-dumpster\" aria-hidden=\"true\"></i> <span>{{ 'delete_account'|trans }}</span>\n                        </button>\n                    </form>\n                {% endif %}\n            {% endif %}\n        </div>\n    </div>\n{% endif %}\n"
  },
  {
    "path": "templates/user/_boost_list.html.twig",
    "content": "<div data-controller=\"subject-list\">\n    {% include 'layout/_subject_list.html.twig' with {entryCommentAttributes: {showMagazineName: false}} %}\n</div>\n"
  },
  {
    "path": "templates/user/_federated_info.html.twig",
    "content": "{% if user.apId %}\n    <div class=\"alert alert__info\">\n        <p>{{ 'federated_user_info'|trans }} <a\n                    href=\"{{ user.apProfileId }}\" rel=\"noopener noreferrer nofollow\" target=\"_blank\"><span>{{ 'go_to_original_instance'|trans }}</span> <i class=\"fa-solid fa-external-link\" aria-hidden=\"true\"></i></a></p>\n    </div>\n    {% if is_instance_of_user_banned(user) %}\n        <div class=\"alert alert__info\">\n            {{ 'user_instance_defederated_info'|trans }}\n        </div>\n    {% endif %}\n{% endif %}\n"
  },
  {
    "path": "templates/user/_info.html.twig",
    "content": "<section class=\"section user-info\">\n    <h3>{{ user.title ?? user.username|username }}</h3>\n    {% if is_route_name_starts_with('user_settings') %}\n        <div class=\"row\">\n            {% if user.avatar %}\n                <figure>\n                    <img class=\"image-inline\"\n                         width=\"100\" height=\"100\"\n                         loading=\"lazy\"\n                         src=\"{{ user.avatar.filePath ? (asset(user.avatar.filePath)|imagine_filter('avatar_thumb')) : user.avatar.sourceUrl }}\"\n                         alt=\"{{ user.username ~' '~ 'avatar'|trans|lower }}\">\n                </figure>\n            {% endif %}\n            <h4><a href=\"{{ path('user_overview', {username:user.username}) }}\"\n                   class=\"stretched-link\">{{ user.title ?? user.username|username(false) }}</a></h4>\n            <p class=\"user__name\">\n                {{ user.username|username }}{% if not user.apId %}@{{ kbin_domain() }}{% endif %}\n                {% if user.apManuallyApprovesFollowers is same as true %}\n                    <i class=\"fa-solid fa-lock\" aria-description=\"{{ 'manually_approves_followers'|trans }}\" title=\"{{ 'manually_approves_followers'|trans }}\" aria-describedby=\"{{ 'manually_approves_followers'|trans }}\"></i>\n                {% endif %}\n            </p>\n        </div>\n        {{ component('user_actions', {user: user}) }}\n    {% endif %}\n    <ul class=\"info\">\n        <li>{{ 'joined'|trans }}: {{ component('date', {date: user.createdAt}) }}</li>\n        <li>{{ 'cake_day'|trans }}: <div><i class=\"fa-solid fa-cake\" aria-hidden=\"true\"></i> <span>{{ user.createdAt|format_date('short', '', null, 'gregorian', mbin_lang()) }}</span></div></li>\n        {% if app.user is defined and app.user is not null and app.user.admin() %}\n            {% set attitude = get_user_attitude(user) %}\n            <li>\n                {{ 'attitude'|trans }}:\n                <div><span>\n                    {% if attitude > 0 %}\n                        {{ attitude|number_format(2) }}%\n                    {% else %}\n                        -\n                    {% endif %}\n                </span></div>\n            </li>\n            {% if user.apId is not null %}\n                <li>\n                    {{ 'last_updated'|trans }}: {{ component('date', {date: user.apFetchedAt}) }}\n                </li>\n            {% endif %}\n        {% endif %}\n\n        {% set instance = get_instance_of_user(user) %}\n        {% if instance is not same as null %}\n            <li>{{ 'server_software'|trans }}: <div><span>{{ instance.software }}{% if instance.version is not same as null and app.user is defined and app.user is not null and app.user.admin() %} v{{ instance.version }}{% endif %}</span></div></li>\n        {% endif %}\n\n        {%- set TYPE_ENTRY = constant('App\\\\Repository\\\\ReputationRepository::TYPE_ENTRY') -%}\n        <li><a href=\"{{ path('user_reputation', {username: user.username, reputationType: TYPE_ENTRY}) }}\" class=\"stretched-link\">{{ 'reputation_points'|trans }}:</a> {{ get_reputation_total(user) }}</li>\n        <li><a href=\"{{ path('user_moderated', {username: user.username}) }}\"\n               class=\"stretched-link\">{{ 'moderated'|trans }}:</a> {{ count_user_moderated(user) }}\n        </li>\n        {% if app.user is not same as user and not is_instance_of_user_banned(user) %}\n            <li>\n                <a href=\"{{ path('messages_create', {username: user.username}) }}\" class=\"stretched-link\">\n                    {{ 'send_message'|trans }}\n                </a>\n                <i class=\"fa-solid fa-envelope\" aria-hidden=\"true\"></i>\n            </li>\n        {% endif %}\n    </ul>\n</section>\n"
  },
  {
    "path": "templates/user/_list.html.twig",
    "content": "{%- set V_TRUE = constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::TRUE') -%}\n{%- set V_FALSE = constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::FALSE') -%}\n{%- set SHOW_USER_FULLNAME = app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::MBIN_SHOW_USER_DOMAIN'), V_FALSE) -%}\n{% if users|length %}\n    <div id=\"content\" class=\"section\">\n        {% if view is same as 'cards'|trans|lower %}\n            <div class=\"users users-cards\">\n                {% for user in users %}\n                    {{ component('user', {user: user, showMeta: false, showInfo: false}) }}\n                {% endfor %}\n            </div>\n        {% elseif view is same as 'columns'|trans|lower %}\n            <div class=\"users users-columns\">\n                <ul>\n                    {% for user in users %}\n                        <li>\n                            {% if user.avatar %}\n                                <figure>\n                                    <img class=\"image-inline\"\n                                         width=\"32\" height=\"32\"\n                                         loading=\"lazy\"\n                                         src=\"{{ user.avatar.filePath ? (asset(user.avatar.filePath)|imagine_filter('avatar_thumb')) : user.avatar.sourceUrl }}\"\n                                         alt=\"{{ user.username ~' '~ 'avatar'|trans|lower }}\">\n                                </figure>\n                            {% endif %}\n                            <div>\n                                <a href=\"{{ path('user_overview', {username: user.username}) }}\" class=\"stretched-link\">\n                                    {{ user.username }}\n                                    {%- if SHOW_USER_FULLNAME is same as V_TRUE -%}\n                                        @{{- user.username|apDomain -}}\n                                    {%- endif -%}\n                                </a>\n                                <small>{{ component('date', {date: user.createdAt}) }}</small>\n                            </div>\n                        </li>\n                    {% endfor %}\n                </ul>\n            </div>\n        {% else %}\n            <div class=\"users table-responsive\">\n                <table>\n                    <thead>\n                    <tr>\n                        <td>{{ 'name'|trans }}</td>\n                        <td>{{ 'threads'|trans }}</td>\n                        <td>{{ 'comments'|trans }}</td>\n                        <td>{{ 'posts'|trans }}</td>\n                        <td></td>\n                    </tr>\n                    </thead>\n                    <tbody>\n                    {% for u in users %}\n                        <tr>\n                            <td>{{ component('user_inline', { user: u, stretchedLink: true, showAvatar: true, showNewIcon: true, fullName: SHOW_USER_FULLNAME is same as V_TRUE}) }}</td>\n                            <td>{{ u.entries|length }}</td>\n                            <td>{{ u.entryComments|length }}</td>\n                            <td>{{ u.posts|length + u.postComments|length }}</td>\n                            <td>{{ component('user_actions', {user: u}) }}</td>\n                        </tr>\n                    {% endfor %}\n                    </tbody>\n                </table>\n            </div>\n        {% endif %}\n    </div>\n{% else %}\n    <aside class=\"section section--muted\">\n        <p>{{ 'empty'|trans }}</p>\n    </aside>\n{% endif %}\n"
  },
  {
    "path": "templates/user/_options.html.twig",
    "content": "<aside class=\"{{ html_classes('options', {'options--top': not is_route_name('user_overview')}) }}\" id=\"options\">\n    <div></div>\n    <menu class=\"options__main\">\n        <li>\n            <a href=\"{{ path('user_overview', {username: user.username}) }}\"\n               class=\"{{ html_classes({'active': is_route_name('user_overview')}) }}\">\n                {{ 'overview'|trans }}\n            </a>\n        </li>\n        <li>\n            <a href=\"{{ path('user_entries', {username: user.username}) }}\"\n               class=\"{{ html_classes({'active': is_route_name('user_entries')}) }}\">\n                {{ 'threads'|trans }} ({{ user.entries|length|abbreviateNumber }})\n            </a>\n        </li>\n        <li>\n            <a href=\"{{ path('user_comments', {username: user.username}) }}\"\n               class=\"{{ html_classes({'active': is_route_name('user_comments')}) }}\">\n                {{ 'comments'|trans }} ({{ user.entryComments|length|abbreviateNumber }})\n            </a>\n        </li>\n        <li>\n            <a href=\"{{ path('user_posts', {username: user.username}) }}\"\n               class=\"{{ html_classes({'active': is_route_name('user_posts')}) }}\">\n                {{ 'posts'|trans }} ({{ user.posts|length|abbreviateNumber }})\n            </a>\n        </li>\n        <li>\n            <a href=\"{{ path('user_replies', {username: user.username}) }}\"\n               class=\"{{ html_classes({'active': is_route_name('user_replies')}) }}\">\n                {{ 'replies'|trans }} ({{ user.postComments|length|abbreviateNumber }})\n            </a>\n        </li>\n        <li>\n            <a href=\"{{ path('user_boosts', {username: user.username}) }}\"\n               class=\"{{ html_classes({'active': is_route_name('user_boosts')}) }}\">\n                {{ 'boosts'|trans }} ({{ count_user_boosts(user)|abbreviateNumber }})\n            </a>\n        </li>\n        {% if user.getShowProfileFollowings or app.user is same as user %}\n            <li>\n                <a href=\"{{ path('user_following', {username: user.username}) }}\"\n                   class=\"{{ html_classes({'active': is_route_name('user_following'), 'opacity-50': not user.getShowProfileFollowings}) }}\">\n                    {{ 'following'|trans }} ({{ user.follows|length|abbreviateNumber }})\n                </a>\n            </li>\n        {% endif %}\n        <li>\n            <a href=\"{{ path('user_followers', {username: user.username}) }}\"\n               class=\"{{ html_classes({'active': is_route_name('user_followers')}) }}\">\n                {{ 'followers'|trans }} ({{ user.followers|length|abbreviateNumber }})\n            </a>\n        </li>\n        {% if user.getShowProfileSubscriptions or app.user is same as user %}\n            <li>\n                <a href=\"{{ path('user_subscriptions', {username: user.username}) }}\"\n                   class=\"{{ html_classes({'active': is_route_name('user_subscriptions'), 'opacity-50': not user.getShowProfileSubscriptions}) }}\">\n                    {{ 'subscriptions'|trans }} ({{ user.subscriptions|length|abbreviateNumber }})\n                </a>\n            </li>\n        {% endif %}\n        <li>\n            {%- set TYPE_ENTRY = constant('App\\\\Repository\\\\ReputationRepository::TYPE_ENTRY') -%}\n            <a href=\"{{ path('user_reputation', {username: user.username, reputationType: TYPE_ENTRY}) }}\"\n               class=\"{{ html_classes({'active': is_route_name('user_reputation')}) }}\">\n                {{ 'reputation'|trans }} ({{ get_reputation_total(user)|abbreviateNumber }})\n            </a>\n        </li>\n    </menu>\n</aside>\n"
  },
  {
    "path": "templates/user/_user_popover.html.twig",
    "content": "<div class=\"user-popover\">\n    <header>\n        {% if user.avatar %}\n            {{ component('user_avatar', {\n                user: user,\n                width: 100,\n                height: 100,\n                asLink: true\n            }) }}\n        {% endif %}\n        <div>\n            <h3>\n                <a class=\"link-muted\" href=\"{{ path('user_overview', {username: user.username}) }}\">{{ user.title ?? user.username|username }}</a>\n                {% if user.isNew() %}\n                    {% set days = constant('App\\\\Entity\\\\User::NEW_FOR_DAYS') %}\n                    <i class=\"fa-solid fa-leaf new-user-icon\" title=\"{{ 'new_user_description'|trans({ '%days%': days }) }}\" aria-description=\"{{ 'new_user_description'|trans({ '%days%': days }) }}\"></i>\n                {% endif %}\n                {% if user.isCakeDay() %}\n                    <i class=\"fa-solid fa-cake-candles\" title=\"Cake Day\" aria-description=\"Cake Day\"></i>\n                {% endif %}\n            </h3>\n            <p class=\"user__name\">\n                <span>\n                    {{ user.username|username(true) }}\n                    {% if user.apManuallyApprovesFollowers is same as true %}\n                        <i class=\"fa-solid fa-lock\" aria-description=\"{{ 'manually_approves_followers'|trans }}\" title=\"{{ 'manually_approves_followers'|trans }}\" aria-describedby=\"{{ 'manually_approves_followers'|trans }}\"></i>\n                    {% endif %}\n                </span>\n                {% if user.apProfileId %}\n                    <a href=\"{{ user.apProfileId }}\" rel=\"noopener noreferrer nofollow\" target=\"_blank\" title=\"{{ 'go_to_original_instance'|trans }}\" aria-label=\"{{ 'go_to_original_instance'|trans }}\">\n                    <i class=\"fa-solid fa-external-link\" aria-hidden=\"true\"></i></a>\n                {% endif %}\n            </p>\n            <ul>\n                <li>{{ 'joined'|trans }}: {{ component('date', {date: user.createdAt}) }}</li>\n                <li>\n                    <div title=\"{{ 'cake_day'|trans }}\" aria-label=\"{{ 'cake_day'|trans }}\">\n                        <i class=\"fa-solid fa-cake\" aria-hidden=\"true\"></i>\n                        <span>{{ user.createdAt|format_date('short', '', null, 'gregorian', mbin_lang()) }}</span>\n                    </div>\n                </li>\n                <li>\n                    {%- set TYPE_ENTRY = constant('App\\\\Repository\\\\ReputationRepository::TYPE_ENTRY') -%}\n                    <a href=\"{{ path('user_reputation', {username: user.username, reputationType: TYPE_ENTRY}) }}\">\n                        {{ 'reputation_points'|trans }}: {{ get_reputation_total(user) }}\n                    </a>\n                </li>\n            </ul>\n            {{ component('user_actions', {user: user}) }}\n            {% if app.user is defined and app.user is not same as null and app.user is not same as user %}\n                {{ component('notification_switch', {target: user}) }}\n            {% endif %}\n        </div>\n    </header>\n    <div class=\"user-note\">\n        {{ form_start(form) }}\n        {{ form_row(form.body, {label: 'note'}) }}\n        {{ form_row(form.submit, {label: 'save', attr: {class: 'btn btn__primary', 'data-action': ''}, row_attr: {class: 'float-end'}}) }}\n        {{ form_end(form) }}\n    </div>\n    <footer>\n        <hr>\n        <menu>\n            <li>\n                <a class=\"stretched-link\" href=\"{{ path('user_entries', {username: user.username}) }}\">\n                    <div>{{ user.entries|length }}</div>\n                    <div>{{ 'threads'|trans }}</div>\n                </a>\n            </li>\n            <li>\n                <a class=\"stretched-link\" href=\"{{ path('user_comments', {username: user.username}) }}\">\n                    <div>{{ user.entryComments|length }}</div>\n                    <div>{{ 'comments'|trans }}</div>\n                </a>\n            </li>\n            <li>\n                <a class=\"stretched-link\" href=\"{{ path('user_posts', {username: user.username}) }}\">\n                    <div>{{ user.posts|length }}</div>\n                    <div>{{ 'posts'|trans }}</div>\n                </a>\n            </li>\n            <li>\n                <a class=\"stretched-link\" href=\"{{ path('user_replies', {username: user.username}) }}\">\n                    <div>{{ user.postComments|length }}</div>\n                    <div>{{ 'replies'|trans }}</div>\n                </a>\n            </li>\n        </menu>\n    </footer>\n</div>\n"
  },
  {
    "path": "templates/user/_visibility_info.html.twig",
    "content": "{% if user is defined and user and user.visibility is same as 'trashed' %}\n    <div class=\"alert alert__danger\">\n        <p>{{ 'account_is_suspended'|trans }}</p>\n    </div>\n{% endif %}\n"
  },
  {
    "path": "templates/user/comments.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    {{- 'comments'|trans }} - {{ user.username|username(false) }} - {{ parent() -}}\n{%- endblock -%}\n\n{% block mainClass %}page-user page-user-overview{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n    {% include('user/_admin_panel.html.twig') %}\n{% endblock %}\n\n{% block body %}\n    <div class=\"section section--top\">\n        {{ component('user_box', {\n            user: user,\n            stretchedLink: false\n        }) }}\n    </div>\n    {% include('user/_options.html.twig') %}\n    {% include('user/_visibility_info.html.twig') %}\n    {% include('user/_federated_info.html.twig') %}\n\n    {% if user.visibility is same as 'visible' or is_granted('ROLE_ADMIN') or is_granted('ROLE_MODERATOR') %}\n    <div id=\"content\">\n        {% include 'entry/comment/_list.html.twig' with {showNested: false} %}\n    </div>\n    {% endif %}\n{% endblock %}\n"
  },
  {
    "path": "templates/user/consent.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    {{- 'oauth.consent.title'|trans }} - {{ parent() -}}\n{%- endblock -%}\n\n{% block mainClass %}page-login{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n{% endblock %}\n\n{% block body %}\n    <h1>{{ app_name }} - {{ 'oauth.consent.grant_permissions'|trans }}</h1>\n    {% include 'layout/_flash.html.twig' %}\n    <div id=\"content\" class=\"section\">\n        <div class=\"container\">\n            <div>\n                {% if image %}\n                    <figure>\n                        <img class=\"thumb-subject oauth-client-logo\"\n                                loading=\"lazy\"\n                                src=\"{{ image.filePath ? (asset(image.filePath)|imagine_filter('entry_thumb')) : image.sourceUrl }}\"\n                                alt=\"{{ image.altText }}\">\n                    </figure>\n                {% endif %}\n                <p><strong>{{ app_name }}</strong> {{ 'oauth.consent.app_requesting_permissions'|trans }}:</p>\n                <ul>\n                    {% for scope in scopes %}\n                    <li id=\"{{ scope }}\">{{ scope|trans }}</li>\n                    {% endfor %}\n                </ul>\n                {% if has_existing_scopes %}\n                <p><strong>{{ app_name }}</strong> {{ 'oauth.consent.app_has_permissions'|trans }}:</p>\n                <ul>\n                    {% for scope in existing_scopes %}\n                    <li id=\"{{ scope }}\">{{ scope|trans }}</li>\n                    {% endfor %}\n                </ul>\n                {% endif %}\n                <p>{{ 'oauth.consent.to_allow_access'|trans }}</p>\n            </div>\n            <form method=\"post\">\n                <input type=\"hidden\" name=\"_csrf_token\" value=\"{{ csrf_token('consent') }}\">\n                <div class=\"float-end\">\n                    <button class=\"btn btn__primary\" type=\"submit\" name=\"consent\" value=\"yes\">{{ 'oauth.consent.allow'|trans }}</button>\n                    <button class=\"btn btn__primary\" type=\"submit\" name=\"consent\" value=\"no\">{{ 'oauth.consent.deny'|trans }}</button>\n                </div>\n            </form>\n        </div>\n    </div>\n{% endblock %}\n"
  },
  {
    "path": "templates/user/entries.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    {{- 'threads'|trans }} - {{ user.username|username(false) }} - {{ parent() -}}\n{%- endblock -%}\n\n{% block mainClass %}page-user page-user-overview{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n    {% include('user/_admin_panel.html.twig') %}\n{% endblock %}\n\n{% block body %}\n    <div class=\"section section--top\">\n        {{ component('user_box', {\n            user: user,\n            stretchedLink: false\n        }) }}\n    </div>\n    {% include('user/_options.html.twig') %}\n    {% include('user/_visibility_info.html.twig') %}\n    {% include('user/_federated_info.html.twig') %}\n\n    {% if user.visibility is same as 'visible' or is_granted('ROLE_ADMIN') or is_granted('ROLE_MODERATOR') %}\n    <div id=\"content\">\n        {% include 'entry/_list.html.twig' %}\n    </div>\n    {% endif %}\n{% endblock %}\n"
  },
  {
    "path": "templates/user/followers.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    {{- 'followers'|trans }} - {{ user.username|username(false) }} - {{ parent() -}}\n{%- endblock -%}\n\n{% block mainClass %}page-user page-user-overview{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n    {% include('user/_admin_panel.html.twig') %}\n{% endblock %}\n\n{% block body %}\n    <div class=\"section section--top\">\n        {{ component('user_box', {\n            user: user,\n            stretchedLink: false\n        }) }}\n    </div>\n    {% include('user/_options.html.twig') %}\n    {% include('user/_visibility_info.html.twig') %}\n    {% include('user/_federated_info.html.twig') %}\n\n    {% if user.visibility is same as 'visible' or is_granted('ROLE_ADMIN') or is_granted('ROLE_MODERATOR') %}\n    <div id=\"content\">\n        {% include 'layout/_user_activity_list.html.twig' with {list: users, actor: 'follower'} %}\n    </div>\n    {% endif %}\n{% endblock %}\n"
  },
  {
    "path": "templates/user/following.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    {{- 'following'|trans }} - {{ user.username|username(false) }} - {{ parent() -}}\n{%- endblock -%}\n\n{% block mainClass %}page-user page-user-overview{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n    {% include('user/_admin_panel.html.twig') %}\n{% endblock %}\n\n{% block body %}\n    <div class=\"section section--top\">\n        {{ component('user_box', {\n            user: user,\n            stretchedLink: false\n        }) }}\n    </div>\n    {% include('user/_options.html.twig') %}\n    {% include('user/_visibility_info.html.twig') %}\n    {% include('user/_federated_info.html.twig') %}\n\n    {% if user.visibility is same as 'visible' or is_granted('ROLE_ADMIN') or is_granted('ROLE_MODERATOR') %}\n    <div id=\"content\">\n        {% include 'layout/_user_activity_list.html.twig' with {list: users, actor: 'following'} %}\n    </div>\n    {% endif %}\n{% endblock %}\n"
  },
  {
    "path": "templates/user/login.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    {{- 'login'|trans }} - {{ parent() -}}\n{%- endblock -%}\n\n{% block mainClass %}page-login{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n{% endblock %}\n\n{% block body %}\n    <h1>{{ 'login'|trans }}</h1>\n    {% include 'layout/_flash.html.twig' %}\n    <div id=\"content\" class=\"section\">\n        <div class=\"container\">\n            {% if mbin_sso_show_first() %}\n                {{ component('login_socials') }}\n            {% endif %}\n            {% if not mbin_sso_only_mode() %}\n            <form method=\"post\">\n                {% if error %}\n                    <div class=\"alert alert__danger\">{{ error.messageKey|trans(error.messageData, 'security')|raw }}</div>\n                {% endif %}\n                <div>\n                    <label for=\"email\">{{ 'login_or_email'|trans }}</label>\n                    <input type=\"text\"\n                           id=\"email\"\n                           name=\"email\"\n                           value=\"{{ last_username }}\"\n                           required>\n                </div>\n                <div class='password-preview' data-controller=\"password-preview\">\n                    <label for=\"password\">{{ 'password'|trans }}</label>\n                    <input type=\"password\"\n                           id=\"password\"\n                           name=\"password\"\n                           required>\n                </div>\n                <div class=\"checkbox\">\n                    <label for=\"remember\">{{ 'remember_me'|trans }}</label>\n                    <input type=\"checkbox\" name=\"_remember_me\" id=\"remember\" checked>\n                </div>\n                <input type=\"hidden\" name=\"_csrf_token\" value=\"{{ csrf_token('authenticate') }}\">\n                <div class=\"button-flex-hf\">\n                    <button class=\"btn btn__primary\" type=\"submit\">{{ 'login'|trans }}</button>\n                </div>\n            </form>\n            {% endif %}\n            {% if not mbin_sso_show_first() %}\n                {{ component('login_socials') }}\n            {% endif %}\n            {% if not mbin_sso_only_mode() %}\n            {{ component('user_form_actions', {showRegister: true, showPasswordReset: true, showResendEmail: true}) }}\n            {% endif %}\n        </div>\n    </div>\n{% endblock %}\n"
  },
  {
    "path": "templates/user/message.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    {{- 'send_message'|trans }} - {{ parent() -}}\n{%- endblock -%}\n\n{% block mainClass %}page-user page-user-send-message{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n{% endblock %}\n\n{% block body %}\n    <header>\n        <h1 hidden>{{ 'message'|trans }}</h1>\n    </header>\n    {% include('user/_options.html.twig') %}\n    <div id=\"content\" class=\"section\">\n        {% include 'messages/_form_create.html.twig' %}\n    </div>\n{% endblock %}\n"
  },
  {
    "path": "templates/user/moderated.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    {{- 'moderated'|trans }} - {{ user.username|username(false) }} - {{ parent() -}}\n{%- endblock -%}\n\n{% block mainClass %}page-user page-user-overview{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n    {% include('user/_admin_panel.html.twig') %}\n{% endblock %}\n\n{% block body %}\n    <div class=\"section section--top\">\n        {{ component('user_box', {\n            user: user,\n            stretchedLink: false\n        }) }}\n    </div>\n    {% include('user/_options.html.twig') %}\n    {% include('user/_visibility_info.html.twig') %}\n    {% include('user/_federated_info.html.twig') %}\n\n    {% if user.visibility is same as 'visible' or is_granted('ROLE_ADMIN') or is_granted('ROLE_MODERATOR') %}\n    <div id=\"content\">\n        {% include('magazine/_list.html.twig') %}\n    </div>\n    {% endif %}\n{% endblock %}\n"
  },
  {
    "path": "templates/user/overview.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    {{- 'overview'|trans }} - {{ user.username|username(false) }} - {{ parent() -}}\n{%- endblock -%}\n\n\n{% block mainClass %}page-user page-user-overview{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n    {% include('user/_admin_panel.html.twig') %}\n{% endblock %}\n\n{% block body %}\n    <div class=\"section section--top\">\n        {{ component('user_box', {\n            user: user,\n            stretchedLink: false\n        }) }}\n    </div>\n    {% include('user/_options.html.twig') %}\n    {% include('layout/_flash.html.twig') %}\n    {% include('user/_visibility_info.html.twig') %}\n    {% include('user/_federated_info.html.twig') %}\n\n    {% if user.visibility is same as 'visible' or is_granted('ROLE_ADMIN') or is_granted('ROLE_MODERATOR') %}\n        <div id=\"content\" class=\"{{ html_classes('overview subjects comments-tree comments', {\n            'show-comment-avatar': app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_COMMENTS_SHOW_USER_AVATAR')) is same as 'true' or not app.request.cookies.has(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_COMMENTS_SHOW_USER_AVATAR')),\n            'show-post-avatar': app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_POSTS_SHOW_USERS_AVATARS')) is same as 'true' or not app.request.cookies.has(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_POSTS_SHOW_USERS_AVATARS'))\n        }) }}\">\n            {% include 'layout/_subject_list.html.twig' with {entryCommentAttributes: {showMagazineName: false}} %}\n        </div>\n    {% endif %}\n{% endblock %}\n"
  },
  {
    "path": "templates/user/posts.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    {{- 'posts'|trans }} - {{ user.username|username(false) }} - {{ parent() -}}\n{%- endblock -%}\n\n{% block mainClass %}page-user page-user-overview{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n    {% include('user/_admin_panel.html.twig') %}\n{% endblock %}\n\n{% block body %}\n    <div class=\"section section--top\">\n        {{ component('user_box', {\n            user: user,\n            stretchedLink: false\n        }) }}\n    </div>\n    {% include('user/_options.html.twig') %}\n    {% include('user/_visibility_info.html.twig') %}\n    {% include('user/_federated_info.html.twig') %}\n\n    {% if user.visibility is same as 'visible' or is_granted('ROLE_ADMIN') or is_granted('ROLE_MODERATOR') %}\n    <div id=\"content\" class=\"{{ html_classes('overview subjects comments-tree comments', {\n        'show-comment-avatar': app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_COMMENTS_SHOW_USER_AVATAR')) is same as 'true' or not app.request.cookies.has(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_COMMENTS_SHOW_USER_AVATAR')),\n        'show-post-avatar': app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_POSTS_SHOW_USERS_AVATARS')) is same as 'true' or not app.request.cookies.has(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_POSTS_SHOW_USERS_AVATARS'))\n    }) }}\">\n        {% include 'post/_list.html.twig' %}\n    </div>\n    {% endif %}\n{% endblock %}\n"
  },
  {
    "path": "templates/user/register.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    {{- 'register'|trans }} - {{ parent() -}}\n{%- endblock -%}\n\n{% block mainClass %}page-register{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n{% endblock %}\n\n{% block body %}\n    <h1>{{ 'register'|trans }}</h1>\n    <div id=\"content\" class=\"section\">\n        <div class=\"container\">\n            {% if mbin_sso_registrations_enabled() and mbin_sso_show_first() %}\n                {{ component('login_socials') }}\n            {% endif %}\n            {% if kbin_registrations_enabled() %}\n                {{ form_start(form) }}\n                {% for flash_error in app.flashes('verify_email_error') %}\n                    <div class=\"alert alert__danger\">{{ flash_error }}</div>\n                {% endfor %}\n                {{ form_row(form.username, {\n                    label: 'username',\n                }) }}\n                {% if do_new_users_need_approval() %}\n                    {{ form_row(form.applicationText, {\n                        label: 'application_text',\n                    }) }}\n                {% endif %}\n                {{ form_row(form.email, {\n                    label: 'email'\n                }) }}\n                {{ form_row(form.plainPassword, {\n                    label: 'password'\n                }) }}\n                {% if kbin_captcha_enabled() %}\n                    {{ form_row(form.captcha, {\n                        label: false\n                    }) }}\n                {% endif %}\n                {{ form_row(form.agreeTerms, {\n                    translation_domain: false,\n                    label: 'agree_terms'|trans({\n                        '%terms_link_start%' : '<a href=\"'~path('page_terms')~'\">', '%terms_link_end%' : '</a>',\n                        '%policy_link_start%' : '<a href=\"'~path('page_privacy_policy')~'\">', '%policy_link_end%' : '</a>',\n                    }),\n                    attr: {\n                        'aria-label': 'agree_terms'|trans\n                    },\n                    row_attr: {\n                        class: 'checkbox'\n                    }\n                }) }}\n                {{ form_row(form.submit, {\n                    label: 'register',\n                    attr: {\n                        class: 'btn btn__primary'\n                    },\n                    row_attr: {\n                        class: 'button-flex-hf'\n                    }\n                }) }}\n                {{ form_end(form) }}\n            {% else %}\n                <h3 class=\"text-muted\">{{ 'registration_disabled'|trans }}</h3>\n            {% endif %}\n            {% if mbin_sso_registrations_enabled() and not mbin_sso_show_first() %}\n                {{ component('login_socials') }}\n            {% endif %}\n            {{ component('user_form_actions', {showLogin: true, showPasswordReset: true, showResendEmail: true}) }}\n        </div>\n    </div>\n{% endblock %}\n"
  },
  {
    "path": "templates/user/replies.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    {{- 'replies'|trans }} - {{ user.username|username(false) }} - {{ parent() -}}\n{%- endblock -%}\n\n{% block mainClass %}page-user page-user-replies{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n    {% include('user/_admin_panel.html.twig') %}\n{% endblock %}\n\n{% block body %}\n    <div class=\"section section--top\">\n        {{ component('user_box', {\n            user: user,\n            stretchedLink: false\n        }) }}\n    </div>\n    {% include('user/_options.html.twig') %}\n    {% include('user/_visibility_info.html.twig') %}\n    {% include('user/_federated_info.html.twig') %}\n\n    {% if user.visibility is same as 'visible' or is_granted('ROLE_ADMIN') or is_granted('ROLE_MODERATOR') %}\n    <div id=\"content\">\n        <div class=\"{{ html_classes('subjects', {\n            'show-comment-avatar': app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_COMMENTS_SHOW_USER_AVATAR')) is same as 'true' or not app.request.cookies.has(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_COMMENTS_SHOW_USER_AVATAR')),\n            'show-post-avatar': app.request.cookies.get(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_POSTS_SHOW_USERS_AVATARS')) is same as 'true' or not app.request.cookies.has(constant('App\\\\Controller\\\\User\\\\ThemeSettingsController::KBIN_POSTS_SHOW_USERS_AVATARS'))\n        }) }}\">\n            {% include 'layout/_subject_list.html.twig' with {'postCommentAttributes': {'showNested': false, 'withPost': true}} %}\n        </div>\n    </div>\n    {% endif %}\n{% endblock %}\n"
  },
  {
    "path": "templates/user/reputation.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    {{- 'reputation_points'|trans }} - {{ user.username|username(false) }} - {{ parent() -}}\n{%- endblock -%}\n\n{% block mainClass %}page-user page-user-overview{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n    {% include('user/_admin_panel.html.twig') %}\n{% endblock %}\n\n{% block body %}\n    {%- set TYPE_ENTRY = constant('App\\\\Repository\\\\ReputationRepository::TYPE_ENTRY') -%}\n    {%- set TYPE_ENTRY_COMMENT = constant('App\\\\Repository\\\\ReputationRepository::TYPE_ENTRY_COMMENT') -%}\n    {%- set TYPE_POST = constant('App\\\\Repository\\\\ReputationRepository::TYPE_POST') -%}\n    {%- set TYPE_POST_COMMENT = constant('App\\\\Repository\\\\ReputationRepository::TYPE_POST_COMMENT') -%}\n\n    <div class=\"section section--top\">\n        {{ component('user_box', {\n            user: user,\n            stretchedLink: false\n        }) }}\n    </div>\n    {% include('user/_options.html.twig') %}\n    {% include('user/_visibility_info.html.twig') %}\n\n    {% if user.visibility is same as 'visible' or is_granted('ROLE_ADMIN') or is_granted('ROLE_MODERATOR') %}\n    <div class=\"pills\">\n        <menu>\n            <li>\n                <a href=\"{{ path('user_reputation', {username: user.username, reputationType: TYPE_ENTRY}) }}\"\n                   class=\"{{ html_classes({'active': is_route_name('user_reputation') and route_has_param('reputationType', TYPE_ENTRY)}) }}\">\n                    {{ 'threads'|trans }}\n                </a>\n            </li>\n            <li>\n                <a href=\"{{ path('user_reputation', {username: user.username, reputationType: TYPE_ENTRY_COMMENT}) }}\"\n                   class=\"{{ html_classes({'active': is_route_name('user_reputation') and route_has_param('reputationType', TYPE_ENTRY_COMMENT)}) }}\">\n                    {{ 'comments'|trans }}\n                </a>\n            </li>\n            <li>\n                <a href=\"{{ path('user_reputation', {username: user.username, reputationType: TYPE_POST}) }}\"\n                   class=\"{{ html_classes({'active': is_route_name('user_reputation') and route_has_param('reputationType', TYPE_POST)}) }}\">\n                    {{ 'posts'|trans }}\n                </a>\n            </li>\n            <li>\n                <a href=\"{{ path('user_reputation', {username: user.username, reputationType: TYPE_POST_COMMENT}) }}\"\n                   class=\"{{ html_classes({'active': is_route_name('user_reputation') and route_has_param('reputationType', TYPE_POST_COMMENT)}) }}\">\n                    {{ 'replies'|trans }}\n                </a>\n            </li>\n        </menu>\n    </div>\n    <div id=\"content\" class=\"reputation\">\n        {% if results|length %}\n            <div class=\"section section--small\">\n                <table>\n                    <tbody>\n                    {% for subject in results %}\n                        <tr>\n                            <td style=\"text-align: center;\"\n                                class=\"{{ html_classes({'success': subject.points >= 0, 'danger': subject.points < 0}) }}\">{{ subject.points }}</td>\n                            <td>{{ subject.day >= date('-2 days') ? subject.day|ago : subject.day|date('Y-m-d') }}</td>\n                        </tr>\n                    {% endfor %}\n                    </tbody>\n                </table>\n            </div>\n        {% endif %}\n        {% if(results.haveToPaginate is defined and results.haveToPaginate) %}\n            {{ pagerfanta(results, null, {'pageParameter':'[p]'}) }}\n        {% endif %}\n        {% if not results|length %}\n            <aside class=\"section section--muted\">\n                <p>{{ 'empty'|trans }}</p>\n            </aside>\n        {% endif %}\n    </div>\n    {% endif %}\n{% endblock %}\n"
  },
  {
    "path": "templates/user/settings/2fa.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    {{- 'two_factor_authentication'|trans }} - {{ app.user.username|username(false) }} - {{ parent() -}}\n{%- endblock -%}\n\n\n{% block mainClass %}page-settings page-settings-2fa{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n{% endblock %}\n\n{% block body %}\n    {% include('user/settings/_options.html.twig') %}\n    <div id=\"content\" class=\"section\">\n        <div class=\"container\">\n            <h1 hidden>{{ 'two_factor_authentication'|trans }}</h1>\n            <h2>{{ '2fa.enable'|trans }}</h2>\n\n            <a class=\"twofa-qrcode\" href=\"{{ two_fa_url }}\" title=\"{{ '2fa.qr_code_link.title'|trans }}\">\n                <img src=\"{{ path('user_settings_2fa_qrcode') }}\" alt=\"{{ '2fa.qr_code_img.alt'|trans }}\"/>\n            </a>\n\n            <p>\n                {{ '2fa.available_apps' | trans({\n                    '%google_authenticator%': '<a href=\"https://support.google.com/accounts/answer/1066447\" rel=\"noopener noreferrer\">Google Authenticator</a>',\n                    '%aegis%': '<a href=\"https://getaegis.app/\" rel=\"noopener noreferrer\">Aegis</a>',\n                    '%raivo%': '<a href=\"https://raivo-otp.com/\" rel=\"noopener noreferrer\">Raivo</a>'\n                }) | raw }}\n            </p>\n\n            <p>\n                {{ '2fa.manual_code_hint'|trans }}:<br />\n                {% include 'user/settings/2fa_secret.html.twig' with {'secret': secret} %}\n            </p>\n\n            <h3>{{ '2fa.backup'|trans }}</h3>\n\n            {% include 'user/settings/_2fa_backup.html.twig' %}\n\n        </div>\n    </div>\n    <div class=\"section\">\n        <div class=\"container\">\n            {{ form_start(form) }}\n            {{ form_row(form.totpCode) }}\n            {{ form_row(form.currentPassword) }}\n            <div class=\"row actions\">\n                <div><span class=\"cancel\"><a href=\"{{ path('user_settings_password') }}\">{{ 'cancel'|trans }}</a></span></div>\n                {{ form_row(form.submit, {label: '2fa.add', attr: {class: 'btn btn__primary'}}) }}\n            </div>\n            {{ form_end(form) }}\n        </div>\n    </div>\n{% endblock %}\n"
  },
  {
    "path": "templates/user/settings/2fa_backup.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    {{- 'two_factor_backup'|trans }} - {{ app.user.username|username(false) }} - {{ parent() -}}\n{%- endblock -%}\n\n\n{% block mainClass %}page-settings page-settings-2fa{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n{% endblock %}\n\n{% block body %}\n    {% include('user/settings/_options.html.twig') %}\n    <div id=\"content\" class=\"section\">\n        <div class=\"container\">\n            <h1 hidden>{{ 'two_factor_backup_codes'|trans }}</h1>\n            <h2>{{ '2fa.backup'|trans }}</h2>\n\n            {% include 'user/settings/_2fa_backup.html.twig' %}\n\n            <div class=\"row actions\">\n                <a href=\"{{ path('user_settings_password') }}\" class=\"btn btn__primary\" role=\"button\">{{ 'done'|trans }}</a>\n            </div>\n        </div>\n    </div>\n{% endblock %}\n"
  },
  {
    "path": "templates/user/settings/2fa_secret.html.twig",
    "content": "<ul class=\"twofa-secret\">\n    <li>{{ secret }}</li>\n</ul>\n"
  },
  {
    "path": "templates/user/settings/_2fa_backup.html.twig",
    "content": "<ul class=\"twofa-backup-codes\">\n    {% for code in codes %}\n        <li>{{ code }}</li>\n    {% endfor %}\n</ul>\n\n<p class=\"mt-4\">{{ '2fa.backup_codes.help' | trans | raw }}</p>\n\n<p><em>{{ '2fa.backup_codes.recommendation' | trans }}</em></p>"
  },
  {
    "path": "templates/user/settings/_options.html.twig",
    "content": "{%- set STATUS_PENDING = constant('App\\\\Entity\\\\Report::STATUS_PENDING') -%}\n<aside class=\"{{ html_classes('options options--top') }}\" id=\"options\">\n    <div></div>\n    <menu class=\"options__main\">\n        <li>\n            <a href=\"{{ path('user_settings_general') }}\"\n               class=\"{{ html_classes({'active': is_route_name('user_settings_general')}) }}\">\n                {{ 'general'|trans }}\n            </a>\n        </li>\n        <li>\n            <a href=\"{{ path('user_settings_profile') }}\"\n               class=\"{{ html_classes({'active': is_route_name('user_settings_profile')}) }}\">\n                {{ 'profile'|trans }}\n            </a>\n        </li>\n        <li>\n            <a href=\"{{ path('user_settings_email') }}\"\n               class=\"{{ html_classes({'active': is_route_name('user_settings_email')}) }}\">\n                {{ 'email'|trans }}\n            </a>\n        </li>\n        {% if not app.user.SsoControlled() %}\n        <li>\n            <a href=\"{{ path('user_settings_password') }}\"\n               class=\"{{ html_classes({'active': is_route_name('user_settings_password') or is_route_name_starts_with('user_settings_2fa')}) }}\">\n                {{ 'password_and_2fa'|trans }}\n            </a>\n        </li>\n        {% endif %}\n        {% if app.user.admin is not same as true %}\n            <li>\n                <a href=\"{{ path('user_settings_account_deletion') }}\"\n                   class=\"{{ html_classes({'active': is_route_name('user_settings_account_deletion')}) }}\">\n                    {{ 'account_deletion_title'|trans }}\n                </a>\n            </li>\n        {% endif %}\n        <li>\n            <a href=\"{{ path('user_settings_filter_lists') }}\"\n               class=\"{{ html_classes({'active': is_route_name('user_settings_filter_lists')}) }}\">\n                {{ 'filter_lists'|trans }}\n            </a>\n        </li>\n        <li>\n            <a href=\"{{ path('user_settings_magazine_blocks') }}\"\n               class=\"{{ html_classes({'active': is_route_name_contains('_blocks')}) }}\">\n                {{ 'blocked'|trans }}\n            </a>\n        </li>\n        <li>\n            <a href=\"{{ path('user_settings_magazine_subscriptions') }}\"\n               class=\"{{ html_classes({'active': is_route_name_contains('_subscriptions')}) }}\">\n                {{ 'subscriptions'|trans }}\n            </a>\n        </li>\n        <li>\n            <a href=\"{{ path('user_settings_reports', {status: STATUS_PENDING}) }}\"\n               class=\"{{ html_classes({'active': is_route_name('user_settings_reports')}) }}\">\n                {{ 'reports'|trans }}\n            </a>\n        </li>\n        <li>\n            <a href=\"{{ path('user_settings_stats') }}\"\n               class=\"{{ html_classes({'active': is_route_name('user_settings_stats')}) }}\">\n                {{ 'stats'|trans }}\n            </a>\n        </li>\n    </menu>\n</aside>\n"
  },
  {
    "path": "templates/user/settings/_stats_pills.html.twig",
    "content": "{%- set TYPE_CONTENT = constant('App\\\\Repository\\\\StatsRepository::TYPE_CONTENT') -%}\n{%- set TYPE_VOTES = constant('App\\\\Repository\\\\StatsRepository::TYPE_VOTES') -%}\n<div class=\"pills\">\n    <menu>\n        <li>\n            <a href=\"{{ path('user_settings_stats', {username: app.user.username, statsType: TYPE_CONTENT}) }}\"\n               class=\"{{ html_classes({'active': route_has_param('statsType', TYPE_CONTENT) or get_route_param('statsType') is same as null}) }}\">\n                {{ TYPE_CONTENT|trans }}\n            </a>\n        </li>\n        <li>\n            <a href=\"{{ path('user_settings_stats', {username: app.user.username, statsType: TYPE_VOTES}) }}\"\n               class=\"{{ html_classes({'active': route_has_param('statsType', TYPE_VOTES)}) }}\">\n                {{ TYPE_VOTES|trans }}\n            </a>\n        </li>\n    </menu>\n</div>"
  },
  {
    "path": "templates/user/settings/account_deletion.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    {{- 'account_deletion_title'|trans }} - {{ app.user.username|username(false) }} - {{ parent() -}}\n{%- endblock -%}\n\n\n{% block mainClass %}page-settings page-settings-account-deletion{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n{% endblock %}\n\n{% block body %}\n    {% include('user/settings/_options.html.twig') %}\n    {% include('user/_visibility_info.html.twig') %}\n    {% include 'layout/_flash.html.twig' %}\n    <div class=\"section section__danger\">\n        <div class=\"container\">\n            <h2>{{ 'account_deletion_title'|trans }}</h2>\n            <p>{{ 'account_deletion_description'|trans }}</p>\n            {{  form_start(form) }}\n            {{ form_row(form.currentPassword, {label: 'current_password'}) }}\n            {{ form_row(form.instantDelete, {label: 'account_deletion_immediate', row_attr: {class: 'checkbox'}}) }}\n            <div class=\"row actions\">\n                {{ form_row(form.submit, { label: 'account_deletion_button', attr: { class: 'btn btn__danger' } }) }}\n            </div>\n            {{ form_end(form) }}\n        </div>\n    </div>\n{% endblock %}\n"
  },
  {
    "path": "templates/user/settings/block_domains.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    {{- 'blocked'|trans }} - {{ app.user.username|username(false) }} - {{ parent() -}}\n{%- endblock -%}\n\n\n{% block mainClass %}page-settings page-settings-block-magazines{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n{% endblock %}\n\n{% block body %}\n    {% include 'user/settings/_options.html.twig' %}\n    {% include('user/_visibility_info.html.twig') %}\n    {% include 'user/settings/block_pills.html.twig' %}\n    {% include 'layout/_domain_activity_list.html.twig' with {list: domains, actor: 'domain'} %}\n{% endblock %}\n"
  },
  {
    "path": "templates/user/settings/block_magazines.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    {{- 'blocked'|trans }} - {{ app.user.username|username(false) }} - {{ parent() -}}\n{%- endblock -%}\n\n\n{% block mainClass %}page-settings page-settings-block-magazines{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n{% endblock %}\n\n{% block body %}\n    {% include('user/settings/_options.html.twig') %}\n    {% include('user/_visibility_info.html.twig') %}\n    {% include 'user/settings/block_pills.html.twig' %}\n    <div id=\"content\">\n        {% include 'layout/_magazine_activity_list.html.twig' with {list: magazines, actor: 'magazine'} %}\n    </div>\n{% endblock %}\n"
  },
  {
    "path": "templates/user/settings/block_pills.html.twig",
    "content": "<div class=\"pills\">\n    <menu>\n        <li>\n            <a href=\"{{ path('user_settings_magazine_blocks') }}\"\n            class=\"{{ html_classes({'active': is_route_name('user_settings_magazine_blocks')}) }}\">\n                {{ 'magazines'|trans }}\n            </a>\n        </li>\n        <li>\n            <a href=\"{{ path('user_settings_user_blocks') }}\"\n               class=\"{{ html_classes({'active': is_route_name('user_settings_user_blocks')}) }}\">\n                {{ 'people'|trans }}\n            </a>\n        </li>\n        <li>\n            <a href=\"{{ path('user_settings_domain_blocks') }}\"\n               class=\"{{ html_classes({'active': is_route_name('user_settings_domain_blocks')}) }}\">\n                {{ 'domains'|trans }}\n            </a>\n        </li>\n    </menu>\n</div>"
  },
  {
    "path": "templates/user/settings/block_users.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    {{- 'blocked'|trans }} - {{ app.user.username|username(false) }} - {{ parent() -}}\n{%- endblock -%}\n\n\n{% block mainClass %}page-settings page-settings-block-magazines{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n{% endblock %}\n\n{% block body %}\n    {% include 'user/settings/_options.html.twig' %}\n    {% include('user/_visibility_info.html.twig') %}\n    {% include 'user/settings/block_pills.html.twig' %}\n    <div id=\"content\">\n        {% include 'layout/_user_activity_list.html.twig' with {list: users, actor: 'blocked'} %}\n    </div>\n{% endblock %}\n"
  },
  {
    "path": "templates/user/settings/email.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    {{- 'email'|trans }} - {{ app.user.username|username(false) }} - {{ parent() -}}\n{%- endblock -%}\n\n\n{% block mainClass %}page-settings page-settings-email{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n{% endblock %}\n\n{% block body %}\n    {% include('user/settings/_options.html.twig') %}\n    {% include('user/_visibility_info.html.twig') %}\n    {% include 'layout/_flash.html.twig' %}\n    <div id=\"content\" class=\"section\">\n        <div class=\"container\">\n            <h1 hidden>{{ 'profile'|trans }}</h1>\n            {% if not app.user.SsoControlled() %}\n                <h2>{{ 'change_email'|trans }}</h2>\n                {{ form_start(form) }}\n                {{ form_row(form.email, {label: 'old_email', value: app.user.email, attr: {disabled: 'disabled'}}) }}\n                {{ form_row(form.newEmail, {label: 'new_email'}) }}\n                {{ form_row(form.currentPassword, {label: 'password'}) }}\n                <div class=\"row actions\">\n                    {{ form_row(form.submit, {label: 'save', attr: {class: 'btn btn__primary'}}) }}\n                </div>\n                {{ form_end(form) }}\n            {% else %}\n                <h2>{{ 'email'|trans }}</h2>\n                <div>{{ 'old_email'|trans }}</div>\n                <div class=\"input-box disabled\">{{ app.user.email }}</div>\n            {% endif %}\n        </div>\n    </div>\n{% endblock %}\n"
  },
  {
    "path": "templates/user/settings/filter_lists.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    {{- 'filter_lists'|trans }} - {{ app.user.username|username(false) }} - {{ parent() -}}\n{%- endblock -%}\n\n\n{% block mainClass %}page-settings page-settings-filter-lists{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n{% endblock %}\n\n{% block body %}\n    {% include('user/settings/_options.html.twig') %}\n    {% include('user/_visibility_info.html.twig') %}\n    {% include 'layout/_flash.html.twig' %}\n    <div id=\"content\" class=\"section\">\n        <div class=\"container\">\n            <h1 hidden>{{ 'filter_lists'|trans }}</h1>\n            {% for list in app.user.filterLists %}\n                {{ component('filter_list', {list: list}) }}\n            {% endfor %}\n            <div style=\"margin-top: 2rem;\" >\n\n            <a class=\"btn btn__primary\" href=\"{{ path('user_settings_filter_lists_create') }}\">{{ 'filter_list_create'|trans }}</a>\n            </div>\n        </div>\n    </div>\n{% endblock %}\n"
  },
  {
    "path": "templates/user/settings/filter_lists_create.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    {{- 'filter_lists'|trans }} - {{ app.user.username|username(false) }} - {{ parent() -}}\n{%- endblock -%}\n\n\n{% block mainClass %}page-settings page-settings-filter-lists{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n{% endblock %}\n\n{% block body %}\n    {% include('user/settings/_options.html.twig') %}\n    {% include('user/_visibility_info.html.twig') %}\n    {% include 'layout/_flash.html.twig' %}\n    <div id=\"content\" class=\"section\">\n        <div class=\"container\">\n            <h1 hidden>{{ 'filter_lists'|trans }}</h1>\n            {{ include('user/settings/filter_lists_form.html.twig', {btn_label: 'add'|trans }) }}\n        </div>\n    </div>\n{% endblock %}\n"
  },
  {
    "path": "templates/user/settings/filter_lists_edit.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    {{- 'filter_lists'|trans }} - {{ app.user.username|username(false) }} - {{ parent() -}}\n{%- endblock -%}\n\n\n{% block mainClass %}page-settings page-settings-filter-lists{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n{% endblock %}\n\n{% block body %}\n    {% include('user/settings/_options.html.twig') %}\n    {% include('user/_visibility_info.html.twig') %}\n    {% include 'layout/_flash.html.twig' %}\n    <div id=\"content\" class=\"section\">\n        <div class=\"container\">\n            <h1 hidden>{{ 'filter_lists'|trans }}</h1>\n            {{ include('user/settings/filter_lists_form.html.twig', {btn_label: 'save'|trans }) }}\n        </div>\n    </div>\n{% endblock %}\n"
  },
  {
    "path": "templates/user/settings/filter_lists_form.html.twig",
    "content": "{{ form_start(form) }}\n{{ form_row(form.name, {label: 'name'}) }}\n<div class=\"form-control\">\n    {{ form_label(form.expirationDate, 'expiration_date') }}\n    {{ form_widget(form.expirationDate, {'attr': {'class': 'form-control', 'style': 'padding: 1rem .5rem;', 'title': 'expiration_date'|trans}}) }}\n</div>\n\n<div>{{ 'filter_lists_where_to_filter'|trans }}:</div>\n\n<div class=\"checkbox\">\n    {{ form_label(form.feeds) }}\n    {{ form_widget(form.feeds) }}\n</div>\n<div class=\"checkbox help-text\">\n    {{ form_help(form.feeds) }}\n</div>\n\n<div class=\"checkbox\">\n    {{ form_label(form.comments) }}\n    {{ form_widget(form.comments) }}\n</div>\n<div class=\"checkbox help-text\">\n    {{ form_help(form.comments) }}\n</div>\n\n<div class=\"checkbox\">\n    {{ form_label(form.profile) }}\n    {{ form_widget(form.profile) }}\n</div>\n<div class=\"checkbox help-text\">\n    {{ form_help(form.profile) }}\n</div>\n\n{{ form_label(form.words) }} <span class=\"help-text\">{{ 'filter_lists_word_exact_match_help'|trans }}</span>\n<div {{ stimulus_controller('form_collection') }}\n    data-form-collection-index-value=\"{{ form.words|length > 0 ? form.words|last.vars.name + 1 : 0 }}\"\n    data-form-collection-prototype-value=\"{{ form_widget(form.words.vars.prototype)|e('html_attr') }}\"\n>\n    {{ form_widget(form.words) }}\n    <div class=\"words-container\" {{ stimulus_target('form-collection', 'collectionContainer') }}>\n    </div>\n    <div style=\"text-align: center;\">\n        <button class=\"btn btn__primary\" type=\"button\" {{ stimulus_action('form-collection', 'addCollectionElement') }}>\n            {{ '+' }}\n        </button>\n    </div>\n</div>\n<div class=\"text-right\">\n    {{ form_row(form.submit, {label: btn_label, attr: {class: 'btn btn__primary'}}) }}\n</div>\n{{ form_end(form) }}\n"
  },
  {
    "path": "templates/user/settings/general.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    {{- 'general'|trans }} - {{ app.user.username|username(false) }} - {{ parent() -}}\n{%- endblock -%}\n\n\n{% block mainClass %}page-settings page-settings-general{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n{% endblock %}\n\n{% block body %}\n    {% include('user/settings/_options.html.twig') %}\n    {% include('user/_visibility_info.html.twig') %}\n    {% include 'layout/_flash.html.twig' %}\n    <div id=\"content\" class=\"section\">\n        <div class=\"container\">\n            <h1 hidden>{{ 'general'|trans }}</h1>\n            {{ form_start(form) }}\n            <h2>{{ 'appearance'|trans }}</h2>\n            {{ form_row(form.homepage, {label: 'homepage'}) }}\n            {{ form_row(form.frontDefaultContent, {label: 'front_default_content'}) }}\n            {{ form_row(form.frontDefaultSort, {label: 'front_default_sort'}) }}\n            {{ form_row(form.commentDefaultSort, {label: 'comment_default_sort'}) }}\n            {{ form_row(form.preferredLanguages, {label: 'preferred_languages'}) }}\n            {{ form_row(form.featuredMagazines, {label: 'featured_magazines'}) }}\n            {{ form_row(form.customCss, {label: 'custom_css', row_attr: {class: 'textarea'}}) }}\n            {{ form_row(form.ignoreMagazinesCustomCss, {label: 'ignore_magazines_custom_css', row_attr: {class: 'checkbox'}}) }}\n            {{ form_row(form.hideAdult, {label: 'hide_adult', row_attr: {class: 'checkbox'}}) }}\n            <div class=\"checkbox\">\n                {{ form_label(form.showFollowingBoosts, 'show_boost_following_label') }}\n                {{ form_widget(form.showFollowingBoosts) }}\n            </div>\n            <div class=\"checkbox help-text\">\n                {{ form_help(form.showFollowingBoosts) }}\n            </div>\n            <h2>{{ 'writing'|trans }}</h2>\n            {{ form_row(form.addMentionsEntries, {label: 'add_mentions_entries', row_attr: {class: 'checkbox'}}) }}\n            {{ form_row(form.addMentionsPosts, {label: 'add_mentions_posts', row_attr: {class: 'checkbox'}}) }}\n            <h2>{{ 'privacy'|trans }}</h2>\n            {{ form_row(form.directMessageSetting, {label: 'direct_message_setting_label'}) }}\n            {{ form_row(form.showProfileSubscriptions, {label: 'show_profile_subscriptions', row_attr: {class: 'checkbox'}}) }}\n            {{ form_row(form.showProfileFollowings, {label: 'show_profile_followings', row_attr: {class: 'checkbox'}}) }}\n            <div class=\"checkbox\">\n                {{ form_label(form.discoverable, 'discoverable') }}\n                {{ form_widget(form.discoverable) }}\n            </div>\n            <div class=\"checkbox help-text\">\n                {{ form_help(form.discoverable) }}\n            </div>\n            <div class=\"checkbox\">\n                {{ form_label(form.indexable, 'indexable_by_search_engines') }}\n                {{ form_widget(form.indexable) }}\n            </div>\n            <div class=\"checkbox help-text\">\n                {{ form_help(form.indexable) }}\n            </div>\n            <h2>{{ 'notifications'|trans }}</h2>\n            {{ form_row(form.notifyOnNewEntryReply, {label: 'notify_on_new_entry_reply', row_attr: {class: 'checkbox'}}) }}\n            {{ form_row(form.notifyOnNewEntryCommentReply, {label: 'notify_on_new_entry_comment_reply', row_attr: {class: 'checkbox'}}) }}\n            {{ form_row(form.notifyOnNewPostReply, {label: 'notify_on_new_post_reply', row_attr: {class: 'checkbox'}}) }}\n            {{ form_row(form.notifyOnNewPostCommentReply, {label: 'notify_on_new_post_comment_reply', row_attr: {class: 'checkbox'}}) }}\n            {{ form_row(form.notifyOnNewEntry, {label: 'notify_on_new_entry', row_attr: {class: 'checkbox'}}) }}\n            {{ form_row(form.notifyOnNewPost, {label: 'notify_on_new_posts', row_attr: {class: 'checkbox'}}) }}\n            {% if app.user.admin or app.user.moderator %}\n                {{ form_row(form.notifyOnUserSignup, {label: 'notify_on_user_signup', row_attr: {class: 'checkbox'}}) }}\n            {% endif %}\n            <div class=\"row actions\">\n                {{ form_row(form.submit, {label: 'save', attr: {class: 'btn btn__primary'}}) }}\n            </div>\n            {{ form_end(form) }}\n        </div>\n    </div>\n\n{% endblock %}\n"
  },
  {
    "path": "templates/user/settings/password.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    {{- 'password'|trans }} - {{ app.user.username|username(false) }} - {{ parent() -}}\n{%- endblock -%}\n\n\n{% block mainClass %}page-settings page-settings-password{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n{% endblock %}\n\n{% block body %}\n    {% include('user/settings/_options.html.twig') %}\n    {% include('user/_visibility_info.html.twig') %}\n    {% include 'layout/_flash.html.twig' %}\n    <div id=\"content\" class=\"section\">\n        <div class=\"container\">\n            <h1 hidden>{{ 'password_and_2fa'|trans }}</h1>\n            <h2>{{ 'change_password'|trans }}</h2>\n            {{ form_start(form) }}\n            {{ form_row(form.currentPassword) }}\n            <div class=\"{{ html_classes({ \"hidden\": not has2fa }) }}\">\n                {{ form_row(form.totpCode, {required: has2fa}) }}\n            </div>\n            {{ form_row(form.plainPassword) }}\n            <div class=\"row actions\">\n                {{ form_row(form.submit, {label: 'save', attr: {class: 'btn btn__primary'}}) }}\n            </div>\n            {{ form_end(form) }}\n        </div>\n    </div>\n\n    <div class=\"section\">\n        <div class=\"container\">\n            <h2>{{ 'two_factor_authentication'|trans }}</h2>\n                {% if has2fa %}\n\n                    {{ form_start(disable2faForm, {action: path('user_settings_2fa_disable'), attr: {'data-action': \"confirmation#ask\", 'data-confirmation-message-param': 'are_you_sure'|trans}}) }}\n                    {{ form_row(disable2faForm.currentPassword) }}\n                    {{ form_row(disable2faForm.totpCode, {required: has2fa}) }}\n                    <div class=\"row actions\">\n                        {{ form_row(disable2faForm.submit, {label: '2fa.disable', attr: {class: 'btn btn__primary'}}) }}\n                    </div>\n                    {{ form_end(disable2faForm) }}\n\n                    <p id=\"backup-create-help\" class=\"mt-4\">{{ '2fa.backup-create.help' | trans }}</p>\n\n                    {{ form_start(regenerateBackupCodes, {action: path('user_settings_2fa_backup'), attr: {'data-action': \"confirmation#ask\", 'data-confirmation-message-param': 'are_you_sure'|trans}}) }}\n                    {{ form_row(regenerateBackupCodes.currentPassword) }}\n                    {{ form_row(regenerateBackupCodes.totpCode, {required: has2fa}) }}\n                    <div class=\"row actions\">\n                        {{ form_row(regenerateBackupCodes.submit, {label: '2fa.backup-create.label', attr: {class: 'btn btn__primary'}}) }}\n                    </div>\n                    {{ form_end(regenerateBackupCodes) }}\n                {% else %}\n                    <div class=\"row params__left\">\n                        <a href=\"{{ path('user_settings_2fa') }}\" class=\"btn btn__primary\" role=\"button\">{{ '2fa.enable'|trans }}</a>\n                    </div>\n                {% endif %}\n        </div>\n    </div>\n{% endblock %}\n"
  },
  {
    "path": "templates/user/settings/profile.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    {{- 'profile'|trans }} - {{ app.user.username|username(false) }} - {{ parent() -}}\n{%- endblock -%}\n\n\n{% block mainClass %}page-settings page-settings-profile{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n{% endblock %}\n\n{% block body %}\n    {% include('user/settings/_options.html.twig') %}\n    {% include('user/_visibility_info.html.twig') %}\n    {% include 'layout/_flash.html.twig' %}\n    <div class=\"section\">\n        {{ component('user_box', {\n            user: app.user,\n            stretchedLink: false\n        }) }}\n    </div>\n    <div id=\"content\" class=\"section\">\n        <div class=\"container\">\n            <h1 hidden>{{ 'profile'|trans }}</h1>\n            {{ form_start(form) }}\n            {{ form_row(form.username, {label: 'username', attr: {\n                'data-controller': 'input-length autogrow',\n                'data-entry-link-create-target': 'user_about',\n                'data-action' : 'input-length#updateDisplay',\n                'data-input-length-max-value' : constant('App\\\\DTO\\\\UserDto::MAX_USERNAME_LENGTH')\n            }}) }}\n\n            {{ form_row(form.title, {label: 'displayname', attr: {\n                'data-controller': 'input-length autogrow',\n                'data-entry-link-create-target': 'user_about',\n                'data-action' : 'input-length#updateDisplay',\n                'data-input-length-max-value' : constant('App\\\\DTO\\\\UserDto::MAX_USERNAME_LENGTH')\n            }}) }}\n\n            {{ component('editor_toolbar', {id: 'user_basic_about'}) }}\n            {{ form_row(form.about, {label: false, attr: {\n                placeholder: 'about',\n                'data-controller': 'input-length rich-textarea autogrow',\n                'data-entry-link-create-target': 'user_about',\n                'data-action' : 'input-length#updateDisplay',\n                'data-input-length-max-value' : constant('App\\\\DTO\\\\UserDto::MAX_ABOUT_LENGTH')\n                }}) }}\n\n            {{ form_row(form.avatar, {label: 'avatar'}) }}\n            {% if app.user.avatar is not same as null %}\n                <div class=\"actions\">\n                    <ul style=\"width: 100%\">\n                        <img width=\"40\"\n                             height=\"40\"\n                             src=\"{{ asset(app.user.avatar.filePath)|imagine_filter('entry_thumb') }}\"\n                             alt=\"{{ app.user.avatar.altText }}\" />\n                        <button formaction=\"{{ path('user_settings_avatar_delete') }}\"\n                                class=\"btn-link\"\n                                aria-label=\"{{ 'remove_user_avatar'|trans }}\"\n                                title=\"{{ 'remove_user_avatar'|trans }}\"\n                                data-action=\"confirmation#ask\" data-confirmation-message-param=\"{{ 'are_you_sure'|trans }}\">\n                            <i class=\"fa-solid fa-xmark\" aria-hidden=\"true\"></i>\n                        </button>\n                    </ul>\n                </div>\n            {% endif %}\n\n            {{ form_row(form.cover, {label: 'cover'}) }}\n            {% if app.user.cover is not same as null %}\n                <div class=\"actions\">\n                    <ul style=\"width: 100%\">\n                        <img width=\"40\"\n                             height=\"40\"\n                             src=\"{{ asset(app.user.cover.filePath)|imagine_filter('entry_thumb') }}\"\n                             alt=\"{{ app.user.cover.altText }}\" />\n                        <button formaction=\"{{ path('user_settings_cover_delete') }}\"\n                                class=\"btn-link\"\n                                aria-label=\"{{ 'remove_user_cover'|trans }}\"\n                                title=\"{{ 'remove_user_cover'|trans }}\"\n                                data-action=\"confirmation#ask\" data-confirmation-message-param=\"{{ 'are_you_sure'|trans }}\">\n                            <i class=\"fa-solid fa-xmark\" aria-hidden=\"true\"></i>\n                        </button>\n                    </ul>\n                </div>\n            {% endif %}\n\n            <div class=\"row actions\">\n                {{ form_row(form.submit, {label: 'save', attr: {class: 'btn btn__primary'}}) }}\n            </div>\n            {{ form_end(form) }}\n        </div>\n    </div>\n{% endblock %}\n"
  },
  {
    "path": "templates/user/settings/reports.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    {{- 'reports'|trans }} - {{ app.user.username|username(false) }} - {{ parent() -}}\n{%- endblock -%}\n\n\n{% block mainClass %}page-settings page-settings-reports{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n{% endblock %}\n\n{% block body %}\n    {% include 'user/settings/_options.html.twig' %}\n    {% include('user/_visibility_info.html.twig') %}\n    <h1 hidden>{{ 'reports'|trans }}</h1>\n    {{ component('report_list', {reports: reports, routeName: 'user_settings_reports'}) }}\n{% endblock %}\n"
  },
  {
    "path": "templates/user/settings/stats.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    {{- 'reports'|trans }} - {{ app.user.username|username(false) }} - {{ parent() -}}\n{%- endblock -%}\n\n\n{% block mainClass %}page-settings page-settings-reports{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n{% endblock %}\n\n{% block body %}\n    {% include 'user/settings/_options.html.twig' %}\n    {% include('user/_visibility_info.html.twig') %}\n    {% include 'user/settings/_stats_pills.html.twig' %}\n    <div class=\"section\">\n        {% include 'stats/_filters.html.twig' %}\n        {{ render_chart(chart) }}\n    </div>\n{% endblock %}\n"
  },
  {
    "path": "templates/user/settings/sub_domains.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    {{- 'subscriptions'|trans }} - {{ app.user.username|username(false) }} - {{ parent() -}}\n{%- endblock -%}\n\n\n{% block mainClass %}page-settings page-settings-sub-magazines{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n{% endblock %}\n\n{% block body %}\n    {% include 'user/settings/_options.html.twig' %}\n    {% include('user/_visibility_info.html.twig') %}\n    {% include 'user/settings/sub_pills.html.twig' %}\n    {% include 'layout/_domain_activity_list.html.twig' with {list: domains, actor: 'domain'} %}\n{% endblock %}\n"
  },
  {
    "path": "templates/user/settings/sub_magazines.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    {{- 'subscriptions'|trans }} - {{ app.user.username|username(false) }} - {{ parent() -}}\n{%- endblock -%}\n\n\n{% block mainClass %}page-settings page-settings-sub-magazines{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n{% endblock %}\n\n{% block body %}\n    {% include('user/settings/_options.html.twig') %}\n    {% include('user/_visibility_info.html.twig') %}\n    {% include 'user/settings/sub_pills.html.twig' %}\n    <div id=\"content\">\n        {% include 'layout/_magazine_activity_list.html.twig' with {list: magazines, actor: 'magazine'} %}\n    </div>\n{% endblock %}\n"
  },
  {
    "path": "templates/user/settings/sub_pills.html.twig",
    "content": "<div class=\"pills\">\n    <menu>\n        <li>\n            <a href=\"{{ path('user_settings_magazine_subscriptions') }}\"\n            class=\"{{ html_classes({'active': is_route_name('user_settings_magazine_subscriptions')}) }}\">\n                {{ 'magazines'|trans }}\n            </a>\n        </li>\n        <li>\n            <a href=\"{{ path('user_settings_user_subscriptions') }}\"\n               class=\"{{ html_classes({'active': is_route_name('user_settings_user_subscriptions')}) }}\">\n                {{ 'people'|trans }}\n            </a>\n        </li>\n        <li>\n            <a href=\"{{ path('user_settings_domain_subscriptions') }}\"\n               class=\"{{ html_classes({'active': is_route_name('user_settings_domain_subscriptions')}) }}\">\n                {{ 'domains'|trans }}\n            </a>\n        </li>\n    </menu>\n</div>"
  },
  {
    "path": "templates/user/settings/sub_users.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    {{- 'subscriptions'|trans }} - {{ app.user.username|username(false) }} - {{ parent() -}}\n{%- endblock -%}\n\n\n{% block mainClass %}page-settings page-settings-sub-magazines{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n{% endblock %}\n\n{% block body %}\n    {% include 'user/settings/_options.html.twig' %}\n    {% include('user/_visibility_info.html.twig') %}\n    {% include 'user/settings/sub_pills.html.twig' %}\n    <div id=\"content\">\n        {% include 'layout/_user_activity_list.html.twig' with {list: users, actor: 'following'} %}\n    </div>\n{% endblock %}\n"
  },
  {
    "path": "templates/user/subscriptions.html.twig",
    "content": "{% extends 'base.html.twig' %}\n\n{%- block title -%}\n    {{- 'subscriptions'|trans }} - {{ user.username|username(false) }} - {{ parent() -}}\n{%- endblock -%}\n\n{% block mainClass %}page-user page-user-overview{% endblock %}\n\n{% block header_nav %}\n{% endblock %}\n\n{% block sidebar_top %}\n{% endblock %}\n\n{% block body %}\n    <div class=\"section section--top\">\n        {{ component('user_box', {\n            user: user,\n            stretchedLink: false\n        }) }}\n    </div>\n    {% include('user/_options.html.twig') %}\n    {% include('user/_visibility_info.html.twig') %}\n    {% include('user/_federated_info.html.twig') %}\n    \n    {% if user.visibility is same as 'visible' or is_granted('ROLE_ADMIN') or is_granted('ROLE_MODERATOR') %}\n        <div id=\"content\">\n            {% include 'layout/_magazine_activity_list.html.twig' with {list: magazines} %}\n        </div>\n    {% endif %}\n{% endblock %}\n"
  },
  {
    "path": "tests/ActivityPubJsonDriver.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests;\n\nuse PHPUnit\\Framework\\Assert;\nuse Spatie\\Snapshots\\Drivers\\JsonDriver;\nuse Spatie\\Snapshots\\Exceptions\\CantBeSerialized;\n\nclass ActivityPubJsonDriver extends JsonDriver\n{\n    public function serialize($data): string\n    {\n        if (\\is_string($data)) {\n            $data = json_decode($data);\n        }\n\n        if (\\is_resource($data)) {\n            throw new CantBeSerialized('Resources can not be serialized to json');\n        }\n\n        $data = $this->scrub($data);\n\n        return json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE).\"\\n\";\n    }\n\n    public function match($expected, $actual): void\n    {\n        if (\\is_string($actual)) {\n            $actual = json_decode($actual, false, 512, JSON_THROW_ON_ERROR);\n        }\n\n        $actual = $this->scrub($actual);\n\n        $expected = json_decode($expected, false, 512, JSON_THROW_ON_ERROR);\n        Assert::assertJsonStringEqualsJsonString(json_encode($expected), json_encode($actual));\n    }\n\n    protected function scrub(mixed $data): mixed\n    {\n        if (\\is_array($data)) {\n            return $this->scrubArray($data);\n        } elseif (\\is_object($data)) {\n            return $this->scrubObject($data);\n        }\n\n        return $this;\n    }\n\n    protected function scrubArray(array $data): array\n    {\n        if (isset($data['id'])) {\n            $data['id'] = 'SCRUBBED_ID';\n        }\n\n        if (isset($data['type']) && 'Note' === $data['type'] && isset($data['url'])) {\n            $data['url'] = 'SCRUBBED_ID';\n        }\n\n        if (isset($data['inReplyTo'])) {\n            $data['inReplyTo'] = 'SCRUBBED_ID';\n        }\n\n        if (isset($data['published'])) {\n            $data['published'] = 'SCRUBBED_DATE';\n        }\n\n        if (isset($data['updated'])) {\n            $data['updated'] = 'SCRUBBED_DATE';\n        }\n\n        if (isset($data['publicKey'])) {\n            $data['publicKey'] = 'SCRUBBED_KEY';\n        }\n\n        if (isset($data['object']) && \\is_string($data['object'])) {\n            $data['object'] = 'SCRUBBED_ID';\n        }\n\n        if (isset($data['object']) && (\\is_array($data['object']) || \\is_object($data['object']))) {\n            $data['object'] = $this->scrub($data['object']);\n        }\n\n        if (isset($data['orderedItems']) && \\is_array($data['orderedItems'])) {\n            $items = [];\n            foreach ($data['orderedItems'] as $item) {\n                $items[] = $this->scrub($item);\n            }\n            $data['orderedItems'] = $items;\n        }\n\n        return $data;\n    }\n\n    protected function scrubObject(object $data): object\n    {\n        if (isset($data->id)) {\n            $data->id = 'SCRUBBED_ID';\n        }\n\n        if (isset($data->type) && 'Note' === $data->type && isset($data->url)) {\n            $data->url = 'SCRUBBED_ID';\n        }\n\n        if (isset($data->inReplyTo)) {\n            $data->inReplyTo = 'SCRUBBED_ID';\n        }\n\n        if (isset($data->published)) {\n            $data->published = 'SCRUBBED_DATE';\n        }\n\n        if (isset($data->updated)) {\n            $data->updated = 'SCRUBBED_DATE';\n        }\n\n        if (isset($data->publicKey)) {\n            $data->publicKey = 'SCRUBBED_KEY';\n        }\n\n        if (isset($data->object) && \\is_string($data->object)) {\n            $data->object = 'SCRUBBED_ID';\n        }\n\n        if (isset($data->object) && (\\is_array($data->object) || \\is_object($data->object))) {\n            $data->object = $this->scrub($data->object);\n        }\n\n        return $data;\n    }\n}\n"
  },
  {
    "path": "tests/ActivityPubTestCase.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests;\n\nuse App\\Entity\\Magazine;\nuse App\\Entity\\User;\nuse App\\Factory\\ActivityPub\\AddRemoveFactory;\nuse App\\Factory\\ActivityPub\\BlockFactory;\nuse App\\Factory\\ActivityPub\\EntryCommentNoteFactory;\nuse App\\Factory\\ActivityPub\\FlagFactory;\nuse App\\Factory\\ActivityPub\\GroupFactory;\nuse App\\Factory\\ActivityPub\\InstanceFactory;\nuse App\\Factory\\ActivityPub\\LockFactory;\nuse App\\Factory\\ActivityPub\\PersonFactory;\nuse App\\Factory\\ActivityPub\\PostCommentNoteFactory;\nuse App\\Factory\\ActivityPub\\PostNoteFactory;\nuse App\\Repository\\UserFollowRequestRepository;\nuse App\\Service\\ActivityPub\\MarkdownConverter;\nuse App\\Service\\ActivityPub\\Wrapper\\AnnounceWrapper;\nuse App\\Service\\ActivityPub\\Wrapper\\CreateWrapper;\nuse App\\Service\\ActivityPub\\Wrapper\\DeleteWrapper;\nuse App\\Service\\ActivityPub\\Wrapper\\FollowResponseWrapper;\nuse App\\Service\\ActivityPub\\Wrapper\\FollowWrapper;\nuse App\\Service\\ActivityPub\\Wrapper\\LikeWrapper;\nuse App\\Service\\ActivityPub\\Wrapper\\UndoWrapper;\nuse App\\Service\\ActivityPub\\Wrapper\\UpdateWrapper;\nuse Spatie\\Snapshots\\MatchesSnapshots;\nuse Symfony\\Component\\Uid\\Uuid;\n\nclass ActivityPubTestCase extends WebTestCase\n{\n    use MatchesSnapshots;\n\n    protected User $owner;\n    protected Magazine $magazine;\n    protected User $user;\n\n    protected PersonFactory $personFactory;\n    protected GroupFactory $groupFactory;\n    protected InstanceFactory $instanceFactory;\n    protected EntryCommentNoteFactory $entryCommentNoteFactory;\n    protected PostNoteFactory $postNoteFactory;\n    protected PostCommentNoteFactory $postCommentNoteFactory;\n    protected AddRemoveFactory $addRemoveFactory;\n    protected CreateWrapper $createWrapper;\n    protected UpdateWrapper $updateWrapper;\n    protected DeleteWrapper $deleteWrapper;\n    protected LikeWrapper $likeWrapper;\n    protected FollowWrapper $followWrapper;\n    protected AnnounceWrapper $announceWrapper;\n    protected UndoWrapper $undoWrapper;\n    protected FollowResponseWrapper $followResponseWrapper;\n    protected FlagFactory $flagFactory;\n    protected BlockFactory $blockFactory;\n    protected LockFactory $lockFactory;\n    protected UserFollowRequestRepository $userFollowRequestRepository;\n\n    protected MarkdownConverter $apMarkdownConverter;\n\n    public function setUp(): void\n    {\n        parent::setUp();\n        $this->owner = $this->getUserByUsername('owner', addImage: false);\n        $this->magazine = $this->getMagazineByName('test', $this->owner);\n        $this->user = $this->getUserByUsername('user', addImage: false);\n\n        $this->personFactory = $this->getService(PersonFactory::class);\n        $this->groupFactory = $this->getService(GroupFactory::class);\n        $this->instanceFactory = $this->getService(InstanceFactory::class);\n        $this->entryCommentNoteFactory = $this->getService(EntryCommentNoteFactory::class);\n        $this->postNoteFactory = $this->getService(PostNoteFactory::class);\n        $this->postCommentNoteFactory = $this->getService(PostCommentNoteFactory::class);\n        $this->addRemoveFactory = $this->getService(AddRemoveFactory::class);\n        $this->createWrapper = $this->getService(CreateWrapper::class);\n        $this->updateWrapper = $this->getService(UpdateWrapper::class);\n        $this->deleteWrapper = $this->getService(DeleteWrapper::class);\n        $this->likeWrapper = $this->getService(LikeWrapper::class);\n        $this->followWrapper = $this->getService(FollowWrapper::class);\n        $this->announceWrapper = $this->getService(AnnounceWrapper::class);\n        $this->undoWrapper = $this->getService(UndoWrapper::class);\n        $this->followResponseWrapper = $this->getService(FollowResponseWrapper::class);\n        $this->flagFactory = $this->getService(FlagFactory::class);\n        $this->blockFactory = $this->getService(BlockFactory::class);\n        $this->lockFactory = $this->getService(LockFactory::class);\n        $this->userFollowRequestRepository = $this->getService(UserFollowRequestRepository::class);\n\n        $this->apMarkdownConverter = $this->getService(MarkdownConverter::class);\n    }\n\n    /**\n     * @template T\n     *\n     * @param class-string<T> $className\n     *\n     * @return T\n     */\n    private function getService(string $className)\n    {\n        return $this->getContainer()->get($className);\n    }\n\n    protected function getDefaultUuid(): Uuid\n    {\n        return new Uuid('00000000-0000-0000-0000-000000000000');\n    }\n\n    protected function getSnapshotDirectory(): string\n    {\n        return \\dirname((new \\ReflectionClass($this))->getFileName()).\n            DIRECTORY_SEPARATOR.\n            'JsonSnapshots';\n    }\n}\n"
  },
  {
    "path": "tests/FactoryTrait.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests;\n\nuse App\\DTO\\EntryCommentDto;\nuse App\\DTO\\EntryDto;\nuse App\\DTO\\ImageDto;\nuse App\\DTO\\MagazineBanDto;\nuse App\\DTO\\MagazineDto;\nuse App\\DTO\\MessageDto;\nuse App\\DTO\\OAuth2ClientDto;\nuse App\\DTO\\PostCommentDto;\nuse App\\DTO\\PostDto;\nuse App\\DTO\\UserDto;\nuse App\\Entity\\Client;\nuse App\\Entity\\Contracts\\VotableInterface;\nuse App\\Entity\\Entry;\nuse App\\Entity\\EntryComment;\nuse App\\Entity\\Image;\nuse App\\Entity\\Magazine;\nuse App\\Entity\\Message;\nuse App\\Entity\\MessageThread;\nuse App\\Entity\\Notification;\nuse App\\Entity\\Post;\nuse App\\Entity\\PostComment;\nuse App\\Entity\\Site;\nuse App\\Entity\\User;\nuse App\\Service\\UserManager;\nuse League\\Bundle\\OAuth2ServerBundle\\Manager\\ClientManagerInterface;\nuse League\\Bundle\\OAuth2ServerBundle\\ValueObject\\Grant;\nuse League\\Bundle\\OAuth2ServerBundle\\ValueObject\\RedirectUri;\nuse League\\Bundle\\OAuth2ServerBundle\\ValueObject\\Scope;\nuse Symfony\\Bundle\\FrameworkBundle\\KernelBrowser;\nuse Symfony\\Component\\HttpFoundation\\File\\UploadedFile;\nuse Symfony\\Component\\Routing\\Generator\\UrlGeneratorInterface;\n\nuse function PHPUnit\\Framework\\assertNotNull;\n\ntrait FactoryTrait\n{\n    public function createVote(int $choice, VotableInterface $subject, User $user): void\n    {\n        if (VotableInterface::VOTE_UP === $choice) {\n            $favManager = $this->favouriteManager;\n            $favManager->toggle($user, $subject);\n        } else {\n            $voteManager = $this->voteManager;\n            $voteManager->vote($choice, $subject, $user);\n        }\n    }\n\n    protected function loadExampleMagazines(): void\n    {\n        $this->loadExampleUsers();\n\n        foreach ($this->provideMagazines() as $data) {\n            $this->createMagazine($data['name'], $data['title'], $data['user'], $data['isAdult'], $data['description']);\n        }\n    }\n\n    protected function loadExampleUsers(): void\n    {\n        foreach ($this->provideUsers() as $data) {\n            $this->createUser($data['username'], $data['email'], $data['password']);\n        }\n    }\n\n    private function provideUsers(): iterable\n    {\n        yield [\n            'username' => 'adminUser',\n            'password' => 'adminUser123',\n            'email' => 'adminUser@example.com',\n            'type' => 'Person',\n        ];\n\n        yield [\n            'username' => 'JohnDoe',\n            'password' => 'JohnDoe123',\n            'email' => 'JohnDoe@example.com',\n            'type' => 'Person',\n        ];\n    }\n\n    private function createUser(string $username, ?string $email = null, ?string $password = null, string $type = 'Person', $active = true, $hideAdult = true, $about = null, $addImage = true): User\n    {\n        $user = new User($email ?: $username.'@example.com', $username, $password ?: 'secret', $type);\n\n        $user->isVerified = $active;\n        $user->notifyOnNewEntry = true;\n        $user->notifyOnNewEntryReply = true;\n        $user->notifyOnNewEntryCommentReply = true;\n        $user->notifyOnNewPost = true;\n        $user->notifyOnNewPostReply = true;\n        $user->notifyOnNewPostCommentReply = true;\n        $user->showProfileFollowings = true;\n        $user->showProfileSubscriptions = true;\n        $user->hideAdult = $hideAdult;\n        $user->apDiscoverable = true;\n        $user->about = $about;\n        $user->apIndexable = true;\n        if ($addImage) {\n            $user->avatar = $this->createImage(bin2hex(random_bytes(20)).'.png');\n        }\n\n        $this->entityManager->persist($user);\n        $this->entityManager->flush();\n\n        $this->users->add($user);\n\n        return $user;\n    }\n\n    public function createMessage(User $to, User $from, string $content): Message\n    {\n        $thread = $this->createMessageThread($to, $from, $content);\n        /** @var Message $message */\n        $message = $thread->messages->get(0);\n\n        return $message;\n    }\n\n    public function createMessageThread(User $to, User $from, string $content): MessageThread\n    {\n        $messageManager = $this->messageManager;\n        $dto = new MessageDto();\n        $dto->body = $content;\n\n        return $messageManager->toThread($dto, $from, $to);\n    }\n\n    public static function createOAuth2AuthCodeClient(): void\n    {\n        /** @var ClientManagerInterface $manager */\n        $manager = self::getContainer()->get(ClientManagerInterface::class);\n\n        $client = new Client('/kbin Test Client', 'testclient', 'testsecret');\n        $client->setDescription('An OAuth2 client for testing purposes');\n        $client->setContactEmail('test@kbin.test');\n        $client->setScopes(...array_map(fn (string $scope) => new Scope($scope), OAuth2ClientDto::AVAILABLE_SCOPES));\n        $client->setGrants(new Grant('authorization_code'), new Grant('refresh_token'));\n        $client->setRedirectUris(new RedirectUri('https://localhost:3001'));\n\n        $manager->save($client);\n    }\n\n    public static function createOAuth2PublicAuthCodeClient(): void\n    {\n        /** @var ClientManagerInterface $manager */\n        $manager = self::getContainer()->get(ClientManagerInterface::class);\n\n        $client = new Client('/kbin Test Client', 'testpublicclient', null);\n        $client->setDescription('An OAuth2 public client for testing purposes');\n        $client->setContactEmail('test@kbin.test');\n        $client->setScopes(...array_map(fn (string $scope) => new Scope($scope), OAuth2ClientDto::AVAILABLE_SCOPES));\n        $client->setGrants(new Grant('authorization_code'), new Grant('refresh_token'));\n        $client->setRedirectUris(new RedirectUri('https://localhost:3001'));\n\n        $manager->save($client);\n    }\n\n    public static function createOAuth2ClientCredsClient(): void\n    {\n        /** @var ClientManagerInterface $clientManager */\n        $clientManager = self::getContainer()->get(ClientManagerInterface::class);\n\n        /** @var UserManager $userManager */\n        $userManager = self::getContainer()->get(UserManager::class);\n\n        $client = new Client('/kbin Test Client', 'testclient', 'testsecret');\n\n        $userDto = new UserDto();\n        $userDto->username = 'test_bot';\n        $userDto->email = 'test@kbin.test';\n        $userDto->plainPassword = hash('sha512', random_bytes(32));\n        $userDto->isBot = true;\n        $user = $userManager->create($userDto, false, false, true);\n        $client->setUser($user);\n\n        $client->setDescription('An OAuth2 client for testing purposes');\n        $client->setContactEmail('test@kbin.test');\n        $client->setScopes(...array_map(fn (string $scope) => new Scope($scope), OAuth2ClientDto::AVAILABLE_SCOPES));\n        $client->setGrants(new Grant('client_credentials'));\n        $client->setRedirectUris(new RedirectUri('https://localhost:3001'));\n\n        $clientManager->save($client);\n    }\n\n    private function provideMagazines(): iterable\n    {\n        yield [\n            'name' => 'acme',\n            'title' => 'Magazyn polityczny',\n            'user' => $this->getUserByUsername('JohnDoe'),\n            'isAdult' => false,\n            'description' => 'Foobar',\n        ];\n\n        yield [\n            'name' => 'kbin',\n            'title' => 'kbin devlog',\n            'user' => $this->getUserByUsername('adminUser'),\n            'isAdult' => false,\n            'description' => 'development process in detail',\n        ];\n\n        yield [\n            'name' => 'adult',\n            'title' => 'Adult only',\n            'user' => $this->getUserByUsername('JohnDoe'),\n            'isAdult' => true,\n            'description' => 'Not for kids',\n        ];\n\n        yield [\n            'name' => 'starwarsmemes@republic.new',\n            'title' => 'starwarsmemes@republic.new',\n            'user' => $this->getUserByUsername('adminUser'),\n            'isAdult' => false,\n            'description' => \"It's a trap\",\n        ];\n    }\n\n    protected function getUserByUsername(string $username, bool $isAdmin = false, bool $hideAdult = true, ?string $about = null, bool $active = true, bool $isModerator = false, bool $addImage = true, ?string $email = null): User\n    {\n        $user = $this->users->filter(fn (User $user) => $user->getUsername() === $username)->first();\n\n        if ($user) {\n            return $user;\n        }\n\n        $user = $this->createUser($username, email: $email, active: $active, hideAdult: $hideAdult, about: $about, addImage: $addImage);\n\n        if ($isAdmin) {\n            $user->roles = ['ROLE_ADMIN'];\n        } elseif ($isModerator) {\n            $user->roles = ['ROLE_MODERATOR'];\n        }\n\n        $this->entityManager->persist($user);\n        $this->entityManager->flush();\n\n        return $user;\n    }\n\n    protected function setAdmin(User $user): void\n    {\n        $user->roles = ['ROLE_ADMIN'];\n        $manager = $this->entityManager;\n\n        $manager->persist($user);\n        $manager->flush();\n\n        $manager->refresh($user);\n    }\n\n    private function createMagazine(\n        string $name,\n        ?string $title = null,\n        ?User $user = null,\n        bool $isAdult = false,\n        ?string $description = null,\n    ): Magazine {\n        $dto = new MagazineDto();\n        $dto->name = $name;\n        $dto->title = $title ?? 'Magazine title';\n        $dto->isAdult = $isAdult;\n        $dto->description = $description;\n\n        if (str_contains($name, '@')) {\n            [$name, $host] = explode('@', $name);\n            $dto->apId = $name;\n            $dto->apProfileId = \"https://{$host}/m/{$name}\";\n        }\n        $newMod = $user ?? $this->getUserByUsername('JohnDoe');\n        $this->entityManager->persist($newMod);\n\n        $magazine = $this->magazineManager->create($dto, $newMod);\n        $this->entityManager->persist($magazine);\n\n        $this->magazines->add($magazine);\n\n        return $magazine;\n    }\n\n    protected function loadNotificationsFixture()\n    {\n        $owner = $this->getUserByUsername('owner');\n        $magazine = $this->getMagazineByName('acme', $owner);\n\n        $actor = $this->getUserByUsername('actor');\n        $regular = $this->getUserByUsername('JohnDoe');\n\n        $entry = $this->getEntryByTitle('test', null, 'test', $magazine, $actor);\n        $comment = $this->createEntryComment('test', $entry, $regular);\n        $this->entryCommentManager->delete($owner, $comment);\n        $this->entryManager->delete($owner, $entry);\n\n        $post = $this->createPost('test', $magazine, $actor);\n        $comment = $this->createPostComment('test', $post, $regular);\n        $this->postCommentManager->delete($owner, $comment);\n        $this->postManager->delete($owner, $post);\n\n        $this->magazineManager->ban(\n            $magazine,\n            $actor,\n            $owner,\n            MagazineBanDto::create('test', new \\DateTimeImmutable('+1 day'))\n        );\n    }\n\n    protected function getMagazineByName(string $name, ?User $user = null, bool $isAdult = false): Magazine\n    {\n        $magazine = $this->magazines->filter(fn (Magazine $magazine) => $magazine->name === $name)->first();\n\n        return $magazine ?: $this->createMagazine($name, $name, $user, $isAdult);\n    }\n\n    protected function getMagazineByNameNoRSAKey(string $name, ?User $user = null, bool $isAdult = false): Magazine\n    {\n        $magazine = $this->magazines->filter(fn (Magazine $magazine) => $magazine->name === $name)->first();\n\n        if ($magazine) {\n            return $magazine;\n        }\n\n        $dto = new MagazineDto();\n        $dto->name = $name;\n        $dto->title = $title ?? 'Magazine title';\n        $dto->isAdult = $isAdult;\n\n        if (str_contains($name, '@')) {\n            [$name, $host] = explode('@', $name);\n            $dto->apId = $name;\n            $dto->apProfileId = \"https://{$host}/m/{$name}\";\n        }\n\n        $factory = $this->magazineFactory;\n        $magazine = $factory->createFromDto($dto, $user ?? $this->getUserByUsername('JohnDoe'));\n        $magazine->apId = $dto->apId;\n        $magazine->apProfileId = $dto->apProfileId;\n        $magazine->apDiscoverable = true;\n\n        if (!$dto->apId) {\n            $urlGenerator = $this->urlGenerator;\n            $magazine->publicKey = 'fakepublic';\n            $magazine->privateKey = 'fakeprivate';\n            $magazine->apProfileId = $urlGenerator->generate(\n                'ap_magazine',\n                ['name' => $magazine->name],\n                UrlGeneratorInterface::ABSOLUTE_URL\n            );\n        }\n\n        $entityManager = $this->entityManager;\n        $entityManager->persist($magazine);\n        $entityManager->flush();\n\n        $manager = $this->magazineManager;\n        $manager->subscribe($magazine, $user ?? $this->getUserByUsername('JohnDoe'));\n\n        $this->magazines->add($magazine);\n\n        return $magazine;\n    }\n\n    protected function getEntryByTitle(\n        string $title,\n        ?string $url = null,\n        ?string $body = null,\n        ?Magazine $magazine = null,\n        ?User $user = null,\n        ?ImageDto $image = null,\n        string $lang = 'en',\n    ): Entry {\n        $entry = $this->entries->filter(fn (Entry $entry) => $entry->title === $title)->first();\n\n        if (!$entry) {\n            $magazine = $magazine ?? $this->getMagazineByName('acme');\n            $user = $user ?? $this->getUserByUsername('JohnDoe');\n            $entry = $this->createEntry($title, $magazine, $user, $url, $body, $image, $lang);\n        }\n\n        return $entry;\n    }\n\n    protected function createEntry(\n        string $title,\n        Magazine $magazine,\n        User $user,\n        ?string $url = null,\n        ?string $body = 'Test entry content',\n        ?ImageDto $imageDto = null,\n        string $lang = 'en',\n    ): Entry {\n        $manager = $this->entryManager;\n\n        $dto = new EntryDto();\n        $dto->magazine = $magazine;\n        $dto->title = $title;\n        $dto->user = $user;\n        $dto->url = $url;\n        $dto->body = $body;\n        $dto->lang = $lang;\n        $dto->image = $imageDto;\n\n        $entry = $manager->create($dto, $user);\n\n        $this->entries->add($entry);\n\n        return $entry;\n    }\n\n    public function createEntryComment(\n        string $body,\n        ?Entry $entry = null,\n        ?User $user = null,\n        ?EntryComment $parent = null,\n        ?ImageDto $imageDto = null,\n        string $lang = 'en',\n    ): EntryComment {\n        $manager = $this->entryCommentManager;\n        $repository = $this->imageRepository;\n\n        if ($parent) {\n            $dto = (new EntryCommentDto())->createWithParent(\n                $entry ?? $this->getEntryByTitle('test entry content', 'https://kbin.pub'),\n                $parent,\n                $imageDto ? $repository->find($imageDto->id) : null,\n                $body\n            );\n        } else {\n            $dto = new EntryCommentDto();\n            $dto->entry = $entry ?? $this->getEntryByTitle('test entry content', 'https://kbin.pub');\n            $dto->body = $body;\n            $dto->image = $imageDto;\n        }\n        $dto->lang = $lang;\n\n        return $manager->create($dto, $user ?? $this->getUserByUsername('JohnDoe'));\n    }\n\n    public function createPost(string $body, ?Magazine $magazine = null, ?User $user = null, ?ImageDto $imageDto = null, string $lang = 'en'): Post\n    {\n        $manager = $this->postManager;\n        $dto = new PostDto();\n        $dto->magazine = $magazine ?: $this->getMagazineByName('acme');\n        $dto->body = $body;\n        $dto->lang = $lang;\n        $dto->image = $imageDto;\n\n        return $manager->create($dto, $user ?? $this->getUserByUsername('JohnDoe'));\n    }\n\n    public function createPostComment(string $body, ?Post $post = null, ?User $user = null, ?ImageDto $imageDto = null, ?PostComment $parent = null, string $lang = 'en'): PostComment\n    {\n        $manager = $this->postCommentManager;\n\n        $dto = new PostCommentDto();\n        $dto->post = $post ?? $this->createPost('test post content');\n        $dto->body = $body;\n        $dto->lang = $lang;\n        $dto->image = $imageDto;\n        $dto->parent = $parent;\n\n        return $manager->create($dto, $user ?? $this->getUserByUsername('JohnDoe'));\n    }\n\n    public function createPostCommentReply(string $body, ?Post $post = null, ?User $user = null, ?PostComment $parent = null): PostComment\n    {\n        $manager = $this->postCommentManager;\n\n        $dto = new PostCommentDto();\n        $dto->post = $post ?? $this->createPost('test post content');\n        $dto->body = $body;\n        $dto->lang = 'en';\n        $dto->parent = $parent ?? $this->createPostComment('test parent comment', $dto->post);\n\n        return $manager->create($dto, $user ?? $this->getUserByUsername('JohnDoe'));\n    }\n\n    public function createImage(string $fileName): Image\n    {\n        $imageRepo = $this->imageRepository;\n        $image = $imageRepo->findOneBy(['fileName' => $fileName]);\n        if ($image) {\n            return $image;\n        }\n        $image = new Image(\n            $fileName,\n            '/dev/random',\n            hash('sha256', $fileName),\n            100,\n            100,\n            null,\n        );\n        $this->entityManager->persist($image);\n        $this->entityManager->flush();\n\n        return $image;\n    }\n\n    public function createMessageNotification(?User $to = null, ?User $from = null): Notification\n    {\n        $messageManager = $this->messageManager;\n        $repository = $this->notificationRepository;\n\n        $dto = new MessageDto();\n        $dto->body = 'test message';\n        $messageManager->toThread($dto, $from ?? $this->getUserByUsername('JaneDoe'), $to ?? $this->getUserByUsername('JohnDoe'));\n\n        return $repository->findOneBy(['user' => $to ?? $this->getUserByUsername('JohnDoe')]);\n    }\n\n    protected function createInstancePages(): Site\n    {\n        $siteRepository = $this->siteRepository;\n        $entityManager = $this->entityManager;\n        $results = $siteRepository->findAll();\n        $site = null;\n        if (!\\count($results)) {\n            $site = new Site();\n        } else {\n            $site = $results[0];\n        }\n        $site->about = 'about';\n        $site->contact = 'contact';\n        $site->faq = 'faq';\n        $site->privacyPolicy = 'privacyPolicy';\n        $site->terms = 'terms';\n\n        $entityManager->persist($site);\n        $entityManager->flush();\n\n        return $site;\n    }\n\n    /**\n     * Creates 5 modlog messages, one each of:\n     *   * log_entry_deleted\n     *   * log_entry_comment_deleted\n     *   * log_post_deleted\n     *   * log_post_comment_deleted\n     *   * log_ban.\n     */\n    public function createModlogMessages(): void\n    {\n        $magazineManager = $this->magazineManager;\n        $entryManager = $this->entryManager;\n        $entryCommentManager = $this->entryCommentManager;\n        $postManager = $this->postManager;\n        $postCommentManager = $this->postCommentManager;\n        $moderator = $this->getUserByUsername('moderator');\n        $magazine = $this->getMagazineByName('acme', $moderator);\n        $user = $this->getUserByUsername('user');\n        $post = $this->createPost('test post', $magazine, $user);\n        $entry = $this->getEntryByTitle('A title', body: 'test entry', magazine: $magazine, user: $user);\n        $postComment = $this->createPostComment('test comment', $post, $user);\n        $entryComment = $this->createEntryComment('test comment 2', $entry, $user);\n\n        $entryCommentManager->delete($moderator, $entryComment);\n        $entryManager->delete($moderator, $entry);\n        $postCommentManager->delete($moderator, $postComment);\n        $postManager->delete($moderator, $post);\n        $magazineManager->ban($magazine, $user, $moderator, MagazineBanDto::create('test ban', new \\DateTimeImmutable('+12 hours')));\n    }\n\n    public function register($active = false): KernelBrowser\n    {\n        $crawler = $this->client->request('GET', '/register');\n\n        $this->client->submit(\n            $crawler->filter('form[name=user_register]')->selectButton('Register')->form(\n                [\n                    'user_register[username]' => 'JohnDoe',\n                    'user_register[email]' => 'johndoe@kbin.pub',\n                    'user_register[plainPassword][first]' => 'secret',\n                    'user_register[plainPassword][second]' => 'secret',\n                    'user_register[agreeTerms]' => true,\n                ]\n            )\n        );\n        if (302 === $this->client->getResponse()->getStatusCode()) {\n            $this->client->followRedirect();\n        }\n        self::assertResponseIsSuccessful();\n\n        if ($active) {\n            $user = self::getContainer()->get('doctrine')->getRepository(User::class)\n                ->findOneBy(['username' => 'JohnDoe']);\n            $user->isVerified = true;\n\n            self::getContainer()->get('doctrine')->getManager()->flush();\n            self::getContainer()->get('doctrine')->getManager()->refresh($user);\n        }\n\n        return $this->client;\n    }\n\n    public function getKibbyImageDto(): ImageDto\n    {\n        return $this->getKibbyImageVariantDto('');\n    }\n\n    public function getKibbyFlippedImageDto(): ImageDto\n    {\n        return $this->getKibbyImageVariantDto('_flipped');\n    }\n\n    private function getKibbyImageVariantDto(string $suffix): ImageDto\n    {\n        $imageRepository = $this->imageRepository;\n        $imageFactory = $this->imageFactory;\n\n        if (!file_exists(\\dirname($this->kibbyPath).'/copy')) {\n            if (!mkdir(\\dirname($this->kibbyPath).'/copy')) {\n                throw new \\Exception('The copy dir could not be created');\n            }\n        }\n\n        // Uploading a file appears to delete the file at the given path, so make a copy before upload\n        $tmpPath = \\dirname($this->kibbyPath).'/copy/'.bin2hex(random_bytes(32)).'.png';\n        $srcPath = \\dirname($this->kibbyPath).'/'.basename($this->kibbyPath, '.png').$suffix.'.png';\n        if (!file_exists($srcPath)) {\n            throw new \\Exception('For some reason the kibby image got deleted');\n        }\n        copy($srcPath, $tmpPath);\n        /** @var Image $image */\n        $image = $imageRepository->findOrCreateFromUpload(new UploadedFile($tmpPath, 'kibby_emoji.png', 'image/png'));\n        self::assertNotNull($image);\n        $image->altText = 'kibby';\n        $this->entityManager->persist($image);\n        $this->entityManager->flush();\n\n        $dto = $imageFactory->createDto($image);\n        assertNotNull($dto->id);\n\n        return $dto;\n    }\n}\n"
  },
  {
    "path": "tests/Functional/ActivityPub/ActivityPubFunctionalTestCase.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\ActivityPub;\n\nuse App\\ActivityPub\\JsonRd;\nuse App\\DTO\\MessageDto;\nuse App\\Entity\\Contracts\\ActivityPubActivityInterface;\nuse App\\Entity\\Contracts\\ActivityPubActorInterface;\nuse App\\Entity\\Entry;\nuse App\\Entity\\EntryComment;\nuse App\\Entity\\Magazine;\nuse App\\Entity\\Message;\nuse App\\Entity\\Post;\nuse App\\Entity\\PostComment;\nuse App\\Entity\\User;\nuse App\\Event\\ActivityPub\\WebfingerResponseEvent;\nuse App\\Service\\ActivityPub\\Webfinger\\WebFingerFactory;\nuse App\\Tests\\ActivityPubTestCase;\nuse Doctrine\\Common\\Collections\\ArrayCollection;\n\nabstract class ActivityPubFunctionalTestCase extends ActivityPubTestCase\n{\n    protected Magazine $localMagazine;\n\n    /**\n     * @var ?Magazine This is only set during the setUp call\n     */\n    protected ?Magazine $remoteMagazine;\n\n    protected User $localUser;\n\n    /**\n     * @var ?User This is only set during the setUp call\n     */\n    protected ?User $remoteUser;\n    protected User $remoteSubscriber;\n    protected ?string $prev;\n\n    protected string $localDomain;\n    protected string $remoteDomain = 'remote.mbin';\n    protected string $remoteSubDomain = 'remote.sub.mbin';\n\n    protected array $entitiesToRemoveAfterSetup = [];\n\n    public function setUp(): void\n    {\n        parent::setUp();\n        $this->localDomain = $this->settingsManager->get('KBIN_DOMAIN');\n        $this->setupLocalActors();\n\n        $this->switchToRemoteDomain($this->remoteSubDomain);\n        $this->setUpRemoteSubscriber();\n\n        $this->entries = new ArrayCollection();\n        $this->magazines = new ArrayCollection();\n        $this->users = new ArrayCollection();\n        $this->switchToLocalDomain();\n\n        $this->switchToRemoteDomain($this->remoteDomain);\n\n        $this->setUpRemoteActors();\n        $this->setUpRemoteEntities();\n\n        $this->entries = new ArrayCollection();\n        $this->magazines = new ArrayCollection();\n        $this->users = new ArrayCollection();\n        $this->switchToLocalDomain();\n\n        $this->setUpLocalEntities();\n\n        $this->switchToRemoteDomain($this->remoteDomain);\n        $this->setUpLateRemoteEntities();\n\n        $this->switchToLocalDomain();\n\n        // foreach ($this->entitiesToRemoveAfterSetup as $entity) {\n        // $this->entityManager->remove($entity);\n        // }\n\n        for ($i = \\sizeof($this->entitiesToRemoveAfterSetup) - 1; $i >= 0; --$i) {\n            $this->entityManager->remove($this->entitiesToRemoveAfterSetup[$i]);\n        }\n        $this->entries = new ArrayCollection();\n        $this->magazines = new ArrayCollection();\n        $this->users = new ArrayCollection();\n\n        $this->entityManager->flush();\n        $this->entityManager->clear();\n\n        $this->remoteSubscriber = $this->activityPubManager->findActorOrCreate(\"@remoteSubscriber@$this->remoteSubDomain\");\n        $this->remoteSubscriber->publicKey = 'some public key';\n        $this->remoteMagazine = $this->activityPubManager->findActorOrCreate(\"!remoteMagazine@$this->remoteDomain\");\n        $this->remoteMagazine->publicKey = 'some public key';\n        $this->remoteUser = $this->activityPubManager->findActorOrCreate(\"@remoteUser@$this->remoteDomain\");\n        $this->remoteUser->publicKey = 'some public key';\n        $this->localMagazine = $this->magazineRepository->findOneByName('magazine');\n        $this->magazineManager->subscribe($this->localMagazine, $this->remoteSubscriber);\n        self::assertTrue($this->localMagazine->isSubscribed($this->remoteSubscriber));\n        $this->entityManager->refresh($this->localMagazine);\n        $this->localUser = $this->userRepository->findOneByUsername('user');\n    }\n\n    protected function setupLocalActors(): void\n    {\n        $this->localUser = $this->getUserByUsername('user', addImage: false);\n        $this->localMagazine = $this->getMagazineByName('magazine', user: $this->localUser);\n        $this->entityManager->flush();\n    }\n\n    abstract public function setUpRemoteEntities(): void;\n\n    /**\n     * Override this method if you want to set up remote objects depending on you local entities.\n     */\n    public function setUpLateRemoteEntities(): void\n    {\n    }\n\n    /**\n     * Override this method if you want to set up additional local entities.\n     */\n    public function setUpLocalEntities(): void\n    {\n    }\n\n    protected function setUpRemoteActors(): void\n    {\n        $domain = $this->remoteDomain;\n\n        $username = 'remoteUser';\n        $this->remoteUser = $this->getUserByUsername($username, addImage: false);\n\n        $magazineName = 'remoteMagazine';\n        $this->remoteMagazine = $this->getMagazineByName($magazineName, user: $this->remoteUser);\n\n        $this->registerActor($this->remoteMagazine, $domain, true);\n        $this->registerActor($this->remoteUser, $domain, true);\n    }\n\n    protected function setUpRemoteSubscriber(): void\n    {\n        $domain = $this->remoteSubDomain;\n        $username = 'remoteSubscriber';\n        $this->remoteSubscriber = $this->getUserByUsername($username, addImage: false);\n        $this->registerActor($this->remoteSubscriber, $domain, true);\n    }\n\n    protected function registerActor(ActivityPubActorInterface $actor, string $domain, bool $removeAfterSetup = false): void\n    {\n        if ($actor instanceof User) {\n            $json = $this->personFactory->create($actor);\n        } elseif ($actor instanceof Magazine) {\n            $json = $this->groupFactory->create($actor);\n        } else {\n            $class = \\get_class($actor);\n            throw new \\LogicException(\"tests do not support actors of type $class\");\n        }\n        $this->testingApHttpClient->actorObjects[$json['id']] = $json;\n        $username = $json['preferredUsername'];\n\n        $userEvent = new WebfingerResponseEvent(new JsonRd(), \"acct:$username@$domain\", ['account' => $username]);\n        $this->eventDispatcher->dispatch($userEvent);\n        $realDomain = \\sprintf(WebFingerFactory::WEBFINGER_URL, 'https', $domain, '', \"$username@$domain\");\n        $this->testingApHttpClient->webfingerObjects[$realDomain] = $userEvent->jsonRd->toArray();\n\n        if ($removeAfterSetup) {\n            $this->entitiesToRemoveAfterSetup[] = $actor;\n        }\n    }\n\n    protected function switchToRemoteDomain($domain): void\n    {\n        $this->prev = $this->settingsManager->get('KBIN_DOMAIN');\n\n        $this->settingsManager->set('KBIN_DOMAIN', $domain);\n        $context = $this->router->getContext();\n        $context->setHost($domain);\n    }\n\n    protected function switchToLocalDomain(): void\n    {\n        if (null === $this->prev) {\n            return;\n        }\n        $context = $this->router->getContext();\n        $this->settingsManager->set('KBIN_DOMAIN', $this->prev);\n        $context->setHost($this->prev);\n        $this->prev = null;\n    }\n\n    /**\n     * @param callable(Entry $entry):void|null $entryCreateCallback\n     */\n    protected function createRemoteEntryInRemoteMagazine(Magazine $magazine, User $user, ?callable $entryCreateCallback = null): array\n    {\n        $entry = $this->getEntryByTitle('remote entry', magazine: $magazine, user: $user);\n        $json = $this->pageFactory->create($entry, $this->tagLinkRepository->getTagsOfContent($entry));\n        $this->testingApHttpClient->activityObjects[$json['id']] = $json;\n\n        $createActivity = $this->createWrapper->build($entry);\n        $create = $this->activityJsonBuilder->buildActivityJson($createActivity);\n        $this->testingApHttpClient->activityObjects[$create['id']] = $create;\n\n        $announceActivity = $this->announceWrapper->build($magazine, $createActivity);\n        $announce = $this->activityJsonBuilder->buildActivityJson($announceActivity);\n        $this->testingApHttpClient->activityObjects[$announce['id']] = $announce;\n\n        if (null !== $entryCreateCallback) {\n            $entryCreateCallback($entry);\n        }\n\n        $this->entitiesToRemoveAfterSetup[] = $announceActivity;\n        $this->entitiesToRemoveAfterSetup[] = $createActivity;\n        $this->entitiesToRemoveAfterSetup[] = $entry;\n\n        return $announce;\n    }\n\n    /**\n     * @param callable(EntryComment $entry):void|null $entryCommentCreateCallback\n     */\n    protected function createRemoteEntryCommentInRemoteMagazine(Magazine $magazine, User $user, ?callable $entryCommentCreateCallback = null): array\n    {\n        $entries = array_filter($this->entitiesToRemoveAfterSetup, fn ($item) => $item instanceof Entry);\n        $entry = $entries[array_key_first($entries)];\n        $comment = $this->createEntryComment('remote entry comment', $entry, $user);\n        $json = $this->entryCommentNoteFactory->create($comment, $this->tagLinkRepository->getTagsOfContent($comment));\n        $this->testingApHttpClient->activityObjects[$json['id']] = $json;\n\n        $createActivity = $this->createWrapper->build($comment);\n        $create = $this->activityJsonBuilder->buildActivityJson($createActivity);\n        $this->testingApHttpClient->activityObjects[$create['id']] = $create;\n\n        $announceActivity = $this->announceWrapper->build($magazine, $createActivity);\n        $announce = $this->activityJsonBuilder->buildActivityJson($announceActivity);\n        $this->testingApHttpClient->activityObjects[$announce['id']] = $announce;\n\n        if (null !== $entryCommentCreateCallback) {\n            $entryCommentCreateCallback($comment);\n        }\n\n        $this->entitiesToRemoveAfterSetup[] = $announceActivity;\n        $this->entitiesToRemoveAfterSetup[] = $createActivity;\n        $this->entitiesToRemoveAfterSetup[] = $comment;\n\n        return $announce;\n    }\n\n    /**\n     * @param callable(Post $entry):void|null $postCreateCallback\n     */\n    protected function createRemotePostInRemoteMagazine(Magazine $magazine, User $user, ?callable $postCreateCallback = null): array\n    {\n        $post = $this->createPost('remote post', magazine: $magazine, user: $user);\n        $json = $this->postNoteFactory->create($post, $this->tagLinkRepository->getTagsOfContent($post));\n        $this->testingApHttpClient->activityObjects[$json['id']] = $json;\n\n        $createActivity = $this->createWrapper->build($post);\n        $create = $this->activityJsonBuilder->buildActivityJson($createActivity);\n        $this->testingApHttpClient->activityObjects[$create['id']] = $create;\n\n        $announceActivity = $this->announceWrapper->build($magazine, $createActivity);\n        $announce = $this->activityJsonBuilder->buildActivityJson($announceActivity);\n        $this->testingApHttpClient->activityObjects[$announce['id']] = $announce;\n\n        if (null !== $postCreateCallback) {\n            $postCreateCallback($post);\n        }\n\n        $this->entitiesToRemoveAfterSetup[] = $announceActivity;\n        $this->entitiesToRemoveAfterSetup[] = $createActivity;\n        $this->entitiesToRemoveAfterSetup[] = $post;\n\n        return $announce;\n    }\n\n    /**\n     * @param callable(PostComment $entry):void|null $postCommentCreateCallback\n     */\n    protected function createRemotePostCommentInRemoteMagazine(Magazine $magazine, User $user, ?callable $postCommentCreateCallback = null): array\n    {\n        $posts = array_filter($this->entitiesToRemoveAfterSetup, fn ($item) => $item instanceof Post);\n        $post = $posts[array_key_first($posts)];\n        $comment = $this->createPostComment('remote post comment', $post, $user);\n        $json = $this->postCommentNoteFactory->create($comment, $this->tagLinkRepository->getTagsOfContent($comment));\n        $this->testingApHttpClient->activityObjects[$json['id']] = $json;\n\n        $createActivity = $this->createWrapper->build($comment);\n        $create = $this->activityJsonBuilder->buildActivityJson($createActivity);\n        $this->testingApHttpClient->activityObjects[$create['id']] = $create;\n\n        $announceActivity = $this->announceWrapper->build($magazine, $createActivity);\n        $announce = $this->activityJsonBuilder->buildActivityJson($announceActivity);\n        $this->testingApHttpClient->activityObjects[$announce['id']] = $announce;\n\n        if (null !== $postCommentCreateCallback) {\n            $postCommentCreateCallback($comment);\n        }\n\n        $this->entitiesToRemoveAfterSetup[] = $announceActivity;\n        $this->entitiesToRemoveAfterSetup[] = $createActivity;\n        $this->entitiesToRemoveAfterSetup[] = $comment;\n\n        return $announce;\n    }\n\n    /**\n     * @param callable(Entry $entry):void|null $entryCreateCallback\n     */\n    protected function createRemoteEntryInLocalMagazine(Magazine $magazine, User $user, ?callable $entryCreateCallback = null): array\n    {\n        $entry = $this->getEntryByTitle('remote entry in local', magazine: $magazine, user: $user);\n        $json = $this->pageFactory->create($entry, $this->tagLinkRepository->getTagsOfContent($entry));\n        $this->testingApHttpClient->activityObjects[$json['id']] = $json;\n\n        $createActivity = $this->createWrapper->build($entry);\n        $create = $this->activityJsonBuilder->buildActivityJson($createActivity);\n        $this->testingApHttpClient->activityObjects[$create['id']] = $create;\n\n        $create = $this->RewriteTargetFieldsToLocal($magazine, $create);\n\n        if (null !== $entryCreateCallback) {\n            $entryCreateCallback($entry);\n        }\n\n        $this->entitiesToRemoveAfterSetup[] = $createActivity;\n        $this->entitiesToRemoveAfterSetup[] = $entry;\n\n        return $create;\n    }\n\n    /**\n     * @param callable(EntryComment $entry):void|null $entryCommentCreateCallback\n     */\n    protected function createRemoteEntryCommentInLocalMagazine(Magazine $magazine, User $user, ?callable $entryCommentCreateCallback = null): array\n    {\n        $entries = array_filter($this->entitiesToRemoveAfterSetup, fn ($item) => $item instanceof Entry && 'remote entry in local' === $item->title);\n        $entry = $entries[array_key_first($entries)];\n        $comment = $this->createEntryComment('remote entry comment', $entry, $user);\n        $json = $this->entryCommentNoteFactory->create($comment, $this->tagLinkRepository->getTagsOfContent($comment));\n        $this->testingApHttpClient->activityObjects[$json['id']] = $json;\n\n        $createActivity = $this->createWrapper->build($comment);\n        $create = $this->activityJsonBuilder->buildActivityJson($createActivity);\n        $this->testingApHttpClient->activityObjects[$create['id']] = $create;\n\n        $create = $this->RewriteTargetFieldsToLocal($magazine, $create);\n\n        if (null !== $entryCommentCreateCallback) {\n            $entryCommentCreateCallback($comment);\n        }\n\n        $this->entitiesToRemoveAfterSetup[] = $createActivity;\n        $this->entitiesToRemoveAfterSetup[] = $comment;\n\n        return $create;\n    }\n\n    /**\n     * @param callable(Post $entry):void|null $postCreateCallback\n     */\n    protected function createRemotePostInLocalMagazine(Magazine $magazine, User $user, ?callable $postCreateCallback = null): array\n    {\n        $post = $this->createPost('remote post in local', magazine: $magazine, user: $user);\n        $json = $this->postNoteFactory->create($post, $this->tagLinkRepository->getTagsOfContent($post));\n        $this->testingApHttpClient->activityObjects[$json['id']] = $json;\n\n        $createActivity = $this->createWrapper->build($post);\n        $create = $this->activityJsonBuilder->buildActivityJson($createActivity);\n        $this->testingApHttpClient->activityObjects[$create['id']] = $create;\n\n        $create = $this->RewriteTargetFieldsToLocal($magazine, $create);\n\n        if (null !== $postCreateCallback) {\n            $postCreateCallback($post);\n        }\n\n        $this->entitiesToRemoveAfterSetup[] = $createActivity;\n        $this->entitiesToRemoveAfterSetup[] = $post;\n\n        return $create;\n    }\n\n    /**\n     * @param callable(PostComment $entry):void|null $postCommentCreateCallback\n     */\n    protected function createRemotePostCommentInLocalMagazine(Magazine $magazine, User $user, ?callable $postCommentCreateCallback = null): array\n    {\n        $posts = array_filter($this->entitiesToRemoveAfterSetup, fn ($item) => $item instanceof Post && 'remote post in local' === $item->body);\n        $post = $posts[array_key_first($posts)];\n        $comment = $this->createPostComment('remote post comment in local', $post, $user);\n        $json = $this->postCommentNoteFactory->create($comment, $this->tagLinkRepository->getTagsOfContent($comment));\n        $this->testingApHttpClient->activityObjects[$json['id']] = $json;\n\n        $createActivity = $this->createWrapper->build($comment);\n        $create = $this->activityJsonBuilder->buildActivityJson($createActivity);\n        $this->testingApHttpClient->activityObjects[$create['id']] = $create;\n\n        $create = $this->RewriteTargetFieldsToLocal($magazine, $create);\n\n        if (null !== $postCommentCreateCallback) {\n            $postCommentCreateCallback($comment);\n        }\n\n        $this->entitiesToRemoveAfterSetup[] = $createActivity;\n        $this->entitiesToRemoveAfterSetup[] = $comment;\n\n        return $create;\n    }\n\n    /**\n     * @param callable(Message $entry):void|null $messageCreateCallback\n     */\n    protected function createRemoteMessage(User $fromRemoteUser, User $toLocalUser, ?callable $messageCreateCallback = null): array\n    {\n        $dto = new MessageDto();\n        $dto->body = 'remote message';\n        $thread = $this->messageManager->toThread($dto, $fromRemoteUser, $toLocalUser);\n        $message = $thread->getLastMessage();\n\n        $this->entitiesToRemoveAfterSetup[] = $thread;\n        $this->entitiesToRemoveAfterSetup[] = $message;\n\n        $createActivity = $this->createWrapper->build($message);\n        $create = $this->activityJsonBuilder->buildActivityJson($createActivity);\n        $correctUserString = \"https://$this->prev/u/$toLocalUser->username\";\n        $create['to'] = [$correctUserString];\n        $create['object']['to'] = [$correctUserString];\n        $this->testingApHttpClient->activityObjects[$create['id']] = $create;\n\n        if (null !== $messageCreateCallback) {\n            $messageCreateCallback($message);\n        }\n\n        $this->entitiesToRemoveAfterSetup[] = $createActivity;\n\n        return $create;\n    }\n\n    /**\n     * This rewrites the target fields `to` and `audience` to the @see self::$prev domain.\n     * This is useful when remote actors create activities on local magazines.\n     *\n     * @return array the array with rewritten target fields\n     */\n    protected function RewriteTargetFieldsToLocal(Magazine $magazine, array $activityArray): array\n    {\n        $magazineAddress = \"https://$this->prev/m/$magazine->name\";\n        $to = [\n            $magazineAddress,\n            ActivityPubActivityInterface::PUBLIC_URL,\n        ];\n        if (isset($activityArray['to'])) {\n            $activityArray['to'] = $to;\n        }\n        if (isset($activityArray['audience'])) {\n            $activityArray['audience'] = $magazineAddress;\n        }\n        if (isset($activityArray['object']) && \\is_array($activityArray['object'])) {\n            $activityArray['object'] = $this->RewriteTargetFieldsToLocal($magazine, $activityArray['object']);\n        }\n\n        return $activityArray;\n    }\n\n    protected function assertCountOfSentActivitiesOfType(int $expectedCount, string $type): void\n    {\n        $activities = $this->getSentActivitiesOfType($type);\n        $this->assertCount($expectedCount, $activities);\n    }\n\n    protected function assertOneSentActivityOfType(string $type, ?string $activityId = null, ?string $inboxUrl = null): array\n    {\n        $activities = $this->getSentActivitiesOfType($type);\n        self::assertCount(1, $activities);\n        if (null !== $activityId) {\n            self::assertEquals($activityId, $activities[0]['payload']['id']);\n        }\n        if (null !== $inboxUrl) {\n            self::assertEquals($inboxUrl, $activities[0]['inboxUrl']);\n        }\n\n        return $activities[0]['payload'];\n    }\n\n    protected function assertOneSentAnnouncedActivityOfType(string $type, ?string $announcedActivityId = null): void\n    {\n        $activities = $this->getSentAnnounceActivitiesOfInnerType($type);\n        self::assertCount(1, $activities);\n        if (null !== $announcedActivityId) {\n            self::assertEquals($announcedActivityId, $activities[0]['payload']['object']['id']);\n        }\n    }\n\n    protected function assertOneSentAnnouncedActivityOfTypeGetInnerActivity(string $type, ?string $announcedActivityId = null, ?string $announceId = null, ?string $inboxUrl = null): array|string\n    {\n        $activities = $this->getSentAnnounceActivitiesOfInnerType($type);\n        self::assertCount(1, $activities);\n        if (null !== $announcedActivityId) {\n            self::assertEquals($announcedActivityId, $activities[0]['payload']['object']['id']);\n        }\n        if (null !== $announceId) {\n            self::assertEquals($announceId, $activities[0]['payload']['id']);\n        }\n        if (null !== $inboxUrl) {\n            self::assertEquals($inboxUrl, $activities[0]['inboxUrl']);\n        }\n\n        return $activities[0]['payload']['object'];\n    }\n\n    /**\n     * @return array<int, array{inboxUrl: string, payload: array, actor: User|Magazine}>\n     */\n    protected function getSentActivitiesOfType(string $type): array\n    {\n        return array_values(array_filter($this->testingApHttpClient->getPostedObjects(), fn (array $item) => $type === $item['payload']['type']));\n    }\n\n    /**\n     * @return array<int, array{inboxUrl: string, payload: array, actor: User|Magazine}>\n     */\n    protected function getSentAnnounceActivitiesOfInnerType(string $type): array\n    {\n        return array_values(array_filter($this->testingApHttpClient->getPostedObjects(), fn (array $item) => 'Announce' === $item['payload']['type'] && $type === $item['payload']['object']['type']));\n    }\n}\n"
  },
  {
    "path": "tests/Functional/ActivityPub/Inbox/AcceptHandlerTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\ActivityPub\\Inbox;\n\nuse App\\Message\\ActivityPub\\Inbox\\ActivityMessage;\nuse App\\Tests\\Functional\\ActivityPub\\ActivityPubFunctionalTestCase;\nuse PHPUnit\\Framework\\Attributes\\Group;\n\n#[Group(name: 'ActivityPub')]\n#[Group(name: 'NonThreadSafe')]\nclass AcceptHandlerTest extends ActivityPubFunctionalTestCase\n{\n    private array $followRemoteUser;\n    private array $acceptFollowRemoteUser;\n    private array $followRemoteMagazine;\n    private array $acceptFollowRemoteMagazine;\n\n    public function setUp(): void\n    {\n        parent::setUp();\n        $this->remoteUser->apManuallyApprovesFollowers = true;\n        $this->userManager->follow($this->localUser, $this->remoteUser);\n    }\n\n    public function setUpLocalEntities(): void\n    {\n        $followActivity = $this->followWrapper->build($this->localUser, $this->remoteUser);\n        $this->followRemoteUser = $this->activityJsonBuilder->buildActivityJson($followActivity);\n        $this->testingApHttpClient->activityObjects[$this->followRemoteUser['id']] = $this->followRemoteUser;\n        $this->entitiesToRemoveAfterSetup[] = $followActivity;\n\n        $this->magazineManager->subscribe($this->remoteMagazine, $this->localUser);\n        $followActivity = $this->followWrapper->build($this->localUser, $this->remoteMagazine);\n        $this->followRemoteMagazine = $this->activityJsonBuilder->buildActivityJson($followActivity);\n        $this->testingApHttpClient->activityObjects[$this->followRemoteMagazine['id']] = $this->followRemoteMagazine;\n        $this->entitiesToRemoveAfterSetup[] = $followActivity;\n    }\n\n    public function setUpRemoteEntities(): void\n    {\n    }\n\n    public function setUpLateRemoteEntities(): void\n    {\n        $acceptActivity = $this->followResponseWrapper->build($this->remoteUser, $this->followRemoteUser);\n        $this->acceptFollowRemoteUser = $this->activityJsonBuilder->buildActivityJson($acceptActivity);\n        $this->testingApHttpClient->activityObjects[$this->acceptFollowRemoteUser['id']] = $this->acceptFollowRemoteUser;\n        $this->entitiesToRemoveAfterSetup[] = $acceptActivity;\n\n        $acceptActivity = $this->followResponseWrapper->build($this->remoteMagazine, $this->followRemoteMagazine);\n        $this->acceptFollowRemoteMagazine = $this->activityJsonBuilder->buildActivityJson($acceptActivity);\n        $this->testingApHttpClient->activityObjects[$this->acceptFollowRemoteMagazine['id']] = $this->acceptFollowRemoteMagazine;\n        $this->entitiesToRemoveAfterSetup[] = $acceptActivity;\n    }\n\n    public function testAcceptFollowMagazine(): void\n    {\n        // we do not have manual follower approving for magazines implemented\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->acceptFollowRemoteMagazine)));\n    }\n\n    public function testAcceptFollowUser(): void\n    {\n        self::assertTrue($this->remoteUser->apManuallyApprovesFollowers);\n        $request = $this->userFollowRequestRepository->findOneby(['follower' => $this->localUser, 'following' => $this->remoteUser]);\n        self::assertNotNull($request);\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->acceptFollowRemoteUser)));\n\n        $request = $this->userFollowRequestRepository->findOneby(['follower' => $this->localUser, 'following' => $this->remoteUser]);\n        self::assertNull($request);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/ActivityPub/Inbox/AddHandlerTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\ActivityPub\\Inbox;\n\nuse App\\DTO\\ModeratorDto;\nuse App\\Entity\\Entry;\nuse App\\Message\\ActivityPub\\Inbox\\ActivityMessage;\nuse App\\Tests\\Functional\\ActivityPub\\ActivityPubFunctionalTestCase;\nuse PHPUnit\\Framework\\Attributes\\Group;\n\n#[Group(name: 'ActivityPub')]\n#[Group(name: 'NonThreadSafe')]\nclass AddHandlerTest extends ActivityPubFunctionalTestCase\n{\n    private array $addModeratorRemoteMagazine;\n\n    private array $addModeratorLocalMagazine;\n\n    private array $createRemoteEntryInRemoteMagazine;\n\n    private array $addPinnedEntryRemoteMagazine;\n\n    private array $createRemoteEntryInLocalMagazine;\n\n    private array $addPinnedEntryLocalMagazine;\n\n    public function setUp(): void\n    {\n        parent::setUp();\n        // it is important that the moderators are initialized here, as they would be removed from the db if added in `setupRemoteEntries`\n        $this->magazineManager->addModerator(new ModeratorDto($this->remoteMagazine, $this->remoteUser, $this->remoteMagazine->getOwner()));\n        $this->magazineManager->addModerator(new ModeratorDto($this->localMagazine, $this->remoteUser, $this->localUser));\n    }\n\n    public function testAddModeratorInRemoteMagazine(): void\n    {\n        self::assertFalse($this->remoteMagazine->userIsModerator($this->remoteSubscriber));\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->addModeratorRemoteMagazine)));\n        self::assertTrue($this->remoteMagazine->userIsModerator($this->remoteSubscriber));\n    }\n\n    public function testAddModeratorLocalMagazine(): void\n    {\n        self::assertFalse($this->localMagazine->userIsModerator($this->remoteSubscriber));\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->addModeratorLocalMagazine)));\n        self::assertTrue($this->localMagazine->userIsModerator($this->remoteSubscriber));\n\n        $this->assertAddSentToSubscriber($this->addModeratorLocalMagazine);\n    }\n\n    public function testAddPinnedEntryInRemoteMagazine(): void\n    {\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->createRemoteEntryInRemoteMagazine)));\n        $entry = $this->entryRepository->findOneBy(['apId' => $this->createRemoteEntryInRemoteMagazine['object']['object']['id']]);\n        self::assertNotNull($entry);\n        self::assertFalse($entry->sticky);\n\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->addPinnedEntryRemoteMagazine)));\n        $this->entityManager->refresh($entry);\n        self::assertTrue($entry->sticky);\n    }\n\n    public function testAddPinnedEntryLocalMagazine(): void\n    {\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->createRemoteEntryInLocalMagazine)));\n        $entry = $this->entryRepository->findOneBy(['apId' => $this->createRemoteEntryInLocalMagazine['object']['id']]);\n        self::assertNotNull($entry);\n        self::assertFalse($entry->sticky);\n\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->addPinnedEntryLocalMagazine)));\n        $this->entityManager->refresh($entry);\n        self::assertTrue($entry->sticky);\n        $this->assertAddSentToSubscriber($this->addPinnedEntryLocalMagazine);\n    }\n\n    public function setUpRemoteEntities(): void\n    {\n        $this->buildAddModeratorInRemoteMagazine();\n        $this->buildAddModeratorInLocalMagazine();\n        $this->createRemoteEntryInRemoteMagazine = $this->createRemoteEntryInRemoteMagazine($this->remoteMagazine, $this->remoteUser, fn (Entry $entry) => $this->buildAddPinnedPostInRemoteMagazine($entry));\n        $this->createRemoteEntryInLocalMagazine = $this->createRemoteEntryInLocalMagazine($this->localMagazine, $this->remoteUser, fn (Entry $entry) => $this->buildAddPinnedPostInLocalMagazine($entry));\n    }\n\n    private function buildAddModeratorInRemoteMagazine(): void\n    {\n        $addActivity = $this->addRemoveFactory->buildAddModerator($this->remoteUser, $this->remoteSubscriber, $this->remoteMagazine);\n        $this->addModeratorRemoteMagazine = $this->activityJsonBuilder->buildActivityJson($addActivity);\n        $this->addModeratorRemoteMagazine['object'] = 'https://remote.sub.mbin/u/remoteSubscriber';\n\n        $this->testingApHttpClient->activityObjects[$this->addModeratorRemoteMagazine['id']] = $this->addModeratorRemoteMagazine;\n        $this->entitiesToRemoveAfterSetup[] = $addActivity;\n    }\n\n    private function buildAddModeratorInLocalMagazine(): void\n    {\n        $addActivity = $this->addRemoveFactory->buildAddModerator($this->remoteUser, $this->remoteSubscriber, $this->localMagazine);\n        $this->addModeratorLocalMagazine = $this->activityJsonBuilder->buildActivityJson($addActivity);\n        $this->addModeratorLocalMagazine['target'] = 'https://kbin.test/m/magazine/moderators';\n        $this->addModeratorLocalMagazine['object'] = 'https://remote.sub.mbin/u/remoteSubscriber';\n\n        $this->testingApHttpClient->activityObjects[$this->addModeratorLocalMagazine['id']] = $this->addModeratorLocalMagazine;\n        $this->entitiesToRemoveAfterSetup[] = $addActivity;\n    }\n\n    private function buildAddPinnedPostInRemoteMagazine(Entry $entry): void\n    {\n        $addActivity = $this->addRemoveFactory->buildAddPinnedPost($this->remoteUser, $entry);\n        $this->addPinnedEntryRemoteMagazine = $this->activityJsonBuilder->buildActivityJson($addActivity);\n\n        $this->testingApHttpClient->activityObjects[$this->addPinnedEntryRemoteMagazine['id']] = $this->addPinnedEntryRemoteMagazine;\n        $this->entitiesToRemoveAfterSetup[] = $addActivity;\n    }\n\n    private function buildAddPinnedPostInLocalMagazine(Entry $entry): void\n    {\n        $addActivity = $this->addRemoveFactory->buildAddPinnedPost($this->remoteUser, $entry);\n        $this->addPinnedEntryLocalMagazine = $this->activityJsonBuilder->buildActivityJson($addActivity);\n        $this->addPinnedEntryLocalMagazine['target'] = 'https://kbin.test/m/magazine/pinned';\n\n        $this->testingApHttpClient->activityObjects[$this->addPinnedEntryLocalMagazine['id']] = $this->addPinnedEntryLocalMagazine;\n        $this->entitiesToRemoveAfterSetup[] = $addActivity;\n    }\n\n    private function assertAddSentToSubscriber(array $originalPayload): void\n    {\n        $postedObjects = $this->testingApHttpClient->getPostedObjects();\n        self::assertNotEmpty($postedObjects);\n        $postedAddAnnounces = array_filter($postedObjects, fn ($arr) => 'Announce' === $arr['payload']['type'] && 'Add' === $arr['payload']['object']['type']);\n        $postedAddAnnounce = $postedAddAnnounces[array_key_first($postedAddAnnounces)];\n        // the id of the 'Add' activity should be wrapped in an 'Announce' activity\n        self::assertEquals($originalPayload['id'], $postedAddAnnounce['payload']['object']['id']);\n        self::assertEquals($originalPayload['object'], $postedAddAnnounce['payload']['object']['object']);\n        self::assertEquals($this->remoteSubscriber->apInboxUrl, $postedAddAnnounce['inboxUrl']);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/ActivityPub/Inbox/BlockHandlerTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\ActivityPub\\Inbox;\n\nuse App\\DTO\\MagazineBanDto;\nuse App\\DTO\\ModeratorDto;\nuse App\\Entity\\User;\nuse App\\Message\\ActivityPub\\Inbox\\ActivityMessage;\nuse App\\Tests\\Functional\\ActivityPub\\ActivityPubFunctionalTestCase;\nuse PHPUnit\\Framework\\Attributes\\Depends;\nuse PHPUnit\\Framework\\Attributes\\Group;\n\n#[Group(name: 'ActivityPub')]\n#[Group(name: 'NonThreadSafe')]\nclass BlockHandlerTest extends ActivityPubFunctionalTestCase\n{\n    private array $blockLocalUserLocalMagazine;\n    private array $undoBlockLocalUserLocalMagazine;\n    private array $blockRemoteUserLocalMagazine;\n    private array $undoBlockRemoteUserLocalMagazine;\n    private array $blockLocalUserRemoteMagazine;\n    private array $undoBlockLocalUserRemoteMagazine;\n    private array $blockRemoteUserRemoteMagazine;\n    private array $undoBlockRemoteUserRemoteMagazine;\n    private array $instanceBanRemoteUser;\n    private array $undoInstanceBanRemoteUser;\n\n    private User $localSubscriber;\n    private User $remoteAdmin;\n\n    public function setUp(): void\n    {\n        parent::setUp();\n        // it is important that the moderators are initialized here, as they would be removed from the db if added in `setupRemoteEntries`\n        $this->magazineManager->addModerator(new ModeratorDto($this->localMagazine, $this->remoteSubscriber, $this->localUser));\n        $this->remoteAdmin = $this->activityPubManager->findActorOrCreate(\"@remoteAdmin@$this->remoteDomain\");\n        $this->magazineManager->subscribe($this->localMagazine, $this->remoteUser);\n    }\n\n    protected function setUpRemoteActors(): void\n    {\n        parent::setUpRemoteActors();\n        $user = $this->getUserByUsername('remoteAdmin', addImage: false);\n        $this->registerActor($user, $this->remoteDomain, true);\n        $this->remoteAdmin = $user;\n    }\n\n    public function setupLocalActors(): void\n    {\n        $this->localSubscriber = $this->getUserByUsername('localSubscriber', addImage: false);\n        parent::setupLocalActors();\n    }\n\n    public function setUpRemoteEntities(): void\n    {\n        $this->buildBlockLocalUserLocalMagazine();\n        $this->buildBlockLocalUserRemoteMagazine();\n        $this->buildBlockRemoteUserLocalMagazine();\n        $this->buildBlockRemoteUserRemoteMagazine();\n        $this->buildInstanceBanRemoteUser();\n    }\n\n    public function testBlockLocalUserLocalMagazine(): void\n    {\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->blockLocalUserLocalMagazine)));\n        $ban = $this->magazineBanRepository->findOneBy(['magazine' => $this->localMagazine, 'user' => $this->localSubscriber]);\n        self::assertNotNull($ban);\n        $this->entityManager->refresh($this->localMagazine);\n        self::assertTrue($this->localMagazine->isBanned($this->localSubscriber));\n\n        // should not be sent to source instance, only to subscriber instance\n        $announcedBlock = $this->assertOneSentAnnouncedActivityOfTypeGetInnerActivity('Block', announcedActivityId: $this->blockLocalUserLocalMagazine['id'], inboxUrl: $this->remoteUser->apInboxUrl);\n        self::assertEquals($this->blockLocalUserLocalMagazine['object'], $announcedBlock['object']);\n    }\n\n    #[Depends('testBlockLocalUserLocalMagazine')]\n    public function testUndoBlockLocalUserLocalMagazine(): void\n    {\n        $this->testBlockLocalUserLocalMagazine();\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->undoBlockLocalUserLocalMagazine)));\n        $ban = $this->magazineBanRepository->findOneBy(['magazine' => $this->localMagazine, 'user' => $this->localSubscriber]);\n        self::assertNotNull($ban);\n        $this->entityManager->refresh($this->localMagazine);\n        self::assertFalse($this->localMagazine->isBanned($this->localSubscriber));\n\n        // should not be sent to source instance, only to subscriber instance\n        $announcedUndo = $this->assertOneSentAnnouncedActivityOfTypeGetInnerActivity('Undo', announcedActivityId: $this->undoBlockLocalUserLocalMagazine['id'], inboxUrl: $this->remoteUser->apInboxUrl);\n        self::assertEquals($this->undoBlockLocalUserLocalMagazine['object']['object'], $announcedUndo['object']['object']);\n        self::assertEquals($this->undoBlockLocalUserLocalMagazine['object']['id'], $announcedUndo['object']['id']);\n    }\n\n    public function testBlockRemoteUserLocalMagazine(): void\n    {\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->blockRemoteUserLocalMagazine)));\n        $ban = $this->magazineBanRepository->findOneBy(['magazine' => $this->localMagazine, 'user' => $this->remoteUser]);\n        self::assertNotNull($ban);\n        $this->entityManager->refresh($this->localMagazine);\n        self::assertTrue($this->localMagazine->isBanned($this->remoteUser));\n\n        // should not be sent to source instance, only to subscriber instance\n        $blockActivity = $this->assertOneSentAnnouncedActivityOfTypeGetInnerActivity('Block', announcedActivityId: $this->blockRemoteUserLocalMagazine['id'], inboxUrl: $this->remoteUser->apInboxUrl);\n        self::assertEquals($this->blockRemoteUserLocalMagazine['object'], $blockActivity['object']);\n    }\n\n    #[Depends('testBlockRemoteUserLocalMagazine')]\n    public function testUndoBlockRemoteUserLocalMagazine(): void\n    {\n        $this->testBlockRemoteUserLocalMagazine();\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->undoBlockRemoteUserLocalMagazine)));\n        $ban = $this->magazineBanRepository->findOneBy(['magazine' => $this->localMagazine, 'user' => $this->remoteUser]);\n        self::assertNotNull($ban);\n        $this->entityManager->refresh($this->localMagazine);\n        self::assertFalse($this->localMagazine->isBanned($this->remoteUser));\n\n        // should not be sent to source instance, only to subscriber instance\n        $announcedUndo = $this->assertOneSentAnnouncedActivityOfTypeGetInnerActivity('Undo', announcedActivityId: $this->undoBlockRemoteUserLocalMagazine['id'], inboxUrl: $this->remoteUser->apInboxUrl);\n        self::assertEquals($this->undoBlockRemoteUserLocalMagazine['object']['id'], $announcedUndo['object']['id']);\n        self::assertEquals($this->undoBlockRemoteUserLocalMagazine['object']['object'], $announcedUndo['object']['object']);\n    }\n\n    public function testBlockLocalUserRemoteMagazine(): void\n    {\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->blockLocalUserRemoteMagazine)));\n        $ban = $this->magazineBanRepository->findOneBy(['magazine' => $this->remoteMagazine, 'user' => $this->localSubscriber]);\n        self::assertNotNull($ban);\n        $this->entityManager->refresh($this->remoteMagazine);\n        self::assertTrue($this->remoteMagazine->isBanned($this->localSubscriber));\n    }\n\n    #[Depends('testBlockLocalUserRemoteMagazine')]\n    public function testUndoBlockLocalUserRemoteMagazine(): void\n    {\n        $this->testBlockLocalUserRemoteMagazine();\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->undoBlockLocalUserRemoteMagazine)));\n        $ban = $this->magazineBanRepository->findOneBy(['magazine' => $this->remoteMagazine, 'user' => $this->localSubscriber]);\n        self::assertNotNull($ban);\n        $this->entityManager->refresh($this->remoteMagazine);\n        self::assertFalse($this->remoteMagazine->isBanned($this->localSubscriber));\n    }\n\n    public function testBlockRemoteUserRemoteMagazine(): void\n    {\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->blockRemoteUserRemoteMagazine)));\n        $ban = $this->magazineBanRepository->findOneBy(['magazine' => $this->remoteMagazine, 'user' => $this->remoteSubscriber]);\n        self::assertNotNull($ban);\n        $this->entityManager->refresh($this->remoteMagazine);\n        self::assertTrue($this->remoteMagazine->isBanned($this->remoteSubscriber));\n    }\n\n    #[Depends('testBlockRemoteUserRemoteMagazine')]\n    public function testUndoBlockRemoteUserRemoteMagazine(): void\n    {\n        $this->testBlockRemoteUserRemoteMagazine();\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->undoBlockRemoteUserRemoteMagazine)));\n        $ban = $this->magazineBanRepository->findOneBy(['magazine' => $this->remoteMagazine, 'user' => $this->remoteSubscriber]);\n        self::assertNotNull($ban);\n        $this->entityManager->refresh($this->remoteMagazine);\n        self::assertFalse($this->remoteMagazine->isBanned($this->remoteSubscriber));\n    }\n\n    public function testInstanceBanRemoteUser(): void\n    {\n        $username = \"@remoteUser@$this->remoteDomain\";\n        $remoteUser = $this->userRepository->findOneByUsername($username);\n        self::assertFalse($remoteUser->isBanned);\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->instanceBanRemoteUser)));\n        $this->entityManager->refresh($remoteUser);\n        self::assertTrue($remoteUser->isBanned);\n        self::assertEquals('testing', $remoteUser->banReason);\n    }\n\n    #[Depends('testInstanceBanRemoteUser')]\n    public function testUndoInstanceBanRemoteUser(): void\n    {\n        $this->testInstanceBanRemoteUser();\n        $username = \"@remoteUser@$this->remoteDomain\";\n        $remoteUser = $this->userRepository->findOneByUsername($username);\n        self::assertTrue($remoteUser->isBanned);\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->undoInstanceBanRemoteUser)));\n        $this->entityManager->refresh($remoteUser);\n        self::assertFalse($remoteUser->isBanned);\n    }\n\n    private function buildBlockLocalUserLocalMagazine(): void\n    {\n        $ban = $this->magazineManager->ban($this->localMagazine, $this->localSubscriber, $this->remoteSubscriber, MagazineBanDto::create('testing'));\n\n        $activity = $this->blockFactory->createActivityFromMagazineBan($ban);\n        $this->blockLocalUserLocalMagazine = $this->activityJsonBuilder->buildActivityJson($activity);\n        $this->blockLocalUserLocalMagazine['actor'] = str_replace($this->remoteDomain, $this->remoteSubDomain, $this->blockLocalUserLocalMagazine['actor']);\n        $this->blockLocalUserLocalMagazine['object'] = str_replace($this->remoteDomain, $this->localDomain, $this->blockLocalUserLocalMagazine['object']);\n        $this->blockLocalUserLocalMagazine['target'] = str_replace($this->remoteDomain, $this->localDomain, $this->blockLocalUserLocalMagazine['target']);\n\n        $undoActivity = $this->undoWrapper->build($activity);\n        $this->undoBlockLocalUserLocalMagazine = $this->activityJsonBuilder->buildActivityJson($undoActivity);\n        $this->undoBlockLocalUserLocalMagazine['actor'] = str_replace($this->remoteDomain, $this->remoteSubDomain, $this->undoBlockLocalUserLocalMagazine['actor']);\n        $this->undoBlockLocalUserLocalMagazine['object'] = $this->blockLocalUserLocalMagazine;\n\n        $this->testingApHttpClient->activityObjects[$this->blockLocalUserLocalMagazine['id']] = $this->blockLocalUserLocalMagazine;\n        $this->testingApHttpClient->activityObjects[$this->undoBlockLocalUserLocalMagazine['id']] = $this->undoBlockLocalUserLocalMagazine;\n        $this->entitiesToRemoveAfterSetup[] = $undoActivity;\n        $this->entitiesToRemoveAfterSetup[] = $activity;\n    }\n\n    private function buildBlockRemoteUserLocalMagazine(): void\n    {\n        $ban = $this->magazineManager->ban($this->localMagazine, $this->remoteUser, $this->remoteSubscriber, MagazineBanDto::create('testing'));\n\n        $activity = $this->blockFactory->createActivityFromMagazineBan($ban);\n        $this->blockRemoteUserLocalMagazine = $this->activityJsonBuilder->buildActivityJson($activity);\n        $this->blockRemoteUserLocalMagazine['actor'] = str_replace($this->remoteDomain, $this->remoteSubDomain, $this->blockRemoteUserLocalMagazine['actor']);\n        $this->blockRemoteUserLocalMagazine['target'] = str_replace($this->remoteDomain, $this->localDomain, $this->blockRemoteUserLocalMagazine['target']);\n\n        $undoActivity = $this->undoWrapper->build($activity);\n        $this->undoBlockRemoteUserLocalMagazine = $this->activityJsonBuilder->buildActivityJson($undoActivity);\n        $this->undoBlockRemoteUserLocalMagazine['actor'] = str_replace($this->remoteDomain, $this->remoteSubDomain, $this->undoBlockRemoteUserLocalMagazine['actor']);\n        $this->undoBlockRemoteUserLocalMagazine['object'] = $this->blockRemoteUserLocalMagazine;\n\n        $this->testingApHttpClient->activityObjects[$this->blockRemoteUserLocalMagazine['id']] = $this->blockRemoteUserLocalMagazine;\n        $this->testingApHttpClient->activityObjects[$this->undoBlockRemoteUserLocalMagazine['id']] = $this->undoBlockRemoteUserLocalMagazine;\n        $this->entitiesToRemoveAfterSetup[] = $undoActivity;\n        $this->entitiesToRemoveAfterSetup[] = $activity;\n    }\n\n    private function buildBlockLocalUserRemoteMagazine(): void\n    {\n        $ban = $this->magazineManager->ban($this->remoteMagazine, $this->localSubscriber, $this->remoteSubscriber, MagazineBanDto::create('testing'));\n\n        $activity = $this->blockFactory->createActivityFromMagazineBan($ban);\n        $this->blockLocalUserRemoteMagazine = $this->activityJsonBuilder->buildActivityJson($activity);\n        $this->blockLocalUserRemoteMagazine['actor'] = str_replace($this->remoteDomain, $this->remoteSubDomain, $this->blockLocalUserRemoteMagazine['actor']);\n        $this->blockLocalUserRemoteMagazine['object'] = str_replace($this->remoteDomain, $this->localDomain, $this->blockLocalUserRemoteMagazine['object']);\n\n        $undoActivity = $this->undoWrapper->build($activity);\n        $this->undoBlockLocalUserRemoteMagazine = $this->activityJsonBuilder->buildActivityJson($undoActivity);\n        $this->undoBlockLocalUserRemoteMagazine['actor'] = str_replace($this->remoteDomain, $this->remoteSubDomain, $this->undoBlockLocalUserRemoteMagazine['actor']);\n        $this->undoBlockLocalUserRemoteMagazine['object'] = $this->blockLocalUserRemoteMagazine;\n\n        $this->testingApHttpClient->activityObjects[$this->blockLocalUserRemoteMagazine['id']] = $this->blockLocalUserRemoteMagazine;\n        $this->testingApHttpClient->activityObjects[$this->undoBlockLocalUserRemoteMagazine['id']] = $this->undoBlockLocalUserRemoteMagazine;\n        $this->entitiesToRemoveAfterSetup[] = $undoActivity;\n        $this->entitiesToRemoveAfterSetup[] = $activity;\n    }\n\n    private function buildBlockRemoteUserRemoteMagazine(): void\n    {\n        $ban = $this->magazineManager->ban($this->remoteMagazine, $this->remoteSubscriber, $this->remoteUser, MagazineBanDto::create('testing'));\n\n        $activity = $this->blockFactory->createActivityFromMagazineBan($ban);\n        $this->blockRemoteUserRemoteMagazine = $this->activityJsonBuilder->buildActivityJson($activity);\n        $this->blockRemoteUserRemoteMagazine['object'] = str_replace($this->remoteDomain, $this->remoteSubDomain, $this->blockRemoteUserRemoteMagazine['object']);\n\n        $undoActivity = $this->undoWrapper->build($activity);\n        $this->undoBlockRemoteUserRemoteMagazine = $this->activityJsonBuilder->buildActivityJson($undoActivity);\n        $this->undoBlockRemoteUserRemoteMagazine['object'] = $this->blockRemoteUserRemoteMagazine;\n\n        $this->testingApHttpClient->activityObjects[$this->blockRemoteUserRemoteMagazine['id']] = $this->blockRemoteUserRemoteMagazine;\n        $this->testingApHttpClient->activityObjects[$this->undoBlockRemoteUserRemoteMagazine['id']] = $this->undoBlockRemoteUserRemoteMagazine;\n        $this->entitiesToRemoveAfterSetup[] = $undoActivity;\n        $this->entitiesToRemoveAfterSetup[] = $activity;\n    }\n\n    private function buildInstanceBanRemoteUser(): void\n    {\n        $this->remoteUser->banReason = 'testing';\n        $activity = $this->blockFactory->createActivityFromInstanceBan($this->remoteUser, $this->remoteAdmin);\n        $this->instanceBanRemoteUser = $this->activityJsonBuilder->buildActivityJson($activity);\n        $this->testingApHttpClient->activityObjects[$this->instanceBanRemoteUser['id']] = $this->instanceBanRemoteUser;\n        $this->entitiesToRemoveAfterSetup[] = $activity;\n\n        $activity = $this->undoWrapper->build($activity);\n        $this->undoInstanceBanRemoteUser = $this->activityJsonBuilder->buildActivityJson($activity);\n        $this->testingApHttpClient->activityObjects[$this->undoInstanceBanRemoteUser['id']] = $this->undoInstanceBanRemoteUser;\n        $this->entitiesToRemoveAfterSetup[] = $activity;\n    }\n}\n"
  },
  {
    "path": "tests/Functional/ActivityPub/Inbox/CreateHandlerTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\ActivityPub\\Inbox;\n\nuse App\\Entity\\Magazine;\nuse App\\Entity\\User;\nuse App\\Enums\\EDirectMessageSettings;\nuse App\\Message\\ActivityPub\\Inbox\\ActivityMessage;\nuse App\\Tests\\Functional\\ActivityPub\\ActivityPubFunctionalTestCase;\nuse PHPUnit\\Framework\\Attributes\\Depends;\nuse PHPUnit\\Framework\\Attributes\\Group;\nuse Symfony\\Component\\Messenger\\Exception\\HandlerFailedException;\n\n#[Group(name: 'ActivityPub')]\n#[Group(name: 'NonThreadSafe')]\nclass CreateHandlerTest extends ActivityPubFunctionalTestCase\n{\n    private array $announceEntry;\n    private array $announceEntryComment;\n    private array $announcePost;\n    private array $announcePostComment;\n    private array $createEntry;\n    private array $createEntryWithUrlAndImage;\n    private array $createEntryComment;\n    private array $createPost;\n    private array $createPostComment;\n    private array $createMessage;\n    private array $createMastodonPostWithMention;\n    private array $createMastodonPostWithMentionWithoutTagArray;\n\n    public function setUpRemoteEntities(): void\n    {\n        $this->announceEntry = $this->createRemoteEntryInRemoteMagazine($this->remoteMagazine, $this->remoteUser);\n        $this->announceEntryComment = $this->createRemoteEntryCommentInRemoteMagazine($this->remoteMagazine, $this->remoteUser);\n        $this->announcePost = $this->createRemotePostInRemoteMagazine($this->remoteMagazine, $this->remoteUser);\n        $this->announcePostComment = $this->createRemotePostCommentInRemoteMagazine($this->remoteMagazine, $this->remoteUser);\n        $this->createEntry = $this->createRemoteEntryInLocalMagazine($this->localMagazine, $this->remoteUser);\n        $this->createEntryWithUrlAndImage = $this->createRemoteEntryWithUrlAndImageInLocalMagazine($this->localMagazine, $this->remoteUser);\n        $this->createEntryComment = $this->createRemoteEntryCommentInLocalMagazine($this->localMagazine, $this->remoteUser);\n        $this->createPost = $this->createRemotePostInLocalMagazine($this->localMagazine, $this->remoteUser);\n        $this->createPostComment = $this->createRemotePostCommentInLocalMagazine($this->localMagazine, $this->remoteUser);\n        $this->createMessage = $this->createRemoteMessage($this->remoteUser, $this->localUser);\n        $this->setupMastodonPost();\n        $this->setupMastodonPostWithoutTagArray();\n    }\n\n    public function setUpLocalEntities(): void\n    {\n        $this->setupRemoteActor();\n    }\n\n    public function testCreateAnnouncedEntry(): void\n    {\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->announceEntry)));\n        $entry = $this->entryRepository->findOneBy(['apId' => $this->announceEntry['object']['object']['id']]);\n        self::assertNotNull($entry);\n    }\n\n    #[Depends('testCreateAnnouncedEntry')]\n    public function testCreateAnnouncedEntryComment(): void\n    {\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->announceEntry)));\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->announceEntryComment)));\n        $entryComment = $this->entryCommentRepository->findOneBy(['apId' => $this->announceEntryComment['object']['object']['id']]);\n        self::assertNotNull($entryComment);\n    }\n\n    #[Depends('testCreateAnnouncedEntryComment')]\n    public function testCannotCreateAnnouncedEntryCommentInLockedEntry(): void\n    {\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->announceEntry)));\n        $entry = $this->entryRepository->findOneBy(['apId' => $this->announceEntry['object']['object']['id']]);\n        self::assertNotNull($entry);\n\n        $entry->isLocked = true;\n\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->announceEntryComment)));\n        $entryComment = $this->entryCommentRepository->findOneBy(['apId' => $this->announceEntryComment['object']['object']['id']]);\n        // the comment should not be created and therefore be null\n        self::assertNull($entryComment);\n    }\n\n    public function testCreateAnnouncedPost(): void\n    {\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->announcePost)));\n        $post = $this->postRepository->findOneBy(['apId' => $this->announcePost['object']['object']['id']]);\n        self::assertNotNull($post);\n    }\n\n    #[Depends('testCreateAnnouncedPost')]\n    public function testCreateAnnouncedPostComment(): void\n    {\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->announcePost)));\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->announcePostComment)));\n        $postComment = $this->postCommentRepository->findOneBy(['apId' => $this->announcePostComment['object']['object']['id']]);\n        self::assertNotNull($postComment);\n    }\n\n    #[Depends('testCreateAnnouncedPostComment')]\n    public function testCannotCreateAnnouncedPostCommentInLockedPost(): void\n    {\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->announcePost)));\n        $post = $this->postRepository->findOneBy(['apId' => $this->announcePost['object']['object']['id']]);\n        self::assertNotNull($post);\n\n        $post->isLocked = true;\n\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->announcePostComment)));\n        $postComment = $this->postCommentRepository->findOneBy(['apId' => $this->announcePostComment['object']['object']['id']]);\n        // the comment should not be created and therefore be null\n        self::assertNull($postComment);\n    }\n\n    public function testCreateEntry(): void\n    {\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->createEntry)));\n        $entry = $this->entryRepository->findOneBy(['apId' => $this->createEntry['object']['id']]);\n        self::assertNotNull($entry);\n        self::assertTrue($this->localMagazine->isSubscribed($this->remoteSubscriber));\n        $postedObjects = $this->testingApHttpClient->getPostedObjects();\n        self::assertNotEmpty($postedObjects);\n        // the id of the 'Create' activity should be wrapped in a 'Announce' activity\n        self::assertEquals($this->createEntry['id'], $postedObjects[0]['payload']['object']['id']);\n        self::assertEquals($this->createEntry['object']['id'], $postedObjects[0]['payload']['object']['object']['id']);\n        self::assertEquals($this->remoteSubscriber->apInboxUrl, $postedObjects[0]['inboxUrl']);\n    }\n\n    public function testCreateEntryWithUrlAndImage(): void\n    {\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->createEntryWithUrlAndImage)));\n        $entry = $this->entryRepository->findOneBy(['apId' => $this->createEntryWithUrlAndImage['object']['id']]);\n        self::assertNotNull($entry);\n        self::assertNotNull($entry->image);\n        self::assertNotNull($entry->url);\n        self::assertTrue($this->localMagazine->isSubscribed($this->remoteSubscriber));\n        $postedObjects = $this->testingApHttpClient->getPostedObjects();\n        self::assertNotEmpty($postedObjects);\n        // the id of the 'Create' activity should be wrapped in a 'Announce' activity\n        self::assertEquals($this->createEntryWithUrlAndImage['id'], $postedObjects[0]['payload']['object']['id']);\n        self::assertEquals($this->createEntryWithUrlAndImage['object']['id'], $postedObjects[0]['payload']['object']['object']['id']);\n        self::assertEquals($this->remoteSubscriber->apInboxUrl, $postedObjects[0]['inboxUrl']);\n    }\n\n    #[Depends('testCreateEntry')]\n    public function testCreateEntryComment(): void\n    {\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->createEntry)));\n        $entry = $this->entryRepository->findOneBy(['apId' => $this->createEntry['object']['id']]);\n        self::assertNotNull($entry);\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->createEntryComment)));\n        $entryComment = $this->entryCommentRepository->findOneBy(['apId' => $this->createEntryComment['object']['id']]);\n        self::assertNotNull($entryComment);\n        self::assertTrue($this->localMagazine->isSubscribed($this->remoteSubscriber));\n        $postedObjects = $this->testingApHttpClient->getPostedObjects();\n        self::assertCount(2, $postedObjects);\n        // the id of the 'Create' activity should be wrapped in a 'Announce' activity\n        self::assertEquals($this->createEntryComment['id'], $postedObjects[1]['payload']['object']['id']);\n        self::assertEquals($this->createEntryComment['object']['id'], $postedObjects[1]['payload']['object']['object']['id']);\n        self::assertEquals($this->remoteSubscriber->apInboxUrl, $postedObjects[1]['inboxUrl']);\n    }\n\n    public function testCreatePost(): void\n    {\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->createPost)));\n        $post = $this->postRepository->findOneBy(['apId' => $this->createPost['object']['id']]);\n        self::assertNotNull($post);\n        self::assertTrue($this->localMagazine->isSubscribed($this->remoteSubscriber));\n        $postedObjects = $this->testingApHttpClient->getPostedObjects();\n        self::assertNotEmpty($postedObjects);\n        // the id of the 'Create' activity should be wrapped in a 'Announce' activity\n        self::assertEquals($this->createPost['id'], $postedObjects[0]['payload']['object']['id']);\n        self::assertEquals($this->createPost['object']['id'], $postedObjects[0]['payload']['object']['object']['id']);\n        self::assertEquals($this->remoteSubscriber->apInboxUrl, $postedObjects[0]['inboxUrl']);\n    }\n\n    #[Depends('testCreatePost')]\n    public function testCreatePostComment(): void\n    {\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->createPost)));\n        $post = $this->postRepository->findOneBy(['apId' => $this->createPost['object']['id']]);\n        self::assertNotNull($post);\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->createPostComment)));\n        $postComment = $this->postCommentRepository->findOneBy(['apId' => $this->createPostComment['object']['id']]);\n        self::assertNotNull($postComment);\n        self::assertTrue($this->localMagazine->isSubscribed($this->remoteSubscriber));\n        $postedObjects = $this->testingApHttpClient->getPostedObjects();\n        self::assertCount(2, $postedObjects);\n        // the id of the 'Create' activity should be wrapped in a 'Announce' activity\n        self::assertEquals($this->createPostComment['id'], $postedObjects[1]['payload']['object']['id']);\n        self::assertEquals($this->createPostComment['object']['id'], $postedObjects[1]['payload']['object']['object']['id']);\n        self::assertEquals($this->remoteSubscriber->apInboxUrl, $postedObjects[1]['inboxUrl']);\n    }\n\n    public function testCreateMessage(): void\n    {\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->createMessage)));\n        $message = $this->messageRepository->findOneBy(['apId' => $this->createMessage['object']['id']]);\n        self::assertNotNull($message);\n    }\n\n    public function testCreateMessageFollowersOnlyFails(): void\n    {\n        $this->localUser->directMessageSetting = EDirectMessageSettings::FollowersOnly->value;\n        self::expectException(HandlerFailedException::class);\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->createMessage)));\n    }\n\n    public function testCreateMessageFollowersOnly(): void\n    {\n        $this->localUser->directMessageSetting = EDirectMessageSettings::FollowersOnly->value;\n        $this->userManager->follow($this->remoteUser, $this->localUser);\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->createMessage)));\n        $message = $this->messageRepository->findOneBy(['apId' => $this->createMessage['object']['id']]);\n        self::assertNotNull($message);\n    }\n\n    public function testCreateMessageNobodyFails(): void\n    {\n        $this->localUser->directMessageSetting = EDirectMessageSettings::Nobody->value;\n        $this->userManager->follow($this->remoteUser, $this->localUser);\n        self::expectException(HandlerFailedException::class);\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->createMessage)));\n    }\n\n    public function testMastodonMentionInPost(): void\n    {\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->createMastodonPostWithMention)));\n        $post = $this->postRepository->findOneBy(['apId' => $this->createMastodonPostWithMention['object']['id']]);\n        self::assertNotNull($post);\n        $mentions = $this->mentionManager->extract($post->body);\n        self::assertCount(3, $mentions);\n        self::assertEquals('@someOtherUser@some.instance.tld', $mentions[0]);\n        self::assertEquals('@someUser@some.instance.tld', $mentions[1]);\n        self::assertEquals('@someMagazine@some.instance.tld', $mentions[2]);\n    }\n\n    public function testMastodonMentionInPostWithoutTagArray(): void\n    {\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->createMastodonPostWithMentionWithoutTagArray)));\n        $post = $this->postRepository->findOneBy(['apId' => $this->createMastodonPostWithMentionWithoutTagArray['object']['id']]);\n        self::assertNotNull($post);\n        $mentions = $this->mentionManager->extract($post->body);\n        self::assertCount(1, $mentions);\n        self::assertEquals('@remoteUser@remote.mbin', $mentions[0]);\n    }\n\n    private function setupRemoteActor(): void\n    {\n        $domain = 'some.instance.tld';\n        $this->switchToRemoteDomain($domain);\n\n        $user = $this->getUserByUsername('someOtherUser', addImage: false, email: 'user@some.tld');\n        $this->registerActor($user, $domain, true);\n\n        $user = $this->getUserByUsername('someUser', addImage: false, email: 'user2@some.tld');\n        $this->registerActor($user, $domain, true);\n\n        $magazine = $this->getMagazineByName('someMagazine', user: $user);\n        $this->registerActor($magazine, $domain, true);\n\n        $this->switchToLocalDomain();\n    }\n\n    private function setupMastodonPost(): void\n    {\n        $this->createMastodonPostWithMention = $this->createRemotePostInLocalMagazine($this->localMagazine, $this->remoteUser);\n        unset($this->createMastodonPostWithMention['object']['source']);\n        // this is what it would look like if a user created a post in Mastodon with just a single mention and nothing else\n        $text = '<p><span class=\"h-card\" translate=\"no\"><a href=\"https://some.instance.tld/u/someOtherUser\" class=\"u-url mention\">@<span>someOtherUser</span></a></span> <span class=\"h-card\" translate=\"no\"><a href=\"https://some.instance.tld/u/someUser\" class=\"u-url mention\">@<span>someUser</span></a></span> <span class=\"h-card\" translate=\"no\"><a href=\"https://some.instance.tld/m/someMagazine\" class=\"u-url mention\">@<span>someMagazine</span></a></span></p>';\n        $this->createMastodonPostWithMention['object']['contentMap']['en'] = $text;\n        $this->createMastodonPostWithMention['object']['content'] = $text;\n        $this->createMastodonPostWithMention['object']['tag'] = [\n            [\n                'type' => 'Mention',\n                'href' => 'https://some.instance.tld/u/someOtherUser',\n                'name' => '@someOtherUser',\n            ],\n            [\n                'type' => 'Mention',\n                'href' => 'https://some.instance.tld/u/someUser',\n                'name' => '@someUser',\n            ],\n            [\n                'type' => 'Mention',\n                'href' => 'https://some.instance.tld/m/someMagazine',\n                'name' => '@someMagazine',\n            ],\n        ];\n    }\n\n    private function setupMastodonPostWithoutTagArray(): void\n    {\n        $this->createMastodonPostWithMentionWithoutTagArray = $this->createRemotePostInLocalMagazine($this->localMagazine, $this->remoteUser);\n        unset($this->createMastodonPostWithMentionWithoutTagArray['object']['source']);\n        // this is what it would look like if a user created a post in Mastodon with just a single mention and nothing else\n        $text = '<p><span class=\"h-card\" translate=\"no\"><a href=\"https://remote.mbin/u/remoteUser\" class=\"u-url mention\">@<span>remoteUser</span></a></span>';\n        $this->createMastodonPostWithMentionWithoutTagArray['object']['contentMap']['en'] = $text;\n        $this->createMastodonPostWithMentionWithoutTagArray['object']['content'] = $text;\n    }\n\n    private function createRemoteEntryWithUrlAndImageInLocalMagazine(Magazine $magazine, User $user): array\n    {\n        $entry = $this->getEntryByTitle('remote entry with URL and image in local', url: 'https://joinmbin.org', magazine: $magazine, user: $user, image: $this->getKibbyImageDto());\n        $json = $this->pageFactory->create($entry, $this->tagLinkRepository->getTagsOfContent($entry));\n        $this->testingApHttpClient->activityObjects[$json['id']] = $json;\n\n        $createActivity = $this->createWrapper->build($entry);\n        $create = $this->activityJsonBuilder->buildActivityJson($createActivity);\n        $this->testingApHttpClient->activityObjects[$create['id']] = $create;\n\n        $create = $this->RewriteTargetFieldsToLocal($magazine, $create);\n\n        $this->entitiesToRemoveAfterSetup[] = $createActivity;\n        $this->entitiesToRemoveAfterSetup[] = $entry;\n\n        return $create;\n    }\n}\n"
  },
  {
    "path": "tests/Functional/ActivityPub/Inbox/DeleteHandlerTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\ActivityPub\\Inbox;\n\nuse App\\DTO\\ModeratorDto;\nuse App\\Entity\\Entry;\nuse App\\Entity\\EntryComment;\nuse App\\Entity\\Magazine;\nuse App\\Entity\\Post;\nuse App\\Entity\\PostComment;\nuse App\\Entity\\User;\nuse App\\Message\\ActivityPub\\Inbox\\ActivityMessage;\nuse App\\Tests\\Functional\\ActivityPub\\ActivityPubFunctionalTestCase;\nuse PHPUnit\\Framework\\Attributes\\Group;\n\nuse function PHPUnit\\Framework\\assertNotNull;\n\n#[Group(name: 'ActivityPub')]\n#[Group(name: 'NonThreadSafe')]\nclass DeleteHandlerTest extends ActivityPubFunctionalTestCase\n{\n    private array $createRemoteEntryInLocalMagazine;\n    private array $deleteRemoteEntryByRemoteModeratorInLocalMagazine;\n    private array $createRemoteEntryInRemoteMagazine;\n    private array $deleteRemoteEntryByRemoteModeratorInRemoteMagazine;\n    private array $createRemoteEntryCommentInLocalMagazine;\n    private array $deleteRemoteEntryCommentByRemoteModeratorInLocalMagazine;\n    private array $createRemoteEntryCommentInRemoteMagazine;\n    private array $deleteRemoteEntryCommentByRemoteModeratorInRemoteMagazine;\n    private array $createRemotePostInLocalMagazine;\n    private array $deleteRemotePostByRemoteModeratorInLocalMagazine;\n    private array $createRemotePostInRemoteMagazine;\n    private array $deleteRemotePostByRemoteModeratorInRemoteMagazine;\n    private array $createRemotePostCommentInLocalMagazine;\n    private array $deleteRemotePostCommentByRemoteModeratorInLocalMagazine;\n    private array $createRemotePostCommentInRemoteMagazine;\n    private array $deleteRemotePostCommentByRemoteModeratorInRemoteMagazine;\n\n    private User $remotePoster;\n\n    public function testDeleteLocalEntryInLocalMagazineByRemoteModerator(): void\n    {\n        $obj = $this->createLocalEntryAndCreateDeleteActivity($this->localMagazine, $this->localUser, $this->remoteUser);\n        $activity = $obj['activity'];\n        $entry = $obj['content'];\n        $this->bus->dispatch(new ActivityMessage(json_encode($activity)));\n        $this->entityManager->refresh($entry);\n        self::assertTrue($entry->isTrashed());\n        $this->assertOneSentAnnouncedActivityOfType('Delete', $activity['id']);\n    }\n\n    public function testDeleteLocalEntryInRemoteMagazineByRemoteModerator(): void\n    {\n        $obj = $this->createLocalEntryAndCreateDeleteActivity($this->remoteMagazine, $this->localUser, $this->remoteUser);\n        $activity = $obj['activity'];\n        $entry = $obj['content'];\n        $this->bus->dispatch(new ActivityMessage(json_encode($activity)));\n        $this->entityManager->refresh($entry);\n        self::assertTrue($entry->isTrashed());\n        $this->assertCountOfSentActivitiesOfType(0, 'Delete');\n        $this->assertOneSentAnnouncedActivityOfType('Delete', $activity['id']);\n    }\n\n    public function testDeleteRemoteEntryInLocalMagazineByRemoteModerator(): void\n    {\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->createRemoteEntryInLocalMagazine)));\n        $entryApId = $this->createRemoteEntryInLocalMagazine['object']['id'];\n        $entry = $this->entryRepository->findOneBy(['apId' => $entryApId]);\n        self::assertNotNull($entry);\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->deleteRemoteEntryByRemoteModeratorInLocalMagazine)));\n        $entry = $this->entryRepository->findOneBy(['apId' => $entryApId]);\n        self::assertTrue($entry->isTrashed());\n        $this->assertOneSentAnnouncedActivityOfType('Delete', $this->deleteRemoteEntryByRemoteModeratorInLocalMagazine['id']);\n    }\n\n    public function testDeleteRemoteEntryInRemoteMagazineByRemoteModerator(): void\n    {\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->createRemoteEntryInRemoteMagazine)));\n        $entryApId = $this->createRemoteEntryInRemoteMagazine['object']['object']['id'];\n        $entry = $this->entryRepository->findOneBy(['apId' => $entryApId]);\n        self::assertNotNull($entry);\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->deleteRemoteEntryByRemoteModeratorInRemoteMagazine)));\n        $entry = $this->entryRepository->findOneBy(['apId' => $entryApId]);\n        self::assertTrue($entry->isTrashed());\n        $this->assertCountOfSentActivitiesOfType(0, 'Delete');\n        $this->assertCountOfSentActivitiesOfType(0, 'Announce');\n        $deleteActivities = $this->activityRepository->findBy(['type' => 'Delete']);\n        self::assertEmpty($deleteActivities);\n    }\n\n    public function testDeleteLocalEntryCommentInLocalMagazineByRemoteModerator(): void\n    {\n        $obj = $this->createLocalEntryCommentAndCreateDeleteActivity($this->localMagazine, $this->localUser, $this->remoteUser);\n        $activity = $obj['activity'];\n        $entryComment = $obj['content'];\n        $this->bus->dispatch(new ActivityMessage(json_encode($activity)));\n        $this->entityManager->refresh($entryComment);\n        self::assertTrue($entryComment->isTrashed());\n        $this->assertOneSentAnnouncedActivityOfType('Delete', $activity['id']);\n    }\n\n    public function testDeleteLocalEntryCommentInRemoteMagazineByRemoteModerator(): void\n    {\n        $obj = $this->createLocalEntryCommentAndCreateDeleteActivity($this->remoteMagazine, $this->localUser, $this->remoteUser);\n        $activity = $obj['activity'];\n        $entryComment = $obj['content'];\n        $this->bus->dispatch(new ActivityMessage(json_encode($activity)));\n        $this->entityManager->refresh($entryComment);\n        self::assertTrue($entryComment->isTrashed());\n        $this->assertCountOfSentActivitiesOfType(0, 'Delete');\n        $this->assertOneSentAnnouncedActivityOfType('Delete', $activity['id']);\n    }\n\n    public function testDeleteRemoteEntryCommentInLocalMagazineByRemoteModerator(): void\n    {\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->createRemoteEntryInLocalMagazine)));\n        $entryApId = $this->createRemoteEntryInLocalMagazine['object']['id'];\n        $entry = $this->entryRepository->findOneBy(['apId' => $entryApId]);\n        assertNotNull($entry);\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->createRemoteEntryCommentInLocalMagazine)));\n        $entryCommentApId = $this->createRemoteEntryCommentInLocalMagazine['object']['id'];\n        $entryComment = $this->entryCommentRepository->findOneBy(['apId' => $entryCommentApId]);\n        self::assertNotNull($entryComment);\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->deleteRemoteEntryCommentByRemoteModeratorInLocalMagazine)));\n        $entryComment = $this->entryCommentRepository->findOneBy(['apId' => $entryCommentApId]);\n        self::assertTrue($entryComment->isTrashed());\n        $this->assertOneSentAnnouncedActivityOfType('Delete', $this->deleteRemoteEntryCommentByRemoteModeratorInLocalMagazine['id']);\n    }\n\n    public function testDeleteRemoteEntryCommentInRemoteMagazineByRemoteModerator(): void\n    {\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->createRemoteEntryInRemoteMagazine)));\n        $entryApId = $this->createRemoteEntryInRemoteMagazine['object']['object']['id'];\n        $entry = $this->entryRepository->findOneBy(['apId' => $entryApId]);\n        self::assertNotNull($entry);\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->createRemoteEntryCommentInRemoteMagazine)));\n        $entryCommentApId = $this->createRemoteEntryCommentInRemoteMagazine['object']['object']['id'];\n        $entryComment = $this->entryCommentRepository->findOneBy(['apId' => $entryCommentApId]);\n        self::assertNotNull($entryComment);\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->deleteRemoteEntryCommentByRemoteModeratorInRemoteMagazine)));\n        $entryComment = $this->entryCommentRepository->findOneBy(['apId' => $entryCommentApId]);\n        self::assertTrue($entryComment->isTrashed());\n        $this->assertCountOfSentActivitiesOfType(0, 'Delete');\n        $this->assertCountOfSentActivitiesOfType(0, 'Announce');\n        $deleteActivities = $this->activityRepository->findBy(['type' => 'Delete']);\n        self::assertEmpty($deleteActivities);\n    }\n\n    public function testDeleteLocalPostInLocalMagazineByRemoteModerator(): void\n    {\n        $obj = $this->createLocalPostAndCreateDeleteActivity($this->localMagazine, $this->localUser, $this->remoteUser);\n        $activity = $obj['activity'];\n        $post = $obj['content'];\n        $this->bus->dispatch(new ActivityMessage(json_encode($activity)));\n        $this->entityManager->refresh($post);\n        self::assertTrue($post->isTrashed());\n        $this->assertOneSentAnnouncedActivityOfType('Delete', $activity['id']);\n    }\n\n    public function testDeleteLocalPostInRemoteMagazineByRemoteModerator(): void\n    {\n        $obj = $this->createLocalPostAndCreateDeleteActivity($this->remoteMagazine, $this->localUser, $this->remoteUser);\n        $activity = $obj['activity'];\n        $post = $obj['content'];\n        $this->bus->dispatch(new ActivityMessage(json_encode($activity)));\n        $this->entityManager->refresh($post);\n        self::assertTrue($post->isTrashed());\n        $this->assertCountOfSentActivitiesOfType(0, 'Delete');\n        $this->assertOneSentAnnouncedActivityOfType('Delete', $activity['id']);\n    }\n\n    public function testDeleteRemotePostInLocalMagazineByRemoteModerator(): void\n    {\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->createRemotePostInLocalMagazine)));\n        $postApId = $this->createRemotePostInLocalMagazine['object']['id'];\n        $post = $this->postRepository->findOneBy(['apId' => $postApId]);\n        self::assertNotNull($post);\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->deleteRemotePostByRemoteModeratorInLocalMagazine)));\n        $post = $this->postRepository->findOneBy(['apId' => $postApId]);\n        self::assertTrue($post->isTrashed());\n        $this->assertOneSentAnnouncedActivityOfType('Delete', $this->deleteRemotePostByRemoteModeratorInLocalMagazine['id']);\n    }\n\n    public function testDeleteRemotePostInRemoteMagazineByRemoteModerator(): void\n    {\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->createRemotePostInRemoteMagazine)));\n        $postApId = $this->createRemotePostInRemoteMagazine['object']['object']['id'];\n        $post = $this->postRepository->findOneBy(['apId' => $postApId]);\n        self::assertNotNull($post);\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->deleteRemotePostByRemoteModeratorInRemoteMagazine)));\n        $post = $this->postRepository->findOneBy(['apId' => $postApId]);\n        self::assertTrue($post->isTrashed());\n        $this->assertCountOfSentActivitiesOfType(0, 'Delete');\n        $this->assertCountOfSentActivitiesOfType(0, 'Announce');\n        $deleteActivities = $this->activityRepository->findBy(['type' => 'Delete']);\n        self::assertEmpty($deleteActivities);\n    }\n\n    public function testDeleteLocalPostCommentInLocalMagazineByRemoteModerator(): void\n    {\n        $obj = $this->createLocalPostCommentAndCreateDeleteActivity($this->localMagazine, $this->localUser, $this->remoteUser);\n        $activity = $obj['activity'];\n        $PostComment = $obj['content'];\n        $this->bus->dispatch(new ActivityMessage(json_encode($activity)));\n        $this->entityManager->refresh($PostComment);\n        self::assertTrue($PostComment->isTrashed());\n        $this->assertOneSentAnnouncedActivityOfType('Delete', $activity['id']);\n    }\n\n    public function testDeleteLocalPostCommentInRemoteMagazineByRemoteModerator(): void\n    {\n        $obj = $this->createLocalPostCommentAndCreateDeleteActivity($this->remoteMagazine, $this->localUser, $this->remoteUser);\n        $activity = $obj['activity'];\n        $postComment = $obj['content'];\n        $this->bus->dispatch(new ActivityMessage(json_encode($activity)));\n        $this->entityManager->refresh($postComment);\n        self::assertTrue($postComment->isTrashed());\n        $this->assertCountOfSentActivitiesOfType(0, 'Delete');\n        $this->assertOneSentAnnouncedActivityOfType('Delete', $activity['id']);\n    }\n\n    public function testDeleteRemotePostCommentInLocalMagazineByRemoteModerator(): void\n    {\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->createRemotePostInLocalMagazine)));\n        $postApId = $this->createRemotePostInLocalMagazine['object']['id'];\n        $post = $this->postRepository->findOneBy(['apId' => $postApId]);\n        self::assertNotNull($post);\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->createRemotePostCommentInLocalMagazine)));\n        $postCommentApId = $this->createRemotePostCommentInLocalMagazine['object']['id'];\n        $postComment = $this->postCommentRepository->findOneBy(['apId' => $postCommentApId]);\n        self::assertNotNull($postComment);\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->deleteRemotePostCommentByRemoteModeratorInLocalMagazine)));\n        $postComment = $this->postCommentRepository->findOneBy(['apId' => $postCommentApId]);\n        self::assertTrue($postComment->isTrashed());\n        $this->assertOneSentAnnouncedActivityOfType('Delete', $this->deleteRemotePostCommentByRemoteModeratorInLocalMagazine['id']);\n    }\n\n    public function testDeleteRemotePostCommentInRemoteMagazineByRemoteModerator(): void\n    {\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->createRemotePostInRemoteMagazine)));\n        $postApId = $this->createRemotePostInRemoteMagazine['object']['object']['id'];\n        $post = $this->postRepository->findOneBy(['apId' => $postApId]);\n        self::assertNotNull($post);\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->createRemotePostCommentInRemoteMagazine)));\n        $postCommentApId = $this->createRemotePostCommentInRemoteMagazine['object']['object']['id'];\n        $postComment = $this->postCommentRepository->findOneBy(['apId' => $postCommentApId]);\n        self::assertNotNull($postComment);\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->deleteRemotePostCommentByRemoteModeratorInRemoteMagazine)));\n        $postComment = $this->postCommentRepository->findOneBy(['apId' => $postCommentApId]);\n        self::assertTrue($postComment->isTrashed());\n        $this->assertCountOfSentActivitiesOfType(0, 'Delete');\n        $this->assertCountOfSentActivitiesOfType(0, 'Announce');\n        $deleteActivities = $this->activityRepository->findBy(['type' => 'Delete']);\n        self::assertEmpty($deleteActivities);\n    }\n\n    public function setUp(): void\n    {\n        parent::setUp();\n        $this->magazineManager->addModerator(new ModeratorDto($this->remoteMagazine, $this->remoteUser));\n        $this->magazineManager->addModerator(new ModeratorDto($this->remoteMagazine, $this->localUser));\n        $this->magazineManager->addModerator(new ModeratorDto($this->localMagazine, $this->remoteUser, $this->localMagazine->getOwner()));\n        $this->magazineManager->subscribe($this->remoteMagazine, $this->remoteSubscriber);\n    }\n\n    protected function setUpRemoteActors(): void\n    {\n        parent::setUpRemoteActors();\n        $username = 'remotePoster';\n        $domain = $this->remoteDomain;\n        $this->remotePoster = $this->getUserByUsername($username, addImage: false);\n        $this->registerActor($this->remotePoster, $domain, true);\n    }\n\n    public function setUpRemoteEntities(): void\n    {\n        $this->createRemoteEntryInRemoteMagazine = $this->createRemoteEntryInRemoteMagazine($this->remoteMagazine, $this->remotePoster, fn ($entry) => $this->createDeletesFromRemoteEntryInRemoteMagazine($entry));\n        $this->createRemoteEntryCommentInRemoteMagazine = $this->createRemoteEntryCommentInRemoteMagazine($this->remoteMagazine, $this->remotePoster, fn ($entryComment) => $this->createDeletesFromRemoteEntryCommentInRemoteMagazine($entryComment));\n        $this->createRemotePostInRemoteMagazine = $this->createRemotePostInRemoteMagazine($this->remoteMagazine, $this->remotePoster, fn ($post) => $this->createDeletesFromRemotePostInRemoteMagazine($post));\n        $this->createRemotePostCommentInRemoteMagazine = $this->createRemotePostCommentInRemoteMagazine($this->remoteMagazine, $this->remotePoster, fn ($comment) => $this->createDeletesFromRemotePostCommentInRemoteMagazine($comment));\n        $this->createRemoteEntryInLocalMagazine = $this->createRemoteEntryInLocalMagazine($this->localMagazine, $this->remotePoster, fn ($entry) => $this->createDeletesFromRemoteEntryInLocalMagazine($entry));\n        $this->createRemoteEntryCommentInLocalMagazine = $this->createRemoteEntryCommentInLocalMagazine($this->localMagazine, $this->remotePoster, fn ($entryComment) => $this->createDeletesFromRemoteEntryCommentInLocalMagazine($entryComment));\n        $this->createRemotePostInLocalMagazine = $this->createRemotePostInLocalMagazine($this->localMagazine, $this->remotePoster, fn ($post) => $this->createDeletesFromRemotePostInLocalMagazine($post));\n        $this->createRemotePostCommentInLocalMagazine = $this->createRemotePostCommentInLocalMagazine($this->localMagazine, $this->remotePoster, fn ($comment) => $this->createDeletesFromRemotePostCommentInLocalMagazine($comment));\n    }\n\n    private function createDeletesFromRemoteEntryInRemoteMagazine(Entry $createdEntry): void\n    {\n        $this->deleteRemoteEntryByRemoteModeratorInRemoteMagazine = $this->createDeleteForContent($createdEntry);\n    }\n\n    private function createDeletesFromRemoteEntryInLocalMagazine(Entry $createdEntry): void\n    {\n        $this->deleteRemoteEntryByRemoteModeratorInLocalMagazine = $this->createDeleteForContent($createdEntry);\n    }\n\n    private function createDeletesFromRemoteEntryCommentInRemoteMagazine(EntryComment $comment): void\n    {\n        $this->deleteRemoteEntryCommentByRemoteModeratorInRemoteMagazine = $this->createDeleteForContent($comment);\n    }\n\n    private function createDeletesFromRemoteEntryCommentInLocalMagazine(EntryComment $comment): void\n    {\n        $this->deleteRemoteEntryCommentByRemoteModeratorInLocalMagazine = $this->createDeleteForContent($comment);\n    }\n\n    private function createDeletesFromRemotePostInRemoteMagazine(Post $post): void\n    {\n        $this->deleteRemotePostByRemoteModeratorInRemoteMagazine = $this->createDeleteForContent($post);\n    }\n\n    private function createDeletesFromRemotePostInLocalMagazine(Post $ost): void\n    {\n        $this->deleteRemotePostByRemoteModeratorInLocalMagazine = $this->createDeleteForContent($ost);\n    }\n\n    private function createDeletesFromRemotePostCommentInRemoteMagazine(PostComment $comment): void\n    {\n        $this->deleteRemotePostCommentByRemoteModeratorInRemoteMagazine = $this->createDeleteForContent($comment);\n    }\n\n    private function createDeletesFromRemotePostCommentInLocalMagazine(PostComment $comment): void\n    {\n        $this->deleteRemotePostCommentByRemoteModeratorInLocalMagazine = $this->createDeleteForContent($comment);\n    }\n\n    private function createDeleteForContent(Entry|EntryComment|Post|PostComment $content): array\n    {\n        $activity = $this->deleteWrapper->build($content, $this->remoteUser);\n        $json = $this->activityJsonBuilder->buildActivityJson($activity);\n        $json['summary'] = ' ';\n\n        $this->testingApHttpClient->activityObjects[$json['id']] = $json;\n        $this->entitiesToRemoveAfterSetup[] = $activity;\n\n        return $json;\n    }\n\n    /**\n     * @return array{entry:Entry, activity: array}\n     */\n    private function createLocalEntryAndCreateDeleteActivity(Magazine $magazine, User $author, User $deletingUser): array\n    {\n        $entry = $this->getEntryByTitle('localEntry', magazine: $magazine, user: $author);\n        $entryJson = $this->pageFactory->create($entry, [], false);\n        $this->switchToRemoteDomain($this->remoteDomain);\n        $activity = $this->deleteWrapper->build($entry, $deletingUser);\n        $activityJson = $this->activityJsonBuilder->buildActivityJson($activity);\n        $activityJson['object'] = $entryJson;\n        $this->switchToLocalDomain();\n\n        $this->entityManager->remove($activity);\n\n        return [\n            'activity' => $activityJson,\n            'content' => $entry,\n        ];\n    }\n\n    /**\n     * @return array{content:EntryComment, activity: array}\n     */\n    private function createLocalEntryCommentAndCreateDeleteActivity(Magazine $magazine, User $author, User $deletingUser): array\n    {\n        $parent = $this->getEntryByTitle('localEntry', magazine: $magazine, user: $author);\n        $comment = $this->createEntryComment('localEntryComment', entry: $parent, user: $author);\n        $commentJson = $this->entryCommentNoteFactory->create($comment, []);\n        $this->switchToRemoteDomain($this->remoteDomain);\n        $activity = $this->deleteWrapper->build($comment, $deletingUser);\n        $activityJson = $this->activityJsonBuilder->buildActivityJson($activity);\n        $activityJson['object'] = $commentJson;\n        $this->switchToLocalDomain();\n\n        $this->entityManager->remove($activity);\n\n        return [\n            'activity' => $activityJson,\n            'content' => $comment,\n        ];\n    }\n\n    /**\n     * @return array{content:EntryComment, activity: array}\n     */\n    private function createLocalPostAndCreateDeleteActivity(Magazine $magazine, User $author, User $deletingUser): array\n    {\n        $post = $this->createPost('localPost', magazine: $magazine, user: $author);\n        $postJson = $this->postNoteFactory->create($post, []);\n        $this->switchToRemoteDomain($this->remoteDomain);\n        $activity = $this->deleteWrapper->build($post, $deletingUser);\n        $activityJson = $this->activityJsonBuilder->buildActivityJson($activity);\n        $activityJson['object'] = $postJson;\n        $this->switchToLocalDomain();\n\n        $this->entityManager->remove($activity);\n\n        return [\n            'activity' => $activityJson,\n            'content' => $post,\n        ];\n    }\n\n    /**\n     * @return array{content:EntryComment, activity: array}\n     */\n    private function createLocalPostCommentAndCreateDeleteActivity(Magazine $magazine, User $author, User $deletingUser): array\n    {\n        $parent = $this->createPost('localPost', magazine: $magazine, user: $author);\n        $postComment = $this->createPostComment('localPost', post: $parent, user: $author);\n        $commentJson = $this->postCommentNoteFactory->create($postComment, []);\n        $this->switchToRemoteDomain($this->remoteDomain);\n        $activity = $this->deleteWrapper->build($postComment, $deletingUser);\n        $activityJson = $this->activityJsonBuilder->buildActivityJson($activity);\n        $activityJson['object'] = $commentJson;\n        $this->switchToLocalDomain();\n\n        $this->entityManager->remove($activity);\n\n        return [\n            'activity' => $activityJson,\n            'content' => $postComment,\n        ];\n    }\n}\n"
  },
  {
    "path": "tests/Functional/ActivityPub/Inbox/DislikeHandlerTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\ActivityPub\\Inbox;\n\nuse App\\Entity\\Entry;\nuse App\\Entity\\EntryComment;\nuse App\\Entity\\Post;\nuse App\\Entity\\PostComment;\nuse App\\Message\\ActivityPub\\Inbox\\ActivityMessage;\nuse App\\Tests\\Functional\\ActivityPub\\ActivityPubFunctionalTestCase;\nuse PHPUnit\\Framework\\Attributes\\Depends;\nuse PHPUnit\\Framework\\Attributes\\Group;\n\n#[Group(name: 'ActivityPub')]\n#[Group(name: 'NonThreadSafe')]\nclass DislikeHandlerTest extends ActivityPubFunctionalTestCase\n{\n    private array $announceEntry;\n    private array $dislikeAnnounceEntry;\n    private array $undoDislikeAnnounceEntry;\n    private array $announceEntryComment;\n    private array $dislikeAnnounceEntryComment;\n    private array $undoDislikeAnnounceEntryComment;\n    private array $announcePost;\n    private array $dislikeAnnouncePost;\n    private array $undoDislikeAnnouncePost;\n    private array $announcePostComment;\n    private array $dislikeAnnouncePostComment;\n    private array $undoDislikeAnnouncePostComment;\n    private array $createEntry;\n    private array $dislikeCreateEntry;\n    private array $undoDislikeCreateEntry;\n    private array $createEntryComment;\n    private array $dislikeCreateEntryComment;\n    private array $undoDislikeCreateEntryComment;\n    private array $createPost;\n    private array $dislikeCreatePost;\n    private array $undoDislikeCreatePost;\n    private array $createPostComment;\n    private array $dislikeCreatePostComment;\n    private array $undoDislikeCreatePostComment;\n\n    public function testDislikeRemoteEntryInRemoteMagazine(): void\n    {\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->announceEntry)));\n        $entry = $this->entryRepository->findOneBy(['apId' => $this->announceEntry['object']['object']['id']]);\n        self::assertSame(0, $entry->countDownVotes());\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->dislikeAnnounceEntry)));\n        $this->entityManager->refresh($entry);\n        self::assertNotNull($entry);\n        self::assertSame(1, $entry->countDownVotes());\n    }\n\n    #[Depends('testDislikeRemoteEntryInRemoteMagazine')]\n    public function testUndoDislikeRemoteEntryInRemoteMagazine(): void\n    {\n        $this->testDislikeRemoteEntryInRemoteMagazine();\n        $entry = $this->entryRepository->findOneBy(['apId' => $this->announceEntry['object']['object']['id']]);\n        self::assertSame(1, $entry->countDownVotes());\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->undoDislikeAnnounceEntry)));\n        $this->entityManager->refresh($entry);\n        self::assertNotNull($entry);\n        self::assertSame(0, $entry->countDownVotes());\n    }\n\n    public function testDislikeRemoteEntryCommentInRemoteMagazine(): void\n    {\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->announceEntry)));\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->announceEntryComment)));\n        $comment = $this->entryCommentRepository->findOneBy(['apId' => $this->announceEntryComment['object']['object']['id']]);\n        self::assertSame(0, $comment->countDownVotes());\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->dislikeAnnounceEntryComment)));\n        $this->entityManager->refresh($comment);\n        self::assertNotNull($comment);\n        self::assertSame(1, $comment->countDownVotes());\n    }\n\n    #[Depends('testDislikeRemoteEntryCommentInRemoteMagazine')]\n    public function testUndoLikeRemoteEntryCommentInRemoteMagazine(): void\n    {\n        $this->testDislikeRemoteEntryCommentInRemoteMagazine();\n        $comment = $this->entryCommentRepository->findOneBy(['apId' => $this->announceEntryComment['object']['object']['id']]);\n        self::assertSame(1, $comment->countDownVotes());\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->undoDislikeAnnounceEntryComment)));\n        $this->entityManager->refresh($comment);\n        self::assertNotNull($comment);\n        self::assertSame(0, $comment->countDownVotes());\n    }\n\n    public function testDislikeRemotePostInRemoteMagazine(): void\n    {\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->announcePost)));\n        $post = $this->postRepository->findOneBy(['apId' => $this->announcePost['object']['object']['id']]);\n        self::assertSame(0, $post->countDownVotes());\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->dislikeAnnouncePost)));\n        $this->entityManager->refresh($post);\n        self::assertNotNull($post);\n        self::assertSame(1, $post->countDownVotes());\n    }\n\n    #[Depends('testDislikeRemotePostInRemoteMagazine')]\n    public function testUndoLikeRemotePostInRemoteMagazine(): void\n    {\n        $this->testDislikeRemotePostInRemoteMagazine();\n        $post = $this->postRepository->findOneBy(['apId' => $this->announcePost['object']['object']['id']]);\n        self::assertSame(1, $post->countDownVotes());\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->undoDislikeAnnouncePost)));\n        $this->entityManager->refresh($post);\n        self::assertNotNull($post);\n        self::assertSame(0, $post->countDownVotes());\n    }\n\n    public function testDislikeRemotePostCommentInRemoteMagazine(): void\n    {\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->announcePost)));\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->announcePostComment)));\n        $postComment = $this->postCommentRepository->findOneBy(['apId' => $this->announcePostComment['object']['object']['id']]);\n        self::assertSame(0, $postComment->countDownVotes());\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->dislikeAnnouncePostComment)));\n        $this->entityManager->refresh($postComment);\n        self::assertNotNull($postComment);\n        self::assertSame(1, $postComment->countDownVotes());\n    }\n\n    #[Depends('testDislikeRemotePostCommentInRemoteMagazine')]\n    public function testUndoLikeRemotePostCommentInRemoteMagazine(): void\n    {\n        $this->testDislikeRemotePostCommentInRemoteMagazine();\n        $postComment = $this->postCommentRepository->findOneBy(['apId' => $this->announcePostComment['object']['object']['id']]);\n        self::assertSame(1, $postComment->countDownVotes());\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->undoDislikeAnnouncePostComment)));\n        $this->entityManager->refresh($postComment);\n        self::assertNotNull($postComment);\n        self::assertSame(0, $postComment->countDownVotes());\n    }\n\n    public function testDislikeEntryInLocalMagazine(): void\n    {\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->createEntry)));\n        $entry = $this->entryRepository->findOneBy(['apId' => $this->createEntry['object']['id']]);\n        self::assertSame(0, $entry->countDownVotes());\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->dislikeCreateEntry)));\n        $this->entityManager->refresh($entry);\n        self::assertSame(1, $entry->countDownVotes());\n\n        $postedObjects = $this->testingApHttpClient->getPostedObjects();\n        self::assertNotEmpty($postedObjects);\n        $postedLikeAnnounces = array_filter($postedObjects, fn ($arr) => 'Announce' === $arr['payload']['type'] && 'Dislike' === $arr['payload']['object']['type']);\n        $postedLikeAnnounce = $postedLikeAnnounces[array_key_first($postedLikeAnnounces)];\n        // the id of the 'Dislike' activity should be wrapped in an 'Announce' activity\n        self::assertEquals($this->dislikeCreateEntry['id'], $postedLikeAnnounce['payload']['object']['id']);\n        // the 'Dislike' activity has the url as the object\n        self::assertEquals($this->dislikeCreateEntry['object'], $postedLikeAnnounce['payload']['object']['object']);\n        self::assertEquals($this->remoteSubscriber->apInboxUrl, $postedLikeAnnounce['inboxUrl']);\n    }\n\n    #[Depends('testDislikeEntryInLocalMagazine')]\n    public function testUndoLikeEntryInLocalMagazine(): void\n    {\n        $this->testDislikeEntryInLocalMagazine();\n        $entry = $this->entryRepository->findOneBy(['apId' => $this->createEntry['object']['id']]);\n        self::assertSame(1, $entry->countDownVotes());\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->undoDislikeCreateEntry)));\n        $this->entityManager->refresh($entry);\n        self::assertSame(0, $entry->countDownVotes());\n\n        $postedObjects = $this->testingApHttpClient->getPostedObjects();\n        self::assertNotEmpty($postedObjects);\n        $postedUndoLikeAnnounces = array_filter($postedObjects, fn ($arr) => 'Announce' === $arr['payload']['type'] && 'Undo' === $arr['payload']['object']['type'] && 'Dislike' === $arr['payload']['object']['object']['type']);\n        $postedUndoLikeAnnounce = $postedUndoLikeAnnounces[array_key_first($postedUndoLikeAnnounces)];\n        // the id of the 'Undo' activity should be wrapped in an 'Announce' activity\n        self::assertEquals($this->undoDislikeCreateEntry['id'], $postedUndoLikeAnnounce['payload']['object']['id']);\n        // the 'Undo' activity has the 'Dislike' activity as the object\n        self::assertEquals($this->undoDislikeCreateEntry['object'], $postedUndoLikeAnnounce['payload']['object']['object']);\n        // the 'Dislike' activity has the url as the object\n        self::assertEquals($this->undoDislikeCreateEntry['object']['object'], $postedUndoLikeAnnounce['payload']['object']['object']['object']);\n        self::assertEquals($this->remoteSubscriber->apInboxUrl, $postedUndoLikeAnnounce['inboxUrl']);\n    }\n\n    public function testDislikeEntryCommentInLocalMagazine(): void\n    {\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->createEntry)));\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->createEntryComment)));\n        $entryComment = $this->entryCommentRepository->findOneBy(['apId' => $this->createEntryComment['object']['id']]);\n        self::assertNotNull($entryComment);\n        self::assertSame(0, $entryComment->countDownVotes());\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->dislikeCreateEntryComment)));\n        $this->entityManager->refresh($entryComment);\n        self::assertSame(1, $entryComment->countDownVotes());\n\n        $postedObjects = $this->testingApHttpClient->getPostedObjects();\n        self::assertNotEmpty($postedObjects);\n        $postedLikeAnnounces = array_filter($postedObjects, fn ($arr) => 'Announce' === $arr['payload']['type'] && 'Dislike' === $arr['payload']['object']['type']);\n        $postedLikeAnnounce = $postedLikeAnnounces[array_key_first($postedLikeAnnounces)];\n        // the id of the 'Dislike' activity should be wrapped in an 'Announce' activity\n        self::assertEquals($this->dislikeCreateEntryComment['id'], $postedLikeAnnounce['payload']['object']['id']);\n        // the 'Dislike' activity has the url as the object\n        self::assertEquals($this->dislikeCreateEntryComment['object'], $postedLikeAnnounce['payload']['object']['object']);\n        self::assertEquals($this->remoteSubscriber->apInboxUrl, $postedLikeAnnounce['inboxUrl']);\n    }\n\n    #[Depends('testDislikeEntryCommentInLocalMagazine')]\n    public function testUndoLikeEntryCommentInLocalMagazine(): void\n    {\n        $this->testDislikeEntryCommentInLocalMagazine();\n        $entryComment = $this->entryCommentRepository->findOneBy(['apId' => $this->createEntryComment['object']['id']]);\n        self::assertNotNull($entryComment);\n        self::assertSame(1, $entryComment->countDownVotes());\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->undoDislikeCreateEntryComment)));\n        $this->entityManager->refresh($entryComment);\n        self::assertSame(0, $entryComment->countDownVotes());\n\n        $postedObjects = $this->testingApHttpClient->getPostedObjects();\n        $postedUndoLikeAnnounces = array_filter($postedObjects, fn ($arr) => 'Announce' === $arr['payload']['type'] && 'Undo' === $arr['payload']['object']['type'] && 'Dislike' === $arr['payload']['object']['object']['type']);\n        $postedUndoLikeAnnounce = $postedUndoLikeAnnounces[array_key_first($postedUndoLikeAnnounces)];\n        // the id of the 'Undo' activity should be wrapped in an 'Announce' activity\n        self::assertEquals($this->undoDislikeCreateEntryComment['id'], $postedUndoLikeAnnounce['payload']['object']['id']);\n        // the 'Undo' activity has the 'Dislike' activity as the object\n        self::assertEquals($this->undoDislikeCreateEntryComment['object'], $postedUndoLikeAnnounce['payload']['object']['object']);\n        // the 'Dislike' activity has the url as the object\n        self::assertEquals($this->undoDislikeCreateEntryComment['object']['object'], $postedUndoLikeAnnounce['payload']['object']['object']['object']);\n        self::assertEquals($this->remoteSubscriber->apInboxUrl, $postedUndoLikeAnnounce['inboxUrl']);\n    }\n\n    public function testDislikePostInLocalMagazine(): void\n    {\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->createPost)));\n        $post = $this->postRepository->findOneBy(['apId' => $this->createPost['object']['id']]);\n        self::assertNotNull($post);\n        self::assertSame(0, $post->countDownVotes());\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->dislikeCreatePost)));\n        $this->entityManager->refresh($post);\n        self::assertSame(1, $post->countDownVotes());\n\n        $postedObjects = $this->testingApHttpClient->getPostedObjects();\n        self::assertNotEmpty($postedObjects);\n        $postedUpdateAnnounces = array_filter($postedObjects, fn ($arr) => 'Announce' === $arr['payload']['type'] && 'Dislike' === $arr['payload']['object']['type']);\n        $postedUpdateAnnounce = $postedUpdateAnnounces[array_key_first($postedUpdateAnnounces)];\n        // the id of the 'Dislike' activity should be wrapped in an 'Announce' activity\n        self::assertEquals($this->dislikeCreatePost['id'], $postedUpdateAnnounce['payload']['object']['id']);\n        // the 'Dislike' activity has the url as the object\n        self::assertEquals($this->dislikeCreatePost['object'], $postedUpdateAnnounce['payload']['object']['object']);\n        self::assertEquals($this->remoteSubscriber->apInboxUrl, $postedUpdateAnnounce['inboxUrl']);\n    }\n\n    #[Depends('testDislikePostInLocalMagazine')]\n    public function testUndoLikePostInLocalMagazine(): void\n    {\n        $this->testDislikePostInLocalMagazine();\n        $post = $this->postRepository->findOneBy(['apId' => $this->createPost['object']['id']]);\n        self::assertNotNull($post);\n        self::assertSame(1, $post->countDownVotes());\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->undoDislikeCreatePost)));\n        $this->entityManager->refresh($post);\n        self::assertSame(0, $post->countDownVotes());\n\n        $postedObjects = $this->testingApHttpClient->getPostedObjects();\n        $postedUndoLikeAnnounces = array_filter($postedObjects, fn ($arr) => 'Announce' === $arr['payload']['type'] && 'Undo' === $arr['payload']['object']['type'] && 'Dislike' === $arr['payload']['object']['object']['type']);\n        $postedUndoLikeAnnounce = $postedUndoLikeAnnounces[array_key_first($postedUndoLikeAnnounces)];\n        // the id of the 'Undo' activity should be wrapped in an 'Announce' activity\n        self::assertEquals($this->undoDislikeCreatePost['id'], $postedUndoLikeAnnounce['payload']['object']['id']);\n        // the 'Undo' activity has the 'Dislike' activity as the object\n        self::assertEquals($this->undoDislikeCreatePost['object'], $postedUndoLikeAnnounce['payload']['object']['object']);\n        // the 'Dislike' activity has the url as the object\n        self::assertEquals($this->undoDislikeCreatePost['object']['object'], $postedUndoLikeAnnounce['payload']['object']['object']['object']);\n        self::assertEquals($this->remoteSubscriber->apInboxUrl, $postedUndoLikeAnnounce['inboxUrl']);\n    }\n\n    public function testDislikePostCommentInLocalMagazine(): void\n    {\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->createPost)));\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->createPostComment)));\n        $postComment = $this->postCommentRepository->findOneBy(['apId' => $this->createPostComment['object']['id']]);\n        self::assertNotNull($postComment);\n        self::assertSame(0, $postComment->countDownVotes());\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->dislikeCreatePostComment)));\n        $this->entityManager->refresh($postComment);\n        self::assertSame(1, $postComment->countDownVotes());\n\n        $postedObjects = $this->testingApHttpClient->getPostedObjects();\n        self::assertNotEmpty($postedObjects);\n        $postedLikeAnnounces = array_filter($postedObjects, fn ($arr) => 'Announce' === $arr['payload']['type'] && 'Dislike' === $arr['payload']['object']['type']);\n        $postedLikeAnnounce = $postedLikeAnnounces[array_key_first($postedLikeAnnounces)];\n        // the id of the 'Dislike' activity should be wrapped in an 'Announce' activity\n        self::assertEquals($this->dislikeCreatePostComment['id'], $postedLikeAnnounce['payload']['object']['id']);\n        // the 'Dislike' activity has the url as the object\n        self::assertEquals($this->dislikeCreatePostComment['object'], $postedLikeAnnounce['payload']['object']['object']);\n        self::assertEquals($this->remoteSubscriber->apInboxUrl, $postedLikeAnnounce['inboxUrl']);\n    }\n\n    #[Depends('testDislikePostCommentInLocalMagazine')]\n    public function testUndoLikePostCommentInLocalMagazine(): void\n    {\n        $this->testDislikePostCommentInLocalMagazine();\n        $postComment = $this->postCommentRepository->findOneBy(['apId' => $this->createPostComment['object']['id']]);\n        self::assertNotNull($postComment);\n        self::assertSame(1, $postComment->countDownVotes());\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->undoDislikeCreatePostComment)));\n        $this->entityManager->refresh($postComment);\n        self::assertSame(0, $postComment->countDownVotes());\n\n        $postedObjects = $this->testingApHttpClient->getPostedObjects();\n        $postedUndoLikeAnnounces = array_filter($postedObjects, fn ($arr) => 'Announce' === $arr['payload']['type'] && 'Undo' === $arr['payload']['object']['type'] && 'Dislike' === $arr['payload']['object']['object']['type']);\n        $postedUndoLikeAnnounce = $postedUndoLikeAnnounces[array_key_first($postedUndoLikeAnnounces)];\n        // the id of the 'Undo' activity should be wrapped in an 'Announce' activity\n        self::assertEquals($this->undoDislikeCreatePostComment['id'], $postedUndoLikeAnnounce['payload']['object']['id']);\n        // the 'Undo' activity has the 'Dislike' activity as the object\n        self::assertEquals($this->undoDislikeCreatePostComment['object'], $postedUndoLikeAnnounce['payload']['object']['object']);\n        // the 'Dislike' activity has the url as the object\n        self::assertEquals($this->undoDislikeCreatePostComment['object']['object'], $postedUndoLikeAnnounce['payload']['object']['object']['object']);\n        self::assertEquals($this->remoteSubscriber->apInboxUrl, $postedUndoLikeAnnounce['inboxUrl']);\n    }\n\n    public function setUpRemoteEntities(): void\n    {\n        $this->announceEntry = $this->createRemoteEntryInRemoteMagazine($this->remoteMagazine, $this->remoteUser, fn (Entry $entry) => $this->buildDislikeRemoteEntryInRemoteMagazine($entry));\n        $this->announceEntryComment = $this->createRemoteEntryCommentInRemoteMagazine($this->remoteMagazine, $this->remoteUser, fn (EntryComment $comment) => $this->buildDislikeRemoteEntryCommentInRemoteMagazine($comment));\n        $this->announcePost = $this->createRemotePostInRemoteMagazine($this->remoteMagazine, $this->remoteUser, fn (Post $post) => $this->buildDislikeRemotePostInRemoteMagazine($post));\n        $this->announcePostComment = $this->createRemotePostCommentInRemoteMagazine($this->remoteMagazine, $this->remoteUser, fn (PostComment $comment) => $this->buildDislikeRemotePostCommentInRemoteMagazine($comment));\n        $this->createEntry = $this->createRemoteEntryInLocalMagazine($this->localMagazine, $this->remoteUser, fn (Entry $entry) => $this->buildDislikeRemoteEntryInLocalMagazine($entry));\n        $this->createEntryComment = $this->createRemoteEntryCommentInLocalMagazine($this->localMagazine, $this->remoteUser, fn (EntryComment $comment) => $this->buildDislikeRemoteEntryCommentInLocalMagazine($comment));\n        $this->createPost = $this->createRemotePostInLocalMagazine($this->localMagazine, $this->remoteUser, fn (Post $post) => $this->buildDislikeRemotePostInLocalMagazine($post));\n        $this->createPostComment = $this->createRemotePostCommentInLocalMagazine($this->localMagazine, $this->remoteUser, fn (PostComment $comment) => $this->buildDislikeRemotePostCommentInLocalMagazine($comment));\n    }\n\n    public function buildDislikeRemoteEntryInRemoteMagazine(Entry $entry): void\n    {\n        $likeActivity = $this->likeWrapper->build($this->remoteUser, $entry);\n        $this->dislikeAnnounceEntry = $this->activityJsonBuilder->buildActivityJson($likeActivity);\n        $undoActivity = $this->undoWrapper->build($likeActivity);\n        $this->undoDislikeAnnounceEntry = $this->activityJsonBuilder->buildActivityJson($undoActivity);\n\n        // since we do not have outgoing federation of dislikes we cheat that here so we can test our inbox federation\n        $this->dislikeAnnounceEntry['type'] = 'Dislike';\n        $this->undoDislikeAnnounceEntry['object']['type'] = 'Dislike';\n\n        $this->testingApHttpClient->activityObjects[$this->dislikeAnnounceEntry['id']] = $this->dislikeAnnounceEntry;\n        $this->entitiesToRemoveAfterSetup[] = $likeActivity;\n    }\n\n    public function buildDislikeRemoteEntryCommentInRemoteMagazine(EntryComment $comment): void\n    {\n        $likeActivity = $this->likeWrapper->build($this->remoteUser, $comment);\n        $this->dislikeAnnounceEntryComment = $this->activityJsonBuilder->buildActivityJson($likeActivity);\n        $undoActivity = $this->undoWrapper->build($likeActivity);\n        $this->undoDislikeAnnounceEntryComment = $this->activityJsonBuilder->buildActivityJson($undoActivity);\n\n        // since we do not have outgoing federation of dislikes we cheat that here so we can test our inbox federation\n        $this->dislikeAnnounceEntryComment['type'] = 'Dislike';\n        $this->undoDislikeAnnounceEntryComment['object']['type'] = 'Dislike';\n\n        $this->testingApHttpClient->activityObjects[$this->dislikeAnnounceEntryComment['id']] = $this->dislikeAnnounceEntryComment;\n        $this->entitiesToRemoveAfterSetup[] = $undoActivity;\n        $this->entitiesToRemoveAfterSetup[] = $likeActivity;\n    }\n\n    public function buildDislikeRemotePostInRemoteMagazine(Post $post): void\n    {\n        $likeActivity = $this->likeWrapper->build($this->remoteUser, $post);\n        $this->dislikeAnnouncePost = $this->activityJsonBuilder->buildActivityJson($likeActivity);\n        $undoActivity = $this->undoWrapper->build($likeActivity);\n        $this->undoDislikeAnnouncePost = $this->activityJsonBuilder->buildActivityJson($undoActivity);\n\n        // since we do not have outgoing federation of dislikes we cheat that here so we can test our inbox federation\n        $this->dislikeAnnouncePost['type'] = 'Dislike';\n        $this->undoDislikeAnnouncePost['object']['type'] = 'Dislike';\n\n        $this->testingApHttpClient->activityObjects[$this->dislikeAnnouncePost['id']] = $this->dislikeAnnouncePost;\n        $this->entitiesToRemoveAfterSetup[] = $undoActivity;\n        $this->entitiesToRemoveAfterSetup[] = $likeActivity;\n    }\n\n    public function buildDislikeRemotePostCommentInRemoteMagazine(PostComment $postComment): void\n    {\n        $likeActivity = $this->likeWrapper->build($this->remoteUser, $postComment);\n        $this->dislikeAnnouncePostComment = $this->activityJsonBuilder->buildActivityJson($likeActivity);\n        $undoActivity = $this->undoWrapper->build($likeActivity);\n        $this->undoDislikeAnnouncePostComment = $this->activityJsonBuilder->buildActivityJson($undoActivity);\n\n        // since we do not have outgoing federation of dislikes we cheat that here so we can test our inbox federation\n        $this->dislikeAnnouncePostComment['type'] = 'Dislike';\n        $this->undoDislikeAnnouncePostComment['object']['type'] = 'Dislike';\n\n        $this->testingApHttpClient->activityObjects[$this->dislikeAnnouncePostComment['id']] = $this->dislikeAnnouncePostComment;\n        $this->entitiesToRemoveAfterSetup[] = $undoActivity;\n        $this->entitiesToRemoveAfterSetup[] = $likeActivity;\n    }\n\n    public function buildDislikeRemoteEntryInLocalMagazine(Entry $entry): void\n    {\n        $likeActivity = $this->likeWrapper->build($this->remoteUser, $entry);\n        $this->dislikeCreateEntry = $this->RewriteTargetFieldsToLocal($entry->magazine, $this->activityJsonBuilder->buildActivityJson($likeActivity));\n        $undoActivity = $this->undoWrapper->build($likeActivity);\n        $this->undoDislikeCreateEntry = $this->RewriteTargetFieldsToLocal($entry->magazine, $this->activityJsonBuilder->buildActivityJson($undoActivity));\n\n        // since we do not have outgoing federation of dislikes we cheat that here so we can test our inbox federation\n        $this->dislikeCreateEntry['type'] = 'Dislike';\n        $this->undoDislikeCreateEntry['object']['type'] = 'Dislike';\n\n        $this->testingApHttpClient->activityObjects[$this->dislikeCreateEntry['id']] = $this->dislikeCreateEntry;\n        $this->entitiesToRemoveAfterSetup[] = $undoActivity;\n        $this->entitiesToRemoveAfterSetup[] = $likeActivity;\n    }\n\n    public function buildDislikeRemoteEntryCommentInLocalMagazine(EntryComment $comment): void\n    {\n        $likeActivity = $this->likeWrapper->build($this->remoteUser, $comment);\n        $this->dislikeCreateEntryComment = $this->RewriteTargetFieldsToLocal($comment->magazine, $this->activityJsonBuilder->buildActivityJson($likeActivity));\n        $undoActivity = $this->undoWrapper->build($likeActivity);\n        $this->undoDislikeCreateEntryComment = $this->RewriteTargetFieldsToLocal($comment->magazine, $this->activityJsonBuilder->buildActivityJson($undoActivity));\n\n        // since we do not have outgoing federation of dislikes we cheat that here so we can test our inbox federation\n        $this->dislikeCreateEntryComment['type'] = 'Dislike';\n        $this->undoDislikeCreateEntryComment['object']['type'] = 'Dislike';\n\n        $this->testingApHttpClient->activityObjects[$this->dislikeCreateEntryComment['id']] = $this->dislikeCreateEntryComment;\n        $this->entitiesToRemoveAfterSetup[] = $undoActivity;\n        $this->entitiesToRemoveAfterSetup[] = $likeActivity;\n    }\n\n    public function buildDislikeRemotePostInLocalMagazine(Post $post): void\n    {\n        $likeActivity = $this->likeWrapper->build($this->remoteUser, $post);\n        $this->dislikeCreatePost = $this->RewriteTargetFieldsToLocal($post->magazine, $this->activityJsonBuilder->buildActivityJson($likeActivity));\n        $undoActivity = $this->undoWrapper->build($likeActivity);\n        $this->undoDislikeCreatePost = $this->RewriteTargetFieldsToLocal($post->magazine, $this->activityJsonBuilder->buildActivityJson($undoActivity));\n\n        // since we do not have outgoing federation of dislikes we cheat that here so we can test our inbox federation\n        $this->dislikeCreatePost['type'] = 'Dislike';\n        $this->undoDislikeCreatePost['object']['type'] = 'Dislike';\n\n        $this->testingApHttpClient->activityObjects[$this->dislikeCreatePost['id']] = $this->dislikeCreatePost;\n        $this->entitiesToRemoveAfterSetup[] = $undoActivity;\n        $this->entitiesToRemoveAfterSetup[] = $likeActivity;\n    }\n\n    public function buildDislikeRemotePostCommentInLocalMagazine(PostComment $postComment): void\n    {\n        $likeActivity = $this->likeWrapper->build($this->remoteUser, $postComment);\n        $this->dislikeCreatePostComment = $this->RewriteTargetFieldsToLocal($postComment->magazine, $this->activityJsonBuilder->buildActivityJson($likeActivity));\n        $undoActivity = $this->undoWrapper->build($likeActivity);\n        $this->undoDislikeCreatePostComment = $this->RewriteTargetFieldsToLocal($postComment->magazine, $this->activityJsonBuilder->buildActivityJson($undoActivity));\n\n        // since we do not have outgoing federation of dislikes we cheat that here so we can test our inbox federation\n        $this->dislikeCreatePostComment['type'] = 'Dislike';\n        $this->undoDislikeCreatePostComment['object']['type'] = 'Dislike';\n\n        $this->testingApHttpClient->activityObjects[$this->dislikeCreatePostComment['id']] = $this->dislikeCreatePostComment;\n        $this->entitiesToRemoveAfterSetup[] = $undoActivity;\n        $this->entitiesToRemoveAfterSetup[] = $likeActivity;\n    }\n}\n"
  },
  {
    "path": "tests/Functional/ActivityPub/Inbox/FlagHandlerTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\ActivityPub\\Inbox;\n\nuse App\\DTO\\ReportDto;\nuse App\\Entity\\Contracts\\ReportInterface;\nuse App\\Entity\\Entry;\nuse App\\Entity\\EntryComment;\nuse App\\Entity\\Magazine;\nuse App\\Entity\\Post;\nuse App\\Entity\\PostComment;\nuse App\\Entity\\User;\nuse App\\Message\\ActivityPub\\Inbox\\ActivityMessage;\nuse App\\Tests\\Functional\\ActivityPub\\ActivityPubFunctionalTestCase;\nuse PHPUnit\\Framework\\Attributes\\Group;\n\n#[Group(name: 'ActivityPub')]\n#[Group(name: 'NonThreadSafe')]\nclass FlagHandlerTest extends ActivityPubFunctionalTestCase\n{\n    public const REASON = 'Some reason';\n    private array $announceEntry;\n    private array $flagAnnounceEntry;\n    private array $announceEntryComment;\n    private array $flagAnnounceEntryComment;\n    private array $announcePost;\n    private array $flagAnnouncePost;\n    private array $announcePostComment;\n    private array $flagAnnouncePostComment;\n    private array $createEntry;\n    private array $flagCreateEntry;\n    private array $createEntryComment;\n    private array $flagCreateEntryComment;\n    private array $createPost;\n    private array $flagCreatePost;\n    private array $createPostComment;\n    private array $flagCreatePostComment;\n\n    public function testFlagRemoteEntryInRemoteMagazine(): void\n    {\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->announceEntry)));\n        $subject = $this->entryRepository->findOneBy(['apId' => $this->announceEntry['object']['object']['id']]);\n        self::assertNotNull($subject);\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->flagAnnounceEntry)));\n        $report = $this->reportRepository->findBySubject($subject);\n        self::assertNotNull($report);\n        self::assertSame($this->remoteSubscriber->username, $report->reporting->username);\n        self::assertSame(self::REASON, $report->reason);\n    }\n\n    public function testFlagRemoteEntryCommentInRemoteMagazine(): void\n    {\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->announceEntry)));\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->announceEntryComment)));\n        $subject = $this->entryCommentRepository->findOneBy(['apId' => $this->announceEntryComment['object']['object']['id']]);\n        self::assertNotNull($subject);\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->flagAnnounceEntryComment)));\n        $report = $this->reportRepository->findBySubject($subject);\n        self::assertNotNull($report);\n        self::assertSame($this->remoteSubscriber->username, $report->reporting->username);\n        self::assertSame(self::REASON, $report->reason);\n    }\n\n    public function testFlagRemotePostInRemoteMagazine(): void\n    {\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->announcePost)));\n        $subject = $this->postRepository->findOneBy(['apId' => $this->announcePost['object']['object']['id']]);\n        self::assertNotNull($subject);\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->flagAnnouncePost)));\n        $report = $this->reportRepository->findBySubject($subject);\n        self::assertNotNull($report);\n        self::assertSame($this->remoteSubscriber->username, $report->reporting->username);\n        self::assertSame(self::REASON, $report->reason);\n    }\n\n    public function testFlagRemotePostCommentInRemoteMagazine(): void\n    {\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->announcePost)));\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->announcePostComment)));\n        $subject = $this->postCommentRepository->findOneBy(['apId' => $this->announcePostComment['object']['object']['id']]);\n        self::assertNotNull($subject);\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->flagAnnouncePostComment)));\n        $report = $this->reportRepository->findBySubject($subject);\n        self::assertNotNull($report);\n        self::assertSame($this->remoteSubscriber->username, $report->reporting->username);\n        self::assertSame(self::REASON, $report->reason);\n    }\n\n    public function testFlagRemoteEntryInLocalMagazine(): void\n    {\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->createEntry)));\n        $subject = $this->entryRepository->findOneBy(['apId' => $this->createEntry['object']['id']]);\n        self::assertNotNull($subject);\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->flagCreateEntry)));\n        $report = $this->reportRepository->findBySubject($subject);\n        self::assertNotNull($report);\n        self::assertSame($this->remoteSubscriber->username, $report->reporting->username);\n        self::assertSame(self::REASON, $report->reason);\n    }\n\n    public function testFlagRemoteEntryCommentInLocalMagazine(): void\n    {\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->createEntry)));\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->createEntryComment)));\n        $subject = $this->entryCommentRepository->findOneBy(['apId' => $this->createEntryComment['object']['id']]);\n        self::assertNotNull($subject);\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->flagCreateEntryComment)));\n        $report = $this->reportRepository->findBySubject($subject);\n        self::assertNotNull($report);\n        self::assertSame($this->remoteSubscriber->username, $report->reporting->username);\n        self::assertSame(self::REASON, $report->reason);\n    }\n\n    public function testFlagRemotePostInLocalMagazine(): void\n    {\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->createPost)));\n        $subject = $this->postRepository->findOneBy(['apId' => $this->createPost['object']['id']]);\n        self::assertNotNull($subject);\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->flagCreatePost)));\n        $report = $this->reportRepository->findBySubject($subject);\n        self::assertNotNull($report);\n        self::assertSame($this->remoteSubscriber->username, $report->reporting->username);\n        self::assertSame(self::REASON, $report->reason);\n    }\n\n    public function testFlagRemotePostCommentInLocalMagazine(): void\n    {\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->createPost)));\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->createPostComment)));\n        $subject = $this->postCommentRepository->findOneBy(['apId' => $this->createPostComment['object']['id']]);\n        self::assertNotNull($subject);\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->flagCreatePostComment)));\n        $report = $this->reportRepository->findBySubject($subject);\n        self::assertNotNull($report);\n        self::assertSame($this->remoteSubscriber->username, $report->reporting->username);\n        self::assertSame(self::REASON, $report->reason);\n    }\n\n    public function setUpRemoteEntities(): void\n    {\n        $this->announceEntry = $this->createRemoteEntryInRemoteMagazine($this->remoteMagazine, $this->remoteUser, fn (Entry $entry) => $this->buildFlagRemoteEntryInRemoteMagazine($entry));\n        $this->announceEntryComment = $this->createRemoteEntryCommentInRemoteMagazine($this->remoteMagazine, $this->remoteUser, fn (EntryComment $comment) => $this->buildFlagRemoteEntryCommentInRemoteMagazine($comment));\n        $this->announcePost = $this->createRemotePostInRemoteMagazine($this->remoteMagazine, $this->remoteUser, fn (Post $post) => $this->buildFlagRemotePostInRemoteMagazine($post));\n        $this->announcePostComment = $this->createRemotePostCommentInRemoteMagazine($this->remoteMagazine, $this->remoteUser, fn (PostComment $comment) => $this->buildFlagRemotePostCommentInRemoteMagazine($comment));\n        $this->createEntry = $this->createRemoteEntryInLocalMagazine($this->localMagazine, $this->remoteUser, fn (Entry $entry) => $this->buildFlagRemoteEntryInLocalMagazine($entry));\n        $this->createEntryComment = $this->createRemoteEntryCommentInLocalMagazine($this->localMagazine, $this->remoteUser, fn (EntryComment $comment) => $this->buildFlagRemoteEntryCommentInLocalMagazine($comment));\n        $this->createPost = $this->createRemotePostInLocalMagazine($this->localMagazine, $this->remoteUser, fn (Post $post) => $this->buildFlagRemotePostInLocalMagazine($post));\n        $this->createPostComment = $this->createRemotePostCommentInLocalMagazine($this->localMagazine, $this->remoteUser, fn (PostComment $comment) => $this->buildFlagRemotePostCommentInLocalMagazine($comment));\n    }\n\n    private function buildFlagRemoteEntryInRemoteMagazine(Entry $entry): void\n    {\n        $this->flagAnnounceEntry = $this->createFlagActivity($this->remoteSubscriber, $entry);\n    }\n\n    private function buildFlagRemoteEntryCommentInRemoteMagazine(EntryComment $comment): void\n    {\n        $this->flagAnnounceEntryComment = $this->createFlagActivity($this->remoteSubscriber, $comment);\n    }\n\n    private function buildFlagRemotePostInRemoteMagazine(Post $post): void\n    {\n        $this->flagAnnouncePost = $this->createFlagActivity($this->remoteSubscriber, $post);\n    }\n\n    private function buildFlagRemotePostCommentInRemoteMagazine(PostComment $comment): void\n    {\n        $this->flagAnnouncePostComment = $this->createFlagActivity($this->remoteSubscriber, $comment);\n    }\n\n    private function buildFlagRemoteEntryInLocalMagazine(Entry $entry): void\n    {\n        $this->flagCreateEntry = $this->createFlagActivity($this->remoteSubscriber, $entry);\n    }\n\n    private function buildFlagRemoteEntryCommentInLocalMagazine(EntryComment $comment): void\n    {\n        $this->flagCreateEntryComment = $this->createFlagActivity($this->remoteSubscriber, $comment);\n    }\n\n    private function buildFlagRemotePostInLocalMagazine(Post $post): void\n    {\n        $this->flagCreatePost = $this->createFlagActivity($this->remoteSubscriber, $post);\n    }\n\n    private function buildFlagRemotePostCommentInLocalMagazine(PostComment $comment): void\n    {\n        $this->flagCreatePostComment = $this->createFlagActivity($this->remoteSubscriber, $comment);\n    }\n\n    private function createFlagActivity(Magazine|User $user, ReportInterface $subject): array\n    {\n        $dto = new ReportDto();\n        $dto->subject = $subject;\n        $dto->reason = self::REASON;\n        $report = $this->reportManager->report($dto, $user);\n        $flagActivity = $this->flagFactory->build($report);\n        $flagActivityJson = $this->activityJsonBuilder->buildActivityJson($flagActivity);\n        $flagActivityJson['actor'] = str_replace($this->remoteDomain, $this->remoteSubDomain, $flagActivityJson['actor']);\n\n        $this->testingApHttpClient->activityObjects[$flagActivityJson['id']] = $flagActivityJson;\n        $this->entitiesToRemoveAfterSetup[] = $flagActivity;\n\n        return $flagActivityJson;\n    }\n}\n"
  },
  {
    "path": "tests/Functional/ActivityPub/Inbox/FollowHandlerTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\ActivityPub\\Inbox;\n\nuse App\\ActivityPub\\JsonRd;\nuse App\\Event\\ActivityPub\\WebfingerResponseEvent;\nuse App\\Message\\ActivityPub\\Inbox\\ActivityMessage;\nuse App\\Service\\ActivityPub\\Webfinger\\WebFingerFactory;\nuse App\\Tests\\Functional\\ActivityPub\\ActivityPubFunctionalTestCase;\nuse PHPUnit\\Framework\\Attributes\\Depends;\nuse PHPUnit\\Framework\\Attributes\\Group;\n\n#[Group(name: 'ActivityPub')]\n#[Group(name: 'NonThreadSafe')]\nclass FollowHandlerTest extends ActivityPubFunctionalTestCase\n{\n    private array $userFollowMagazine;\n    private array $undoUserFollowMagazine;\n    private array $userFollowUser;\n    private array $undoUserFollowUser;\n    private string $followUserApId;\n\n    public function setUpRemoteEntities(): void\n    {\n        $domain = $this->remoteDomain;\n        $username = 'followUser';\n        $followUser = $this->getUserByUsername('followUser');\n        $json = $this->personFactory->create($followUser);\n        $this->testingApHttpClient->actorObjects[$json['id']] = $json;\n        $this->followUserApId = $this->personFactory->getActivityPubId($followUser);\n\n        $userEvent = new WebfingerResponseEvent(new JsonRd(), \"acct:$username@$domain\", ['account' => $username]);\n        $this->eventDispatcher->dispatch($userEvent);\n        $realDomain = \\sprintf(WebFingerFactory::WEBFINGER_URL, 'https', $domain, '', \"$username@$domain\");\n        $this->testingApHttpClient->webfingerObjects[$realDomain] = $userEvent->jsonRd->toArray();\n\n        $followActivity = $this->followWrapper->build($followUser, $this->localMagazine);\n        $this->userFollowMagazine = $this->activityJsonBuilder->buildActivityJson($followActivity);\n        $apId = \"https://$this->prev/m/{$this->localMagazine->name}\";\n        $this->userFollowMagazine['object'] = $apId;\n        $this->userFollowMagazine['to'] = [$apId];\n        $this->testingApHttpClient->activityObjects[$this->userFollowMagazine['id']] = $this->userFollowMagazine;\n\n        $undoFollowActivity = $this->undoWrapper->build($followActivity);\n        $this->undoUserFollowMagazine = $this->activityJsonBuilder->buildActivityJson($undoFollowActivity);\n        $this->undoUserFollowMagazine['to'] = [$apId];\n        $this->undoUserFollowMagazine['object']['to'] = $apId;\n        $this->undoUserFollowMagazine['object']['object'] = $apId;\n        $this->testingApHttpClient->activityObjects[$this->undoUserFollowMagazine['id']] = $this->undoUserFollowMagazine;\n\n        $followActivity2 = $this->followWrapper->build($followUser, $this->localUser);\n        $this->userFollowUser = $this->activityJsonBuilder->buildActivityJson($followActivity2);\n        $apId = \"https://$this->prev/u/{$this->localUser->username}\";\n        $this->userFollowUser['object'] = $apId;\n        $this->userFollowUser['to'] = [$apId];\n        $this->testingApHttpClient->activityObjects[$this->userFollowUser['id']] = $this->userFollowUser;\n\n        $undoFollowActivity2 = $this->undoWrapper->build($followActivity2);\n        $this->undoUserFollowUser = $this->activityJsonBuilder->buildActivityJson($undoFollowActivity2);\n        $this->undoUserFollowUser['to'] = [$apId];\n        $this->undoUserFollowUser['object']['to'] = $apId;\n        $this->undoUserFollowUser['object']['object'] = $apId;\n        $this->testingApHttpClient->activityObjects[$this->undoUserFollowUser['id']] = $this->undoUserFollowUser;\n\n        $this->entitiesToRemoveAfterSetup[] = $undoFollowActivity2;\n        $this->entitiesToRemoveAfterSetup[] = $followActivity2;\n        $this->entitiesToRemoveAfterSetup[] = $undoFollowActivity;\n        $this->entitiesToRemoveAfterSetup[] = $followActivity;\n        $this->entitiesToRemoveAfterSetup[] = $followUser;\n    }\n\n    public function testUserFollowUser(): void\n    {\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->userFollowUser)));\n        $this->entityManager->refresh($this->localUser);\n        $followUser = $this->userRepository->findOneBy(['apProfileId' => $this->followUserApId]);\n        $this->entityManager->refresh($followUser);\n\n        self::assertNotNull($followUser);\n        self::assertTrue($followUser->isFollower($this->localUser));\n        self::assertTrue($followUser->isFollowing($this->localUser));\n        self::assertNotNull($this->userFollowRepository->findOneBy(['follower' => $followUser, 'following' => $this->localUser]));\n        self::assertNull($this->userFollowRepository->findOneBy(['follower' => $this->localUser, 'following' => $followUser]));\n        $postedObjects = $this->testingApHttpClient->getPostedObjects();\n        self::assertCount(1, $postedObjects);\n        self::assertEquals('Accept', $postedObjects[0]['payload']['type']);\n        self::assertEquals($followUser->apInboxUrl, $postedObjects[0]['inboxUrl']);\n        self::assertEquals($this->userFollowUser['id'], $postedObjects[0]['payload']['object']['id']);\n    }\n\n    #[Depends('testUserFollowUser')]\n    public function testUndoUserFollowUser(): void\n    {\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->userFollowUser)));\n        $followUser = $this->userRepository->findOneBy(['apProfileId' => $this->followUserApId]);\n        $this->entityManager->refresh($followUser);\n        $this->entityManager->refresh($this->localUser);\n        $prevPostedObjects = $this->testingApHttpClient->getPostedObjects();\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->undoUserFollowUser)));\n        $this->entityManager->refresh($this->localUser);\n        $this->entityManager->refresh($followUser);\n\n        self::assertNotNull($followUser);\n        self::assertFalse($followUser->isFollower($this->localUser));\n        self::assertFalse($followUser->isFollowing($this->localUser));\n        self::assertNull($this->userFollowRepository->findOneBy(['follower' => $followUser, 'following' => $this->localUser]));\n        $postedObjects = $this->testingApHttpClient->getPostedObjects();\n        self::assertEquals(0, \\sizeof($prevPostedObjects) - \\sizeof($postedObjects));\n    }\n\n    public function testUserFollowMagazine(): void\n    {\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->userFollowMagazine)));\n        $this->entityManager->refresh($this->localUser);\n        $followUser = $this->userRepository->findOneBy(['apProfileId' => $this->followUserApId]);\n        $this->entityManager->refresh($followUser);\n\n        self::assertNotNull($followUser);\n        $sub = $this->magazineSubscriptionRepository->findOneBy(['user' => $followUser, 'magazine' => $this->localMagazine]);\n        self::assertNotNull($sub);\n        $postedObjects = $this->testingApHttpClient->getPostedObjects();\n        self::assertCount(1, $postedObjects);\n        self::assertEquals('Accept', $postedObjects[0]['payload']['type']);\n        self::assertEquals($followUser->apInboxUrl, $postedObjects[0]['inboxUrl']);\n        self::assertEquals($this->userFollowMagazine['id'], $postedObjects[0]['payload']['object']['id']);\n    }\n\n    #[Depends('testUserFollowMagazine')]\n    public function testUndoUserFollowMagazine(): void\n    {\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->userFollowMagazine)));\n        $followUser = $this->userRepository->findOneBy(['apProfileId' => $this->followUserApId]);\n        $this->entityManager->refresh($followUser);\n        $this->entityManager->refresh($this->localUser);\n        $prevPostedObjects = $this->testingApHttpClient->getPostedObjects();\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->undoUserFollowMagazine)));\n        $this->entityManager->refresh($this->localUser);\n        $this->entityManager->refresh($followUser);\n\n        self::assertNotNull($followUser);\n        $sub = $this->magazineSubscriptionRepository->findOneBy(['magazine' => $this->localMagazine, 'user' => $followUser]);\n        self::assertNull($sub);\n        $postedObjects = $this->testingApHttpClient->getPostedObjects();\n        self::assertEquals(0, \\sizeof($prevPostedObjects) - \\sizeof($postedObjects));\n    }\n}\n"
  },
  {
    "path": "tests/Functional/ActivityPub/Inbox/LikeHandlerTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\ActivityPub\\Inbox;\n\nuse App\\Entity\\Entry;\nuse App\\Entity\\EntryComment;\nuse App\\Entity\\Post;\nuse App\\Entity\\PostComment;\nuse App\\Message\\ActivityPub\\Inbox\\ActivityMessage;\nuse App\\Tests\\Functional\\ActivityPub\\ActivityPubFunctionalTestCase;\nuse PHPUnit\\Framework\\Attributes\\Depends;\nuse PHPUnit\\Framework\\Attributes\\Group;\n\n#[Group(name: 'ActivityPub')]\n#[Group(name: 'NonThreadSafe')]\nclass LikeHandlerTest extends ActivityPubFunctionalTestCase\n{\n    private array $announceEntry;\n    private array $likeAnnounceEntry;\n    private array $undoLikeAnnounceEntry;\n    private array $announceEntryComment;\n    private array $likeAnnounceEntryComment;\n    private array $undoLikeAnnounceEntryComment;\n    private array $announcePost;\n    private array $likeAnnouncePost;\n    private array $undoLikeAnnouncePost;\n    private array $announcePostComment;\n    private array $likeAnnouncePostComment;\n    private array $undoLikeAnnouncePostComment;\n    private array $createEntry;\n    private array $likeCreateEntry;\n    private array $undoLikeCreateEntry;\n    private array $createEntryComment;\n    private array $likeCreateEntryComment;\n    private array $undoLikeCreateEntryComment;\n    private array $createPost;\n    private array $likeCreatePost;\n    private array $undoLikeCreatePost;\n    private array $createPostComment;\n    private array $likeCreatePostComment;\n    private array $undoLikeCreatePostComment;\n\n    public function testLikeRemoteEntryInRemoteMagazine(): void\n    {\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->announceEntry)));\n        $entry = $this->entryRepository->findOneBy(['apId' => $this->announceEntry['object']['object']['id']]);\n        self::assertSame(0, $entry->favouriteCount);\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->likeAnnounceEntry)));\n        $this->entityManager->refresh($entry);\n        self::assertNotNull($entry);\n        self::assertSame(1, $entry->favouriteCount);\n    }\n\n    #[Depends('testLikeRemoteEntryInRemoteMagazine')]\n    public function testUndoLikeRemoteEntryInRemoteMagazine(): void\n    {\n        $this->testLikeRemoteEntryInRemoteMagazine();\n        $entry = $this->entryRepository->findOneBy(['apId' => $this->announceEntry['object']['object']['id']]);\n        self::assertSame(1, $entry->favouriteCount);\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->undoLikeAnnounceEntry)));\n        $this->entityManager->refresh($entry);\n        self::assertNotNull($entry);\n        self::assertSame(0, $entry->favouriteCount);\n    }\n\n    public function testLikeRemoteEntryCommentInRemoteMagazine(): void\n    {\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->announceEntry)));\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->announceEntryComment)));\n        $comment = $this->entryCommentRepository->findOneBy(['apId' => $this->announceEntryComment['object']['object']['id']]);\n        self::assertSame(0, $comment->favouriteCount);\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->likeAnnounceEntryComment)));\n        $this->entityManager->refresh($comment);\n        self::assertNotNull($comment);\n        self::assertSame(1, $comment->favouriteCount);\n    }\n\n    #[Depends('testLikeRemoteEntryCommentInRemoteMagazine')]\n    public function testUndoLikeRemoteEntryCommentInRemoteMagazine(): void\n    {\n        $this->testLikeRemoteEntryCommentInRemoteMagazine();\n        $comment = $this->entryCommentRepository->findOneBy(['apId' => $this->announceEntryComment['object']['object']['id']]);\n        self::assertSame(1, $comment->favouriteCount);\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->undoLikeAnnounceEntryComment)));\n        $this->entityManager->refresh($comment);\n        self::assertNotNull($comment);\n        self::assertSame(0, $comment->favouriteCount);\n    }\n\n    public function testLikeRemotePostInRemoteMagazine(): void\n    {\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->announcePost)));\n        $post = $this->postRepository->findOneBy(['apId' => $this->announcePost['object']['object']['id']]);\n        self::assertSame(0, $post->favouriteCount);\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->likeAnnouncePost)));\n        $this->entityManager->refresh($post);\n        self::assertNotNull($post);\n        self::assertSame(1, $post->favouriteCount);\n    }\n\n    #[Depends('testLikeRemotePostInRemoteMagazine')]\n    public function testUndoLikeRemotePostInRemoteMagazine(): void\n    {\n        $this->testLikeRemotePostInRemoteMagazine();\n        $post = $this->postRepository->findOneBy(['apId' => $this->announcePost['object']['object']['id']]);\n        self::assertSame(1, $post->favouriteCount);\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->undoLikeAnnouncePost)));\n        $this->entityManager->refresh($post);\n        self::assertNotNull($post);\n        self::assertSame(0, $post->favouriteCount);\n    }\n\n    public function testLikeRemotePostCommentInRemoteMagazine(): void\n    {\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->announcePost)));\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->announcePostComment)));\n        $postComment = $this->postCommentRepository->findOneBy(['apId' => $this->announcePostComment['object']['object']['id']]);\n        self::assertSame(0, $postComment->favouriteCount);\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->likeAnnouncePostComment)));\n        $this->entityManager->refresh($postComment);\n        self::assertNotNull($postComment);\n        self::assertSame(1, $postComment->favouriteCount);\n    }\n\n    #[Depends('testLikeRemotePostCommentInRemoteMagazine')]\n    public function testUndoLikeRemotePostCommentInRemoteMagazine(): void\n    {\n        $this->testLikeRemotePostCommentInRemoteMagazine();\n        $postComment = $this->postCommentRepository->findOneBy(['apId' => $this->announcePostComment['object']['object']['id']]);\n        self::assertSame(1, $postComment->favouriteCount);\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->undoLikeAnnouncePostComment)));\n        $this->entityManager->refresh($postComment);\n        self::assertNotNull($postComment);\n        self::assertSame(0, $postComment->favouriteCount);\n    }\n\n    public function testLikeEntryInLocalMagazine(): void\n    {\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->createEntry)));\n        $entry = $this->entryRepository->findOneBy(['apId' => $this->createEntry['object']['id']]);\n        self::assertSame(0, $entry->favouriteCount);\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->likeCreateEntry)));\n        $this->entityManager->refresh($entry);\n        self::assertSame(1, $entry->favouriteCount);\n\n        $postedObjects = $this->testingApHttpClient->getPostedObjects();\n        self::assertNotEmpty($postedObjects);\n        $postedLikeAnnounces = array_filter($postedObjects, fn ($arr) => 'Announce' === $arr['payload']['type'] && 'Like' === $arr['payload']['object']['type']);\n        $postedLikeAnnounce = $postedLikeAnnounces[array_key_first($postedLikeAnnounces)];\n        // the id of the 'Like' activity should be wrapped in an 'Announce' activity\n        self::assertEquals($this->likeCreateEntry['id'], $postedLikeAnnounce['payload']['object']['id']);\n        // the 'Like' activity has the url as the object\n        self::assertEquals($this->likeCreateEntry['object'], $postedLikeAnnounce['payload']['object']['object']);\n        self::assertEquals($this->remoteSubscriber->apInboxUrl, $postedLikeAnnounce['inboxUrl']);\n    }\n\n    #[Depends('testLikeEntryInLocalMagazine')]\n    public function testUndoLikeEntryInLocalMagazine(): void\n    {\n        $this->testLikeEntryInLocalMagazine();\n        $entry = $this->entryRepository->findOneBy(['apId' => $this->createEntry['object']['id']]);\n        self::assertSame(1, $entry->favouriteCount);\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->undoLikeCreateEntry)));\n        $this->entityManager->refresh($entry);\n        self::assertSame(0, $entry->favouriteCount);\n\n        $postedObjects = $this->testingApHttpClient->getPostedObjects();\n        self::assertNotEmpty($postedObjects);\n        $postedUndoLikeAnnounces = array_filter($postedObjects, fn ($arr) => 'Announce' === $arr['payload']['type'] && 'Undo' === $arr['payload']['object']['type'] && 'Like' === $arr['payload']['object']['object']['type']);\n        $postedUndoLikeAnnounce = $postedUndoLikeAnnounces[array_key_first($postedUndoLikeAnnounces)];\n        // the id of the 'Undo' activity should be wrapped in an 'Announce' activity\n        self::assertEquals($this->undoLikeCreateEntry['id'], $postedUndoLikeAnnounce['payload']['object']['id']);\n        // the 'Undo' activity has the 'Like' activity as the object\n        self::assertEquals($this->undoLikeCreateEntry['object'], $postedUndoLikeAnnounce['payload']['object']['object']);\n        // the 'Like' activity has the url as the object\n        self::assertEquals($this->undoLikeCreateEntry['object']['object'], $postedUndoLikeAnnounce['payload']['object']['object']['object']);\n        self::assertEquals($this->remoteSubscriber->apInboxUrl, $postedUndoLikeAnnounce['inboxUrl']);\n    }\n\n    public function testLikeEntryCommentInLocalMagazine(): void\n    {\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->createEntry)));\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->createEntryComment)));\n        $entryComment = $this->entryCommentRepository->findOneBy(['apId' => $this->createEntryComment['object']['id']]);\n        self::assertNotNull($entryComment);\n        self::assertSame(0, $entryComment->favouriteCount);\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->likeCreateEntryComment)));\n        $this->entityManager->refresh($entryComment);\n        self::assertSame(1, $entryComment->favouriteCount);\n\n        $postedObjects = $this->testingApHttpClient->getPostedObjects();\n        self::assertNotEmpty($postedObjects);\n        $postedLikeAnnounces = array_filter($postedObjects, fn ($arr) => 'Announce' === $arr['payload']['type'] && 'Like' === $arr['payload']['object']['type']);\n        $postedLikeAnnounce = $postedLikeAnnounces[array_key_first($postedLikeAnnounces)];\n        // the id of the 'Like' activity should be wrapped in an 'Announce' activity\n        self::assertEquals($this->likeCreateEntryComment['id'], $postedLikeAnnounce['payload']['object']['id']);\n        // the 'Like' activity has the url as the object\n        self::assertEquals($this->likeCreateEntryComment['object'], $postedLikeAnnounce['payload']['object']['object']);\n        self::assertEquals($this->remoteSubscriber->apInboxUrl, $postedLikeAnnounce['inboxUrl']);\n    }\n\n    #[Depends('testLikeEntryCommentInLocalMagazine')]\n    public function testUndoLikeEntryCommentInLocalMagazine(): void\n    {\n        $this->testLikeEntryCommentInLocalMagazine();\n        $entryComment = $this->entryCommentRepository->findOneBy(['apId' => $this->createEntryComment['object']['id']]);\n        self::assertNotNull($entryComment);\n        self::assertSame(1, $entryComment->favouriteCount);\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->undoLikeCreateEntryComment)));\n        $this->entityManager->refresh($entryComment);\n        self::assertSame(0, $entryComment->favouriteCount);\n\n        $postedObjects = $this->testingApHttpClient->getPostedObjects();\n        $postedUndoLikeAnnounces = array_filter($postedObjects, fn ($arr) => 'Announce' === $arr['payload']['type'] && 'Undo' === $arr['payload']['object']['type'] && 'Like' === $arr['payload']['object']['object']['type']);\n        $postedUndoLikeAnnounce = $postedUndoLikeAnnounces[array_key_first($postedUndoLikeAnnounces)];\n        // the id of the 'Undo' activity should be wrapped in an 'Announce' activity\n        self::assertEquals($this->undoLikeCreateEntryComment['id'], $postedUndoLikeAnnounce['payload']['object']['id']);\n        // the 'Undo' activity has the 'Like' activity as the object\n        self::assertEquals($this->undoLikeCreateEntryComment['object'], $postedUndoLikeAnnounce['payload']['object']['object']);\n        // the 'Like' activity has the url as the object\n        self::assertEquals($this->undoLikeCreateEntryComment['object']['object'], $postedUndoLikeAnnounce['payload']['object']['object']['object']);\n        self::assertEquals($this->remoteSubscriber->apInboxUrl, $postedUndoLikeAnnounce['inboxUrl']);\n    }\n\n    public function testLikePostInLocalMagazine(): void\n    {\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->createPost)));\n        $post = $this->postRepository->findOneBy(['apId' => $this->createPost['object']['id']]);\n        self::assertNotNull($post);\n        self::assertSame(0, $post->favouriteCount);\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->likeCreatePost)));\n        $this->entityManager->refresh($post);\n        self::assertSame(1, $post->favouriteCount);\n\n        $postedObjects = $this->testingApHttpClient->getPostedObjects();\n        self::assertNotEmpty($postedObjects);\n        $postedUpdateAnnounces = array_filter($postedObjects, fn ($arr) => 'Announce' === $arr['payload']['type'] && 'Like' === $arr['payload']['object']['type']);\n        $postedUpdateAnnounce = $postedUpdateAnnounces[array_key_first($postedUpdateAnnounces)];\n        // the id of the 'Like' activity should be wrapped in an 'Announce' activity\n        self::assertEquals($this->likeCreatePost['id'], $postedUpdateAnnounce['payload']['object']['id']);\n        // the 'Like' activity has the url as the object\n        self::assertEquals($this->likeCreatePost['object'], $postedUpdateAnnounce['payload']['object']['object']);\n        self::assertEquals($this->remoteSubscriber->apInboxUrl, $postedUpdateAnnounce['inboxUrl']);\n    }\n\n    #[Depends('testLikePostInLocalMagazine')]\n    public function testUndoLikePostInLocalMagazine(): void\n    {\n        $this->testLikePostInLocalMagazine();\n        $post = $this->postRepository->findOneBy(['apId' => $this->createPost['object']['id']]);\n        self::assertNotNull($post);\n        self::assertSame(1, $post->favouriteCount);\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->undoLikeCreatePost)));\n        $this->entityManager->refresh($post);\n        self::assertSame(0, $post->favouriteCount);\n\n        $postedObjects = $this->testingApHttpClient->getPostedObjects();\n        $postedUndoLikeAnnounces = array_filter($postedObjects, fn ($arr) => 'Announce' === $arr['payload']['type'] && 'Undo' === $arr['payload']['object']['type'] && 'Like' === $arr['payload']['object']['object']['type']);\n        $postedUndoLikeAnnounce = $postedUndoLikeAnnounces[array_key_first($postedUndoLikeAnnounces)];\n        // the id of the 'Undo' activity should be wrapped in an 'Announce' activity\n        self::assertEquals($this->undoLikeCreatePost['id'], $postedUndoLikeAnnounce['payload']['object']['id']);\n        // the 'Undo' activity has the 'Like' activity as the object\n        self::assertEquals($this->undoLikeCreatePost['object'], $postedUndoLikeAnnounce['payload']['object']['object']);\n        // the 'Like' activity has the url as the object\n        self::assertEquals($this->undoLikeCreatePost['object']['object'], $postedUndoLikeAnnounce['payload']['object']['object']['object']);\n        self::assertEquals($this->remoteSubscriber->apInboxUrl, $postedUndoLikeAnnounce['inboxUrl']);\n    }\n\n    public function testLikePostCommentInLocalMagazine(): void\n    {\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->createPost)));\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->createPostComment)));\n        $postComment = $this->postCommentRepository->findOneBy(['apId' => $this->createPostComment['object']['id']]);\n        self::assertNotNull($postComment);\n        self::assertSame(0, $postComment->favouriteCount);\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->likeCreatePostComment)));\n        $this->entityManager->refresh($postComment);\n        self::assertSame(1, $postComment->favouriteCount);\n\n        $postedObjects = $this->testingApHttpClient->getPostedObjects();\n        self::assertNotEmpty($postedObjects);\n        $postedLikeAnnounces = array_filter($postedObjects, fn ($arr) => 'Announce' === $arr['payload']['type'] && 'Like' === $arr['payload']['object']['type']);\n        $postedLikeAnnounce = $postedLikeAnnounces[array_key_first($postedLikeAnnounces)];\n        // the id of the 'Like' activity should be wrapped in an 'Announce' activity\n        self::assertEquals($this->likeCreatePostComment['id'], $postedLikeAnnounce['payload']['object']['id']);\n        // the 'Like' activity has the url as the object\n        self::assertEquals($this->likeCreatePostComment['object'], $postedLikeAnnounce['payload']['object']['object']);\n        self::assertEquals($this->remoteSubscriber->apInboxUrl, $postedLikeAnnounce['inboxUrl']);\n    }\n\n    #[Depends('testLikePostCommentInLocalMagazine')]\n    public function testUndoLikePostCommentInLocalMagazine(): void\n    {\n        $this->testLikePostCommentInLocalMagazine();\n        $postComment = $this->postCommentRepository->findOneBy(['apId' => $this->createPostComment['object']['id']]);\n        self::assertNotNull($postComment);\n        self::assertSame(1, $postComment->favouriteCount);\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->undoLikeCreatePostComment)));\n        $this->entityManager->refresh($postComment);\n        self::assertSame(0, $postComment->favouriteCount);\n\n        $postedObjects = $this->testingApHttpClient->getPostedObjects();\n        $postedUndoLikeAnnounces = array_filter($postedObjects, fn ($arr) => 'Announce' === $arr['payload']['type'] && 'Undo' === $arr['payload']['object']['type'] && 'Like' === $arr['payload']['object']['object']['type']);\n        $postedUndoLikeAnnounce = $postedUndoLikeAnnounces[array_key_first($postedUndoLikeAnnounces)];\n        // the id of the 'Undo' activity should be wrapped in an 'Announce' activity\n        self::assertEquals($this->undoLikeCreatePostComment['id'], $postedUndoLikeAnnounce['payload']['object']['id']);\n        // the 'Undo' activity has the 'Like' activity as the object\n        self::assertEquals($this->undoLikeCreatePostComment['object'], $postedUndoLikeAnnounce['payload']['object']['object']);\n        // the 'Like' activity has the url as the object\n        self::assertEquals($this->undoLikeCreatePostComment['object']['object'], $postedUndoLikeAnnounce['payload']['object']['object']['object']);\n        self::assertEquals($this->remoteSubscriber->apInboxUrl, $postedUndoLikeAnnounce['inboxUrl']);\n    }\n\n    public function setUpRemoteEntities(): void\n    {\n        $this->announceEntry = $this->createRemoteEntryInRemoteMagazine($this->remoteMagazine, $this->remoteUser, fn (Entry $entry) => $this->buildLikeRemoteEntryInRemoteMagazine($entry));\n        $this->announceEntryComment = $this->createRemoteEntryCommentInRemoteMagazine($this->remoteMagazine, $this->remoteUser, fn (EntryComment $comment) => $this->buildLikeRemoteEntryCommentInRemoteMagazine($comment));\n        $this->announcePost = $this->createRemotePostInRemoteMagazine($this->remoteMagazine, $this->remoteUser, fn (Post $post) => $this->buildLikeRemotePostInRemoteMagazine($post));\n        $this->announcePostComment = $this->createRemotePostCommentInRemoteMagazine($this->remoteMagazine, $this->remoteUser, fn (PostComment $comment) => $this->buildLikeRemotePostCommentInRemoteMagazine($comment));\n        $this->createEntry = $this->createRemoteEntryInLocalMagazine($this->localMagazine, $this->remoteUser, fn (Entry $entry) => $this->buildLikeRemoteEntryInLocalMagazine($entry));\n        $this->createEntryComment = $this->createRemoteEntryCommentInLocalMagazine($this->localMagazine, $this->remoteUser, fn (EntryComment $comment) => $this->buildLikeRemoteEntryCommentInLocalMagazine($comment));\n        $this->createPost = $this->createRemotePostInLocalMagazine($this->localMagazine, $this->remoteUser, fn (Post $post) => $this->buildLikeRemotePostInLocalMagazine($post));\n        $this->createPostComment = $this->createRemotePostCommentInLocalMagazine($this->localMagazine, $this->remoteUser, fn (PostComment $comment) => $this->buildLikeRemotePostCommentInLocalMagazine($comment));\n    }\n\n    public function buildLikeRemoteEntryInRemoteMagazine(Entry $entry): void\n    {\n        $likeActivity = $this->likeWrapper->build($this->remoteUser, $entry);\n        $this->likeAnnounceEntry = $this->activityJsonBuilder->buildActivityJson($likeActivity);\n        $undoLikeActivity = $this->undoWrapper->build($likeActivity, $this->remoteUser);\n        $this->undoLikeAnnounceEntry = $this->activityJsonBuilder->buildActivityJson($undoLikeActivity);\n\n        $this->testingApHttpClient->activityObjects[$this->likeAnnounceEntry['id']] = $this->likeAnnounceEntry;\n        $this->testingApHttpClient->activityObjects[$this->undoLikeAnnounceEntry['id']] = $this->undoLikeAnnounceEntry;\n        $this->entitiesToRemoveAfterSetup[] = $likeActivity;\n        $this->entitiesToRemoveAfterSetup[] = $undoLikeActivity;\n    }\n\n    public function buildLikeRemoteEntryCommentInRemoteMagazine(EntryComment $comment): void\n    {\n        $likeActivity = $this->likeWrapper->build($this->remoteUser, $comment);\n        $this->likeAnnounceEntryComment = $this->activityJsonBuilder->buildActivityJson($likeActivity);\n        $undoActivity = $this->undoWrapper->build($likeActivity, $this->remoteUser);\n        $this->undoLikeAnnounceEntryComment = $this->activityJsonBuilder->buildActivityJson($undoActivity);\n\n        $this->testingApHttpClient->activityObjects[$this->likeAnnounceEntryComment['id']] = $this->likeAnnounceEntryComment;\n        $this->testingApHttpClient->activityObjects[$this->undoLikeAnnounceEntryComment['id']] = $this->undoLikeAnnounceEntryComment;\n        $this->entitiesToRemoveAfterSetup[] = $likeActivity;\n        $this->entitiesToRemoveAfterSetup[] = $undoActivity;\n    }\n\n    public function buildLikeRemotePostInRemoteMagazine(Post $post): void\n    {\n        $likeActivity = $this->likeWrapper->build($this->remoteUser, $post);\n        $this->likeAnnouncePost = $this->activityJsonBuilder->buildActivityJson($likeActivity);\n        $undoActivity = $this->undoWrapper->build($likeActivity, $this->remoteUser);\n        $this->undoLikeAnnouncePost = $this->activityJsonBuilder->buildActivityJson($undoActivity);\n\n        $this->testingApHttpClient->activityObjects[$this->likeAnnouncePost['id']] = $this->likeAnnouncePost;\n        $this->testingApHttpClient->activityObjects[$this->undoLikeAnnouncePost['id']] = $this->undoLikeAnnouncePost;\n        $this->entitiesToRemoveAfterSetup[] = $likeActivity;\n        $this->entitiesToRemoveAfterSetup[] = $undoActivity;\n    }\n\n    public function buildLikeRemotePostCommentInRemoteMagazine(PostComment $postComment): void\n    {\n        $likeActivity = $this->likeWrapper->build($this->remoteUser, $postComment);\n        $this->likeAnnouncePostComment = $this->activityJsonBuilder->buildActivityJson($likeActivity);\n        $undoActivity = $this->undoWrapper->build($likeActivity, $this->remoteUser);\n        $this->undoLikeAnnouncePostComment = $this->activityJsonBuilder->buildActivityJson($undoActivity);\n\n        $this->testingApHttpClient->activityObjects[$this->likeAnnouncePostComment['id']] = $this->likeAnnouncePostComment;\n        $this->testingApHttpClient->activityObjects[$this->undoLikeAnnouncePostComment['id']] = $this->undoLikeAnnouncePostComment;\n        $this->entitiesToRemoveAfterSetup[] = $likeActivity;\n        $this->entitiesToRemoveAfterSetup[] = $undoActivity;\n    }\n\n    public function buildLikeRemoteEntryInLocalMagazine(Entry $entry): void\n    {\n        $likeActivity = $this->likeWrapper->build($this->remoteUser, $entry);\n        $this->likeCreateEntry = $this->RewriteTargetFieldsToLocal($entry->magazine, $this->activityJsonBuilder->buildActivityJson($likeActivity));\n        $undoActivity = $this->undoWrapper->build($likeActivity, $this->remoteUser);\n        $this->undoLikeCreateEntry = $this->RewriteTargetFieldsToLocal($entry->magazine, $this->activityJsonBuilder->buildActivityJson($undoActivity));\n\n        $this->testingApHttpClient->activityObjects[$this->likeCreateEntry['id']] = $this->likeCreateEntry;\n        $this->testingApHttpClient->activityObjects[$this->undoLikeCreateEntry['id']] = $this->undoLikeCreateEntry;\n        $this->entitiesToRemoveAfterSetup[] = $likeActivity;\n        $this->entitiesToRemoveAfterSetup[] = $undoActivity;\n    }\n\n    public function buildLikeRemoteEntryCommentInLocalMagazine(EntryComment $comment): void\n    {\n        $likeActivity = $this->likeWrapper->build($this->remoteUser, $comment);\n        $this->likeCreateEntryComment = $this->RewriteTargetFieldsToLocal($comment->magazine, $this->activityJsonBuilder->buildActivityJson($likeActivity));\n        $undoActivity = $this->undoWrapper->build($likeActivity, $this->remoteUser);\n        $this->undoLikeCreateEntryComment = $this->RewriteTargetFieldsToLocal($comment->magazine, $this->activityJsonBuilder->buildActivityJson($undoActivity));\n\n        $this->testingApHttpClient->activityObjects[$this->likeCreateEntryComment['id']] = $this->likeCreateEntryComment;\n        $this->testingApHttpClient->activityObjects[$this->undoLikeCreateEntryComment['id']] = $this->undoLikeCreateEntryComment;\n        $this->entitiesToRemoveAfterSetup[] = $likeActivity;\n        $this->entitiesToRemoveAfterSetup[] = $undoActivity;\n    }\n\n    public function buildLikeRemotePostInLocalMagazine(Post $post): void\n    {\n        $likeActivity = $this->likeWrapper->build($this->remoteUser, $post);\n        $this->likeCreatePost = $this->RewriteTargetFieldsToLocal($post->magazine, $this->activityJsonBuilder->buildActivityJson($likeActivity));\n        $undoActivity = $this->undoWrapper->build($likeActivity, $this->remoteUser);\n        $this->undoLikeCreatePost = $this->RewriteTargetFieldsToLocal($post->magazine, $this->activityJsonBuilder->buildActivityJson($undoActivity));\n\n        $this->testingApHttpClient->activityObjects[$this->likeCreatePost['id']] = $this->likeCreatePost;\n        $this->testingApHttpClient->activityObjects[$this->undoLikeCreatePost['id']] = $this->undoLikeCreatePost;\n        $this->entitiesToRemoveAfterSetup[] = $likeActivity;\n        $this->entitiesToRemoveAfterSetup[] = $undoActivity;\n    }\n\n    public function buildLikeRemotePostCommentInLocalMagazine(PostComment $postComment): void\n    {\n        $likeActivity = $this->likeWrapper->build($this->remoteUser, $postComment);\n        $this->likeCreatePostComment = $this->RewriteTargetFieldsToLocal($postComment->magazine, $this->activityJsonBuilder->buildActivityJson($likeActivity));\n        $undoActivity = $this->undoWrapper->build($likeActivity, $this->remoteUser);\n        $this->undoLikeCreatePostComment = $this->RewriteTargetFieldsToLocal($postComment->magazine, $this->activityJsonBuilder->buildActivityJson($undoActivity));\n\n        $this->testingApHttpClient->activityObjects[$this->likeCreatePostComment['id']] = $this->likeCreatePostComment;\n        $this->testingApHttpClient->activityObjects[$this->undoLikeCreatePostComment['id']] = $this->undoLikeCreatePostComment;\n        $this->entitiesToRemoveAfterSetup[] = $likeActivity;\n        $this->entitiesToRemoveAfterSetup[] = $undoActivity;\n    }\n}\n"
  },
  {
    "path": "tests/Functional/ActivityPub/Inbox/LockHandlerTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\ActivityPub\\Inbox;\n\nuse App\\DTO\\ModeratorDto;\nuse App\\Entity\\Entry;\nuse App\\Entity\\Magazine;\nuse App\\Entity\\Post;\nuse App\\Entity\\User;\nuse App\\Message\\ActivityPub\\Inbox\\ActivityMessage;\nuse App\\Tests\\Functional\\ActivityPub\\ActivityPubFunctionalTestCase;\nuse PHPUnit\\Framework\\Attributes\\Group;\n\n#[Group(name: 'ActivityPub')]\n#[Group(name: 'NonThreadSafe')]\nclass LockHandlerTest extends ActivityPubFunctionalTestCase\n{\n    private array $createRemoteEntryInLocalMagazine;\n    private array $lockRemoteEntryByRemoteModeratorInLocalMagazine;\n    private array $undoLockRemoteEntryByRemoteModeratorInLocalMagazine;\n    private array $createRemoteEntryInRemoteMagazine;\n    private array $lockRemoteEntryByRemoteModeratorInRemoteMagazine;\n    private array $undoLockRemoteEntryByRemoteModeratorInRemoteMagazine;\n    private array $createRemotePostInLocalMagazine;\n    private array $lockRemotePostByRemoteModeratorInLocalMagazine;\n    private array $undoLockRemotePostByRemoteModeratorInLocalMagazine;\n    private array $createRemotePostInRemoteMagazine;\n    private array $lockRemotePostByRemoteModeratorInRemoteMagazine;\n    private array $undoLockRemotePostByRemoteModeratorInRemoteMagazine;\n\n    private User $remotePoster;\n\n    public function testLockLocalEntryInLocalMagazineByRemoteModerator(): void\n    {\n        $obj = $this->createLocalEntryAndCreateLockActivity($this->localMagazine, $this->localUser, $this->remoteUser);\n        $activity = $obj['activity'];\n        /** @var Entry $entry */\n        $entry = $obj['content'];\n        $this->bus->dispatch(new ActivityMessage(json_encode($activity)));\n        $this->entityManager->refresh($entry);\n        self::assertTrue($entry->isLocked);\n        $this->assertOneSentAnnouncedActivityOfType('Lock', $activity['id']);\n\n        $this->bus->dispatch(new ActivityMessage(json_encode($obj['undo'])));\n        $this->entityManager->refresh($entry);\n        self::assertFalse($entry->isLocked);\n        $this->assertOneSentAnnouncedActivityOfType('Undo', $obj['undo']['id']);\n    }\n\n    public function testLockLocalEntryInRemoteMagazineByRemoteModerator(): void\n    {\n        $obj = $this->createLocalEntryAndCreateLockActivity($this->remoteMagazine, $this->localUser, $this->remoteUser);\n        $activity = $obj['activity'];\n        /** @var Entry $entry */\n        $entry = $obj['content'];\n        $this->bus->dispatch(new ActivityMessage(json_encode($activity)));\n        $this->entityManager->refresh($entry);\n        self::assertTrue($entry->isLocked);\n        $this->assertCountOfSentActivitiesOfType(0, 'Lock');\n        $this->assertCountOfSentActivitiesOfType(0, 'Announce');\n\n        $this->bus->dispatch(new ActivityMessage(json_encode($obj['undo'])));\n        $this->entityManager->refresh($entry);\n        self::assertFalse($entry->isLocked);\n        $this->assertCountOfSentActivitiesOfType(0, 'Undo');\n        $this->assertCountOfSentActivitiesOfType(0, 'Announce');\n    }\n\n    public function testLockRemoteEntryInLocalMagazineByRemoteModerator(): void\n    {\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->createRemoteEntryInLocalMagazine)));\n        $entryApId = $this->createRemoteEntryInLocalMagazine['object']['id'];\n        $entry = $this->entryRepository->findOneBy(['apId' => $entryApId]);\n        self::assertNotNull($entry);\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->lockRemoteEntryByRemoteModeratorInLocalMagazine)));\n        $entry = $this->entryRepository->findOneBy(['apId' => $entryApId]);\n        self::assertTrue($entry->isLocked);\n        $this->assertOneSentAnnouncedActivityOfType('Lock', $this->lockRemoteEntryByRemoteModeratorInLocalMagazine['id']);\n\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->undoLockRemoteEntryByRemoteModeratorInLocalMagazine)));\n        $this->entityManager->refresh($entry);\n        self::assertFalse($entry->isLocked);\n        $this->assertOneSentAnnouncedActivityOfType('Undo', $this->undoLockRemoteEntryByRemoteModeratorInLocalMagazine['id']);\n    }\n\n    public function testLockRemoteEntryInRemoteMagazineByRemoteModerator(): void\n    {\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->createRemoteEntryInRemoteMagazine)));\n        $entryApId = $this->createRemoteEntryInRemoteMagazine['object']['object']['id'];\n        $entry = $this->entryRepository->findOneBy(['apId' => $entryApId]);\n        self::assertNotNull($entry);\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->lockRemoteEntryByRemoteModeratorInRemoteMagazine)));\n        $entry = $this->entryRepository->findOneBy(['apId' => $entryApId]);\n        self::assertTrue($entry->isLocked);\n        $this->assertCountOfSentActivitiesOfType(0, 'Lock');\n        $this->assertCountOfSentActivitiesOfType(0, 'Announce');\n        $lockActivities = $this->activityRepository->findBy(['type' => 'Lock']);\n        self::assertEmpty($lockActivities);\n\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->undoLockRemoteEntryByRemoteModeratorInRemoteMagazine)));\n        $this->entityManager->refresh($entry);\n        self::assertFalse($entry->isLocked);\n        $this->assertCountOfSentActivitiesOfType(0, 'Undo');\n        $this->assertCountOfSentActivitiesOfType(0, 'Announce');\n    }\n\n    public function testLockLocalPostInLocalMagazineByRemoteModerator(): void\n    {\n        $obj = $this->createLocalPostAndCreateLockActivity($this->localMagazine, $this->localUser, $this->remoteUser);\n        $activity = $obj['activity'];\n        $post = $obj['content'];\n        $this->bus->dispatch(new ActivityMessage(json_encode($activity)));\n        $this->entityManager->refresh($post);\n        self::assertTrue($post->isLocked);\n        $this->assertOneSentAnnouncedActivityOfType('Lock', $activity['id']);\n\n        $this->bus->dispatch(new ActivityMessage(json_encode($obj['undo'])));\n        $this->entityManager->refresh($post);\n        self::assertFalse($post->isLocked);\n        $this->assertOneSentAnnouncedActivityOfType('Undo', $obj['undo']['id']);\n    }\n\n    public function testLockLocalPostInRemoteMagazineByRemoteModerator(): void\n    {\n        $obj = $this->createLocalPostAndCreateLockActivity($this->remoteMagazine, $this->localUser, $this->remoteUser);\n        $activity = $obj['activity'];\n        $post = $obj['content'];\n        $this->bus->dispatch(new ActivityMessage(json_encode($activity)));\n        $this->entityManager->refresh($post);\n        self::assertTrue($post->isLocked);\n        $this->assertCountOfSentActivitiesOfType(0, 'Lock');\n        $this->assertCountOfSentActivitiesOfType(0, 'Announce');\n\n        $this->bus->dispatch(new ActivityMessage(json_encode($obj['undo'])));\n        $this->entityManager->refresh($post);\n        self::assertFalse($post->isLocked);\n        $this->assertCountOfSentActivitiesOfType(0, 'Undo');\n        $this->assertCountOfSentActivitiesOfType(0, 'Announce');\n    }\n\n    public function testLockRemotePostInLocalMagazineByRemoteModerator(): void\n    {\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->createRemotePostInLocalMagazine)));\n        $postApId = $this->createRemotePostInLocalMagazine['object']['id'];\n        $post = $this->postRepository->findOneBy(['apId' => $postApId]);\n        self::assertNotNull($post);\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->lockRemotePostByRemoteModeratorInLocalMagazine)));\n        $post = $this->postRepository->findOneBy(['apId' => $postApId]);\n        self::assertTrue($post->isLocked);\n        $this->assertOneSentAnnouncedActivityOfType('Lock', $this->lockRemotePostByRemoteModeratorInLocalMagazine['id']);\n\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->undoLockRemotePostByRemoteModeratorInLocalMagazine)));\n        $this->entityManager->refresh($post);\n        self::assertFalse($post->isLocked);\n        $this->assertOneSentAnnouncedActivityOfType('Undo', $this->undoLockRemotePostByRemoteModeratorInLocalMagazine['id']);\n    }\n\n    public function testLockRemotePostInRemoteMagazineByRemoteModerator(): void\n    {\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->createRemotePostInRemoteMagazine)));\n        $postApId = $this->createRemotePostInRemoteMagazine['object']['object']['id'];\n        $post = $this->postRepository->findOneBy(['apId' => $postApId]);\n        self::assertNotNull($post);\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->lockRemotePostByRemoteModeratorInRemoteMagazine)));\n        $post = $this->postRepository->findOneBy(['apId' => $postApId]);\n        self::assertTrue($post->isLocked);\n        $this->assertCountOfSentActivitiesOfType(0, 'Lock');\n        $this->assertCountOfSentActivitiesOfType(0, 'Announce');\n        $lockActivities = $this->activityRepository->findBy(['type' => 'Lock']);\n        self::assertEmpty($lockActivities);\n\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->undoLockRemotePostByRemoteModeratorInRemoteMagazine)));\n        $this->entityManager->refresh($post);\n        self::assertFalse($post->isLocked);\n        $this->assertCountOfSentActivitiesOfType(0, 'Undo');\n        $this->assertCountOfSentActivitiesOfType(0, 'Announce');\n    }\n\n    public function setUp(): void\n    {\n        parent::setUp();\n        $this->magazineManager->addModerator(new ModeratorDto($this->remoteMagazine, $this->remoteUser));\n        $this->magazineManager->addModerator(new ModeratorDto($this->remoteMagazine, $this->localUser));\n        $this->magazineManager->addModerator(new ModeratorDto($this->localMagazine, $this->remoteUser, $this->localMagazine->getOwner()));\n        $this->magazineManager->subscribe($this->remoteMagazine, $this->remoteSubscriber);\n    }\n\n    protected function setUpRemoteActors(): void\n    {\n        parent::setUpRemoteActors();\n        $username = 'remotePoster';\n        $domain = $this->remoteDomain;\n        $this->remotePoster = $this->getUserByUsername($username, addImage: false);\n        $this->registerActor($this->remotePoster, $domain, true);\n    }\n\n    public function setUpRemoteEntities(): void\n    {\n        $this->createRemoteEntryInRemoteMagazine = $this->createRemoteEntryInRemoteMagazine($this->remoteMagazine, $this->remotePoster, fn ($entry) => $this->createLockFromRemoteEntryInRemoteMagazine($entry));\n        $this->createRemotePostInRemoteMagazine = $this->createRemotePostInRemoteMagazine($this->remoteMagazine, $this->remotePoster, fn ($post) => $this->createLockFromRemotePostInRemoteMagazine($post));\n        $this->createRemoteEntryInLocalMagazine = $this->createRemoteEntryInLocalMagazine($this->localMagazine, $this->remotePoster, fn ($entry) => $this->createLockFromRemoteEntryInLocalMagazine($entry));\n        $this->createRemotePostInLocalMagazine = $this->createRemotePostInLocalMagazine($this->localMagazine, $this->remotePoster, fn ($post) => $this->createLockFromRemotePostInLocalMagazine($post));\n    }\n\n    private function createLockFromRemoteEntryInRemoteMagazine(Entry $createdEntry): void\n    {\n        $activities = $this->createLockAndUnlockForContent($createdEntry);\n        $this->lockRemoteEntryByRemoteModeratorInRemoteMagazine = $activities['lock'];\n        $this->undoLockRemoteEntryByRemoteModeratorInRemoteMagazine = $activities['unlock'];\n    }\n\n    private function createLockFromRemoteEntryInLocalMagazine(Entry $createdEntry): void\n    {\n        $activities = $this->createLockAndUnlockForContent($createdEntry);\n        $this->lockRemoteEntryByRemoteModeratorInLocalMagazine = $activities['lock'];\n        $this->undoLockRemoteEntryByRemoteModeratorInLocalMagazine = $activities['unlock'];\n    }\n\n    private function createLockFromRemotePostInRemoteMagazine(Post $post): void\n    {\n        $activities = $this->createLockAndUnlockForContent($post);\n        $this->lockRemotePostByRemoteModeratorInRemoteMagazine = $activities['lock'];\n        $this->undoLockRemotePostByRemoteModeratorInRemoteMagazine = $activities['unlock'];\n    }\n\n    private function createLockFromRemotePostInLocalMagazine(Post $ost): void\n    {\n        $activities = $this->createLockAndUnlockForContent($ost);\n        $this->lockRemotePostByRemoteModeratorInLocalMagazine = $activities['lock'];\n        $this->undoLockRemotePostByRemoteModeratorInLocalMagazine = $activities['unlock'];\n    }\n\n    /**\n     * @return array{lock: array, unlock: array}\n     */\n    private function createLockAndUnlockForContent(Entry|Post $content): array\n    {\n        $activity = $this->lockFactory->build($this->remoteUser, $content);\n        $lock = $this->activityJsonBuilder->buildActivityJson($activity);\n\n        $undoActivity = $this->undoWrapper->build($activity);\n        $unlock = $this->activityJsonBuilder->buildActivityJson($undoActivity);\n\n        $this->testingApHttpClient->activityObjects[$lock['id']] = $lock;\n        $this->testingApHttpClient->activityObjects[$unlock['id']] = $unlock;\n        $this->entitiesToRemoveAfterSetup[] = $activity;\n        $this->entitiesToRemoveAfterSetup[] = $undoActivity;\n\n        return [\n            'lock' => $lock,\n            'unlock' => $unlock,\n        ];\n    }\n\n    /**\n     * @return array{entry: Entry, activity: array, undo: array}\n     */\n    private function createLocalEntryAndCreateLockActivity(Magazine $magazine, User $author, User $lockingUser): array\n    {\n        $entry = $this->getEntryByTitle('localEntry', magazine: $magazine, user: $author);\n        $entryJson = $this->pageFactory->create($entry, [], false);\n        $this->switchToRemoteDomain($this->remoteDomain);\n        $activity = $this->lockFactory->build($lockingUser, $entry);\n        $activityJson = $this->activityJsonBuilder->buildActivityJson($activity);\n        $activityJson['object'] = $entryJson['id'];\n        $undoActivity = $this->undoWrapper->build($activity);\n        $undoJson = $this->activityJsonBuilder->buildActivityJson($undoActivity);\n        $undoJson['object']['object'] = $entryJson['id'];\n        $this->switchToLocalDomain();\n\n        $this->entityManager->remove($activity);\n        $this->entityManager->remove($undoActivity);\n\n        return [\n            'activity' => $activityJson,\n            'content' => $entry,\n            'undo' => $undoJson,\n        ];\n    }\n\n    /**\n     * @return array{content:Post, activity: array, undo: array}\n     */\n    private function createLocalPostAndCreateLockActivity(Magazine $magazine, User $author, User $lockingUser): array\n    {\n        $post = $this->createPost('localPost', magazine: $magazine, user: $author);\n        $postJson = $this->postNoteFactory->create($post, []);\n        $this->switchToRemoteDomain($this->remoteDomain);\n        $activity = $this->lockFactory->build($lockingUser, $post);\n        $activityJson = $this->activityJsonBuilder->buildActivityJson($activity);\n        $activityJson['object'] = $postJson['id'];\n        $undoActivity = $this->undoWrapper->build($activity);\n        $undoJson = $this->activityJsonBuilder->buildActivityJson($undoActivity);\n        $undoJson['object']['object'] = $postJson['id'];\n        $this->switchToLocalDomain();\n\n        $this->entityManager->remove($activity);\n        $this->entityManager->remove($undoActivity);\n\n        return [\n            'activity' => $activityJson,\n            'content' => $post,\n            'undo' => $undoJson,\n        ];\n    }\n}\n"
  },
  {
    "path": "tests/Functional/ActivityPub/Inbox/RemoveHandlerTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\ActivityPub\\Inbox;\n\nuse App\\DTO\\ModeratorDto;\nuse App\\Entity\\Entry;\nuse App\\Message\\ActivityPub\\Inbox\\ActivityMessage;\nuse App\\Tests\\Functional\\ActivityPub\\ActivityPubFunctionalTestCase;\nuse PHPUnit\\Framework\\Attributes\\Group;\n\n#[Group(name: 'ActivityPub')]\n#[Group(name: 'NonThreadSafe')]\nclass RemoveHandlerTest extends ActivityPubFunctionalTestCase\n{\n    private array $removeModeratorRemoteMagazine;\n\n    private array $removeModeratorLocalMagazine;\n\n    private array $createRemoteEntryInRemoteMagazine;\n\n    private array $removePinnedEntryRemoteMagazine;\n\n    private array $createRemoteEntryInLocalMagazine;\n\n    private array $removePinnedEntryLocalMagazine;\n\n    public function setUp(): void\n    {\n        parent::setUp();\n        $this->remoteMagazine = $this->activityPubManager->findActorOrCreate('!remoteMagazine@remote.mbin');\n        $this->remoteUser = $this->activityPubManager->findActorOrCreate('@remoteUser@remote.mbin');\n        // it is important that the moderators are initialized here, as they would be removed from the db if added in `setupRemoteEntries`\n        $this->magazineManager->addModerator(new ModeratorDto($this->remoteMagazine, $this->remoteUser, $this->remoteMagazine->getOwner()));\n        $this->magazineManager->addModerator(new ModeratorDto($this->localMagazine, $this->remoteUser, $this->localUser));\n        $this->magazineManager->addModerator(new ModeratorDto($this->remoteMagazine, $this->remoteSubscriber, $this->remoteMagazine->getOwner()));\n        $this->magazineManager->addModerator(new ModeratorDto($this->localMagazine, $this->remoteSubscriber, $this->localUser));\n    }\n\n    public function testRemoveModeratorInRemoteMagazine(): void\n    {\n        self::assertTrue($this->remoteMagazine->userIsModerator($this->remoteSubscriber));\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->removeModeratorRemoteMagazine)));\n        self::assertFalse($this->remoteMagazine->userIsModerator($this->remoteSubscriber));\n    }\n\n    public function testRemoveModeratorLocalMagazine(): void\n    {\n        self::assertTrue($this->localMagazine->userIsModerator($this->remoteSubscriber));\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->removeModeratorLocalMagazine)));\n        self::assertFalse($this->localMagazine->userIsModerator($this->remoteSubscriber));\n\n        $this->assertRemoveSentToSubscriber($this->removeModeratorLocalMagazine);\n    }\n\n    public function testRemovePinnedEntryInRemoteMagazine(): void\n    {\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->createRemoteEntryInRemoteMagazine)));\n        $entry = $this->entryRepository->findOneBy(['apId' => $this->createRemoteEntryInRemoteMagazine['object']['object']['id']]);\n        self::assertNotNull($entry);\n        $entry->sticky = true;\n        $this->entityManager->flush();\n\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->removePinnedEntryRemoteMagazine)));\n        $this->entityManager->refresh($entry);\n        self::assertFalse($entry->sticky);\n    }\n\n    public function testRemovePinnedEntryLocalMagazine(): void\n    {\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->createRemoteEntryInLocalMagazine)));\n        $entry = $this->entryRepository->findOneBy(['apId' => $this->createRemoteEntryInLocalMagazine['object']['id']]);\n        self::assertNotNull($entry);\n        $entry->sticky = true;\n        $this->entityManager->flush();\n\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->removePinnedEntryLocalMagazine)));\n        $this->entityManager->refresh($entry);\n        self::assertFalse($entry->sticky);\n        $this->assertRemoveSentToSubscriber($this->removePinnedEntryLocalMagazine);\n    }\n\n    public function setUpRemoteEntities(): void\n    {\n        $this->buildRemoveModeratorInRemoteMagazine();\n        $this->buildRemoveModeratorInLocalMagazine();\n        $this->createRemoteEntryInRemoteMagazine = $this->createRemoteEntryInRemoteMagazine($this->remoteMagazine, $this->remoteUser, fn (Entry $entry) => $this->buildRemovePinnedPostInRemoteMagazine($entry));\n        $this->createRemoteEntryInLocalMagazine = $this->createRemoteEntryInLocalMagazine($this->localMagazine, $this->remoteUser, fn (Entry $entry) => $this->buildRemovePinnedPostInLocalMagazine($entry));\n    }\n\n    private function buildRemoveModeratorInRemoteMagazine(): void\n    {\n        $removeActivity = $this->addRemoveFactory->buildRemoveModerator($this->remoteUser, $this->remoteSubscriber, $this->remoteMagazine);\n        $this->removeModeratorRemoteMagazine = $this->activityJsonBuilder->buildActivityJson($removeActivity);\n        $this->removeModeratorRemoteMagazine['object'] = 'https://remote.sub.mbin/u/remoteSubscriber';\n\n        $this->testingApHttpClient->activityObjects[$this->removeModeratorRemoteMagazine['id']] = $this->removeModeratorRemoteMagazine;\n        $this->entitiesToRemoveAfterSetup[] = $removeActivity;\n    }\n\n    private function buildRemoveModeratorInLocalMagazine(): void\n    {\n        $removeActivity = $this->addRemoveFactory->buildRemoveModerator($this->remoteUser, $this->remoteSubscriber, $this->localMagazine);\n        $this->removeModeratorLocalMagazine = $this->activityJsonBuilder->buildActivityJson($removeActivity);\n        $this->removeModeratorLocalMagazine['target'] = 'https://kbin.test/m/magazine/moderators';\n        $this->removeModeratorLocalMagazine['object'] = 'https://remote.sub.mbin/u/remoteSubscriber';\n\n        $this->testingApHttpClient->activityObjects[$this->removeModeratorLocalMagazine['id']] = $this->removeModeratorLocalMagazine;\n        $this->entitiesToRemoveAfterSetup[] = $removeActivity;\n    }\n\n    private function buildRemovePinnedPostInRemoteMagazine(Entry $entry): void\n    {\n        $removeActivity = $this->addRemoveFactory->buildRemovePinnedPost($this->remoteUser, $entry);\n        $this->removePinnedEntryRemoteMagazine = $this->activityJsonBuilder->buildActivityJson($removeActivity);\n\n        $this->testingApHttpClient->activityObjects[$this->removePinnedEntryRemoteMagazine['id']] = $this->removePinnedEntryRemoteMagazine;\n        $this->entitiesToRemoveAfterSetup[] = $removeActivity;\n    }\n\n    private function buildRemovePinnedPostInLocalMagazine(Entry $entry): void\n    {\n        $removeActivity = $this->addRemoveFactory->buildRemovePinnedPost($this->remoteUser, $entry);\n        $this->removePinnedEntryLocalMagazine = $this->activityJsonBuilder->buildActivityJson($removeActivity);\n        $this->removePinnedEntryLocalMagazine['target'] = 'https://kbin.test/m/magazine/pinned';\n\n        $this->testingApHttpClient->activityObjects[$this->removePinnedEntryLocalMagazine['id']] = $this->removePinnedEntryLocalMagazine;\n        $this->entitiesToRemoveAfterSetup[] = $removeActivity;\n    }\n\n    private function assertRemoveSentToSubscriber(array $originalPayload): void\n    {\n        $postedObjects = $this->testingApHttpClient->getPostedObjects();\n        self::assertNotEmpty($postedObjects);\n        $postedAddAnnounces = array_filter($postedObjects, fn ($arr) => 'Announce' === $arr['payload']['type'] && 'Remove' === $arr['payload']['object']['type']);\n        $postedAddAnnounce = $postedAddAnnounces[array_key_first($postedAddAnnounces)];\n        // the id of the 'Remove' activity should be wrapped in an 'Announce' activity\n        self::assertEquals($originalPayload['id'], $postedAddAnnounce['payload']['object']['id']);\n        self::assertEquals($originalPayload['object'], $postedAddAnnounce['payload']['object']['object']);\n        self::assertEquals($this->remoteSubscriber->apInboxUrl, $postedAddAnnounce['inboxUrl']);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/ActivityPub/Inbox/UpdateHandlerTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\ActivityPub\\Inbox;\n\nuse App\\Entity\\Entry;\nuse App\\Entity\\EntryComment;\nuse App\\Entity\\Post;\nuse App\\Entity\\PostComment;\nuse App\\Message\\ActivityPub\\Inbox\\ActivityMessage;\nuse App\\Tests\\Functional\\ActivityPub\\ActivityPubFunctionalTestCase;\nuse PHPUnit\\Framework\\Attributes\\Group;\n\n#[Group(name: 'ActivityPub')]\n#[Group(name: 'NonThreadSafe')]\nclass UpdateHandlerTest extends ActivityPubFunctionalTestCase\n{\n    private array $announceEntry;\n    private array $updateAnnounceEntry;\n    private array $announceEntryComment;\n    private array $updateAnnounceEntryComment;\n    private array $announcePost;\n    private array $updateAnnouncePost;\n    private array $announcePostComment;\n    private array $updateAnnouncePostComment;\n    private array $createEntry;\n    private array $updateCreateEntry;\n    private array $createEntryComment;\n    private array $updateCreateEntryComment;\n    private array $createPost;\n    private array $updateCreatePost;\n    private array $createPostComment;\n    private array $updateCreatePostComment;\n    private array $updateUser;\n    private array $updateMagazine;\n\n    public function testUpdateRemoteEntryInRemoteMagazine(): void\n    {\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->announceEntry)));\n        $entry = $this->entryRepository->findOneBy(['apId' => $this->announceEntry['object']['object']['id']]);\n        self::assertStringNotContainsString('update', $entry->title);\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->updateAnnounceEntry)));\n        $this->entityManager->refresh($entry);\n        self::assertNotNull($entry);\n        self::assertStringContainsString('update', $entry->title);\n        self::assertStringContainsString('update', $entry->body);\n        self::assertFalse($entry->isLocked);\n    }\n\n    public function testUpdateRemoteEntryCommentInRemoteMagazine(): void\n    {\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->announceEntry)));\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->announceEntryComment)));\n        $comment = $this->entryCommentRepository->findOneBy(['apId' => $this->announceEntryComment['object']['object']['id']]);\n        self::assertStringNotContainsString('update', $comment->body);\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->updateAnnounceEntryComment)));\n        $this->entityManager->refresh($comment);\n        self::assertNotNull($comment);\n        self::assertStringContainsString('update', $comment->body);\n    }\n\n    public function testUpdateRemotePostInRemoteMagazine(): void\n    {\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->announcePost)));\n        $post = $this->postRepository->findOneBy(['apId' => $this->announcePost['object']['object']['id']]);\n        self::assertStringNotContainsString('update', $post->body);\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->updateAnnouncePost)));\n        $this->entityManager->refresh($post);\n        self::assertNotNull($post);\n        self::assertStringContainsString('update', $post->body);\n        self::assertFalse($post->isLocked);\n    }\n\n    public function testUpdateRemotePostCommentInRemoteMagazine(): void\n    {\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->announcePost)));\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->announcePostComment)));\n        $postComment = $this->postCommentRepository->findOneBy(['apId' => $this->announcePostComment['object']['object']['id']]);\n        self::assertStringNotContainsString('update', $postComment->body);\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->updateAnnouncePostComment)));\n        $this->entityManager->refresh($postComment);\n        self::assertNotNull($postComment);\n        self::assertStringContainsString('update', $postComment->body);\n    }\n\n    public function testUpdateEntryInLocalMagazine(): void\n    {\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->createEntry)));\n        $entry = $this->entryRepository->findOneBy(['apId' => $this->createEntry['object']['id']]);\n        self::assertStringNotContainsString('update', $entry->title);\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->updateCreateEntry)));\n        self::assertStringContainsString('update', $entry->title);\n        // explicitly set in the build method\n        self::assertTrue($entry->isLocked);\n\n        $postedObjects = $this->testingApHttpClient->getPostedObjects();\n        self::assertNotEmpty($postedObjects);\n        $postedUpdateAnnounces = array_filter($postedObjects, fn ($arr) => 'Announce' === $arr['payload']['type'] && 'Update' === $arr['payload']['object']['type']);\n        $postedUpdateAnnounce = $postedUpdateAnnounces[array_key_first($postedUpdateAnnounces)];\n        // the id of the 'Update' activity should be wrapped in an 'Announce' activity\n        self::assertEquals($this->updateCreateEntry['id'], $postedUpdateAnnounce['payload']['object']['id']);\n        self::assertEquals($this->updateCreateEntry['object']['id'], $postedUpdateAnnounce['payload']['object']['object']['id']);\n        self::assertEquals($this->remoteSubscriber->apInboxUrl, $postedUpdateAnnounce['inboxUrl']);\n    }\n\n    public function testUpdateEntryCommentInLocalMagazine(): void\n    {\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->createEntry)));\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->createEntryComment)));\n        $entryComment = $this->entryCommentRepository->findOneBy(['apId' => $this->createEntryComment['object']['id']]);\n        self::assertNotNull($entryComment);\n        self::assertStringNotContainsString('update', $entryComment->body);\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->updateCreateEntryComment)));\n        self::assertStringContainsString('update', $entryComment->body);\n\n        $postedObjects = $this->testingApHttpClient->getPostedObjects();\n        self::assertNotEmpty($postedObjects);\n        $postedUpdateAnnounces = array_filter($postedObjects, fn ($arr) => 'Announce' === $arr['payload']['type'] && 'Update' === $arr['payload']['object']['type']);\n        $postedUpdateAnnounce = $postedUpdateAnnounces[array_key_first($postedUpdateAnnounces)];\n        // the id of the 'Update' activity should be wrapped in an 'Announce' activity\n        self::assertEquals($this->updateCreateEntryComment['id'], $postedUpdateAnnounce['payload']['object']['id']);\n        self::assertEquals($this->updateCreateEntryComment['object']['id'], $postedUpdateAnnounce['payload']['object']['object']['id']);\n        self::assertEquals($this->remoteSubscriber->apInboxUrl, $postedUpdateAnnounce['inboxUrl']);\n    }\n\n    public function testUpdatePostInLocalMagazine(): void\n    {\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->createPost)));\n        $post = $this->postRepository->findOneBy(['apId' => $this->createPost['object']['id']]);\n        self::assertStringNotContainsString('update', $post->body);\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->updateCreatePost)));\n        self::assertStringContainsString('update', $post->body);\n        // explicitly set in the build method\n        self::assertTrue($post->isLocked);\n\n        $postedObjects = $this->testingApHttpClient->getPostedObjects();\n        self::assertNotEmpty($postedObjects);\n        $postedUpdateAnnounces = array_filter($postedObjects, fn ($arr) => 'Announce' === $arr['payload']['type'] && 'Update' === $arr['payload']['object']['type']);\n        $postedUpdateAnnounce = $postedUpdateAnnounces[array_key_first($postedUpdateAnnounces)];\n        // the id of the 'Update' activity should be wrapped in an 'Announce' activity\n        self::assertEquals($this->updateCreatePost['id'], $postedUpdateAnnounce['payload']['object']['id']);\n        self::assertEquals($this->updateCreatePost['object']['id'], $postedUpdateAnnounce['payload']['object']['object']['id']);\n        self::assertEquals($this->remoteSubscriber->apInboxUrl, $postedUpdateAnnounce['inboxUrl']);\n    }\n\n    public function testUpdatePostCommentInLocalMagazine(): void\n    {\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->createPost)));\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->createPostComment)));\n        $postComment = $this->postCommentRepository->findOneBy(['apId' => $this->createPostComment['object']['id']]);\n        self::assertNotNull($postComment);\n        self::assertStringNotContainsString('update', $postComment->body);\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->updateCreatePostComment)));\n        self::assertStringContainsString('update', $postComment->body);\n\n        $postedObjects = $this->testingApHttpClient->getPostedObjects();\n        self::assertNotEmpty($postedObjects);\n        $postedUpdateAnnounces = array_filter($postedObjects, fn ($arr) => 'Announce' === $arr['payload']['type'] && 'Update' === $arr['payload']['object']['type']);\n        $postedUpdateAnnounce = $postedUpdateAnnounces[array_key_first($postedUpdateAnnounces)];\n        // the id of the 'Update' activity should be wrapped in an 'Announce' activity\n        self::assertEquals($this->updateCreatePostComment['id'], $postedUpdateAnnounce['payload']['object']['id']);\n        self::assertEquals($this->updateCreatePostComment['object']['id'], $postedUpdateAnnounce['payload']['object']['object']['id']);\n        self::assertEquals($this->remoteSubscriber->apInboxUrl, $postedUpdateAnnounce['inboxUrl']);\n    }\n\n    public function testUpdateRemoteUser(): void\n    {\n        // an update activity forces to fetch the remote object again -> rewrite the actor id to the updated object from the activity\n        $this->testingApHttpClient->actorObjects[$this->updateUser['object']['id']] = $this->updateUser['object'];\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->updateUser)));\n        $user = $this->userRepository->findOneBy(['apPublicUrl' => $this->updateUser['object']['id']]);\n        self::assertNotNull($user);\n        self::assertStringContainsString('update', $user->about);\n        self::assertNotNull($user->publicKey);\n        self::assertStringContainsString('new public key', $user->publicKey);\n        self::assertNotNull($user->lastKeyRotationDate);\n    }\n\n    public function testUpdateRemoteUserTitle(): void\n    {\n        // an update activity forces to fetch the remote object again -> rewrite the actor id to the updated object from the activity\n\n        $object = $this->updateUser['object'];\n        $object['name'] = 'Test User';\n        $this->testingApHttpClient->actorObjects[$this->updateUser['object']['id']] = $object;\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->updateUser)));\n        $user = $this->userRepository->findOneBy(['apPublicUrl' => $this->updateUser['object']['id']]);\n        self::assertNotNull($user);\n        self::assertEquals('Test User', $user->title);\n\n        $object = $this->updateUser['object'];\n        unset($object['name']);\n        $this->testingApHttpClient->actorObjects[$this->updateUser['object']['id']] = $object;\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->updateUser)));\n        $user = $this->userRepository->findOneBy(['apPublicUrl' => $this->updateUser['object']['id']]);\n        self::assertNotNull($user);\n        self::assertNull($user->title);\n    }\n\n    public function testUpdateRemoteMagazine(): void\n    {\n        // an update activity forces to fetch the remote object again -> rewrite the actor id to the updated object from the activity\n        $this->testingApHttpClient->actorObjects[$this->updateMagazine['object']['id']] = $this->updateMagazine['object'];\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->updateMagazine)));\n        $magazine = $this->magazineRepository->findOneBy(['apPublicUrl' => $this->updateMagazine['object']['id']]);\n        self::assertNotNull($magazine);\n        self::assertStringContainsString('update', $magazine->description);\n        self::assertNotNull($magazine->publicKey);\n        self::assertStringContainsString('new public key', $magazine->publicKey);\n        self::assertNotNull($magazine->lastKeyRotationDate);\n    }\n\n    public function setUpRemoteEntities(): void\n    {\n        $this->announceEntry = $this->createRemoteEntryInRemoteMagazine($this->remoteMagazine, $this->remoteUser, fn (Entry $entry) => $this->buildUpdateRemoteEntryInRemoteMagazine($entry));\n        $this->announceEntryComment = $this->createRemoteEntryCommentInRemoteMagazine($this->remoteMagazine, $this->remoteUser, fn (EntryComment $comment) => $this->buildUpdateRemoteEntryCommentInRemoteMagazine($comment));\n        $this->announcePost = $this->createRemotePostInRemoteMagazine($this->remoteMagazine, $this->remoteUser, fn (Post $post) => $this->buildUpdateRemotePostInRemoteMagazine($post));\n        $this->announcePostComment = $this->createRemotePostCommentInRemoteMagazine($this->remoteMagazine, $this->remoteUser, fn (PostComment $comment) => $this->buildUpdateRemotePostCommentInRemoteMagazine($comment));\n        $this->createEntry = $this->createRemoteEntryInLocalMagazine($this->localMagazine, $this->remoteUser, fn (Entry $entry) => $this->buildUpdateRemoteEntryInLocalMagazine($entry));\n        $this->createEntryComment = $this->createRemoteEntryCommentInLocalMagazine($this->localMagazine, $this->remoteUser, fn (EntryComment $comment) => $this->buildUpdateRemoteEntryCommentInLocalMagazine($comment));\n        $this->createPost = $this->createRemotePostInLocalMagazine($this->localMagazine, $this->remoteUser, fn (Post $post) => $this->buildUpdateRemotePostInLocalMagazine($post));\n        $this->createPostComment = $this->createRemotePostCommentInLocalMagazine($this->localMagazine, $this->remoteUser, fn (PostComment $comment) => $this->buildUpdateRemotePostCommentInLocalMagazine($comment));\n        $this->buildUpdateUser();\n        $this->buildUpdateMagazine();\n    }\n\n    public function buildUpdateRemoteEntryInRemoteMagazine(Entry $entry): void\n    {\n        $updateActivity = $this->updateWrapper->buildForActivity($entry, $this->remoteUser);\n        $entry->title = 'Some updated title';\n        $entry->body = 'Some updated body';\n        $this->updateAnnounceEntry = $this->activityJsonBuilder->buildActivityJson($updateActivity);\n\n        $this->testingApHttpClient->activityObjects[$this->updateAnnounceEntry['id']] = $this->updateAnnounceEntry;\n        $this->entitiesToRemoveAfterSetup[] = $updateActivity;\n    }\n\n    public function buildUpdateRemoteEntryCommentInRemoteMagazine(EntryComment $comment): void\n    {\n        $updateActivity = $this->updateWrapper->buildForActivity($comment, $this->remoteUser);\n        $comment->body = 'Some updated body';\n        $this->updateAnnounceEntryComment = $this->activityJsonBuilder->buildActivityJson($updateActivity);\n\n        $this->testingApHttpClient->activityObjects[$this->updateAnnounceEntryComment['id']] = $this->updateAnnounceEntryComment;\n        $this->entitiesToRemoveAfterSetup[] = $updateActivity;\n    }\n\n    public function buildUpdateRemotePostInRemoteMagazine(Post $post): void\n    {\n        $updateActivity = $this->updateWrapper->buildForActivity($post, $this->remoteUser);\n        $post->body = 'Some updated body';\n        $this->updateAnnouncePost = $this->activityJsonBuilder->buildActivityJson($updateActivity);\n\n        $this->testingApHttpClient->activityObjects[$this->updateAnnouncePost['id']] = $this->updateAnnouncePost;\n        $this->entitiesToRemoveAfterSetup[] = $updateActivity;\n    }\n\n    public function buildUpdateRemotePostCommentInRemoteMagazine(PostComment $postComment): void\n    {\n        $updateActivity = $this->updateWrapper->buildForActivity($postComment, $this->remoteUser);\n        $postComment->body = 'Some updated body';\n        $this->updateAnnouncePostComment = $this->activityJsonBuilder->buildActivityJson($updateActivity);\n\n        $this->testingApHttpClient->activityObjects[$this->updateAnnouncePostComment['id']] = $this->updateAnnouncePostComment;\n        $this->entitiesToRemoveAfterSetup[] = $updateActivity;\n    }\n\n    public function buildUpdateRemoteEntryInLocalMagazine(Entry $entry): void\n    {\n        $updateActivity = $this->updateWrapper->buildForActivity($entry, $this->remoteUser);\n        $titleBefore = $entry->title;\n        $entry->title = 'Some updated title';\n        $entry->body = 'Some updated body';\n        $entry->isLocked = true;\n        $this->updateCreateEntry = $this->RewriteTargetFieldsToLocal($entry->magazine, $this->activityJsonBuilder->buildActivityJson($updateActivity));\n        $entry->title = $titleBefore;\n        $entry->isLocked = false;\n\n        $this->testingApHttpClient->activityObjects[$this->updateCreateEntry['id']] = $this->updateCreateEntry;\n        $this->entitiesToRemoveAfterSetup[] = $updateActivity;\n    }\n\n    public function buildUpdateRemoteEntryCommentInLocalMagazine(EntryComment $comment): void\n    {\n        $updateActivity = $this->updateWrapper->buildForActivity($comment, $this->remoteUser);\n        $comment->body = 'Some updated body';\n        $this->updateCreateEntryComment = $this->RewriteTargetFieldsToLocal($comment->magazine, $this->activityJsonBuilder->buildActivityJson($updateActivity));\n\n        $this->testingApHttpClient->activityObjects[$this->updateCreateEntryComment['id']] = $this->updateCreateEntryComment;\n        $this->entitiesToRemoveAfterSetup[] = $updateActivity;\n    }\n\n    public function buildUpdateRemotePostInLocalMagazine(Post $post): void\n    {\n        $updateActivity = $this->updateWrapper->buildForActivity($post, $this->remoteUser);\n        $bodyBefore = $post->body;\n        $post->body = 'Some updated body';\n        $post->isLocked = true;\n        $this->updateCreatePost = $this->RewriteTargetFieldsToLocal($post->magazine, $this->activityJsonBuilder->buildActivityJson($updateActivity));\n        $post->body = $bodyBefore;\n        $post->isLocked = false;\n\n        $this->testingApHttpClient->activityObjects[$this->updateCreatePost['id']] = $this->updateCreatePost;\n        $this->entitiesToRemoveAfterSetup[] = $updateActivity;\n    }\n\n    public function buildUpdateRemotePostCommentInLocalMagazine(PostComment $postComment): void\n    {\n        $updateActivity = $this->updateWrapper->buildForActivity($postComment, $this->remoteUser);\n        $postComment->body = 'Some updated body';\n        $this->updateCreatePostComment = $this->RewriteTargetFieldsToLocal($postComment->magazine, $this->activityJsonBuilder->buildActivityJson($updateActivity));\n\n        $this->testingApHttpClient->activityObjects[$this->updateCreatePostComment['id']] = $this->updateCreatePostComment;\n        $this->entitiesToRemoveAfterSetup[] = $updateActivity;\n    }\n\n    public function buildUpdateUser(): void\n    {\n        $aboutBefore = $this->remoteUser->about;\n        $this->remoteUser->about = 'Some updated user description';\n        $this->remoteUser->publicKey = 'Some new public key';\n        $this->remoteUser->privateKey = 'Some new private key';\n        $updateActivity = $this->updateWrapper->buildForActor($this->remoteUser, $this->remoteUser);\n        $this->updateUser = $this->activityJsonBuilder->buildActivityJson($updateActivity);\n        $this->remoteUser->about = $aboutBefore;\n\n        $this->testingApHttpClient->activityObjects[$this->updateUser['id']] = $this->updateUser;\n        $this->entitiesToRemoveAfterSetup[] = $updateActivity;\n    }\n\n    public function buildUpdateMagazine(): void\n    {\n        $descriptionBefore = $this->remoteMagazine->description;\n        $this->remoteMagazine->description = 'Some updated magazine description';\n        $this->remoteMagazine->publicKey = 'Some new public key';\n        $this->remoteMagazine->privateKey = 'Some new private key';\n        $updateActivity = $this->updateWrapper->buildForActor($this->remoteMagazine, $this->remoteMagazine->getOwner());\n        $this->updateMagazine = $this->activityJsonBuilder->buildActivityJson($updateActivity);\n        $this->remoteMagazine->description = $descriptionBefore;\n\n        $this->testingApHttpClient->activityObjects[$this->updateMagazine['id']] = $this->updateMagazine;\n        $this->entitiesToRemoveAfterSetup[] = $updateActivity;\n    }\n}\n"
  },
  {
    "path": "tests/Functional/ActivityPub/MarkdownConverterTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\ActivityPub;\n\nuse App\\Entity\\User;\nuse PHPUnit\\Framework\\Attributes\\DataProvider;\n\nuse function PHPUnit\\Framework\\assertEquals;\n\nclass MarkdownConverterTest extends ActivityPubFunctionalTestCase\n{\n    public function setUpRemoteEntities(): void\n    {\n    }\n\n    public function setUpLocalEntities(): void\n    {\n        $domain = 'some.domain.tld';\n        $this->switchToRemoteDomain($domain);\n\n        $this->registerActor($this->getUserByUsername('someUser', email: \"someUser@$domain\"), $domain, true);\n        $this->registerActor($this->getMagazineByName('someMagazine'), $domain, true);\n\n        $this->switchToLocalDomain();\n    }\n\n    public function setUp(): void\n    {\n        parent::setUp();\n\n        // generate the local user 'someUser'\n        $user = $this->getUserByUsername('someUser', email: 'someUser@kbin.test');\n        $this->getMagazineByName('someMagazine', $user);\n        $mastodonUser = new User('SomeUser@mastodon.tld', 'SomeUser@mastodon.tld', '', 'Person', 'https://mastodon.tld/users/SomeAccount');\n        $mastodonUser->apPublicUrl = 'https://mastodon.tld/@SomeAccount';\n        $this->entityManager->persist($mastodonUser);\n    }\n\n    #[DataProvider('htmlMentionsProvider')]\n    public function testMentions(string $html, array $apTags, array $expectedMentions, string $name): void\n    {\n        $converted = $this->apMarkdownConverter->convert($html, $apTags);\n        $mentions = $this->mentionManager->extract($converted);\n        assertEquals($expectedMentions, $mentions, message: \"Mention test '$name'\");\n    }\n\n    public static function htmlMentionsProvider(): array\n    {\n        return [\n            [\n                'html' => '<p><span class=\"h-card\" translate=\"no\"><a href=\"https://some.domain.tld/u/someUser\" class=\"u-url mention\">@<span>someUser</span></a></span> <span class=\"h-card\" translate=\"no\"><a href=\"https://kbin.test/u/someUser\" class=\"u-url mention\">@<span>someUser@kbin.test</span></a></span></p>',\n                'apTags' => [\n                    [\n                        'type' => 'Mention',\n                        'href' => 'https://some.domain.tld/u/someUser',\n                        'name' => '@someUser',\n                    ],\n                    [\n                        'type' => 'Mention',\n                        'href' => 'https://kbin.test/u/someUser',\n                        'name' => '@someUser@kbin.test',\n                    ],\n                ],\n                'expectedMentions' => ['@someUser@some.domain.tld', '@someUser@kbin.test'],\n                'name' => 'Local and remote user',\n            ],\n            [\n                'html' => '<p><span class=\"h-card\" translate=\"no\"><a href=\"https://some.domain.tld/m/someMagazine\" class=\"u-url mention\">@<span>someMagazine</span></a></span></p>',\n                'apTags' => [\n                    [\n                        'type' => 'Mention',\n                        'href' => 'https://some.domain.tld/m/someMagazine',\n                        'name' => '@someMagazine',\n                    ],\n                ],\n                'expectedMentions' => ['@someMagazine@some.domain.tld'],\n                'name' => 'Magazine mention',\n            ],\n            [\n                'html' => '<p><span class=\"h-card\" translate=\"no\"><a href=\"https://kbin.test/m/someMagazine\" class=\"u-url mention\">@<span>someMagazine</span></a></span></p>',\n                'apTags' => [\n                    [\n                        'type' => 'Mention',\n                        'href' => 'https://kbin.test/m/someMagazine',\n                        'name' => '@someMagazine',\n                    ],\n                ],\n                'expectedMentions' => ['@someMagazine@kbin.test'],\n                'name' => 'Local magazine mention',\n            ],\n            [\n                'html' => '<a href=\\\"https://mastodon.tld/@SomeAccount\\\" class=\\\"u-url mention\\\">@<span>SomeAccount</span></a></span>',\n                'apTags' => [\n                    [\n                        'type' => 'Mention',\n                        'href' => 'https://mastodon.tld/users/SomeAccount',\n                        'name' => '@SomeAccount@mastodon.tld',\n                    ],\n                ],\n                'expectedMentions' => ['@SomeAccount@mastodon.tld'],\n                'name' => 'Mastodon account mention',\n            ],\n        ];\n    }\n}\n"
  },
  {
    "path": "tests/Functional/ActivityPub/Outbox/BlockHandlerTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\ActivityPub\\Outbox;\n\nuse App\\DTO\\MagazineBanDto;\nuse App\\Entity\\User;\nuse App\\Tests\\Functional\\ActivityPub\\ActivityPubFunctionalTestCase;\nuse PHPUnit\\Framework\\Attributes\\Group;\n\n#[Group(name: 'ActivityPub')]\n#[Group(name: 'NonThreadSafe')]\nclass BlockHandlerTest extends ActivityPubFunctionalTestCase\n{\n    private User $localSubscriber;\n\n    public function setUp(): void\n    {\n        parent::setUp();\n        $this->localSubscriber = $this->getUserByUsername('localSubscriber', addImage: false);\n        // so localSubscriber has one interaction with another instance\n        $this->magazineManager->subscribe($this->remoteMagazine, $this->localSubscriber);\n    }\n\n    public function setUpRemoteEntities(): void\n    {\n    }\n\n    public function testBanLocalUserLocalMagazineLocalModerator(): void\n    {\n        $this->magazineManager->ban($this->localMagazine, $this->localSubscriber, $this->localUser, MagazineBanDto::create(reason: 'test'));\n\n        $blockActivity = $this->assertOneSentActivityOfType('Block', inboxUrl: $this->remoteSubscriber->apInboxUrl);\n        self::assertEquals('test', $blockActivity['summary']);\n        self::assertEquals($this->personFactory->getActivityPubId($this->localSubscriber), $blockActivity['object']);\n        self::assertEquals($this->groupFactory->getActivityPubId($this->localMagazine), $blockActivity['target']);\n    }\n\n    public function testUndoBanLocalUserLocalMagazineLocalModerator(): void\n    {\n        $this->magazineManager->ban($this->localMagazine, $this->localSubscriber, $this->localUser, MagazineBanDto::create(reason: 'test'));\n\n        $blockActivity = $this->assertOneSentActivityOfType('Block', inboxUrl: $this->remoteSubscriber->apInboxUrl);\n        $this->magazineManager->unban($this->localMagazine, $this->localSubscriber);\n        $undoActivity = $this->assertOneSentActivityOfType('Undo', inboxUrl: $this->remoteSubscriber->apInboxUrl);\n        self::assertEquals($blockActivity['id'], $undoActivity['object']['id']);\n    }\n\n    public function testBanRemoteUserLocalMagazineLocalModerator(): void\n    {\n        $this->magazineManager->ban($this->localMagazine, $this->remoteSubscriber, $this->localUser, MagazineBanDto::create(reason: 'test'));\n\n        $blockActivity = $this->assertOneSentActivityOfType('Block', inboxUrl: $this->remoteSubscriber->apInboxUrl);\n        self::assertEquals('test', $blockActivity['summary']);\n        self::assertEquals($this->remoteSubscriber->apProfileId, $blockActivity['object']);\n        self::assertEquals($this->groupFactory->getActivityPubId($this->localMagazine), $blockActivity['target']);\n    }\n\n    public function testUndoBanRemoteUserLocalMagazineLocalModerator(): void\n    {\n        $this->magazineManager->ban($this->localMagazine, $this->remoteSubscriber, $this->localUser, MagazineBanDto::create(reason: 'test'));\n\n        $blockActivity = $this->assertOneSentActivityOfType('Block', inboxUrl: $this->remoteSubscriber->apInboxUrl);\n        $this->magazineManager->unban($this->localMagazine, $this->remoteSubscriber);\n        $undoActivity = $this->assertOneSentActivityOfType('Undo', inboxUrl: $this->remoteSubscriber->apInboxUrl);\n        self::assertEquals($blockActivity['id'], $undoActivity['object']['id']);\n    }\n\n    public function testBanLocalUserInstanceLocalModerator(): void\n    {\n        $this->userManager->ban($this->localSubscriber, $this->localUser, 'test');\n\n        $blockActivity = $this->assertOneSentActivityOfType('Block', inboxUrl: $this->remoteMagazine->apInboxUrl);\n        self::assertEquals('test', $blockActivity['summary']);\n        self::assertEquals($this->personFactory->getActivityPubId($this->localSubscriber), $blockActivity['object']);\n        self::assertEquals($this->instanceFactory->getTargetUrl(), $blockActivity['target']);\n    }\n\n    public function testUndoBanLocalUserInstanceLocalModerator(): void\n    {\n        $this->userManager->ban($this->localSubscriber, $this->localUser, 'test');\n\n        $blockActivity = $this->assertOneSentActivityOfType('Block', inboxUrl: $this->remoteMagazine->apInboxUrl);\n        $this->userManager->unban($this->localSubscriber, $this->localUser, 'test');\n        $undoActivity = $this->assertOneSentActivityOfType('Undo', inboxUrl: $this->remoteMagazine->apInboxUrl);\n        self::assertEquals($blockActivity['id'], $undoActivity['object']['id']);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/ActivityPub/Outbox/DeleteHandlerTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\ActivityPub\\Outbox;\n\nuse App\\DTO\\ModeratorDto;\nuse App\\Entity\\Contracts\\ActivityPubActivityInterface;\nuse App\\Entity\\Contracts\\ActivityPubActorInterface;\nuse App\\Entity\\User;\nuse App\\Message\\ActivityPub\\Inbox\\ActivityMessage;\nuse App\\Tests\\Functional\\ActivityPub\\ActivityPubFunctionalTestCase;\nuse PHPUnit\\Framework\\Attributes\\Group;\nuse Symfony\\Component\\Routing\\Generator\\UrlGeneratorInterface;\n\n#[Group(name: 'ActivityPub')]\n#[Group(name: 'NonThreadSafe')]\nclass DeleteHandlerTest extends ActivityPubFunctionalTestCase\n{\n    private array $createRemoteEntryInLocalMagazine;\n    private array $createRemoteEntryInRemoteMagazine;\n    private array $createRemoteEntryCommentInLocalMagazine;\n    private array $createRemoteEntryCommentInRemoteMagazine;\n    private array $createRemotePostInLocalMagazine;\n    private array $createRemotePostInRemoteMagazine;\n    private array $createRemotePostCommentInLocalMagazine;\n    private array $createRemotePostCommentInRemoteMagazine;\n\n    private User $remotePoster;\n    private User $localPoster;\n\n    public function setUp(): void\n    {\n        parent::setUp();\n        $this->magazineManager->addModerator(new ModeratorDto($this->remoteMagazine, $this->localUser));\n        $this->localPoster = $this->getUserByUsername('localPoster', addImage: false);\n    }\n\n    public function setUpRemoteEntities(): void\n    {\n        $this->createRemoteEntryInRemoteMagazine = $this->createRemoteEntryInRemoteMagazine($this->remoteMagazine, $this->remotePoster);\n        $this->createRemoteEntryCommentInRemoteMagazine = $this->createRemoteEntryCommentInRemoteMagazine($this->remoteMagazine, $this->remotePoster);\n        $this->createRemotePostInRemoteMagazine = $this->createRemotePostInRemoteMagazine($this->remoteMagazine, $this->remotePoster);\n        $this->createRemotePostCommentInRemoteMagazine = $this->createRemotePostCommentInRemoteMagazine($this->remoteMagazine, $this->remotePoster);\n        $this->createRemoteEntryInLocalMagazine = $this->createRemoteEntryInLocalMagazine($this->localMagazine, $this->remotePoster);\n        $this->createRemoteEntryCommentInLocalMagazine = $this->createRemoteEntryCommentInLocalMagazine($this->localMagazine, $this->remotePoster);\n        $this->createRemotePostInLocalMagazine = $this->createRemotePostInLocalMagazine($this->localMagazine, $this->remotePoster);\n        $this->createRemotePostCommentInLocalMagazine = $this->createRemotePostCommentInLocalMagazine($this->localMagazine, $this->remotePoster);\n    }\n\n    protected function setUpRemoteActors(): void\n    {\n        parent::setUpRemoteActors();\n        $username = 'remotePoster';\n        $domain = $this->remoteDomain;\n        $this->remotePoster = $this->getUserByUsername($username, addImage: false);\n        $this->registerActor($this->remotePoster, $domain, true);\n    }\n\n    public function testDeleteLocalEntryInLocalMagazineByLocalModerator(): void\n    {\n        $entry = $this->getEntryByTitle(title: 'test entry', magazine: $this->localMagazine, user: $this->localUser);\n        $this->entryManager->delete($this->localUser, $entry);\n        $this->assertOneSentActivityOfType('Delete');\n    }\n\n    public function testDeleteLocalEntryInRemoteMagazineByLocalModerator(): void\n    {\n        $entry = $this->getEntryByTitle(title: 'test entry', magazine: $this->remoteMagazine, user: $this->localUser);\n        $this->entryManager->delete($this->localUser, $entry);\n        $this->assertOneSentActivityOfType('Delete');\n    }\n\n    public function testDeleteRemoteEntryInLocalMagazineByLocalModerator(): void\n    {\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->createRemoteEntryInLocalMagazine)));\n        $entryApId = $this->createRemoteEntryInLocalMagazine['object']['id'];\n        $entry = $this->entryRepository->findOneBy(['apId' => $entryApId]);\n        self::assertNotNull($entry);\n        $this->entryManager->delete($this->localUser, $entry);\n        $entry = $this->entryRepository->findOneBy(['apId' => $entryApId]);\n        self::assertTrue($entry->isTrashed());\n        $this->assertOneSentActivityOfType('Delete');\n    }\n\n    public function testDeleteRemoteEntryInRemoteMagazineByLocalModerator(): void\n    {\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->createRemoteEntryInRemoteMagazine)));\n        $entryApId = $this->createRemoteEntryInRemoteMagazine['object']['object']['id'];\n        $entry = $this->entryRepository->findOneBy(['apId' => $entryApId]);\n        self::assertNotNull($entry);\n        $this->entryManager->purge($this->localUser, $entry);\n        $entry = $this->entryRepository->findOneBy(['apId' => $entryApId]);\n        self::assertNull($entry);\n        self::assertNotEmpty($this->testingApHttpClient->getPostedObjects());\n        $deleteActivity = $this->activityRepository->findOneBy(['type' => 'Delete']);\n        self::assertNotNull($deleteActivity);\n        $activityId = $this->urlGenerator->generate('ap_object', ['id' => $deleteActivity->uuid], UrlGeneratorInterface::ABSOLUTE_URL);\n        $this->assertOneSentActivityOfType('Delete', $activityId);\n    }\n\n    public function testDeleteLocalEntryCommentInLocalMagazineByLocalModerator(): void\n    {\n        $entry = $this->getEntryByTitle(title: 'test entry', magazine: $this->localMagazine, user: $this->localUser);\n        $comment = $this->createEntryComment('test entry comment', entry: $entry, user: $this->localUser);\n        $this->removeActivitiesWithObject($comment);\n        $this->entryCommentManager->delete($this->localUser, $comment);\n        $this->assertOneSentActivityOfType('Delete');\n    }\n\n    public function testDeleteLocalEntryCommentInRemoteMagazineByLocalModerator(): void\n    {\n        $entry = $this->getEntryByTitle(title: 'test entry', magazine: $this->remoteMagazine, user: $this->localUser);\n        $comment = $this->createEntryComment('test entry comment', entry: $entry, user: $this->localUser);\n        $this->removeActivitiesWithObject($comment);\n        $this->entryCommentManager->delete($this->localUser, $comment);\n        $this->assertOneSentActivityOfType('Delete');\n    }\n\n    public function testDeleteRemoteEntryCommentInLocalMagazineByLocalModerator(): void\n    {\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->createRemoteEntryInLocalMagazine)));\n        $entryApId = $this->createRemoteEntryInLocalMagazine['object']['id'];\n        $entry = $this->entryRepository->findOneBy(['apId' => $entryApId]);\n        self::assertNotNull($entry);\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->createRemoteEntryCommentInLocalMagazine)));\n        $entryCommentApId = $this->createRemoteEntryCommentInLocalMagazine['object']['id'];\n        $entryComment = $this->entryCommentRepository->findOneBy(['apId' => $entryCommentApId]);\n        self::assertNotNull($entryComment);\n        $this->entryCommentManager->delete($this->localUser, $entryComment);\n        $entryComment = $this->entryCommentRepository->findOneBy(['apId' => $entryCommentApId]);\n        self::assertTrue($entryComment->isTrashed());\n        // 2 subs -> 2 delete activities\n        $this->assertCountOfSentActivitiesOfType(2, 'Delete');\n    }\n\n    public function testDeleteRemoteEntryCommentInRemoteMagazineByLocalModerator(): void\n    {\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->createRemoteEntryInRemoteMagazine)));\n        $entryApId = $this->createRemoteEntryInRemoteMagazine['object']['object']['id'];\n        $entry = $this->entryRepository->findOneBy(['apId' => $entryApId]);\n        self::assertNotNull($entry);\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->createRemoteEntryCommentInRemoteMagazine)));\n        $entryCommentApId = $this->createRemoteEntryCommentInRemoteMagazine['object']['object']['id'];\n        $entryComment = $this->entryCommentRepository->findOneBy(['apId' => $entryCommentApId]);\n        self::assertNotNull($entryComment);\n        $this->entryCommentManager->purge($this->localUser, $entryComment);\n        $entryComment = $this->entryCommentRepository->findOneBy(['apId' => $entryCommentApId]);\n        self::assertNull($entryComment);\n        self::assertNotEmpty($this->testingApHttpClient->getPostedObjects());\n        $deleteActivity = $this->activityRepository->findOneBy(['type' => 'Delete']);\n        self::assertNotNull($deleteActivity);\n        $activityId = $this->urlGenerator->generate('ap_object', ['id' => $deleteActivity->uuid], UrlGeneratorInterface::ABSOLUTE_URL);\n        $this->assertOneSentActivityOfType('Delete', $activityId);\n    }\n\n    public function testDeleteLocalPostInLocalMagazineByLocalModerator(): void\n    {\n        $post = $this->createPost(body: 'test post', magazine: $this->localMagazine, user: $this->localUser);\n        $this->postManager->delete($this->localUser, $post);\n        $this->assertOneSentActivityOfType('Delete');\n    }\n\n    public function testDeleteLocalPostInRemoteMagazineByLocalModerator(): void\n    {\n        $post = $this->createPost(body: 'test post', magazine: $this->remoteMagazine, user: $this->localUser);\n        $this->postManager->delete($this->localUser, $post);\n        $this->assertOneSentActivityOfType('Delete');\n    }\n\n    public function testDeleteRemotePostInLocalMagazineByLocalModerator(): void\n    {\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->createRemotePostInLocalMagazine)));\n        $postApId = $this->createRemotePostInLocalMagazine['object']['id'];\n        $post = $this->postRepository->findOneBy(['apId' => $postApId]);\n        self::assertNotNull($post);\n        $this->postManager->delete($this->localUser, $post);\n        $post = $this->postRepository->findOneBy(['apId' => $postApId]);\n        self::assertTrue($post->isTrashed());\n        $this->assertOneSentActivityOfType('Delete');\n    }\n\n    public function testDeleteRemotePostInRemoteMagazineByLocalModerator(): void\n    {\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->createRemotePostInRemoteMagazine)));\n        $postApId = $this->createRemotePostInRemoteMagazine['object']['object']['id'];\n        $post = $this->postRepository->findOneBy(['apId' => $postApId]);\n        self::assertNotNull($post);\n        $this->postManager->purge($this->localUser, $post);\n        $post = $this->postRepository->findOneBy(['apId' => $postApId]);\n        self::assertNull($post);\n        self::assertNotEmpty($this->testingApHttpClient->getPostedObjects());\n        $deleteActivity = $this->activityRepository->findOneBy(['type' => 'Delete']);\n        self::assertNotNull($deleteActivity);\n        $activityId = $this->urlGenerator->generate('ap_object', ['id' => $deleteActivity->uuid], UrlGeneratorInterface::ABSOLUTE_URL);\n        $this->assertOneSentActivityOfType('Delete', $activityId);\n    }\n\n    public function testDeleteLocalPostCommentInLocalMagazineByLocalModerator(): void\n    {\n        $post = $this->createPost(body: 'test post', magazine: $this->localMagazine, user: $this->localUser);\n        $comment = $this->createPostComment('test post comment', post: $post, user: $this->localUser);\n        $this->removeActivitiesWithObject($comment);\n        $this->postCommentManager->delete($this->localUser, $comment);\n        $this->assertOneSentActivityOfType('Delete');\n    }\n\n    public function testDeleteLocalPostCommentInRemoteMagazineByLocalModerator(): void\n    {\n        $post = $this->createPost(body: 'test post', magazine: $this->remoteMagazine, user: $this->localUser);\n        $comment = $this->createPostComment('test post comment', post: $post, user: $this->localUser);\n        $this->removeActivitiesWithObject($comment);\n        $this->postCommentManager->delete($this->localUser, $comment);\n        $this->assertOneSentActivityOfType('Delete');\n    }\n\n    public function testDeleteRemotePostCommentInLocalMagazineByLocalModerator(): void\n    {\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->createRemotePostInLocalMagazine)));\n        $postApId = $this->createRemotePostInLocalMagazine['object']['id'];\n        $post = $this->postRepository->findOneBy(['apId' => $postApId]);\n        self::assertNotNull($post);\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->createRemotePostCommentInLocalMagazine)));\n        $postCommentApId = $this->createRemotePostCommentInLocalMagazine['object']['id'];\n        $postComment = $this->postCommentRepository->findOneBy(['apId' => $postCommentApId]);\n        self::assertNotNull($postComment);\n        $this->postCommentManager->delete($this->localUser, $postComment);\n        $postComment = $this->postCommentRepository->findOneBy(['apId' => $postCommentApId]);\n        self::assertTrue($postComment->isTrashed());\n        // 2 subs -> 2 delete activities\n        $this->assertCountOfSentActivitiesOfType(2, 'Delete');\n    }\n\n    public function testDeleteRemotePostCommentInRemoteMagazineByLocalModerator(): void\n    {\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->createRemotePostInRemoteMagazine)));\n        $postApId = $this->createRemotePostInRemoteMagazine['object']['object']['id'];\n        $post = $this->postRepository->findOneBy(['apId' => $postApId]);\n        self::assertNotNull($post);\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->createRemotePostCommentInRemoteMagazine)));\n        $postCommentApId = $this->createRemotePostCommentInRemoteMagazine['object']['object']['id'];\n        $postComment = $this->postCommentRepository->findOneBy(['apId' => $postCommentApId]);\n        self::assertNotNull($postComment);\n        $this->postCommentManager->purge($this->localUser, $postComment);\n        $postComment = $this->postCommentRepository->findOneBy(['apId' => $postCommentApId]);\n        self::assertNull($postComment);\n        self::assertNotEmpty($this->testingApHttpClient->getPostedObjects());\n        $deleteActivity = $this->activityRepository->findOneBy(['type' => 'Delete']);\n        self::assertNotNull($deleteActivity);\n        $activityId = $this->urlGenerator->generate('ap_object', ['id' => $deleteActivity->uuid], UrlGeneratorInterface::ABSOLUTE_URL);\n        $this->assertOneSentActivityOfType('Delete', $activityId);\n    }\n\n    public function testDeleteLocalEntryInRemoteMagazineByAuthor(): void\n    {\n        $entry = $this->createEntry('test local entry', $this->remoteMagazine, $this->localPoster);\n        $createEntryActivity = $this->activityRepository->findOneBy(['objectEntry' => $entry]);\n        $this->entityManager->remove($createEntryActivity);\n        $this->entryManager->delete($this->localPoster, $entry);\n        $this->assertOneSentActivityOfType('Delete');\n    }\n\n    public function testDeleteLocalEntryCommentInRemoteMagazineByAuthor(): void\n    {\n        $entry = $this->createEntry('test local entry', $this->remoteMagazine, $this->localPoster);\n        $entryComment = $this->createEntryComment('test local entryComment', $entry, $this->localPoster);\n        $createEntryCommentActivity = $this->activityRepository->findOneBy(['objectEntryComment' => $entryComment]);\n        $this->entityManager->remove($createEntryCommentActivity);\n        $this->entryCommentManager->delete($this->localPoster, $entryComment);\n        $this->assertOneSentActivityOfType('Delete');\n    }\n\n    public function testDeleteLocalPostInRemoteMagazineByAuthor(): void\n    {\n        $post = $this->createPost('test local post', $this->remoteMagazine, $this->localPoster);\n        $createPostActivity = $this->activityRepository->findOneBy(['objectPost' => $post]);\n        $this->entityManager->remove($createPostActivity);\n        $this->postManager->delete($this->localPoster, $post);\n        $this->assertOneSentActivityOfType('Delete');\n    }\n\n    public function testDeleteLocalPostCommentInRemoteMagazineByAuthor(): void\n    {\n        $post = $this->createPost('test local post', $this->remoteMagazine, $this->localPoster);\n        $postComment = $this->createPostComment('test local post comment', $post, $this->localPoster);\n        $createPostCommentActivity = $this->activityRepository->findOneBy(['objectPostComment' => $postComment]);\n        $this->entityManager->remove($createPostCommentActivity);\n        $this->postCommentManager->delete($this->localPoster, $postComment);\n        $this->assertOneSentActivityOfType('Delete');\n    }\n\n    public function removeActivitiesWithObject(ActivityPubActivityInterface|ActivityPubActorInterface $object): void\n    {\n        $activities = $this->activityRepository->findAllActivitiesByObject($object);\n        foreach ($activities as $activity) {\n            $this->entityManager->remove($activity);\n        }\n    }\n}\n"
  },
  {
    "path": "tests/Functional/ActivityPub/Outbox/LockHandlerTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\ActivityPub\\Outbox;\n\nuse App\\DTO\\ModeratorDto;\nuse App\\Entity\\User;\nuse App\\Message\\ActivityPub\\Inbox\\ActivityMessage;\nuse App\\Tests\\Functional\\ActivityPub\\ActivityPubFunctionalTestCase;\nuse PHPUnit\\Framework\\Attributes\\Group;\n\n#[Group(name: 'ActivityPub')]\n#[Group(name: 'NonThreadSafe')]\nclass LockHandlerTest extends ActivityPubFunctionalTestCase\n{\n    private array $createRemoteEntryInLocalMagazine;\n    private array $createRemoteEntryInRemoteMagazine;\n    private array $createRemotePostInLocalMagazine;\n    private array $createRemotePostInRemoteMagazine;\n    private User $remotePoster;\n    private User $localPoster;\n\n    public function setUp(): void\n    {\n        parent::setUp();\n        $this->magazineManager->addModerator(new ModeratorDto($this->remoteMagazine, $this->localUser));\n        $this->localPoster = $this->getUserByUsername('localPoster', addImage: false);\n    }\n\n    public function setUpRemoteEntities(): void\n    {\n        $this->createRemoteEntryInRemoteMagazine = $this->createRemoteEntryInRemoteMagazine($this->remoteMagazine, $this->remotePoster);\n        $this->createRemotePostInRemoteMagazine = $this->createRemotePostInRemoteMagazine($this->remoteMagazine, $this->remotePoster);\n        $this->createRemoteEntryInLocalMagazine = $this->createRemoteEntryInLocalMagazine($this->localMagazine, $this->remotePoster);\n        $this->createRemotePostInLocalMagazine = $this->createRemotePostInLocalMagazine($this->localMagazine, $this->remotePoster);\n    }\n\n    protected function setUpRemoteActors(): void\n    {\n        parent::setUpRemoteActors();\n        $username = 'remotePoster';\n        $domain = $this->remoteDomain;\n        $this->remotePoster = $this->getUserByUsername($username, addImage: false);\n        $this->registerActor($this->remotePoster, $domain, true);\n    }\n\n    public function testLockLocalEntryInLocalMagazineByLocalModerator(): void\n    {\n        $entry = $this->createEntry('Some local entry', $this->localMagazine, $this->localPoster);\n        $this->entryManager->toggleLock($entry, $this->localUser);\n        $this->assertOneSentActivityOfType('Lock');\n    }\n\n    public function testLockLocalEntryInRemoteMagazineByLocalModerator(): void\n    {\n        $entry = $this->createEntry('Some local entry', $this->remoteMagazine, $this->localPoster);\n        $this->entryManager->toggleLock($entry, $this->localUser);\n        $this->assertOneSentActivityOfType('Lock');\n    }\n\n    public function testLockRemoteEntryInLocalMagazineByLocalModerator(): void\n    {\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->createRemoteEntryInLocalMagazine)));\n        $entry = $this->entryRepository->findOneBy(['apId' => $this->createRemoteEntryInLocalMagazine['object']['id']]);\n        self::assertNotNull($entry);\n        $this->entryManager->toggleLock($entry, $this->localUser);\n        $this->assertOneSentActivityOfType('Lock');\n    }\n\n    public function testLockRemoteEntryInRemoteMagazineByLocalModerator(): void\n    {\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->createRemoteEntryInRemoteMagazine)));\n        $entry = $this->entryRepository->findOneBy(['apId' => $this->createRemoteEntryInRemoteMagazine['object']['object']['id']]);\n        self::assertNotNull($entry);\n        $this->entryManager->toggleLock($entry, $this->localUser);\n        $this->assertOneSentActivityOfType('Lock');\n    }\n\n    public function testLockLocalPostInLocalMagazineByLocalModerator(): void\n    {\n        $post = $this->createPost('Some post', $this->localMagazine, $this->localPoster);\n        $this->postManager->toggleLock($post, $this->localUser);\n        $this->assertOneSentActivityOfType('Lock');\n    }\n\n    public function testLockLocalPostInRemoteMagazineByLocalModerator(): void\n    {\n        $post = $this->createPost('Some post', $this->remoteMagazine, $this->localPoster);\n        $this->postManager->toggleLock($post, $this->localUser);\n        $this->assertOneSentActivityOfType('Lock');\n    }\n\n    public function testLockRemotePostInLocalMagazineByLocalModerator(): void\n    {\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->createRemotePostInLocalMagazine)));\n        $post = $this->postRepository->findOneBy(['apId' => $this->createRemotePostInLocalMagazine['object']['id']]);\n        self::assertNotNull($post);\n        $this->postManager->toggleLock($post, $this->localUser);\n        $this->assertOneSentActivityOfType('Lock');\n    }\n\n    public function testLockRemotePostInRemoteMagazineByLocalModerator(): void\n    {\n        $this->bus->dispatch(new ActivityMessage(json_encode($this->createRemotePostInRemoteMagazine)));\n        $post = $this->postRepository->findOneBy(['apId' => $this->createRemotePostInRemoteMagazine['object']['object']['id']]);\n        self::assertNotNull($post);\n        $this->postManager->toggleLock($post, $this->localUser);\n        $this->assertOneSentActivityOfType('Lock');\n    }\n\n    public function testLockLocalEntryInRemoteMagazineByAuthor(): void\n    {\n        $entry = $this->createEntry('Some local entry', $this->remoteMagazine, $this->localPoster);\n        $this->entryManager->toggleLock($entry, $this->localPoster);\n        $this->assertOneSentActivityOfType('Lock');\n    }\n\n    public function testLockLocalPostInRemoteMagazineByAuthor(): void\n    {\n        $post = $this->createPost('Some local post', $this->remoteMagazine, $this->localPoster);\n        $this->postManager->toggleLock($post, $this->localPoster);\n        $this->assertOneSentActivityOfType('Lock');\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Command/AdminCommandTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Command;\n\nuse App\\DTO\\UserDto;\nuse App\\Repository\\UserRepository;\nuse App\\Service\\UserManager;\nuse Symfony\\Bundle\\FrameworkBundle\\Console\\Application;\nuse Symfony\\Bundle\\FrameworkBundle\\Test\\KernelTestCase;\nuse Symfony\\Component\\Console\\Command\\Command;\nuse Symfony\\Component\\Console\\Tester\\CommandTester;\n\nclass AdminCommandTest extends KernelTestCase\n{\n    private Command $command;\n    private ?UserRepository $repository;\n\n    public function testCreateUser(): void\n    {\n        $dto = (new UserDto())->create('actor', 'contact@example.com');\n        $dto->plainPassword = 'secret';\n\n        $this->getContainer()->get(UserManager::class)\n            ->create($dto, false);\n\n        $this->assertFalse($this->repository->findOneByUsername('actor')->isAdmin());\n\n        $tester = new CommandTester($this->command);\n        $tester->execute(['username' => 'actor']);\n\n        $this->assertStringContainsString('Administrator privileges have been granted.', $tester->getDisplay());\n        $this->assertTrue($this->repository->findOneByUsername('actor')->isAdmin());\n    }\n\n    protected function setUp(): void\n    {\n        $application = new Application(self::bootKernel());\n\n        $this->command = $application->find('mbin:user:admin');\n        $this->repository = $this->getContainer()->get(UserRepository::class);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Command/ModeratorCommandTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Command;\n\nuse App\\DTO\\UserDto;\nuse App\\Repository\\UserRepository;\nuse App\\Service\\UserManager;\nuse Symfony\\Bundle\\FrameworkBundle\\Console\\Application;\nuse Symfony\\Bundle\\FrameworkBundle\\Test\\KernelTestCase;\nuse Symfony\\Component\\Console\\Command\\Command;\nuse Symfony\\Component\\Console\\Tester\\CommandTester;\n\nclass ModeratorCommandTest extends KernelTestCase\n{\n    private Command $command;\n    private ?UserRepository $repository;\n\n    public function testCreateUser(): void\n    {\n        $dto = (new UserDto())->create('actor', 'contact@example.com');\n        $dto->plainPassword = 'secret';\n\n        $this->getContainer()->get(UserManager::class)\n            ->create($dto, false);\n\n        $this->assertFalse($this->repository->findOneByUsername('actor')->isModerator());\n\n        $tester = new CommandTester($this->command);\n        $tester->execute(['username' => 'actor']);\n\n        $this->assertStringContainsString('Global moderator privileges have been granted.', $tester->getDisplay());\n        $this->assertTrue($this->repository->findOneByUsername('actor')->isModerator());\n    }\n\n    protected function setUp(): void\n    {\n        $application = new Application(self::bootKernel());\n\n        $this->command = $application->find('mbin:user:moderator');\n        $this->repository = $this->getContainer()->get(UserRepository::class);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Command/UserCommandTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Command;\n\nuse App\\Entity\\User;\nuse App\\Repository\\UserRepository;\nuse Symfony\\Bundle\\FrameworkBundle\\Console\\Application;\nuse Symfony\\Bundle\\FrameworkBundle\\Test\\KernelTestCase;\nuse Symfony\\Component\\Console\\Command\\Command;\nuse Symfony\\Component\\Console\\Tester\\CommandTester;\n\nclass UserCommandTest extends KernelTestCase\n{\n    private Command $command;\n    private ?UserRepository $repository;\n\n    public function testCreateUser(): void\n    {\n        $tester = new CommandTester($this->command);\n        $tester->execute(\n            [\n                'username' => 'actor',\n                'email' => 'contact@example.com',\n                'password' => 'secret',\n            ]\n        );\n\n        $this->assertStringContainsString('A user has been created.', $tester->getDisplay());\n        $this->assertInstanceOf(User::class, $this->repository->findOneByUsername('actor'));\n    }\n\n    public function testCreateAdminUser(): void\n    {\n        $tester = new CommandTester($this->command);\n        $tester->execute(\n            [\n                'username' => 'actor',\n                'email' => 'contact@example.com',\n                'password' => 'secret',\n                '--admin' => true,\n            ],\n        );\n\n        $this->assertStringContainsString('A user has been created.', $tester->getDisplay());\n\n        $actor = $this->repository->findOneByUsername('actor');\n        $this->assertInstanceOf(User::class, $actor);\n        $this->assertTrue($actor->isAdmin());\n    }\n\n    protected function setUp(): void\n    {\n        $application = new Application(self::bootKernel());\n\n        $this->command = $application->find('mbin:user:create');\n        $this->repository = $this->getContainer()->get(UserRepository::class);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/ActivityPub/GeneralAPTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\ActivityPub;\n\nuse App\\Tests\\WebTestCase;\nuse PHPUnit\\Framework\\Attributes\\DataProvider;\n\nclass GeneralAPTest extends WebTestCase\n{\n    #[DataProvider('provideAcceptHeaders')]\n    public function testResponseToApProfile(string $acceptHeader): void\n    {\n        $user = $this->getUserByUsername('user');\n\n        $this->client->request('GET', '/u/user', [], [], [\n            'HTTP_ACCEPT' => $acceptHeader,\n        ]);\n\n        self::assertResponseHeaderSame('Content-Type', 'application/activity+json');\n    }\n\n    public static function provideAcceptHeaders(): array\n    {\n        return [\n            ['application/ld+json;profile=https://www.w3.org/ns/activitystreams'],\n            ['application/ld+json;profile=\"https://www.w3.org/ns/activitystreams\"'],\n            ['application/ld+json ; profile=\"https://www.w3.org/ns/activitystreams\"'],\n            ['application/ld+json'],\n            ['application/activity+json'],\n            ['application/json'],\n        ];\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/ActivityPub/UserOutboxControllerTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\ActivityPub;\n\nuse App\\DTO\\MessageDto;\nuse App\\Tests\\ActivityPubTestCase;\n\nclass UserOutboxControllerTest extends ActivityPubTestCase\n{\n    public const array COLLECTION_KEYS = ['@context', 'first', 'id', 'type', 'totalItems'];\n    public const array COLLECTION_ITEMS_KEYS = ['@context', 'type', 'id', 'totalItems', 'orderedItems', 'partOf'];\n\n    public function setUp(): void\n    {\n        parent::setUp();\n\n        $user = $this->getUserByUsername('apUser', addImage: false);\n        $user2 = $this->getUserByUsername('apUser2', addImage: false);\n        $magazine = $this->getMagazineByName('test-magazine');\n\n        // create a message to test that it is not part of the outbox\n        $dto = new MessageDto();\n        $dto->body = 'this is a message';\n        $thread = $this->messageManager->toThread($dto, $user, $user2);\n\n        $entry = $this->createEntry('entry', $magazine, user: $user);\n        $entryComment = $this->createEntryComment('comment', $entry, user: $user);\n        $post = $this->createPost('post', $magazine, user: $user);\n        $postComment = $this->createPostComment('comment', $post, user: $user);\n\n        // upvote an entry to check that it is not part of the outbox\n        $entryToLike = $this->getEntryByTitle('test entry 2');\n        $this->favouriteManager->toggle($user, $entryToLike);\n\n        // downvote an entry to check that it is not part of the outbox\n        $entryToDislike = $this->getEntryByTitle('test entry 3');\n        $this->voteManager->vote(-1, $entryToDislike, $user);\n\n        // boost an entry to check that it is part of the outbox\n        $entryToDislike = $this->getEntryByTitle('test entry 4');\n        $this->voteManager->vote(1, $entryToDislike, $user);\n    }\n\n    public function testUserOutbox(): void\n    {\n        $this->client->request('GET', '/u/apUser/outbox', server: ['HTTP_ACCEPT' => 'application/activity+json']);\n        self::assertResponseIsSuccessful();\n        $json = self::getJsonResponse($this->client);\n\n        self::assertArrayKeysMatch(self::COLLECTION_KEYS, $json);\n        self::assertEquals('OrderedCollection', $json['type']);\n        self::assertEquals(5, $json['totalItems']);\n\n        $firstPage = $json['first'];\n\n        $this->client->request('GET', $firstPage, server: ['HTTP_ACCEPT' => 'application/activity+json']);\n        self::assertResponseIsSuccessful();\n    }\n\n    public function testUserOutboxPage1(): void\n    {\n        $this->client->request('GET', '/u/apUser/outbox?page=1', server: ['HTTP_ACCEPT' => 'application/activity+json']);\n        self::assertResponseIsSuccessful();\n        $json = self::getJsonResponse($this->client);\n\n        self::assertArrayKeysMatch(self::COLLECTION_ITEMS_KEYS, $json);\n        self::assertEquals(5, $json['totalItems']);\n        self::assertCount(5, $json['orderedItems']);\n\n        $entries = array_filter($json['orderedItems'], fn (array $createActivity) => 'Create' === $createActivity['type'] && 'Page' === $createActivity['object']['type']);\n        self::assertCount(1, $entries);\n        $entryComments = array_filter($json['orderedItems'], fn (array $createActivity) => 'Create' === $createActivity['type'] && 'Note' === $createActivity['object']['type'] && str_contains($createActivity['object']['inReplyTo'] ?? '', '/t/'));\n        self::assertCount(1, $entryComments);\n        $posts = array_filter($json['orderedItems'], fn (array $createActivity) => 'Create' === $createActivity['type'] && 'Note' === $createActivity['object']['type'] && null === $createActivity['object']['inReplyTo']);\n        self::assertCount(1, $posts);\n        $postComments = array_filter($json['orderedItems'], fn (array $createActivity) => 'Create' === $createActivity['type'] && 'Note' === $createActivity['object']['type'] && str_contains($createActivity['object']['inReplyTo'] ?? '', '/p/'));\n        self::assertCount(1, $postComments);\n        $boosts = array_filter($json['orderedItems'], fn (array $createActivity) => 'Announce' === $createActivity['type']);\n        self::assertCount(1, $boosts);\n\n        // the outbox should not contain ChatMessages, likes or dislikes\n        $likes = array_filter($json['orderedItems'], fn (array $createActivity) => 'Like' === $createActivity['type']);\n        self::assertCount(0, $likes);\n        $dislikes = array_filter($json['orderedItems'], fn (array $createActivity) => 'Dislike' === $createActivity['type']);\n        self::assertCount(0, $dislikes);\n        $chatMessages = array_filter($json['orderedItems'], fn (array $createActivity) => 'Create' === $createActivity['type'] && 'ChatMessage' === $createActivity['object']['type']);\n        self::assertCount(0, $chatMessages);\n\n        $ids = array_map(fn (array $createActivity) => $createActivity['id'], $json['orderedItems']);\n\n        $this->client->request('GET', '/u/apUser/outbox?page=1', server: ['HTTP_ACCEPT' => 'application/activity+json']);\n        self::assertResponseIsSuccessful();\n        $json = self::getJsonResponse($this->client);\n\n        $ids2 = array_map(fn (array $createActivity) => $createActivity['id'], $json['orderedItems']);\n\n        // check that the ids of the 'Create' activities are stable\n        self::assertEquals($ids, $ids2);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Admin/AdminFederationControllerTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Admin;\n\nuse App\\Tests\\WebTestCase;\n\nclass AdminFederationControllerTest extends WebTestCase\n{\n    public function testAdminCanClearBannedInstances(): void\n    {\n        $instance = $this->instanceRepository->getOrCreateInstance('www.example.com');\n        $this->instanceManager->banInstance($instance);\n\n        $this->client->loginUser($this->getUserByUsername('admin', isAdmin: true));\n\n        $crawler = $this->client->request('GET', '/admin/federation');\n\n        $this->client->submit($crawler->filter('#content tr td button[type=submit]')->form());\n\n        $this->assertSame(\n            [],\n            $this->settingsManager->getBannedInstances(),\n        );\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Admin/AdminUserControllerTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Admin;\n\nuse App\\Tests\\WebTestCase;\n\nclass AdminUserControllerTest extends WebTestCase\n{\n    public function testInactiveUser(): void\n    {\n        $this->getUserByUsername('inactiveUser', active: false);\n        $admin = $this->getUserByUsername('admin', isAdmin: true);\n        $this->client->loginUser($admin);\n        $this->client->request('GET', '/admin/users/inactive');\n        self::assertResponseIsSuccessful();\n\n        self::assertAnySelectorTextContains('a.user-inline', 'inactiveUser');\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/Bookmark/BookmarkApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\Bookmark;\n\nuse App\\Entity\\User;\nuse App\\Tests\\WebTestCase;\n\nuse function PHPUnit\\Framework\\assertCount;\nuse function PHPUnit\\Framework\\assertIsArray;\n\nclass BookmarkApiTest extends WebTestCase\n{\n    private User $user;\n    private string $token;\n\n    public function setUp(): void\n    {\n        parent::setUp();\n        $this->user = $this->getUserByUsername('user');\n        $this->client->loginUser($this->user);\n        self::createOAuth2PublicAuthCodeClient();\n        $codes = self::getPublicAuthorizationCodeTokenResponse($this->client, scopes: 'read bookmark bookmark_list');\n        $this->token = $codes['token_type'].' '.$codes['access_token'];\n        // it seems that the oauth flow detaches the user object from the entity manager, so fetch it again\n        $this->user = $this->userRepository->findOneByUsername('user');\n    }\n\n    public function testBookmarkEntryToDefault(): void\n    {\n        $entry = $this->getEntryByTitle('entry');\n        $this->client->request('PUT', \"/api/bos/{$entry->getId()}/entry\", server: ['HTTP_AUTHORIZATION' => $this->token]);\n        self::assertResponseIsSuccessful();\n        $bookmarks = $this->bookmarkRepository->findByList($this->user, $this->bookmarkListRepository->findOneByUserDefault($this->user));\n        self::assertIsArray($bookmarks);\n        self::assertCount(1, $bookmarks);\n    }\n\n    public function testBookmarkEntryCommentToDefault(): void\n    {\n        $entry = $this->getEntryByTitle('entry');\n        $comment = $this->createEntryComment('comment', $entry);\n        $this->client->request('PUT', \"/api/bos/{$comment->getId()}/entry_comment\", server: ['HTTP_AUTHORIZATION' => $this->token]);\n        self::assertResponseIsSuccessful();\n        $bookmarks = $this->bookmarkRepository->findByList($this->user, $this->bookmarkListRepository->findOneByUserDefault($this->user));\n        self::assertIsArray($bookmarks);\n        self::assertCount(1, $bookmarks);\n    }\n\n    public function testBookmarkPostToDefault(): void\n    {\n        $post = $this->createPost('post');\n        $this->client->request('PUT', \"/api/bos/{$post->getId()}/post\", server: ['HTTP_AUTHORIZATION' => $this->token]);\n        self::assertResponseIsSuccessful();\n        $bookmarks = $this->bookmarkRepository->findByList($this->user, $this->bookmarkListRepository->findOneByUserDefault($this->user));\n        self::assertIsArray($bookmarks);\n        self::assertCount(1, $bookmarks);\n    }\n\n    public function testBookmarkPostCommentToDefault(): void\n    {\n        $post = $this->createPost('entry');\n        $comment = $this->createPostComment('comment', $post);\n        $this->client->request('PUT', \"/api/bos/{$comment->getId()}/post_comment\", server: ['HTTP_AUTHORIZATION' => $this->token]);\n        self::assertResponseIsSuccessful();\n        $bookmarks = $this->bookmarkRepository->findByList($this->user, $this->bookmarkListRepository->findOneByUserDefault($this->user));\n        self::assertIsArray($bookmarks);\n        self::assertCount(1, $bookmarks);\n    }\n\n    public function testRemoveBookmarkEntryFromDefault(): void\n    {\n        $entry = $this->getEntryByTitle('entry');\n        $this->client->request('PUT', \"/api/bos/{$entry->getId()}/entry\", server: ['HTTP_AUTHORIZATION' => $this->token]);\n        self::assertResponseIsSuccessful();\n        $list = $this->bookmarkListRepository->findOneByUserDefault($this->user);\n        $bookmarks = $this->bookmarkRepository->findByList($this->user, $list);\n        self::assertIsArray($bookmarks);\n        self::assertCount(1, $bookmarks);\n\n        $this->client->request('DELETE', \"/api/rbo/{$entry->getId()}/entry\", server: ['HTTP_AUTHORIZATION' => $this->token]);\n        self::assertResponseIsSuccessful();\n        $bookmarks = $this->bookmarkRepository->findByList($this->user, $list);\n        self::assertIsArray($bookmarks);\n        self::assertCount(0, $bookmarks);\n    }\n\n    public function testRemoveBookmarkEntryCommentFromDefault(): void\n    {\n        $entry = $this->getEntryByTitle('entry');\n        $comment = $this->createEntryComment('comment', $entry);\n        $this->client->request('PUT', \"/api/bos/{$comment->getId()}/entry_comment\", server: ['HTTP_AUTHORIZATION' => $this->token]);\n        self::assertResponseIsSuccessful();\n        $list = $this->bookmarkListRepository->findOneByUserDefault($this->user);\n        $bookmarks = $this->bookmarkRepository->findByList($this->user, $list);\n        self::assertIsArray($bookmarks);\n        self::assertCount(1, $bookmarks);\n\n        $this->client->request('DELETE', \"/api/rbo/{$comment->getId()}/entry_comment\", server: ['HTTP_AUTHORIZATION' => $this->token]);\n        self::assertResponseIsSuccessful();\n        $bookmarks = $this->bookmarkRepository->findByList($this->user, $list);\n        self::assertIsArray($bookmarks);\n        self::assertCount(0, $bookmarks);\n    }\n\n    public function testRemoveBookmarkPostFromDefault(): void\n    {\n        $post = $this->createPost('post');\n        $this->client->request('PUT', \"/api/bos/{$post->getId()}/post\", server: ['HTTP_AUTHORIZATION' => $this->token]);\n        self::assertResponseIsSuccessful();\n        $list = $this->bookmarkListRepository->findOneByUserDefault($this->user);\n        $bookmarks = $this->bookmarkRepository->findByList($this->user, $list);\n        self::assertIsArray($bookmarks);\n        self::assertCount(1, $bookmarks);\n\n        $this->client->request('DELETE', \"/api/rbo/{$post->getId()}/post\", server: ['HTTP_AUTHORIZATION' => $this->token]);\n        self::assertResponseIsSuccessful();\n        $bookmarks = $this->bookmarkRepository->findByList($this->user, $list);\n        self::assertIsArray($bookmarks);\n        self::assertCount(0, $bookmarks);\n    }\n\n    public function testRemoveBookmarkPostCommentFromDefault(): void\n    {\n        $post = $this->createPost('entry');\n        $comment = $this->createPostComment('comment', $post);\n        $this->client->request('PUT', \"/api/bos/{$comment->getId()}/post_comment\", server: ['HTTP_AUTHORIZATION' => $this->token]);\n        self::assertResponseIsSuccessful();\n        $list = $this->bookmarkListRepository->findOneByUserDefault($this->user);\n        $bookmarks = $this->bookmarkRepository->findByList($this->user, $list);\n        self::assertIsArray($bookmarks);\n        self::assertCount(1, $bookmarks);\n\n        $this->client->request('DELETE', \"/api/rbo/{$comment->getId()}/post_comment\", server: ['HTTP_AUTHORIZATION' => $this->token]);\n        self::assertResponseIsSuccessful();\n        $bookmarks = $this->bookmarkRepository->findByList($this->user, $list);\n        self::assertIsArray($bookmarks);\n        self::assertCount(0, $bookmarks);\n    }\n\n    public function testBookmarkEntryToList(): void\n    {\n        $this->entityManager->refresh($this->user);\n        $list = $this->bookmarkManager->createList($this->user, 'list');\n\n        $entry = $this->getEntryByTitle('entry');\n        $this->client->request('PUT', \"/api/bol/{$entry->getId()}/entry/$list->name\", server: ['HTTP_AUTHORIZATION' => $this->token]);\n        self::assertResponseIsSuccessful();\n        $bookmarks = $this->bookmarkRepository->findByList($this->user, $list);\n        self::assertIsArray($bookmarks);\n        self::assertCount(1, $bookmarks);\n    }\n\n    public function testBookmarkEntryCommentToList(): void\n    {\n        $list = $this->bookmarkManager->createList($this->user, 'list');\n        $entry = $this->getEntryByTitle('entry');\n        $comment = $this->createEntryComment('comment', $entry);\n        $this->client->request('PUT', \"/api/bol/{$comment->getId()}/entry_comment/$list->name\", server: ['HTTP_AUTHORIZATION' => $this->token]);\n        self::assertResponseIsSuccessful();\n        $bookmarks = $this->bookmarkRepository->findByList($this->user, $list);\n        self::assertIsArray($bookmarks);\n        self::assertCount(1, $bookmarks);\n    }\n\n    public function testBookmarkPostToList(): void\n    {\n        $list = $this->bookmarkManager->createList($this->user, 'list');\n        $post = $this->createPost('post');\n        $this->client->request('PUT', \"/api/bol/{$post->getId()}/post/$list->name\", server: ['HTTP_AUTHORIZATION' => $this->token]);\n        self::assertResponseIsSuccessful();\n        $bookmarks = $this->bookmarkRepository->findByList($this->user, $list);\n        self::assertIsArray($bookmarks);\n        self::assertCount(1, $bookmarks);\n    }\n\n    public function testBookmarkPostCommentToList(): void\n    {\n        $list = $this->bookmarkManager->createList($this->user, 'list');\n        $post = $this->createPost('entry');\n        $comment = $this->createPostComment('comment', $post);\n        $this->client->request('PUT', \"/api/bol/{$comment->getId()}/post_comment/$list->name\", server: ['HTTP_AUTHORIZATION' => $this->token]);\n        self::assertResponseIsSuccessful();\n        $bookmarks = $this->bookmarkRepository->findByList($this->user, $list);\n        self::assertIsArray($bookmarks);\n        self::assertCount(1, $bookmarks);\n    }\n\n    public function testRemoveBookmarkEntryFromList(): void\n    {\n        $list = $this->bookmarkManager->createList($this->user, 'list');\n        $entry = $this->getEntryByTitle('entry');\n        $this->client->request('PUT', \"/api/bol/{$entry->getId()}/entry/$list->name\", server: ['HTTP_AUTHORIZATION' => $this->token]);\n        self::assertResponseIsSuccessful();\n        $bookmarks = $this->bookmarkRepository->findByList($this->user, $list);\n        self::assertIsArray($bookmarks);\n        self::assertCount(1, $bookmarks);\n\n        $this->client->request('DELETE', \"/api/rbol/{$entry->getId()}/entry/$list->name\", server: ['HTTP_AUTHORIZATION' => $this->token]);\n        self::assertResponseIsSuccessful();\n        $bookmarks = $this->bookmarkRepository->findByList($this->user, $list);\n        self::assertIsArray($bookmarks);\n        self::assertCount(0, $bookmarks);\n    }\n\n    public function testRemoveBookmarkEntryCommentFromList(): void\n    {\n        $list = $this->bookmarkManager->createList($this->user, 'list');\n        $entry = $this->getEntryByTitle('entry');\n        $comment = $this->createEntryComment('comment', $entry);\n        $this->client->request('PUT', \"/api/bol/{$comment->getId()}/entry_comment/$list->name\", server: ['HTTP_AUTHORIZATION' => $this->token]);\n        self::assertResponseIsSuccessful();\n        $bookmarks = $this->bookmarkRepository->findByList($this->user, $list);\n        self::assertIsArray($bookmarks);\n        self::assertCount(1, $bookmarks);\n\n        $this->client->request('DELETE', \"/api/rbol/{$comment->getId()}/entry_comment/$list->name\", server: ['HTTP_AUTHORIZATION' => $this->token]);\n        self::assertResponseIsSuccessful();\n        $bookmarks = $this->bookmarkRepository->findByList($this->user, $list);\n        self::assertIsArray($bookmarks);\n        self::assertCount(0, $bookmarks);\n    }\n\n    public function testRemoveBookmarkPostFromList(): void\n    {\n        $list = $this->bookmarkManager->createList($this->user, 'list');\n        $post = $this->createPost('post');\n        $this->client->request('PUT', \"/api/bol/{$post->getId()}/post/$list->name\", server: ['HTTP_AUTHORIZATION' => $this->token]);\n        self::assertResponseIsSuccessful();\n        $bookmarks = $this->bookmarkRepository->findByList($this->user, $list);\n        self::assertIsArray($bookmarks);\n        self::assertCount(1, $bookmarks);\n\n        $this->client->request('DELETE', \"/api/rbol/{$post->getId()}/post/$list->name\", server: ['HTTP_AUTHORIZATION' => $this->token]);\n        self::assertResponseIsSuccessful();\n        $bookmarks = $this->bookmarkRepository->findByList($this->user, $list);\n        self::assertIsArray($bookmarks);\n        self::assertCount(0, $bookmarks);\n    }\n\n    public function testRemoveBookmarkPostCommentFromList(): void\n    {\n        $list = $this->bookmarkManager->createList($this->user, 'list');\n        $post = $this->createPost('entry');\n        $comment = $this->createPostComment('comment', $post);\n        $this->client->request('PUT', \"/api/bol/{$comment->getId()}/post_comment/$list->name\", server: ['HTTP_AUTHORIZATION' => $this->token]);\n        self::assertResponseIsSuccessful();\n        $bookmarks = $this->bookmarkRepository->findByList($this->user, $list);\n        self::assertIsArray($bookmarks);\n        self::assertCount(1, $bookmarks);\n\n        $this->client->request('DELETE', \"/api/rbol/{$comment->getId()}/post_comment/$list->name\", server: ['HTTP_AUTHORIZATION' => $this->token]);\n        self::assertResponseIsSuccessful();\n        $bookmarks = $this->bookmarkRepository->findByList($this->user, $list);\n        self::assertIsArray($bookmarks);\n        self::assertCount(0, $bookmarks);\n    }\n\n    public function testBookmarkedEntryJson(): void\n    {\n        $entry = $this->getEntryByTitle('entry');\n        $list = $this->bookmarkManager->createList($this->user, 'list');\n        $this->bookmarkManager->addBookmarkToDefaultList($this->user, $entry);\n        $this->bookmarkManager->addBookmark($this->user, $list, $entry);\n        $this->client->request('GET', \"/api/entry/{$entry->getId()}\", server: ['HTTP_AUTHORIZATION' => $this->token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n        assertIsArray($jsonData['bookmarks']);\n        assertCount(2, $jsonData['bookmarks']);\n        self::assertContains('list', $jsonData['bookmarks']);\n    }\n\n    public function testBookmarkedEntryCommentJson(): void\n    {\n        $entry = $this->getEntryByTitle('entry');\n        $comment = $this->createEntryComment('comment', $entry);\n        $list = $this->bookmarkManager->createList($this->user, 'list');\n        $this->bookmarkManager->addBookmarkToDefaultList($this->user, $comment);\n        $this->bookmarkManager->addBookmark($this->user, $list, $comment);\n        $this->client->request('GET', \"/api/comments/{$comment->getId()}\", server: ['HTTP_AUTHORIZATION' => $this->token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n        assertIsArray($jsonData['bookmarks']);\n        assertCount(2, $jsonData['bookmarks']);\n        self::assertContains('list', $jsonData['bookmarks']);\n    }\n\n    public function testBookmarkedPostJson(): void\n    {\n        $post = $this->createPost('post');\n        $list = $this->bookmarkManager->createList($this->user, 'list');\n        $this->bookmarkManager->addBookmarkToDefaultList($this->user, $post);\n        $this->bookmarkManager->addBookmark($this->user, $list, $post);\n        $this->client->request('GET', \"/api/post/{$post->getId()}\", server: ['HTTP_AUTHORIZATION' => $this->token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n        assertIsArray($jsonData['bookmarks']);\n        assertCount(2, $jsonData['bookmarks']);\n        self::assertContains('list', $jsonData['bookmarks']);\n    }\n\n    public function testBookmarkedPostCommentJson(): void\n    {\n        $post = $this->createPost('post');\n        $comment = $this->createPostComment('comment', $post);\n        $list = $this->bookmarkManager->createList($this->user, 'list');\n        $this->bookmarkManager->addBookmarkToDefaultList($this->user, $comment);\n        $this->bookmarkManager->addBookmark($this->user, $list, $comment);\n        $this->client->request('GET', \"/api/post-comments/{$comment->getId()}\", server: ['HTTP_AUTHORIZATION' => $this->token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n        assertIsArray($jsonData['bookmarks']);\n        assertCount(2, $jsonData['bookmarks']);\n        self::assertContains('list', $jsonData['bookmarks']);\n    }\n\n    public function testBookmarkListFront(): void\n    {\n        $list = $this->bookmarkManager->createList($this->user, 'list');\n        $entry = $this->getEntryByTitle('entry');\n        $comment = $this->createEntryComment('comment', $entry);\n        $comment2 = $this->createEntryComment('coment2', $entry, parent: $comment);\n\n        $post = $this->createPost('post');\n        $postComment = $this->createPostComment('comment', $post);\n        $postComment2 = $this->createPostComment('comment2', $post, parent: $postComment);\n\n        $this->bookmarkManager->addBookmark($this->user, $list, $entry);\n        $this->bookmarkManager->addBookmark($this->user, $list, $comment);\n        $this->bookmarkManager->addBookmark($this->user, $list, $comment2);\n        $this->bookmarkManager->addBookmark($this->user, $list, $post);\n        $this->bookmarkManager->addBookmark($this->user, $list, $postComment);\n        $this->bookmarkManager->addBookmark($this->user, $list, $postComment2);\n\n        $this->client->request('GET', \"/api/bookmark-lists/show?list={$list->name}\", server: ['HTTP_AUTHORIZATION' => $this->token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        assertIsArray($jsonData['items']);\n        assertCount(6, $jsonData['items']);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/Bookmark/BookmarkListApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\Bookmark;\n\nuse App\\DTO\\BookmarkListDto;\nuse App\\Entity\\User;\nuse App\\Tests\\WebTestCase;\n\nclass BookmarkListApiTest extends WebTestCase\n{\n    private User $user;\n    private string $token;\n\n    public function setUp(): void\n    {\n        parent::setUp();\n        $this->user = $this->getUserByUsername('user');\n        $this->client->loginUser($this->user);\n        self::createOAuth2PublicAuthCodeClient();\n        $codes = self::getPublicAuthorizationCodeTokenResponse($this->client, scopes: 'bookmark_list');\n        $this->token = $codes['token_type'].' '.$codes['access_token'];\n    }\n\n    public function testCreateList(): void\n    {\n        $this->client->request('GET', '/api/bookmark-lists', server: ['HTTP_AUTHORIZATION' => $this->token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(0, $jsonData['items']);\n\n        $this->client->request('POST', '/api/bookmark-lists/test-list', server: ['HTTP_AUTHORIZATION' => $this->token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertEquals('test-list', $jsonData['name']);\n        self::assertEquals(0, $jsonData['count']);\n        self::assertFalse($jsonData['isDefault']);\n\n        $this->client->request('GET', '/api/bookmark-lists', server: ['HTTP_AUTHORIZATION' => $this->token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(1, $jsonData['items']);\n        self::assertEquals('test-list', $jsonData['items'][0]['name']);\n        self::assertEquals(0, $jsonData['items'][0]['count']);\n        self::assertFalse($jsonData['items'][0]['isDefault']);\n    }\n\n    public function testRenameList(): void\n    {\n        $dto = new BookmarkListDto();\n        $dto->name = 'new-test-list';\n\n        $this->client->request('POST', '/api/bookmark-lists/test-list', server: ['HTTP_AUTHORIZATION' => $this->token]);\n        self::assertResponseIsSuccessful();\n\n        $this->client->jsonRequest('PUT', '/api/bookmark-lists/test-list', parameters: $dto->jsonSerialize(), server: ['HTTP_AUTHORIZATION' => $this->token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertEquals('new-test-list', $jsonData['name']);\n        self::assertEquals(0, $jsonData['count']);\n        self::assertFalse($jsonData['isDefault']);\n\n        $dto = new BookmarkListDto();\n        $dto->name = 'new-test-list2';\n        $dto->isDefault = true;\n\n        $this->client->jsonRequest('PUT', '/api/bookmark-lists/new-test-list', parameters: $dto->jsonSerialize(), server: ['HTTP_AUTHORIZATION' => $this->token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertEquals('new-test-list2', $jsonData['name']);\n        self::assertEquals(0, $jsonData['count']);\n        self::assertTrue($jsonData['isDefault']);\n    }\n\n    public function testDeleteList(): void\n    {\n        $this->client->request('GET', '/api/bookmark-lists', server: ['HTTP_AUTHORIZATION' => $this->token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(0, $jsonData['items']);\n\n        $this->client->request('POST', '/api/bookmark-lists/test-list', server: ['HTTP_AUTHORIZATION' => $this->token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        $this->client->request('GET', '/api/bookmark-lists', server: ['HTTP_AUTHORIZATION' => $this->token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(1, $jsonData['items']);\n\n        $this->client->request('DELETE', '/api/bookmark-lists/test-list', server: ['HTTP_AUTHORIZATION' => $this->token]);\n        self::assertResponseIsSuccessful();\n\n        $this->client->request('GET', '/api/bookmark-lists', server: ['HTTP_AUTHORIZATION' => $this->token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(0, $jsonData['items']);\n    }\n\n    public function testMakeListDefault(): void\n    {\n        $this->client->request('POST', '/api/bookmark-lists/test-list', server: ['HTTP_AUTHORIZATION' => $this->token]);\n        self::assertResponseIsSuccessful();\n\n        $this->client->jsonRequest('PUT', '/api/bookmark-lists/test-list/makeDefault', server: ['HTTP_AUTHORIZATION' => $this->token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertEquals('test-list', $jsonData['name']);\n        self::assertEquals(0, $jsonData['count']);\n        self::assertTrue($jsonData['isDefault']);\n\n        $this->client->request('GET', '/api/bookmark-lists', server: ['HTTP_AUTHORIZATION' => $this->token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(1, $jsonData['items']);\n        self::assertIsArray($jsonData['items'][0]);\n\n        self::assertEquals('test-list', $jsonData['items'][0]['name']);\n        self::assertEquals(0, $jsonData['items'][0]['count']);\n        self::assertTrue($jsonData['items'][0]['isDefault']);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/Combined/CombinedRetrieveApiCursoredTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\Combined;\n\nuse App\\Entity\\Magazine;\nuse App\\Entity\\User;\nuse App\\Tests\\WebTestCase;\n\nuse function PHPUnit\\Framework\\assertEquals;\n\nclass CombinedRetrieveApiCursoredTest extends WebTestCase\n{\n    private Magazine $magazine;\n    private User $user;\n    private array $generatedEntries = [];\n    private array $generatedPosts = [];\n\n    public function setUp(): void\n    {\n        parent::setUp();\n        $this->magazine = $this->getMagazineByName('acme');\n        $this->user = $this->getUserByUsername('user');\n        $this->magazineManager->subscribe($this->magazine, $this->user);\n        for ($i = 0; $i < 10; ++$i) {\n            $entry = $this->getEntryByTitle(\"Test Entry $i\", magazine: $this->magazine);\n            $entry->createdAt = new \\DateTimeImmutable(\"now - $i minutes\");\n            $this->entityManager->persist($entry);\n            $this->generatedEntries[] = $entry;\n            ++$i;\n            $post = $this->createPost(\"Test Post $i\", magazine: $this->magazine);\n            $post->createdAt = new \\DateTimeImmutable(\"now - $i minutes\");\n            $this->entityManager->persist($post);\n            $this->generatedPosts[] = $post;\n        }\n        $this->entityManager->flush();\n    }\n\n    public function testCombinedAnonymous(): void\n    {\n        $this->client->request('GET', '/api/combined?perPage=2&content=all&sort=newest');\n\n        self::assertResponseIsSuccessful();\n        $data = self::getJsonResponse($this->client);\n        self::assertArrayKeysMatch(WebTestCase::PAGINATED_KEYS, $data);\n        self::assertCount(2, $data['items']);\n        self::assertArrayKeysMatch(WebTestCase::PAGINATION_KEYS, $data['pagination']);\n        self::assertEquals(5, $data['pagination']['maxPage']);\n\n        self::assertArrayKeysMatch(WebTestCase::ENTRY_RESPONSE_KEYS, $data['items'][0]['entry']);\n        self::assertNull($data['items'][0]['post']);\n        assertEquals($this->generatedEntries[0]->getId(), $data['items'][0]['entry']['entryId']);\n        self::assertArrayKeysMatch(WebTestCase::POST_RESPONSE_KEYS, $data['items'][1]['post']);\n        self::assertNull($data['items'][1]['entry']);\n        assertEquals($this->generatedPosts[0]->getId(), $data['items'][1]['post']['postId']);\n    }\n\n    public function testCombinedCursoredAnonymous(): void\n    {\n        $this->client->request('GET', '/api/combined/v2?perPage=2&sort=newest');\n\n        self::assertResponseIsSuccessful();\n        $data = self::getJsonResponse($this->client);\n        $this->assertCursorDataShape($data);\n    }\n\n    public function testUserCombinedCursored(): void\n    {\n        $this->client->loginUser($this->user);\n        self::createOAuth2PublicAuthCodeClient();\n        $codes = self::getPublicAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n        $this->client->request('GET', '/api/combined/v2/subscribed?perPage=2&sort=newest', server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseIsSuccessful();\n        $data = self::getJsonResponse($this->client);\n        $this->assertCursorDataShape($data);\n    }\n\n    public function testCombinedCursoredPagination(): void\n    {\n        $this->client->request('GET', '/api/combined/v2?perPage=2&sort=commented');\n\n        self::assertResponseIsSuccessful();\n        $data1 = self::getJsonResponse($this->client);\n\n        self::assertCount(2, $data1['items']);\n        self::assertArrayKeysMatch(WebTestCase::CURSOR_PAGINATION_KEYS, $data1['pagination']);\n        self::assertNotNull($data1['pagination']['nextCursor']);\n        self::assertNotNull($data1['pagination']['nextCursor2']);\n        self::assertNotNull($data1['pagination']['currentCursor']);\n        self::assertNotNull($data1['pagination']['currentCursor2']);\n        self::assertNull($data1['pagination']['previousCursor']);\n        self::assertNull($data1['pagination']['previousCursor2']);\n\n        $this->client->request('GET', '/api/combined/v2?perPage=2&sort=commented&cursor='.urlencode($data1['pagination']['nextCursor']).'&cursor2='.urlencode($data1['pagination']['nextCursor2']));\n\n        self::assertResponseIsSuccessful();\n        $data2 = self::getJsonResponse($this->client);\n\n        self::assertCount(2, $data2['items']);\n        self::assertArrayKeysMatch(WebTestCase::CURSOR_PAGINATION_KEYS, $data2['pagination']);\n        self::assertNotNull($data2['pagination']['nextCursor']);\n        self::assertNotNull($data2['pagination']['nextCursor2']);\n        self::assertNotNull($data2['pagination']['currentCursor']);\n        self::assertNotNull($data2['pagination']['currentCursor2']);\n        self::assertNotNull($data2['pagination']['previousCursor']);\n        self::assertNotNull($data2['pagination']['previousCursor2']);\n\n        $this->client->request('GET', '/api/combined/v2?perPage=2&sort=commented&cursor='.urlencode($data2['pagination']['previousCursor']).'&cursor2='.urlencode($data2['pagination']['previousCursor2']));\n\n        self::assertResponseIsSuccessful();\n        $data3 = self::getJsonResponse($this->client);\n\n        self::assertCount(2, $data3['items']);\n        self::assertArrayKeysMatch(WebTestCase::CURSOR_PAGINATION_KEYS, $data3['pagination']);\n        self::assertNotNull($data3['pagination']['nextCursor']);\n        self::assertNotNull($data3['pagination']['nextCursor2']);\n        self::assertNotNull($data3['pagination']['currentCursor']);\n        self::assertNotNull($data3['pagination']['currentCursor2']);\n        self::assertNull($data3['pagination']['previousCursor']);\n        self::assertNull($data3['pagination']['previousCursor2']);\n\n        self::assertEquals($data1['items'][0]['entry']['entryId'], $data3['items'][0]['entry']['entryId']);\n        self::assertEquals($data1['items'][1]['post']['postId'], $data3['items'][1]['post']['postId']);\n    }\n\n    private function assertCursorDataShape(array $data): void\n    {\n        self::assertArrayKeysMatch(WebTestCase::PAGINATED_KEYS, $data);\n\n        self::assertCount(2, $data['items']);\n        self::assertArrayKeysMatch(WebTestCase::CURSOR_PAGINATION_KEYS, $data['pagination']);\n        self::assertNotNull($data['pagination']['nextCursor']);\n        self::assertNotNull($data['pagination']['nextCursor2']);\n        self::assertNotNull($data['pagination']['currentCursor']);\n        self::assertNotNull($data['pagination']['currentCursor2']);\n        self::assertNull($data['pagination']['previousCursor']);\n        self::assertNull($data['pagination']['previousCursor2']);\n\n        self::assertArrayKeysMatch(WebTestCase::ENTRY_RESPONSE_KEYS, $data['items'][0]['entry']);\n        self::assertNull($data['items'][0]['post']);\n        assertEquals($this->generatedEntries[0]->getId(), $data['items'][0]['entry']['entryId']);\n        self::assertArrayKeysMatch(WebTestCase::POST_RESPONSE_KEYS, $data['items'][1]['post']);\n        self::assertNull($data['items'][1]['entry']);\n        assertEquals($this->generatedPosts[0]->getId(), $data['items'][1]['post']['postId']);\n\n        $this->client->request('GET', '/api/combined/v2?perPage=2&sort=newest&cursor='.urlencode($data['pagination']['nextCursor']));\n        self::assertResponseIsSuccessful();\n        $data = self::getJsonResponse($this->client);\n\n        self::assertCount(2, $data['items']);\n        self::assertArrayKeysMatch(WebTestCase::CURSOR_PAGINATION_KEYS, $data['pagination']);\n        self::assertNotNull($data['pagination']['nextCursor']);\n        self::assertNotNull($data['pagination']['nextCursor2']);\n        self::assertNotNull($data['pagination']['currentCursor']);\n        self::assertNotNull($data['pagination']['currentCursor2']);\n        self::assertNotNull($data['pagination']['previousCursor']);\n        self::assertNotNull($data['pagination']['previousCursor2']);\n\n        self::assertArrayKeysMatch(WebTestCase::ENTRY_RESPONSE_KEYS, $data['items'][0]['entry']);\n        self::assertNull($data['items'][0]['post']);\n        assertEquals($this->generatedEntries[1]->getId(), $data['items'][0]['entry']['entryId']);\n        self::assertArrayKeysMatch(WebTestCase::POST_RESPONSE_KEYS, $data['items'][1]['post']);\n        self::assertNull($data['items'][1]['entry']);\n        assertEquals($this->generatedPosts[1]->getId(), $data['items'][1]['post']['postId']);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/Combined/CombinedRetrieveApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\Combined;\n\nuse App\\Tests\\WebTestCase;\n\nclass CombinedRetrieveApiTest extends WebTestCase\n{\n    public function testApiCanGetSubscribedContentWithBoosts(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $userFollowing = $this->getUserByUsername('user2');\n        $user3 = $this->getUserByUsername('user3');\n        $magazine = $this->getMagazineByName('abc');\n\n        $this->userManager->follow($user, $userFollowing, false);\n\n        $postFollowed = $this->createPost('a post', user: $userFollowing);\n        $postBoosted = $this->createPost('third user post', user: $user3);\n        $this->createPost('unrelated post', user: $user3);\n        $postCommentFollowed = $this->createPostComment('a comment', $postBoosted, $userFollowing);\n        $postCommentBoosted = $this->createPostComment('a boosted comment', $postBoosted, $user3);\n        $this->createPostComment('unrelated comment', $postBoosted, $user3);\n        $entryFollowed = $this->createEntry('title', $magazine, body: 'an entry', user: $userFollowing);\n        $entryBoosted = $this->createEntry('title', $magazine, body: 'third user post', user: $user3);\n        $this->createEntry('title', $magazine, body: 'unrelated post', user: $user3);\n        $entryCommentFollowed = $this->createEntryComment('a comment', $entryBoosted, $userFollowing);\n        $entryCommentBoosted = $this->createEntryComment('a boosted comment', $entryBoosted, $user3);\n        $this->createEntryComment('unrelated comment', $entryBoosted, $user3);\n\n        $this->voteManager->upvote($postBoosted, $userFollowing);\n        $this->voteManager->upvote($postCommentBoosted, $userFollowing);\n        $this->voteManager->upvote($entryBoosted, $userFollowing);\n        $this->voteManager->upvote($entryCommentBoosted, $userFollowing);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', '/api/combined/subscribed?includeBoosts=true', server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(8, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(8, $jsonData['pagination']['count']);\n\n        $retrievedPostIds = array_map(function ($item) {\n            if (null !== $item['post']) {\n                self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $item['post']);\n\n                return $item['post']['postId'];\n            } else {\n                return null;\n            }\n        }, $jsonData['items']);\n        $retrievedPostIds = array_filter($retrievedPostIds, function ($item) { return null !== $item; });\n        sort($retrievedPostIds);\n\n        $retrievedPostCommentIds = array_map(function ($item) {\n            if (null !== $item['postComment']) {\n                self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $item['postComment']);\n\n                return $item['postComment']['commentId'];\n            } else {\n                return null;\n            }\n        }, $jsonData['items']);\n        $retrievedPostCommentIds = array_filter($retrievedPostCommentIds, function ($item) { return null !== $item; });\n        sort($retrievedPostCommentIds);\n\n        $retrievedEntryIds = array_map(function ($item) {\n            if (null !== $item['entry']) {\n                self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $item['entry']);\n\n                return $item['entry']['entryId'];\n            } else {\n                return null;\n            }\n        }, $jsonData['items']);\n        $retrievedEntryIds = array_filter($retrievedEntryIds, function ($item) { return null !== $item; });\n        sort($retrievedEntryIds);\n\n        $retrievedEntryCommentIds = array_map(function ($item) {\n            if (null !== $item['entryComment']) {\n                self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $item['entryComment']);\n\n                return $item['entryComment']['commentId'];\n            } else {\n                return null;\n            }\n        }, $jsonData['items']);\n        $retrievedEntryCommentIds = array_filter($retrievedEntryCommentIds, function ($item) { return null !== $item; });\n        sort($retrievedEntryCommentIds);\n\n        $expectedPostIds = [$postFollowed->getId(), $postBoosted->getId()];\n        sort($expectedPostIds);\n        $expectedPostCommentIds = [$postCommentFollowed->getId(), $postCommentBoosted->getId()];\n        sort($expectedPostCommentIds);\n        $expectedEntryIds = [$entryFollowed->getId(), $entryBoosted->getId()];\n        sort($expectedEntryIds);\n        $expectedEntryCommentIds = [$entryCommentFollowed->getId(), $entryCommentBoosted->getId()];\n        sort($expectedEntryCommentIds);\n        self::assertEquals($retrievedPostIds, $expectedPostIds);\n        self::assertEquals($expectedPostCommentIds, $expectedPostCommentIds);\n        self::assertEquals($expectedEntryIds, $retrievedEntryIds);\n        self::assertEquals($expectedEntryCommentIds, $retrievedEntryCommentIds);\n    }\n\n    public function testApiHonersIncludeBoostsUserSetting(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $userFollowing = $this->getUserByUsername('user2');\n        $user3 = $this->getUserByUsername('user3');\n\n        $this->userManager->follow($user, $userFollowing, false);\n\n        $this->createPost('a post', user: $userFollowing);\n        $postBoosted = $this->createPost('third user post', user: $user3);\n\n        $this->voteManager->upvote($postBoosted, $userFollowing);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', '/api/combined/subscribed', server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(1, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(1, $jsonData['pagination']['count']);\n\n        $this->userRepository->find($user->getId())->showBoostsOfFollowing = true;\n        $this->entityManager->flush();\n\n        $this->client->request('GET', '/api/combined/subscribed', server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(2, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(2, $jsonData['pagination']['count']);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/Domain/DomainBlockApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\Domain;\n\nuse App\\Tests\\WebTestCase;\nuse PHPUnit\\Framework\\Attributes\\Group;\n\nclass DomainBlockApiTest extends WebTestCase\n{\n    public function testApiCannotBlockDomainAnonymous()\n    {\n        $domain = $this->getEntryByTitle('Test link to a domain', 'https://example.com')->domain;\n\n        $this->client->request('PUT', \"/api/domain/{$domain->getId()}/block\");\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotBlockDomainWithoutScope()\n    {\n        $domain = $this->getEntryByTitle('Test link to a domain', 'https://example.com')->domain;\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('PUT', \"/api/domain/{$domain->getId()}/block\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    #[Group(name: 'NonThreadSafe')]\n    public function testApiCanBlockDomain()\n    {\n        $domain = $this->getEntryByTitle('Test link to a domain', 'https://example.com')->domain;\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read domain:block');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('PUT', \"/api/domain/{$domain->getId()}/block\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(DomainRetrieveApiTest::DOMAIN_RESPONSE_KEYS, $jsonData);\n        self::assertEquals('example.com', $jsonData['name']);\n        self::assertSame(1, $jsonData['entryCount']);\n        self::assertSame(0, $jsonData['subscriptionsCount']);\n        self::assertTrue($jsonData['isBlockedByUser']);\n        // Scope not granted so subscribe flag not populated\n        self::assertNull($jsonData['isUserSubscribed']);\n\n        // Idempotent when called multiple times\n        $this->client->request('PUT', \"/api/domain/{$domain->getId()}/block\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(DomainRetrieveApiTest::DOMAIN_RESPONSE_KEYS, $jsonData);\n        self::assertEquals('example.com', $jsonData['name']);\n        self::assertSame(1, $jsonData['entryCount']);\n        self::assertSame(0, $jsonData['subscriptionsCount']);\n        self::assertTrue($jsonData['isBlockedByUser']);\n        // Scope not granted so subscribe flag not populated\n        self::assertNull($jsonData['isUserSubscribed']);\n    }\n\n    public function testApiCannotUnblockDomainAnonymous()\n    {\n        $domain = $this->getEntryByTitle('Test link to a domain', 'https://example.com')->domain;\n\n        $this->client->request('PUT', \"/api/domain/{$domain->getId()}/unblock\");\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotUnblockDomainWithoutScope()\n    {\n        $domain = $this->getEntryByTitle('Test link to a domain', 'https://example.com')->domain;\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('PUT', \"/api/domain/{$domain->getId()}/unblock\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    #[Group(name: 'NonThreadSafe')]\n    public function testApiCanUnblockDomain()\n    {\n        $user = $this->getUserByUsername('JohnDoe');\n        $domain = $this->getEntryByTitle('Test link to a domain', 'https://example.com')->domain;\n        $manager = $this->domainManager;\n        $manager->block($domain, $user);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read domain:block');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('PUT', \"/api/domain/{$domain->getId()}/unblock\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(DomainRetrieveApiTest::DOMAIN_RESPONSE_KEYS, $jsonData);\n        self::assertEquals('example.com', $jsonData['name']);\n        self::assertSame(1, $jsonData['entryCount']);\n        self::assertSame(0, $jsonData['subscriptionsCount']);\n        self::assertFalse($jsonData['isBlockedByUser']);\n        // Scope not granted so subscribe flag not populated\n        self::assertNull($jsonData['isUserSubscribed']);\n\n        // Idempotent when called multiple times\n        $this->client->request('PUT', \"/api/domain/{$domain->getId()}/unblock\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(DomainRetrieveApiTest::DOMAIN_RESPONSE_KEYS, $jsonData);\n        self::assertEquals('example.com', $jsonData['name']);\n        self::assertSame(1, $jsonData['entryCount']);\n        self::assertSame(0, $jsonData['subscriptionsCount']);\n        self::assertFalse($jsonData['isBlockedByUser']);\n        // Scope not granted so subscribe flag not populated\n        self::assertNull($jsonData['isUserSubscribed']);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/Domain/DomainRetrieveApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\Domain;\n\nuse App\\Tests\\WebTestCase;\n\nclass DomainRetrieveApiTest extends WebTestCase\n{\n    public function testApiCanRetrieveDomainsAnonymous()\n    {\n        $this->getEntryByTitle('Test link to a domain', 'https://example.com');\n\n        $this->client->request('GET', '/api/domains');\n        self::assertResponseIsSuccessful();\n\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(1, $jsonData['items']);\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(self::DOMAIN_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertEquals('example.com', $jsonData['items'][0]['name']);\n        self::assertSame(1, $jsonData['items'][0]['entryCount']);\n        self::assertSame(0, $jsonData['items'][0]['subscriptionsCount']);\n        self::assertNull($jsonData['items'][0]['isUserSubscribed']);\n        self::assertNull($jsonData['items'][0]['isBlockedByUser']);\n    }\n\n    public function testApiCanRetrieveDomains()\n    {\n        $this->getEntryByTitle('Test link to a domain', 'https://example.com');\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', '/api/domains', server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(1, $jsonData['items']);\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(self::DOMAIN_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertEquals('example.com', $jsonData['items'][0]['name']);\n        self::assertSame(1, $jsonData['items'][0]['entryCount']);\n        self::assertSame(0, $jsonData['items'][0]['subscriptionsCount']);\n        // Scope not granted so subscription and block flags not populated\n        self::assertNull($jsonData['items'][0]['isUserSubscribed']);\n        self::assertNull($jsonData['items'][0]['isBlockedByUser']);\n    }\n\n    public function testApiCanRetrieveDomainsSubscriptionAndBlockStatus()\n    {\n        $domain = $this->getEntryByTitle('Test link to a domain', 'https://example.com')->domain;\n        $user = $this->getUserByUsername('JohnDoe');\n        $manager = $this->domainManager;\n        $manager->subscribe($domain, $user);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read domain:subscribe domain:block');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', '/api/domains', server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(1, $jsonData['items']);\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(self::DOMAIN_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertEquals('example.com', $jsonData['items'][0]['name']);\n        self::assertSame(1, $jsonData['items'][0]['entryCount']);\n        self::assertSame(1, $jsonData['items'][0]['subscriptionsCount']);\n        // Scope granted so subscription and block flags populated\n        self::assertTrue($jsonData['items'][0]['isUserSubscribed']);\n        self::assertFalse($jsonData['items'][0]['isBlockedByUser']);\n    }\n\n    public function testApiCannotRetrieveSubscribedDomainsAnonymous()\n    {\n        $this->client->request('GET', '/api/domains/subscribed');\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotRetrieveSubscribedDomainsWithoutScope()\n    {\n        $this->getEntryByTitle('Test link to a second domain', 'https://example.org');\n        $domain = $this->getEntryByTitle('Test link to a domain', 'https://example.com')->domain;\n        $user = $this->getUserByUsername('JohnDoe');\n        $manager = $this->domainManager;\n        $manager->subscribe($domain, $user);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', '/api/domains/subscribed', server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanRetrieveSubscribedDomains()\n    {\n        $this->getEntryByTitle('Test link to a second domain', 'https://example.org');\n        $domain = $this->getEntryByTitle('Test link to a domain', 'https://example.com')->domain;\n        $user = $this->getUserByUsername('JohnDoe');\n        $manager = $this->domainManager;\n        $manager->subscribe($domain, $user);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read domain:subscribe');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', '/api/domains/subscribed', server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(1, $jsonData['items']);\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(self::DOMAIN_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertSame(1, $jsonData['items'][0]['entryCount']);\n        self::assertEquals('example.com', $jsonData['items'][0]['name']);\n        self::assertSame(1, $jsonData['items'][0]['entryCount']);\n        self::assertSame(1, $jsonData['items'][0]['subscriptionsCount']);\n        // Scope granted so subscription flag populated\n        self::assertTrue($jsonData['items'][0]['isUserSubscribed']);\n        self::assertNull($jsonData['items'][0]['isBlockedByUser']);\n    }\n\n    public function testApiCannotRetrieveBlockedDomainsAnonymous()\n    {\n        $this->client->request('GET', '/api/domains/blocked');\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotRetrieveBlockedDomainsWithoutScope()\n    {\n        $this->getEntryByTitle('Test link to a second domain', 'https://example.org');\n        $domain = $this->getEntryByTitle('Test link to a domain', 'https://example.com')->domain;\n        $user = $this->getUserByUsername('JohnDoe');\n        $manager = $this->domainManager;\n        $manager->block($domain, $user);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', '/api/domains/blocked', server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanRetrieveBlockedDomains()\n    {\n        $this->getEntryByTitle('Test link to a second domain', 'https://example.org');\n        $domain = $this->getEntryByTitle('Test link to a domain', 'https://example.com')->domain;\n        $user = $this->getUserByUsername('JohnDoe');\n        $manager = $this->domainManager;\n        $manager->block($domain, $user);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read domain:block');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', '/api/domains/blocked', server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(1, $jsonData['items']);\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(self::DOMAIN_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertSame(1, $jsonData['items'][0]['entryCount']);\n        self::assertEquals('example.com', $jsonData['items'][0]['name']);\n        self::assertSame(1, $jsonData['items'][0]['entryCount']);\n        self::assertSame(0, $jsonData['items'][0]['subscriptionsCount']);\n        // Scope granted so block flag populated\n        self::assertNull($jsonData['items'][0]['isUserSubscribed']);\n        self::assertTrue($jsonData['items'][0]['isBlockedByUser']);\n    }\n\n    public function testApiCanRetrieveDomainByIdAnonymous()\n    {\n        $domain = $this->getEntryByTitle('Test link to a domain', 'https://example.com')->domain;\n\n        $this->client->request('GET', \"/api/domain/{$domain->getId()}\");\n        self::assertResponseIsSuccessful();\n\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::DOMAIN_RESPONSE_KEYS, $jsonData);\n        self::assertEquals('example.com', $jsonData['name']);\n        self::assertSame(1, $jsonData['entryCount']);\n        self::assertSame(0, $jsonData['subscriptionsCount']);\n        self::assertNull($jsonData['isUserSubscribed']);\n        self::assertNull($jsonData['isBlockedByUser']);\n    }\n\n    public function testApiCanRetrieveDomainById()\n    {\n        $domain = $this->getEntryByTitle('Test link to a domain', 'https://example.com')->domain;\n        $user = $this->getUserByUsername('JohnDoe');\n        $manager = $this->domainManager;\n        $manager->subscribe($domain, $user);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/domain/{$domain->getId()}\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::DOMAIN_RESPONSE_KEYS, $jsonData);\n        self::assertEquals('example.com', $jsonData['name']);\n        self::assertSame(1, $jsonData['entryCount']);\n        self::assertSame(1, $jsonData['subscriptionsCount']);\n        // Scope not granted so subscription and block flags not populated\n        self::assertNull($jsonData['isUserSubscribed']);\n        self::assertNull($jsonData['isBlockedByUser']);\n    }\n\n    public function testApiCanRetrieveDomainByIdSubscriptionAndBlockStatus()\n    {\n        $domain = $this->getEntryByTitle('Test link to a domain', 'https://example.com')->domain;\n        $user = $this->getUserByUsername('JohnDoe');\n        $manager = $this->domainManager;\n        $manager->subscribe($domain, $user);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read domain:subscribe domain:block');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/domain/{$domain->getId()}\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::DOMAIN_RESPONSE_KEYS, $jsonData);\n        self::assertEquals('example.com', $jsonData['name']);\n        self::assertSame(1, $jsonData['entryCount']);\n        self::assertSame(1, $jsonData['subscriptionsCount']);\n        // Scope granted so subscription and block flags populated\n        self::assertTrue($jsonData['isUserSubscribed']);\n        self::assertFalse($jsonData['isBlockedByUser']);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/Domain/DomainSubscribeApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\Domain;\n\nuse App\\Tests\\WebTestCase;\n\nclass DomainSubscribeApiTest extends WebTestCase\n{\n    public function testApiCannotSubscribeToDomainAnonymous()\n    {\n        $domain = $this->getEntryByTitle('Test link to a domain', 'https://example.com')->domain;\n\n        $this->client->request('PUT', \"/api/domain/{$domain->getId()}/subscribe\");\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotSubscribeToDomainWithoutScope()\n    {\n        $domain = $this->getEntryByTitle('Test link to a domain', 'https://example.com')->domain;\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('PUT', \"/api/domain/{$domain->getId()}/subscribe\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanSubscribeToDomain()\n    {\n        $domain = $this->getEntryByTitle('Test link to a domain', 'https://example.com')->domain;\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read domain:subscribe');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('PUT', \"/api/domain/{$domain->getId()}/subscribe\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(DomainRetrieveApiTest::DOMAIN_RESPONSE_KEYS, $jsonData);\n        self::assertEquals('example.com', $jsonData['name']);\n        self::assertSame(1, $jsonData['entryCount']);\n        self::assertSame(1, $jsonData['subscriptionsCount']);\n        self::assertTrue($jsonData['isUserSubscribed']);\n        // Scope not granted so block flag not populated\n        self::assertNull($jsonData['isBlockedByUser']);\n\n        // Idempotent when called multiple times\n        $this->client->request('PUT', \"/api/domain/{$domain->getId()}/subscribe\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(DomainRetrieveApiTest::DOMAIN_RESPONSE_KEYS, $jsonData);\n        self::assertEquals('example.com', $jsonData['name']);\n        self::assertSame(1, $jsonData['entryCount']);\n        self::assertSame(1, $jsonData['subscriptionsCount']);\n        self::assertTrue($jsonData['isUserSubscribed']);\n        // Scope not granted so block flag not populated\n        self::assertNull($jsonData['isBlockedByUser']);\n    }\n\n    public function testApiCannotUnsubscribeFromDomainAnonymous()\n    {\n        $domain = $this->getEntryByTitle('Test link to a domain', 'https://example.com')->domain;\n\n        $this->client->request('PUT', \"/api/domain/{$domain->getId()}/unsubscribe\");\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotUnsubscribeFromDomainWithoutScope()\n    {\n        $domain = $this->getEntryByTitle('Test link to a domain', 'https://example.com')->domain;\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('PUT', \"/api/domain/{$domain->getId()}/unsubscribe\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanUnsubscribeFromDomain()\n    {\n        $user = $this->getUserByUsername('JohnDoe');\n        $domain = $this->getEntryByTitle('Test link to a domain', 'https://example.com')->domain;\n        $manager = $this->domainManager;\n        $manager->subscribe($domain, $user);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read domain:subscribe');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('PUT', \"/api/domain/{$domain->getId()}/unsubscribe\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(DomainRetrieveApiTest::DOMAIN_RESPONSE_KEYS, $jsonData);\n        self::assertEquals('example.com', $jsonData['name']);\n        self::assertSame(1, $jsonData['entryCount']);\n        self::assertSame(0, $jsonData['subscriptionsCount']);\n        self::assertFalse($jsonData['isUserSubscribed']);\n        // Scope not granted so block flag not populated\n        self::assertNull($jsonData['isBlockedByUser']);\n\n        // Idempotent when called multiple times\n        $this->client->request('PUT', \"/api/domain/{$domain->getId()}/unsubscribe\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(DomainRetrieveApiTest::DOMAIN_RESPONSE_KEYS, $jsonData);\n        self::assertEquals('example.com', $jsonData['name']);\n        self::assertSame(1, $jsonData['entryCount']);\n        self::assertSame(0, $jsonData['subscriptionsCount']);\n        self::assertFalse($jsonData['isUserSubscribed']);\n        // Scope not granted so block flag not populated\n        self::assertNull($jsonData['isBlockedByUser']);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/Entry/Admin/EntryChangeMagazineApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\Entry\\Admin;\n\nuse App\\Tests\\WebTestCase;\n\nclass EntryChangeMagazineApiTest extends WebTestCase\n{\n    public function testApiCannotChangeEntryMagazineAnonymous(): void\n    {\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $magazine2 = $this->getMagazineByNameNoRSAKey('acme2');\n        $entry = $this->getEntryByTitle('test article', body: 'test for favourite', magazine: $magazine);\n\n        $this->client->jsonRequest('PUT', \"/api/admin/entry/{$entry->getId()}/change-magazine/{$magazine2->getId()}\");\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiNonAdminCannotChangeEntryMagazine(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $magazine2 = $this->getMagazineByNameNoRSAKey('acme2');\n        $entry = $this->getEntryByTitle('test article', body: 'test for favourite', user: $user, magazine: $magazine);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read admin:magazine:move_entry');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/admin/entry/{$entry->getId()}/change-magazine/{$magazine2->getId()}\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCannotChangeEntryMagazineWithoutScope(): void\n    {\n        $user = $this->getUserByUsername('user', isAdmin: true);\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $magazine2 = $this->getMagazineByNameNoRSAKey('acme2');\n        $entry = $this->getEntryByTitle('test article', body: 'test for favourite', user: $user, magazine: $magazine);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/admin/entry/{$entry->getId()}/change-magazine/{$magazine2->getId()}\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanChangeEntryMagazine(): void\n    {\n        $user = $this->getUserByUsername('user', isAdmin: true);\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $magazine2 = $this->getMagazineByNameNoRSAKey('acme2');\n        $entry = $this->getEntryByTitle('test article', body: 'test for favourite', user: $user, magazine: $magazine);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read admin:magazine:move_entry');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/admin/entry/{$entry->getId()}/change-magazine/{$magazine2->getId()}\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData);\n        self::assertSame($entry->getId(), $jsonData['entryId']);\n        self::assertEquals($entry->title, $jsonData['title']);\n        self::assertIsArray($jsonData['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']);\n        self::assertSame($magazine2->getId(), $jsonData['magazine']['magazineId']);\n        self::assertIsArray($jsonData['user']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']);\n        self::assertSame($user->getId(), $jsonData['user']['userId']);\n        self::assertNull($jsonData['domain']);\n        self::assertNull($jsonData['url']);\n        self::assertEquals($entry->body, $jsonData['body']);\n        self::assertNull($jsonData['image']);\n        self::assertEquals($entry->lang, $jsonData['lang']);\n        self::assertEmpty($jsonData['tags']);\n        self::assertIsArray($jsonData['badges']);\n        self::assertEmpty($jsonData['badges']);\n        self::assertSame(0, $jsonData['numComments']);\n        self::assertSame(0, $jsonData['uv']);\n        self::assertSame(0, $jsonData['dv']);\n        self::assertSame(0, $jsonData['favourites']);\n        // No scope for seeing votes granted\n        self::assertNull($jsonData['isFavourited']);\n        self::assertNull($jsonData['userVote']);\n        self::assertFalse($jsonData['isOc']);\n        self::assertFalse($jsonData['isAdult']);\n        self::assertFalse($jsonData['isPinned']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid');\n        self::assertNull($jsonData['editedAt']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid');\n        self::assertEquals('article', $jsonData['type']);\n        self::assertEquals('test-article', $jsonData['slug']);\n        self::assertNull($jsonData['apId']);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/Entry/Admin/EntryPurgeApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\Entry\\Admin;\n\nuse App\\Tests\\WebTestCase;\n\nclass EntryPurgeApiTest extends WebTestCase\n{\n    public function testApiCannotPurgeArticleEntryAnonymous(): void\n    {\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $entry = $this->getEntryByTitle('test article', body: 'test for deletion', magazine: $magazine);\n\n        $this->client->request('DELETE', \"/api/admin/entry/{$entry->getId()}/purge\");\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotPurgeArticleEntryWithoutScope(): void\n    {\n        $user = $this->getUserByUsername('user', isAdmin: true);\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $entry = $this->getEntryByTitle('test article', body: 'test for deletion', user: $user, magazine: $magazine);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('DELETE', \"/api/admin/entry/{$entry->getId()}/purge\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiNonAdminCannotPurgeArticleEntry(): void\n    {\n        $otherUser = $this->getUserByUsername('somebody');\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $entry = $this->getEntryByTitle('test article', body: 'test for deletion', user: $otherUser, magazine: $magazine);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read admin:entry:purge');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('DELETE', \"/api/admin/entry/{$entry->getId()}/purge\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanPurgeArticleEntry(): void\n    {\n        $admin = $this->getUserByUsername('admin', isAdmin: true);\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $entry = $this->getEntryByTitle('test article', body: 'test for deletion', user: $user, magazine: $magazine);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($admin);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read admin:entry:purge');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('DELETE', \"/api/admin/entry/{$entry->getId()}/purge\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(204);\n    }\n\n    public function testApiCannotPurgeLinkEntryAnonymous(): void\n    {\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $entry = $this->getEntryByTitle('test link', url: 'https://google.com', magazine: $magazine);\n\n        $this->client->request('DELETE', \"/api/admin/entry/{$entry->getId()}/purge\");\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotPurgeLinkEntryWithoutScope(): void\n    {\n        $user = $this->getUserByUsername('user', isAdmin: true);\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $entry = $this->getEntryByTitle('test link', url: 'https://google.com', user: $user, magazine: $magazine);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('DELETE', \"/api/admin/entry/{$entry->getId()}/purge\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiNonAdminCannotPurgeLinkEntry(): void\n    {\n        $otherUser = $this->getUserByUsername('somebody');\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $entry = $this->getEntryByTitle('test link', url: 'https://google.com', user: $otherUser, magazine: $magazine);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read admin:entry:purge');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('DELETE', \"/api/admin/entry/{$entry->getId()}/purge\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanPurgeLinkEntry(): void\n    {\n        $admin = $this->getUserByUsername('admin', isAdmin: true);\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $entry = $this->getEntryByTitle('test link', url: 'https://google.com', user: $user, magazine: $magazine);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($admin);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read admin:entry:purge');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('DELETE', \"/api/admin/entry/{$entry->getId()}/purge\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(204);\n    }\n\n    public function testApiCannotPurgeImageEntryAnonymous(): void\n    {\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n\n        $imageDto = $this->getKibbyImageDto();\n        $entry = $this->getEntryByTitle('test image', image: $imageDto, magazine: $magazine);\n\n        $this->client->request('DELETE', \"/api/admin/entry/{$entry->getId()}/purge\");\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotPurgeImageEntryWithoutScope(): void\n    {\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $user = $this->getUserByUsername('user', isAdmin: true);\n\n        $imageDto = $this->getKibbyImageDto();\n        $entry = $this->getEntryByTitle('test image', image: $imageDto, user: $user, magazine: $magazine);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('DELETE', \"/api/admin/entry/{$entry->getId()}/purge\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiNonAdminCannotPurgeImageEntry(): void\n    {\n        $otherUser = $this->getUserByUsername('somebody');\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n\n        $imageDto = $this->getKibbyImageDto();\n        $entry = $this->getEntryByTitle('test image', image: $imageDto, user: $otherUser, magazine: $magazine);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read admin:entry:purge');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('DELETE', \"/api/admin/entry/{$entry->getId()}/purge\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanPurgeImageEntry(): void\n    {\n        $admin = $this->getUserByUsername('admin', isAdmin: true);\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n\n        $imageDto = $this->getKibbyImageDto();\n        $entry = $this->getEntryByTitle('test image', image: $imageDto, user: $user, magazine: $magazine);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($admin);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read admin:entry:purge');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('DELETE', \"/api/admin/entry/{$entry->getId()}/purge\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(204);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/Entry/Comment/Admin/EntryCommentPurgeApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\Entry\\Comment\\Admin;\n\nuse App\\Tests\\WebTestCase;\n\nclass EntryCommentPurgeApiTest extends WebTestCase\n{\n    public function testApiCannotPurgeArticleEntryAnonymous(): void\n    {\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $entry = $this->getEntryByTitle('test article', body: 'test for deletion', magazine: $magazine);\n        $comment = $this->createEntryComment('test comment', $entry);\n\n        $commentRepository = $this->entryCommentRepository;\n\n        $this->client->request('DELETE', \"/api/admin/comment/{$comment->getId()}/purge\");\n        self::assertResponseStatusCodeSame(401);\n\n        $comment = $commentRepository->find($comment->getId());\n        self::assertNotNull($comment);\n    }\n\n    public function testApiCannotPurgeArticleEntryWithoutScope(): void\n    {\n        $user = $this->getUserByUsername('user', isAdmin: true);\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $entry = $this->getEntryByTitle('test article', body: 'test for deletion', user: $user, magazine: $magazine);\n        $comment = $this->createEntryComment('test comment', $entry);\n\n        $commentRepository = $this->entryCommentRepository;\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('DELETE', \"/api/admin/comment/{$comment->getId()}/purge\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n\n        $comment = $commentRepository->find($comment->getId());\n        self::assertNotNull($comment);\n    }\n\n    public function testApiNonAdminCannotPurgeComment(): void\n    {\n        $otherUser = $this->getUserByUsername('somebody');\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $entry = $this->getEntryByTitle('test article', body: 'test for deletion', user: $otherUser, magazine: $magazine);\n        $comment = $this->createEntryComment('test comment', $entry);\n\n        $commentRepository = $this->entryCommentRepository;\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read admin:entry_comment:purge');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('DELETE', \"/api/admin/comment/{$comment->getId()}/purge\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n\n        $comment = $commentRepository->find($comment->getId());\n        self::assertNotNull($comment);\n    }\n\n    public function testApiCanPurgeComment(): void\n    {\n        $admin = $this->getUserByUsername('admin', isAdmin: true);\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $entry = $this->getEntryByTitle('test article', body: 'test for deletion', user: $user, magazine: $magazine);\n        $comment = $this->createEntryComment('test comment', $entry);\n\n        $commentRepository = $this->entryCommentRepository;\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($admin);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read admin:entry_comment:purge');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('DELETE', \"/api/admin/comment/{$comment->getId()}/purge\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(204);\n\n        $comment = $commentRepository->find($comment->getId());\n        self::assertNull($comment);\n    }\n\n    public function testApiCannotPurgeImageCommentAnonymous(): void\n    {\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n\n        $imageDto = $this->getKibbyImageDto();\n        $entry = $this->getEntryByTitle('test image', body: 'test', magazine: $magazine);\n        $comment = $this->createEntryComment('test comment', $entry, imageDto: $imageDto);\n\n        $commentRepository = $this->entryCommentRepository;\n\n        $this->client->request('DELETE', \"/api/admin/comment/{$comment->getId()}/purge\");\n        self::assertResponseStatusCodeSame(401);\n\n        $comment = $commentRepository->find($comment->getId());\n        self::assertNotNull($comment);\n    }\n\n    public function testApiCannotPurgeImageCommentWithoutScope(): void\n    {\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $user = $this->getUserByUsername('user', isAdmin: true);\n\n        $imageDto = $this->getKibbyImageDto();\n        $entry = $this->getEntryByTitle('test image', body: 'test', magazine: $magazine);\n        $comment = $this->createEntryComment('test comment', $entry, imageDto: $imageDto);\n\n        $commentRepository = $this->entryCommentRepository;\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('DELETE', \"/api/admin/comment/{$comment->getId()}/purge\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n\n        $comment = $commentRepository->find($comment->getId());\n        self::assertNotNull($comment);\n    }\n\n    public function testApiNonAdminCannotPurgeImageComment(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n\n        $imageDto = $this->getKibbyImageDto();\n        $entry = $this->getEntryByTitle('test image', body: 'test', magazine: $magazine);\n        $comment = $this->createEntryComment('test comment', $entry, imageDto: $imageDto);\n\n        $commentRepository = $this->entryCommentRepository;\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read admin:entry_comment:purge');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('DELETE', \"/api/admin/comment/{$comment->getId()}/purge\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n\n        $comment = $commentRepository->find($comment->getId());\n        self::assertNotNull($comment);\n    }\n\n    public function testApiCanPurgeImageComment(): void\n    {\n        $admin = $this->getUserByUsername('admin', isAdmin: true);\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n\n        $imageDto = $this->getKibbyImageDto();\n        $entry = $this->getEntryByTitle('test image', body: 'test', magazine: $magazine);\n        $comment = $this->createEntryComment('test comment', $entry, imageDto: $imageDto);\n\n        $commentRepository = $this->entryCommentRepository;\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($admin);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read admin:entry_comment:purge');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('DELETE', \"/api/admin/comment/{$comment->getId()}/purge\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(204);\n\n        $comment = $commentRepository->find($comment->getId());\n        self::assertNull($comment);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/Entry/Comment/DomainEntryCommentRetrieveApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\Entry\\Comment;\n\nuse App\\Tests\\WebTestCase;\n\nclass DomainEntryCommentRetrieveApiTest extends WebTestCase\n{\n    public function testApiCanGetDomainEntryCommentsAnonymous(): void\n    {\n        $this->getEntryByTitle('an entry', body: 'test');\n        $magazine = $this->getMagazineByNameNoRSAKey('somemag');\n        $entry = $this->getEntryByTitle('another entry', url: 'https://google.com', magazine: $magazine);\n        $comment = $this->createEntryComment('test comment', $entry);\n        $domain = $entry->domain;\n\n        $this->client->request('GET', \"/api/domain/{$domain->getId()}/comments\");\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(1, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(1, $jsonData['pagination']['count']);\n\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertEquals('test comment', $jsonData['items'][0]['body']);\n        self::assertIsArray($jsonData['items'][0]['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['magazine']);\n        self::assertSame($magazine->getId(), $jsonData['items'][0]['magazine']['magazineId']);\n        self::assertSame(0, $jsonData['items'][0]['childCount']);\n        self::assertIsArray($jsonData['items'][0]['children']);\n        self::assertEmpty($jsonData['items'][0]['children']);\n        self::assertSame($comment->getId(), $jsonData['items'][0]['commentId']);\n        self::assertSame($entry->getId(), $jsonData['items'][0]['entryId']);\n    }\n\n    public function testApiCanGetDomainEntryComments(): void\n    {\n        $this->getEntryByTitle('an entry', body: 'test');\n        $magazine = $this->getMagazineByNameNoRSAKey('somemag');\n        $entry = $this->getEntryByTitle('another entry', url: 'https://google.com', magazine: $magazine);\n        $comment = $this->createEntryComment('test comment', $entry);\n        $domain = $entry->domain;\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/domain/{$domain->getId()}/comments\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(1, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(1, $jsonData['pagination']['count']);\n\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertEquals('test comment', $jsonData['items'][0]['body']);\n        self::assertIsArray($jsonData['items'][0]['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['magazine']);\n        self::assertSame($magazine->getId(), $jsonData['items'][0]['magazine']['magazineId']);\n        self::assertSame(0, $jsonData['items'][0]['childCount']);\n        self::assertIsArray($jsonData['items'][0]['children']);\n        self::assertEmpty($jsonData['items'][0]['children']);\n        self::assertSame($comment->getId(), $jsonData['items'][0]['commentId']);\n        self::assertSame($entry->getId(), $jsonData['items'][0]['entryId']);\n    }\n\n    public function testApiCanGetDomainEntryCommentsDepth(): void\n    {\n        $this->getEntryByTitle('an entry', body: 'test');\n        $magazine = $this->getMagazineByNameNoRSAKey('somemag');\n        $entry = $this->getEntryByTitle('another entry', url: 'https://google.com', magazine: $magazine);\n        $comment = $this->createEntryComment('test comment', $entry);\n        $nested1 = $this->createEntryComment('test comment nested 1', $entry, parent: $comment);\n        $nested2 = $this->createEntryComment('test comment nested 2', $entry, parent: $nested1);\n        $nested3 = $this->createEntryComment('test comment nested 3', $entry, parent: $nested2);\n        $domain = $entry->domain;\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/domain/{$domain->getId()}/comments?d=2\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(1, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(1, $jsonData['pagination']['count']);\n\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertEquals('test comment', $jsonData['items'][0]['body']);\n        self::assertIsArray($jsonData['items'][0]['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['magazine']);\n        self::assertSame($magazine->getId(), $jsonData['items'][0]['magazine']['magazineId']);\n        self::assertSame(3, $jsonData['items'][0]['childCount']);\n        self::assertIsArray($jsonData['items'][0]['children']);\n        self::assertCount(1, $jsonData['items'][0]['children']);\n        $child = $jsonData['items'][0]['children'][0];\n        self::assertIsArray($child);\n        self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $child);\n        self::assertSame(2, $child['childCount']);\n        self::assertIsArray($child['children']);\n        self::assertCount(1, $child['children']);\n        self::assertIsArray($child['children'][0]);\n        self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $child);\n        self::assertSame(1, $child['children'][0]['childCount']);\n        self::assertIsArray($child['children'][0]['children']);\n        self::assertEmpty($child['children'][0]['children']);\n        self::assertSame($comment->getId(), $jsonData['items'][0]['commentId']);\n        self::assertSame($entry->getId(), $jsonData['items'][0]['entryId']);\n    }\n\n    public function testApiCanGetDomainEntryCommentsNewest(): void\n    {\n        $entry = $this->getEntryByTitle('entry', url: 'https://google.com');\n        $first = $this->createEntryComment('first', $entry);\n        $second = $this->createEntryComment('second', $entry);\n        $third = $this->createEntryComment('third', $entry);\n        $domain = $entry->domain;\n\n        $first->createdAt = new \\DateTimeImmutable('-1 hour');\n        $second->createdAt = new \\DateTimeImmutable('-1 second');\n        $third->createdAt = new \\DateTimeImmutable();\n\n        $entityManager = $this->entityManager;\n        $entityManager->persist($first);\n        $entityManager->persist($second);\n        $entityManager->persist($third);\n        $entityManager->flush();\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/domain/{$domain->getId()}/comments?sort=newest\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(3, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(3, $jsonData['pagination']['count']);\n\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertSame($third->getId(), $jsonData['items'][0]['commentId']);\n\n        self::assertIsArray($jsonData['items'][1]);\n        self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData['items'][1]);\n        self::assertSame($second->getId(), $jsonData['items'][1]['commentId']);\n\n        self::assertIsArray($jsonData['items'][2]);\n        self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData['items'][2]);\n        self::assertSame($first->getId(), $jsonData['items'][2]['commentId']);\n    }\n\n    public function testApiCanGetDomainEntryCommentsOldest(): void\n    {\n        $entry = $this->getEntryByTitle('entry', url: 'https://google.com');\n        $first = $this->createEntryComment('first', $entry);\n        $second = $this->createEntryComment('second', $entry);\n        $third = $this->createEntryComment('third', $entry);\n        $domain = $entry->domain;\n\n        $first->createdAt = new \\DateTimeImmutable('-1 hour');\n        $second->createdAt = new \\DateTimeImmutable('-1 second');\n        $third->createdAt = new \\DateTimeImmutable();\n\n        $entityManager = $this->entityManager;\n        $entityManager->persist($first);\n        $entityManager->persist($second);\n        $entityManager->persist($third);\n        $entityManager->flush();\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/domain/{$domain->getId()}/comments?sort=oldest\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(3, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(3, $jsonData['pagination']['count']);\n\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertSame($first->getId(), $jsonData['items'][0]['commentId']);\n\n        self::assertIsArray($jsonData['items'][1]);\n        self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData['items'][1]);\n        self::assertSame($second->getId(), $jsonData['items'][1]['commentId']);\n\n        self::assertIsArray($jsonData['items'][2]);\n        self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData['items'][2]);\n        self::assertSame($third->getId(), $jsonData['items'][2]['commentId']);\n    }\n\n    public function testApiCanGetDomainEntryCommentsActive(): void\n    {\n        $entry = $this->getEntryByTitle('entry', url: 'https://google.com');\n        $first = $this->createEntryComment('first', $entry);\n        $second = $this->createEntryComment('second', $entry);\n        $third = $this->createEntryComment('third', $entry);\n        $domain = $entry->domain;\n\n        $first->lastActive = new \\DateTime('-1 hour');\n        $second->lastActive = new \\DateTime('-1 second');\n        $third->lastActive = new \\DateTime();\n\n        $entityManager = $this->entityManager;\n        $entityManager->persist($first);\n        $entityManager->persist($second);\n        $entityManager->persist($third);\n        $entityManager->flush();\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/domain/{$domain->getId()}/comments?sort=active\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(3, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(3, $jsonData['pagination']['count']);\n\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertSame($third->getId(), $jsonData['items'][0]['commentId']);\n\n        self::assertIsArray($jsonData['items'][1]);\n        self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData['items'][1]);\n        self::assertSame($second->getId(), $jsonData['items'][1]['commentId']);\n\n        self::assertIsArray($jsonData['items'][2]);\n        self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData['items'][2]);\n        self::assertSame($first->getId(), $jsonData['items'][2]['commentId']);\n    }\n\n    public function testApiCanGetDomainEntryCommentsTop(): void\n    {\n        $entry = $this->getEntryByTitle('entry', url: 'https://google.com');\n        $first = $this->createEntryComment('first', $entry);\n        $second = $this->createEntryComment('second', $entry);\n        $third = $this->createEntryComment('third', $entry);\n        $domain = $entry->domain;\n\n        $favouriteManager = $this->favouriteManager;\n        $favouriteManager->toggle($this->getUserByUsername('voter1'), $first);\n        $favouriteManager->toggle($this->getUserByUsername('voter2'), $first);\n        $favouriteManager->toggle($this->getUserByUsername('voter1'), $second);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/domain/{$domain->getId()}/comments?sort=top\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(3, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(3, $jsonData['pagination']['count']);\n\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertSame($first->getId(), $jsonData['items'][0]['commentId']);\n        self::assertSame(2, $jsonData['items'][0]['favourites']);\n\n        self::assertIsArray($jsonData['items'][1]);\n        self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData['items'][1]);\n        self::assertSame($second->getId(), $jsonData['items'][1]['commentId']);\n        self::assertSame(1, $jsonData['items'][1]['favourites']);\n\n        self::assertIsArray($jsonData['items'][2]);\n        self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData['items'][2]);\n        self::assertSame($third->getId(), $jsonData['items'][2]['commentId']);\n        self::assertSame(0, $jsonData['items'][2]['favourites']);\n    }\n\n    public function testApiCanGetDomainEntryCommentsHot(): void\n    {\n        $entry = $this->getEntryByTitle('entry', url: 'https://google.com');\n        $first = $this->createEntryComment('first', $entry);\n        $second = $this->createEntryComment('second', $entry);\n        $third = $this->createEntryComment('third', $entry);\n        $domain = $entry->domain;\n\n        $voteManager = $this->voteManager;\n        $voteManager->vote(1, $first, $this->getUserByUsername('voter1'), rateLimit: false);\n        $voteManager->vote(1, $first, $this->getUserByUsername('voter2'), rateLimit: false);\n        $voteManager->vote(1, $second, $this->getUserByUsername('voter1'), rateLimit: false);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/domain/{$domain->getId()}/comments?sort=hot\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(3, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(3, $jsonData['pagination']['count']);\n\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertSame($first->getId(), $jsonData['items'][0]['commentId']);\n        self::assertSame(2, $jsonData['items'][0]['uv']);\n\n        self::assertIsArray($jsonData['items'][1]);\n        self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData['items'][1]);\n        self::assertSame($second->getId(), $jsonData['items'][1]['commentId']);\n        self::assertSame(1, $jsonData['items'][1]['uv']);\n\n        self::assertIsArray($jsonData['items'][2]);\n        self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData['items'][2]);\n        self::assertSame($third->getId(), $jsonData['items'][2]['commentId']);\n        self::assertSame(0, $jsonData['items'][2]['uv']);\n    }\n\n    public function testApiCanGetDomainEntryCommentsWithUserVoteStatus(): void\n    {\n        $this->getEntryByTitle('an entry', body: 'test');\n        $magazine = $this->getMagazineByNameNoRSAKey('somemag');\n        $entry = $this->getEntryByTitle('another entry', url: 'https://google.com', magazine: $magazine);\n        $comment = $this->createEntryComment('test comment', $entry);\n        $domain = $entry->domain;\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read vote');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/domain/{$domain->getId()}/comments\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(1, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(1, $jsonData['pagination']['count']);\n\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertSame($comment->getId(), $jsonData['items'][0]['commentId']);\n        self::assertSame($entry->getId(), $jsonData['items'][0]['entryId']);\n        self::assertEquals('test comment', $jsonData['items'][0]['body']);\n        self::assertIsArray($jsonData['items'][0]['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['magazine']);\n        self::assertSame($magazine->getId(), $jsonData['items'][0]['magazine']['magazineId']);\n        self::assertIsArray($jsonData['items'][0]['user']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['user']);\n        if (null !== $jsonData['items'][0]['image']) {\n            self::assertStringContainsString('google.com', parse_url($jsonData['items'][0]['image']['sourceUrl'], PHP_URL_HOST));\n        }\n        self::assertEquals('en', $jsonData['items'][0]['lang']);\n        self::assertEmpty($jsonData['items'][0]['tags']);\n        self::assertSame(0, $jsonData['items'][0]['childCount']);\n        self::assertIsArray($jsonData['items'][0]['children']);\n        self::assertEmpty($jsonData['items'][0]['children']);\n        self::assertSame(0, $jsonData['items'][0]['uv']);\n        self::assertSame(0, $jsonData['items'][0]['dv']);\n        self::assertSame(0, $jsonData['items'][0]['favourites']);\n        self::assertFalse($jsonData['items'][0]['isFavourited']);\n        self::assertSame(0, $jsonData['items'][0]['userVote']);\n        self::assertFalse($jsonData['items'][0]['isAdult']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['items'][0]['createdAt'], 'createdAt date format invalid');\n        self::assertNull($jsonData['items'][0]['editedAt']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['items'][0]['lastActive'], 'lastActive date format invalid');\n        self::assertNull($jsonData['items'][0]['apId']);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/Entry/Comment/EntryCommentCreateApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\Entry\\Comment;\n\nuse App\\Tests\\WebTestCase;\nuse Symfony\\Component\\HttpFoundation\\File\\UploadedFile;\n\nclass EntryCommentCreateApiTest extends WebTestCase\n{\n    public function testApiCannotCreateCommentAnonymous(): void\n    {\n        $entry = $this->getEntryByTitle('an entry', body: 'test');\n\n        $comment = [\n            'body' => 'Test comment',\n            'lang' => 'en',\n            'isAdult' => false,\n        ];\n\n        $this->client->jsonRequest(\n            'POST', \"/api/entry/{$entry->getId()}/comments\",\n            parameters: $comment\n        );\n\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotCreateCommentWithoutScope(): void\n    {\n        $entry = $this->getEntryByTitle('an entry', body: 'test');\n\n        $comment = [\n            'body' => 'Test comment',\n            'lang' => 'en',\n            'isAdult' => false,\n        ];\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest(\n            'POST', \"/api/entry/{$entry->getId()}/comments\",\n            parameters: $comment, server: ['HTTP_AUTHORIZATION' => $token]\n        );\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanCreateComment(): void\n    {\n        $entry = $this->getEntryByTitle('an entry', body: 'test');\n\n        $comment = [\n            'body' => 'Test comment',\n            'lang' => 'en',\n            'isAdult' => false,\n        ];\n\n        self::createOAuth2AuthCodeClient();\n        $user = $this->getUserByUsername('user');\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read entry_comment:create');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest(\n            'POST', \"/api/entry/{$entry->getId()}/comments\",\n            parameters: $comment, server: ['HTTP_AUTHORIZATION' => $token]\n        );\n\n        self::assertResponseStatusCodeSame(201);\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData);\n        self::assertSame($comment['body'], $jsonData['body']);\n        self::assertSame($comment['lang'], $jsonData['lang']);\n        self::assertSame($comment['isAdult'], $jsonData['isAdult']);\n        self::assertSame($entry->getId(), $jsonData['entryId']);\n        self::assertIsArray($jsonData['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']);\n        self::assertSame($entry->magazine->getId(), $jsonData['magazine']['magazineId']);\n        self::assertIsArray($jsonData['user']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']);\n        self::assertSame($user->getId(), $jsonData['user']['userId']);\n        self::assertNull($jsonData['rootId']);\n        self::assertNull($jsonData['parentId']);\n    }\n\n    public function testApiCannotCreateCommentReplyAnonymous(): void\n    {\n        $entry = $this->getEntryByTitle('an entry', body: 'test');\n        $entryComment = $this->createEntryComment('a comment', $entry);\n\n        $comment = [\n            'body' => 'Test comment',\n            'lang' => 'en',\n            'isAdult' => false,\n        ];\n\n        $this->client->jsonRequest(\n            'POST', \"/api/entry/{$entry->getId()}/comments/{$entryComment->getId()}/reply\",\n            parameters: $comment\n        );\n\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotCreateCommentReplyWithoutScope(): void\n    {\n        $entry = $this->getEntryByTitle('an entry', body: 'test');\n        $entryComment = $this->createEntryComment('a comment', $entry);\n\n        $comment = [\n            'body' => 'Test comment',\n            'lang' => 'en',\n            'isAdult' => false,\n        ];\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest(\n            'POST', \"/api/entry/{$entry->getId()}/comments/{$entryComment->getId()}/reply\",\n            parameters: $comment, server: ['HTTP_AUTHORIZATION' => $token]\n        );\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanCreateCommentReply(): void\n    {\n        $entry = $this->getEntryByTitle('an entry', body: 'test');\n        $entryComment = $this->createEntryComment('a comment', $entry);\n\n        $comment = [\n            'body' => 'Test comment',\n            'lang' => 'en',\n            'isAdult' => false,\n        ];\n\n        self::createOAuth2AuthCodeClient();\n        $user = $this->getUserByUsername('user');\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read entry_comment:create');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest(\n            'POST', \"/api/entry/{$entry->getId()}/comments/{$entryComment->getId()}/reply\",\n            parameters: $comment, server: ['HTTP_AUTHORIZATION' => $token]\n        );\n\n        self::assertResponseStatusCodeSame(201);\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData);\n        self::assertSame($comment['body'], $jsonData['body']);\n        self::assertSame($comment['lang'], $jsonData['lang']);\n        self::assertSame($comment['isAdult'], $jsonData['isAdult']);\n        self::assertSame($entry->getId(), $jsonData['entryId']);\n        self::assertIsArray($jsonData['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']);\n        self::assertSame($entry->magazine->getId(), $jsonData['magazine']['magazineId']);\n        self::assertIsArray($jsonData['user']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']);\n        self::assertSame($user->getId(), $jsonData['user']['userId']);\n        self::assertSame($entryComment->getId(), $jsonData['rootId']);\n        self::assertSame($entryComment->getId(), $jsonData['parentId']);\n    }\n\n    public function testApiCannotCreateImageCommentAnonymous(): void\n    {\n        $entry = $this->getEntryByTitle('an entry', body: 'test');\n\n        $comment = [\n            'body' => 'Test comment',\n            'lang' => 'en',\n            'isAdult' => false,\n            'alt' => 'It\\'s Kibby!',\n        ];\n\n        // Uploading a file appears to delete the file at the given path, so make a copy before upload\n        $tmpPath = bin2hex(random_bytes(32));\n        copy($this->kibbyPath, $tmpPath.'.png');\n        $image = new UploadedFile($tmpPath.'.png', 'kibby_emoji.png', 'image/png');\n\n        $this->client->request(\n            'POST', \"/api/entry/{$entry->getId()}/comments/image\",\n            parameters: $comment, files: ['uploadImage' => $image]\n        );\n\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotCreateImageCommentWithoutScope(): void\n    {\n        $entry = $this->getEntryByTitle('an entry', body: 'test');\n\n        $comment = [\n            'body' => 'Test comment',\n            'lang' => 'en',\n            'isAdult' => false,\n            'alt' => 'It\\'s Kibby!',\n        ];\n\n        // Uploading a file appears to delete the file at the given path, so make a copy before upload\n        $tmpPath = bin2hex(random_bytes(32));\n        copy($this->kibbyPath, $tmpPath.'.png');\n        $image = new UploadedFile($tmpPath.'.png', 'kibby_emoji.png', 'image/png');\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request(\n            'POST', \"/api/entry/{$entry->getId()}/comments/image\",\n            parameters: $comment, files: ['uploadImage' => $image],\n            server: ['HTTP_AUTHORIZATION' => $token]\n        );\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanCreateImageComment(): void\n    {\n        $entry = $this->getEntryByTitle('an entry', body: 'test');\n\n        $comment = [\n            'body' => 'Test comment',\n            'lang' => 'en',\n            'isAdult' => false,\n            'alt' => 'It\\'s Kibby!',\n        ];\n\n        // Uploading a file appears to delete the file at the given path, so make a copy before upload\n        $tmpPath = bin2hex(random_bytes(32));\n        copy($this->kibbyPath, $tmpPath.'.png');\n        $image = new UploadedFile($tmpPath.'.png', 'kibby_emoji.png', 'image/png');\n\n        self::createOAuth2AuthCodeClient();\n        $user = $this->getUserByUsername('user');\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read entry_comment:create');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request(\n            'POST', \"/api/entry/{$entry->getId()}/comments/image\",\n            parameters: $comment, files: ['uploadImage' => $image],\n            server: ['HTTP_AUTHORIZATION' => $token]\n        );\n\n        self::assertResponseStatusCodeSame(201);\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData);\n        self::assertSame($comment['body'], $jsonData['body']);\n        self::assertSame($comment['lang'], $jsonData['lang']);\n        self::assertSame($comment['isAdult'], $jsonData['isAdult']);\n        self::assertSame($entry->getId(), $jsonData['entryId']);\n        self::assertIsArray($jsonData['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']);\n        self::assertSame($entry->magazine->getId(), $jsonData['magazine']['magazineId']);\n        self::assertIsArray($jsonData['user']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']);\n        self::assertSame($user->getId(), $jsonData['user']['userId']);\n        self::assertNull($jsonData['rootId']);\n        self::assertNull($jsonData['parentId']);\n    }\n\n    public function testApiCannotCreateImageCommentReplyAnonymous(): void\n    {\n        $entry = $this->getEntryByTitle('an entry', body: 'test');\n        $entryComment = $this->createEntryComment('a comment', $entry);\n\n        $comment = [\n            'body' => 'Test comment',\n            'lang' => 'en',\n            'isAdult' => false,\n            'alt' => 'It\\'s Kibby!',\n        ];\n\n        // Uploading a file appears to delete the file at the given path, so make a copy before upload\n        $tmpPath = bin2hex(random_bytes(32));\n        copy($this->kibbyPath, $tmpPath.'.png');\n        $image = new UploadedFile($tmpPath.'.png', 'kibby_emoji.png', 'image/png');\n\n        $this->client->request(\n            'POST', \"/api/entry/{$entry->getId()}/comments/{$entryComment->getId()}/reply/image\",\n            parameters: $comment, files: ['uploadImage' => $image]\n        );\n\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotCreateImageCommentReplyWithoutScope(): void\n    {\n        $entry = $this->getEntryByTitle('an entry', body: 'test');\n        $entryComment = $this->createEntryComment('a comment', $entry);\n\n        $comment = [\n            'body' => 'Test comment',\n            'lang' => 'en',\n            'isAdult' => false,\n        ];\n\n        // Uploading a file appears to delete the file at the given path, so make a copy before upload\n        $tmpPath = bin2hex(random_bytes(32));\n        copy($this->kibbyPath, $tmpPath.'.png');\n        $image = new UploadedFile($tmpPath.'.png', 'kibby_emoji.png', 'image/png');\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request(\n            'POST', \"/api/entry/{$entry->getId()}/comments/{$entryComment->getId()}/reply/image\",\n            parameters: $comment, files: ['uploadImage' => $image],\n            server: ['HTTP_AUTHORIZATION' => $token]\n        );\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanCreateImageCommentReply(): void\n    {\n        $imageManager = $this->imageManager;\n        $entry = $this->getEntryByTitle('an entry', body: 'test');\n        $entryComment = $this->createEntryComment('a comment', $entry);\n\n        $comment = [\n            'body' => 'Test comment',\n            'lang' => 'en',\n            'isAdult' => false,\n            'alt' => 'It\\'s Kibby!',\n        ];\n\n        // Uploading a file appears to delete the file at the given path, so make a copy before upload\n        $tmpPath = bin2hex(random_bytes(32));\n        copy($this->kibbyPath, $tmpPath.'.png');\n        $image = new UploadedFile($tmpPath.'.png', 'kibby_emoji.png', 'image/png');\n        $resultingPath = $imageManager->getFilePath($image->getFilename());\n\n        self::createOAuth2AuthCodeClient();\n        $user = $this->getUserByUsername('user');\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read entry_comment:create');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request(\n            'POST', \"/api/entry/{$entry->getId()}/comments/{$entryComment->getId()}/reply/image\",\n            parameters: $comment, files: ['uploadImage' => $image],\n            server: ['HTTP_AUTHORIZATION' => $token]\n        );\n\n        self::assertResponseStatusCodeSame(201);\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData);\n        self::assertSame($comment['body'], $jsonData['body']);\n        self::assertSame($comment['lang'], $jsonData['lang']);\n        self::assertSame($comment['isAdult'], $jsonData['isAdult']);\n        self::assertSame($entry->getId(), $jsonData['entryId']);\n        self::assertIsArray($jsonData['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']);\n        self::assertSame($entry->magazine->getId(), $jsonData['magazine']['magazineId']);\n        self::assertIsArray($jsonData['user']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']);\n        self::assertSame($user->getId(), $jsonData['user']['userId']);\n        self::assertSame($entryComment->getId(), $jsonData['rootId']);\n        self::assertSame($entryComment->getId(), $jsonData['parentId']);\n        self::assertIsArray($jsonData['image']);\n        self::assertArrayKeysMatch(self::IMAGE_KEYS, $jsonData['image']);\n        self::assertEquals($resultingPath, $jsonData['image']['filePath']);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/Entry/Comment/EntryCommentDeleteApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\Entry\\Comment;\n\nuse App\\Tests\\WebTestCase;\n\nclass EntryCommentDeleteApiTest extends WebTestCase\n{\n    public function testApiCannotDeleteCommentAnonymous(): void\n    {\n        $entry = $this->getEntryByTitle('an entry', body: 'test');\n        $comment = $this->createEntryComment('test comment', $entry);\n\n        $this->client->request('DELETE', \"/api/comments/{$comment->getId()}\");\n\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotDeleteCommentWithoutScope(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $entry = $this->getEntryByTitle('an entry', body: 'test');\n        $comment = $this->createEntryComment('test comment', $entry, $user);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('DELETE', \"/api/comments/{$comment->getId()}\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCannotDeleteOtherUsersComment(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $user2 = $this->getUserByUsername('other');\n        $entry = $this->getEntryByTitle('an entry', body: 'test');\n        $comment = $this->createEntryComment('test comment', $entry, $user2);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read entry_comment:delete');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('DELETE', \"/api/comments/{$comment->getId()}\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanDeleteComment(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $entry = $this->getEntryByTitle('an entry', body: 'test');\n        $comment = $this->createEntryComment('test comment', $entry, $user);\n\n        $commentRepository = $this->entryCommentRepository;\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read entry_comment:delete');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('DELETE', \"/api/comments/{$comment->getId()}\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(204);\n        $comment = $commentRepository->find($comment->getId());\n        self::assertNull($comment);\n    }\n\n    public function testApiCanSoftDeleteComment(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $entry = $this->getEntryByTitle('an entry', body: 'test');\n        $comment = $this->createEntryComment('test comment', $entry, $user);\n        $this->createEntryComment('test comment', $entry, $user, $comment);\n\n        $commentRepository = $this->entryCommentRepository;\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read entry_comment:delete');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('DELETE', \"/api/comments/{$comment->getId()}\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(204);\n        $comment = $commentRepository->find($comment->getId());\n        self::assertNotNull($comment);\n        self::assertTrue($comment->isSoftDeleted());\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/Entry/Comment/EntryCommentReportApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\Entry\\Comment;\n\nuse App\\Tests\\WebTestCase;\n\nclass EntryCommentReportApiTest extends WebTestCase\n{\n    public function testApiCannotReportCommentAnonymous(): void\n    {\n        $entry = $this->getEntryByTitle('an entry', body: 'test');\n        $comment = $this->createEntryComment('test comment', $entry);\n\n        $report = [\n            'reason' => 'This comment breaks the rules!',\n        ];\n\n        $this->client->jsonRequest('POST', \"/api/comments/{$comment->getId()}/report\", $report);\n\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotReportCommentWithoutScope(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $entry = $this->getEntryByTitle('an entry', body: 'test');\n        $comment = $this->createEntryComment('test comment', $entry, $user);\n\n        $report = [\n            'reason' => 'This comment breaks the rules!',\n        ];\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('POST', \"/api/comments/{$comment->getId()}/report\", $report, server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanReportOtherUsersComment(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $user2 = $this->getUserByUsername('other');\n        $entry = $this->getEntryByTitle('an entry', body: 'test');\n        $comment = $this->createEntryComment('test comment', $entry, $user2);\n\n        $reportRepository = $this->reportRepository;\n\n        $report = [\n            'reason' => 'This comment breaks the rules!',\n        ];\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read entry_comment:report');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('POST', \"/api/comments/{$comment->getId()}/report\", $report, server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(204);\n        $report = $reportRepository->findBySubject($comment);\n        self::assertNotNull($report);\n        self::assertSame('This comment breaks the rules!', $report->reason);\n        self::assertSame($user->getId(), $report->reporting->getId());\n    }\n\n    public function testApiCanReportOwnComment(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $entry = $this->getEntryByTitle('an entry', body: 'test');\n        $comment = $this->createEntryComment('test comment', $entry, $user);\n\n        $reportRepository = $this->reportRepository;\n\n        $report = [\n            'reason' => 'This comment breaks the rules!',\n        ];\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read entry_comment:report');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('POST', \"/api/comments/{$comment->getId()}/report\", $report, server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(204);\n        $report = $reportRepository->findBySubject($comment);\n        self::assertNotNull($report);\n        self::assertSame('This comment breaks the rules!', $report->reason);\n        self::assertSame($user->getId(), $report->reporting->getId());\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/Entry/Comment/EntryCommentRetrieveApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\Entry\\Comment;\n\nuse App\\Tests\\WebTestCase;\n\nclass EntryCommentRetrieveApiTest extends WebTestCase\n{\n    public function testApiCanGetEntryCommentsAnonymous(): void\n    {\n        $entry = $this->getEntryByTitle('test entry', body: 'test');\n        for ($i = 0; $i < 5; ++$i) {\n            $this->createEntryComment(\"test parent comment {$i}\", $entry);\n        }\n\n        $this->client->request('GET', \"/api/entry/{$entry->getId()}/comments\");\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(5, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(5, $jsonData['pagination']['count']);\n\n        foreach ($jsonData['items'] as $comment) {\n            self::assertIsArray($comment);\n            self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $comment);\n            self::assertIsArray($comment['user']);\n            self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $comment['user']);\n            self::assertIsArray($comment['magazine']);\n            self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $comment['magazine']);\n            self::assertSame($entry->getId(), $comment['entryId']);\n            self::assertStringContainsString('test parent comment', $comment['body']);\n            self::assertSame('en', $comment['lang']);\n            self::assertSame(0, $comment['uv']);\n            self::assertSame(0, $comment['dv']);\n            self::assertSame(0, $comment['favourites']);\n            self::assertSame(0, $comment['childCount']);\n            self::assertSame('visible', $comment['visibility']);\n            self::assertIsArray($comment['mentions']);\n            self::assertEmpty($comment['mentions']);\n            self::assertIsArray($comment['children']);\n            self::assertEmpty($comment['children']);\n            self::assertFalse($comment['isAdult']);\n            self::assertNull($comment['image']);\n            self::assertNull($comment['parentId']);\n            self::assertNull($comment['rootId']);\n            self::assertNull($comment['isFavourited']);\n            self::assertNull($comment['userVote']);\n            self::assertNull($comment['apId']);\n            self::assertEmpty($comment['tags']);\n            self::assertNull($comment['editedAt']);\n            self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $comment['createdAt'], 'createdAt date format invalid');\n            self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $comment['lastActive'], 'lastActive date format invalid');\n            self::assertNull($comment['bookmarks']);\n        }\n    }\n\n    public function testApiCannotGetEntryCommentsByPreferredLangAnonymous(): void\n    {\n        $entry = $this->getEntryByTitle('test entry', body: 'test');\n        for ($i = 0; $i < 5; ++$i) {\n            $this->createEntryComment(\"test parent comment {$i}\", $entry);\n        }\n\n        $this->client->request('GET', \"/api/entry/{$entry->getId()}/comments?usePreferredLangs=true\");\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanGetEntryCommentsByPreferredLang(): void\n    {\n        $entry = $this->getEntryByTitle('test entry', body: 'test');\n        for ($i = 0; $i < 5; ++$i) {\n            $this->createEntryComment(\"test parent comment {$i}\", $entry);\n            $this->createEntryComment(\"test german parent comment {$i}\", $entry, lang: 'de');\n            $this->createEntryComment(\"test dutch parent comment {$i}\", $entry, lang: 'nl');\n        }\n\n        self::createOAuth2AuthCodeClient();\n        $user = $this->getUserByUsername('user');\n        $user->preferredLanguages = ['en', 'de'];\n\n        $entityManager = $this->entityManager;\n        $entityManager->persist($user);\n        $entityManager->flush();\n\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/entry/{$entry->getId()}/comments?usePreferredLangs=true\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(10, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(10, $jsonData['pagination']['count']);\n\n        foreach ($jsonData['items'] as $comment) {\n            self::assertIsArray($comment);\n            self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $comment);\n            self::assertIsArray($comment['user']);\n            self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $comment['user']);\n            self::assertIsArray($comment['magazine']);\n            self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $comment['magazine']);\n            self::assertSame($entry->getId(), $comment['entryId']);\n            self::assertStringContainsString('parent comment', $comment['body']);\n            self::assertTrue('en' === $comment['lang'] || 'de' === $comment['lang']);\n            self::assertSame(0, $comment['uv']);\n            self::assertSame(0, $comment['dv']);\n            self::assertSame(0, $comment['favourites']);\n            self::assertSame(0, $comment['childCount']);\n            self::assertSame('visible', $comment['visibility']);\n            self::assertIsArray($comment['mentions']);\n            self::assertEmpty($comment['mentions']);\n            self::assertIsArray($comment['children']);\n            self::assertEmpty($comment['children']);\n            self::assertFalse($comment['isAdult']);\n            self::assertNull($comment['image']);\n            self::assertNull($comment['parentId']);\n            self::assertNull($comment['rootId']);\n            // No scope granted so these should be null\n            self::assertNull($comment['isFavourited']);\n            self::assertNull($comment['userVote']);\n            self::assertNull($comment['apId']);\n            self::assertEmpty($comment['tags']);\n            self::assertNull($comment['editedAt']);\n            self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $comment['createdAt'], 'createdAt date format invalid');\n            self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $comment['lastActive'], 'lastActive date format invalid');\n            self::assertIsArray($comment['bookmarks']);\n            self::assertEmpty($comment['bookmarks']);\n        }\n    }\n\n    public function testApiCanGetEntryCommentsWithLanguageAnonymous(): void\n    {\n        $entry = $this->getEntryByTitle('test entry', body: 'test');\n        for ($i = 0; $i < 5; ++$i) {\n            $this->createEntryComment(\"test parent comment {$i}\", $entry);\n            $this->createEntryComment(\"test german parent comment {$i}\", $entry, lang: 'de');\n            $this->createEntryComment(\"test dutch comment {$i}\", $entry, lang: 'nl');\n        }\n\n        $this->client->request('GET', \"/api/entry/{$entry->getId()}/comments?lang[]=en&lang[]=de\");\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(10, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(10, $jsonData['pagination']['count']);\n\n        foreach ($jsonData['items'] as $comment) {\n            self::assertIsArray($comment);\n            self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $comment);\n            self::assertIsArray($comment['user']);\n            self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $comment['user']);\n            self::assertIsArray($comment['magazine']);\n            self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $comment['magazine']);\n            self::assertSame($entry->getId(), $comment['entryId']);\n            self::assertStringContainsString('parent comment', $comment['body']);\n            self::assertTrue('en' === $comment['lang'] || 'de' === $comment['lang']);\n            self::assertSame(0, $comment['uv']);\n            self::assertSame(0, $comment['dv']);\n            self::assertSame(0, $comment['favourites']);\n            self::assertSame(0, $comment['childCount']);\n            self::assertSame('visible', $comment['visibility']);\n            self::assertIsArray($comment['mentions']);\n            self::assertEmpty($comment['mentions']);\n            self::assertIsArray($comment['children']);\n            self::assertEmpty($comment['children']);\n            self::assertFalse($comment['isAdult']);\n            self::assertNull($comment['image']);\n            self::assertNull($comment['parentId']);\n            self::assertNull($comment['rootId']);\n            self::assertNull($comment['isFavourited']);\n            self::assertNull($comment['userVote']);\n            self::assertNull($comment['apId']);\n            self::assertEmpty($comment['tags']);\n            self::assertNull($comment['editedAt']);\n            self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $comment['createdAt'], 'createdAt date format invalid');\n            self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $comment['lastActive'], 'lastActive date format invalid');\n            self::assertNull($comment['bookmarks']);\n        }\n    }\n\n    public function testApiCanGetEntryCommentsWithLanguage(): void\n    {\n        $entry = $this->getEntryByTitle('test entry', body: 'test');\n        for ($i = 0; $i < 5; ++$i) {\n            $this->createEntryComment(\"test parent comment {$i}\", $entry);\n            $this->createEntryComment(\"test german parent comment {$i}\", $entry, lang: 'de');\n            $this->createEntryComment(\"test dutch parent comment {$i}\", $entry, lang: 'nl');\n        }\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/entry/{$entry->getId()}/comments?lang[]=en&lang[]=de\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(10, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(10, $jsonData['pagination']['count']);\n\n        foreach ($jsonData['items'] as $comment) {\n            self::assertIsArray($comment);\n            self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $comment);\n            self::assertIsArray($comment['user']);\n            self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $comment['user']);\n            self::assertIsArray($comment['magazine']);\n            self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $comment['magazine']);\n            self::assertSame($entry->getId(), $comment['entryId']);\n            self::assertStringContainsString('parent comment', $comment['body']);\n            self::assertTrue('en' === $comment['lang'] || 'de' === $comment['lang']);\n            self::assertSame(0, $comment['uv']);\n            self::assertSame(0, $comment['dv']);\n            self::assertSame(0, $comment['favourites']);\n            self::assertSame(0, $comment['childCount']);\n            self::assertSame('visible', $comment['visibility']);\n            self::assertIsArray($comment['mentions']);\n            self::assertEmpty($comment['mentions']);\n            self::assertIsArray($comment['children']);\n            self::assertEmpty($comment['children']);\n            self::assertFalse($comment['isAdult']);\n            self::assertNull($comment['image']);\n            self::assertNull($comment['parentId']);\n            self::assertNull($comment['rootId']);\n            // No scope granted so these should be null\n            self::assertNull($comment['isFavourited']);\n            self::assertNull($comment['userVote']);\n            self::assertNull($comment['apId']);\n            self::assertEmpty($comment['tags']);\n            self::assertNull($comment['editedAt']);\n            self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $comment['createdAt'], 'createdAt date format invalid');\n            self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $comment['lastActive'], 'lastActive date format invalid');\n            self::assertIsArray($comment['bookmarks']);\n            self::assertEmpty($comment['bookmarks']);\n        }\n    }\n\n    public function testApiCanGetEntryComments(): void\n    {\n        $entry = $this->getEntryByTitle('test entry', body: 'test');\n        for ($i = 0; $i < 5; ++$i) {\n            $this->createEntryComment(\"test parent comment {$i}\", $entry);\n        }\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/entry/{$entry->getId()}/comments\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(5, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(5, $jsonData['pagination']['count']);\n\n        foreach ($jsonData['items'] as $comment) {\n            self::assertIsArray($comment);\n            self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $comment);\n            self::assertIsArray($comment['user']);\n            self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $comment['user']);\n            self::assertIsArray($comment['magazine']);\n            self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $comment['magazine']);\n            self::assertSame($entry->getId(), $comment['entryId']);\n            self::assertStringContainsString('test parent comment', $comment['body']);\n            self::assertSame('en', $comment['lang']);\n            self::assertSame(0, $comment['uv']);\n            self::assertSame(0, $comment['dv']);\n            self::assertSame(0, $comment['favourites']);\n            self::assertSame(0, $comment['childCount']);\n            self::assertSame('visible', $comment['visibility']);\n            self::assertIsArray($comment['mentions']);\n            self::assertEmpty($comment['mentions']);\n            self::assertIsArray($comment['children']);\n            self::assertEmpty($comment['children']);\n            self::assertFalse($comment['isAdult']);\n            self::assertNull($comment['image']);\n            self::assertNull($comment['parentId']);\n            self::assertNull($comment['rootId']);\n            // No scope granted so these should be null\n            self::assertNull($comment['isFavourited']);\n            self::assertNull($comment['userVote']);\n            self::assertNull($comment['apId']);\n            self::assertEmpty($comment['tags']);\n            self::assertNull($comment['editedAt']);\n            self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $comment['createdAt'], 'createdAt date format invalid');\n            self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $comment['lastActive'], 'lastActive date format invalid');\n            self::assertIsArray($comment['bookmarks']);\n            self::assertEmpty($comment['bookmarks']);\n        }\n    }\n\n    public function testApiCanGetEntryCommentsWithChildren(): void\n    {\n        $entry = $this->getEntryByTitle('test entry', body: 'test');\n        for ($i = 0; $i < 5; ++$i) {\n            $comment = $this->createEntryComment(\"test parent comment {$i}\", $entry);\n            $this->createEntryComment(\"test child comment {$i}\", $entry, parent: $comment);\n        }\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/entry/{$entry->getId()}/comments\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(5, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(5, $jsonData['pagination']['count']);\n\n        foreach ($jsonData['items'] as $comment) {\n            self::assertIsArray($comment);\n            self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $comment);\n            self::assertIsArray($comment['user']);\n            self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $comment['user']);\n            self::assertIsArray($comment['magazine']);\n            self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $comment['magazine']);\n            self::assertSame($entry->getId(), $comment['entryId']);\n            self::assertStringContainsString('test parent comment', $comment['body']);\n            self::assertSame('en', $comment['lang']);\n            self::assertSame(0, $comment['uv']);\n            self::assertSame(0, $comment['dv']);\n            self::assertSame(0, $comment['favourites']);\n            self::assertSame(1, $comment['childCount']);\n            self::assertSame('visible', $comment['visibility']);\n            self::assertIsArray($comment['mentions']);\n            self::assertEmpty($comment['mentions']);\n            self::assertIsArray($comment['children']);\n            self::assertCount(1, $comment['children']);\n            self::assertIsArray($comment['children'][0]);\n            self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $comment['children'][0]);\n            self::assertStringContainsString('test child comment', $comment['children'][0]['body']);\n            self::assertFalse($comment['isAdult']);\n            self::assertNull($comment['image']);\n            self::assertNull($comment['parentId']);\n            self::assertNull($comment['rootId']);\n            // No scope granted so these should be null\n            self::assertNull($comment['isFavourited']);\n            self::assertNull($comment['userVote']);\n            self::assertNull($comment['apId']);\n            self::assertEmpty($comment['tags']);\n            self::assertNull($comment['editedAt']);\n            self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $comment['createdAt'], 'createdAt date format invalid');\n            self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $comment['lastActive'], 'lastActive date format invalid');\n            self::assertIsArray($comment['bookmarks']);\n            self::assertEmpty($comment['bookmarks']);\n        }\n    }\n\n    public function testApiCanGetEntryCommentsLimitedDepth(): void\n    {\n        $entry = $this->getEntryByTitle('test entry', body: 'test');\n        for ($i = 0; $i < 2; ++$i) {\n            $comment = $this->createEntryComment(\"test parent comment {$i}\", $entry);\n            $parent = $comment;\n            for ($j = 1; $j <= 5; ++$j) {\n                $parent = $this->createEntryComment(\"test child comment {$i} depth {$j}\", $entry, parent: $parent);\n            }\n        }\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/entry/{$entry->getId()}/comments?d=3\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(2, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(2, $jsonData['pagination']['count']);\n\n        foreach ($jsonData['items'] as $comment) {\n            self::assertIsArray($comment);\n            self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $comment);\n            self::assertIsArray($comment['user']);\n            self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $comment['user']);\n            self::assertIsArray($comment['magazine']);\n            self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $comment['magazine']);\n            self::assertSame($entry->getId(), $comment['entryId']);\n            self::assertStringContainsString('test parent comment', $comment['body']);\n            self::assertSame('en', $comment['lang']);\n            self::assertSame(0, $comment['uv']);\n            self::assertSame(0, $comment['dv']);\n            self::assertSame(0, $comment['favourites']);\n            self::assertSame(5, $comment['childCount']);\n            self::assertSame('visible', $comment['visibility']);\n            self::assertIsArray($comment['mentions']);\n            self::assertEmpty($comment['mentions']);\n            self::assertIsArray($comment['children']);\n            self::assertCount(1, $comment['children']);\n            $depth = 0;\n            $current = $comment;\n            while (\\count($current['children']) > 0) {\n                self::assertIsArray($current['children'][0]);\n                self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $current['children'][0]);\n                self::assertStringContainsString('test child comment', $current['children'][0]['body']);\n                self::assertSame(5 - ($depth + 1), $current['children'][0]['childCount']);\n                $current = $current['children'][0];\n                ++$depth;\n            }\n            self::assertSame(3, $depth);\n            self::assertFalse($comment['isAdult']);\n            self::assertNull($comment['image']);\n            self::assertNull($comment['parentId']);\n            self::assertNull($comment['rootId']);\n            // No scope granted so these should be null\n            self::assertNull($comment['isFavourited']);\n            self::assertNull($comment['userVote']);\n            self::assertNull($comment['apId']);\n            self::assertEmpty($comment['tags']);\n            self::assertNull($comment['editedAt']);\n            self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $comment['createdAt'], 'createdAt date format invalid');\n            self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $comment['lastActive'], 'lastActive date format invalid');\n            self::assertIsArray($comment['bookmarks']);\n            self::assertEmpty($comment['bookmarks']);\n        }\n    }\n\n    public function testApiCanGetEntryCommentByIdAnonymous(): void\n    {\n        $entry = $this->getEntryByTitle('test entry', body: 'test');\n        $comment = $this->createEntryComment('test parent comment', $entry);\n\n        $this->client->request('GET', \"/api/comments/{$comment->getId()}\");\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData);\n        self::assertIsArray($jsonData['user']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']);\n        self::assertIsArray($jsonData['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']);\n        self::assertSame($entry->getId(), $jsonData['entryId']);\n        self::assertStringContainsString('test parent comment', $jsonData['body']);\n        self::assertSame('en', $jsonData['lang']);\n        self::assertSame(0, $jsonData['uv']);\n        self::assertSame(0, $jsonData['dv']);\n        self::assertSame(0, $jsonData['favourites']);\n        self::assertSame(0, $jsonData['childCount']);\n        self::assertSame('visible', $jsonData['visibility']);\n        self::assertIsArray($jsonData['mentions']);\n        self::assertEmpty($jsonData['mentions']);\n        self::assertIsArray($jsonData['children']);\n        self::assertEmpty($jsonData['children']);\n        self::assertFalse($jsonData['isAdult']);\n        self::assertNull($jsonData['image']);\n        self::assertNull($jsonData['parentId']);\n        self::assertNull($jsonData['rootId']);\n        self::assertNull($jsonData['isFavourited']);\n        self::assertNull($jsonData['userVote']);\n        self::assertNull($jsonData['apId']);\n        self::assertEmpty($jsonData['tags']);\n        self::assertNull($jsonData['editedAt']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid');\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid');\n        self::assertNull($jsonData['bookmarks']);\n    }\n\n    public function testApiCanGetEntryCommentById(): void\n    {\n        $entry = $this->getEntryByTitle('test entry', body: 'test');\n        $comment = $this->createEntryComment('test parent comment', $entry);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/comments/{$comment->getId()}\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData);\n        self::assertIsArray($jsonData['user']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']);\n        self::assertIsArray($jsonData['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']);\n        self::assertSame($entry->getId(), $jsonData['entryId']);\n        self::assertStringContainsString('test parent comment', $jsonData['body']);\n        self::assertSame('en', $jsonData['lang']);\n        self::assertSame(0, $jsonData['uv']);\n        self::assertSame(0, $jsonData['dv']);\n        self::assertSame(0, $jsonData['favourites']);\n        self::assertSame(0, $jsonData['childCount']);\n        self::assertSame('visible', $jsonData['visibility']);\n        self::assertIsArray($jsonData['mentions']);\n        self::assertEmpty($jsonData['mentions']);\n        self::assertIsArray($jsonData['children']);\n        self::assertEmpty($jsonData['children']);\n        self::assertFalse($jsonData['isAdult']);\n        self::assertNull($jsonData['image']);\n        self::assertNull($jsonData['parentId']);\n        self::assertNull($jsonData['rootId']);\n        // No scope granted so these should be null\n        self::assertNull($jsonData['isFavourited']);\n        self::assertNull($jsonData['userVote']);\n        self::assertNull($jsonData['apId']);\n        self::assertEmpty($jsonData['tags']);\n        self::assertNull($jsonData['editedAt']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid');\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid');\n        self::assertIsArray($jsonData['bookmarks']);\n        self::assertEmpty($jsonData['bookmarks']);\n    }\n\n    public function testApiCanGetEntryCommentByIdWithDepth(): void\n    {\n        $entry = $this->getEntryByTitle('test entry', body: 'test');\n        $comment = $this->createEntryComment('test parent comment', $entry);\n        $parent = $comment;\n        for ($i = 0; $i < 5; ++$i) {\n            $parent = $this->createEntryComment('test nested reply', $entry, parent: $parent);\n        }\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/comments/{$comment->getId()}?d=2\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData);\n        self::assertIsArray($jsonData['user']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']);\n        self::assertIsArray($jsonData['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']);\n        self::assertSame($entry->getId(), $jsonData['entryId']);\n        self::assertStringContainsString('test parent comment', $jsonData['body']);\n        self::assertSame('en', $jsonData['lang']);\n        self::assertSame(0, $jsonData['uv']);\n        self::assertSame(0, $jsonData['dv']);\n        self::assertSame(0, $jsonData['favourites']);\n        self::assertSame(5, $jsonData['childCount']);\n        self::assertSame('visible', $jsonData['visibility']);\n        self::assertIsArray($jsonData['mentions']);\n        self::assertEmpty($jsonData['mentions']);\n        self::assertIsArray($jsonData['children']);\n        self::assertCount(1, $jsonData['children']);\n        self::assertFalse($jsonData['isAdult']);\n        self::assertNull($jsonData['image']);\n        self::assertNull($jsonData['parentId']);\n        self::assertNull($jsonData['rootId']);\n        // No scope granted so these should be null\n        self::assertNull($jsonData['isFavourited']);\n        self::assertNull($jsonData['userVote']);\n        self::assertNull($jsonData['apId']);\n        self::assertEmpty($jsonData['tags']);\n        self::assertNull($jsonData['editedAt']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid');\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid');\n        self::assertIsArray($jsonData['bookmarks']);\n        self::assertEmpty($jsonData['bookmarks']);\n\n        $depth = 0;\n        $current = $jsonData;\n        while (\\count($current['children']) > 0) {\n            self::assertIsArray($current['children'][0]);\n            self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $current['children'][0]);\n            ++$depth;\n            $current = $current['children'][0];\n        }\n\n        self::assertSame(2, $depth);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/Entry/Comment/EntryCommentUpdateApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\Entry\\Comment;\n\nuse App\\Tests\\WebTestCase;\n\nclass EntryCommentUpdateApiTest extends WebTestCase\n{\n    public function testApiCannotUpdateCommentAnonymous(): void\n    {\n        $entry = $this->getEntryByTitle('an entry', body: 'test');\n        $comment = $this->createEntryComment('test comment', $entry);\n\n        $update = [\n            'body' => 'updated body',\n            'lang' => 'de',\n            'isAdult' => true,\n        ];\n\n        $this->client->jsonRequest('PUT', \"/api/comments/{$comment->getId()}\", $update);\n\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotUpdateCommentWithoutScope(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $entry = $this->getEntryByTitle('an entry', body: 'test');\n        $comment = $this->createEntryComment('test comment', $entry, $user);\n\n        $update = [\n            'body' => 'updated body',\n            'lang' => 'de',\n            'isAdult' => true,\n        ];\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/comments/{$comment->getId()}\", $update, server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCannotUpdateOtherUsersComment(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $user2 = $this->getUserByUsername('other');\n        $entry = $this->getEntryByTitle('an entry', body: 'test');\n        $comment = $this->createEntryComment('test comment', $entry, $user2);\n\n        $update = [\n            'body' => 'updated body',\n            'lang' => 'de',\n            'isAdult' => true,\n        ];\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read entry_comment:edit');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/comments/{$comment->getId()}\", $update, server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanUpdateComment(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $entry = $this->getEntryByTitle('an entry', body: 'test');\n        $comment = $this->createEntryComment('test comment', $entry, $user);\n        $parent = $comment;\n        for ($i = 0; $i < 5; ++$i) {\n            $parent = $this->createEntryComment('test reply', $entry, $user, $parent);\n        }\n\n        $update = [\n            'body' => 'updated body',\n            'lang' => 'de',\n            'isAdult' => true,\n        ];\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read entry_comment:edit');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/comments/{$comment->getId()}?d=2\", $update, server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(200);\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData);\n        self::assertSame($comment->getId(), $jsonData['commentId']);\n        self::assertSame($update['body'], $jsonData['body']);\n        self::assertSame($update['lang'], $jsonData['lang']);\n        self::assertSame($update['isAdult'], $jsonData['isAdult']);\n        self::assertSame(5, $jsonData['childCount']);\n\n        $depth = 0;\n        $current = $jsonData;\n        while (\\count($current['children']) > 0) {\n            self::assertIsArray($current['children'][0]);\n            self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $current['children'][0]);\n            ++$depth;\n            $current = $current['children'][0];\n        }\n\n        self::assertSame(2, $depth);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/Entry/Comment/EntryCommentVoteApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\Entry\\Comment;\n\nuse App\\Tests\\WebTestCase;\n\nclass EntryCommentVoteApiTest extends WebTestCase\n{\n    public function testApiCannotUpvoteCommentAnonymous(): void\n    {\n        $entry = $this->getEntryByTitle('an entry', body: 'test');\n        $comment = $this->createEntryComment('test comment', $entry);\n\n        $this->client->request('PUT', \"/api/comments/{$comment->getId()}/vote/1\");\n\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotUpvoteCommentWithoutScope(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $entry = $this->getEntryByTitle('an entry', body: 'test');\n        $comment = $this->createEntryComment('test comment', $entry, $user);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('PUT', \"/api/comments/{$comment->getId()}/vote/1\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanUpvoteComment(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $entry = $this->getEntryByTitle('an entry', body: 'test');\n        $comment = $this->createEntryComment('test comment', $entry, $user);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read entry_comment:vote');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('PUT', \"/api/comments/{$comment->getId()}/vote/1\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(200);\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData);\n        self::assertSame(1, $jsonData['uv']);\n        self::assertSame(0, $jsonData['dv']);\n        self::assertSame(0, $jsonData['favourites']);\n        self::assertSame(1, $jsonData['userVote']);\n        self::assertFalse($jsonData['isFavourited']);\n    }\n\n    public function testApiCannotDownvoteCommentAnonymous(): void\n    {\n        $entry = $this->getEntryByTitle('an entry', body: 'test');\n        $comment = $this->createEntryComment('test comment', $entry);\n\n        $this->client->request('PUT', \"/api/comments/{$comment->getId()}/vote/-1\");\n\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotDownvoteCommentWithoutScope(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $entry = $this->getEntryByTitle('an entry', body: 'test');\n        $comment = $this->createEntryComment('test comment', $entry, $user);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('PUT', \"/api/comments/{$comment->getId()}/vote/-1\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanDownvoteComment(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $entry = $this->getEntryByTitle('an entry', body: 'test');\n        $comment = $this->createEntryComment('test comment', $entry, $user);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read entry_comment:vote');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('PUT', \"/api/comments/{$comment->getId()}/vote/-1\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(200);\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData);\n        self::assertSame(0, $jsonData['uv']);\n        self::assertSame(1, $jsonData['dv']);\n        self::assertSame(0, $jsonData['favourites']);\n        self::assertSame(-1, $jsonData['userVote']);\n        self::assertFalse($jsonData['isFavourited']);\n    }\n\n    public function testApiCannotRemoveVoteCommentAnonymous(): void\n    {\n        $entry = $this->getEntryByTitle('an entry', body: 'test');\n        $comment = $this->createEntryComment('test comment', $entry);\n\n        $voteManager = $this->voteManager;\n        $voteManager->vote(1, $comment, $this->getUserByUsername('user'), rateLimit: false);\n\n        $this->client->request('PUT', \"/api/comments/{$comment->getId()}/vote/0\");\n\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotRemoveVoteCommentWithoutScope(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $entry = $this->getEntryByTitle('an entry', body: 'test');\n        $comment = $this->createEntryComment('test comment', $entry, $user);\n\n        $voteManager = $this->voteManager;\n        $voteManager->vote(1, $comment, $user, rateLimit: false);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('PUT', \"/api/comments/{$comment->getId()}/vote/0\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanRemoveVoteComment(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $entry = $this->getEntryByTitle('an entry', body: 'test');\n        $comment = $this->createEntryComment('test comment', $entry, $user);\n\n        $voteManager = $this->voteManager;\n        $voteManager->vote(1, $comment, $user, rateLimit: false);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read entry_comment:vote');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('PUT', \"/api/comments/{$comment->getId()}/vote/0\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(200);\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData);\n        self::assertSame(0, $jsonData['uv']);\n        self::assertSame(0, $jsonData['dv']);\n        self::assertSame(0, $jsonData['favourites']);\n        self::assertSame(0, $jsonData['userVote']);\n        self::assertFalse($jsonData['isFavourited']);\n    }\n\n    public function testApiCannotFavouriteCommentAnonymous(): void\n    {\n        $entry = $this->getEntryByTitle('an entry', body: 'test');\n        $comment = $this->createEntryComment('test comment', $entry);\n\n        $this->client->request('PUT', \"/api/comments/{$comment->getId()}/favourite\");\n\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotFavouriteCommentWithoutScope(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $entry = $this->getEntryByTitle('an entry', body: 'test');\n        $comment = $this->createEntryComment('test comment', $entry, $user);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('PUT', \"/api/comments/{$comment->getId()}/favourite\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanFavouriteComment(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $entry = $this->getEntryByTitle('an entry', body: 'test');\n        $comment = $this->createEntryComment('test comment', $entry, $user);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read entry_comment:vote');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('PUT', \"/api/comments/{$comment->getId()}/favourite\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(200);\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData);\n        self::assertSame(0, $jsonData['uv']);\n        self::assertSame(0, $jsonData['dv']);\n        self::assertSame(1, $jsonData['favourites']);\n        self::assertSame(0, $jsonData['userVote']);\n        self::assertTrue($jsonData['isFavourited']);\n    }\n\n    public function testApiCannotUnfavouriteCommentWithoutScope(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $entry = $this->getEntryByTitle('an entry', body: 'test');\n        $comment = $this->createEntryComment('test comment', $entry, $user);\n\n        $favouriteManager = $this->favouriteManager;\n        $favouriteManager->toggle($user, $comment);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('PUT', \"/api/comments/{$comment->getId()}/favourite\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanUnfavouriteComment(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $entry = $this->getEntryByTitle('an entry', body: 'test');\n        $comment = $this->createEntryComment('test comment', $entry, $user);\n\n        $favouriteManager = $this->favouriteManager;\n        $favouriteManager->toggle($user, $comment);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read entry_comment:vote');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('PUT', \"/api/comments/{$comment->getId()}/favourite\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(200);\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData);\n        self::assertSame(0, $jsonData['uv']);\n        self::assertSame(0, $jsonData['dv']);\n        self::assertSame(0, $jsonData['favourites']);\n        self::assertSame(0, $jsonData['userVote']);\n        self::assertFalse($jsonData['isFavourited']);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/Entry/Comment/EntryCommentsActivityApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\Entry\\Comment;\n\nuse App\\DTO\\UserSmallResponseDto;\nuse App\\Tests\\Functional\\Controller\\Api\\Entry\\EntriesActivityApiTest;\nuse App\\Tests\\WebTestCase;\n\nclass EntryCommentsActivityApiTest extends WebTestCase\n{\n    public function testEmpty()\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $entry = $this->getEntryByTitle('test article', body: 'test for activites', user: $user, magazine: $magazine);\n        $comment = $this->createEntryComment('test comment', $entry, $user);\n\n        $this->client->jsonRequest('GET', \"/api/comments/{$comment->getId()}/activity\");\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(EntriesActivityApiTest::ACTIVITIES_RESPONSE_DTO_KEYS, $jsonData);\n        self::assertSame([], $jsonData['boosts']);\n        self::assertSame([], $jsonData['upvotes']);\n        self::assertSame(null, $jsonData['downvotes']);\n    }\n\n    public function testUpvotes()\n    {\n        $author = $this->getUserByUsername('userA');\n        $user1 = $this->getUserByUsername('user1');\n        $user2 = $this->getUserByUsername('user2');\n        $this->getUserByUsername('user3');\n\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $entry = $this->getEntryByTitle('test article', body: 'test for activites', user: $author, magazine: $magazine);\n        $comment = $this->createEntryComment('test comment', $entry, $author);\n\n        $this->favouriteManager->toggle($user1, $comment);\n        $this->favouriteManager->toggle($user2, $comment);\n\n        $this->client->jsonRequest('GET', \"/api/comments/{$comment->getId()}/activity\");\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(EntriesActivityApiTest::ACTIVITIES_RESPONSE_DTO_KEYS, $jsonData);\n        self::assertSame([], $jsonData['boosts']);\n        self::assertSame(null, $jsonData['downvotes']);\n\n        self::assertCount(2, $jsonData['upvotes']);\n        self::assertTrue(array_all($jsonData['upvotes'], function ($u) use ($user1, $user2) {\n            /* @var UserSmallResponseDto $u */\n            return $u['userId'] === $user1->getId() || $u['userId'] === $user2->getId();\n        }), serialize($jsonData['upvotes']));\n    }\n\n    public function testBoosts()\n    {\n        $author = $this->getUserByUsername('userA');\n        $user1 = $this->getUserByUsername('user1');\n        $user2 = $this->getUserByUsername('user2');\n        $this->getUserByUsername('user3');\n\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $entry = $this->getEntryByTitle('test article', body: 'test for activites', user: $author, magazine: $magazine);\n        $comment = $this->createEntryComment('test comment', $entry, $author);\n\n        $this->voteManager->upvote($comment, $user1);\n        $this->voteManager->upvote($comment, $user2);\n\n        $this->client->jsonRequest('GET', \"/api/comments/{$comment->getId()}/activity\");\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(EntriesActivityApiTest::ACTIVITIES_RESPONSE_DTO_KEYS, $jsonData);\n        self::assertSame([], $jsonData['upvotes']);\n        self::assertSame(null, $jsonData['downvotes']);\n\n        self::assertCount(2, $jsonData['boosts']);\n        self::assertTrue(array_all($jsonData['boosts'], function ($u) use ($user1, $user2) {\n            /* @var UserSmallResponseDto $u */\n            return $u['userId'] === $user1->getId() || $u['userId'] === $user2->getId();\n        }), serialize($jsonData['boosts']));\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/Entry/Comment/Moderate/EntryCommentSetAdultApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\Entry\\Comment\\Moderate;\n\nuse App\\DTO\\ModeratorDto;\nuse App\\Tests\\WebTestCase;\n\nclass EntryCommentSetAdultApiTest extends WebTestCase\n{\n    public function testApiCannotSetCommentAdultAnonymous(): void\n    {\n        $entry = $this->getEntryByTitle('an entry', body: 'test');\n        $comment = $this->createEntryComment('test comment', $entry);\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/comment/{$comment->getId()}/adult/true\");\n\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotSetCommentAdultWithoutScope(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByName('acme');\n        $user2 = $this->getUserByUsername('user2');\n        $admin = $this->getUserByUsername('admin', isAdmin: true);\n        $entry = $this->getEntryByTitle('an entry', body: 'test', magazine: $magazine);\n        $comment = $this->createEntryComment('test comment', $entry, $user2);\n\n        $magazineManager = $this->magazineManager;\n        $moderator = new ModeratorDto($magazine);\n        $moderator->user = $user;\n        $moderator->addedBy = $admin;\n        $magazineManager->addModerator($moderator);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/comment/{$comment->getId()}/adult/true\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiNonModCannotSetCommentAdult(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $user2 = $this->getUserByUsername('user2');\n        $entry = $this->getEntryByTitle('an entry', body: 'test');\n        $comment = $this->createEntryComment('test comment', $entry, $user2);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:entry_comment:set_adult');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/comment/{$comment->getId()}/adult/true\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanSetCommentAdult(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByName('acme');\n        $user2 = $this->getUserByUsername('other');\n        $admin = $this->getUserByUsername('admin', isAdmin: true);\n        $entry = $this->getEntryByTitle('an entry', body: 'test', magazine: $magazine);\n        $comment = $this->createEntryComment('test comment', $entry, $user2);\n\n        $magazineManager = $this->magazineManager;\n        $moderator = new ModeratorDto($magazine);\n        $moderator->user = $user;\n        $moderator->addedBy = $admin;\n        $magazineManager->addModerator($moderator);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:entry_comment:set_adult');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/comment/{$comment->getId()}/adult/true\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(200);\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData);\n        self::assertTrue($jsonData['isAdult']);\n    }\n\n    public function testApiCannotUnsetCommentAdultAnonymous(): void\n    {\n        $entry = $this->getEntryByTitle('an entry', body: 'test');\n        $comment = $this->createEntryComment('test comment', $entry);\n\n        $entityManager = $this->entityManager;\n        $comment->isAdult = true;\n        $entityManager->persist($comment);\n        $entityManager->flush();\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/comment/{$comment->getId()}/adult/false\");\n\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotUnsetCommentAdultWithoutScope(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByName('acme');\n        $user2 = $this->getUserByUsername('user2');\n        $admin = $this->getUserByUsername('admin', isAdmin: true);\n        $entry = $this->getEntryByTitle('an entry', body: 'test', magazine: $magazine);\n        $comment = $this->createEntryComment('test comment', $entry, $user2);\n\n        $magazineManager = $this->magazineManager;\n        $moderator = new ModeratorDto($magazine);\n        $moderator->user = $user;\n        $moderator->addedBy = $admin;\n        $magazineManager->addModerator($moderator);\n\n        $entityManager = $this->entityManager;\n        $comment->isAdult = true;\n        $entityManager->persist($comment);\n        $entityManager->flush();\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/comment/{$comment->getId()}/adult/false\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiNonModCannotUnsetCommentAdult(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $user2 = $this->getUserByUsername('user2');\n        $entry = $this->getEntryByTitle('an entry', body: 'test');\n        $comment = $this->createEntryComment('test comment', $entry, $user2);\n\n        $entityManager = $this->entityManager;\n        $comment->isAdult = true;\n        $entityManager->persist($comment);\n        $entityManager->flush();\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:entry_comment:set_adult');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/comment/{$comment->getId()}/adult/false\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanUnsetCommentAdult(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByName('acme');\n        $user2 = $this->getUserByUsername('other');\n        $admin = $this->getUserByUsername('admin', isAdmin: true);\n        $entry = $this->getEntryByTitle('an entry', body: 'test', magazine: $magazine);\n        $comment = $this->createEntryComment('test comment', $entry, $user2);\n\n        $magazineManager = $this->magazineManager;\n        $moderator = new ModeratorDto($magazine);\n        $moderator->user = $user;\n        $moderator->addedBy = $admin;\n        $magazineManager->addModerator($moderator);\n\n        $entityManager = $this->entityManager;\n        $comment->isAdult = true;\n        $entityManager->persist($comment);\n        $entityManager->flush();\n\n        $commentRepository = $this->entryCommentRepository;\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:entry_comment:set_adult');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/comment/{$comment->getId()}/adult/false\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(200);\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData);\n        self::assertFalse($jsonData['isAdult']);\n\n        $comment = $commentRepository->find($comment->getId());\n        self::assertFalse($comment->isAdult);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/Entry/Comment/Moderate/EntryCommentSetLanguageApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\Entry\\Comment\\Moderate;\n\nuse App\\DTO\\ModeratorDto;\nuse App\\Tests\\WebTestCase;\n\nclass EntryCommentSetLanguageApiTest extends WebTestCase\n{\n    public function testApiCannotSetCommentLanguageAnonymous(): void\n    {\n        $entry = $this->getEntryByTitle('an entry', body: 'test');\n        $comment = $this->createEntryComment('test comment', $entry);\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/comment/{$comment->getId()}/de\");\n\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotSetCommentLanguageWithoutScope(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByName('acme');\n        $user2 = $this->getUserByUsername('user2');\n        $admin = $this->getUserByUsername('admin', isAdmin: true);\n        $entry = $this->getEntryByTitle('an entry', body: 'test', magazine: $magazine);\n        $comment = $this->createEntryComment('test comment', $entry, $user2);\n\n        $magazineManager = $this->magazineManager;\n        $moderator = new ModeratorDto($magazine);\n        $moderator->user = $user;\n        $moderator->addedBy = $admin;\n        $magazineManager->addModerator($moderator);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/comment/{$comment->getId()}/de\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiNonModCannotSetCommentLanguage(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $user2 = $this->getUserByUsername('user2');\n        $entry = $this->getEntryByTitle('an entry', body: 'test');\n        $comment = $this->createEntryComment('test comment', $entry, $user2);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:entry_comment:language');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/comment/{$comment->getId()}/de\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanSetCommentLanguage(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByName('acme');\n        $user2 = $this->getUserByUsername('other');\n        $admin = $this->getUserByUsername('admin', isAdmin: true);\n        $entry = $this->getEntryByTitle('an entry', body: 'test', magazine: $magazine);\n        $comment = $this->createEntryComment('test comment', $entry, $user2);\n\n        $magazineManager = $this->magazineManager;\n        $moderator = new ModeratorDto($magazine);\n        $moderator->user = $user;\n        $moderator->addedBy = $admin;\n        $magazineManager->addModerator($moderator);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:entry_comment:language');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/comment/{$comment->getId()}/de\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(200);\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData);\n        self::assertSame($comment->getId(), $jsonData['commentId']);\n        self::assertSame('test comment', $jsonData['body']);\n        self::assertSame('de', $jsonData['lang']);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/Entry/Comment/Moderate/EntryCommentTrashApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\Entry\\Comment\\Moderate;\n\nuse App\\DTO\\ModeratorDto;\nuse App\\Tests\\WebTestCase;\n\nclass EntryCommentTrashApiTest extends WebTestCase\n{\n    public function testApiCannotTrashCommentAnonymous(): void\n    {\n        $entry = $this->getEntryByTitle('an entry', body: 'test');\n        $comment = $this->createEntryComment('test comment', $entry);\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/comment/{$comment->getId()}/trash\");\n\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotTrashCommentWithoutScope(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByName('acme');\n        $user2 = $this->getUserByUsername('user2');\n        $admin = $this->getUserByUsername('admin', isAdmin: true);\n        $entry = $this->getEntryByTitle('an entry', body: 'test', magazine: $magazine);\n        $comment = $this->createEntryComment('test comment', $entry, $user2);\n\n        $magazineManager = $this->magazineManager;\n        $moderator = new ModeratorDto($magazine);\n        $moderator->user = $user;\n        $moderator->addedBy = $admin;\n        $magazineManager->addModerator($moderator);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/comment/{$comment->getId()}/trash\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiNonModCannotTrashComment(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $user2 = $this->getUserByUsername('user2');\n        $entry = $this->getEntryByTitle('an entry', body: 'test');\n        $comment = $this->createEntryComment('test comment', $entry, $user2);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:entry_comment:trash');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/comment/{$comment->getId()}/trash\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanTrashComment(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByName('acme');\n        $user2 = $this->getUserByUsername('other');\n        $admin = $this->getUserByUsername('admin', isAdmin: true);\n        $entry = $this->getEntryByTitle('an entry', body: 'test', magazine: $magazine);\n        $comment = $this->createEntryComment('test comment', $entry, $user2);\n\n        $magazineManager = $this->magazineManager;\n        $moderator = new ModeratorDto($magazine);\n        $moderator->user = $user;\n        $moderator->addedBy = $admin;\n        $magazineManager->addModerator($moderator);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:entry_comment:trash');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/comment/{$comment->getId()}/trash\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(200);\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData);\n        self::assertSame($comment->getId(), $jsonData['commentId']);\n        self::assertSame('test comment', $jsonData['body']);\n        self::assertSame('trashed', $jsonData['visibility']);\n    }\n\n    public function testApiCannotRestoreCommentAnonymous(): void\n    {\n        $entry = $this->getEntryByTitle('an entry', body: 'test');\n        $comment = $this->createEntryComment('test comment', $entry);\n\n        $entryCommentManager = $this->entryCommentManager;\n        $entryCommentManager->trash($this->getUserByUsername('user'), $comment);\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/comment/{$comment->getId()}/restore\");\n\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotRestoreCommentWithoutScope(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $user2 = $this->getUserByUsername('user2');\n        $entry = $this->getEntryByTitle('an entry', body: 'test');\n        $comment = $this->createEntryComment('test comment', $entry, $user2);\n\n        $entryCommentManager = $this->entryCommentManager;\n        $entryCommentManager->trash($user, $comment);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/comment/{$comment->getId()}/restore\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiNonModCannotRestoreComment(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $user2 = $this->getUserByUsername('user2');\n        $entry = $this->getEntryByTitle('an entry', body: 'test');\n        $comment = $this->createEntryComment('test comment', $entry, $user2);\n\n        $entryCommentManager = $this->entryCommentManager;\n        $entryCommentManager->trash($user, $comment);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:entry_comment:trash');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/comment/{$comment->getId()}/restore\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanRestoreComment(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByName('acme');\n        $user2 = $this->getUserByUsername('other');\n        $admin = $this->getUserByUsername('admin', isAdmin: true);\n        $entry = $this->getEntryByTitle('an entry', body: 'test', magazine: $magazine);\n        $comment = $this->createEntryComment('test comment', $entry, $user2);\n\n        $magazineManager = $this->magazineManager;\n        $moderator = new ModeratorDto($magazine);\n        $moderator->user = $user;\n        $moderator->addedBy = $admin;\n        $magazineManager->addModerator($moderator);\n\n        $entryCommentManager = $this->entryCommentManager;\n        $entryCommentManager->trash($user, $comment);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:entry_comment:trash');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/comment/{$comment->getId()}/restore\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(200);\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData);\n        self::assertSame($comment->getId(), $jsonData['commentId']);\n        self::assertSame('test comment', $jsonData['body']);\n        self::assertSame('visible', $jsonData['visibility']);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/Entry/Comment/UserEntryCommentRetrieveApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\Entry\\Comment;\n\nuse App\\Tests\\WebTestCase;\n\nclass UserEntryCommentRetrieveApiTest extends WebTestCase\n{\n    public function testApiCanGetUserEntryCommentsAnonymous(): void\n    {\n        $this->getEntryByTitle('an entry', body: 'test');\n        $magazine = $this->getMagazineByNameNoRSAKey('somemag');\n        $entry = $this->getEntryByTitle('another entry', url: 'https://google.com', magazine: $magazine);\n        $comment = $this->createEntryComment('test comment', $entry);\n        $user = $entry->user;\n\n        $this->client->request('GET', \"/api/users/{$user->getId()}/comments\");\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(1, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(1, $jsonData['pagination']['count']);\n\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertEquals('test comment', $jsonData['items'][0]['body']);\n        self::assertIsArray($jsonData['items'][0]['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['magazine']);\n        self::assertSame($magazine->getId(), $jsonData['items'][0]['magazine']['magazineId']);\n        self::assertSame(0, $jsonData['items'][0]['childCount']);\n        self::assertIsArray($jsonData['items'][0]['children']);\n        self::assertEmpty($jsonData['items'][0]['children']);\n        self::assertSame($comment->getId(), $jsonData['items'][0]['commentId']);\n        self::assertSame($entry->getId(), $jsonData['items'][0]['entryId']);\n    }\n\n    public function testApiCanGetUserEntryComments(): void\n    {\n        $this->getEntryByTitle('an entry', body: 'test');\n        $magazine = $this->getMagazineByNameNoRSAKey('somemag');\n        $entry = $this->getEntryByTitle('another entry', url: 'https://google.com', magazine: $magazine);\n        $comment = $this->createEntryComment('test comment', $entry);\n        $user = $entry->user;\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/users/{$user->getId()}/comments\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(1, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(1, $jsonData['pagination']['count']);\n\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertEquals('test comment', $jsonData['items'][0]['body']);\n        self::assertIsArray($jsonData['items'][0]['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['magazine']);\n        self::assertSame($magazine->getId(), $jsonData['items'][0]['magazine']['magazineId']);\n        self::assertSame(0, $jsonData['items'][0]['childCount']);\n        self::assertIsArray($jsonData['items'][0]['children']);\n        self::assertEmpty($jsonData['items'][0]['children']);\n        self::assertSame($comment->getId(), $jsonData['items'][0]['commentId']);\n        self::assertSame($entry->getId(), $jsonData['items'][0]['entryId']);\n    }\n\n    public function testApiCanGetUserEntryCommentsDepth(): void\n    {\n        $this->getEntryByTitle('an entry', body: 'test');\n        $magazine = $this->getMagazineByNameNoRSAKey('somemag');\n        $entry = $this->getEntryByTitle('another entry', url: 'https://google.com', magazine: $magazine);\n        $comment = $this->createEntryComment('test comment', $entry);\n        $nested1 = $this->createEntryComment('test comment nested 1', $entry, parent: $comment);\n        $nested2 = $this->createEntryComment('test comment nested 2', $entry, parent: $nested1);\n        $nested3 = $this->createEntryComment('test comment nested 3', $entry, parent: $nested2);\n        $user = $entry->user;\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/users/{$user->getId()}/comments?d=2\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(4, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(4, $jsonData['pagination']['count']);\n\n        foreach ($jsonData['items'] as $comment) {\n            self::assertIsArray($comment);\n            self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $comment);\n            self::assertTrue(\\count($comment['children']) <= 1);\n            $depth = 0;\n            $current = $comment;\n            while (\\count($current['children']) > 0) {\n                ++$depth;\n                $current = $current['children'][0];\n                self::assertIsArray($current);\n                self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $current);\n            }\n            self::assertTrue($depth <= 2);\n        }\n    }\n\n    public function testApiCanGetUserEntryCommentsNewest(): void\n    {\n        $entry = $this->getEntryByTitle('entry', url: 'https://google.com');\n        $first = $this->createEntryComment('first', $entry);\n        $second = $this->createEntryComment('second', $entry);\n        $third = $this->createEntryComment('third', $entry);\n        $user = $entry->user;\n\n        $first->createdAt = new \\DateTimeImmutable('-1 hour');\n        $second->createdAt = new \\DateTimeImmutable('-1 second');\n        $third->createdAt = new \\DateTimeImmutable();\n\n        $entityManager = $this->entityManager;\n        $entityManager->persist($first);\n        $entityManager->persist($second);\n        $entityManager->persist($third);\n        $entityManager->flush();\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/users/{$user->getId()}/comments?sort=newest\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(3, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(3, $jsonData['pagination']['count']);\n\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertSame($third->getId(), $jsonData['items'][0]['commentId']);\n\n        self::assertIsArray($jsonData['items'][1]);\n        self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData['items'][1]);\n        self::assertSame($second->getId(), $jsonData['items'][1]['commentId']);\n\n        self::assertIsArray($jsonData['items'][2]);\n        self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData['items'][2]);\n        self::assertSame($first->getId(), $jsonData['items'][2]['commentId']);\n    }\n\n    public function testApiCanGetUserEntryCommentsOldest(): void\n    {\n        $entry = $this->getEntryByTitle('entry', url: 'https://google.com');\n        $first = $this->createEntryComment('first', $entry);\n        $second = $this->createEntryComment('second', $entry);\n        $third = $this->createEntryComment('third', $entry);\n        $user = $entry->user;\n\n        $first->createdAt = new \\DateTimeImmutable('-1 hour');\n        $second->createdAt = new \\DateTimeImmutable('-1 second');\n        $third->createdAt = new \\DateTimeImmutable();\n\n        $entityManager = $this->entityManager;\n        $entityManager->persist($first);\n        $entityManager->persist($second);\n        $entityManager->persist($third);\n        $entityManager->flush();\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/users/{$user->getId()}/comments?sort=oldest\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(3, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(3, $jsonData['pagination']['count']);\n\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertSame($first->getId(), $jsonData['items'][0]['commentId']);\n\n        self::assertIsArray($jsonData['items'][1]);\n        self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData['items'][1]);\n        self::assertSame($second->getId(), $jsonData['items'][1]['commentId']);\n\n        self::assertIsArray($jsonData['items'][2]);\n        self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData['items'][2]);\n        self::assertSame($third->getId(), $jsonData['items'][2]['commentId']);\n    }\n\n    public function testApiCanGetUserEntryCommentsActive(): void\n    {\n        $entry = $this->getEntryByTitle('entry', url: 'https://google.com');\n        $first = $this->createEntryComment('first', $entry);\n        $second = $this->createEntryComment('second', $entry);\n        $third = $this->createEntryComment('third', $entry);\n        $user = $entry->user;\n\n        $first->lastActive = new \\DateTime('-1 hour');\n        $second->lastActive = new \\DateTime('-1 second');\n        $third->lastActive = new \\DateTime();\n\n        $entityManager = $this->entityManager;\n        $entityManager->persist($first);\n        $entityManager->persist($second);\n        $entityManager->persist($third);\n        $entityManager->flush();\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/users/{$user->getId()}/comments?sort=active\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(3, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(3, $jsonData['pagination']['count']);\n\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertSame($third->getId(), $jsonData['items'][0]['commentId']);\n\n        self::assertIsArray($jsonData['items'][1]);\n        self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData['items'][1]);\n        self::assertSame($second->getId(), $jsonData['items'][1]['commentId']);\n\n        self::assertIsArray($jsonData['items'][2]);\n        self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData['items'][2]);\n        self::assertSame($first->getId(), $jsonData['items'][2]['commentId']);\n    }\n\n    public function testApiCanGetUserEntryCommentsTop(): void\n    {\n        $entry = $this->getEntryByTitle('entry', url: 'https://google.com');\n        $first = $this->createEntryComment('first', $entry);\n        $second = $this->createEntryComment('second', $entry);\n        $third = $this->createEntryComment('third', $entry);\n        $user = $entry->user;\n\n        $favouriteManager = $this->favouriteManager;\n        $favouriteManager->toggle($this->getUserByUsername('voter1'), $first);\n        $favouriteManager->toggle($this->getUserByUsername('voter2'), $first);\n        $favouriteManager->toggle($this->getUserByUsername('voter1'), $second);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/users/{$user->getId()}/comments?sort=top\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(3, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(3, $jsonData['pagination']['count']);\n\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertSame($first->getId(), $jsonData['items'][0]['commentId']);\n        self::assertSame(2, $jsonData['items'][0]['favourites']);\n\n        self::assertIsArray($jsonData['items'][1]);\n        self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData['items'][1]);\n        self::assertSame($second->getId(), $jsonData['items'][1]['commentId']);\n        self::assertSame(1, $jsonData['items'][1]['favourites']);\n\n        self::assertIsArray($jsonData['items'][2]);\n        self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData['items'][2]);\n        self::assertSame($third->getId(), $jsonData['items'][2]['commentId']);\n        self::assertSame(0, $jsonData['items'][2]['favourites']);\n    }\n\n    public function testApiCanGetUserEntryCommentsHot(): void\n    {\n        $entry = $this->getEntryByTitle('entry', url: 'https://google.com');\n        $first = $this->createEntryComment('first', $entry);\n        $second = $this->createEntryComment('second', $entry);\n        $third = $this->createEntryComment('third', $entry);\n        $user = $entry->user;\n\n        $voteManager = $this->voteManager;\n        $voteManager->vote(1, $first, $this->getUserByUsername('voter1'), rateLimit: false);\n        $voteManager->vote(1, $first, $this->getUserByUsername('voter2'), rateLimit: false);\n        $voteManager->vote(1, $second, $this->getUserByUsername('voter1'), rateLimit: false);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/users/{$user->getId()}/comments?sort=hot\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(3, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(3, $jsonData['pagination']['count']);\n\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertSame($first->getId(), $jsonData['items'][0]['commentId']);\n        self::assertSame(2, $jsonData['items'][0]['uv']);\n\n        self::assertIsArray($jsonData['items'][1]);\n        self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData['items'][1]);\n        self::assertSame($second->getId(), $jsonData['items'][1]['commentId']);\n        self::assertSame(1, $jsonData['items'][1]['uv']);\n\n        self::assertIsArray($jsonData['items'][2]);\n        self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData['items'][2]);\n        self::assertSame($third->getId(), $jsonData['items'][2]['commentId']);\n        self::assertSame(0, $jsonData['items'][2]['uv']);\n    }\n\n    public function testApiCanGetUserEntryCommentsWithUserVoteStatus(): void\n    {\n        $this->getEntryByTitle('an entry', body: 'test');\n        $magazine = $this->getMagazineByNameNoRSAKey('somemag');\n        $entry = $this->getEntryByTitle('another entry', url: 'https://google.com', magazine: $magazine);\n        $comment = $this->createEntryComment('test comment', $entry);\n        $user = $entry->user;\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read vote');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/users/{$user->getId()}/comments\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(1, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(1, $jsonData['pagination']['count']);\n\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertSame($comment->getId(), $jsonData['items'][0]['commentId']);\n        self::assertSame($entry->getId(), $jsonData['items'][0]['entryId']);\n        self::assertEquals('test comment', $jsonData['items'][0]['body']);\n        self::assertIsArray($jsonData['items'][0]['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['magazine']);\n        self::assertSame($magazine->getId(), $jsonData['items'][0]['magazine']['magazineId']);\n        self::assertIsArray($jsonData['items'][0]['user']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['user']);\n        if (null !== $jsonData['items'][0]['image']) {\n            self::assertStringContainsString('google.com', parse_url($jsonData['items'][0]['image']['sourceUrl'], PHP_URL_HOST));\n        }\n        self::assertEquals('en', $jsonData['items'][0]['lang']);\n        self::assertEmpty($jsonData['items'][0]['tags']);\n        self::assertSame(0, $jsonData['items'][0]['childCount']);\n        self::assertIsArray($jsonData['items'][0]['children']);\n        self::assertEmpty($jsonData['items'][0]['children']);\n        self::assertSame(0, $jsonData['items'][0]['uv']);\n        self::assertSame(0, $jsonData['items'][0]['dv']);\n        self::assertSame(0, $jsonData['items'][0]['favourites']);\n        self::assertFalse($jsonData['items'][0]['isFavourited']);\n        self::assertSame(0, $jsonData['items'][0]['userVote']);\n        self::assertFalse($jsonData['items'][0]['isAdult']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['items'][0]['createdAt'], 'createdAt date format invalid');\n        self::assertNull($jsonData['items'][0]['editedAt']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['items'][0]['lastActive'], 'lastActive date format invalid');\n        self::assertNull($jsonData['items'][0]['apId']);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/Entry/DomainEntryRetrieveApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\Entry;\n\nuse App\\Tests\\WebTestCase;\n\nclass DomainEntryRetrieveApiTest extends WebTestCase\n{\n    public function testApiCanGetDomainEntriesAnonymous(): void\n    {\n        $this->getEntryByTitle('an entry', body: 'test');\n        $magazine = $this->getMagazineByNameNoRSAKey('somemag');\n        $entry = $this->getEntryByTitle('another entry', url: 'https://google.com', magazine: $magazine);\n        $domain = $entry->domain;\n\n        $this->client->request('GET', \"/api/domain/{$domain->getId()}/entries\");\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(1, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(1, $jsonData['pagination']['count']);\n\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertEquals('another entry', $jsonData['items'][0]['title']);\n        self::assertIsArray($jsonData['items'][0]['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['magazine']);\n        self::assertSame($magazine->getId(), $jsonData['items'][0]['magazine']['magazineId']);\n        self::assertEquals('link', $jsonData['items'][0]['type']);\n        self::assertSame(0, $jsonData['items'][0]['numComments']);\n        self::assertNull($jsonData['items'][0]['crosspostedEntries']);\n    }\n\n    public function testApiCanGetDomainEntries(): void\n    {\n        $this->getEntryByTitle('an entry', body: 'test');\n        $magazine = $this->getMagazineByNameNoRSAKey('somemag');\n        $entry = $this->getEntryByTitle('another entry', url: 'https://google.com', magazine: $magazine);\n        $domain = $entry->domain;\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/domain/{$domain->getId()}/entries\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(1, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(1, $jsonData['pagination']['count']);\n\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertEquals('another entry', $jsonData['items'][0]['title']);\n        self::assertIsArray($jsonData['items'][0]['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['magazine']);\n        self::assertSame($magazine->getId(), $jsonData['items'][0]['magazine']['magazineId']);\n        self::assertEquals('link', $jsonData['items'][0]['type']);\n        self::assertSame(0, $jsonData['items'][0]['numComments']);\n        self::assertNull($jsonData['items'][0]['crosspostedEntries']);\n    }\n\n    public function testApiCanGetDomainEntriesNewest(): void\n    {\n        $first = $this->getEntryByTitle('first', url: 'https://google.com');\n        $second = $this->getEntryByTitle('second', url: 'https://google.com');\n        $third = $this->getEntryByTitle('third', url: 'https://google.com');\n        $domain = $first->domain;\n\n        $first->createdAt = new \\DateTimeImmutable('-1 hour');\n        $second->createdAt = new \\DateTimeImmutable('-1 second');\n        $third->createdAt = new \\DateTimeImmutable();\n\n        $entityManager = $this->entityManager;\n        $entityManager->persist($first);\n        $entityManager->persist($second);\n        $entityManager->persist($third);\n        $entityManager->flush();\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/domain/{$domain->getId()}/entries?sort=newest\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(3, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(3, $jsonData['pagination']['count']);\n\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertSame($third->getId(), $jsonData['items'][0]['entryId']);\n\n        self::assertIsArray($jsonData['items'][1]);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][1]);\n        self::assertSame($second->getId(), $jsonData['items'][1]['entryId']);\n\n        self::assertIsArray($jsonData['items'][2]);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][2]);\n        self::assertSame($first->getId(), $jsonData['items'][2]['entryId']);\n    }\n\n    public function testApiCanGetDomainEntriesOldest(): void\n    {\n        $first = $this->getEntryByTitle('first', url: 'https://google.com');\n        $second = $this->getEntryByTitle('second', url: 'https://google.com');\n        $third = $this->getEntryByTitle('third', url: 'https://google.com');\n        $domain = $first->domain;\n\n        $first->createdAt = new \\DateTimeImmutable('-1 hour');\n        $second->createdAt = new \\DateTimeImmutable('-1 second');\n        $third->createdAt = new \\DateTimeImmutable();\n\n        $entityManager = $this->entityManager;\n        $entityManager->persist($first);\n        $entityManager->persist($second);\n        $entityManager->persist($third);\n        $entityManager->flush();\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/domain/{$domain->getId()}/entries?sort=oldest\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(3, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(3, $jsonData['pagination']['count']);\n\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertSame($first->getId(), $jsonData['items'][0]['entryId']);\n\n        self::assertIsArray($jsonData['items'][1]);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][1]);\n        self::assertSame($second->getId(), $jsonData['items'][1]['entryId']);\n\n        self::assertIsArray($jsonData['items'][2]);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][2]);\n        self::assertSame($third->getId(), $jsonData['items'][2]['entryId']);\n    }\n\n    public function testApiCanGetDomainEntriesCommented(): void\n    {\n        $first = $this->getEntryByTitle('first', url: 'https://google.com');\n        $this->createEntryComment('comment 1', $first);\n        $this->createEntryComment('comment 2', $first);\n        $second = $this->getEntryByTitle('second', url: 'https://google.com');\n        $this->createEntryComment('comment 1', $second);\n        $third = $this->getEntryByTitle('third', url: 'https://google.com');\n        $domain = $first->domain;\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/domain/{$domain->getId()}/entries?sort=commented\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(3, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(3, $jsonData['pagination']['count']);\n\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertSame($first->getId(), $jsonData['items'][0]['entryId']);\n        self::assertSame(2, $jsonData['items'][0]['numComments']);\n\n        self::assertIsArray($jsonData['items'][1]);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][1]);\n        self::assertSame($second->getId(), $jsonData['items'][1]['entryId']);\n        self::assertSame(1, $jsonData['items'][1]['numComments']);\n\n        self::assertIsArray($jsonData['items'][2]);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][2]);\n        self::assertSame($third->getId(), $jsonData['items'][2]['entryId']);\n        self::assertSame(0, $jsonData['items'][2]['numComments']);\n    }\n\n    public function testApiCanGetDomainEntriesActive(): void\n    {\n        $first = $this->getEntryByTitle('first', url: 'https://google.com');\n        $second = $this->getEntryByTitle('second', url: 'https://google.com');\n        $third = $this->getEntryByTitle('third', url: 'https://google.com');\n        $domain = $first->domain;\n\n        $first->lastActive = new \\DateTime('-1 hour');\n        $second->lastActive = new \\DateTime('-1 second');\n        $third->lastActive = new \\DateTime();\n\n        $entityManager = $this->entityManager;\n        $entityManager->persist($first);\n        $entityManager->persist($second);\n        $entityManager->persist($third);\n        $entityManager->flush();\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/domain/{$domain->getId()}/entries?sort=active\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(3, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(3, $jsonData['pagination']['count']);\n\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertSame($third->getId(), $jsonData['items'][0]['entryId']);\n\n        self::assertIsArray($jsonData['items'][1]);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][1]);\n        self::assertSame($second->getId(), $jsonData['items'][1]['entryId']);\n\n        self::assertIsArray($jsonData['items'][2]);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][2]);\n        self::assertSame($first->getId(), $jsonData['items'][2]['entryId']);\n    }\n\n    public function testApiCanGetDomainEntriesTop(): void\n    {\n        $first = $this->getEntryByTitle('first', url: 'https://google.com');\n        $second = $this->getEntryByTitle('second', url: 'https://google.com');\n        $third = $this->getEntryByTitle('third', url: 'https://google.com');\n        $domain = $first->domain;\n\n        $voteManager = $this->voteManager;\n        $voteManager->vote(1, $first, $this->getUserByUsername('voter1'), rateLimit: false);\n        $voteManager->vote(1, $first, $this->getUserByUsername('voter2'), rateLimit: false);\n        $voteManager->vote(1, $second, $this->getUserByUsername('voter1'), rateLimit: false);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/domain/{$domain->getId()}/entries?sort=top\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(3, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(3, $jsonData['pagination']['count']);\n\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertSame($first->getId(), $jsonData['items'][0]['entryId']);\n        self::assertSame(2, $jsonData['items'][0]['uv']);\n\n        self::assertIsArray($jsonData['items'][1]);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][1]);\n        self::assertSame($second->getId(), $jsonData['items'][1]['entryId']);\n        self::assertSame(1, $jsonData['items'][1]['uv']);\n\n        self::assertIsArray($jsonData['items'][2]);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][2]);\n        self::assertSame($third->getId(), $jsonData['items'][2]['entryId']);\n        self::assertSame(0, $jsonData['items'][2]['uv']);\n    }\n\n    public function testApiCanGetDomainEntriesWithUserVoteStatus(): void\n    {\n        $this->getEntryByTitle('an entry', body: 'test');\n        $magazine = $this->getMagazineByNameNoRSAKey('somemag');\n        $entry = $this->getEntryByTitle('another entry', url: 'https://google.com', magazine: $magazine);\n        $domain = $entry->domain;\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read vote');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/domain/{$domain->getId()}/entries\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(1, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(1, $jsonData['pagination']['count']);\n\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertSame($entry->getId(), $jsonData['items'][0]['entryId']);\n        self::assertEquals('another entry', $jsonData['items'][0]['title']);\n        self::assertIsArray($jsonData['items'][0]['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['magazine']);\n        self::assertSame($magazine->getId(), $jsonData['items'][0]['magazine']['magazineId']);\n        self::assertIsArray($jsonData['items'][0]['user']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['user']);\n        self::assertIsArray($jsonData['items'][0]['domain']);\n        self::assertArrayKeysMatch(self::DOMAIN_RESPONSE_KEYS, $jsonData['items'][0]['domain']);\n        self::assertEquals('https://google.com', $jsonData['items'][0]['url']);\n        self::assertNull($jsonData['items'][0]['body']);\n        if (null !== $jsonData['items'][0]['image']) {\n            self::assertStringContainsString('google.com', parse_url($jsonData['items'][0]['image']['sourceUrl'], PHP_URL_HOST));\n        }\n        self::assertEquals('en', $jsonData['items'][0]['lang']);\n        self::assertEmpty($jsonData['items'][0]['tags']);\n        self::assertSame(0, $jsonData['items'][0]['numComments']);\n        self::assertSame(0, $jsonData['items'][0]['uv']);\n        self::assertSame(0, $jsonData['items'][0]['dv']);\n        self::assertSame(0, $jsonData['items'][0]['favourites']);\n        self::assertFalse($jsonData['items'][0]['isFavourited']);\n        self::assertSame(0, $jsonData['items'][0]['userVote']);\n        self::assertFalse($jsonData['items'][0]['isOc']);\n        self::assertFalse($jsonData['items'][0]['isAdult']);\n        self::assertFalse($jsonData['items'][0]['isPinned']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['items'][0]['createdAt'], 'createdAt date format invalid');\n        self::assertNull($jsonData['items'][0]['editedAt']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['items'][0]['lastActive'], 'lastActive date format invalid');\n        self::assertEquals('link', $jsonData['items'][0]['type']);\n        self::assertEquals('another-entry', $jsonData['items'][0]['slug']);\n        self::assertNull($jsonData['items'][0]['apId']);\n        self::assertNull($jsonData['items'][0]['crosspostedEntries']);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/Entry/EntriesActivityApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\Entry;\n\nuse App\\DTO\\UserSmallResponseDto;\nuse App\\Tests\\WebTestCase;\n\nclass EntriesActivityApiTest extends WebTestCase\n{\n    public const array ACTIVITIES_RESPONSE_DTO_KEYS = ['boosts', 'upvotes', 'downvotes'];\n\n    public function testEmpty()\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $entry = $this->getEntryByTitle('test article', body: 'test for activites', user: $user, magazine: $magazine);\n\n        $this->client->jsonRequest('GET', \"/api/entry/{$entry->getId()}/activity\");\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::ACTIVITIES_RESPONSE_DTO_KEYS, $jsonData);\n        self::assertSame([], $jsonData['boosts']);\n        self::assertSame([], $jsonData['upvotes']);\n        self::assertSame(null, $jsonData['downvotes']);\n    }\n\n    public function testUpvotes()\n    {\n        $author = $this->getUserByUsername('userA');\n        $user1 = $this->getUserByUsername('user1');\n        $user2 = $this->getUserByUsername('user2');\n        $this->getUserByUsername('user3');\n\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $entry = $this->getEntryByTitle('test article', body: 'test for activites', user: $author, magazine: $magazine);\n\n        $this->favouriteManager->toggle($user1, $entry);\n        $this->favouriteManager->toggle($user2, $entry);\n\n        $this->client->jsonRequest('GET', \"/api/entry/{$entry->getId()}/activity\");\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::ACTIVITIES_RESPONSE_DTO_KEYS, $jsonData);\n        self::assertSame([], $jsonData['boosts']);\n        self::assertSame(null, $jsonData['downvotes']);\n\n        self::assertCount(2, $jsonData['upvotes']);\n        self::assertTrue(array_all($jsonData['upvotes'], function ($u) use ($user1, $user2) {\n            /* @var UserSmallResponseDto $u */\n            return $u['userId'] === $user1->getId() || $u['userId'] === $user2->getId();\n        }), serialize($jsonData['upvotes']));\n    }\n\n    public function testBoosts()\n    {\n        $author = $this->getUserByUsername('userA');\n        $user1 = $this->getUserByUsername('user1');\n        $user2 = $this->getUserByUsername('user2');\n        $this->getUserByUsername('user3');\n\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $entry = $this->getEntryByTitle('test article', body: 'test for activites', user: $author, magazine: $magazine);\n\n        $this->voteManager->upvote($entry, $user1);\n        $this->voteManager->upvote($entry, $user2);\n\n        $this->client->jsonRequest('GET', \"/api/entry/{$entry->getId()}/activity\");\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::ACTIVITIES_RESPONSE_DTO_KEYS, $jsonData);\n        self::assertSame([], $jsonData['upvotes']);\n        self::assertSame(null, $jsonData['downvotes']);\n\n        self::assertCount(2, $jsonData['boosts']);\n        self::assertTrue(array_all($jsonData['boosts'], function ($u) use ($user1, $user2) {\n            /* @var UserSmallResponseDto $u */\n            return $u['userId'] === $user1->getId() || $u['userId'] === $user2->getId();\n        }), serialize($jsonData['boosts']));\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/Entry/EntryCreateApiNewTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\Entry;\n\nuse App\\Tests\\WebTestCase;\nuse Symfony\\Component\\HttpFoundation\\File\\UploadedFile;\n\nclass EntryCreateApiNewTest extends WebTestCase\n{\n    public function testApiCannotCreateArticleEntryAnonymous(): void\n    {\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $entryRequest = [\n            'title' => 'Anonymous Thread',\n            'body' => 'This is an article',\n            'tags' => ['test'],\n            'isOc' => false,\n            'lang' => 'en',\n            'isAdult' => false,\n        ];\n\n        $this->client->request('POST', \"/api/magazine/{$magazine->getId()}/entries\", parameters: $entryRequest);\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotCreateArticleEntryWithoutScope(): void\n    {\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $entryRequest = [\n            'title' => 'No Scope Thread',\n            'body' => 'This is an article',\n            'tags' => ['test'],\n            'isOc' => false,\n            'lang' => 'en',\n            'isAdult' => false,\n        ];\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('POST', \"/api/magazine/{$magazine->getId()}/entries\", parameters: $entryRequest, server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCannotCreateEntryWithoutTitle(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $entryRequest = [\n            'body' => 'This has no title',\n            'url' => 'https://google.com',\n            'tags' => ['test'],\n            'isOc' => false,\n            'lang' => 'en',\n            'isAdult' => false,\n        ];\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read entry:create');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('POST', \"/api/magazine/{$magazine->getId()}/entries\", parameters: $entryRequest, server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(400);\n    }\n\n    public function testApiCanCreateArticleEntry(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $entryRequest = [\n            'title' => 'Test Thread',\n            'body' => 'This is an article',\n            'tags' => ['test'],\n            'isOc' => false,\n            'lang' => 'en',\n            'isAdult' => false,\n        ];\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read entry:create');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('POST', \"/api/magazine/{$magazine->getId()}/entries\", parameters: $entryRequest, server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(201);\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData);\n        self::assertNotNull($jsonData['entryId']);\n        self::assertEquals('Test Thread', $jsonData['title']);\n        self::assertIsArray($jsonData['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']);\n        self::assertSame($magazine->getId(), $jsonData['magazine']['magazineId']);\n        self::assertIsArray($jsonData['user']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']);\n        self::assertSame($user->getId(), $jsonData['user']['userId']);\n        self::assertNull($jsonData['domain']);\n        self::assertNull($jsonData['url']);\n        self::assertEquals('This is an article', $jsonData['body']);\n        self::assertNull($jsonData['image']);\n        self::assertEquals('en', $jsonData['lang']);\n        self::assertIsArray($jsonData['tags']);\n        self::assertSame(['test'], $jsonData['tags']);\n        self::assertIsArray($jsonData['badges']);\n        self::assertEmpty($jsonData['badges']);\n        self::assertSame(0, $jsonData['numComments']);\n        self::assertSame(0, $jsonData['uv']);\n        self::assertSame(0, $jsonData['dv']);\n        self::assertSame(0, $jsonData['favourites']);\n        // No scope for seeing votes granted\n        self::assertNull($jsonData['isFavourited']);\n        self::assertNull($jsonData['userVote']);\n        self::assertFalse($jsonData['isOc']);\n        self::assertFalse($jsonData['isAdult']);\n        self::assertFalse($jsonData['isPinned']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid');\n        self::assertNull($jsonData['editedAt']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid');\n        self::assertEquals('article', $jsonData['type']);\n        self::assertEquals('Test-Thread', $jsonData['slug']);\n        self::assertNull($jsonData['apId']);\n    }\n\n    public function testApiCannotCreateLinkEntryAnonymous(): void\n    {\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $entryRequest = [\n            'title' => 'Anonymous Thread',\n            'url' => 'https://google.com',\n            'body' => 'google',\n            'tags' => ['test'],\n            'isOc' => false,\n            'lang' => 'en',\n            'isAdult' => false,\n        ];\n\n        $this->client->request('POST', \"/api/magazine/{$magazine->getId()}/entries\", parameters: $entryRequest);\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotCreateLinkEntryWithoutScope(): void\n    {\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $entryRequest = [\n            'title' => 'No Scope Thread',\n            'url' => 'https://google.com',\n            'body' => 'google',\n            'tags' => ['test'],\n            'isOc' => false,\n            'lang' => 'en',\n            'isAdult' => false,\n        ];\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('POST', \"/api/magazine/{$magazine->getId()}/entries\", parameters: $entryRequest, server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanCreateLinkEntry(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $entryRequest = [\n            'title' => 'Test Thread',\n            'url' => 'https://google.com',\n            'body' => 'This is a link',\n            'tags' => ['test'],\n            'isOc' => false,\n            'lang' => 'en',\n            'isAdult' => false,\n        ];\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read entry:create');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('POST', \"/api/magazine/{$magazine->getId()}/entries\", parameters: $entryRequest, server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(201);\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData);\n        self::assertNotNull($jsonData['entryId']);\n        self::assertEquals('Test Thread', $jsonData['title']);\n        self::assertIsArray($jsonData['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']);\n        self::assertSame($magazine->getId(), $jsonData['magazine']['magazineId']);\n        self::assertIsArray($jsonData['user']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']);\n        self::assertSame($user->getId(), $jsonData['user']['userId']);\n        self::assertIsArray($jsonData['domain']);\n        self::assertArrayKeysMatch(self::DOMAIN_RESPONSE_KEYS, $jsonData['domain']);\n        self::assertEquals('https://google.com', $jsonData['url']);\n        self::assertEquals('This is a link', $jsonData['body']);\n        if (null !== $jsonData['image']) {\n            self::assertStringContainsString('google.com', parse_url($jsonData['image']['sourceUrl'], PHP_URL_HOST));\n        }\n        self::assertEquals('en', $jsonData['lang']);\n        self::assertIsArray($jsonData['tags']);\n        self::assertSame(['test'], $jsonData['tags']);\n        self::assertIsArray($jsonData['badges']);\n        self::assertEmpty($jsonData['badges']);\n        self::assertSame(0, $jsonData['numComments']);\n        self::assertSame(0, $jsonData['uv']);\n        self::assertSame(0, $jsonData['dv']);\n        self::assertSame(0, $jsonData['favourites']);\n        // No scope for seeing votes granted\n        self::assertNull($jsonData['isFavourited']);\n        self::assertNull($jsonData['userVote']);\n        self::assertFalse($jsonData['isOc']);\n        self::assertFalse($jsonData['isAdult']);\n        self::assertFalse($jsonData['isPinned']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid');\n        self::assertNull($jsonData['editedAt']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid');\n        self::assertEquals('link', $jsonData['type']);\n        self::assertEquals('Test-Thread', $jsonData['slug']);\n        self::assertNull($jsonData['apId']);\n    }\n\n    public function testApiCanCreateLinkWithImageEntry(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $entryRequest = [\n            'title' => 'Test Thread',\n            'url' => 'https://google.com',\n            'body' => 'This is a link',\n            'tags' => ['test'],\n            'isOc' => false,\n            'lang' => 'en',\n            'isAdult' => false,\n        ];\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read entry:create');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        // Uploading a file appears to delete the file at the given path, so make a copy before upload\n        $tmpPath = bin2hex(random_bytes(32));\n        copy($this->kibbyPath, $tmpPath.'.png');\n        $image = new UploadedFile($tmpPath.'.png', 'kibby_emoji.png', 'image/png');\n\n        $imageManager = $this->imageManager;\n        $expectedPath = $imageManager->getFilePath($image->getFilename());\n\n        $this->client->request(\n            'POST', \"/api/magazine/{$magazine->getId()}/entries\",\n            parameters: $entryRequest, files: ['uploadImage' => $image],\n            server: ['HTTP_AUTHORIZATION' => $token],\n        );\n\n        self::assertResponseStatusCodeSame(201);\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData);\n        self::assertNotNull($jsonData['entryId']);\n        self::assertEquals('Test Thread', $jsonData['title']);\n        self::assertIsArray($jsonData['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']);\n        self::assertSame($magazine->getId(), $jsonData['magazine']['magazineId']);\n        self::assertIsArray($jsonData['user']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']);\n        self::assertSame($user->getId(), $jsonData['user']['userId']);\n        self::assertIsArray($jsonData['domain']);\n        self::assertArrayKeysMatch(self::DOMAIN_RESPONSE_KEYS, $jsonData['domain']);\n        self::assertEquals('https://google.com', $jsonData['url']);\n        self::assertEquals('This is a link', $jsonData['body']);\n        self::assertIsArray($jsonData['image']);\n        self::assertArrayKeysMatch(self::IMAGE_KEYS, $jsonData['image']);\n        self::assertStringContainsString($expectedPath, $jsonData['image']['filePath']);\n        self::assertEquals('en', $jsonData['lang']);\n        self::assertIsArray($jsonData['tags']);\n        self::assertSame(['test'], $jsonData['tags']);\n        self::assertIsArray($jsonData['badges']);\n        self::assertEmpty($jsonData['badges']);\n        self::assertSame(0, $jsonData['numComments']);\n        self::assertSame(0, $jsonData['uv']);\n        self::assertSame(0, $jsonData['dv']);\n        self::assertSame(0, $jsonData['favourites']);\n        // No scope for seeing votes granted\n        self::assertNull($jsonData['isFavourited']);\n        self::assertNull($jsonData['userVote']);\n        self::assertFalse($jsonData['isOc']);\n        self::assertFalse($jsonData['isAdult']);\n        self::assertFalse($jsonData['isPinned']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid');\n        self::assertNull($jsonData['editedAt']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid');\n        self::assertEquals('image', $jsonData['type']);\n        self::assertEquals('Test-Thread', $jsonData['slug']);\n        self::assertNull($jsonData['apId']);\n    }\n\n    public function testApiCannotCreateImageEntryAnonymous(): void\n    {\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $entryRequest = [\n            'title' => 'Anonymous Thread',\n            'alt' => 'It\\'s kibby!',\n            'tags' => ['test'],\n            'isOc' => false,\n            'lang' => 'en',\n            'isAdult' => false,\n        ];\n\n        // Uploading a file appears to delete the file at the given path, so make a copy before upload\n        copy($this->kibbyPath, $this->kibbyPath.'.tmp');\n        $image = new UploadedFile($this->kibbyPath.'.tmp', 'kibby_emoji.png', 'image/png');\n\n        $this->client->request(\n            'POST', \"/api/magazine/{$magazine->getId()}/entries\",\n            parameters: $entryRequest, files: ['uploadImage' => $image],\n        );\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotCreateImageEntryWithoutScope(): void\n    {\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $entryRequest = [\n            'title' => 'No Scope Thread',\n            'alt' => 'It\\'s kibby!',\n            'tags' => ['test'],\n            'isOc' => false,\n            'lang' => 'en',\n            'isAdult' => false,\n        ];\n\n        // Uploading a file appears to delete the file at the given path, so make a copy before upload\n        copy($this->kibbyPath, $this->kibbyPath.'.tmp');\n        $image = new UploadedFile($this->kibbyPath.'.tmp', 'kibby_emoji.png', 'image/png');\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request(\n            'POST', \"/api/magazine/{$magazine->getId()}/entries\",\n            parameters: $entryRequest, files: ['uploadImage' => $image],\n            server: ['HTTP_AUTHORIZATION' => $token]\n        );\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanCreateImageEntry(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $entryRequest = [\n            'title' => 'Test Thread',\n            'alt' => 'It\\'s kibby!',\n            'tags' => ['test'],\n            'isOc' => false,\n            'lang' => 'en',\n            'isAdult' => false,\n        ];\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        // Uploading a file appears to delete the file at the given path, so make a copy before upload\n        $tmpPath = bin2hex(random_bytes(32));\n        copy($this->kibbyPath, $tmpPath.'.png');\n        $image = new UploadedFile($tmpPath.'.png', 'kibby_emoji.png', 'image/png');\n\n        $imageManager = $this->imageManager;\n        $expectedPath = $imageManager->getFilePath($image->getFilename());\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read entry:create');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request(\n            'POST', \"/api/magazine/{$magazine->getId()}/entries\",\n            parameters: $entryRequest, files: ['uploadImage' => $image],\n            server: ['HTTP_AUTHORIZATION' => $token]\n        );\n        self::assertResponseStatusCodeSame(201);\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData);\n        self::assertNotNull($jsonData['entryId']);\n        self::assertEquals('Test Thread', $jsonData['title']);\n        self::assertIsArray($jsonData['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']);\n        self::assertSame($magazine->getId(), $jsonData['magazine']['magazineId']);\n        self::assertIsArray($jsonData['user']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']);\n        self::assertSame($user->getId(), $jsonData['user']['userId']);\n        self::assertNull($jsonData['domain']);\n        self::assertNull($jsonData['url']);\n        self::assertNull($jsonData['body']);\n        self::assertIsArray($jsonData['image']);\n        self::assertArrayKeysMatch(self::IMAGE_KEYS, $jsonData['image']);\n        self::assertStringContainsString($expectedPath, $jsonData['image']['filePath']);\n        self::assertEquals('It\\'s kibby!', $jsonData['image']['altText']);\n        self::assertEquals('en', $jsonData['lang']);\n        self::assertIsArray($jsonData['tags']);\n        self::assertSame(['test'], $jsonData['tags']);\n        self::assertIsArray($jsonData['badges']);\n        self::assertEmpty($jsonData['badges']);\n        self::assertSame(0, $jsonData['numComments']);\n        self::assertSame(0, $jsonData['uv']);\n        self::assertSame(0, $jsonData['dv']);\n        self::assertSame(0, $jsonData['favourites']);\n        // No scope for seeing votes granted\n        self::assertNull($jsonData['isFavourited']);\n        self::assertNull($jsonData['userVote']);\n        self::assertFalse($jsonData['isOc']);\n        self::assertFalse($jsonData['isAdult']);\n        self::assertFalse($jsonData['isPinned']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid');\n        self::assertNull($jsonData['editedAt']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid');\n        self::assertEquals('image', $jsonData['type']);\n        self::assertEquals('Test-Thread', $jsonData['slug']);\n        self::assertNull($jsonData['apId']);\n    }\n\n    public function testApiCanCreateImageWithBodyEntry(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $entryRequest = [\n            'title' => 'Test Thread',\n            'body' => 'Isn\\'t it a cute picture?',\n            'alt' => 'It\\'s kibby!',\n            'tags' => ['test'],\n            'isOc' => false,\n            'lang' => 'en',\n            'isAdult' => false,\n        ];\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        // Uploading a file appears to delete the file at the given path, so make a copy before upload\n        $tmpPath = bin2hex(random_bytes(32));\n        copy($this->kibbyPath, $tmpPath.'.png');\n        $image = new UploadedFile($tmpPath.'.png', 'kibby_emoji.png', 'image/png');\n\n        $imageManager = $this->imageManager;\n        $expectedPath = $imageManager->getFilePath($image->getFilename());\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read entry:create');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request(\n            'POST', \"/api/magazine/{$magazine->getId()}/entries\",\n            parameters: $entryRequest, files: ['uploadImage' => $image],\n            server: ['HTTP_AUTHORIZATION' => $token]\n        );\n        self::assertResponseStatusCodeSame(201);\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData);\n        self::assertNotNull($jsonData['entryId']);\n        self::assertEquals('Test Thread', $jsonData['title']);\n        self::assertIsArray($jsonData['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']);\n        self::assertSame($magazine->getId(), $jsonData['magazine']['magazineId']);\n        self::assertIsArray($jsonData['user']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']);\n        self::assertSame($user->getId(), $jsonData['user']['userId']);\n        self::assertNull($jsonData['domain']);\n        self::assertNull($jsonData['url']);\n        self::assertEquals('Isn\\'t it a cute picture?', $jsonData['body']);\n        self::assertIsArray($jsonData['image']);\n        self::assertArrayKeysMatch(self::IMAGE_KEYS, $jsonData['image']);\n        self::assertStringContainsString($expectedPath, $jsonData['image']['filePath']);\n        self::assertEquals('It\\'s kibby!', $jsonData['image']['altText']);\n        self::assertEquals('en', $jsonData['lang']);\n        self::assertIsArray($jsonData['tags']);\n        self::assertSame(['test'], $jsonData['tags']);\n        self::assertIsArray($jsonData['badges']);\n        self::assertEmpty($jsonData['badges']);\n        self::assertSame(0, $jsonData['numComments']);\n        self::assertSame(0, $jsonData['uv']);\n        self::assertSame(0, $jsonData['dv']);\n        self::assertSame(0, $jsonData['favourites']);\n        // No scope for seeing votes granted\n        self::assertNull($jsonData['isFavourited']);\n        self::assertNull($jsonData['userVote']);\n        self::assertFalse($jsonData['isOc']);\n        self::assertFalse($jsonData['isAdult']);\n        self::assertFalse($jsonData['isPinned']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid');\n        self::assertNull($jsonData['editedAt']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid');\n        self::assertEquals('image', $jsonData['type']);\n        self::assertEquals('Test-Thread', $jsonData['slug']);\n        self::assertNull($jsonData['apId']);\n    }\n\n    public function testApiCannotCreateEntryWithoutMagazine(): void\n    {\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $invalidId = $magazine->getId() + 1;\n        $entryRequest = [\n            'title' => 'No Url/Body Thread',\n            'tags' => ['test'],\n            'isOc' => false,\n            'lang' => 'en',\n            'isAdult' => false,\n        ];\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read entry:create');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('POST', \"/api/magazine/{$invalidId}/entries\", parameters: $entryRequest, server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(404);\n    }\n\n    public function testApiCannotCreateEntryWithoutUrlBodyOrImage(): void\n    {\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $entryRequest = [\n            'title' => 'No Url/Body Thread',\n            'tags' => ['test'],\n            'isOc' => false,\n            'lang' => 'en',\n            'isAdult' => false,\n        ];\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read entry:create');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('POST', \"/api/magazine/{$magazine->getId()}/entries\", parameters: $entryRequest, server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(400);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/Entry/EntryCreateApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\Entry;\n\nuse App\\Tests\\WebTestCase;\nuse Symfony\\Component\\HttpFoundation\\File\\UploadedFile;\n\nclass EntryCreateApiTest extends WebTestCase\n{\n    public function testApiCannotCreateArticleEntryAnonymous(): void\n    {\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $entryRequest = [\n            'title' => 'Anonymous Thread',\n            'body' => 'This is an article',\n            'tags' => ['test'],\n            'isOc' => false,\n            'lang' => 'en',\n            'isAdult' => false,\n        ];\n\n        $this->client->jsonRequest('POST', \"/api/magazine/{$magazine->getId()}/article\", parameters: $entryRequest);\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotCreateArticleEntryWithoutScope(): void\n    {\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $entryRequest = [\n            'title' => 'No Scope Thread',\n            'body' => 'This is an article',\n            'tags' => ['test'],\n            'isOc' => false,\n            'lang' => 'en',\n            'isAdult' => false,\n        ];\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('POST', \"/api/magazine/{$magazine->getId()}/article\", parameters: $entryRequest, server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanCreateArticleEntry(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $entryRequest = [\n            'title' => 'Test Thread',\n            'body' => 'This is an article',\n            'tags' => ['test'],\n            'isOc' => false,\n            'lang' => 'en',\n            'isAdult' => false,\n        ];\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read entry:create');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('POST', \"/api/magazine/{$magazine->getId()}/article\", parameters: $entryRequest, server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(201);\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData);\n        self::assertNotNull($jsonData['entryId']);\n        self::assertEquals('Test Thread', $jsonData['title']);\n        self::assertIsArray($jsonData['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']);\n        self::assertSame($magazine->getId(), $jsonData['magazine']['magazineId']);\n        self::assertIsArray($jsonData['user']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']);\n        self::assertSame($user->getId(), $jsonData['user']['userId']);\n        self::assertNull($jsonData['domain']);\n        self::assertNull($jsonData['url']);\n        self::assertEquals('This is an article', $jsonData['body']);\n        self::assertNull($jsonData['image']);\n        self::assertEquals('en', $jsonData['lang']);\n        self::assertIsArray($jsonData['tags']);\n        self::assertSame(['test'], $jsonData['tags']);\n        self::assertIsArray($jsonData['badges']);\n        self::assertEmpty($jsonData['badges']);\n        self::assertSame(0, $jsonData['numComments']);\n        self::assertSame(0, $jsonData['uv']);\n        self::assertSame(0, $jsonData['dv']);\n        self::assertSame(0, $jsonData['favourites']);\n        // No scope for seeing votes granted\n        self::assertNull($jsonData['isFavourited']);\n        self::assertNull($jsonData['userVote']);\n        self::assertFalse($jsonData['isOc']);\n        self::assertFalse($jsonData['isAdult']);\n        self::assertFalse($jsonData['isPinned']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid');\n        self::assertNull($jsonData['editedAt']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid');\n        self::assertEquals('article', $jsonData['type']);\n        self::assertEquals('Test-Thread', $jsonData['slug']);\n        self::assertNull($jsonData['apId']);\n    }\n\n    public function testApiCannotCreateLinkEntryAnonymous(): void\n    {\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $entryRequest = [\n            'title' => 'Anonymous Thread',\n            'url' => 'https://google.com',\n            'body' => 'google',\n            'tags' => ['test'],\n            'isOc' => false,\n            'lang' => 'en',\n            'isAdult' => false,\n        ];\n\n        $this->client->jsonRequest('POST', \"/api/magazine/{$magazine->getId()}/link\", parameters: $entryRequest);\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotCreateLinkEntryWithoutScope(): void\n    {\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $entryRequest = [\n            'title' => 'No Scope Thread',\n            'url' => 'https://google.com',\n            'body' => 'google',\n            'tags' => ['test'],\n            'isOc' => false,\n            'lang' => 'en',\n            'isAdult' => false,\n        ];\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('POST', \"/api/magazine/{$magazine->getId()}/link\", parameters: $entryRequest, server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanCreateLinkEntry(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $entryRequest = [\n            'title' => 'Test Thread',\n            'url' => 'https://google.com',\n            'body' => 'This is a link',\n            'tags' => ['test'],\n            'isOc' => false,\n            'lang' => 'en',\n            'isAdult' => false,\n        ];\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read entry:create');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('POST', \"/api/magazine/{$magazine->getId()}/link\", parameters: $entryRequest, server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(201);\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData);\n        self::assertNotNull($jsonData['entryId']);\n        self::assertEquals('Test Thread', $jsonData['title']);\n        self::assertIsArray($jsonData['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']);\n        self::assertSame($magazine->getId(), $jsonData['magazine']['magazineId']);\n        self::assertIsArray($jsonData['user']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']);\n        self::assertSame($user->getId(), $jsonData['user']['userId']);\n        self::assertIsArray($jsonData['domain']);\n        self::assertArrayKeysMatch(self::DOMAIN_RESPONSE_KEYS, $jsonData['domain']);\n        self::assertEquals('https://google.com', $jsonData['url']);\n        self::assertEquals('This is a link', $jsonData['body']);\n        if (null !== $jsonData['image']) {\n            self::assertStringContainsString('google.com', parse_url($jsonData['image']['sourceUrl'], PHP_URL_HOST));\n        }\n        self::assertEquals('en', $jsonData['lang']);\n        self::assertIsArray($jsonData['tags']);\n        self::assertSame(['test'], $jsonData['tags']);\n        self::assertIsArray($jsonData['badges']);\n        self::assertEmpty($jsonData['badges']);\n        self::assertSame(0, $jsonData['numComments']);\n        self::assertSame(0, $jsonData['uv']);\n        self::assertSame(0, $jsonData['dv']);\n        self::assertSame(0, $jsonData['favourites']);\n        // No scope for seeing votes granted\n        self::assertNull($jsonData['isFavourited']);\n        self::assertNull($jsonData['userVote']);\n        self::assertFalse($jsonData['isOc']);\n        self::assertFalse($jsonData['isAdult']);\n        self::assertFalse($jsonData['isPinned']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid');\n        self::assertNull($jsonData['editedAt']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid');\n        self::assertEquals('link', $jsonData['type']);\n        self::assertEquals('Test-Thread', $jsonData['slug']);\n        self::assertNull($jsonData['apId']);\n    }\n\n    public function testApiCannotCreateImageEntryAnonymous(): void\n    {\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $entryRequest = [\n            'title' => 'Anonymous Thread',\n            'alt' => 'It\\'s kibby!',\n            'tags' => ['test'],\n            'isOc' => false,\n            'lang' => 'en',\n            'isAdult' => false,\n        ];\n\n        // Uploading a file appears to delete the file at the given path, so make a copy before upload\n        copy($this->kibbyPath, $this->kibbyPath.'.tmp');\n        $image = new UploadedFile($this->kibbyPath.'.tmp', 'kibby_emoji.png', 'image/png');\n\n        $this->client->request(\n            'POST', \"/api/magazine/{$magazine->getId()}/image\",\n            parameters: $entryRequest, files: ['uploadImage' => $image],\n        );\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotCreateImageEntryWithoutScope(): void\n    {\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $entryRequest = [\n            'title' => 'No Scope Thread',\n            'alt' => 'It\\'s kibby!',\n            'tags' => ['test'],\n            'isOc' => false,\n            'lang' => 'en',\n            'isAdult' => false,\n        ];\n\n        // Uploading a file appears to delete the file at the given path, so make a copy before upload\n        copy($this->kibbyPath, $this->kibbyPath.'.tmp');\n        $image = new UploadedFile($this->kibbyPath.'.tmp', 'kibby_emoji.png', 'image/png');\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request(\n            'POST', \"/api/magazine/{$magazine->getId()}/image\",\n            parameters: $entryRequest, files: ['uploadImage' => $image],\n            server: ['HTTP_AUTHORIZATION' => $token]\n        );\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanCreateImageEntry(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $entryRequest = [\n            'title' => 'Test Thread',\n            'alt' => 'It\\'s kibby!',\n            'tags' => ['test'],\n            'isOc' => false,\n            'lang' => 'en',\n            'isAdult' => false,\n        ];\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        // Uploading a file appears to delete the file at the given path, so make a copy before upload\n        $tmpPath = bin2hex(random_bytes(32));\n        copy($this->kibbyPath, $tmpPath.'.png');\n        $image = new UploadedFile($tmpPath.'.png', 'kibby_emoji.png', 'image/png');\n\n        $imageManager = $this->imageManager;\n        $expectedPath = $imageManager->getFilePath($image->getFilename());\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read entry:create');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request(\n            'POST', \"/api/magazine/{$magazine->getId()}/image\",\n            parameters: $entryRequest, files: ['uploadImage' => $image],\n            server: ['HTTP_AUTHORIZATION' => $token]\n        );\n        self::assertResponseStatusCodeSame(201);\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData);\n        self::assertNotNull($jsonData['entryId']);\n        self::assertEquals('Test Thread', $jsonData['title']);\n        self::assertIsArray($jsonData['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']);\n        self::assertSame($magazine->getId(), $jsonData['magazine']['magazineId']);\n        self::assertIsArray($jsonData['user']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']);\n        self::assertSame($user->getId(), $jsonData['user']['userId']);\n        self::assertNull($jsonData['domain']);\n        self::assertNull($jsonData['url']);\n        self::assertNull($jsonData['body']);\n        self::assertIsArray($jsonData['image']);\n        self::assertArrayKeysMatch(self::IMAGE_KEYS, $jsonData['image']);\n        self::assertStringContainsString($expectedPath, $jsonData['image']['filePath']);\n        self::assertEquals('It\\'s kibby!', $jsonData['image']['altText']);\n        self::assertEquals('en', $jsonData['lang']);\n        self::assertIsArray($jsonData['tags']);\n        self::assertSame(['test'], $jsonData['tags']);\n        self::assertIsArray($jsonData['badges']);\n        self::assertEmpty($jsonData['badges']);\n        self::assertSame(0, $jsonData['numComments']);\n        self::assertSame(0, $jsonData['uv']);\n        self::assertSame(0, $jsonData['dv']);\n        self::assertSame(0, $jsonData['favourites']);\n        // No scope for seeing votes granted\n        self::assertNull($jsonData['isFavourited']);\n        self::assertNull($jsonData['userVote']);\n        self::assertFalse($jsonData['isOc']);\n        self::assertFalse($jsonData['isAdult']);\n        self::assertFalse($jsonData['isPinned']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid');\n        self::assertNull($jsonData['editedAt']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid');\n        self::assertEquals('image', $jsonData['type']);\n        self::assertEquals('Test-Thread', $jsonData['slug']);\n        self::assertNull($jsonData['apId']);\n    }\n\n    public function testApiCanCreateImageEntryWithBody(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $entryRequest = [\n            'title' => 'Test Thread',\n            'alt' => 'It\\'s kibby!',\n            'tags' => ['test'],\n            'isOc' => false,\n            'lang' => 'en',\n            'isAdult' => false,\n            'body' => 'body text',\n        ];\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        // Uploading a file appears to delete the file at the given path, so make a copy before upload\n        $tmpPath = bin2hex(random_bytes(32));\n        copy($this->kibbyPath, $tmpPath.'.png');\n        $image = new UploadedFile($tmpPath.'.png', 'kibby_emoji.png', 'image/png');\n\n        $imageManager = $this->imageManager;\n        $expectedPath = $imageManager->getFilePath($image->getFilename());\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read entry:create');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request(\n            'POST', \"/api/magazine/{$magazine->getId()}/image\",\n            parameters: $entryRequest, files: ['uploadImage' => $image],\n            server: ['HTTP_AUTHORIZATION' => $token]\n        );\n        self::assertResponseStatusCodeSame(201);\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData);\n        self::assertNotNull($jsonData['entryId']);\n        self::assertEquals('Test Thread', $jsonData['title']);\n        self::assertIsArray($jsonData['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']);\n        self::assertSame($magazine->getId(), $jsonData['magazine']['magazineId']);\n        self::assertIsArray($jsonData['user']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']);\n        self::assertSame($user->getId(), $jsonData['user']['userId']);\n        self::assertNull($jsonData['domain']);\n        self::assertNull($jsonData['url']);\n        self::assertEquals('body text', $jsonData['body']);\n        self::assertIsArray($jsonData['image']);\n        self::assertArrayKeysMatch(self::IMAGE_KEYS, $jsonData['image']);\n        self::assertStringContainsString($expectedPath, $jsonData['image']['filePath']);\n        self::assertEquals('It\\'s kibby!', $jsonData['image']['altText']);\n        self::assertEquals('en', $jsonData['lang']);\n        self::assertIsArray($jsonData['tags']);\n        self::assertSame(['test'], $jsonData['tags']);\n        self::assertIsArray($jsonData['badges']);\n        self::assertEmpty($jsonData['badges']);\n        self::assertSame(0, $jsonData['numComments']);\n        self::assertSame(0, $jsonData['uv']);\n        self::assertSame(0, $jsonData['dv']);\n        self::assertSame(0, $jsonData['favourites']);\n        // No scope for seeing votes granted\n        self::assertNull($jsonData['isFavourited']);\n        self::assertNull($jsonData['userVote']);\n        self::assertFalse($jsonData['isOc']);\n        self::assertFalse($jsonData['isAdult']);\n        self::assertFalse($jsonData['isPinned']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid');\n        self::assertNull($jsonData['editedAt']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid');\n        self::assertEquals('image', $jsonData['type']);\n        self::assertEquals('Test-Thread', $jsonData['slug']);\n        self::assertNull($jsonData['apId']);\n    }\n\n    public function testApiCannotCreateEntryWithoutMagazine(): void\n    {\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $invalidId = $magazine->getId() + 1;\n        $entryRequest = [\n            'title' => 'No Url/Body Thread',\n            'tags' => ['test'],\n            'isOc' => false,\n            'lang' => 'en',\n            'isAdult' => false,\n        ];\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read entry:create');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('POST', \"/api/magazine/{$invalidId}/article\", parameters: $entryRequest, server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(404);\n\n        $this->client->jsonRequest('POST', \"/api/magazine/{$invalidId}/link\", parameters: $entryRequest, server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(404);\n\n        $this->client->request('POST', \"/api/magazine/{$invalidId}/image\", parameters: $entryRequest, server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(404);\n    }\n\n    public function testApiCannotCreateEntryWithoutUrlBodyOrImage(): void\n    {\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $entryRequest = [\n            'title' => 'No Url/Body Thread',\n            'tags' => ['test'],\n            'isOc' => false,\n            'lang' => 'en',\n            'isAdult' => false,\n        ];\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read entry:create');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('POST', \"/api/magazine/{$magazine->getId()}/article\", parameters: $entryRequest, server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(400);\n\n        $this->client->jsonRequest('POST', \"/api/magazine/{$magazine->getId()}/link\", parameters: $entryRequest, server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(400);\n\n        $this->client->request('POST', \"/api/magazine/{$magazine->getId()}/image\", parameters: $entryRequest, server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(400);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/Entry/EntryDeleteApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\Entry;\n\nuse App\\Tests\\WebTestCase;\n\nclass EntryDeleteApiTest extends WebTestCase\n{\n    public function testApiCannotDeleteArticleEntryAnonymous(): void\n    {\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $entry = $this->getEntryByTitle('test article', body: 'test for deletion', magazine: $magazine);\n\n        $this->client->request('DELETE', \"/api/entry/{$entry->getId()}\");\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotDeleteArticleEntryWithoutScope(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $entry = $this->getEntryByTitle('test article', body: 'test for deletion', user: $user, magazine: $magazine);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('DELETE', \"/api/entry/{$entry->getId()}\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCannotDeleteOtherUsersArticleEntry(): void\n    {\n        $otherUser = $this->getUserByUsername('somebody');\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $entry = $this->getEntryByTitle('test article', body: 'test for deletion', user: $otherUser, magazine: $magazine);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read entry:delete');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('DELETE', \"/api/entry/{$entry->getId()}\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanDeleteArticleEntry(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $entry = $this->getEntryByTitle('test article', body: 'test for deletion', user: $user, magazine: $magazine);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read entry:delete');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('DELETE', \"/api/entry/{$entry->getId()}\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(204);\n    }\n\n    public function testApiCannotDeleteLinkEntryAnonymous(): void\n    {\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $entry = $this->getEntryByTitle('test link', url: 'https://google.com');\n\n        $this->client->request('DELETE', \"/api/entry/{$entry->getId()}\");\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotDeleteLinkEntryWithoutScope(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $entry = $this->getEntryByTitle('test link', url: 'https://google.com', user: $user, magazine: $magazine);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('DELETE', \"/api/entry/{$entry->getId()}\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCannotDeleteOtherUsersLinkEntry(): void\n    {\n        $otherUser = $this->getUserByUsername('somebody');\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $entry = $this->getEntryByTitle('test link', url: 'https://google.com', user: $otherUser, magazine: $magazine);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read entry:delete');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('DELETE', \"/api/entry/{$entry->getId()}\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanDeleteLinkEntry(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $entry = $this->getEntryByTitle('test link', url: 'https://google.com', user: $user, magazine: $magazine);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read entry:delete');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('DELETE', \"/api/entry/{$entry->getId()}\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(204);\n    }\n\n    public function testApiCannotDeleteImageEntryAnonymous(): void\n    {\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n\n        $imageDto = $this->getKibbyImageDto();\n        $entry = $this->getEntryByTitle('test image', image: $imageDto, magazine: $magazine);\n\n        $this->client->request('DELETE', \"/api/entry/{$entry->getId()}\");\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotDeleteImageEntryWithoutScope(): void\n    {\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $user = $this->getUserByUsername('user');\n\n        $imageDto = $this->getKibbyImageDto();\n        $entry = $this->getEntryByTitle('test image', image: $imageDto, user: $user, magazine: $magazine);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('DELETE', \"/api/entry/{$entry->getId()}\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanDeleteOtherUsersImageEntry(): void\n    {\n        $otherUser = $this->getUserByUsername('somebody');\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n\n        $imageDto = $this->getKibbyImageDto();\n        $entry = $this->getEntryByTitle('test image', image: $imageDto, user: $otherUser, magazine: $magazine);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read entry:delete');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('DELETE', \"/api/entry/{$entry->getId()}\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanDeleteImageEntry(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n\n        $imageDto = $this->getKibbyImageDto();\n        $entry = $this->getEntryByTitle('test image', image: $imageDto, user: $user, magazine: $magazine);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read entry:delete');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('DELETE', \"/api/entry/{$entry->getId()}\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(204);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/Entry/EntryFavouriteApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\Entry;\n\nuse App\\Tests\\WebTestCase;\n\nclass EntryFavouriteApiTest extends WebTestCase\n{\n    public function testApiCannotFavouriteEntryAnonymous(): void\n    {\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $entry = $this->getEntryByTitle('test article', body: 'test for favourite', magazine: $magazine);\n\n        $this->client->jsonRequest('PUT', \"/api/entry/{$entry->getId()}/favourite\");\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotFavouriteEntryWithoutScope(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $entry = $this->getEntryByTitle('test article', body: 'test for favourite', user: $user, magazine: $magazine);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/entry/{$entry->getId()}/favourite\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanFavouriteEntry(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $entry = $this->getEntryByTitle('test article', body: 'test for favourite', user: $user, magazine: $magazine);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read entry:vote');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/entry/{$entry->getId()}/favourite\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData);\n        self::assertSame($entry->getId(), $jsonData['entryId']);\n        self::assertEquals($entry->title, $jsonData['title']);\n        self::assertIsArray($jsonData['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']);\n        self::assertSame($magazine->getId(), $jsonData['magazine']['magazineId']);\n        self::assertIsArray($jsonData['user']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']);\n        self::assertSame($user->getId(), $jsonData['user']['userId']);\n        self::assertNull($jsonData['domain']);\n        self::assertNull($jsonData['url']);\n        self::assertEquals($entry->body, $jsonData['body']);\n        self::assertNull($jsonData['image']);\n        self::assertEquals($entry->lang, $jsonData['lang']);\n        self::assertEmpty($jsonData['tags']);\n        self::assertIsArray($jsonData['badges']);\n        self::assertEmpty($jsonData['badges']);\n        self::assertSame(0, $jsonData['numComments']);\n        self::assertSame(0, $jsonData['uv']);\n        self::assertSame(0, $jsonData['dv']);\n        self::assertSame(1, $jsonData['favourites']);\n        // No scope for seeing votes granted\n        self::assertTrue($jsonData['isFavourited']);\n        self::assertSame(0, $jsonData['userVote']);\n        self::assertFalse($jsonData['isOc']);\n        self::assertFalse($jsonData['isAdult']);\n        self::assertFalse($jsonData['isPinned']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid');\n        self::assertNull($jsonData['editedAt']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid');\n        self::assertEquals('article', $jsonData['type']);\n        self::assertEquals('test-article', $jsonData['slug']);\n        self::assertNull($jsonData['apId']);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/Entry/EntryReportApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\Entry;\n\nuse App\\Entity\\Report;\nuse App\\Tests\\WebTestCase;\n\nclass EntryReportApiTest extends WebTestCase\n{\n    public function testApiCannotReportEntryAnonymous(): void\n    {\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $entry = $this->getEntryByTitle('test article', body: 'test for report', magazine: $magazine);\n\n        $reportRequest = [\n            'reason' => 'Test reporting',\n        ];\n\n        $this->client->jsonRequest('POST', \"/api/entry/{$entry->getId()}/report\", $reportRequest);\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotReportEntryWithoutScope(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $entry = $this->getEntryByTitle('test article', body: 'test for report', user: $user, magazine: $magazine);\n\n        $reportRequest = [\n            'reason' => 'Test reporting',\n        ];\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('POST', \"/api/entry/{$entry->getId()}/report\", $reportRequest, server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanReportEntry(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $otherUser = $this->getUserByUsername('somebody');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $entry = $this->getEntryByTitle('test article', body: 'test for report', user: $otherUser, magazine: $magazine);\n\n        $reportRequest = [\n            'reason' => 'Test reporting',\n        ];\n\n        $magazineRepository = $this->magazineRepository;\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read entry:report');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('POST', \"/api/entry/{$entry->getId()}/report\", $reportRequest, server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(204);\n\n        $magazine = $magazineRepository->find($magazine->getId());\n        $reports = $magazineRepository->findReports($magazine);\n        self::assertSame(1, $reports->count());\n\n        /** @var Report $report */\n        $report = $reports->getCurrentPageResults()[0];\n\n        self::assertEquals('Test reporting', $report->reason);\n        self::assertSame($user->getId(), $report->reporting->getId());\n        self::assertSame($otherUser->getId(), $report->reported->getId());\n        self::assertSame($entry->getId(), $report->getSubject()->getId());\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/Entry/EntryRetrieveApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\Entry;\n\nuse App\\Tests\\WebTestCase;\n\nclass EntryRetrieveApiTest extends WebTestCase\n{\n    public function testApiCannotGetSubscribedEntriesAnonymous(): void\n    {\n        $this->client->request('GET', '/api/entries/subscribed');\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotGetSubscribedEntriesWithoutScope(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'write');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', '/api/entries/subscribed', server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanGetSubscribedEntries(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $this->getEntryByTitle('an entry', body: 'test');\n        $magazine = $this->getMagazineByNameNoRSAKey('somemag', $user);\n        $entry = $this->getEntryByTitle('another entry', url: 'https://google.com', magazine: $magazine);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', '/api/entries/subscribed', server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(1, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(1, $jsonData['pagination']['count']);\n\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertSame($entry->getId(), $jsonData['items'][0]['entryId']);\n        self::assertEquals('another entry', $jsonData['items'][0]['title']);\n        self::assertIsArray($jsonData['items'][0]['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['magazine']);\n        self::assertSame($magazine->getId(), $jsonData['items'][0]['magazine']['magazineId']);\n        self::assertIsArray($jsonData['items'][0]['user']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['user']);\n        self::assertIsArray($jsonData['items'][0]['domain']);\n        self::assertArrayKeysMatch(self::DOMAIN_RESPONSE_KEYS, $jsonData['items'][0]['domain']);\n        self::assertEquals('https://google.com', $jsonData['items'][0]['url']);\n        self::assertNull($jsonData['items'][0]['body']);\n        if (null !== $jsonData['items'][0]['image']) {\n            self::assertStringContainsString('google.com', parse_url($jsonData['items'][0]['image']['sourceUrl'], PHP_URL_HOST));\n        }\n        self::assertEquals('en', $jsonData['items'][0]['lang']);\n        self::assertEmpty($jsonData['items'][0]['tags']);\n        self::assertIsArray($jsonData['items'][0]['badges']);\n        self::assertEmpty($jsonData['items'][0]['badges']);\n        self::assertSame(0, $jsonData['items'][0]['numComments']);\n        self::assertSame(0, $jsonData['items'][0]['uv']);\n        self::assertSame(0, $jsonData['items'][0]['dv']);\n        self::assertSame(0, $jsonData['items'][0]['favourites']);\n        // No scope for seeing votes granted\n        self::assertNull($jsonData['items'][0]['isFavourited']);\n        self::assertNull($jsonData['items'][0]['userVote']);\n        self::assertFalse($jsonData['items'][0]['isOc']);\n        self::assertFalse($jsonData['items'][0]['isAdult']);\n        self::assertFalse($jsonData['items'][0]['isPinned']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['items'][0]['createdAt'], 'createdAt date format invalid');\n        self::assertNull($jsonData['items'][0]['editedAt']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['items'][0]['lastActive'], 'lastActive date format invalid');\n        self::assertEquals('link', $jsonData['items'][0]['type']);\n        self::assertEquals('another-entry', $jsonData['items'][0]['slug']);\n        self::assertNull($jsonData['items'][0]['apId']);\n        self::assertIsArray($jsonData['items'][0]['bookmarks']);\n        self::assertEmpty($jsonData['items'][0]['bookmarks']);\n    }\n\n    public function testApiCannotGetModeratedEntriesAnonymous(): void\n    {\n        $this->client->request('GET', '/api/entries/moderated');\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotGetModeratedEntriesWithoutScope(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', '/api/entries/moderated', server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanGetModeratedEntries(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $this->getEntryByTitle('an entry', body: 'test');\n        $magazine = $this->getMagazineByNameNoRSAKey('somemag', $user);\n        $entry = $this->getEntryByTitle('another entry', url: 'https://google.com', magazine: $magazine);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:entry');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', '/api/entries/moderated', server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(1, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(1, $jsonData['pagination']['count']);\n\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertSame($entry->getId(), $jsonData['items'][0]['entryId']);\n        self::assertEquals('another entry', $jsonData['items'][0]['title']);\n        self::assertIsArray($jsonData['items'][0]['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['magazine']);\n        self::assertSame($magazine->getId(), $jsonData['items'][0]['magazine']['magazineId']);\n        self::assertIsArray($jsonData['items'][0]['user']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['user']);\n        self::assertIsArray($jsonData['items'][0]['domain']);\n        self::assertArrayKeysMatch(self::DOMAIN_RESPONSE_KEYS, $jsonData['items'][0]['domain']);\n        self::assertEquals('https://google.com', $jsonData['items'][0]['url']);\n        self::assertNull($jsonData['items'][0]['body']);\n        if (null !== $jsonData['items'][0]['image']) {\n            self::assertStringContainsString('google.com', parse_url($jsonData['items'][0]['image']['sourceUrl'], PHP_URL_HOST));\n        }\n        self::assertEquals('en', $jsonData['items'][0]['lang']);\n        self::assertEmpty($jsonData['items'][0]['tags']);\n        self::assertIsArray($jsonData['items'][0]['badges']);\n        self::assertEmpty($jsonData['items'][0]['badges']);\n        self::assertSame(0, $jsonData['items'][0]['numComments']);\n        self::assertSame(0, $jsonData['items'][0]['uv']);\n        self::assertSame(0, $jsonData['items'][0]['dv']);\n        self::assertSame(0, $jsonData['items'][0]['favourites']);\n        // No scope for seeing votes granted\n        self::assertNull($jsonData['items'][0]['isFavourited']);\n        self::assertNull($jsonData['items'][0]['userVote']);\n        self::assertFalse($jsonData['items'][0]['isOc']);\n        self::assertFalse($jsonData['items'][0]['isAdult']);\n        self::assertFalse($jsonData['items'][0]['isPinned']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['items'][0]['createdAt'], 'createdAt date format invalid');\n        self::assertNull($jsonData['items'][0]['editedAt']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['items'][0]['lastActive'], 'lastActive date format invalid');\n        self::assertEquals('link', $jsonData['items'][0]['type']);\n        self::assertEquals('another-entry', $jsonData['items'][0]['slug']);\n        self::assertNull($jsonData['items'][0]['apId']);\n        self::assertIsArray($jsonData['items'][0]['bookmarks']);\n        self::assertEmpty($jsonData['items'][0]['bookmarks']);\n    }\n\n    public function testApiCannotGetFavouritedEntriesAnonymous(): void\n    {\n        $this->client->request('GET', '/api/entries/favourited');\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotGetFavouritedEntriesWithoutScope(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', '/api/entries/favourited', server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanGetFavouritedEntries(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $entry = $this->getEntryByTitle('an entry', body: 'test');\n        $magazine = $this->getMagazineByNameNoRSAKey('somemag');\n        $this->getEntryByTitle('another entry', url: 'https://google.com', magazine: $magazine);\n\n        $favouriteManager = $this->favouriteManager;\n        $favouriteManager->toggle($user, $entry);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read entry:vote');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', '/api/entries/favourited', server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(1, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(1, $jsonData['pagination']['count']);\n\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertSame($entry->getId(), $jsonData['items'][0]['entryId']);\n        self::assertEquals('an entry', $jsonData['items'][0]['title']);\n        self::assertIsArray($jsonData['items'][0]['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['magazine']);\n        self::assertIsArray($jsonData['items'][0]['user']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['user']);\n        self::assertNull($jsonData['items'][0]['domain']);\n        self::assertNull($jsonData['items'][0]['url']);\n        self::assertEquals('test', $jsonData['items'][0]['body']);\n        if (null !== $jsonData['items'][0]['image']) {\n            self::assertStringContainsString('google.com', parse_url($jsonData['items'][0]['image']['sourceUrl'], PHP_URL_HOST));\n        }\n        self::assertEquals('en', $jsonData['items'][0]['lang']);\n        self::assertEmpty($jsonData['items'][0]['tags']);\n        self::assertIsArray($jsonData['items'][0]['badges']);\n        self::assertEmpty($jsonData['items'][0]['badges']);\n        self::assertSame(0, $jsonData['items'][0]['numComments']);\n        self::assertSame(0, $jsonData['items'][0]['uv']);\n        self::assertSame(0, $jsonData['items'][0]['dv']);\n        self::assertSame(1, $jsonData['items'][0]['favourites']);\n        // No scope for seeing votes granted\n        self::assertTrue($jsonData['items'][0]['isFavourited']);\n        self::assertSame(0, $jsonData['items'][0]['userVote']);\n        self::assertFalse($jsonData['items'][0]['isOc']);\n        self::assertFalse($jsonData['items'][0]['isAdult']);\n        self::assertFalse($jsonData['items'][0]['isPinned']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['items'][0]['createdAt'], 'createdAt date format invalid');\n        self::assertNull($jsonData['items'][0]['editedAt']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['items'][0]['lastActive'], 'lastActive date format invalid');\n        self::assertEquals('article', $jsonData['items'][0]['type']);\n        self::assertEquals('an-entry', $jsonData['items'][0]['slug']);\n        self::assertNull($jsonData['items'][0]['apId']);\n        self::assertIsArray($jsonData['items'][0]['bookmarks']);\n        self::assertEmpty($jsonData['items'][0]['bookmarks']);\n    }\n\n    public function testApiCanGetEntriesAnonymous(): void\n    {\n        $entry = $this->getEntryByTitle('an entry', body: 'test');\n        $this->createEntryComment('up the ranking', $entry);\n        $magazine = $this->getMagazineByNameNoRSAKey('somemag');\n        $second = $this->getEntryByTitle('another entry', url: 'https://google.com', magazine: $magazine);\n        // Check that pinned entries don't get pinned to the top of the instance, just the magazine\n        $entryManager = $this->entryManager;\n        $entryManager->pin($second, null);\n\n        $this->client->request('GET', '/api/entries');\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(2, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(2, $jsonData['pagination']['count']);\n\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertSame($entry->getId(), $jsonData['items'][0]['entryId']);\n        self::assertEquals('an entry', $jsonData['items'][0]['title']);\n        self::assertIsArray($jsonData['items'][0]['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['magazine']);\n        self::assertIsArray($jsonData['items'][0]['user']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['user']);\n        self::assertNull($jsonData['items'][0]['domain']);\n        self::assertNull($jsonData['items'][0]['url']);\n        self::assertEquals('test', $jsonData['items'][0]['body']);\n        if (null !== $jsonData['items'][0]['image']) {\n            self::assertStringContainsString('google.com', parse_url($jsonData['items'][0]['image']['sourceUrl'], PHP_URL_HOST));\n        }\n        self::assertEquals('en', $jsonData['items'][0]['lang']);\n        self::assertEmpty($jsonData['items'][0]['tags']);\n        self::assertIsArray($jsonData['items'][0]['badges']);\n        self::assertEmpty($jsonData['items'][0]['badges']);\n        self::assertSame(1, $jsonData['items'][0]['numComments']);\n        self::assertSame(0, $jsonData['items'][0]['uv']);\n        self::assertSame(0, $jsonData['items'][0]['dv']);\n        self::assertSame(0, $jsonData['items'][0]['favourites']);\n        self::assertNull($jsonData['items'][0]['isFavourited']);\n        self::assertNull($jsonData['items'][0]['userVote']);\n        self::assertFalse($jsonData['items'][0]['isOc']);\n        self::assertFalse($jsonData['items'][0]['isAdult']);\n        self::assertFalse($jsonData['items'][0]['isPinned']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['items'][0]['createdAt'], 'createdAt date format invalid');\n        self::assertNull($jsonData['items'][0]['editedAt']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['items'][0]['lastActive'], 'lastActive date format invalid');\n        self::assertEquals('article', $jsonData['items'][0]['type']);\n        self::assertEquals('an-entry', $jsonData['items'][0]['slug']);\n        self::assertNull($jsonData['items'][0]['apId']);\n        self::assertNull($jsonData['items'][0]['bookmarks']);\n\n        self::assertIsArray($jsonData['items'][1]);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][1]);\n        self::assertEquals('another entry', $jsonData['items'][1]['title']);\n        self::assertIsArray($jsonData['items'][1]['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][1]['magazine']);\n        self::assertSame($magazine->getId(), $jsonData['items'][1]['magazine']['magazineId']);\n        self::assertEquals('link', $jsonData['items'][1]['type']);\n        self::assertSame(0, $jsonData['items'][1]['numComments']);\n        self::assertNull($jsonData['items'][0]['bookmarks']);\n    }\n\n    public function testApiCanGetEntries(): void\n    {\n        $entry = $this->getEntryByTitle('an entry', body: 'test');\n        $this->createEntryComment('up the ranking', $entry);\n        $magazine = $this->getMagazineByNameNoRSAKey('somemag');\n        $this->getEntryByTitle('another entry', url: 'https://google.com', magazine: $magazine);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', '/api/entries', server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(2, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(2, $jsonData['pagination']['count']);\n\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertSame($entry->getId(), $jsonData['items'][0]['entryId']);\n        self::assertEquals('an entry', $jsonData['items'][0]['title']);\n        self::assertIsArray($jsonData['items'][0]['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['magazine']);\n        self::assertIsArray($jsonData['items'][0]['user']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['user']);\n        self::assertNull($jsonData['items'][0]['domain']);\n        self::assertNull($jsonData['items'][0]['url']);\n        self::assertEquals('test', $jsonData['items'][0]['body']);\n        if (null !== $jsonData['items'][0]['image']) {\n            self::assertStringContainsString('google.com', parse_url($jsonData['items'][0]['image']['sourceUrl'], PHP_URL_HOST));\n        }\n        self::assertEquals('en', $jsonData['items'][0]['lang']);\n        self::assertEmpty($jsonData['items'][0]['tags']);\n        self::assertIsArray($jsonData['items'][0]['badges']);\n        self::assertEmpty($jsonData['items'][0]['badges']);\n        self::assertSame(1, $jsonData['items'][0]['numComments']);\n        self::assertSame(0, $jsonData['items'][0]['uv']);\n        self::assertSame(0, $jsonData['items'][0]['dv']);\n        self::assertSame(0, $jsonData['items'][0]['favourites']);\n        // No scope for seeing votes granted\n        self::assertNull($jsonData['items'][0]['isFavourited']);\n        self::assertNull($jsonData['items'][0]['userVote']);\n        self::assertFalse($jsonData['items'][0]['isOc']);\n        self::assertFalse($jsonData['items'][0]['isAdult']);\n        self::assertFalse($jsonData['items'][0]['isPinned']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['items'][0]['createdAt'], 'createdAt date format invalid');\n        self::assertNull($jsonData['items'][0]['editedAt']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['items'][0]['lastActive'], 'lastActive date format invalid');\n        self::assertEquals('article', $jsonData['items'][0]['type']);\n        self::assertEquals('an-entry', $jsonData['items'][0]['slug']);\n        self::assertNull($jsonData['items'][0]['apId']);\n        self::assertIsArray($jsonData['items'][0]['bookmarks']);\n        self::assertEmpty($jsonData['items'][0]['bookmarks']);\n\n        self::assertIsArray($jsonData['items'][1]);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][1]);\n        self::assertEquals('another entry', $jsonData['items'][1]['title']);\n        self::assertIsArray($jsonData['items'][1]['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][1]['magazine']);\n        self::assertSame($magazine->getId(), $jsonData['items'][1]['magazine']['magazineId']);\n        self::assertEquals('link', $jsonData['items'][1]['type']);\n        self::assertSame(0, $jsonData['items'][1]['numComments']);\n    }\n\n    public function testApiCanGetEntriesWithLanguageAnonymous(): void\n    {\n        $entry = $this->getEntryByTitle('an entry', body: 'test');\n        $this->createEntryComment('up the ranking', $entry);\n        $magazine = $this->getMagazineByNameNoRSAKey('somemag');\n        $second = $this->getEntryByTitle('another entry', url: 'https://google.com', magazine: $magazine, lang: 'de');\n        $this->getEntryByTitle('a dutch entry', body: 'some body', magazine: $magazine, lang: 'nl');\n        // Check that pinned entries don't get pinned to the top of the instance, just the magazine\n        $entryManager = $this->entryManager;\n        $entryManager->pin($second, null);\n\n        $this->client->request('GET', '/api/entries?lang[]=en&lang[]=de');\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(2, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(2, $jsonData['pagination']['count']);\n\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertSame($entry->getId(), $jsonData['items'][0]['entryId']);\n        self::assertEquals('an entry', $jsonData['items'][0]['title']);\n        self::assertIsArray($jsonData['items'][0]['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['magazine']);\n        self::assertIsArray($jsonData['items'][0]['user']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['user']);\n        self::assertNull($jsonData['items'][0]['domain']);\n        self::assertNull($jsonData['items'][0]['url']);\n        self::assertEquals('test', $jsonData['items'][0]['body']);\n        if (null !== $jsonData['items'][0]['image']) {\n            self::assertStringContainsString('google.com', parse_url($jsonData['items'][0]['image']['sourceUrl'], PHP_URL_HOST));\n        }\n        self::assertEquals('en', $jsonData['items'][0]['lang']);\n        self::assertEmpty($jsonData['items'][0]['tags']);\n        self::assertIsArray($jsonData['items'][0]['badges']);\n        self::assertEmpty($jsonData['items'][0]['badges']);\n        self::assertSame(1, $jsonData['items'][0]['numComments']);\n        self::assertSame(0, $jsonData['items'][0]['uv']);\n        self::assertSame(0, $jsonData['items'][0]['dv']);\n        self::assertSame(0, $jsonData['items'][0]['favourites']);\n        self::assertNull($jsonData['items'][0]['isFavourited']);\n        self::assertNull($jsonData['items'][0]['userVote']);\n        self::assertFalse($jsonData['items'][0]['isOc']);\n        self::assertFalse($jsonData['items'][0]['isAdult']);\n        self::assertFalse($jsonData['items'][0]['isPinned']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['items'][0]['createdAt'], 'createdAt date format invalid');\n        self::assertNull($jsonData['items'][0]['editedAt']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['items'][0]['lastActive'], 'lastActive date format invalid');\n        self::assertEquals('article', $jsonData['items'][0]['type']);\n        self::assertEquals('an-entry', $jsonData['items'][0]['slug']);\n        self::assertNull($jsonData['items'][0]['apId']);\n        self::assertNull($jsonData['items'][0]['bookmarks']);\n\n        self::assertIsArray($jsonData['items'][1]);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][1]);\n        self::assertEquals('another entry', $jsonData['items'][1]['title']);\n        self::assertIsArray($jsonData['items'][1]['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][1]['magazine']);\n        self::assertSame($magazine->getId(), $jsonData['items'][1]['magazine']['magazineId']);\n        self::assertEquals('link', $jsonData['items'][1]['type']);\n        self::assertEquals('de', $jsonData['items'][1]['lang']);\n        self::assertSame(0, $jsonData['items'][1]['numComments']);\n    }\n\n    public function testApiCanGetEntriesWithLanguage(): void\n    {\n        $entry = $this->getEntryByTitle('an entry', body: 'test');\n        $this->createEntryComment('up the ranking', $entry);\n        $magazine = $this->getMagazineByNameNoRSAKey('somemag');\n        $this->getEntryByTitle('another entry', url: 'https://google.com', magazine: $magazine, lang: 'de');\n        $this->getEntryByTitle('a dutch entry', body: 'some body', magazine: $magazine, lang: 'nl');\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', '/api/entries?lang[]=en&lang[]=de', server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(2, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(2, $jsonData['pagination']['count']);\n\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertSame($entry->getId(), $jsonData['items'][0]['entryId']);\n        self::assertEquals('an entry', $jsonData['items'][0]['title']);\n        self::assertIsArray($jsonData['items'][0]['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['magazine']);\n        self::assertIsArray($jsonData['items'][0]['user']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['user']);\n        self::assertNull($jsonData['items'][0]['domain']);\n        self::assertNull($jsonData['items'][0]['url']);\n        self::assertEquals('test', $jsonData['items'][0]['body']);\n        if (null !== $jsonData['items'][0]['image']) {\n            self::assertStringContainsString('google.com', parse_url($jsonData['items'][0]['image']['sourceUrl'], PHP_URL_HOST));\n        }\n        self::assertEquals('en', $jsonData['items'][0]['lang']);\n        self::assertEmpty($jsonData['items'][0]['tags']);\n        self::assertIsArray($jsonData['items'][0]['badges']);\n        self::assertEmpty($jsonData['items'][0]['badges']);\n        self::assertSame(1, $jsonData['items'][0]['numComments']);\n        self::assertSame(0, $jsonData['items'][0]['uv']);\n        self::assertSame(0, $jsonData['items'][0]['dv']);\n        self::assertSame(0, $jsonData['items'][0]['favourites']);\n        // No scope for seeing votes granted\n        self::assertNull($jsonData['items'][0]['isFavourited']);\n        self::assertNull($jsonData['items'][0]['userVote']);\n        self::assertFalse($jsonData['items'][0]['isOc']);\n        self::assertFalse($jsonData['items'][0]['isAdult']);\n        self::assertFalse($jsonData['items'][0]['isPinned']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['items'][0]['createdAt'], 'createdAt date format invalid');\n        self::assertNull($jsonData['items'][0]['editedAt']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['items'][0]['lastActive'], 'lastActive date format invalid');\n        self::assertEquals('article', $jsonData['items'][0]['type']);\n        self::assertEquals('an-entry', $jsonData['items'][0]['slug']);\n        self::assertNull($jsonData['items'][0]['apId']);\n        self::assertIsArray($jsonData['items'][0]['bookmarks']);\n        self::assertEmpty($jsonData['items'][0]['bookmarks']);\n\n        self::assertIsArray($jsonData['items'][1]);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][1]);\n        self::assertEquals('another entry', $jsonData['items'][1]['title']);\n        self::assertIsArray($jsonData['items'][1]['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][1]['magazine']);\n        self::assertSame($magazine->getId(), $jsonData['items'][1]['magazine']['magazineId']);\n        self::assertEquals('link', $jsonData['items'][1]['type']);\n        self::assertEquals('de', $jsonData['items'][1]['lang']);\n        self::assertSame(0, $jsonData['items'][1]['numComments']);\n    }\n\n    public function testApiCannotGetEntriesByPreferredLangAnonymous(): void\n    {\n        $entry = $this->getEntryByTitle('an entry', body: 'test');\n        $this->createEntryComment('up the ranking', $entry);\n        $magazine = $this->getMagazineByNameNoRSAKey('somemag');\n        $second = $this->getEntryByTitle('another entry', url: 'https://google.com', magazine: $magazine);\n        // Check that pinned entries don't get pinned to the top of the instance, just the magazine\n        $entryManager = $this->entryManager;\n        $entryManager->pin($second, null);\n\n        $this->client->request('GET', '/api/entries?usePreferredLangs=true');\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanGetEntriesByPreferredLang(): void\n    {\n        $entry = $this->getEntryByTitle('an entry', body: 'test');\n        $this->createEntryComment('up the ranking', $entry);\n        $magazine = $this->getMagazineByNameNoRSAKey('somemag');\n        $this->getEntryByTitle('another entry', url: 'https://google.com', magazine: $magazine);\n        $this->getEntryByTitle('German entry', body: 'Some body', lang: 'de');\n\n        $user = $this->getUserByUsername('user');\n        $user->preferredLanguages = ['en'];\n        $entityManager = $this->entityManager;\n        $entityManager->persist($user);\n        $entityManager->flush();\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', '/api/entries?usePreferredLangs=true', server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(2, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(2, $jsonData['pagination']['count']);\n\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertSame($entry->getId(), $jsonData['items'][0]['entryId']);\n        self::assertEquals('an entry', $jsonData['items'][0]['title']);\n        self::assertIsArray($jsonData['items'][0]['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['magazine']);\n        self::assertIsArray($jsonData['items'][0]['user']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['user']);\n        self::assertNull($jsonData['items'][0]['domain']);\n        self::assertNull($jsonData['items'][0]['url']);\n        self::assertEquals('test', $jsonData['items'][0]['body']);\n        if (null !== $jsonData['items'][0]['image']) {\n            self::assertStringContainsString('google.com', parse_url($jsonData['items'][0]['image']['sourceUrl'], PHP_URL_HOST));\n        }\n        self::assertEquals('en', $jsonData['items'][0]['lang']);\n        self::assertEmpty($jsonData['items'][0]['tags']);\n        self::assertIsArray($jsonData['items'][0]['badges']);\n        self::assertEmpty($jsonData['items'][0]['badges']);\n        self::assertSame(1, $jsonData['items'][0]['numComments']);\n        self::assertSame(0, $jsonData['items'][0]['uv']);\n        self::assertSame(0, $jsonData['items'][0]['dv']);\n        self::assertSame(0, $jsonData['items'][0]['favourites']);\n        // No scope for seeing votes granted\n        self::assertNull($jsonData['items'][0]['isFavourited']);\n        self::assertNull($jsonData['items'][0]['userVote']);\n        self::assertFalse($jsonData['items'][0]['isOc']);\n        self::assertFalse($jsonData['items'][0]['isAdult']);\n        self::assertFalse($jsonData['items'][0]['isPinned']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['items'][0]['createdAt'], 'createdAt date format invalid');\n        self::assertNull($jsonData['items'][0]['editedAt']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['items'][0]['lastActive'], 'lastActive date format invalid');\n        self::assertEquals('article', $jsonData['items'][0]['type']);\n        self::assertEquals('an-entry', $jsonData['items'][0]['slug']);\n        self::assertNull($jsonData['items'][0]['apId']);\n        self::assertIsArray($jsonData['items'][0]['bookmarks']);\n        self::assertEmpty($jsonData['items'][0]['bookmarks']);\n\n        self::assertIsArray($jsonData['items'][1]);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][1]);\n        self::assertEquals('another entry', $jsonData['items'][1]['title']);\n        self::assertIsArray($jsonData['items'][1]['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][1]['magazine']);\n        self::assertSame($magazine->getId(), $jsonData['items'][1]['magazine']['magazineId']);\n        self::assertEquals('link', $jsonData['items'][1]['type']);\n        self::assertEquals('en', $jsonData['items'][1]['lang']);\n        self::assertSame(0, $jsonData['items'][1]['numComments']);\n    }\n\n    public function testApiCanGetEntriesNewest(): void\n    {\n        $first = $this->getEntryByTitle('first', body: 'test');\n        $second = $this->getEntryByTitle('second', url: 'https://google.com');\n        $third = $this->getEntryByTitle('third', url: 'https://google.com');\n\n        $first->createdAt = new \\DateTimeImmutable('-1 hour');\n        $second->createdAt = new \\DateTimeImmutable('-1 second');\n        $third->createdAt = new \\DateTimeImmutable();\n\n        $entityManager = $this->entityManager;\n        $entityManager->persist($first);\n        $entityManager->persist($second);\n        $entityManager->persist($third);\n        $entityManager->flush();\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', '/api/entries?sort=newest', server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(3, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(3, $jsonData['pagination']['count']);\n\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertSame($third->getId(), $jsonData['items'][0]['entryId']);\n\n        self::assertIsArray($jsonData['items'][1]);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][1]);\n        self::assertSame($second->getId(), $jsonData['items'][1]['entryId']);\n\n        self::assertIsArray($jsonData['items'][2]);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][2]);\n        self::assertSame($first->getId(), $jsonData['items'][2]['entryId']);\n    }\n\n    public function testApiCanGetEntriesOldest(): void\n    {\n        $first = $this->getEntryByTitle('first', body: 'test');\n        $second = $this->getEntryByTitle('second', url: 'https://google.com');\n        $third = $this->getEntryByTitle('third', url: 'https://google.com');\n\n        $first->createdAt = new \\DateTimeImmutable('-1 hour');\n        $second->createdAt = new \\DateTimeImmutable('-1 second');\n        $third->createdAt = new \\DateTimeImmutable();\n\n        $entityManager = $this->entityManager;\n        $entityManager->persist($first);\n        $entityManager->persist($second);\n        $entityManager->persist($third);\n        $entityManager->flush();\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', '/api/entries?sort=oldest', server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(3, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(3, $jsonData['pagination']['count']);\n\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertSame($first->getId(), $jsonData['items'][0]['entryId']);\n\n        self::assertIsArray($jsonData['items'][1]);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][1]);\n        self::assertSame($second->getId(), $jsonData['items'][1]['entryId']);\n\n        self::assertIsArray($jsonData['items'][2]);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][2]);\n        self::assertSame($third->getId(), $jsonData['items'][2]['entryId']);\n    }\n\n    public function testApiCanGetEntriesCommented(): void\n    {\n        $first = $this->getEntryByTitle('first', body: 'test');\n        $this->createEntryComment('comment 1', $first);\n        $this->createEntryComment('comment 2', $first);\n        $second = $this->getEntryByTitle('second', url: 'https://google.com');\n        $this->createEntryComment('comment 1', $second);\n        $third = $this->getEntryByTitle('third', url: 'https://google.com');\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', '/api/entries?sort=commented', server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(3, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(3, $jsonData['pagination']['count']);\n\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertSame($first->getId(), $jsonData['items'][0]['entryId']);\n        self::assertSame(2, $jsonData['items'][0]['numComments']);\n\n        self::assertIsArray($jsonData['items'][1]);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][1]);\n        self::assertSame($second->getId(), $jsonData['items'][1]['entryId']);\n        self::assertSame(1, $jsonData['items'][1]['numComments']);\n\n        self::assertIsArray($jsonData['items'][2]);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][2]);\n        self::assertSame($third->getId(), $jsonData['items'][2]['entryId']);\n        self::assertSame(0, $jsonData['items'][2]['numComments']);\n    }\n\n    public function testApiCanGetEntriesActive(): void\n    {\n        $first = $this->getEntryByTitle('first', body: 'test');\n        $second = $this->getEntryByTitle('second', url: 'https://google.com');\n        $third = $this->getEntryByTitle('third', url: 'https://google.com');\n\n        $first->lastActive = new \\DateTime('-1 hour');\n        $second->lastActive = new \\DateTime('-1 second');\n        $third->lastActive = new \\DateTime();\n\n        $entityManager = $this->entityManager;\n        $entityManager->persist($first);\n        $entityManager->persist($second);\n        $entityManager->persist($third);\n        $entityManager->flush();\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', '/api/entries?sort=active', server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(3, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(3, $jsonData['pagination']['count']);\n\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertSame($third->getId(), $jsonData['items'][0]['entryId']);\n\n        self::assertIsArray($jsonData['items'][1]);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][1]);\n        self::assertSame($second->getId(), $jsonData['items'][1]['entryId']);\n\n        self::assertIsArray($jsonData['items'][2]);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][2]);\n        self::assertSame($first->getId(), $jsonData['items'][2]['entryId']);\n    }\n\n    public function testApiCanGetEntriesTop(): void\n    {\n        $first = $this->getEntryByTitle('first', body: 'test');\n        $second = $this->getEntryByTitle('second', url: 'https://google.com');\n        $third = $this->getEntryByTitle('third', url: 'https://google.com');\n\n        $voteManager = $this->voteManager;\n        $voteManager->vote(1, $first, $this->getUserByUsername('voter1'), rateLimit: false);\n        $voteManager->vote(1, $first, $this->getUserByUsername('voter2'), rateLimit: false);\n        $voteManager->vote(1, $second, $this->getUserByUsername('voter1'), rateLimit: false);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', '/api/entries?sort=top', server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(3, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(3, $jsonData['pagination']['count']);\n\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertSame($first->getId(), $jsonData['items'][0]['entryId']);\n        self::assertSame(2, $jsonData['items'][0]['uv']);\n\n        self::assertIsArray($jsonData['items'][1]);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][1]);\n        self::assertSame($second->getId(), $jsonData['items'][1]['entryId']);\n        self::assertSame(1, $jsonData['items'][1]['uv']);\n\n        self::assertIsArray($jsonData['items'][2]);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][2]);\n        self::assertSame($third->getId(), $jsonData['items'][2]['entryId']);\n        self::assertSame(0, $jsonData['items'][2]['uv']);\n    }\n\n    public function testApiCanGetEntriesWithUserVoteStatus(): void\n    {\n        $entry = $this->getEntryByTitle('an entry', body: 'test');\n        $this->createEntryComment('up the ranking', $entry);\n        $magazine = $this->getMagazineByNameNoRSAKey('somemag');\n        $this->getEntryByTitle('another entry', url: 'https://google.com', magazine: $magazine);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read vote');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', '/api/entries', server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(2, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(2, $jsonData['pagination']['count']);\n\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertSame($entry->getId(), $jsonData['items'][0]['entryId']);\n        self::assertEquals('an entry', $jsonData['items'][0]['title']);\n        self::assertIsArray($jsonData['items'][0]['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['magazine']);\n        self::assertIsArray($jsonData['items'][0]['user']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['user']);\n        self::assertNull($jsonData['items'][0]['domain']);\n        self::assertNull($jsonData['items'][0]['url']);\n        self::assertEquals('test', $jsonData['items'][0]['body']);\n        if (null !== $jsonData['items'][0]['image']) {\n            self::assertStringContainsString('google.com', parse_url($jsonData['items'][0]['image']['sourceUrl'], PHP_URL_HOST));\n        }\n        self::assertEquals('en', $jsonData['items'][0]['lang']);\n        self::assertEmpty($jsonData['items'][0]['tags']);\n        self::assertSame(1, $jsonData['items'][0]['numComments']);\n        self::assertSame(0, $jsonData['items'][0]['uv']);\n        self::assertSame(0, $jsonData['items'][0]['dv']);\n        self::assertSame(0, $jsonData['items'][0]['favourites']);\n        self::assertFalse($jsonData['items'][0]['isFavourited']);\n        self::assertSame(0, $jsonData['items'][0]['userVote']);\n        self::assertFalse($jsonData['items'][0]['isOc']);\n        self::assertFalse($jsonData['items'][0]['isAdult']);\n        self::assertFalse($jsonData['items'][0]['isPinned']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['items'][0]['createdAt'], 'createdAt date format invalid');\n        self::assertNull($jsonData['items'][0]['editedAt']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['items'][0]['lastActive'], 'lastActive date format invalid');\n        self::assertEquals('article', $jsonData['items'][0]['type']);\n        self::assertEquals('an-entry', $jsonData['items'][0]['slug']);\n        self::assertNull($jsonData['items'][0]['apId']);\n        self::assertIsArray($jsonData['items'][0]['bookmarks']);\n        self::assertEmpty($jsonData['items'][0]['bookmarks']);\n\n        self::assertIsArray($jsonData['items'][1]);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][1]);\n        self::assertEquals('another entry', $jsonData['items'][1]['title']);\n        self::assertIsArray($jsonData['items'][1]['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][1]['magazine']);\n        self::assertSame($magazine->getId(), $jsonData['items'][1]['magazine']['magazineId']);\n        self::assertEquals('link', $jsonData['items'][1]['type']);\n        self::assertSame(0, $jsonData['items'][1]['numComments']);\n    }\n\n    public function testApiCanGetEntryByIdAnonymous(): void\n    {\n        $entry = $this->getEntryByTitle('an entry', body: 'test');\n\n        $this->client->request('GET', \"/api/entry/{$entry->getId()}\");\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData);\n        self::assertSame($entry->getId(), $jsonData['entryId']);\n        self::assertEquals('an entry', $jsonData['title']);\n        self::assertIsArray($jsonData['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']);\n        self::assertIsArray($jsonData['user']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']);\n        self::assertNull($jsonData['domain']);\n        self::assertNull($jsonData['url']);\n        self::assertEquals('test', $jsonData['body']);\n        self::assertNull($jsonData['image']);\n        self::assertEquals('en', $jsonData['lang']);\n        self::assertEmpty($jsonData['tags']);\n        self::assertIsArray($jsonData['badges']);\n        self::assertEmpty($jsonData['badges']);\n        self::assertSame(0, $jsonData['numComments']);\n        self::assertSame(0, $jsonData['uv']);\n        self::assertSame(0, $jsonData['dv']);\n        self::assertSame(0, $jsonData['favourites']);\n        self::assertNull($jsonData['isFavourited']);\n        self::assertNull($jsonData['userVote']);\n        self::assertFalse($jsonData['isOc']);\n        self::assertFalse($jsonData['isAdult']);\n        self::assertFalse($jsonData['isPinned']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid');\n        self::assertNull($jsonData['editedAt']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid');\n        self::assertEquals('article', $jsonData['type']);\n        self::assertEquals('an-entry', $jsonData['slug']);\n        self::assertNull($jsonData['apId']);\n        self::assertNull($jsonData['bookmarks']);\n    }\n\n    public function testApiCanGetEntryById(): void\n    {\n        $entry = $this->getEntryByTitle('an entry', body: 'test');\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/entry/{$entry->getId()}\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData);\n        self::assertSame($entry->getId(), $jsonData['entryId']);\n        self::assertEquals('an entry', $jsonData['title']);\n        self::assertIsArray($jsonData['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']);\n        self::assertIsArray($jsonData['user']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']);\n        self::assertNull($jsonData['domain']);\n        self::assertNull($jsonData['url']);\n        self::assertEquals('test', $jsonData['body']);\n        self::assertNull($jsonData['image']);\n        self::assertEquals('en', $jsonData['lang']);\n        self::assertEmpty($jsonData['tags']);\n        self::assertSame(0, $jsonData['numComments']);\n        self::assertSame(0, $jsonData['uv']);\n        self::assertSame(0, $jsonData['dv']);\n        self::assertSame(0, $jsonData['favourites']);\n        // No scope for seeing votes granted\n        self::assertNull($jsonData['isFavourited']);\n        self::assertNull($jsonData['userVote']);\n        self::assertFalse($jsonData['isOc']);\n        self::assertFalse($jsonData['isAdult']);\n        self::assertFalse($jsonData['isPinned']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid');\n        self::assertNull($jsonData['editedAt']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid');\n        self::assertEquals('article', $jsonData['type']);\n        self::assertEquals('an-entry', $jsonData['slug']);\n        self::assertNull($jsonData['apId']);\n        self::assertIsArray($jsonData['bookmarks']);\n        self::assertEmpty($jsonData['bookmarks']);\n    }\n\n    public function testApiCanGetEntryByIdWithUserVoteStatus(): void\n    {\n        $entry = $this->getEntryByTitle('an entry', body: 'test');\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read vote');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/entry/{$entry->getId()}\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData);\n        self::assertSame($entry->getId(), $jsonData['entryId']);\n        self::assertEquals('an entry', $jsonData['title']);\n        self::assertIsArray($jsonData['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']);\n        self::assertIsArray($jsonData['user']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']);\n        self::assertNull($jsonData['domain']);\n        self::assertNull($jsonData['url']);\n        self::assertEquals('test', $jsonData['body']);\n        self::assertNull($jsonData['image']);\n        self::assertEquals('en', $jsonData['lang']);\n        self::assertEmpty($jsonData['tags']);\n        self::assertSame(0, $jsonData['numComments']);\n        self::assertSame(0, $jsonData['uv']);\n        self::assertSame(0, $jsonData['dv']);\n        self::assertSame(0, $jsonData['favourites']);\n        self::assertFalse($jsonData['isFavourited']);\n        self::assertSame(0, $jsonData['userVote']);\n        self::assertFalse($jsonData['isOc']);\n        self::assertFalse($jsonData['isAdult']);\n        self::assertFalse($jsonData['isPinned']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid');\n        self::assertNull($jsonData['editedAt']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid');\n        self::assertEquals('article', $jsonData['type']);\n        self::assertEquals('an-entry', $jsonData['slug']);\n        self::assertNull($jsonData['apId']);\n        self::assertIsArray($jsonData['bookmarks']);\n        self::assertEmpty($jsonData['bookmarks']);\n    }\n\n    public function testApiCanGetEntriesLocal(): void\n    {\n        $first = $this->getEntryByTitle('first', body: 'test');\n        $second = $this->getEntryByTitle('second', body: 'test2');\n\n        $second->apId = 'https://some.url';\n\n        $entityManager = $this->entityManager;\n        $entityManager->persist($first);\n        $entityManager->persist($second);\n        $entityManager->flush();\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', '/api/entries?federation=local', server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n\n        $jsonData = self::getJsonResponse($this->client);\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(1, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(1, $jsonData['pagination']['count']);\n\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertSame($first->getId(), $jsonData['items'][0]['entryId']);\n        self::assertTrue($jsonData['items'][0]['isAuthorModeratorInMagazine']);\n    }\n\n    public function testApiCanGetEntriesFederated(): void\n    {\n        $first = $this->getEntryByTitle('first', body: 'test');\n        $second = $this->getEntryByTitle('second', body: 'test2');\n\n        $second->apId = 'https://some.url';\n\n        $entityManager = $this->entityManager;\n        $entityManager->persist($first);\n        $entityManager->persist($second);\n        $entityManager->flush();\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', '/api/entries?federation=federated', server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n\n        $jsonData = self::getJsonResponse($this->client);\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(1, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(1, $jsonData['pagination']['count']);\n\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertSame($second->getId(), $jsonData['items'][0]['entryId']);\n        self::assertTrue($jsonData['items'][0]['isAuthorModeratorInMagazine']);\n    }\n\n    public function testApiGetAuthorNotModerator(): void\n    {\n        $first = $this->getEntryByTitle('first', body: 'test');\n        sleep(1);\n        $second = $this->getEntryByTitle('second', body: 'test2', user: $this->getUserByUsername('Jane Doe'));\n\n        $entityManager = $this->entityManager;\n        $entityManager->persist($first);\n        $entityManager->persist($second);\n        $entityManager->flush();\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', '/api/entries?sort=oldest', server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n\n        $jsonData = self::getJsonResponse($this->client);\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(2, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(2, $jsonData['pagination']['count']);\n\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertSame($first->getId(), $jsonData['items'][0]['entryId']);\n        self::assertTrue($jsonData['items'][0]['isAuthorModeratorInMagazine']);\n\n        self::assertIsArray($jsonData['items'][1]);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][1]);\n        self::assertSame($second->getId(), $jsonData['items'][1]['entryId']);\n        self::assertFalse($jsonData['items'][1]['isAuthorModeratorInMagazine']);\n    }\n\n    /**\n     * This function tests that the collection endpoint does not contain crosspost information,\n     * but fetching a single entry does.\n     */\n    public function testApiContainsCrosspostInformation(): void\n    {\n        $magazine1 = $this->getMagazineByName('acme');\n        $entry1 = $this->getEntryByTitle('first URL', url: 'https://joinmbin.org', magazine: $magazine1);\n        sleep(1);\n        $magazine2 = $this->getMagazineByName('acme2');\n        $entry2 = $this->getEntryByTitle('second URL', url: 'https://joinmbin.org', magazine: $magazine2);\n\n        $this->entityManager->persist($entry1);\n        $this->entityManager->persist($entry2);\n        $this->entityManager->flush();\n\n        $this->client->request('GET', '/api/entries?sort=oldest');\n        self::assertResponseIsSuccessful();\n\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(2, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(2, $jsonData['pagination']['count']);\n\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertSame($entry1->getId(), $jsonData['items'][0]['entryId']);\n        self::assertNull($jsonData['items'][0]['crosspostedEntries']);\n\n        self::assertIsArray($jsonData['items'][1]);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][1]);\n        self::assertSame($entry2->getId(), $jsonData['items'][1]['entryId']);\n        self::assertNull($jsonData['items'][1]['crosspostedEntries']);\n\n        $this->client->request('GET', '/api/entry/'.$entry1->getId());\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData['crosspostedEntries']);\n        self::assertCount(1, $jsonData['crosspostedEntries']);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['crosspostedEntries'][0]);\n        self::assertSame($entry2->getId(), $jsonData['crosspostedEntries'][0]['entryId']);\n\n        $this->client->request('GET', '/api/entry/'.$entry2->getId());\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData['crosspostedEntries']);\n        self::assertCount(1, $jsonData['crosspostedEntries']);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['crosspostedEntries'][0]);\n        self::assertSame($entry1->getId(), $jsonData['crosspostedEntries'][0]['entryId']);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/Entry/EntryUpdateApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\Entry;\n\nuse App\\Tests\\WebTestCase;\nuse PHPUnit\\Framework\\Attributes\\Group;\n\nclass EntryUpdateApiTest extends WebTestCase\n{\n    public function testApiCannotUpdateArticleEntryAnonymous(): void\n    {\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $entry = $this->getEntryByTitle('test article', body: 'test for update', magazine: $magazine);\n\n        $updateRequest = [\n            'title' => 'Updated title',\n            'tags' => [\n                'edit',\n            ],\n            'isOc' => true,\n            'body' => 'Updated body',\n            'lang' => 'nl',\n            'isAdult' => true,\n        ];\n\n        $this->client->jsonRequest('PUT', \"/api/entry/{$entry->getId()}\", $updateRequest);\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotUpdateArticleEntryWithoutScope(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $entry = $this->getEntryByTitle('test article', body: 'test for update', user: $user, magazine: $magazine);\n\n        $updateRequest = [\n            'title' => 'Updated title',\n            'tags' => [\n                'edit',\n            ],\n            'isOc' => true,\n            'body' => 'Updated body',\n            'lang' => 'nl',\n            'isAdult' => true,\n        ];\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/entry/{$entry->getId()}\", $updateRequest, server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCannotUpdateOtherUsersArticleEntry(): void\n    {\n        $otherUser = $this->getUserByUsername('somebody');\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $entry = $this->getEntryByTitle('test article', body: 'test for update', user: $otherUser, magazine: $magazine);\n\n        $updateRequest = [\n            'title' => 'Updated title',\n            'tags' => [\n                'edit',\n            ],\n            'isOc' => true,\n            'body' => 'Updated body',\n            'lang' => 'nl',\n            'isAdult' => true,\n        ];\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read entry:edit');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/entry/{$entry->getId()}\", $updateRequest, server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanUpdateArticleEntry(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $entry = $this->getEntryByTitle('test article', body: 'test for update', user: $user, magazine: $magazine);\n\n        $updateRequest = [\n            'title' => 'Updated title',\n            'tags' => [\n                'edit',\n            ],\n            'isOc' => true,\n            'body' => 'Updated body',\n            'lang' => 'nl',\n            'isAdult' => true,\n        ];\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read entry:edit');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/entry/{$entry->getId()}\", $updateRequest, server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData);\n        self::assertSame($entry->getId(), $jsonData['entryId']);\n        self::assertEquals($updateRequest['title'], $jsonData['title']);\n        self::assertIsArray($jsonData['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']);\n        self::assertSame($magazine->getId(), $jsonData['magazine']['magazineId']);\n        self::assertIsArray($jsonData['user']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']);\n        self::assertSame($user->getId(), $jsonData['user']['userId']);\n        self::assertNull($jsonData['domain']);\n        self::assertNull($jsonData['url']);\n        self::assertEquals($updateRequest['body'], $jsonData['body']);\n        self::assertNull($jsonData['image']);\n        self::assertEquals($updateRequest['lang'], $jsonData['lang']);\n        self::assertIsArray($jsonData['tags']);\n        self::assertSame($updateRequest['tags'], $jsonData['tags']);\n        self::assertIsArray($jsonData['badges']);\n        self::assertEmpty($jsonData['badges']);\n        self::assertSame(0, $jsonData['numComments']);\n        self::assertSame(0, $jsonData['uv']);\n        self::assertSame(0, $jsonData['dv']);\n        self::assertSame(0, $jsonData['favourites']);\n        // No scope for seeing votes granted\n        self::assertNull($jsonData['isFavourited']);\n        self::assertNull($jsonData['userVote']);\n        self::assertTrue($jsonData['isOc']);\n        self::assertTrue($jsonData['isAdult']);\n        self::assertFalse($jsonData['isPinned']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid');\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['editedAt'], 'editedAt date format invalid');\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid');\n        self::assertEquals('article', $jsonData['type']);\n        self::assertEquals('Updated-title', $jsonData['slug']);\n        self::assertNull($jsonData['apId']);\n    }\n\n    public function testApiCannotUpdateLinkEntryAnonymous(): void\n    {\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $entry = $this->getEntryByTitle('test link', url: 'https://google.com', magazine: $magazine);\n\n        $updateRequest = [\n            'title' => 'Updated title',\n            'tags' => [\n                'edit',\n            ],\n            'isOc' => true,\n            'body' => 'Updated body',\n            'lang' => 'nl',\n            'isAdult' => true,\n        ];\n\n        $this->client->jsonRequest('PUT', \"/api/entry/{$entry->getId()}\");\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotUpdateLinkEntryWithoutScope(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $entry = $this->getEntryByTitle('test link', url: 'https://google.com', user: $user, magazine: $magazine);\n\n        $updateRequest = [\n            'title' => 'Updated title',\n            'tags' => [\n                'edit',\n            ],\n            'isOc' => true,\n            'body' => 'Updated body',\n            'lang' => 'nl',\n            'isAdult' => true,\n        ];\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/entry/{$entry->getId()}\", $updateRequest, server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCannotUpdateOtherUsersLinkEntry(): void\n    {\n        $otherUser = $this->getUserByUsername('somebody');\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $entry = $this->getEntryByTitle('test link', url: 'https://google.com', user: $otherUser, magazine: $magazine);\n\n        $updateRequest = [\n            'title' => 'Updated title',\n            'tags' => [\n                'edit',\n            ],\n            'isOc' => true,\n            'body' => 'Updated body',\n            'lang' => 'nl',\n            'isAdult' => true,\n        ];\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read entry:edit');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/entry/{$entry->getId()}\", $updateRequest, server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanUpdateLinkEntry(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $entry = $this->getEntryByTitle('test link', url: 'https://google.com', user: $user, magazine: $magazine);\n\n        $updateRequest = [\n            'title' => 'Updated title',\n            'tags' => [\n                'edit',\n            ],\n            'isOc' => true,\n            'body' => 'Updated body',\n            'lang' => 'nl',\n            'isAdult' => true,\n        ];\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read entry:edit');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/entry/{$entry->getId()}\", $updateRequest, server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData);\n        self::assertSame($entry->getId(), $jsonData['entryId']);\n        self::assertEquals($updateRequest['title'], $jsonData['title']);\n        self::assertIsArray($jsonData['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']);\n        self::assertSame($magazine->getId(), $jsonData['magazine']['magazineId']);\n        self::assertIsArray($jsonData['user']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']);\n        self::assertSame($user->getId(), $jsonData['user']['userId']);\n        self::assertIsArray($jsonData['domain']);\n        self::assertArrayKeysMatch(self::DOMAIN_RESPONSE_KEYS, $jsonData['domain']);\n        self::assertEquals('https://google.com', $jsonData['url']);\n        self::assertEquals($updateRequest['body'], $jsonData['body']);\n        if (null !== $jsonData['image']) {\n            self::assertStringContainsString('google.com', parse_url($jsonData['image']['sourceUrl'], PHP_URL_HOST));\n        }\n        self::assertEquals($updateRequest['lang'], $jsonData['lang']);\n        self::assertIsArray($jsonData['tags']);\n        self::assertSame($updateRequest['tags'], $jsonData['tags']);\n        self::assertIsArray($jsonData['badges']);\n        self::assertEmpty($jsonData['badges']);\n        self::assertSame(0, $jsonData['numComments']);\n        self::assertSame(0, $jsonData['uv']);\n        self::assertSame(0, $jsonData['dv']);\n        self::assertSame(0, $jsonData['favourites']);\n        // No scope for seeing votes granted\n        self::assertNull($jsonData['isFavourited']);\n        self::assertNull($jsonData['userVote']);\n        self::assertTrue($jsonData['isOc']);\n        self::assertTrue($jsonData['isAdult']);\n        self::assertFalse($jsonData['isPinned']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid');\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['editedAt'], 'editedAt date format invalid');\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid');\n        self::assertEquals('link', $jsonData['type']);\n        self::assertEquals('Updated-title', $jsonData['slug']);\n        self::assertNull($jsonData['apId']);\n    }\n\n    public function testApiCannotUpdateImageEntryAnonymous(): void\n    {\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n\n        $imageDto = $this->getKibbyImageDto();\n        $entry = $this->getEntryByTitle('test image', image: $imageDto, magazine: $magazine);\n\n        $updateRequest = [\n            'title' => 'Updated title',\n            'tags' => [\n                'edit',\n            ],\n            'isOc' => true,\n            'body' => 'Updated body',\n            'lang' => 'nl',\n            'isAdult' => true,\n        ];\n\n        $this->client->jsonRequest('PUT', \"/api/entry/{$entry->getId()}\", $updateRequest);\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotUpdateImageEntryWithoutScope(): void\n    {\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $user = $this->getUserByUsername('user');\n\n        $imageDto = $this->getKibbyImageDto();\n        $entry = $this->getEntryByTitle('test image', image: $imageDto, user: $user, magazine: $magazine);\n\n        $updateRequest = [\n            'title' => 'Updated title',\n            'tags' => [\n                'edit',\n            ],\n            'isOc' => true,\n            'body' => 'Updated body',\n            'lang' => 'nl',\n            'isAdult' => true,\n        ];\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/entry/{$entry->getId()}\", $updateRequest, server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanUpdateOtherUsersImageEntry(): void\n    {\n        $otherUser = $this->getUserByUsername('somebody');\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n\n        $imageDto = $this->getKibbyImageDto();\n        $entry = $this->getEntryByTitle('test image', image: $imageDto, user: $otherUser, magazine: $magazine);\n\n        $updateRequest = [\n            'title' => 'Updated title',\n            'tags' => [\n                'edit',\n            ],\n            'isOc' => true,\n            'body' => 'Updated body',\n            'lang' => 'nl',\n            'isAdult' => true,\n        ];\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read entry:edit');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/entry/{$entry->getId()}\", $updateRequest, server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    #[Group(name: 'NonThreadSafe')]\n    public function testApiCanUpdateImageEntry(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n\n        $imageDto = $this->getKibbyImageDto();\n        $entry = $this->getEntryByTitle('test image', image: $imageDto, user: $user, magazine: $magazine);\n        self::assertNotNull($imageDto->id);\n        self::assertNotNull($entry->image);\n        self::assertNotNull($entry->image->getId());\n        self::assertSame($imageDto->id, $entry->image->getId());\n        self::assertSame($imageDto->filePath, $entry->image->filePath);\n\n        $updateRequest = [\n            'title' => 'Updated title',\n            'tags' => [\n                'edit',\n            ],\n            'isOc' => true,\n            'body' => 'Updated body',\n            'lang' => 'nl',\n            'isAdult' => true,\n        ];\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read entry:edit');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/entry/{$entry->getId()}\", $updateRequest, server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData);\n        self::assertSame($entry->getId(), $jsonData['entryId']);\n        self::assertEquals($updateRequest['title'], $jsonData['title']);\n        self::assertIsArray($jsonData['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']);\n        self::assertSame($magazine->getId(), $jsonData['magazine']['magazineId']);\n        self::assertIsArray($jsonData['user']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']);\n        self::assertSame($user->getId(), $jsonData['user']['userId']);\n        self::assertNull($jsonData['domain']);\n        self::assertNull($jsonData['url']);\n        self::assertEquals($updateRequest['body'], $jsonData['body']);\n        self::assertIsArray($jsonData['image']);\n        self::assertArrayKeysMatch(self::IMAGE_KEYS, $jsonData['image']);\n        self::assertStringContainsString($imageDto->filePath, $jsonData['image']['filePath']);\n        self::assertEquals($updateRequest['lang'], $jsonData['lang']);\n        self::assertIsArray($jsonData['tags']);\n        self::assertSame($updateRequest['tags'], $jsonData['tags']);\n        self::assertIsArray($jsonData['badges']);\n        self::assertEmpty($jsonData['badges']);\n        self::assertSame(0, $jsonData['numComments']);\n        self::assertSame(0, $jsonData['uv']);\n        self::assertSame(0, $jsonData['dv']);\n        self::assertSame(0, $jsonData['favourites']);\n        // No scope for seeing votes granted\n        self::assertNull($jsonData['isFavourited']);\n        self::assertNull($jsonData['userVote']);\n        self::assertTrue($jsonData['isOc']);\n        self::assertTrue($jsonData['isAdult']);\n        self::assertFalse($jsonData['isPinned']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid');\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['editedAt'], 'editedAt date format invalid');\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid');\n        self::assertEquals('image', $jsonData['type']);\n        self::assertEquals('Updated-title', $jsonData['slug']);\n        self::assertNull($jsonData['apId']);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/Entry/EntryVoteApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\Entry;\n\nuse App\\Tests\\WebTestCase;\n\nclass EntryVoteApiTest extends WebTestCase\n{\n    public function testApiCannotUpvoteEntryAnonymous(): void\n    {\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $entry = $this->getEntryByTitle('test article', body: 'test for upvote', magazine: $magazine);\n\n        $this->client->jsonRequest('PUT', \"/api/entry/{$entry->getId()}/vote/1\");\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotUpvoteEntryWithoutScope(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $entry = $this->getEntryByTitle('test article', body: 'test for upvote', user: $user, magazine: $magazine);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/entry/{$entry->getId()}/vote/1\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanUpvoteEntry(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $entry = $this->getEntryByTitle('test article', body: 'test for upvote', user: $user, magazine: $magazine);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read entry:vote');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/entry/{$entry->getId()}/vote/1\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData);\n        self::assertSame($entry->getId(), $jsonData['entryId']);\n        self::assertEquals($entry->title, $jsonData['title']);\n        self::assertIsArray($jsonData['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']);\n        self::assertSame($magazine->getId(), $jsonData['magazine']['magazineId']);\n        self::assertIsArray($jsonData['user']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']);\n        self::assertSame($user->getId(), $jsonData['user']['userId']);\n        self::assertNull($jsonData['domain']);\n        self::assertNull($jsonData['url']);\n        self::assertEquals($entry->body, $jsonData['body']);\n        self::assertNull($jsonData['image']);\n        self::assertEquals($entry->lang, $jsonData['lang']);\n        self::assertEmpty($jsonData['tags']);\n        self::assertIsArray($jsonData['badges']);\n        self::assertEmpty($jsonData['badges']);\n        self::assertSame(0, $jsonData['numComments']);\n        self::assertSame(1, $jsonData['uv']);\n        self::assertSame(0, $jsonData['dv']);\n        self::assertSame(0, $jsonData['favourites']);\n        // No scope for seeing votes granted\n        self::assertFalse($jsonData['isFavourited']);\n        self::assertSame(1, $jsonData['userVote']);\n        self::assertFalse($jsonData['isOc']);\n        self::assertFalse($jsonData['isAdult']);\n        self::assertFalse($jsonData['isPinned']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid');\n        self::assertNull($jsonData['editedAt']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid');\n        self::assertEquals('article', $jsonData['type']);\n        self::assertEquals('test-article', $jsonData['slug']);\n        self::assertNull($jsonData['apId']);\n    }\n\n    public function testApiCannotDownvoteEntryAnonymous(): void\n    {\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $entry = $this->getEntryByTitle('test article', body: 'test for upvote', magazine: $magazine);\n\n        $this->client->jsonRequest('PUT', \"/api/entry/{$entry->getId()}/vote/-1\");\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotDownvoteEntryWithoutScope(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $entry = $this->getEntryByTitle('test article', body: 'test for upvote', user: $user, magazine: $magazine);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/entry/{$entry->getId()}/vote/-1\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanDownvoteEntry(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $entry = $this->getEntryByTitle('test article', body: 'test for upvote', user: $user, magazine: $magazine);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read entry:vote');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/entry/{$entry->getId()}/vote/-1\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData);\n        self::assertSame($entry->getId(), $jsonData['entryId']);\n        self::assertEquals($entry->title, $jsonData['title']);\n        self::assertIsArray($jsonData['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']);\n        self::assertSame($magazine->getId(), $jsonData['magazine']['magazineId']);\n        self::assertIsArray($jsonData['user']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']);\n        self::assertSame($user->getId(), $jsonData['user']['userId']);\n        self::assertNull($jsonData['domain']);\n        self::assertNull($jsonData['url']);\n        self::assertEquals($entry->body, $jsonData['body']);\n        self::assertNull($jsonData['image']);\n        self::assertEquals($entry->lang, $jsonData['lang']);\n        self::assertEmpty($jsonData['tags']);\n        self::assertIsArray($jsonData['badges']);\n        self::assertEmpty($jsonData['badges']);\n        self::assertSame(0, $jsonData['numComments']);\n        self::assertSame(0, $jsonData['uv']);\n        self::assertSame(1, $jsonData['dv']);\n        self::assertSame(0, $jsonData['favourites']);\n        // No scope for seeing votes granted\n        self::assertFalse($jsonData['isFavourited']);\n        self::assertSame(-1, $jsonData['userVote']);\n        self::assertFalse($jsonData['isOc']);\n        self::assertFalse($jsonData['isAdult']);\n        self::assertFalse($jsonData['isPinned']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid');\n        self::assertNull($jsonData['editedAt']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid');\n        self::assertEquals('article', $jsonData['type']);\n        self::assertEquals('test-article', $jsonData['slug']);\n        self::assertNull($jsonData['apId']);\n    }\n\n    public function testApiCannotClearVoteEntryAnonymous(): void\n    {\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $entry = $this->getEntryByTitle('test article', body: 'test for upvote', magazine: $magazine);\n\n        $this->client->jsonRequest('PUT', \"/api/entry/{$entry->getId()}/vote/0\");\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotClearVoteEntryWithoutScope(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $entry = $this->getEntryByTitle('test article', body: 'test for upvote', user: $user, magazine: $magazine);\n\n        $voteManager = $this->voteManager;\n        $voteManager->vote(1, $entry, $user, rateLimit: false);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/entry/{$entry->getId()}/vote/0\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanClearVoteEntry(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $entry = $this->getEntryByTitle('test article', body: 'test for upvote', user: $user, magazine: $magazine);\n\n        $voteManager = $this->voteManager;\n        $voteManager->vote(1, $entry, $user, rateLimit: false);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read entry:vote');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/entry/{$entry->getId()}/vote/0\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData);\n        self::assertSame($entry->getId(), $jsonData['entryId']);\n        self::assertEquals($entry->title, $jsonData['title']);\n        self::assertIsArray($jsonData['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']);\n        self::assertSame($magazine->getId(), $jsonData['magazine']['magazineId']);\n        self::assertIsArray($jsonData['user']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']);\n        self::assertSame($user->getId(), $jsonData['user']['userId']);\n        self::assertNull($jsonData['domain']);\n        self::assertNull($jsonData['url']);\n        self::assertEquals($entry->body, $jsonData['body']);\n        self::assertNull($jsonData['image']);\n        self::assertEquals($entry->lang, $jsonData['lang']);\n        self::assertEmpty($jsonData['tags']);\n        self::assertIsArray($jsonData['badges']);\n        self::assertEmpty($jsonData['badges']);\n        self::assertSame(0, $jsonData['numComments']);\n        self::assertSame(0, $jsonData['uv']);\n        self::assertSame(0, $jsonData['dv']);\n        self::assertSame(0, $jsonData['favourites']);\n        // No scope for seeing votes granted\n        self::assertFalse($jsonData['isFavourited']);\n        self::assertSame(0, $jsonData['userVote']);\n        self::assertFalse($jsonData['isOc']);\n        self::assertFalse($jsonData['isAdult']);\n        self::assertFalse($jsonData['isPinned']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid');\n        self::assertNull($jsonData['editedAt']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid');\n        self::assertEquals('article', $jsonData['type']);\n        self::assertEquals('test-article', $jsonData['slug']);\n        self::assertNull($jsonData['apId']);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/Entry/MagazineEntryRetrieveApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\Entry;\n\nuse App\\Tests\\WebTestCase;\n\nclass MagazineEntryRetrieveApiTest extends WebTestCase\n{\n    public function testApiCanGetMagazineEntriesAnonymous(): void\n    {\n        $entry = $this->getEntryByTitle('an entry', body: 'test');\n        $this->createEntryComment('up the ranking', $entry);\n        $magazine = $this->getMagazineByNameNoRSAKey('somemag');\n        $this->getEntryByTitle('another entry', url: 'https://google.com', magazine: $magazine);\n\n        $this->client->request('GET', \"/api/magazine/{$magazine->getId()}/entries\");\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(1, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(1, $jsonData['pagination']['count']);\n\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertEquals('another entry', $jsonData['items'][0]['title']);\n        self::assertIsArray($jsonData['items'][0]['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['magazine']);\n        self::assertSame($magazine->getId(), $jsonData['items'][0]['magazine']['magazineId']);\n        self::assertEquals('link', $jsonData['items'][0]['type']);\n        self::assertSame(0, $jsonData['items'][0]['numComments']);\n        self::assertNull($jsonData['items'][0]['crosspostedEntries']);\n    }\n\n    public function testApiCanGetMagazineEntries(): void\n    {\n        $entry = $this->getEntryByTitle('an entry', body: 'test');\n        $this->createEntryComment('up the ranking', $entry);\n        $magazine = $this->getMagazineByNameNoRSAKey('somemag');\n        $this->getEntryByTitle('another entry', url: 'https://google.com', magazine: $magazine);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/magazine/{$magazine->getId()}/entries\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(1, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(1, $jsonData['pagination']['count']);\n\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertEquals('another entry', $jsonData['items'][0]['title']);\n        self::assertIsArray($jsonData['items'][0]['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['magazine']);\n        self::assertSame($magazine->getId(), $jsonData['items'][0]['magazine']['magazineId']);\n        self::assertEquals('link', $jsonData['items'][0]['type']);\n        self::assertSame(0, $jsonData['items'][0]['numComments']);\n        self::assertNull($jsonData['items'][0]['crosspostedEntries']);\n    }\n\n    public function testApiCanGetMagazineEntriesPinnedFirst(): void\n    {\n        $voteManager = $this->voteManager;\n        $entryManager = $this->entryManager;\n        $voter = $this->getUserByUsername('voter');\n        $first = $this->getEntryByTitle('an entry', body: 'test');\n        $this->createEntryComment('up the ranking', $first);\n        $magazine = $this->getMagazineByNameNoRSAKey('somemag');\n        $second = $this->getEntryByTitle('another entry', url: 'https://google.com', magazine: $magazine);\n        // Upvote and comment on $second so it should come first, but then pin $third so it actually comes first\n        $voteManager->vote(1, $second, $voter, rateLimit: false);\n        $this->createEntryComment('test', $second, $voter);\n        $third = $this->getEntryByTitle('a pinned entry', url: 'https://google.com', magazine: $magazine);\n        $entryManager->pin($third, null);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/magazine/{$magazine->getId()}/entries\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(2, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(2, $jsonData['pagination']['count']);\n\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertEquals('a pinned entry', $jsonData['items'][0]['title']);\n        self::assertIsArray($jsonData['items'][0]['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['magazine']);\n        self::assertSame($magazine->getId(), $jsonData['items'][0]['magazine']['magazineId']);\n        self::assertEquals('link', $jsonData['items'][0]['type']);\n        self::assertSame(0, $jsonData['items'][0]['numComments']);\n        self::assertSame(0, $jsonData['items'][0]['uv']);\n        self::assertTrue($jsonData['items'][0]['isPinned']);\n        self::assertNull($jsonData['items'][0]['crosspostedEntries']);\n\n        self::assertIsArray($jsonData['items'][1]);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][1]);\n        self::assertEquals('another entry', $jsonData['items'][1]['title']);\n        self::assertIsArray($jsonData['items'][1]['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][1]['magazine']);\n        self::assertSame($magazine->getId(), $jsonData['items'][1]['magazine']['magazineId']);\n        self::assertEquals('link', $jsonData['items'][1]['type']);\n        self::assertSame(1, $jsonData['items'][1]['numComments']);\n        self::assertSame(1, $jsonData['items'][1]['uv']);\n        self::assertFalse($jsonData['items'][1]['isPinned']);\n        self::assertNull($jsonData['items'][1]['crosspostedEntries']);\n    }\n\n    public function testApiCanGetMagazineEntriesNewest(): void\n    {\n        $first = $this->getEntryByTitle('first', body: 'test');\n        $second = $this->getEntryByTitle('second', url: 'https://google.com');\n        $third = $this->getEntryByTitle('third', url: 'https://google.com');\n        $magazine = $first->magazine;\n\n        $first->createdAt = new \\DateTimeImmutable('-1 hour');\n        $second->createdAt = new \\DateTimeImmutable('-1 second');\n        $third->createdAt = new \\DateTimeImmutable();\n\n        $entityManager = $this->entityManager;\n        $entityManager->persist($first);\n        $entityManager->persist($second);\n        $entityManager->persist($third);\n        $entityManager->flush();\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/magazine/{$magazine->getId()}/entries?sort=newest\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(3, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(3, $jsonData['pagination']['count']);\n\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertSame($third->getId(), $jsonData['items'][0]['entryId']);\n\n        self::assertIsArray($jsonData['items'][1]);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][1]);\n        self::assertSame($second->getId(), $jsonData['items'][1]['entryId']);\n\n        self::assertIsArray($jsonData['items'][2]);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][2]);\n        self::assertSame($first->getId(), $jsonData['items'][2]['entryId']);\n    }\n\n    public function testApiCanGetMagazineEntriesOldest(): void\n    {\n        $first = $this->getEntryByTitle('first', body: 'test');\n        $second = $this->getEntryByTitle('second', url: 'https://google.com');\n        $third = $this->getEntryByTitle('third', url: 'https://google.com');\n        $magazine = $first->magazine;\n\n        $first->createdAt = new \\DateTimeImmutable('-1 hour');\n        $second->createdAt = new \\DateTimeImmutable('-1 second');\n        $third->createdAt = new \\DateTimeImmutable();\n\n        $entityManager = $this->entityManager;\n        $entityManager->persist($first);\n        $entityManager->persist($second);\n        $entityManager->persist($third);\n        $entityManager->flush();\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/magazine/{$magazine->getId()}/entries?sort=oldest\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(3, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(3, $jsonData['pagination']['count']);\n\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertSame($first->getId(), $jsonData['items'][0]['entryId']);\n\n        self::assertIsArray($jsonData['items'][1]);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][1]);\n        self::assertSame($second->getId(), $jsonData['items'][1]['entryId']);\n\n        self::assertIsArray($jsonData['items'][2]);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][2]);\n        self::assertSame($third->getId(), $jsonData['items'][2]['entryId']);\n    }\n\n    public function testApiCanGetMagazineEntriesCommented(): void\n    {\n        $first = $this->getEntryByTitle('first', body: 'test');\n        $this->createEntryComment('comment 1', $first);\n        $this->createEntryComment('comment 2', $first);\n        $second = $this->getEntryByTitle('second', url: 'https://google.com');\n        $this->createEntryComment('comment 1', $second);\n        $third = $this->getEntryByTitle('third', url: 'https://google.com');\n        $magazine = $first->magazine;\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/magazine/{$magazine->getId()}/entries?sort=commented\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(3, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(3, $jsonData['pagination']['count']);\n\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertSame($first->getId(), $jsonData['items'][0]['entryId']);\n        self::assertSame(2, $jsonData['items'][0]['numComments']);\n\n        self::assertIsArray($jsonData['items'][1]);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][1]);\n        self::assertSame($second->getId(), $jsonData['items'][1]['entryId']);\n        self::assertSame(1, $jsonData['items'][1]['numComments']);\n\n        self::assertIsArray($jsonData['items'][2]);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][2]);\n        self::assertSame($third->getId(), $jsonData['items'][2]['entryId']);\n        self::assertSame(0, $jsonData['items'][2]['numComments']);\n    }\n\n    public function testApiCanGetMagazineEntriesActive(): void\n    {\n        $first = $this->getEntryByTitle('first', body: 'test');\n        $second = $this->getEntryByTitle('second', url: 'https://google.com');\n        $third = $this->getEntryByTitle('third', url: 'https://google.com');\n        $magazine = $first->magazine;\n\n        $first->lastActive = new \\DateTime('-1 hour');\n        $second->lastActive = new \\DateTime('-1 second');\n        $third->lastActive = new \\DateTime();\n\n        $entityManager = $this->entityManager;\n        $entityManager->persist($first);\n        $entityManager->persist($second);\n        $entityManager->persist($third);\n        $entityManager->flush();\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/magazine/{$magazine->getId()}/entries?sort=active\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(3, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(3, $jsonData['pagination']['count']);\n\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertSame($third->getId(), $jsonData['items'][0]['entryId']);\n\n        self::assertIsArray($jsonData['items'][1]);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][1]);\n        self::assertSame($second->getId(), $jsonData['items'][1]['entryId']);\n\n        self::assertIsArray($jsonData['items'][2]);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][2]);\n        self::assertSame($first->getId(), $jsonData['items'][2]['entryId']);\n    }\n\n    public function testApiCanGetMagazineEntriesTop(): void\n    {\n        $first = $this->getEntryByTitle('first', body: 'test');\n        $second = $this->getEntryByTitle('second', url: 'https://google.com');\n        $third = $this->getEntryByTitle('third', url: 'https://google.com');\n        $magazine = $first->magazine;\n\n        $voteManager = $this->voteManager;\n        $voteManager->vote(1, $first, $this->getUserByUsername('voter1'), rateLimit: false);\n        $voteManager->vote(1, $first, $this->getUserByUsername('voter2'), rateLimit: false);\n        $voteManager->vote(1, $second, $this->getUserByUsername('voter1'), rateLimit: false);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/magazine/{$magazine->getId()}/entries?sort=top\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(3, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(3, $jsonData['pagination']['count']);\n\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertSame($first->getId(), $jsonData['items'][0]['entryId']);\n        self::assertSame(2, $jsonData['items'][0]['uv']);\n\n        self::assertIsArray($jsonData['items'][1]);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][1]);\n        self::assertSame($second->getId(), $jsonData['items'][1]['entryId']);\n        self::assertSame(1, $jsonData['items'][1]['uv']);\n\n        self::assertIsArray($jsonData['items'][2]);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][2]);\n        self::assertSame($third->getId(), $jsonData['items'][2]['entryId']);\n        self::assertSame(0, $jsonData['items'][2]['uv']);\n    }\n\n    public function testApiCanGetMagazineEntriesWithUserVoteStatus(): void\n    {\n        $first = $this->getEntryByTitle('an entry', body: 'test');\n        $this->createEntryComment('up the ranking', $first);\n        $magazine = $this->getMagazineByNameNoRSAKey('somemag');\n        $entry = $this->getEntryByTitle('another entry', url: 'https://google.com', magazine: $magazine);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read vote');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/magazine/{$magazine->getId()}/entries\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(1, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(1, $jsonData['pagination']['count']);\n\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertSame($entry->getId(), $jsonData['items'][0]['entryId']);\n        self::assertEquals('another entry', $jsonData['items'][0]['title']);\n        self::assertIsArray($jsonData['items'][0]['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['magazine']);\n        self::assertSame($magazine->getId(), $jsonData['items'][0]['magazine']['magazineId']);\n        self::assertIsArray($jsonData['items'][0]['user']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['user']);\n        self::assertIsArray($jsonData['items'][0]['domain']);\n        self::assertArrayKeysMatch(self::DOMAIN_RESPONSE_KEYS, $jsonData['items'][0]['domain']);\n        self::assertEquals('https://google.com', $jsonData['items'][0]['url']);\n        self::assertNull($jsonData['items'][0]['body']);\n        if (null !== $jsonData['items'][0]['image']) {\n            self::assertStringContainsString('google.com', parse_url($jsonData['items'][0]['image']['sourceUrl'], PHP_URL_HOST));\n        }\n        self::assertEquals('en', $jsonData['items'][0]['lang']);\n        self::assertEmpty($jsonData['items'][0]['tags']);\n        self::assertSame(0, $jsonData['items'][0]['numComments']);\n        self::assertSame(0, $jsonData['items'][0]['uv']);\n        self::assertSame(0, $jsonData['items'][0]['dv']);\n        self::assertSame(0, $jsonData['items'][0]['favourites']);\n        self::assertFalse($jsonData['items'][0]['isFavourited']);\n        self::assertSame(0, $jsonData['items'][0]['userVote']);\n        self::assertFalse($jsonData['items'][0]['isOc']);\n        self::assertFalse($jsonData['items'][0]['isAdult']);\n        self::assertFalse($jsonData['items'][0]['isPinned']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['items'][0]['createdAt'], 'createdAt date format invalid');\n        self::assertNull($jsonData['items'][0]['editedAt']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['items'][0]['lastActive'], 'lastActive date format invalid');\n        self::assertEquals('link', $jsonData['items'][0]['type']);\n        self::assertEquals('another-entry', $jsonData['items'][0]['slug']);\n        self::assertNull($jsonData['items'][0]['apId']);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/Entry/Moderate/EntryLockApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\Entry\\Moderate;\n\nuse App\\Tests\\WebTestCase;\n\nclass EntryLockApiTest extends WebTestCase\n{\n    public function testApiCannotLockEntryAnonymous(): void\n    {\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $entry = $this->getEntryByTitle('test article', body: 'test for favourite', magazine: $magazine);\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/entry/{$entry->getId()}/lock\");\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiNonModeratorNonAuthorCannotLockEntry(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $user2 = $this->getUserByUsername('user2');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $entry = $this->getEntryByTitle('test article', body: 'test for favourite', user: $user2, magazine: $magazine);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:entry:lock');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/entry/{$entry->getId()}/lock\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCannotLockEntryWithoutScope(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme', $user);\n        $entry = $this->getEntryByTitle('test article', body: 'test for favourite', user: $user, magazine: $magazine);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/entry/{$entry->getId()}/lock\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanLockEntry(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme', $user);\n        $entry = $this->getEntryByTitle('test article', body: 'test for favourite', user: $user, magazine: $magazine);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:entry:lock');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/entry/{$entry->getId()}/lock\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData);\n        self::assertSame($entry->getId(), $jsonData['entryId']);\n        self::assertEquals($entry->title, $jsonData['title']);\n        self::assertIsArray($jsonData['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']);\n        self::assertSame($magazine->getId(), $jsonData['magazine']['magazineId']);\n        self::assertIsArray($jsonData['user']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']);\n        self::assertSame($user->getId(), $jsonData['user']['userId']);\n        self::assertNull($jsonData['domain']);\n        self::assertNull($jsonData['url']);\n        self::assertEquals($entry->body, $jsonData['body']);\n        self::assertNull($jsonData['image']);\n        self::assertEquals($entry->lang, $jsonData['lang']);\n        self::assertEmpty($jsonData['tags']);\n        self::assertIsArray($jsonData['badges']);\n        self::assertEmpty($jsonData['badges']);\n        self::assertSame(0, $jsonData['numComments']);\n        self::assertSame(0, $jsonData['uv']);\n        self::assertSame(0, $jsonData['dv']);\n        self::assertSame(0, $jsonData['favourites']);\n        // No scope for seeing votes granted\n        self::assertNull($jsonData['isFavourited']);\n        self::assertNull($jsonData['userVote']);\n        self::assertFalse($jsonData['isOc']);\n        self::assertFalse($jsonData['isAdult']);\n        self::assertFalse($jsonData['isPinned']);\n        self::assertTrue($jsonData['isLocked']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid');\n        self::assertNull($jsonData['editedAt']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid');\n        self::assertEquals('article', $jsonData['type']);\n        self::assertEquals('test-article', $jsonData['slug']);\n        self::assertNull($jsonData['apId']);\n    }\n\n    public function testApiAuthorNonModeratorCanLockEntry(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $entry = $this->getEntryByTitle('test article', body: 'test for favourite', user: $user, magazine: $magazine);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:entry:lock');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/entry/{$entry->getId()}/lock\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData);\n        self::assertSame($entry->getId(), $jsonData['entryId']);\n        self::assertEquals($entry->title, $jsonData['title']);\n        self::assertIsArray($jsonData['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']);\n        self::assertSame($magazine->getId(), $jsonData['magazine']['magazineId']);\n        self::assertIsArray($jsonData['user']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']);\n        self::assertSame($user->getId(), $jsonData['user']['userId']);\n        self::assertNull($jsonData['domain']);\n        self::assertNull($jsonData['url']);\n        self::assertEquals($entry->body, $jsonData['body']);\n        self::assertNull($jsonData['image']);\n        self::assertEquals($entry->lang, $jsonData['lang']);\n        self::assertEmpty($jsonData['tags']);\n        self::assertIsArray($jsonData['badges']);\n        self::assertEmpty($jsonData['badges']);\n        self::assertSame(0, $jsonData['numComments']);\n        self::assertSame(0, $jsonData['uv']);\n        self::assertSame(0, $jsonData['dv']);\n        self::assertSame(0, $jsonData['favourites']);\n        // No scope for seeing votes granted\n        self::assertNull($jsonData['isFavourited']);\n        self::assertNull($jsonData['userVote']);\n        self::assertFalse($jsonData['isOc']);\n        self::assertFalse($jsonData['isAdult']);\n        self::assertFalse($jsonData['isPinned']);\n        self::assertTrue($jsonData['isLocked']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid');\n        self::assertNull($jsonData['editedAt']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid');\n        self::assertEquals('article', $jsonData['type']);\n        self::assertEquals('test-article', $jsonData['slug']);\n        self::assertNull($jsonData['apId']);\n    }\n\n    public function testApiCannotUnlockEntryAnonymous(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $entry = $this->getEntryByTitle('test article', body: 'test for favourite', magazine: $magazine);\n\n        $entryManager = $this->entryManager;\n        $entryManager->toggleLock($entry, $user);\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/entry/{$entry->getId()}/lock\");\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiNonModeratorNonAuthorCannotUnlockEntry(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $user2 = $this->getUserByUsername('user2');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $entry = $this->getEntryByTitle('test article', body: 'test for favourite', user: $user2, magazine: $magazine);\n\n        $entryManager = $this->entryManager;\n        $entryManager->pin($entry, null);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:entry:lock');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/entry/{$entry->getId()}/lock\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCannotUnlockEntryWithoutScope(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme', $user);\n        $entry = $this->getEntryByTitle('test article', body: 'test for favourite', user: $user, magazine: $magazine);\n\n        $entryManager = $this->entryManager;\n        $entryManager->toggleLock($entry, $user);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/entry/{$entry->getId()}/lock\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanUnlockEntry(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme', $user);\n        $entry = $this->getEntryByTitle('test article', body: 'test for favourite', user: $user, magazine: $magazine);\n\n        $entryManager = $this->entryManager;\n        $entryManager->toggleLock($entry, $user);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:entry:lock');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/entry/{$entry->getId()}/lock\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData);\n        self::assertSame($entry->getId(), $jsonData['entryId']);\n        self::assertEquals($entry->title, $jsonData['title']);\n        self::assertIsArray($jsonData['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']);\n        self::assertSame($magazine->getId(), $jsonData['magazine']['magazineId']);\n        self::assertIsArray($jsonData['user']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']);\n        self::assertSame($user->getId(), $jsonData['user']['userId']);\n        self::assertNull($jsonData['domain']);\n        self::assertNull($jsonData['url']);\n        self::assertEquals($entry->body, $jsonData['body']);\n        self::assertNull($jsonData['image']);\n        self::assertEquals($entry->lang, $jsonData['lang']);\n        self::assertEmpty($jsonData['tags']);\n        self::assertIsArray($jsonData['badges']);\n        self::assertEmpty($jsonData['badges']);\n        self::assertSame(0, $jsonData['numComments']);\n        self::assertSame(0, $jsonData['uv']);\n        self::assertSame(0, $jsonData['dv']);\n        self::assertSame(0, $jsonData['favourites']);\n        // No scope for seeing votes granted\n        self::assertNull($jsonData['isFavourited']);\n        self::assertNull($jsonData['userVote']);\n        self::assertFalse($jsonData['isOc']);\n        self::assertFalse($jsonData['isAdult']);\n        self::assertFalse($jsonData['isPinned']);\n        self::assertFalse($jsonData['isLocked']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid');\n        self::assertNull($jsonData['editedAt']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid');\n        self::assertEquals('article', $jsonData['type']);\n        self::assertEquals('test-article', $jsonData['slug']);\n        self::assertNull($jsonData['apId']);\n    }\n\n    public function testApiAuthorNonModeratorCanUnlockEntry(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $entry = $this->getEntryByTitle('test article', body: 'test for favourite', user: $user, magazine: $magazine);\n\n        $entryManager = $this->entryManager;\n        $entryManager->toggleLock($entry, $user);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:entry:lock');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/entry/{$entry->getId()}/lock\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData);\n        self::assertSame($entry->getId(), $jsonData['entryId']);\n        self::assertEquals($entry->title, $jsonData['title']);\n        self::assertIsArray($jsonData['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']);\n        self::assertSame($magazine->getId(), $jsonData['magazine']['magazineId']);\n        self::assertIsArray($jsonData['user']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']);\n        self::assertSame($user->getId(), $jsonData['user']['userId']);\n        self::assertNull($jsonData['domain']);\n        self::assertNull($jsonData['url']);\n        self::assertEquals($entry->body, $jsonData['body']);\n        self::assertNull($jsonData['image']);\n        self::assertEquals($entry->lang, $jsonData['lang']);\n        self::assertEmpty($jsonData['tags']);\n        self::assertIsArray($jsonData['badges']);\n        self::assertEmpty($jsonData['badges']);\n        self::assertSame(0, $jsonData['numComments']);\n        self::assertSame(0, $jsonData['uv']);\n        self::assertSame(0, $jsonData['dv']);\n        self::assertSame(0, $jsonData['favourites']);\n        // No scope for seeing votes granted\n        self::assertNull($jsonData['isFavourited']);\n        self::assertNull($jsonData['userVote']);\n        self::assertFalse($jsonData['isOc']);\n        self::assertFalse($jsonData['isAdult']);\n        self::assertFalse($jsonData['isPinned']);\n        self::assertFalse($jsonData['isLocked']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid');\n        self::assertNull($jsonData['editedAt']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid');\n        self::assertEquals('article', $jsonData['type']);\n        self::assertEquals('test-article', $jsonData['slug']);\n        self::assertNull($jsonData['apId']);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/Entry/Moderate/EntryPinApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\Entry\\Moderate;\n\nuse App\\Tests\\WebTestCase;\n\nclass EntryPinApiTest extends WebTestCase\n{\n    public function testApiCannotPinEntryAnonymous(): void\n    {\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $entry = $this->getEntryByTitle('test article', body: 'test for favourite', magazine: $magazine);\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/entry/{$entry->getId()}/pin\");\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiNonModeratorCannotPinEntry(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $entry = $this->getEntryByTitle('test article', body: 'test for favourite', user: $user, magazine: $magazine);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:entry:pin');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/entry/{$entry->getId()}/pin\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCannotPinEntryWithoutScope(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme', $user);\n        $entry = $this->getEntryByTitle('test article', body: 'test for favourite', user: $user, magazine: $magazine);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/entry/{$entry->getId()}/pin\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanPinEntry(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme', $user);\n        $entry = $this->getEntryByTitle('test article', body: 'test for favourite', user: $user, magazine: $magazine);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:entry:pin');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/entry/{$entry->getId()}/pin\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData);\n        self::assertSame($entry->getId(), $jsonData['entryId']);\n        self::assertEquals($entry->title, $jsonData['title']);\n        self::assertIsArray($jsonData['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']);\n        self::assertSame($magazine->getId(), $jsonData['magazine']['magazineId']);\n        self::assertIsArray($jsonData['user']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']);\n        self::assertSame($user->getId(), $jsonData['user']['userId']);\n        self::assertNull($jsonData['domain']);\n        self::assertNull($jsonData['url']);\n        self::assertEquals($entry->body, $jsonData['body']);\n        self::assertNull($jsonData['image']);\n        self::assertEquals($entry->lang, $jsonData['lang']);\n        self::assertEmpty($jsonData['tags']);\n        self::assertIsArray($jsonData['badges']);\n        self::assertEmpty($jsonData['badges']);\n        self::assertSame(0, $jsonData['numComments']);\n        self::assertSame(0, $jsonData['uv']);\n        self::assertSame(0, $jsonData['dv']);\n        self::assertSame(0, $jsonData['favourites']);\n        // No scope for seeing votes granted\n        self::assertNull($jsonData['isFavourited']);\n        self::assertNull($jsonData['userVote']);\n        self::assertFalse($jsonData['isOc']);\n        self::assertFalse($jsonData['isAdult']);\n        self::assertTrue($jsonData['isPinned']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid');\n        self::assertNull($jsonData['editedAt']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid');\n        self::assertEquals('article', $jsonData['type']);\n        self::assertEquals('test-article', $jsonData['slug']);\n        self::assertNull($jsonData['apId']);\n    }\n\n    public function testApiCannotUnpinEntryAnonymous(): void\n    {\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $entry = $this->getEntryByTitle('test article', body: 'test for favourite', magazine: $magazine);\n\n        $entryManager = $this->entryManager;\n        $entryManager->pin($entry, null);\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/entry/{$entry->getId()}/pin\");\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiNonModeratorCannotUnpinEntry(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $entry = $this->getEntryByTitle('test article', body: 'test for favourite', user: $user, magazine: $magazine);\n\n        $entryManager = $this->entryManager;\n        $entryManager->pin($entry, null);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:entry:pin');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/entry/{$entry->getId()}/pin\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCannotUnpinEntryWithoutScope(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme', $user);\n        $entry = $this->getEntryByTitle('test article', body: 'test for favourite', user: $user, magazine: $magazine);\n\n        $entryManager = $this->entryManager;\n        $entryManager->pin($entry, null);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/entry/{$entry->getId()}/pin\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanUnpinEntry(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme', $user);\n        $entry = $this->getEntryByTitle('test article', body: 'test for favourite', user: $user, magazine: $magazine);\n\n        $entryManager = $this->entryManager;\n        $entryManager->pin($entry, null);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:entry:pin');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/entry/{$entry->getId()}/pin\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData);\n        self::assertSame($entry->getId(), $jsonData['entryId']);\n        self::assertEquals($entry->title, $jsonData['title']);\n        self::assertIsArray($jsonData['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']);\n        self::assertSame($magazine->getId(), $jsonData['magazine']['magazineId']);\n        self::assertIsArray($jsonData['user']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']);\n        self::assertSame($user->getId(), $jsonData['user']['userId']);\n        self::assertNull($jsonData['domain']);\n        self::assertNull($jsonData['url']);\n        self::assertEquals($entry->body, $jsonData['body']);\n        self::assertNull($jsonData['image']);\n        self::assertEquals($entry->lang, $jsonData['lang']);\n        self::assertEmpty($jsonData['tags']);\n        self::assertIsArray($jsonData['badges']);\n        self::assertEmpty($jsonData['badges']);\n        self::assertSame(0, $jsonData['numComments']);\n        self::assertSame(0, $jsonData['uv']);\n        self::assertSame(0, $jsonData['dv']);\n        self::assertSame(0, $jsonData['favourites']);\n        // No scope for seeing votes granted\n        self::assertNull($jsonData['isFavourited']);\n        self::assertNull($jsonData['userVote']);\n        self::assertFalse($jsonData['isOc']);\n        self::assertFalse($jsonData['isAdult']);\n        self::assertFalse($jsonData['isPinned']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid');\n        self::assertNull($jsonData['editedAt']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid');\n        self::assertEquals('article', $jsonData['type']);\n        self::assertEquals('test-article', $jsonData['slug']);\n        self::assertNull($jsonData['apId']);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/Entry/Moderate/EntrySetAdultApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\Entry\\Moderate;\n\nuse App\\DTO\\ModeratorDto;\nuse App\\Tests\\WebTestCase;\n\nclass EntrySetAdultApiTest extends WebTestCase\n{\n    public function testApiCannotSetEntryAdultAnonymous(): void\n    {\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $entry = $this->getEntryByTitle('test article', body: 'test for favourite', magazine: $magazine);\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/entry/{$entry->getId()}/adult/true\");\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiNonModeratorCannotSetEntryAdult(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $entry = $this->getEntryByTitle('test article', body: 'test for favourite', user: $user, magazine: $magazine);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:entry:set_adult');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/entry/{$entry->getId()}/adult/true\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCannotSetEntryAdultWithoutScope(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme', $user);\n        $entry = $this->getEntryByTitle('test article', body: 'test for favourite', user: $user, magazine: $magazine);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/entry/{$entry->getId()}/adult/true\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanSetEntryAdult(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $admin = $this->getUserByUsername('admin', isAdmin: true);\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $entry = $this->getEntryByTitle('test article', body: 'test for favourite', user: $user, magazine: $magazine);\n\n        $magazineManager = $this->magazineManager;\n        $moderator = new ModeratorDto($magazine);\n        $moderator->user = $user;\n        $moderator->addedBy = $admin;\n        $magazineManager->addModerator($moderator);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:entry:set_adult');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/entry/{$entry->getId()}/adult/true\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData);\n        self::assertSame($entry->getId(), $jsonData['entryId']);\n        self::assertEquals($entry->title, $jsonData['title']);\n        self::assertIsArray($jsonData['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']);\n        self::assertSame($magazine->getId(), $jsonData['magazine']['magazineId']);\n        self::assertIsArray($jsonData['user']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']);\n        self::assertSame($user->getId(), $jsonData['user']['userId']);\n        self::assertNull($jsonData['domain']);\n        self::assertNull($jsonData['url']);\n        self::assertEquals($entry->body, $jsonData['body']);\n        self::assertNull($jsonData['image']);\n        self::assertEquals($entry->lang, $jsonData['lang']);\n        self::assertEmpty($jsonData['tags']);\n        self::assertIsArray($jsonData['badges']);\n        self::assertEmpty($jsonData['badges']);\n        self::assertSame(0, $jsonData['numComments']);\n        self::assertSame(0, $jsonData['uv']);\n        self::assertSame(0, $jsonData['dv']);\n        self::assertSame(0, $jsonData['favourites']);\n        // No scope for seeing votes granted\n        self::assertNull($jsonData['isFavourited']);\n        self::assertNull($jsonData['userVote']);\n        self::assertFalse($jsonData['isOc']);\n        self::assertTrue($jsonData['isAdult']);\n        self::assertFalse($jsonData['isPinned']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid');\n        self::assertNull($jsonData['editedAt']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid');\n        self::assertEquals('visible', $jsonData['visibility']);\n        self::assertEquals('article', $jsonData['type']);\n        self::assertEquals('test-article', $jsonData['slug']);\n        self::assertNull($jsonData['apId']);\n    }\n\n    public function testApiCannotSetEntryNotAdultAnonymous(): void\n    {\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $entry = $this->getEntryByTitle('test article', body: 'test for favourite', magazine: $magazine);\n\n        $entityManager = $this->entityManager;\n        $entry->isAdult = true;\n        $entityManager->persist($entry);\n        $entityManager->flush();\n\n        $this->client->request('PUT', \"/api/moderate/entry/{$entry->getId()}/adult/false\");\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiNonModeratorCannotSetEntryNotAdult(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $entry = $this->getEntryByTitle('test article', body: 'test for favourite', user: $user, magazine: $magazine);\n\n        $entityManager = $this->entityManager;\n        $entry->isAdult = true;\n        $entityManager->persist($entry);\n        $entityManager->flush();\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:entry:set_adult');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/entry/{$entry->getId()}/adult/false\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCannotSetEntryNotAdultWithoutScope(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme', $user);\n        $entry = $this->getEntryByTitle('test article', body: 'test for favourite', user: $user, magazine: $magazine);\n\n        $entityManager = $this->entityManager;\n        $entry->isAdult = true;\n        $entityManager->persist($entry);\n        $entityManager->flush();\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/entry/{$entry->getId()}/adult/false\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanSetEntryNotAdult(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $admin = $this->getUserByUsername('admin', isAdmin: true);\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $entry = $this->getEntryByTitle('test article', body: 'test for favourite', user: $user, magazine: $magazine);\n\n        $magazineManager = $this->magazineManager;\n        $moderator = new ModeratorDto($magazine);\n        $moderator->user = $user;\n        $moderator->addedBy = $admin;\n        $magazineManager->addModerator($moderator);\n\n        $entityManager = $this->entityManager;\n        $entry->isAdult = true;\n        $entityManager->persist($entry);\n        $entityManager->flush();\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:entry:set_adult');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/entry/{$entry->getId()}/adult/false\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData);\n        self::assertSame($entry->getId(), $jsonData['entryId']);\n        self::assertEquals($entry->title, $jsonData['title']);\n        self::assertIsArray($jsonData['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']);\n        self::assertSame($magazine->getId(), $jsonData['magazine']['magazineId']);\n        self::assertIsArray($jsonData['user']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']);\n        self::assertSame($user->getId(), $jsonData['user']['userId']);\n        self::assertNull($jsonData['domain']);\n        self::assertNull($jsonData['url']);\n        self::assertEquals($entry->body, $jsonData['body']);\n        self::assertNull($jsonData['image']);\n        self::assertEquals($entry->lang, $jsonData['lang']);\n        self::assertEmpty($jsonData['tags']);\n        self::assertIsArray($jsonData['badges']);\n        self::assertEmpty($jsonData['badges']);\n        self::assertSame(0, $jsonData['numComments']);\n        self::assertSame(0, $jsonData['uv']);\n        self::assertSame(0, $jsonData['dv']);\n        self::assertSame(0, $jsonData['favourites']);\n        // No scope for seeing votes granted\n        self::assertNull($jsonData['isFavourited']);\n        self::assertNull($jsonData['userVote']);\n        self::assertFalse($jsonData['isOc']);\n        self::assertFalse($jsonData['isAdult']);\n        self::assertFalse($jsonData['isPinned']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid');\n        self::assertNull($jsonData['editedAt']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid');\n        self::assertEquals('visible', $jsonData['visibility']);\n        self::assertEquals('article', $jsonData['type']);\n        self::assertEquals('test-article', $jsonData['slug']);\n        self::assertNull($jsonData['apId']);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/Entry/Moderate/EntrySetLanguageApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\Entry\\Moderate;\n\nuse App\\DTO\\ModeratorDto;\nuse App\\Tests\\WebTestCase;\n\nclass EntrySetLanguageApiTest extends WebTestCase\n{\n    public function testApiCannotSetEntryLanguageAnonymous(): void\n    {\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $entry = $this->getEntryByTitle('test article', body: 'test for favourite', magazine: $magazine);\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/entry/{$entry->getId()}/de\");\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiNonModeratorCannotSetEntryLanguage(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $entry = $this->getEntryByTitle('test article', body: 'test for favourite', user: $user, magazine: $magazine);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:entry:language');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/entry/{$entry->getId()}/de\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCannotSetEntryLanguageWithoutScope(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme', $user);\n        $entry = $this->getEntryByTitle('test article', body: 'test for favourite', user: $user, magazine: $magazine);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/entry/{$entry->getId()}/de\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCannotSetEntryLanguageInvalid(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $admin = $this->getUserByUsername('admin', isAdmin: true);\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $entry = $this->getEntryByTitle('test article', body: 'test for favourite', user: $user, magazine: $magazine);\n\n        $magazineManager = $this->magazineManager;\n        $moderator = new ModeratorDto($magazine);\n        $moderator->user = $user;\n        $moderator->addedBy = $admin;\n        $magazineManager->addModerator($moderator);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:entry:language');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/entry/{$entry->getId()}/fake\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(400);\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/entry/{$entry->getId()}/ac\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(400);\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/entry/{$entry->getId()}/aaa\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(400);\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/entry/{$entry->getId()}/a\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(400);\n    }\n\n    public function testApiCanSetEntryLanguage(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $admin = $this->getUserByUsername('admin', isAdmin: true);\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $entry = $this->getEntryByTitle('test article', body: 'test for favourite', user: $user, magazine: $magazine);\n\n        $magazineManager = $this->magazineManager;\n        $moderator = new ModeratorDto($magazine);\n        $moderator->user = $user;\n        $moderator->addedBy = $admin;\n        $magazineManager->addModerator($moderator);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:entry:language');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/entry/{$entry->getId()}/de\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData);\n        self::assertSame($entry->getId(), $jsonData['entryId']);\n        self::assertEquals($entry->title, $jsonData['title']);\n        self::assertIsArray($jsonData['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']);\n        self::assertSame($magazine->getId(), $jsonData['magazine']['magazineId']);\n        self::assertIsArray($jsonData['user']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']);\n        self::assertSame($user->getId(), $jsonData['user']['userId']);\n        self::assertNull($jsonData['domain']);\n        self::assertNull($jsonData['url']);\n        self::assertEquals($entry->body, $jsonData['body']);\n        self::assertNull($jsonData['image']);\n        self::assertEquals('de', $jsonData['lang']);\n        self::assertEmpty($jsonData['tags']);\n        self::assertIsArray($jsonData['badges']);\n        self::assertEmpty($jsonData['badges']);\n        self::assertSame(0, $jsonData['numComments']);\n        self::assertSame(0, $jsonData['uv']);\n        self::assertSame(0, $jsonData['dv']);\n        self::assertSame(0, $jsonData['favourites']);\n        // No scope for seeing votes granted\n        self::assertNull($jsonData['isFavourited']);\n        self::assertNull($jsonData['userVote']);\n        self::assertFalse($jsonData['isOc']);\n        self::assertFalse($jsonData['isAdult']);\n        self::assertFalse($jsonData['isPinned']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid');\n        self::assertNull($jsonData['editedAt']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid');\n        self::assertEquals('visible', $jsonData['visibility']);\n        self::assertEquals('article', $jsonData['type']);\n        self::assertEquals('test-article', $jsonData['slug']);\n        self::assertNull($jsonData['apId']);\n    }\n\n    public function testApiCanSetEntryLanguage3Letter(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $admin = $this->getUserByUsername('admin', isAdmin: true);\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $entry = $this->getEntryByTitle('test article', body: 'test for favourite', user: $user, magazine: $magazine);\n\n        $magazineManager = $this->magazineManager;\n        $moderator = new ModeratorDto($magazine);\n        $moderator->user = $user;\n        $moderator->addedBy = $admin;\n        $magazineManager->addModerator($moderator);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:entry:language');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/entry/{$entry->getId()}/elx\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData);\n        self::assertSame($entry->getId(), $jsonData['entryId']);\n        self::assertEquals($entry->title, $jsonData['title']);\n        self::assertIsArray($jsonData['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']);\n        self::assertSame($magazine->getId(), $jsonData['magazine']['magazineId']);\n        self::assertIsArray($jsonData['user']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']);\n        self::assertSame($user->getId(), $jsonData['user']['userId']);\n        self::assertNull($jsonData['domain']);\n        self::assertNull($jsonData['url']);\n        self::assertEquals($entry->body, $jsonData['body']);\n        self::assertNull($jsonData['image']);\n        self::assertEquals('elx', $jsonData['lang']);\n        self::assertEmpty($jsonData['tags']);\n        self::assertIsArray($jsonData['badges']);\n        self::assertEmpty($jsonData['badges']);\n        self::assertSame(0, $jsonData['numComments']);\n        self::assertSame(0, $jsonData['uv']);\n        self::assertSame(0, $jsonData['dv']);\n        self::assertSame(0, $jsonData['favourites']);\n        // No scope for seeing votes granted\n        self::assertNull($jsonData['isFavourited']);\n        self::assertNull($jsonData['userVote']);\n        self::assertFalse($jsonData['isOc']);\n        self::assertFalse($jsonData['isAdult']);\n        self::assertFalse($jsonData['isPinned']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid');\n        self::assertNull($jsonData['editedAt']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid');\n        self::assertEquals('visible', $jsonData['visibility']);\n        self::assertEquals('article', $jsonData['type']);\n        self::assertEquals('test-article', $jsonData['slug']);\n        self::assertNull($jsonData['apId']);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/Entry/Moderate/EntryTrashApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\Entry\\Moderate;\n\nuse App\\DTO\\ModeratorDto;\nuse App\\Tests\\WebTestCase;\n\nclass EntryTrashApiTest extends WebTestCase\n{\n    public function testApiCannotTrashEntryAnonymous(): void\n    {\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $entry = $this->getEntryByTitle('test article', body: 'test for favourite', magazine: $magazine);\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/entry/{$entry->getId()}/trash\");\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiNonModeratorCannotTrashEntry(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $entry = $this->getEntryByTitle('test article', body: 'test for favourite', user: $user, magazine: $magazine);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:entry:trash');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/entry/{$entry->getId()}/trash\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCannotTrashEntryWithoutScope(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme', $user);\n        $entry = $this->getEntryByTitle('test article', body: 'test for favourite', user: $user, magazine: $magazine);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/entry/{$entry->getId()}/trash\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanTrashEntry(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $admin = $this->getUserByUsername('admin', isAdmin: true);\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $entry = $this->getEntryByTitle('test article', body: 'test for favourite', user: $user, magazine: $magazine);\n\n        $magazineManager = $this->magazineManager;\n        $moderator = new ModeratorDto($magazine);\n        $moderator->user = $user;\n        $moderator->addedBy = $admin;\n        $magazineManager->addModerator($moderator);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:entry:trash');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/entry/{$entry->getId()}/trash\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData);\n        self::assertSame($entry->getId(), $jsonData['entryId']);\n        self::assertEquals($entry->title, $jsonData['title']);\n        self::assertIsArray($jsonData['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']);\n        self::assertSame($magazine->getId(), $jsonData['magazine']['magazineId']);\n        self::assertIsArray($jsonData['user']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']);\n        self::assertSame($user->getId(), $jsonData['user']['userId']);\n        self::assertNull($jsonData['domain']);\n        self::assertNull($jsonData['url']);\n        self::assertEquals($entry->body, $jsonData['body']);\n        self::assertNull($jsonData['image']);\n        self::assertEquals($entry->lang, $jsonData['lang']);\n        self::assertEmpty($jsonData['tags']);\n        self::assertIsArray($jsonData['badges']);\n        self::assertEmpty($jsonData['badges']);\n        self::assertSame(0, $jsonData['numComments']);\n        self::assertSame(0, $jsonData['uv']);\n        self::assertSame(0, $jsonData['dv']);\n        self::assertSame(0, $jsonData['favourites']);\n        // No scope for seeing votes granted\n        self::assertNull($jsonData['isFavourited']);\n        self::assertNull($jsonData['userVote']);\n        self::assertFalse($jsonData['isOc']);\n        self::assertFalse($jsonData['isAdult']);\n        self::assertFalse($jsonData['isPinned']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid');\n        self::assertNull($jsonData['editedAt']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid');\n        self::assertEquals('trashed', $jsonData['visibility']);\n        self::assertEquals('article', $jsonData['type']);\n        self::assertEquals('test-article', $jsonData['slug']);\n        self::assertNull($jsonData['apId']);\n    }\n\n    public function testApiCannotRestoreEntryAnonymous(): void\n    {\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $user = $this->getUserByUsername('user');\n        $entry = $this->getEntryByTitle('test article', body: 'test for favourite', magazine: $magazine);\n\n        $entryManager = $this->entryManager;\n        $entryManager->trash($user, $entry);\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/entry/{$entry->getId()}/restore\");\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiNonModeratorCannotRestoreEntry(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $entry = $this->getEntryByTitle('test article', body: 'test for favourite', user: $user, magazine: $magazine);\n\n        $entryManager = $this->entryManager;\n        $entryManager->trash($user, $entry);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:entry:trash');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/entry/{$entry->getId()}/restore\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCannotRestoreEntryWithoutScope(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme', $user);\n        $entry = $this->getEntryByTitle('test article', body: 'test for favourite', user: $user, magazine: $magazine);\n\n        $entryManager = $this->entryManager;\n        $entryManager->trash($user, $entry);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/entry/{$entry->getId()}/restore\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanRestoreEntry(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $admin = $this->getUserByUsername('admin', isAdmin: true);\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $entry = $this->getEntryByTitle('test article', body: 'test for favourite', user: $user, magazine: $magazine);\n\n        $magazineManager = $this->magazineManager;\n        $moderator = new ModeratorDto($magazine);\n        $moderator->user = $user;\n        $moderator->addedBy = $admin;\n        $magazineManager->addModerator($moderator);\n\n        $entryManager = $this->entryManager;\n        $entryManager->trash($user, $entry);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:entry:trash');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/entry/{$entry->getId()}/restore\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData);\n        self::assertSame($entry->getId(), $jsonData['entryId']);\n        self::assertEquals($entry->title, $jsonData['title']);\n        self::assertIsArray($jsonData['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']);\n        self::assertSame($magazine->getId(), $jsonData['magazine']['magazineId']);\n        self::assertIsArray($jsonData['user']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']);\n        self::assertSame($user->getId(), $jsonData['user']['userId']);\n        self::assertNull($jsonData['domain']);\n        self::assertNull($jsonData['url']);\n        self::assertEquals($entry->body, $jsonData['body']);\n        self::assertNull($jsonData['image']);\n        self::assertEquals($entry->lang, $jsonData['lang']);\n        self::assertEmpty($jsonData['tags']);\n        self::assertIsArray($jsonData['badges']);\n        self::assertEmpty($jsonData['badges']);\n        self::assertSame(0, $jsonData['numComments']);\n        self::assertSame(0, $jsonData['uv']);\n        self::assertSame(0, $jsonData['dv']);\n        self::assertSame(0, $jsonData['favourites']);\n        // No scope for seeing votes granted\n        self::assertNull($jsonData['isFavourited']);\n        self::assertNull($jsonData['userVote']);\n        self::assertFalse($jsonData['isOc']);\n        self::assertFalse($jsonData['isAdult']);\n        self::assertFalse($jsonData['isPinned']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid');\n        self::assertNull($jsonData['editedAt']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid');\n        self::assertEquals('visible', $jsonData['visibility']);\n        self::assertEquals('article', $jsonData['type']);\n        self::assertEquals('test-article', $jsonData['slug']);\n        self::assertNull($jsonData['apId']);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/Entry/UserEntryRetrieveApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\Entry;\n\nuse App\\Tests\\WebTestCase;\n\nclass UserEntryRetrieveApiTest extends WebTestCase\n{\n    public function testApiCanGetUserEntriesAnonymous(): void\n    {\n        $entry = $this->getEntryByTitle('an entry', body: 'test');\n        $this->createEntryComment('up the ranking', $entry);\n        $magazine = $this->getMagazineByNameNoRSAKey('somemag');\n        $otherUser = $this->getUserByUsername('somebody');\n        $this->getEntryByTitle('another entry', url: 'https://google.com', magazine: $magazine, user: $otherUser);\n\n        $this->client->request('GET', \"/api/users/{$otherUser->getId()}/entries\");\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(1, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(1, $jsonData['pagination']['count']);\n\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertEquals('another entry', $jsonData['items'][0]['title']);\n        self::assertIsArray($jsonData['items'][0]['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['magazine']);\n        self::assertSame($magazine->getId(), $jsonData['items'][0]['magazine']['magazineId']);\n        self::assertIsArray($jsonData['items'][0]['user']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['user']);\n        self::assertSame($otherUser->getId(), $jsonData['items'][0]['user']['userId']);\n        self::assertEquals('link', $jsonData['items'][0]['type']);\n        self::assertSame(0, $jsonData['items'][0]['numComments']);\n        self::assertNull($jsonData['items'][0]['crosspostedEntries']);\n    }\n\n    public function testApiCanGetUserEntries(): void\n    {\n        $entry = $this->getEntryByTitle('an entry', body: 'test');\n        $this->createEntryComment('up the ranking', $entry);\n        $magazine = $this->getMagazineByNameNoRSAKey('somemag');\n        $otherUser = $this->getUserByUsername('somebody');\n        $this->getEntryByTitle('another entry', url: 'https://google.com', magazine: $magazine, user: $otherUser);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/users/{$otherUser->getId()}/entries\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(1, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(1, $jsonData['pagination']['count']);\n\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertEquals('another entry', $jsonData['items'][0]['title']);\n        self::assertIsArray($jsonData['items'][0]['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['magazine']);\n        self::assertSame($magazine->getId(), $jsonData['items'][0]['magazine']['magazineId']);\n        self::assertIsArray($jsonData['items'][0]['user']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['user']);\n        self::assertSame($otherUser->getId(), $jsonData['items'][0]['user']['userId']);\n        self::assertEquals('link', $jsonData['items'][0]['type']);\n        self::assertSame(0, $jsonData['items'][0]['numComments']);\n        self::assertNull($jsonData['items'][0]['crosspostedEntries']);\n    }\n\n    public function testApiCanGetUserEntriesNewest(): void\n    {\n        $first = $this->getEntryByTitle('first', body: 'test');\n        $second = $this->getEntryByTitle('second', url: 'https://google.com');\n        $third = $this->getEntryByTitle('third', url: 'https://google.com');\n        $otherUser = $first->user;\n\n        $first->createdAt = new \\DateTimeImmutable('-1 hour');\n        $second->createdAt = new \\DateTimeImmutable('-1 second');\n        $third->createdAt = new \\DateTimeImmutable();\n\n        $entityManager = $this->entityManager;\n        $entityManager->persist($first);\n        $entityManager->persist($second);\n        $entityManager->persist($third);\n        $entityManager->flush();\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/users/{$otherUser->getId()}/entries?sort=newest\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(3, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(3, $jsonData['pagination']['count']);\n\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertSame($third->getId(), $jsonData['items'][0]['entryId']);\n\n        self::assertIsArray($jsonData['items'][1]);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][1]);\n        self::assertSame($second->getId(), $jsonData['items'][1]['entryId']);\n\n        self::assertIsArray($jsonData['items'][2]);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][2]);\n        self::assertSame($first->getId(), $jsonData['items'][2]['entryId']);\n    }\n\n    public function testApiCanGetUserEntriesOldest(): void\n    {\n        $first = $this->getEntryByTitle('first', body: 'test');\n        $second = $this->getEntryByTitle('second', url: 'https://google.com');\n        $third = $this->getEntryByTitle('third', url: 'https://google.com');\n        $otherUser = $first->user;\n\n        $first->createdAt = new \\DateTimeImmutable('-1 hour');\n        $second->createdAt = new \\DateTimeImmutable('-1 second');\n        $third->createdAt = new \\DateTimeImmutable();\n\n        $entityManager = $this->entityManager;\n        $entityManager->persist($first);\n        $entityManager->persist($second);\n        $entityManager->persist($third);\n        $entityManager->flush();\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/users/{$otherUser->getId()}/entries?sort=oldest\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(3, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(3, $jsonData['pagination']['count']);\n\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertSame($first->getId(), $jsonData['items'][0]['entryId']);\n\n        self::assertIsArray($jsonData['items'][1]);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][1]);\n        self::assertSame($second->getId(), $jsonData['items'][1]['entryId']);\n\n        self::assertIsArray($jsonData['items'][2]);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][2]);\n        self::assertSame($third->getId(), $jsonData['items'][2]['entryId']);\n    }\n\n    public function testApiCanGetUserEntriesCommented(): void\n    {\n        $first = $this->getEntryByTitle('first', body: 'test');\n        $this->createEntryComment('comment 1', $first);\n        $this->createEntryComment('comment 2', $first);\n        $second = $this->getEntryByTitle('second', url: 'https://google.com');\n        $this->createEntryComment('comment 1', $second);\n        $third = $this->getEntryByTitle('third', url: 'https://google.com');\n        $otherUser = $first->user;\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/users/{$otherUser->getId()}/entries?sort=commented\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(3, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(3, $jsonData['pagination']['count']);\n\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertSame($first->getId(), $jsonData['items'][0]['entryId']);\n        self::assertSame(2, $jsonData['items'][0]['numComments']);\n\n        self::assertIsArray($jsonData['items'][1]);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][1]);\n        self::assertSame($second->getId(), $jsonData['items'][1]['entryId']);\n        self::assertSame(1, $jsonData['items'][1]['numComments']);\n\n        self::assertIsArray($jsonData['items'][2]);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][2]);\n        self::assertSame($third->getId(), $jsonData['items'][2]['entryId']);\n        self::assertSame(0, $jsonData['items'][2]['numComments']);\n    }\n\n    public function testApiCanGetUserEntriesActive(): void\n    {\n        $first = $this->getEntryByTitle('first', body: 'test');\n        $second = $this->getEntryByTitle('second', url: 'https://google.com');\n        $third = $this->getEntryByTitle('third', url: 'https://google.com');\n        $otherUser = $first->user;\n\n        $first->lastActive = new \\DateTime('-1 hour');\n        $second->lastActive = new \\DateTime('-1 second');\n        $third->lastActive = new \\DateTime();\n\n        $entityManager = $this->entityManager;\n        $entityManager->persist($first);\n        $entityManager->persist($second);\n        $entityManager->persist($third);\n        $entityManager->flush();\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/users/{$otherUser->getId()}/entries?sort=active\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(3, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(3, $jsonData['pagination']['count']);\n\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertSame($third->getId(), $jsonData['items'][0]['entryId']);\n\n        self::assertIsArray($jsonData['items'][1]);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][1]);\n        self::assertSame($second->getId(), $jsonData['items'][1]['entryId']);\n\n        self::assertIsArray($jsonData['items'][2]);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][2]);\n        self::assertSame($first->getId(), $jsonData['items'][2]['entryId']);\n    }\n\n    public function testApiCanGetUserEntriesTop(): void\n    {\n        $first = $this->getEntryByTitle('first', body: 'test');\n        $second = $this->getEntryByTitle('second', url: 'https://google.com');\n        $third = $this->getEntryByTitle('third', url: 'https://google.com');\n        $otherUser = $first->user;\n\n        $voteManager = $this->voteManager;\n        $voteManager->vote(1, $first, $this->getUserByUsername('voter1'), rateLimit: false);\n        $voteManager->vote(1, $first, $this->getUserByUsername('voter2'), rateLimit: false);\n        $voteManager->vote(1, $second, $this->getUserByUsername('voter1'), rateLimit: false);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/users/{$otherUser->getId()}/entries?sort=top\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(3, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(3, $jsonData['pagination']['count']);\n\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertSame($first->getId(), $jsonData['items'][0]['entryId']);\n        self::assertSame(2, $jsonData['items'][0]['uv']);\n\n        self::assertIsArray($jsonData['items'][1]);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][1]);\n        self::assertSame($second->getId(), $jsonData['items'][1]['entryId']);\n        self::assertSame(1, $jsonData['items'][1]['uv']);\n\n        self::assertIsArray($jsonData['items'][2]);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][2]);\n        self::assertSame($third->getId(), $jsonData['items'][2]['entryId']);\n        self::assertSame(0, $jsonData['items'][2]['uv']);\n    }\n\n    public function testApiCanGetUserEntriesWithUserVoteStatus(): void\n    {\n        $this->getEntryByTitle('an entry', body: 'test');\n        $otherUser = $this->getUserByUsername('somebody');\n        $magazine = $this->getMagazineByNameNoRSAKey('somemag');\n        $entry = $this->getEntryByTitle('another entry', url: 'https://google.com', magazine: $magazine, user: $otherUser);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read vote');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/users/{$otherUser->getId()}/entries\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(1, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(1, $jsonData['pagination']['count']);\n\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertSame($entry->getId(), $jsonData['items'][0]['entryId']);\n        self::assertEquals('another entry', $jsonData['items'][0]['title']);\n        self::assertIsArray($jsonData['items'][0]['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['magazine']);\n        self::assertSame($magazine->getId(), $jsonData['items'][0]['magazine']['magazineId']);\n        self::assertIsArray($jsonData['items'][0]['user']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['user']);\n        self::assertSame($otherUser->getId(), $jsonData['items'][0]['user']['userId']);\n        self::assertIsArray($jsonData['items'][0]['domain']);\n        self::assertArrayKeysMatch(self::DOMAIN_RESPONSE_KEYS, $jsonData['items'][0]['domain']);\n        self::assertEquals('https://google.com', $jsonData['items'][0]['url']);\n        self::assertNull($jsonData['items'][0]['body']);\n        if (null !== $jsonData['items'][0]['image']) {\n            self::assertStringContainsString('google.com', parse_url($jsonData['items'][0]['image']['sourceUrl'], PHP_URL_HOST));\n        }\n        self::assertEquals('en', $jsonData['items'][0]['lang']);\n        self::assertEmpty($jsonData['items'][0]['tags']);\n        self::assertSame(0, $jsonData['items'][0]['numComments']);\n        self::assertSame(0, $jsonData['items'][0]['uv']);\n        self::assertSame(0, $jsonData['items'][0]['dv']);\n        self::assertSame(0, $jsonData['items'][0]['favourites']);\n        self::assertFalse($jsonData['items'][0]['isFavourited']);\n        self::assertSame(0, $jsonData['items'][0]['userVote']);\n        self::assertFalse($jsonData['items'][0]['isOc']);\n        self::assertFalse($jsonData['items'][0]['isAdult']);\n        self::assertFalse($jsonData['items'][0]['isPinned']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['items'][0]['createdAt'], 'createdAt date format invalid');\n        self::assertNull($jsonData['items'][0]['editedAt']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['items'][0]['lastActive'], 'lastActive date format invalid');\n        self::assertEquals('link', $jsonData['items'][0]['type']);\n        self::assertEquals('another-entry', $jsonData['items'][0]['slug']);\n        self::assertNull($jsonData['items'][0]['apId']);\n        self::assertNull($jsonData['items'][0]['crosspostedEntries']);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/Instance/Admin/InstanceFederationUpdateApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\Instance\\Admin;\n\nuse App\\Tests\\WebTestCase;\n\nclass InstanceFederationUpdateApiTest extends WebTestCase\n{\n    public function testApiCannotUpdateInstanceFederationAnonymous(): void\n    {\n        $this->client->request('PUT', '/api/defederated');\n\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotUpdateInstanceFederationWithoutAdmin(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $user = $this->getUserByUsername('JohnDoe');\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('PUT', '/api/defederated', server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCannotUpdateInstanceFederationWithoutScope(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $user = $this->getUserByUsername('JohnDoe', isAdmin: true);\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('PUT', '/api/defederated', server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanUpdateInstanceFederation(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $user = $this->getUserByUsername('JohnDoe', isAdmin: true);\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read admin:federation:update');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', '/api/defederated', ['instances' => ['bad-instance.com']], server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertArrayKeysMatch(['instances'], $jsonData);\n        self::assertSame(['bad-instance.com'], $jsonData['instances']);\n    }\n\n    public function testApiCanClearInstanceFederation(): void\n    {\n        $this->instanceManager->setBannedInstances(['defederated.social', 'evil.social']);\n\n        self::createOAuth2AuthCodeClient();\n        $user = $this->getUserByUsername('JohnDoe', isAdmin: true);\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read admin:federation:update');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', '/api/defederated', ['instances' => []], server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertArrayKeysMatch(['instances'], $jsonData);\n        self::assertEmpty($jsonData['instances']);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/Instance/Admin/InstancePagesUpdateApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\Instance\\Admin;\n\nuse App\\Tests\\Functional\\Controller\\Api\\Instance\\InstanceDetailsApiTest;\nuse App\\Tests\\WebTestCase;\n\nclass InstancePagesUpdateApiTest extends WebTestCase\n{\n    public function testApiCannotUpdateInstanceAboutPageAnonymous(): void\n    {\n        $this->client->request('PUT', '/api/instance/about');\n\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotUpdateInstanceAboutPageWithoutAdmin(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $user = $this->getUserByUsername('JohnDoe');\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('PUT', '/api/instance/about', server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCannotUpdateInstanceAboutPageWithoutScope(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $user = $this->getUserByUsername('JohnDoe', isAdmin: true);\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('PUT', '/api/instance/about', server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanUpdateInstanceAboutPage(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $user = $this->getUserByUsername('JohnDoe', isAdmin: true);\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read admin:instance:information:edit');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', '/api/instance/about', ['body' => 'about page'], server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertArrayKeysMatch(InstanceDetailsApiTest::INSTANCE_PAGE_RESPONSE_KEYS, $jsonData);\n        self::assertEquals('about page', $jsonData['about']);\n    }\n\n    public function testApiCannotUpdateInstanceContactPageAnonymous(): void\n    {\n        $this->client->request('PUT', '/api/instance/contact');\n\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotUpdateInstanceContactPageWithoutAdmin(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $user = $this->getUserByUsername('JohnDoe');\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('PUT', '/api/instance/contact', server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCannotUpdateInstanceContactPageWithoutScope(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $user = $this->getUserByUsername('JohnDoe', isAdmin: true);\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('PUT', '/api/instance/contact', server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanUpdateInstanceContactPage(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $user = $this->getUserByUsername('JohnDoe', isAdmin: true);\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read admin:instance:information:edit');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', '/api/instance/contact', ['body' => 'contact page'], server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertArrayKeysMatch(InstanceDetailsApiTest::INSTANCE_PAGE_RESPONSE_KEYS, $jsonData);\n        self::assertEquals('contact page', $jsonData['contact']);\n    }\n\n    public function testApiCannotUpdateInstanceFAQPageAnonymous(): void\n    {\n        $this->client->request('PUT', '/api/instance/faq');\n\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotUpdateInstanceFAQPageWithoutAdmin(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $user = $this->getUserByUsername('JohnDoe');\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('PUT', '/api/instance/faq', server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCannotUpdateInstanceFAQPageWithoutScope(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $user = $this->getUserByUsername('JohnDoe', isAdmin: true);\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('PUT', '/api/instance/faq', server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanUpdateInstanceFAQPage(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $user = $this->getUserByUsername('JohnDoe', isAdmin: true);\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read admin:instance:information:edit');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', '/api/instance/faq', ['body' => 'faq page'], server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertArrayKeysMatch(InstanceDetailsApiTest::INSTANCE_PAGE_RESPONSE_KEYS, $jsonData);\n        self::assertEquals('faq page', $jsonData['faq']);\n    }\n\n    public function testApiCannotUpdateInstancePrivacyPolicyPageAnonymous(): void\n    {\n        $this->client->request('PUT', '/api/instance/privacyPolicy');\n\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotUpdateInstancePrivacyPolicyPageWithoutAdmin(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $user = $this->getUserByUsername('JohnDoe');\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('PUT', '/api/instance/privacyPolicy', server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCannotUpdateInstancePrivacyPolicyPageWithoutScope(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $user = $this->getUserByUsername('JohnDoe', isAdmin: true);\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('PUT', '/api/instance/privacyPolicy', server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanUpdateInstancePrivacyPolicyPage(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $user = $this->getUserByUsername('JohnDoe', isAdmin: true);\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read admin:instance:information:edit');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', '/api/instance/privacyPolicy', ['body' => 'privacyPolicy page'], server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertArrayKeysMatch(InstanceDetailsApiTest::INSTANCE_PAGE_RESPONSE_KEYS, $jsonData);\n        self::assertEquals('privacyPolicy page', $jsonData['privacyPolicy']);\n    }\n\n    public function testApiCannotUpdateInstanceTermsPageAnonymous(): void\n    {\n        $this->client->request('PUT', '/api/instance/terms');\n\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotUpdateInstanceTermsPageWithoutAdmin(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $user = $this->getUserByUsername('JohnDoe');\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('PUT', '/api/instance/terms', server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCannotUpdateInstanceTermsPageWithoutScope(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $user = $this->getUserByUsername('JohnDoe', isAdmin: true);\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('PUT', '/api/instance/terms', server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanUpdateInstanceTermsPage(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $user = $this->getUserByUsername('JohnDoe', isAdmin: true);\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read admin:instance:information:edit');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', '/api/instance/terms', ['body' => 'terms page'], server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertArrayKeysMatch(InstanceDetailsApiTest::INSTANCE_PAGE_RESPONSE_KEYS, $jsonData);\n        self::assertEquals('terms page', $jsonData['terms']);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/Instance/Admin/InstanceSettingsRetrieveApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\Instance\\Admin;\n\nuse App\\Tests\\WebTestCase;\n\nclass InstanceSettingsRetrieveApiTest extends WebTestCase\n{\n    public const INSTANCE_SETTINGS_RESPONSE_KEYS = [\n        'KBIN_DOMAIN',\n        'KBIN_TITLE',\n        'KBIN_META_TITLE',\n        'KBIN_META_KEYWORDS',\n        'KBIN_META_DESCRIPTION',\n        'KBIN_DEFAULT_LANG',\n        'KBIN_CONTACT_EMAIL',\n        'KBIN_SENDER_EMAIL',\n        'MBIN_DEFAULT_THEME',\n        'KBIN_JS_ENABLED',\n        'KBIN_FEDERATION_ENABLED',\n        'KBIN_REGISTRATIONS_ENABLED',\n        'KBIN_HEADER_LOGO',\n        'KBIN_CAPTCHA_ENABLED',\n        'KBIN_MERCURE_ENABLED',\n        'KBIN_FEDERATION_PAGE_ENABLED',\n        'KBIN_ADMIN_ONLY_OAUTH_CLIENTS',\n        'MBIN_PRIVATE_INSTANCE',\n        'KBIN_FEDERATED_SEARCH_ONLY_LOGGEDIN',\n        'MBIN_SIDEBAR_SECTIONS_RANDOM_LOCAL_ONLY',\n        'MBIN_SIDEBAR_SECTIONS_USERS_LOCAL_ONLY',\n        'MBIN_SSO_REGISTRATIONS_ENABLED',\n        'MBIN_RESTRICT_MAGAZINE_CREATION',\n        'MBIN_DOWNVOTES_MODE',\n        'MBIN_SSO_ONLY_MODE',\n        'MBIN_SSO_SHOW_FIRST',\n        'MBIN_NEW_USERS_NEED_APPROVAL',\n        'MBIN_USE_FEDERATION_ALLOW_LIST',\n    ];\n\n    public function testApiCannotRetrieveInstanceSettingsAnonymous(): void\n    {\n        $this->client->request('GET', '/api/instance/settings');\n\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotRetrieveInstanceSettingsWithoutAdmin(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $user = $this->getUserByUsername('JohnDoe');\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', '/api/instance/settings', server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCannotRetrieveInstanceSettingsWithoutScope(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $user = $this->getUserByUsername('JohnDoe', isAdmin: true);\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', '/api/instance/settings', server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanRetrieveInstanceSettings(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $user = $this->getUserByUsername('JohnDoe', isAdmin: true);\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read admin:instance:settings:read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', '/api/instance/settings', server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertArrayKeysMatch(self::INSTANCE_SETTINGS_RESPONSE_KEYS, $jsonData);\n        foreach ($jsonData as $key => $value) {\n            self::assertNotNull($value, \"$key was null!\");\n        }\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/Instance/Admin/InstanceSettingsUpdateApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\Instance\\Admin;\n\nuse App\\Service\\SettingsManager;\nuse App\\Tests\\WebTestCase;\nuse App\\Utils\\DownvotesMode;\n\nclass InstanceSettingsUpdateApiTest extends WebTestCase\n{\n    public const INSTANCE_SETTINGS_RESPONSE_KEYS = [\n        'KBIN_DOMAIN',\n        'KBIN_TITLE',\n        'KBIN_META_TITLE',\n        'KBIN_META_KEYWORDS',\n        'KBIN_META_DESCRIPTION',\n        'KBIN_DEFAULT_LANG',\n        'KBIN_CONTACT_EMAIL',\n        'KBIN_SENDER_EMAIL',\n        'MBIN_DEFAULT_THEME',\n        'KBIN_JS_ENABLED',\n        'KBIN_FEDERATION_ENABLED',\n        'KBIN_REGISTRATIONS_ENABLED',\n        'KBIN_HEADER_LOGO',\n        'KBIN_CAPTCHA_ENABLED',\n        'KBIN_MERCURE_ENABLED',\n        'KBIN_FEDERATION_PAGE_ENABLED',\n        'KBIN_ADMIN_ONLY_OAUTH_CLIENTS',\n        'MBIN_PRIVATE_INSTANCE',\n        'KBIN_FEDERATED_SEARCH_ONLY_LOGGEDIN',\n        'MBIN_SIDEBAR_SECTIONS_RANDOM_LOCAL_ONLY',\n        'MBIN_SIDEBAR_SECTIONS_USERS_LOCAL_ONLY',\n        'MBIN_SSO_REGISTRATIONS_ENABLED',\n        'MBIN_RESTRICT_MAGAZINE_CREATION',\n        'MBIN_DOWNVOTES_MODE',\n        'MBIN_SSO_ONLY_MODE',\n        'MBIN_SSO_SHOW_FIRST',\n        'MBIN_NEW_USERS_NEED_APPROVAL',\n        'MBIN_USE_FEDERATION_ALLOW_LIST',\n    ];\n\n    public function testApiCannotUpdateInstanceSettingsAnonymous(): void\n    {\n        $this->client->request('PUT', '/api/instance/settings');\n\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotUpdateInstanceSettingsWithoutAdmin(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $user = $this->getUserByUsername('JohnDoe');\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('PUT', '/api/instance/settings', server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCannotUpdateInstanceSettingsWithoutScope(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $user = $this->getUserByUsername('JohnDoe', isAdmin: true);\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('PUT', '/api/instance/settings', server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanUpdateInstanceSettings(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $user = $this->getUserByUsername('JohnDoe', isAdmin: true);\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read admin:instance:settings:edit');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $settings = [\n            'KBIN_DOMAIN' => 'kbinupdated.test',\n            'KBIN_TITLE' => 'updated title',\n            'KBIN_META_TITLE' => 'meta title',\n            'KBIN_META_KEYWORDS' => 'this, is, a, test',\n            'KBIN_META_DESCRIPTION' => 'Testing out the API',\n            'KBIN_DEFAULT_LANG' => 'de',\n            'KBIN_CONTACT_EMAIL' => 'test@kbinupdated.test',\n            'KBIN_SENDER_EMAIL' => 'noreply@kbinupdated.test',\n            'MBIN_DEFAULT_THEME' => 'dark',\n            'KBIN_JS_ENABLED' => true,\n            'KBIN_FEDERATION_ENABLED' => true,\n            'KBIN_REGISTRATIONS_ENABLED' => false,\n            'KBIN_HEADER_LOGO' => true,\n            'KBIN_CAPTCHA_ENABLED' => true,\n            'KBIN_MERCURE_ENABLED' => false,\n            'KBIN_FEDERATION_PAGE_ENABLED' => false,\n            'KBIN_ADMIN_ONLY_OAUTH_CLIENTS' => true,\n            'MBIN_PRIVATE_INSTANCE' => true,\n            'KBIN_FEDERATED_SEARCH_ONLY_LOGGEDIN' => false,\n            'MBIN_SIDEBAR_SECTIONS_RANDOM_LOCAL_ONLY' => false,\n            'MBIN_SIDEBAR_SECTIONS_USERS_LOCAL_ONLY' => false,\n            'MBIN_SSO_REGISTRATIONS_ENABLED' => true,\n            'MBIN_RESTRICT_MAGAZINE_CREATION' => false,\n            'MBIN_DOWNVOTES_MODE' => DownvotesMode::Enabled->value,\n            'MBIN_SSO_ONLY_MODE' => false,\n            'MBIN_SSO_SHOW_FIRST' => false,\n            'MBIN_NEW_USERS_NEED_APPROVAL' => false,\n            'MBIN_USE_FEDERATION_ALLOW_LIST' => false,\n        ];\n\n        $this->client->jsonRequest('PUT', '/api/instance/settings', $settings, server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertArrayKeysMatch(self::INSTANCE_SETTINGS_RESPONSE_KEYS, $jsonData);\n        foreach ($jsonData as $key => $value) {\n            self::assertEquals($settings[$key], $value, \"$key did not match!\");\n        }\n\n        $settings = [\n            'KBIN_DOMAIN' => 'kbin.test',\n            'KBIN_TITLE' => 'updated title',\n            'KBIN_META_TITLE' => 'meta title',\n            'KBIN_META_KEYWORDS' => 'this, is, a, test',\n            'KBIN_META_DESCRIPTION' => 'Testing out the API',\n            'KBIN_DEFAULT_LANG' => 'en',\n            'KBIN_CONTACT_EMAIL' => 'test@kbinupdated.test',\n            'KBIN_SENDER_EMAIL' => 'noreply@kbinupdated.test',\n            'MBIN_DEFAULT_THEME' => 'light',\n            'KBIN_JS_ENABLED' => false,\n            'KBIN_FEDERATION_ENABLED' => false,\n            'KBIN_REGISTRATIONS_ENABLED' => true,\n            'KBIN_HEADER_LOGO' => false,\n            'KBIN_CAPTCHA_ENABLED' => false,\n            'KBIN_MERCURE_ENABLED' => true,\n            'KBIN_FEDERATION_PAGE_ENABLED' => true,\n            'KBIN_ADMIN_ONLY_OAUTH_CLIENTS' => false,\n            'MBIN_PRIVATE_INSTANCE' => false,\n            'KBIN_FEDERATED_SEARCH_ONLY_LOGGEDIN' => true,\n            'MBIN_SIDEBAR_SECTIONS_RANDOM_LOCAL_ONLY' => true,\n            'MBIN_SIDEBAR_SECTIONS_USERS_LOCAL_ONLY' => true,\n            'MBIN_SSO_REGISTRATIONS_ENABLED' => false,\n            'MBIN_RESTRICT_MAGAZINE_CREATION' => true,\n            'MBIN_DOWNVOTES_MODE' => DownvotesMode::Hidden->value,\n            'MBIN_SSO_ONLY_MODE' => true,\n            'MBIN_SSO_SHOW_FIRST' => true,\n            'MBIN_NEW_USERS_NEED_APPROVAL' => false,\n            'MBIN_USE_FEDERATION_ALLOW_LIST' => false,\n        ];\n\n        $this->client->jsonRequest('PUT', '/api/instance/settings', $settings, server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertArrayKeysMatch(self::INSTANCE_SETTINGS_RESPONSE_KEYS, $jsonData);\n        foreach ($jsonData as $key => $value) {\n            self::assertEquals($settings[$key], $value, \"$key did not match!\");\n        }\n    }\n\n    protected function tearDown(): void\n    {\n        parent::tearDown();\n        SettingsManager::resetDto();\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/Instance/InstanceDetailsApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\Instance;\n\nuse App\\Tests\\WebTestCase;\n\nclass InstanceDetailsApiTest extends WebTestCase\n{\n    public const INSTANCE_PAGE_RESPONSE_KEYS = ['about', 'contact', 'faq', 'privacyPolicy', 'terms'];\n\n    public function testApiCanRetrieveInstanceDetailsAnonymous(): void\n    {\n        $site = $this->createInstancePages();\n\n        $this->client->request('GET', '/api/instance');\n\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertArrayKeysMatch(self::INSTANCE_PAGE_RESPONSE_KEYS, $jsonData);\n        self::assertEquals($site->about, $jsonData['about']);\n        self::assertEquals($site->contact, $jsonData['contact']);\n        self::assertEquals($site->faq, $jsonData['faq']);\n        self::assertEquals($site->privacyPolicy, $jsonData['privacyPolicy']);\n        self::assertEquals($site->terms, $jsonData['terms']);\n    }\n\n    public function testApiCanRetrieveInstanceDetails(): void\n    {\n        $site = $this->createInstancePages();\n\n        self::createOAuth2AuthCodeClient();\n        $user = $this->getUserByUsername('JohnDoe');\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', '/api/instance', server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertArrayKeysMatch(self::INSTANCE_PAGE_RESPONSE_KEYS, $jsonData);\n        self::assertEquals($site->about, $jsonData['about']);\n        self::assertEquals($site->contact, $jsonData['contact']);\n        self::assertEquals($site->faq, $jsonData['faq']);\n        self::assertEquals($site->privacyPolicy, $jsonData['privacyPolicy']);\n        self::assertEquals($site->terms, $jsonData['terms']);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/Instance/InstanceFederationApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\Instance;\n\nuse App\\Tests\\WebTestCase;\nuse PHPUnit\\Framework\\Attributes\\Group;\n\nclass InstanceFederationApiTest extends WebTestCase\n{\n    public const INSTANCE_DEFEDERATED_RESPONSE_KEYS = ['instances'];\n\n    public function testApiCanRetrieveEmptyInstanceDefederation(): void\n    {\n        $this->instanceManager->setBannedInstances([]);\n\n        self::createOAuth2AuthCodeClient();\n        $user = $this->getUserByUsername('JohnDoe');\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', '/api/defederated', server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertArrayKeysMatch(self::INSTANCE_DEFEDERATED_RESPONSE_KEYS, $jsonData);\n        self::assertSame([], $jsonData['instances']);\n    }\n\n    #[Group(name: 'NonThreadSafe')]\n    public function testApiCanRetrieveInstanceDefederationAnonymous(): void\n    {\n        $this->instanceManager->setBannedInstances(['defederated.social']);\n\n        $this->client->request('GET', '/api/defederated');\n\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertArrayKeysMatch(self::INSTANCE_DEFEDERATED_RESPONSE_KEYS, $jsonData);\n        self::assertSame(['defederated.social'], $jsonData['instances']);\n    }\n\n    #[Group(name: 'NonThreadSafe')]\n    public function testApiCanRetrieveInstanceDefederation(): void\n    {\n        $this->instanceManager->setBannedInstances(['defederated.social', 'evil.social']);\n\n        self::createOAuth2AuthCodeClient();\n        $user = $this->getUserByUsername('JohnDoe');\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', '/api/defederated', server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertArrayKeysMatch(self::INSTANCE_DEFEDERATED_RESPONSE_KEYS, $jsonData);\n        self::assertSame(['defederated.social', 'evil.social'], $jsonData['instances']);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/Instance/InstanceModlogApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\Instance;\n\nuse App\\Entity\\MagazineLog;\nuse App\\Tests\\WebTestCase;\n\nclass InstanceModlogApiTest extends WebTestCase\n{\n    public function testApiCanRetrieveModlogAnonymous(): void\n    {\n        $this->createModlogMessages();\n\n        $this->client->request('GET', '/api/modlog');\n\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(5, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(5, $jsonData['pagination']['count']);\n\n        $magazine = $this->getMagazineByName('acme');\n        $moderator = $magazine->getOwner();\n\n        $this->validateModlog($jsonData, $magazine, $moderator);\n    }\n\n    public function testApiCanRetrieveModlogAnonymousWithTypeFilter(): void\n    {\n        $this->createModlogMessages();\n\n        $this->client->request('GET', '/api/modlog?types[]='.MagazineLog::CHOICES[0].'&types[]='.MagazineLog::CHOICES[1]);\n\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n    }\n\n    public function testApiCanRetrieveModlog(): void\n    {\n        $this->createModlogMessages();\n\n        self::createOAuth2AuthCodeClient();\n        $user = $this->getUserByUsername('JohnDoe');\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', '/api/modlog', server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(5, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(5, $jsonData['pagination']['count']);\n\n        $magazine = $this->getMagazineByName('acme');\n        $moderator = $magazine->getOwner();\n\n        $this->validateModlog($jsonData, $magazine, $moderator);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/Instance/InstanceRetrieveInfoApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\Instance;\n\nuse App\\Tests\\WebTestCase;\n\nclass InstanceRetrieveInfoApiTest extends WebTestCase\n{\n    public const INFO_KEYS = [\n        'softwareName',\n        'softwareVersion',\n        'softwareRepository',\n        'websiteDomain',\n        'websiteContactEmail',\n        'websiteTitle',\n        'websiteOpenRegistrations',\n        'websiteFederationEnabled',\n        'websiteDefaultLang',\n        'instanceModerators',\n        'instanceAdmins',\n    ];\n\n    public const AP_USER_DEFAULT_KEYS = [\n        'id',\n        'type',\n        'name',\n        'preferredUsername',\n        'inbox',\n        'outbox',\n        'url',\n        'manuallyApprovesFollowers',\n        'published',\n        'following',\n        'followers',\n        'publicKey',\n        'endpoints',\n        'icon',\n        'discoverable',\n        'indexable',\n    ];\n\n    public function testCanRetrieveInfoAnonymous(): void\n    {\n        $this->getUserByUsername('admin', isAdmin: true);\n        $this->getUserByUsername('moderator', isModerator: true);\n        $this->client->request('GET', '/api/info');\n\n        self::assertResponseIsSuccessful();\n\n        $jsonData = self::getJsonResponse($this->client);\n        self::assertArrayKeysMatch(self::INFO_KEYS, $jsonData);\n        self::assertIsString($jsonData['softwareName']);\n        self::assertIsString($jsonData['softwareVersion']);\n        self::assertIsString($jsonData['softwareRepository']);\n        self::assertIsString($jsonData['websiteDomain']);\n        self::assertIsString($jsonData['websiteContactEmail']);\n        self::assertIsString($jsonData['websiteTitle']);\n        self::assertIsBool($jsonData['websiteOpenRegistrations']);\n        self::assertIsBool($jsonData['websiteFederationEnabled']);\n        self::assertIsString($jsonData['websiteDefaultLang']);\n        self::assertIsArray($jsonData['instanceAdmins']);\n        self::assertIsArray($jsonData['instanceModerators']);\n        self::assertNotEmpty($jsonData['instanceAdmins']);\n        self::assertNotEmpty($jsonData['instanceModerators']);\n        self::assertArrayKeysMatch(self::AP_USER_DEFAULT_KEYS, $jsonData['instanceAdmins'][0]);\n        self::assertArrayKeysMatch(self::AP_USER_DEFAULT_KEYS, $jsonData['instanceModerators'][0]);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/Magazine/Admin/MagazineBadgesApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\Magazine\\Admin;\n\nuse App\\DTO\\BadgeDto;\nuse App\\DTO\\ModeratorDto;\nuse App\\Tests\\Functional\\Controller\\Api\\Magazine\\MagazineRetrieveApiTest;\nuse App\\Tests\\WebTestCase;\n\nclass MagazineBadgesApiTest extends WebTestCase\n{\n    public const BADGE_RESPONSE_KEYS = ['magazineId', 'name', 'badgeId'];\n\n    public function testApiCannotAddBadgesToMagazineAnonymous(): void\n    {\n        $magazine = $this->getMagazineByName('test');\n        $this->client->jsonRequest('POST', \"/api/moderate/magazine/{$magazine->getId()}/badge\", parameters: ['name' => 'test']);\n\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotRemoveBadgesFromMagazineAnonymous(): void\n    {\n        $magazine = $this->getMagazineByName('test');\n        $badgeManager = $this->badgeManager;\n        $badge = $badgeManager->create(BadgeDto::create($magazine, 'test'));\n\n        $this->client->request('DELETE', \"/api/moderate/magazine/{$magazine->getId()}/badge/{$badge->getId()}\");\n\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotAddBadgesToMagazineWithoutScope(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n        self::createOAuth2AuthCodeClient();\n\n        $magazine = $this->getMagazineByName('test');\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client);\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('POST', \"/api/moderate/magazine/{$magazine->getId()}/badge\", parameters: ['name' => 'test'], server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCannotRemoveBadgesFromMagazineWithoutScope(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n        self::createOAuth2AuthCodeClient();\n\n        $magazine = $this->getMagazineByName('test');\n        $badgeManager = $this->badgeManager;\n        $badge = $badgeManager->create(BadgeDto::create($magazine, 'test'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client);\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('DELETE', \"/api/moderate/magazine/{$magazine->getId()}/badge/{$badge->getId()}\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiModCannotAddBadgesMagazine(): void\n    {\n        $moderator = $this->getUserByUsername('JohnDoe');\n        $this->client->loginUser($moderator);\n        $owner = $this->getUserByUsername('JaneDoe');\n        $admin = $this->getUserByUsername('admin', isAdmin: true);\n        self::createOAuth2AuthCodeClient();\n\n        $magazine = $this->getMagazineByName('test', $owner);\n        $magazineManager = $this->magazineManager;\n        $dto = new ModeratorDto($magazine);\n        $dto->user = $moderator;\n        $dto->addedBy = $admin;\n        $magazineManager->addModerator($dto);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write moderate:magazine_admin:badges');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('POST', \"/api/moderate/magazine/{$magazine->getId()}/badge\", parameters: ['name' => 'test'], server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiModCannotRemoveBadgesMagazine(): void\n    {\n        $moderator = $this->getUserByUsername('JohnDoe');\n        $this->client->loginUser($moderator);\n        $owner = $this->getUserByUsername('JaneDoe');\n        $admin = $this->getUserByUsername('admin', isAdmin: true);\n        self::createOAuth2AuthCodeClient();\n\n        $magazine = $this->getMagazineByName('test', $owner);\n        $magazineManager = $this->magazineManager;\n        $dto = new ModeratorDto($magazine);\n        $dto->user = $moderator;\n        $dto->addedBy = $admin;\n        $magazineManager->addModerator($dto);\n\n        $badgeManager = $this->badgeManager;\n        $badge = $badgeManager->create(BadgeDto::create($magazine, 'test'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write moderate:magazine_admin:badges');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('DELETE', \"/api/moderate/magazine/{$magazine->getId()}/badge/{$badge->getId()}\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiOwnerCanAddBadgesMagazine(): void\n    {\n        $user = $this->getUserByUsername('JohnDoe');\n        $this->client->loginUser($user);\n        self::createOAuth2AuthCodeClient();\n\n        $magazine = $this->getMagazineByName('test');\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write moderate:magazine_admin:badges');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('POST', \"/api/moderate/magazine/{$magazine->getId()}/badge\", parameters: ['name' => 'test'], server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(MagazineRetrieveApiTest::MAGAZINE_RESPONSE_KEYS, $jsonData);\n        self::assertIsArray($jsonData['badges']);\n        self::assertCount(1, $jsonData['badges']);\n        self::assertArrayKeysMatch(self::BADGE_RESPONSE_KEYS, $jsonData['badges'][0]);\n        self::assertEquals('test', $jsonData['badges'][0]['name']);\n    }\n\n    public function testApiOwnerCanRemoveBadgesMagazine(): void\n    {\n        $user = $this->getUserByUsername('JohnDoe');\n        $this->client->loginUser($user);\n        self::createOAuth2AuthCodeClient();\n\n        $magazine = $this->getMagazineByName('test');\n        $badgeManager = $this->badgeManager;\n        $badge = $badgeManager->create(BadgeDto::create($magazine, 'test'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write moderate:magazine_admin:badges');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('DELETE', \"/api/moderate/magazine/{$magazine->getId()}/badge/{$badge->getId()}\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(MagazineRetrieveApiTest::MAGAZINE_RESPONSE_KEYS, $jsonData);\n        self::assertIsArray($jsonData['badges']);\n        self::assertCount(0, $jsonData['badges']);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/Magazine/Admin/MagazineCreateApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\Magazine\\Admin;\n\nuse App\\Tests\\Functional\\Controller\\Api\\Magazine\\MagazineRetrieveApiTest;\nuse App\\Tests\\WebTestCase;\n\nclass MagazineCreateApiTest extends WebTestCase\n{\n    public function testApiCannotCreateMagazineAnonymous(): void\n    {\n        $this->client->request('POST', '/api/moderate/magazine/new');\n\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotCreateMagazineWithoutScope(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n        self::createOAuth2AuthCodeClient();\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client);\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('POST', '/api/moderate/magazine/new', server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanCreateMagazine(): void\n    {\n        $user = $this->getUserByUsername('JohnDoe');\n        $this->client->loginUser($user);\n        self::createOAuth2AuthCodeClient();\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write moderate:magazine_admin:create');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $name = 'test';\n        $title = 'API Test Magazine';\n        $description = 'A description';\n\n        $this->client->jsonRequest(\n            'POST', '/api/moderate/magazine/new',\n            parameters: [\n                'name' => $name,\n                'title' => $title,\n                'description' => $description,\n                'isAdult' => false,\n                'discoverable' => false,\n                'isPostingRestrictedToMods' => true,\n                'indexable' => false,\n            ],\n            server: ['HTTP_AUTHORIZATION' => $token]\n        );\n\n        self::assertResponseStatusCodeSame(201);\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(MagazineRetrieveApiTest::MAGAZINE_RESPONSE_KEYS, $jsonData);\n        self::assertEquals($name, $jsonData['name']);\n        self::assertSame($user->getId(), $jsonData['owner']['userId']);\n        self::assertEquals($description, $jsonData['description']);\n        self::assertEquals($rules, $jsonData['rules']);\n        self::assertFalse($jsonData['isAdult']);\n        self::assertFalse($jsonData['discoverable']);\n        self::assertTrue($jsonData['isPostingRestrictedToMods']);\n        self::assertFalse($jsonData['indexable']);\n    }\n\n    public function testApiCannotCreateInvalidMagazine(): void\n    {\n        $user = $this->getUserByUsername('JohnDoe');\n        $this->client->loginUser($user);\n        self::createOAuth2AuthCodeClient();\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write moderate:magazine_admin:create');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $title = 'No name';\n        $description = 'A description';\n\n        $this->client->jsonRequest(\n            'POST', '/api/moderate/magazine/new',\n            parameters: [\n                'name' => null,\n                'title' => $title,\n                'description' => $description,\n                'isAdult' => false,\n            ],\n            server: ['HTTP_AUTHORIZATION' => $token]\n        );\n\n        self::assertResponseStatusCodeSame(400);\n\n        $name = 'a';\n        $title = 'Too short name';\n\n        $this->client->jsonRequest(\n            'POST', '/api/moderate/magazine/new',\n            parameters: [\n                'name' => $name,\n                'title' => $title,\n                'description' => $description,\n                'isAdult' => false,\n            ],\n            server: ['HTTP_AUTHORIZATION' => $token]\n        );\n\n        self::assertResponseStatusCodeSame(400);\n\n        $name = 'long_name_that_exceeds_the_limit';\n        $title = 'Too long name';\n        $this->client->jsonRequest(\n            'POST', '/api/moderate/magazine/new',\n            parameters: [\n                'name' => $name,\n                'title' => $title,\n                'description' => $description,\n                'isAdult' => false,\n            ],\n            server: ['HTTP_AUTHORIZATION' => $token]\n        );\n\n        self::assertResponseStatusCodeSame(400);\n\n        $name = 'invalidch@racters!';\n        $title = 'Invalid Characters in name';\n        $this->client->jsonRequest(\n            'POST', '/api/moderate/magazine/new',\n            parameters: [\n                'name' => $name,\n                'title' => $title,\n                'description' => $description,\n                'isAdult' => false,\n            ],\n            server: ['HTTP_AUTHORIZATION' => $token]\n        );\n\n        self::assertResponseStatusCodeSame(400);\n\n        $name = 'nulltitle';\n        $title = null;\n        $this->client->jsonRequest(\n            'POST', '/api/moderate/magazine/new',\n            parameters: [\n                'name' => $name,\n                'title' => $title,\n                'description' => $description,\n                'isAdult' => false,\n            ],\n            server: ['HTTP_AUTHORIZATION' => $token]\n        );\n\n        self::assertResponseStatusCodeSame(400);\n\n        $name = 'shorttitle';\n        $title = 'as';\n        $this->client->jsonRequest(\n            'POST', '/api/moderate/magazine/new',\n            parameters: [\n                'name' => $name,\n                'title' => $title,\n                'description' => $description,\n                'isAdult' => false,\n            ],\n            server: ['HTTP_AUTHORIZATION' => $token]\n        );\n\n        self::assertResponseStatusCodeSame(400);\n\n        $name = 'longtitle';\n        $title = 'Way too long of a title. This can only be 50 characters!';\n        $this->client->jsonRequest(\n            'POST', '/api/moderate/magazine/new',\n            parameters: [\n                'name' => $name,\n                'title' => $title,\n                'description' => $description,\n                'isAdult' => false,\n            ],\n            server: ['HTTP_AUTHORIZATION' => $token]\n        );\n\n        self::assertResponseStatusCodeSame(400);\n\n        $name = 'rulesDeprecated';\n        $title = 'rules are deprecated';\n        $rules = 'Some rules';\n        $this->client->jsonRequest(\n            'POST', '/api/moderate/magazine/new',\n            parameters: [\n                'name' => $name,\n                'title' => $title,\n                'rules' => $rules,\n                'isAdult' => false,\n            ],\n            server: ['HTTP_AUTHORIZATION' => $token]\n        );\n\n        self::assertResponseStatusCodeSame(400);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/Magazine/Admin/MagazineDeleteApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\Magazine\\Admin;\n\nuse App\\DTO\\ModeratorDto;\nuse App\\Tests\\WebTestCase;\n\nclass MagazineDeleteApiTest extends WebTestCase\n{\n    public function testApiCannotDeleteMagazineAnonymous(): void\n    {\n        $magazine = $this->getMagazineByName('test');\n        $this->client->request('DELETE', \"/api/moderate/magazine/{$magazine->getId()}\");\n\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotDeleteMagazineWithoutScope(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n        self::createOAuth2AuthCodeClient();\n\n        $magazine = $this->getMagazineByName('test');\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client);\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('DELETE', \"/api/moderate/magazine/{$magazine->getId()}\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiUserCannotDeleteUnownedMagazine(): void\n    {\n        $moderator = $this->getUserByUsername('JohnDoe');\n        $this->client->loginUser($moderator);\n        $owner = $this->getUserByUsername('JaneDoe');\n        self::createOAuth2AuthCodeClient();\n\n        $magazine = $this->getMagazineByName('test', $owner);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write moderate:magazine_admin:delete');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('DELETE', \"/api/moderate/magazine/{$magazine->getId()}\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiModCannotDeleteUnownedMagazine(): void\n    {\n        $moderator = $this->getUserByUsername('JohnDoe');\n        $this->client->loginUser($moderator);\n        $owner = $this->getUserByUsername('JaneDoe');\n        $admin = $this->getUserByUsername('admin', isAdmin: true);\n        self::createOAuth2AuthCodeClient();\n\n        $magazine = $this->getMagazineByName('test', $owner);\n        $magazineManager = $this->magazineManager;\n        $dto = new ModeratorDto($magazine);\n        $dto->user = $moderator;\n        $dto->addedBy = $admin;\n        $magazineManager->addModerator($dto);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write moderate:magazine_admin:delete');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('DELETE', \"/api/moderate/magazine/{$magazine->getId()}\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanDeleteMagazine(): void\n    {\n        $user = $this->getUserByUsername('JohnDoe');\n        $this->client->loginUser($user);\n        self::createOAuth2AuthCodeClient();\n\n        $magazine = $this->getMagazineByName('test');\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write moderate:magazine_admin:delete');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('DELETE', \"/api/moderate/magazine/{$magazine->getId()}\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(204);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/Magazine/Admin/MagazineDeleteIconApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\Magazine\\Admin;\n\nuse App\\DTO\\ModeratorDto;\nuse App\\Tests\\Functional\\Controller\\Api\\Magazine\\MagazineRetrieveApiTest;\nuse App\\Tests\\WebTestCase;\nuse PHPUnit\\Framework\\Attributes\\Group;\nuse Symfony\\Component\\HttpFoundation\\File\\UploadedFile;\n\nclass MagazineDeleteIconApiTest extends WebTestCase\n{\n    public function setUp(): void\n    {\n        parent::setUp();\n        $this->kibbyPath = \\dirname(__FILE__, 6).'/assets/kibby_emoji.png';\n    }\n\n    public function testApiCannotDeleteMagazineIconAnonymous(): void\n    {\n        $magazine = $this->getMagazineByName('test');\n        $this->client->request('DELETE', \"/api/moderate/magazine/{$magazine->getId()}/icon\");\n\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotDeleteMagazineIconWithoutScope(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n        self::createOAuth2AuthCodeClient();\n\n        $magazine = $this->getMagazineByName('test');\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client);\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('DELETE', \"/api/moderate/magazine/{$magazine->getId()}/icon\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiModCannotDeleteMagazineIcon(): void\n    {\n        $moderator = $this->getUserByUsername('JohnDoe');\n        $this->client->loginUser($moderator);\n        $owner = $this->getUserByUsername('JaneDoe');\n        $admin = $this->getUserByUsername('admin', isAdmin: true);\n        self::createOAuth2AuthCodeClient();\n\n        $magazine = $this->getMagazineByName('test', $owner);\n        $magazineManager = $this->magazineManager;\n        $dto = new ModeratorDto($magazine);\n        $dto->user = $moderator;\n        $dto->addedBy = $admin;\n        $magazineManager->addModerator($dto);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client);\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('DELETE', \"/api/moderate/magazine/{$magazine->getId()}/icon\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    #[Group(name: 'NonThreadSafe')]\n    public function testApiCanDeleteMagazineIcon(): void\n    {\n        $user = $this->getUserByUsername('JohnDoe');\n        $this->client->loginUser($user);\n        self::createOAuth2AuthCodeClient();\n\n        $magazine = $this->getMagazineByName('test');\n\n        $tmpPath = bin2hex(random_bytes(32));\n        copy($this->kibbyPath, $tmpPath.'.png');\n        $upload = new UploadedFile($tmpPath.'.png', 'kibby_emoji.png', 'image/png');\n\n        $imageRepository = $this->imageRepository;\n        $image = $imageRepository->findOrCreateFromUpload($upload);\n        self::assertNotNull($image);\n        $magazine->icon = $image;\n\n        $entityManager = $this->entityManager;\n        $entityManager->persist($magazine);\n        $entityManager->flush();\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write moderate:magazine_admin:theme');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/magazine/{$magazine->getId()}\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n        self::assertArrayKeysMatch(WebTestCase::MAGAZINE_RESPONSE_KEYS, $jsonData);\n        self::assertIsArray($jsonData['icon']);\n        self::assertArrayKeysMatch(self::IMAGE_KEYS, $jsonData['icon']);\n        self::assertSame(96, $jsonData['icon']['width']);\n        self::assertSame(96, $jsonData['icon']['height']);\n        self::assertEquals('a8/1c/a81cc2fea35eeb232cd28fcb109b3eb5a4e52c71bce95af6650d71876c1bcbb7.png', $jsonData['icon']['filePath']);\n\n        self::assertNull($jsonData['banner']);\n\n        $this->client->request('DELETE', \"/api/moderate/magazine/{$magazine->getId()}/icon\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(MagazineUpdateThemeApiTest::MAGAZINE_THEME_RESPONSE_KEYS, $jsonData);\n        self::assertIsArray($jsonData['magazine']);\n        self::assertArrayKeysMatch(MagazineRetrieveApiTest::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']);\n        self::assertNull($jsonData['icon']);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/Magazine/Admin/MagazineModeratorsApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\Magazine\\Admin;\n\nuse App\\DTO\\ModeratorDto;\nuse App\\Tests\\Functional\\Controller\\Api\\Magazine\\MagazineRetrieveApiTest;\nuse App\\Tests\\WebTestCase;\n\nclass MagazineModeratorsApiTest extends WebTestCase\n{\n    public function testApiCannotAddModeratorsToMagazineAnonymous(): void\n    {\n        $magazine = $this->getMagazineByName('test');\n        $user = $this->getUserByUsername('notamod');\n        $this->client->request('POST', \"/api/moderate/magazine/{$magazine->getId()}/mod/{$user->getId()}\");\n\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotRemoveModeratorsFromMagazineAnonymous(): void\n    {\n        $magazine = $this->getMagazineByName('test');\n        $user = $this->getUserByUsername('yesamod');\n        $admin = $this->getUserByUsername('admin', isAdmin: true);\n        $magazineManager = $this->magazineManager;\n        $dto = new ModeratorDto($magazine);\n        $dto->user = $user;\n        $dto->addedBy = $admin;\n        $magazineManager->addModerator($dto);\n\n        $this->client->request('DELETE', \"/api/moderate/magazine/{$magazine->getId()}/mod/{$user->getId()}\");\n\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotAddModeratorsToMagazineWithoutScope(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n        self::createOAuth2AuthCodeClient();\n\n        $magazine = $this->getMagazineByName('test');\n        $user = $this->getUserByUsername('notamod');\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client);\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('POST', \"/api/moderate/magazine/{$magazine->getId()}/mod/{$user->getId()}\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCannotRemoveModeratorsFromMagazineWithoutScope(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n        self::createOAuth2AuthCodeClient();\n\n        $magazine = $this->getMagazineByName('test');\n        $user = $this->getUserByUsername('yesamod');\n        $admin = $this->getUserByUsername('admin', isAdmin: true);\n        $magazineManager = $this->magazineManager;\n        $dto = new ModeratorDto($magazine);\n        $dto->user = $user;\n        $dto->addedBy = $admin;\n        $magazineManager->addModerator($dto);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client);\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('DELETE', \"/api/moderate/magazine/{$magazine->getId()}/mod/{$user->getId()}\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiModCannotAddModeratorsMagazine(): void\n    {\n        $moderator = $this->getUserByUsername('JohnDoe');\n        $user = $this->getUserByUsername('notamod');\n        $this->client->loginUser($moderator);\n        $owner = $this->getUserByUsername('JaneDoe');\n        self::createOAuth2AuthCodeClient();\n\n        $magazine = $this->getMagazineByName('test', $owner);\n        $magazineManager = $this->magazineManager;\n        $dto = new ModeratorDto($magazine);\n        $dto->user = $moderator;\n        $dto->addedBy = $owner;\n        $magazineManager->addModerator($dto);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write moderate:magazine_admin:moderators');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('POST', \"/api/moderate/magazine/{$magazine->getId()}/mod/{$user->getId()}\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiModCannotRemoveModeratorsMagazine(): void\n    {\n        $moderator = $this->getUserByUsername('JohnDoe');\n        $user = $this->getUserByUsername('yesamod');\n        $this->client->loginUser($moderator);\n        $owner = $this->getUserByUsername('JaneDoe');\n        self::createOAuth2AuthCodeClient();\n\n        $magazine = $this->getMagazineByName('test', $owner);\n        $magazineManager = $this->magazineManager;\n        $dto = new ModeratorDto($magazine);\n        $dto->user = $moderator;\n        $dto->addedBy = $owner;\n        $magazineManager->addModerator($dto);\n        $dto = new ModeratorDto($magazine);\n        $dto->user = $user;\n        $dto->addedBy = $owner;\n        $magazineManager->addModerator($dto);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write moderate:magazine_admin:moderators');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('DELETE', \"/api/moderate/magazine/{$magazine->getId()}/mod/{$user->getId()}\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiOwnerCanAddModeratorsMagazine(): void\n    {\n        $user = $this->getUserByUsername('JohnDoe');\n        $moderator = $this->getUserByUsername('willbeamod');\n        $this->client->loginUser($user);\n        self::createOAuth2AuthCodeClient();\n\n        $magazine = $this->getMagazineByName('test');\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write moderate:magazine_admin:moderators');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('POST', \"/api/moderate/magazine/{$magazine->getId()}/mod/{$moderator->getId()}\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(MagazineRetrieveApiTest::MAGAZINE_RESPONSE_KEYS, $jsonData);\n        self::assertIsArray($jsonData['moderators']);\n        self::assertCount(2, $jsonData['moderators']);\n        self::assertArrayKeysMatch(MagazineRetrieveApiTest::MODERATOR_RESPONSE_KEYS, $jsonData['moderators'][1]);\n        self::assertSame($moderator->getId(), $jsonData['moderators'][1]['userId']);\n    }\n\n    public function testApiOwnerCanRemoveModeratorsMagazine(): void\n    {\n        $user = $this->getUserByUsername('JohnDoe');\n        $this->client->loginUser($user);\n        self::createOAuth2AuthCodeClient();\n\n        $magazine = $this->getMagazineByName('test');\n        $moderator = $this->getUserByUsername('yesamod');\n        $admin = $this->getUserByUsername('admin', isAdmin: true);\n        $magazineManager = $this->magazineManager;\n        $dto = new ModeratorDto($magazine);\n        $dto->user = $moderator;\n        $dto->addedBy = $admin;\n        $magazineManager->addModerator($dto);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write moderate:magazine_admin:moderators');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/magazine/{$magazine->getId()}\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(MagazineRetrieveApiTest::MAGAZINE_RESPONSE_KEYS, $jsonData);\n        self::assertIsArray($jsonData['moderators']);\n        self::assertCount(2, $jsonData['moderators']);\n        self::assertArrayKeysMatch(MagazineRetrieveApiTest::MODERATOR_RESPONSE_KEYS, $jsonData['moderators'][1]);\n        self::assertSame($moderator->getId(), $jsonData['moderators'][1]['userId']);\n\n        $this->client->request('DELETE', \"/api/moderate/magazine/{$magazine->getId()}/mod/{$moderator->getId()}\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(MagazineRetrieveApiTest::MAGAZINE_RESPONSE_KEYS, $jsonData);\n        self::assertIsArray($jsonData['moderators']);\n        self::assertCount(1, $jsonData['moderators']);\n        self::assertArrayKeysMatch(MagazineRetrieveApiTest::MODERATOR_RESPONSE_KEYS, $jsonData['moderators'][0]);\n        self::assertSame($user->getId(), $jsonData['moderators'][0]['userId']);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/Magazine/Admin/MagazinePurgeApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\Magazine\\Admin;\n\nuse App\\DTO\\ModeratorDto;\nuse App\\Tests\\WebTestCase;\n\nclass MagazinePurgeApiTest extends WebTestCase\n{\n    public function testApiCannotPurgeMagazineAnonymous(): void\n    {\n        $magazine = $this->getMagazineByName('test');\n        $this->client->request('DELETE', \"/api/admin/magazine/{$magazine->getId()}/purge\");\n\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotPurgeMagazineWithoutScope(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n        self::createOAuth2AuthCodeClient();\n\n        $magazine = $this->getMagazineByName('test');\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client);\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('DELETE', \"/api/admin/magazine/{$magazine->getId()}/purge\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiNonAdminUserCannotPurgeMagazine(): void\n    {\n        $moderator = $this->getUserByUsername('JohnDoe');\n        $this->client->loginUser($moderator);\n        $owner = $this->getUserByUsername('JaneDoe');\n        self::createOAuth2AuthCodeClient();\n\n        $magazine = $this->getMagazineByName('test', $owner);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write admin:magazine:purge');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('DELETE', \"/api/admin/magazine/{$magazine->getId()}/purge\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiModCannotPurgeMagazine(): void\n    {\n        $moderator = $this->getUserByUsername('JohnDoe');\n        $this->client->loginUser($moderator);\n        $owner = $this->getUserByUsername('JaneDoe');\n        self::createOAuth2AuthCodeClient();\n\n        $magazine = $this->getMagazineByName('test', $owner);\n        $magazineManager = $this->magazineManager;\n        $dto = new ModeratorDto($magazine);\n        $dto->user = $moderator;\n        $dto->addedBy = $owner;\n        $magazineManager->addModerator($dto);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write admin:magazine:purge');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('DELETE', \"/api/admin/magazine/{$magazine->getId()}/purge\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiOwnerCannotPurgeMagazine(): void\n    {\n        $user = $this->getUserByUsername('JohnDoe');\n        $this->client->loginUser($user);\n        self::createOAuth2AuthCodeClient();\n\n        $magazine = $this->getMagazineByName('test');\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write admin:magazine:purge');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('DELETE', \"/api/admin/magazine/{$magazine->getId()}/purge\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiAdminCanPurgeMagazine(): void\n    {\n        $admin = $this->getUserByUsername('JohnDoe', isAdmin: true);\n        $owner = $this->getUserByUsername('JaneDoe');\n        $this->client->loginUser($admin);\n        self::createOAuth2AuthCodeClient();\n\n        $magazine = $this->getMagazineByName('test', $owner);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write admin:magazine:purge');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('DELETE', \"/api/admin/magazine/{$magazine->getId()}/purge\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(204);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/Magazine/Admin/MagazineRetrieveStatsApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\Magazine\\Admin;\n\nuse App\\DTO\\ModeratorDto;\nuse App\\Event\\Entry\\EntryHasBeenSeenEvent;\nuse App\\Tests\\WebTestCase;\nuse Symfony\\Component\\HttpFoundation\\Request;\n\nclass MagazineRetrieveStatsApiTest extends WebTestCase\n{\n    public const VIEW_STATS_KEYS = ['data'];\n    public const STATS_BY_CONTENT_TYPE_KEYS = ['entry', 'post', 'entry_comment', 'post_comment'];\n\n    public const COUNT_ITEM_KEYS = ['datetime', 'count'];\n    public const VOTE_ITEM_KEYS = ['datetime', 'boost', 'down', 'up'];\n\n    public function testApiCannotRetrieveMagazineStatsAnonymous(): void\n    {\n        $magazine = $this->getMagazineByName('test');\n        $this->client->request('GET', \"/api/stats/magazine/{$magazine->getId()}/votes\");\n\n        self::assertResponseStatusCodeSame(401);\n        $this->client->request('GET', \"/api/stats/magazine/{$magazine->getId()}/content\");\n\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotRetrieveMagazineStatsWithoutScope(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n        self::createOAuth2AuthCodeClient();\n        $magazine = $this->getMagazineByName('test');\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client);\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/stats/magazine/{$magazine->getId()}/votes\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n        $this->client->request('GET', \"/api/stats/magazine/{$magazine->getId()}/content\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCannotRetrieveMagazineStatsIfNotOwner(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n        self::createOAuth2AuthCodeClient();\n\n        $owner = $this->getUserByUsername('JaneDoe');\n        $magazine = $this->getMagazineByName('test', $owner);\n        $magazineManager = $this->magazineManager;\n        $dto = new ModeratorDto($magazine);\n        $dto->user = $this->getUserByUsername('JohnDoe');\n        $dto->addedBy = $owner;\n        $magazineManager->addModerator($dto);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write moderate:magazine_admin:stats');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/stats/magazine/{$magazine->getId()}/votes\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n        $this->client->request('GET', \"/api/stats/magazine/{$magazine->getId()}/content\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanRetrieveMagazineStats(): void\n    {\n        $user = $this->getUserByUsername('JohnDoe');\n        $user2 = $this->getUserByUsername('JohnDoe2');\n        $this->client->loginUser($user);\n        self::createOAuth2AuthCodeClient();\n        $magazine = $this->getMagazineByName('test');\n\n        $entry = $this->getEntryByTitle('Stats test', body: 'This is gonna be a statistic', magazine: $magazine, user: $user);\n\n        $requestStack = $this->requestStack;\n        $requestStack->push(Request::create('/'));\n        $dispatcher = $this->eventDispatcher;\n        $dispatcher->dispatch(new EntryHasBeenSeenEvent($entry));\n\n        $favouriteManager = $this->favouriteManager;\n        $favourite = $favouriteManager->toggle($user, $entry);\n\n        $voteManager = $this->voteManager;\n        $vote = $voteManager->upvote($entry, $user);\n\n        $entityManager = $this->entityManager;\n        $entityManager->persist($favourite);\n        $entityManager->persist($vote);\n        $entityManager->flush();\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write moderate:magazine_admin:stats');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        // Start a day ago to avoid timezone issues when testing on machines with non-UTC timezones\n        $startString = rawurlencode($entry->getCreatedAt()->add(\\DateInterval::createFromDateString('-1 minute'))->format(\\DateTimeImmutable::ATOM));\n        $this->client->request('GET', \"/api/stats/magazine/{$magazine->getId()}/votes?resolution=hour&start=$startString\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::STATS_BY_CONTENT_TYPE_KEYS, $jsonData);\n        self::assertIsArray($jsonData['entry']);\n        self::assertCount(1, $jsonData['entry']);\n        self::assertIsArray($jsonData['entry_comment']);\n        self::assertEmpty($jsonData['entry_comment']);\n        self::assertIsArray($jsonData['post']);\n        self::assertEmpty($jsonData['post']);\n        self::assertIsArray($jsonData['post_comment']);\n        self::assertEmpty($jsonData['post_comment']);\n        self::assertArrayKeysMatch(self::VOTE_ITEM_KEYS, $jsonData['entry'][0]);\n        self::assertSame(1, $jsonData['entry'][0]['up']);\n        self::assertSame(0, $jsonData['entry'][0]['down']);\n        self::assertSame(1, $jsonData['entry'][0]['boost']);\n\n        $this->client->request('GET', \"/api/stats/magazine/{$magazine->getId()}/content?resolution=hour&start=$startString\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::STATS_BY_CONTENT_TYPE_KEYS, $jsonData);\n        self::assertIsInt($jsonData['entry']);\n        self::assertIsInt($jsonData['entry_comment']);\n        self::assertIsInt($jsonData['post']);\n        self::assertIsInt($jsonData['post_comment']);\n        self::assertSame(1, $jsonData['entry']);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/Magazine/Admin/MagazineTagsApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\Magazine\\Admin;\n\nuse App\\DTO\\ModeratorDto;\nuse App\\Tests\\Functional\\Controller\\Api\\Magazine\\MagazineRetrieveApiTest;\nuse App\\Tests\\WebTestCase;\n\nclass MagazineTagsApiTest extends WebTestCase\n{\n    public function testApiCannotAddTagsToMagazineAnonymous(): void\n    {\n        $magazine = $this->getMagazineByName('test');\n        $this->client->request('POST', \"/api/moderate/magazine/{$magazine->getId()}/tag/test\");\n\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotRemoveTagsFromMagazineAnonymous(): void\n    {\n        $magazine = $this->getMagazineByName('test');\n        $magazine->tags = ['test'];\n        $entityManager = $this->entityManager;\n        $entityManager->persist($magazine);\n        $entityManager->flush();\n\n        $this->client->request('DELETE', \"/api/moderate/magazine/{$magazine->getId()}/tag/test\");\n\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotAddTagsToMagazineWithoutScope(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n        self::createOAuth2AuthCodeClient();\n\n        $magazine = $this->getMagazineByName('test');\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client);\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('POST', \"/api/moderate/magazine/{$magazine->getId()}/tag/test\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCannotRemoveTagsFromMagazineWithoutScope(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n        self::createOAuth2AuthCodeClient();\n\n        $magazine = $this->getMagazineByName('test');\n        $magazine->tags = ['test'];\n        $entityManager = $this->entityManager;\n        $entityManager->persist($magazine);\n        $entityManager->flush();\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client);\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('DELETE', \"/api/moderate/magazine/{$magazine->getId()}/tag/test\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiModCannotAddTagsMagazine(): void\n    {\n        $moderator = $this->getUserByUsername('JohnDoe');\n        $this->client->loginUser($moderator);\n        $owner = $this->getUserByUsername('JaneDoe');\n        self::createOAuth2AuthCodeClient();\n\n        $magazine = $this->getMagazineByName('test', $owner);\n        $magazineManager = $this->magazineManager;\n        $dto = new ModeratorDto($magazine);\n        $dto->user = $moderator;\n        $dto->addedBy = $owner;\n        $magazineManager->addModerator($dto);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write moderate:magazine_admin:tags');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('POST', \"/api/moderate/magazine/{$magazine->getId()}/tag/test\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiModCannotRemoveTagsMagazine(): void\n    {\n        $moderator = $this->getUserByUsername('JohnDoe');\n        $this->client->loginUser($moderator);\n        $owner = $this->getUserByUsername('JaneDoe');\n        self::createOAuth2AuthCodeClient();\n\n        $magazine = $this->getMagazineByName('test', $owner);\n        $magazineManager = $this->magazineManager;\n        $dto = new ModeratorDto($magazine);\n        $dto->user = $moderator;\n        $dto->addedBy = $owner;\n        $magazineManager->addModerator($dto);\n\n        $magazine->tags = ['test'];\n        $entityManager = $this->entityManager;\n        $entityManager->persist($magazine);\n        $entityManager->flush();\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write moderate:magazine_admin:tags');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('DELETE', \"/api/moderate/magazine/{$magazine->getId()}/tag/test\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiOwnerCanAddTagsMagazine(): void\n    {\n        $user = $this->getUserByUsername('JohnDoe');\n        $this->client->loginUser($user);\n        self::createOAuth2AuthCodeClient();\n\n        $magazine = $this->getMagazineByName('test');\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write moderate:magazine_admin:tags');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('POST', \"/api/moderate/magazine/{$magazine->getId()}/tag/test\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(MagazineRetrieveApiTest::MAGAZINE_RESPONSE_KEYS, $jsonData);\n        self::assertIsArray($jsonData['tags']);\n        self::assertCount(1, $jsonData['tags']);\n        self::assertEquals('test', $jsonData['tags'][0]);\n    }\n\n    public function testApiOwnerCannotAddWeirdTagsMagazine(): void\n    {\n        $user = $this->getUserByUsername('JohnDoe');\n        $this->client->loginUser($user);\n        self::createOAuth2AuthCodeClient();\n\n        $magazine = $this->getMagazineByName('test');\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write moderate:magazine_admin:tags');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('POST', \"/api/moderate/magazine/{$magazine->getId()}/tag/test%20Weird\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(400);\n    }\n\n    public function testApiOwnerCanRemoveTagsMagazine(): void\n    {\n        $user = $this->getUserByUsername('JohnDoe');\n        $this->client->loginUser($user);\n        self::createOAuth2AuthCodeClient();\n\n        $magazine = $this->getMagazineByName('test');\n        $magazine->tags = ['test'];\n        $entityManager = $this->entityManager;\n        $entityManager->persist($magazine);\n        $entityManager->flush();\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write moderate:magazine_admin:tags');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/magazine/{$magazine->getId()}\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(MagazineRetrieveApiTest::MAGAZINE_RESPONSE_KEYS, $jsonData);\n        self::assertIsArray($jsonData['tags']);\n        self::assertCount(1, $jsonData['tags']);\n        self::assertEquals('test', $jsonData['tags'][0]);\n\n        $this->client->request('DELETE', \"/api/moderate/magazine/{$magazine->getId()}/tag/test\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(MagazineRetrieveApiTest::MAGAZINE_RESPONSE_KEYS, $jsonData);\n        self::assertEmpty($jsonData['tags']);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/Magazine/Admin/MagazineUpdateApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\Magazine\\Admin;\n\nuse App\\Tests\\WebTestCase;\n\nclass MagazineUpdateApiTest extends WebTestCase\n{\n    public function testApiCannotUpdateMagazineAnonymous(): void\n    {\n        $magazine = $this->getMagazineByName('test');\n        $this->client->request('PUT', \"/api/moderate/magazine/{$magazine->getId()}\");\n\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotUpdateMagazineWithoutScope(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n        self::createOAuth2AuthCodeClient();\n\n        $magazine = $this->getMagazineByName('test');\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client);\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('PUT', \"/api/moderate/magazine/{$magazine->getId()}\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanUpdateMagazine(): void\n    {\n        $user = $this->getUserByUsername('JohnDoe');\n        $this->client->loginUser($user);\n        self::createOAuth2AuthCodeClient();\n\n        $magazine = $this->getMagazineByName('test');\n        $magazine->rules = 'Some initial rules';\n        $this->entityManager->persist($magazine);\n        $this->entityManager->flush();\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write moderate:magazine_admin:update');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $name = 'test';\n        $title = 'API Test Magazine';\n        $description = 'A description';\n        $rules = 'Some rules';\n\n        $this->client->jsonRequest(\n            'PUT', \"/api/moderate/magazine/{$magazine->getId()}\",\n            parameters: [\n                'name' => $name,\n                'title' => $title,\n                'description' => $description,\n                'rules' => $rules,\n                'isAdult' => true,\n                'discoverable' => false,\n                'indexable' => false,\n            ],\n            server: ['HTTP_AUTHORIZATION' => $token]\n        );\n\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(WebTestCase::MAGAZINE_RESPONSE_KEYS, $jsonData);\n        self::assertEquals($name, $jsonData['name']);\n        self::assertSame($user->getId(), $jsonData['owner']['userId']);\n        self::assertEquals($description, $jsonData['description']);\n        self::assertEquals($rules, $jsonData['rules']);\n        self::assertTrue($jsonData['isAdult']);\n        self::assertFalse($jsonData['discoverable']);\n        self::assertFalse($jsonData['indexable']);\n    }\n\n    public function testApiCannotUpdateMagazineWithInvalidParams(): void\n    {\n        $user = $this->getUserByUsername('JohnDoe');\n        $this->client->loginUser($user);\n        self::createOAuth2AuthCodeClient();\n\n        $magazine = $this->getMagazineByName('test');\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write moderate:magazine_admin:update');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $name = 'someothername';\n        $title = 'Different name';\n        $description = 'A description';\n\n        $this->client->jsonRequest(\n            'PUT', \"/api/moderate/magazine/{$magazine->getId()}\",\n            parameters: [\n                'name' => $name,\n                'title' => $title,\n                'description' => $description,\n                'isAdult' => false,\n            ],\n            server: ['HTTP_AUTHORIZATION' => $token]\n        );\n\n        self::assertResponseStatusCodeSame(400);\n\n        $description = 'short title';\n        $title = 'as';\n        $this->client->jsonRequest(\n            'PUT', \"/api/moderate/magazine/{$magazine->getId()}\",\n            parameters: [\n                'title' => $title,\n                'description' => $description,\n                'isAdult' => false,\n            ],\n            server: ['HTTP_AUTHORIZATION' => $token]\n        );\n\n        self::assertResponseStatusCodeSame(400);\n\n        $description = 'long title';\n        $title = 'Way too long of a title. This can only be 50 characters!';\n        $this->client->jsonRequest(\n            'PUT', \"/api/moderate/magazine/{$magazine->getId()}\",\n            parameters: [\n                'title' => $title,\n                'description' => $description,\n                'isAdult' => false,\n            ],\n            server: ['HTTP_AUTHORIZATION' => $token]\n        );\n\n        self::assertResponseStatusCodeSame(400);\n\n        $rules = 'Some rules';\n        $description = 'Rules are deprecated';\n        $this->client->jsonRequest(\n            'PUT', \"/api/moderate/magazine/{$magazine->getId()}\",\n            parameters: [\n                'rules' => $rules,\n                'description' => $description,\n                'isAdult' => false,\n            ],\n            server: ['HTTP_AUTHORIZATION' => $token]\n        );\n        self::assertResponseStatusCodeSame(400);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/Magazine/Admin/MagazineUpdateThemeApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\Magazine\\Admin;\n\nuse App\\DTO\\ModeratorDto;\nuse App\\Tests\\Functional\\Controller\\Api\\Magazine\\MagazineRetrieveApiTest;\nuse App\\Tests\\WebTestCase;\nuse Symfony\\Component\\HttpFoundation\\File\\UploadedFile;\n\nclass MagazineUpdateThemeApiTest extends WebTestCase\n{\n    public const MAGAZINE_THEME_RESPONSE_KEYS = ['magazine', 'customCss', 'icon', 'banner'];\n\n    public function setUp(): void\n    {\n        parent::setUp();\n        $this->kibbyPath = \\dirname(__FILE__, 6).'/assets/kibby_emoji.png';\n    }\n\n    public function testApiCannotUpdateMagazineThemeAnonymous(): void\n    {\n        $magazine = $this->getMagazineByName('test');\n        $this->client->request('POST', \"/api/moderate/magazine/{$magazine->getId()}/theme\");\n\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiModCannotUpdateMagazineTheme(): void\n    {\n        $moderator = $this->getUserByUsername('JohnDoe');\n        $this->client->loginUser($moderator);\n        $owner = $this->getUserByUsername('JaneDoe');\n        self::createOAuth2AuthCodeClient();\n\n        $magazine = $this->getMagazineByName('test', $owner);\n        $magazineManager = $this->magazineManager;\n        $dto = new ModeratorDto($magazine);\n        $dto->user = $moderator;\n        $dto->addedBy = $owner;\n        $magazineManager->addModerator($dto);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write moderate:magazine_admin:theme');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('POST', \"/api/moderate/magazine/{$magazine->getId()}/theme\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCannotUpdateMagazineThemeWithoutScope(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n        self::createOAuth2AuthCodeClient();\n\n        $magazine = $this->getMagazineByName('test');\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client);\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('POST', \"/api/moderate/magazine/{$magazine->getId()}/theme\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanUpdateMagazineThemeWithCustomCss(): void\n    {\n        $user = $this->getUserByUsername('JohnDoe');\n        $this->client->loginUser($user);\n        self::createOAuth2AuthCodeClient();\n\n        $magazine = $this->getMagazineByName('test');\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write moderate:magazine_admin:theme');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        // Uploading a file appears to delete the file at the given path, so make a copy before upload\n        $tmpPath = bin2hex(random_bytes(32));\n        copy($this->kibbyPath, $tmpPath.'.tmp');\n        $image = new UploadedFile($tmpPath.'.tmp', 'kibby_emoji.png', 'image/png');\n\n        $customCss = 'a {background: red;}';\n\n        $this->client->request(\n            'POST', \"/api/moderate/magazine/{$magazine->getId()}/theme\",\n            parameters: [\n                'customCss' => $customCss,\n            ],\n            files: ['uploadImage' => $image],\n            server: ['HTTP_AUTHORIZATION' => $token]\n        );\n\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::MAGAZINE_THEME_RESPONSE_KEYS, $jsonData);\n        self::assertIsArray($jsonData['magazine']);\n        self::assertArrayKeysMatch(MagazineRetrieveApiTest::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']);\n        self::assertStringContainsString($customCss, $jsonData['customCss']);\n        self::assertIsArray($jsonData['icon']);\n        self::assertArrayKeysMatch(self::IMAGE_KEYS, $jsonData['icon']);\n        self::assertNull($jsonData['banner']);\n        self::assertSame(96, $jsonData['icon']['width']);\n        self::assertSame(96, $jsonData['icon']['height']);\n        self::assertEquals('a8/1c/a81cc2fea35eeb232cd28fcb109b3eb5a4e52c71bce95af6650d71876c1bcbb7.png', $jsonData['icon']['filePath']);\n    }\n\n    public function testApiCanUpdateMagazineThemeWithBackgroundImage(): void\n    {\n        $user = $this->getUserByUsername('JohnDoe');\n        $this->client->loginUser($user);\n        self::createOAuth2AuthCodeClient();\n\n        $magazine = $this->getMagazineByName('test');\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write moderate:magazine_admin:theme');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        // Uploading a file appears to delete the file at the given path, so make a copy before upload\n        $tmpPath = bin2hex(random_bytes(32));\n        copy($this->kibbyPath, $tmpPath.'.png');\n        $image = new UploadedFile($tmpPath.'.png', 'kibby_emoji.png', 'image/png');\n\n        $imageManager = $this->imageManager;\n        $expectedPath = $imageManager->getFilePath($image->getFilename());\n\n        $backgroundImage = 'shape1';\n\n        $this->client->request(\n            'POST', \"/api/moderate/magazine/{$magazine->getId()}/theme\",\n            parameters: [\n                'backgroundImage' => $backgroundImage,\n            ],\n            files: ['uploadImage' => $image],\n            server: ['HTTP_AUTHORIZATION' => $token]\n        );\n\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::MAGAZINE_THEME_RESPONSE_KEYS, $jsonData);\n        self::assertIsArray($jsonData['magazine']);\n        self::assertArrayKeysMatch(MagazineRetrieveApiTest::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']);\n        self::assertStringContainsString('/build/images/shape.png', $jsonData['customCss']);\n        self::assertIsArray($jsonData['icon']);\n        self::assertArrayKeysMatch(self::IMAGE_KEYS, $jsonData['icon']);\n        self::assertSame(96, $jsonData['icon']['width']);\n        self::assertSame(96, $jsonData['icon']['height']);\n        self::assertEquals($expectedPath, $jsonData['icon']['filePath']);\n    }\n\n    public function testCanUpdateMagazineBanner(): void\n    {\n        $user = $this->getUserByUsername('JohnDoe');\n        $this->client->loginUser($user);\n        self::createOAuth2AuthCodeClient();\n\n        $magazine = $this->getMagazineByName('test');\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write moderate:magazine_admin:theme');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        // Uploading a file appears to delete the file at the given path, so make a copy before upload\n        $tmpPath = bin2hex(random_bytes(32));\n        copy($this->kibbyPath, $tmpPath.'.tmp');\n        $image = new UploadedFile($tmpPath.'.tmp', 'kibby_emoji.png', 'image/png');\n\n        $this->client->request(\n            'PUT', \"/api/moderate/magazine/{$magazine->getId()}/banner\",\n            files: ['uploadImage' => $image],\n            server: ['HTTP_AUTHORIZATION' => $token]\n        );\n\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::MAGAZINE_THEME_RESPONSE_KEYS, $jsonData);\n        self::assertIsArray($jsonData['magazine']);\n        self::assertArrayKeysMatch(WebTestCase::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']);\n        self::assertNull($jsonData['icon']);\n        self::assertArrayKeysMatch(self::IMAGE_KEYS, $jsonData['banner']);\n        self::assertSame(96, $jsonData['banner']['width']);\n        self::assertSame(96, $jsonData['banner']['height']);\n        self::assertEquals('a8/1c/a81cc2fea35eeb232cd28fcb109b3eb5a4e52c71bce95af6650d71876c1bcbb7.png', $jsonData['banner']['filePath']);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/Magazine/MagazineBlockApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\Magazine;\n\nuse App\\Tests\\WebTestCase;\n\nclass MagazineBlockApiTest extends WebTestCase\n{\n    public function testApiCannotBlockMagazineAnonymously(): void\n    {\n        $magazine = $this->getMagazineByName('test');\n\n        $this->client->request('PUT', '/api/magazine/'.(string) $magazine->getId().'/block');\n\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotBlockMagazineWithoutScope(): void\n    {\n        $user = $this->getUserByUsername('testuser');\n        $this->client->loginUser($user);\n        self::createOAuth2AuthCodeClient();\n\n        $magazine = $this->getMagazineByName('test');\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('PUT', '/api/magazine/'.(string) $magazine->getId().'/block', server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanBlockMagazine(): void\n    {\n        $user = $this->getUserByUsername('testuser');\n        $this->client->loginUser($user);\n        self::createOAuth2AuthCodeClient();\n\n        $magazine = $this->getMagazineByName('test');\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write magazine:block');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('PUT', '/api/magazine/'.(string) $magazine->getId().'/block', server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(MagazineRetrieveApiTest::MAGAZINE_RESPONSE_KEYS, $jsonData);\n\n        // Scopes for reading subscriptions and blocklists granted, so these values should be filled\n        self::assertNull($jsonData['isUserSubscribed']);\n        self::assertTrue($jsonData['isBlockedByUser']);\n\n        $this->client->request('GET', '/api/magazine/'.(string) $magazine->getId(), server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(MagazineRetrieveApiTest::MAGAZINE_RESPONSE_KEYS, $jsonData);\n\n        // Scopes for reading subscriptions and blocklists granted, so these values should be filled\n        self::assertNull($jsonData['isUserSubscribed']);\n        self::assertTrue($jsonData['isBlockedByUser']);\n    }\n\n    public function testApiCannotUnblockMagazineAnonymously(): void\n    {\n        $magazine = $this->getMagazineByName('test');\n\n        $this->client->request('PUT', '/api/magazine/'.(string) $magazine->getId().'/unblock');\n\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotUnblockMagazineWithoutScope(): void\n    {\n        $user = $this->getUserByUsername('testuser');\n        $this->client->loginUser($user);\n        self::createOAuth2AuthCodeClient();\n\n        $magazine = $this->getMagazineByName('test');\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('PUT', '/api/magazine/'.(string) $magazine->getId().'/unblock', server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanUnblockMagazine(): void\n    {\n        $user = $this->getUserByUsername('testuser');\n        $this->client->loginUser($user);\n        self::createOAuth2AuthCodeClient();\n\n        $magazine = $this->getMagazineByName('test');\n        $manager = $this->magazineManager;\n        $manager->block($magazine, $user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write magazine:block');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('PUT', '/api/magazine/'.(string) $magazine->getId().'/unblock', server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(MagazineRetrieveApiTest::MAGAZINE_RESPONSE_KEYS, $jsonData);\n\n        // Scopes for reading subscriptions and blocklists granted, so these values should be filled\n        self::assertNull($jsonData['isUserSubscribed']);\n        self::assertFalse($jsonData['isBlockedByUser']);\n\n        $this->client->request('GET', '/api/magazine/'.(string) $magazine->getId(), server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(MagazineRetrieveApiTest::MAGAZINE_RESPONSE_KEYS, $jsonData);\n\n        // Scopes for reading subscriptions and blocklists granted, so these values should be filled\n        self::assertNull($jsonData['isUserSubscribed']);\n        self::assertFalse($jsonData['isBlockedByUser']);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/Magazine/MagazineModlogApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\Magazine;\n\nuse App\\DTO\\MagazineBanDto;\nuse App\\DTO\\ModeratorDto;\nuse App\\Entity\\MagazineLog;\nuse App\\Tests\\WebTestCase;\n\nclass MagazineModlogApiTest extends WebTestCase\n{\n    public function testApiCanRetrieveModlogByMagazineIdAnonymously(): void\n    {\n        $magazine = $this->getMagazineByName('test');\n\n        $this->client->request('GET', '/api/magazine/'.(string) $magazine->getId().'/log');\n\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n        self::assertIsArray($jsonData['items']);\n        self::assertEmpty($jsonData['items']);\n    }\n\n    public function testApiCanRetrieveModlogByMagazineIdAnonymouslyWithTypeFilter(): void\n    {\n        $magazine = $this->getMagazineByName('test');\n\n        $this->client->request('GET', '/api/magazine/'.$magazine->getId().'/log?types[]='.MagazineLog::CHOICES[0].'&types[]='.MagazineLog::CHOICES[1]);\n\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n        self::assertIsArray($jsonData['items']);\n        self::assertEmpty($jsonData['items']);\n    }\n\n    public function testApiCanRetrieveMagazineById(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n        self::createOAuth2AuthCodeClient();\n\n        $magazine = $this->getMagazineByName('test');\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client);\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', '/api/magazine/'.(string) $magazine->getId().'/log', server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n        self::assertIsArray($jsonData['items']);\n        self::assertEmpty($jsonData['items']);\n    }\n\n    public function testApiCanRetrieveEntryPinnedLog(): void\n    {\n        $user = $this->getUserByUsername('JohnDoe');\n        $this->client->loginUser($user);\n        self::createOAuth2AuthCodeClient();\n\n        $magazine = $this->getMagazineByName('test');\n        $entry = $this->getEntryByTitle('Something to pin', magazine: $magazine);\n        $this->entryManager->pin($entry, $user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client);\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', '/api/magazine/'.$magazine->getId().'/log', server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(1, $jsonData['items']);\n        $item = $jsonData['items'][0];\n        self::assertArrayKeysMatch(WebTestCase::LOG_ENTRY_KEYS, $item);\n        self::assertEquals('log_entry_pinned', $item['type']);\n        self::assertIsArray($item['subject']);\n        self::assertArrayKeysMatch(WebTestCase::ENTRY_RESPONSE_KEYS, $item['subject']);\n        self::assertEquals($entry->getId(), $item['subject']['entryId']);\n    }\n\n    public function testApiCanRetrieveUserBannedLog(): void\n    {\n        $user = $this->getUserByUsername('JohnDoe');\n        $this->client->loginUser($user);\n        self::createOAuth2AuthCodeClient();\n\n        $magazine = $this->getMagazineByName('test');\n        $banned = $this->getUserByUsername('troll');\n        $dto = new MagazineBanDto();\n        $dto->reason = 'because';\n        $ban = $this->magazineManager->ban($magazine, $banned, $user, $dto);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client);\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', '/api/magazine/'.$magazine->getId().'/log', server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(1, $jsonData['items']);\n        $item = $jsonData['items'][0];\n        self::assertArrayKeysMatch(WebTestCase::LOG_ENTRY_KEYS, $item);\n        self::assertEquals('log_ban', $item['type']);\n        self::assertArrayKeysMatch(WebTestCase::BAN_RESPONSE_KEYS, $item['subject']);\n        self::assertEquals($ban->getId(), $item['subject']['banId']);\n    }\n\n    public function testApiCanRetrieveModeratorAddedLog(): void\n    {\n        $user = $this->getUserByUsername('JohnDoe');\n        $this->client->loginUser($user);\n        self::createOAuth2AuthCodeClient();\n\n        $magazine = $this->getMagazineByName('test');\n        $mod = $this->getUserByUsername('mod');\n        $dto = new ModeratorDto($magazine, $mod, $user);\n        $this->magazineManager->addModerator($dto);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client);\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', '/api/magazine/'.$magazine->getId().'/log', server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(1, $jsonData['items']);\n        $item = $jsonData['items'][0];\n        self::assertArrayKeysMatch(WebTestCase::LOG_ENTRY_KEYS, $item);\n        self::assertEquals('log_moderator_add', $item['type']);\n        self::assertArrayKeysMatch(WebTestCase::USER_SMALL_RESPONSE_KEYS, $item['subject']);\n        self::assertEquals($mod->getId(), $item['subject']['userId']);\n        self::assertArrayKeysMatch(WebTestCase::USER_SMALL_RESPONSE_KEYS, $item['moderator']);\n        self::assertEquals($user->getId(), $item['moderator']['userId']);\n    }\n\n    public function testApiModlogReflectsModerationActionsTaken(): void\n    {\n        $this->createModlogMessages();\n        $magazine = $this->getMagazineByName('acme');\n        $moderator = $magazine->getOwner();\n\n        $entityManager = $this->entityManager;\n        $entityManager->refresh($magazine);\n\n        $this->client->request('GET', '/api/magazine/'.(string) $magazine->getId().'/log');\n\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(5, $jsonData['items']);\n\n        $this->validateModlog($jsonData, $magazine, $moderator);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/Magazine/MagazineRetrieveApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\Magazine;\n\nuse App\\Service\\MagazineManager;\nuse App\\Tests\\WebTestCase;\n\nclass MagazineRetrieveApiTest extends WebTestCase\n{\n    public const MODERATOR_RESPONSE_KEYS = [\n        'magazineId',\n        'userId',\n        'username',\n        'avatar',\n        'apId',\n    ];\n\n    public const MAGAZINE_COUNT = 20;\n\n    public function testApiCanRetrieveMagazineByIdAnonymously(): void\n    {\n        $magazine = $this->getMagazineByName('test');\n\n        $this->client->request('GET', \"/api/magazine/{$magazine->getId()}\");\n\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::MAGAZINE_RESPONSE_KEYS, $jsonData);\n        self::assertSame($magazine->getId(), $jsonData['magazineId']);\n        self::assertIsArray($jsonData['owner']);\n        self::assertArrayKeysMatch(self::MODERATOR_RESPONSE_KEYS, $jsonData['owner']);\n        self::assertSame($magazine->getOwner()->getId(), $jsonData['owner']['userId']);\n        self::assertNull($jsonData['icon']);\n        self::assertNull($jsonData['banner']);\n        self::assertEmpty($jsonData['tags']);\n        self::assertEquals('test', $jsonData['name']);\n        self::assertIsArray($jsonData['badges']);\n        self::assertIsArray($jsonData['moderators']);\n        self::assertCount(1, $jsonData['moderators']);\n        self::assertIsArray($jsonData['moderators'][0]);\n        self::assertArrayKeysMatch(self::MODERATOR_RESPONSE_KEYS, $jsonData['moderators'][0]);\n        self::assertSame($magazine->getOwner()->getId(), $jsonData['moderators'][0]['userId']);\n\n        self::assertFalse($jsonData['isAdult']);\n        // Anonymous access, so these values should be null\n        self::assertNull($jsonData['isUserSubscribed']);\n        self::assertNull($jsonData['isBlockedByUser']);\n    }\n\n    public function testApiCanRetrieveMagazineById(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n        self::createOAuth2AuthCodeClient();\n\n        $magazine = $this->getMagazineByName('test');\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client);\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/magazine/{$magazine->getId()}\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::MAGAZINE_RESPONSE_KEYS, $jsonData);\n        self::assertSame($magazine->getId(), $jsonData['magazineId']);\n        self::assertIsArray($jsonData['owner']);\n        self::assertArrayKeysMatch(self::MODERATOR_RESPONSE_KEYS, $jsonData['owner']);\n        self::assertSame($magazine->getOwner()->getId(), $jsonData['owner']['userId']);\n        self::assertNull($jsonData['icon']);\n        self::assertNull($jsonData['banner']);\n        self::assertEmpty($jsonData['tags']);\n        self::assertEquals('test', $jsonData['name']);\n        self::assertIsArray($jsonData['badges']);\n        self::assertIsArray($jsonData['moderators']);\n        self::assertCount(1, $jsonData['moderators']);\n        self::assertIsArray($jsonData['moderators'][0]);\n        self::assertArrayKeysMatch(self::MODERATOR_RESPONSE_KEYS, $jsonData['moderators'][0]);\n        self::assertSame($magazine->getOwner()->getId(), $jsonData['moderators'][0]['userId']);\n\n        self::assertFalse($jsonData['isAdult']);\n        // Scopes for reading subscriptions and blocklists not granted, so these values should be null\n        self::assertNull($jsonData['isUserSubscribed']);\n        self::assertNull($jsonData['isBlockedByUser']);\n    }\n\n    public function testApiCanRetrieveMagazineByNameAnonymously(): void\n    {\n        $magazine = $this->getMagazineByName('test');\n\n        $this->client->request('GET', '/api/magazine/name/test');\n\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::MAGAZINE_RESPONSE_KEYS, $jsonData);\n        self::assertSame($magazine->getId(), $jsonData['magazineId']);\n        self::assertIsArray($jsonData['owner']);\n        self::assertArrayKeysMatch(self::MODERATOR_RESPONSE_KEYS, $jsonData['owner']);\n        self::assertSame($magazine->getOwner()->getId(), $jsonData['owner']['userId']);\n        self::assertNull($jsonData['icon']);\n        self::assertNull($jsonData['banner']);\n        self::assertEmpty($jsonData['tags']);\n        self::assertEquals('test', $jsonData['name']);\n        self::assertIsArray($jsonData['badges']);\n        self::assertIsArray($jsonData['moderators']);\n        self::assertCount(1, $jsonData['moderators']);\n        self::assertIsArray($jsonData['moderators'][0]);\n        self::assertArrayKeysMatch(self::MODERATOR_RESPONSE_KEYS, $jsonData['moderators'][0]);\n        self::assertSame($magazine->getOwner()->getId(), $jsonData['moderators'][0]['userId']);\n\n        self::assertFalse($jsonData['isAdult']);\n        // Anonymous access, so these values should be null\n        self::assertNull($jsonData['isUserSubscribed']);\n        self::assertNull($jsonData['isBlockedByUser']);\n    }\n\n    public function testApiCanRetrieveMagazineByName(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n        self::createOAuth2AuthCodeClient();\n\n        $magazine = $this->getMagazineByName('test');\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client);\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', '/api/magazine/name/test', server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::MAGAZINE_RESPONSE_KEYS, $jsonData);\n        self::assertSame($magazine->getId(), $jsonData['magazineId']);\n        self::assertIsArray($jsonData['owner']);\n        self::assertArrayKeysMatch(self::MODERATOR_RESPONSE_KEYS, $jsonData['owner']);\n        self::assertSame($magazine->getOwner()->getId(), $jsonData['owner']['userId']);\n        self::assertNull($jsonData['icon']);\n        self::assertNull($jsonData['banner']);\n        self::assertEmpty($jsonData['tags']);\n        self::assertEquals('test', $jsonData['name']);\n        self::assertIsArray($jsonData['badges']);\n        self::assertIsArray($jsonData['moderators']);\n        self::assertCount(1, $jsonData['moderators']);\n        self::assertIsArray($jsonData['moderators'][0]);\n        self::assertArrayKeysMatch(self::MODERATOR_RESPONSE_KEYS, $jsonData['moderators'][0]);\n        self::assertSame($magazine->getOwner()->getId(), $jsonData['moderators'][0]['userId']);\n\n        self::assertFalse($jsonData['isAdult']);\n        // Scopes for reading subscriptions and blocklists not granted, so these values should be null\n        self::assertNull($jsonData['isUserSubscribed']);\n        self::assertNull($jsonData['isBlockedByUser']);\n    }\n\n    public function testApiMagazineSubscribeAndBlockFlags(): void\n    {\n        $user = $this->getUserByUsername('testuser');\n        $this->client->loginUser($user);\n        self::createOAuth2AuthCodeClient();\n\n        $magazine = $this->getMagazineByName('test');\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write magazine:subscribe magazine:block');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/magazine/{$magazine->getId()}\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::MAGAZINE_RESPONSE_KEYS, $jsonData);\n\n        // Scopes for reading subscriptions and blocklists granted, so these values should be filled\n        self::assertFalse($jsonData['isUserSubscribed']);\n        self::assertFalse($jsonData['isBlockedByUser']);\n    }\n\n    // The 2 next tests exist because changing the subscription status via MagazineManager after calling the API\n    //      was causing strange doctrine exceptions. If doctrine did not throw exceptions when modifications\n    //      were made, these tests could be rolled into testApiMagazineSubscribeAndBlockFlags above\n    public function testApiMagazineSubscribeFlagIsTrueWhenSubscribed(): void\n    {\n        $user = $this->getUserByUsername('testuser');\n        $this->client->loginUser($user);\n        self::createOAuth2AuthCodeClient();\n\n        $magazine = $this->getMagazineByName('test');\n        $manager = $this->magazineManager;\n        $manager->subscribe($magazine, $user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write magazine:subscribe magazine:block');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/magazine/{$magazine->getId()}\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::MAGAZINE_RESPONSE_KEYS, $jsonData);\n\n        // Scopes for reading subscriptions and blocklists granted, so these values should be filled\n        self::assertTrue($jsonData['isUserSubscribed']);\n        self::assertFalse($jsonData['isBlockedByUser']);\n    }\n\n    public function testApiMagazineBlockFlagIsTrueWhenBlocked(): void\n    {\n        $user = $this->getUserByUsername('testuser');\n        $this->client->loginUser($user);\n        self::createOAuth2AuthCodeClient();\n\n        $magazine = $this->getMagazineByName('test');\n        $manager = $this->magazineManager;\n        $manager->block($magazine, $user);\n        $entityManager = $this->entityManager;\n        $entityManager->persist($user);\n        $entityManager->flush();\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write magazine:subscribe magazine:block');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/magazine/{$magazine->getId()}\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::MAGAZINE_RESPONSE_KEYS, $jsonData);\n\n        // Scopes for reading subscriptions and blocklists granted, so these values should be filled\n        self::assertFalse($jsonData['isUserSubscribed']);\n        self::assertTrue($jsonData['isBlockedByUser']);\n    }\n\n    public function testApiCanRetrieveMagazineCollectionAnonymous(): void\n    {\n        $magazine = $this->getMagazineByName('test');\n\n        $this->client->request('GET', '/api/magazines');\n\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(1, $jsonData['pagination']['count']);\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(1, $jsonData['items']);\n        self::assertArrayKeysMatch(self::MAGAZINE_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertSame($magazine->getId(), $jsonData['items'][0]['magazineId']);\n    }\n\n    public function testApiCanRetrieveMagazineCollection(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n        self::createOAuth2AuthCodeClient();\n\n        $magazine = $this->getMagazineByName('test');\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client);\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', '/api/magazines', server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(1, $jsonData['pagination']['count']);\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(1, $jsonData['items']);\n        self::assertArrayKeysMatch(self::MAGAZINE_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertSame($magazine->getId(), $jsonData['items'][0]['magazineId']);\n        // Scopes not granted\n        self::assertNull($jsonData['items'][0]['isUserSubscribed']);\n        self::assertNull($jsonData['items'][0]['isBlockedByUser']);\n    }\n\n    public function testApiCanRetrieveMagazineCollectionMultiplePages(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n        self::createOAuth2AuthCodeClient();\n\n        $magazines = [];\n        for ($i = 0; $i < self::MAGAZINE_COUNT; ++$i) {\n            $magazines[] = $this->getMagazineByNameNoRSAKey(\"test{$i}\");\n        }\n        $perPage = max((int) ceil(self::MAGAZINE_COUNT / 2), 1);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client);\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/magazines?perPage={$perPage}\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(self::MAGAZINE_COUNT, $jsonData['pagination']['count']);\n        self::assertSame($perPage, $jsonData['pagination']['perPage']);\n        self::assertSame(1, $jsonData['pagination']['currentPage']);\n        self::assertSame(2, $jsonData['pagination']['maxPage']);\n        self::assertIsArray($jsonData['items']);\n        self::assertCount($perPage, $jsonData['items']);\n        self::assertArrayKeysMatch(self::MAGAZINE_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertAllValuesFoundByName($magazines, $jsonData['items']);\n    }\n\n    public function testApiCannotRetrieveMagazineSubscriptionsAnonymous(): void\n    {\n        $this->client->request('GET', '/api/magazines/subscribed');\n\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotRetrieveMagazineSubscriptionsWithoutScope(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n        self::createOAuth2AuthCodeClient();\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client);\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', '/api/magazines/subscribed', server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanRetrieveMagazineSubscriptions(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n        self::createOAuth2AuthCodeClient();\n\n        $notSubbedMag = $this->getMagazineByName('someother', $this->getUserByUsername('JaneDoe'));\n        $magazine = $this->getMagazineByName('test');\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write magazine:subscribe');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', '/api/magazines/subscribed', server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(1, $jsonData['pagination']['count']);\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(1, $jsonData['items']);\n        self::assertArrayKeysMatch(self::MAGAZINE_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertSame($magazine->getId(), $jsonData['items'][0]['magazineId']);\n        // Block scope not granted\n        self::assertTrue($jsonData['items'][0]['isUserSubscribed']);\n        self::assertNull($jsonData['items'][0]['isBlockedByUser']);\n    }\n\n    public function testApiCannotRetrieveUserMagazineSubscriptionsAnonymous(): void\n    {\n        $user = $this->getUserByUsername('testUser');\n        $this->client->request('GET', \"/api/users/{$user->getId()}/magazines/subscriptions\");\n\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotRetrieveUserMagazineSubscriptionsWithoutScope(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n        self::createOAuth2AuthCodeClient();\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client);\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $user = $this->getUserByUsername('testUser');\n        $this->client->request('GET', \"/api/users/{$user->getId()}/magazines/subscriptions\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanRetrieveUserMagazineSubscriptions(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n        self::createOAuth2AuthCodeClient();\n\n        $user = $this->getUserByUsername('testUser');\n        $user->showProfileSubscriptions = true;\n        $entityManager = $this->entityManager;\n        $entityManager->persist($user);\n        $entityManager->flush();\n\n        $notSubbedMag = $this->getMagazineByName('someother', $this->getUserByUsername('JaneDoe'));\n        $magazine = $this->getMagazineByName('test', $user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write magazine:subscribe');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/users/{$user->getId()}/magazines/subscriptions\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(1, $jsonData['pagination']['count']);\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(1, $jsonData['items']);\n        self::assertArrayKeysMatch(self::MAGAZINE_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertSame($magazine->getId(), $jsonData['items'][0]['magazineId']);\n        // Block scope not granted\n        self::assertFalse($jsonData['items'][0]['isUserSubscribed']);\n        self::assertNull($jsonData['items'][0]['isBlockedByUser']);\n    }\n\n    public function testApiCannotRetrieveUserMagazineSubscriptionsIfSettingTurnedOff(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n        self::createOAuth2AuthCodeClient();\n\n        $user = $this->getUserByUsername('testUser');\n        $user->showProfileSubscriptions = false;\n        $entityManager = $this->entityManager;\n        $entityManager->persist($user);\n        $entityManager->flush();\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write magazine:subscribe');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/users/{$user->getId()}/magazines/subscriptions\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCannotRetrieveModeratedMagazinesAnonymous(): void\n    {\n        $this->client->request('GET', '/api/magazines/moderated');\n\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotRetrieveModeratedMagazinesWithoutScope(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n        self::createOAuth2AuthCodeClient();\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client);\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', '/api/magazines/moderated', server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanRetrieveModeratedMagazines(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n        self::createOAuth2AuthCodeClient();\n\n        $notModdedMag = $this->getMagazineByName('someother', $this->getUserByUsername('JaneDoe'));\n        $magazine = $this->getMagazineByName('test');\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write moderate:magazine:list');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', '/api/magazines/moderated', server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(1, $jsonData['pagination']['count']);\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(1, $jsonData['items']);\n        self::assertArrayKeysMatch(self::MAGAZINE_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertSame($magazine->getId(), $jsonData['items'][0]['magazineId']);\n        // Subscribe and block scopes not granted\n        self::assertNull($jsonData['items'][0]['isUserSubscribed']);\n        self::assertNull($jsonData['items'][0]['isBlockedByUser']);\n    }\n\n    public function testApiCannotRetrieveBlockedMagazinesAnonymous(): void\n    {\n        $this->client->request('GET', '/api/magazines/blocked');\n\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotRetrieveBlockedMagazinesWithoutScope(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n        self::createOAuth2AuthCodeClient();\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client);\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', '/api/magazines/blocked', server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanRetrieveBlockedMagazines(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n        self::createOAuth2AuthCodeClient();\n\n        $notBlockedMag = $this->getMagazineByName('someother', $this->getUserByUsername('JaneDoe'));\n        $magazine = $this->getMagazineByName('test', $this->getUserByUsername('JaneDoe'));\n\n        $manager = $this->magazineManager;\n        $manager->block($magazine, $this->getUserByUsername('JohnDoe'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write magazine:block');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', '/api/magazines/blocked', server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(1, $jsonData['pagination']['count']);\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(1, $jsonData['items']);\n        self::assertArrayKeysMatch(self::MAGAZINE_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertSame($magazine->getId(), $jsonData['items'][0]['magazineId']);\n        // Subscribe and block scopes not granted\n        self::assertNull($jsonData['items'][0]['isUserSubscribed']);\n        self::assertTrue($jsonData['items'][0]['isBlockedByUser']);\n    }\n\n    public function testApiCanRetrieveAbandonedMagazine(): void\n    {\n        $abandoningUser = $this->getUserByUsername('JohnDoe');\n        $activeUser = $this->getUserByUsername('DoeJohn');\n        $magazine1 = $this->getMagazineByName('test1', $abandoningUser);\n        $magazine2 = $this->getMagazineByName('test2', $abandoningUser);\n        $magazine3 = $this->getMagazineByName('test3', $activeUser);\n\n        $abandoningUser->lastActive = new \\DateTime('-6 months');\n        $activeUser->lastActive = new \\DateTime('-2 days');\n        $this->userRepository->save($abandoningUser, true);\n        $this->userRepository->save($activeUser, true);\n\n        $this->client->request('GET', '/api/magazines?abandoned=true&federation=local');\n\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(2, $jsonData['pagination']['count']);\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(2, $jsonData['items']);\n        self::assertArrayKeysMatch(self::MAGAZINE_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertSame($magazine1->getId(), $jsonData['items'][0]['magazineId']);\n        self::assertSame($magazine2->getId(), $jsonData['items'][1]['magazineId']);\n    }\n\n    public function testApiCanRetrieveAbandonedMagazineSortedByOwner(): void\n    {\n        $abandoningUser1 = $this->getUserByUsername('user1');\n        $abandoningUser2 = $this->getUserByUsername('user2');\n        $abandoningUser3 = $this->getUserByUsername('user3');\n        $magazine1 = $this->getMagazineByName('test1', $abandoningUser1);\n        $magazine2 = $this->getMagazineByName('test2', $abandoningUser2);\n        $magazine3 = $this->getMagazineByName('test3', $abandoningUser3);\n\n        $abandoningUser1->lastActive = new \\DateTime('-6 months');\n        $abandoningUser2->lastActive = new \\DateTime('-5 months');\n        $abandoningUser3->lastActive = new \\DateTime('-7 months');\n        $this->userRepository->save($abandoningUser1, true);\n        $this->userRepository->save($abandoningUser2, true);\n        $this->userRepository->save($abandoningUser3, true);\n\n        $this->client->request('GET', '/api/magazines?abandoned=true&federation=local&sort=ownerLastActive');\n\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(3, $jsonData['pagination']['count']);\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(3, $jsonData['items']);\n        self::assertArrayKeysMatch(self::MAGAZINE_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertSame($magazine1->getId(), $jsonData['items'][1]['magazineId']);\n        self::assertSame($magazine2->getId(), $jsonData['items'][2]['magazineId']);\n        self::assertSame($magazine3->getId(), $jsonData['items'][0]['magazineId']);\n    }\n\n    public static function assertAllValuesFoundByName(array $magazines, array $values, string $message = '')\n    {\n        $nameMap = array_column($magazines, null, 'name');\n        $containsMagazine = fn (bool $result, array $item) => $result && null !== $nameMap[$item['name']];\n        self::assertTrue(array_reduce($values, $containsMagazine, true), $message);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/Magazine/MagazineRetrieveThemeApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\Magazine;\n\nuse App\\Tests\\WebTestCase;\n\nclass MagazineRetrieveThemeApiTest extends WebTestCase\n{\n    public const MAGAZINE_THEME_RESPONSE_KEYS = ['magazine', 'customCss', 'icon', 'banner'];\n\n    public function testApiCanRetrieveMagazineThemeByIdAnonymously(): void\n    {\n        $magazine = $this->getMagazineByName('test');\n        $magazine->customCss = '.test {}';\n        $entityManager = $this->entityManager;\n\n        $entityManager->persist($magazine);\n        $entityManager->flush();\n\n        $this->client->request('GET', '/api/magazine/'.(string) $magazine->getId().'/theme');\n\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::MAGAZINE_THEME_RESPONSE_KEYS, $jsonData);\n        self::assertIsArray($jsonData['magazine']);\n        self::assertArrayKeysMatch(MagazineRetrieveApiTest::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']);\n        self::assertSame($magazine->getId(), $jsonData['magazine']['magazineId']);\n        self::assertEquals('.test {}', $jsonData['customCss']);\n    }\n\n    public function testApiCanRetrieveMagazineThemeById(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n        self::createOAuth2AuthCodeClient();\n\n        $magazine = $this->getMagazineByName('test');\n        $magazine->customCss = '.test {}';\n        $entityManager = $this->entityManager;\n\n        $entityManager->persist($magazine);\n        $entityManager->flush();\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client);\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', '/api/magazine/'.(string) $magazine->getId().'/theme', server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::MAGAZINE_THEME_RESPONSE_KEYS, $jsonData);\n        self::assertIsArray($jsonData['magazine']);\n        self::assertArrayKeysMatch(MagazineRetrieveApiTest::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']);\n        self::assertSame($magazine->getId(), $jsonData['magazine']['magazineId']);\n        self::assertEquals('.test {}', $jsonData['customCss']);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/Magazine/MagazineSubscribeApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\Magazine;\n\nuse App\\Tests\\WebTestCase;\n\nclass MagazineSubscribeApiTest extends WebTestCase\n{\n    public function testApiCannotSubscribeToMagazineAnonymously(): void\n    {\n        $magazine = $this->getMagazineByName('test');\n\n        $this->client->request('PUT', '/api/magazine/'.(string) $magazine->getId().'/subscribe');\n\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotSubscribeToMagazineWithoutScope(): void\n    {\n        $user = $this->getUserByUsername('testuser');\n        $this->client->loginUser($user);\n        self::createOAuth2AuthCodeClient();\n\n        $magazine = $this->getMagazineByName('test');\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write magazine:block');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('PUT', '/api/magazine/'.(string) $magazine->getId().'/subscribe', server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanSubscribeToMagazine(): void\n    {\n        $user = $this->getUserByUsername('testuser');\n        $this->client->loginUser($user);\n        self::createOAuth2AuthCodeClient();\n\n        $magazine = $this->getMagazineByName('test');\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write magazine:subscribe magazine:block');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('PUT', '/api/magazine/'.(string) $magazine->getId().'/subscribe', server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(MagazineRetrieveApiTest::MAGAZINE_RESPONSE_KEYS, $jsonData);\n\n        // Scopes for reading subscriptions and blocklists granted, so these values should be filled\n        self::assertTrue($jsonData['isUserSubscribed']);\n        self::assertFalse($jsonData['isBlockedByUser']);\n\n        $this->client->request('GET', '/api/magazine/'.(string) $magazine->getId(), server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(MagazineRetrieveApiTest::MAGAZINE_RESPONSE_KEYS, $jsonData);\n\n        // Scopes for reading subscriptions and blocklists granted, so these values should be filled\n        self::assertTrue($jsonData['isUserSubscribed']);\n        self::assertFalse($jsonData['isBlockedByUser']);\n    }\n\n    public function testApiCannotUnsubscribeFromMagazineAnonymously(): void\n    {\n        $magazine = $this->getMagazineByName('test');\n\n        $this->client->request('PUT', '/api/magazine/'.(string) $magazine->getId().'/unsubscribe');\n\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotUnsubscribeFromMagazineWithoutScope(): void\n    {\n        $user = $this->getUserByUsername('testuser');\n        $this->client->loginUser($user);\n        self::createOAuth2AuthCodeClient();\n\n        $magazine = $this->getMagazineByName('test');\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write magazine:block');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('PUT', '/api/magazine/'.(string) $magazine->getId().'/unsubscribe', server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanUnsubscribeFromMagazine(): void\n    {\n        $user = $this->getUserByUsername('testuser');\n        $this->client->loginUser($user);\n        self::createOAuth2AuthCodeClient();\n\n        $magazine = $this->getMagazineByName('test');\n        $manager = $this->magazineManager;\n        $manager->subscribe($magazine, $user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write magazine:subscribe');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('PUT', '/api/magazine/'.(string) $magazine->getId().'/unsubscribe', server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(MagazineRetrieveApiTest::MAGAZINE_RESPONSE_KEYS, $jsonData);\n\n        // Scopes for reading subscriptions and blocklists granted, so these values should be filled\n        self::assertFalse($jsonData['isUserSubscribed']);\n        self::assertNull($jsonData['isBlockedByUser']);\n\n        $this->client->request('GET', '/api/magazine/'.(string) $magazine->getId(), server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(MagazineRetrieveApiTest::MAGAZINE_RESPONSE_KEYS, $jsonData);\n\n        // Scopes for reading subscriptions and blocklists granted, so these values should be filled\n        self::assertFalse($jsonData['isUserSubscribed']);\n        self::assertNull($jsonData['isBlockedByUser']);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/Magazine/Moderate/MagazineActionReportsApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\Magazine\\Moderate;\n\nuse App\\DTO\\ReportDto;\nuse App\\Tests\\Functional\\Controller\\Api\\Magazine\\MagazineRetrieveApiTest;\nuse App\\Tests\\WebTestCase;\nuse PHPUnit\\Framework\\Attributes\\Group;\n\nclass MagazineActionReportsApiTest extends WebTestCase\n{\n    public function testApiCannotAcceptReportAnonymous(): void\n    {\n        $magazine = $this->getMagazineByName('test');\n        $user = $this->getUserByUsername('JohnDoe');\n        $reportedUser = $this->getUserByUsername('testuser');\n        $entry = $this->getEntryByTitle('Report test', body: 'This is gonna be reported', magazine: $magazine, user: $reportedUser);\n\n        $reportManager = $this->reportManager;\n        $report = $reportManager->report(ReportDto::create($entry, 'I don\\'t like it'), $user);\n        $this->client->request('POST', \"/api/moderate/magazine/{$magazine->getId()}/reports/{$report->getId()}/accept\");\n\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotRejectReportAnonymous(): void\n    {\n        $magazine = $this->getMagazineByName('test');\n        $user = $this->getUserByUsername('JohnDoe');\n        $reportedUser = $this->getUserByUsername('testuser');\n        $entry = $this->getEntryByTitle('Report test', body: 'This is gonna be reported', magazine: $magazine, user: $reportedUser);\n\n        $reportManager = $this->reportManager;\n        $report = $reportManager->report(ReportDto::create($entry, 'I don\\'t like it'), $user);\n        $this->client->request('POST', \"/api/moderate/magazine/{$magazine->getId()}/reports/{$report->getId()}/reject\");\n\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotAcceptReportWithoutScope(): void\n    {\n        $user = $this->getUserByUsername('JohnDoe');\n        $this->client->loginUser($user);\n        self::createOAuth2AuthCodeClient();\n        $magazine = $this->getMagazineByName('test');\n        $reportedUser = $this->getUserByUsername('testuser');\n        $entry = $this->getEntryByTitle('Report test', body: 'This is gonna be reported', magazine: $magazine, user: $reportedUser);\n\n        $reportManager = $this->reportManager;\n        $report = $reportManager->report(ReportDto::create($entry, 'I don\\'t like it'), $user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client);\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('POST', \"/api/moderate/magazine/{$magazine->getId()}/reports/{$report->getId()}/accept\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCannotRejectReportWithoutScope(): void\n    {\n        $user = $this->getUserByUsername('JohnDoe');\n        $this->client->loginUser($user);\n        self::createOAuth2AuthCodeClient();\n        $magazine = $this->getMagazineByName('test');\n        $reportedUser = $this->getUserByUsername('testuser');\n        $entry = $this->getEntryByTitle('Report test', body: 'This is gonna be reported', magazine: $magazine, user: $reportedUser);\n\n        $reportManager = $this->reportManager;\n        $report = $reportManager->report(ReportDto::create($entry, 'I don\\'t like it'), $user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client);\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('POST', \"/api/moderate/magazine/{$magazine->getId()}/reports/{$report->getId()}/reject\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCannotAcceptReportIfNotMod(): void\n    {\n        $user = $this->getUserByUsername('JohnDoe');\n        $this->client->loginUser($user);\n        self::createOAuth2AuthCodeClient();\n        $magazine = $this->getMagazineByName('test', $this->getUserByUsername('JaneDoe'));\n        $reportedUser = $this->getUserByUsername('testuser');\n        $entry = $this->getEntryByTitle('Report test', body: 'This is gonna be reported', magazine: $magazine, user: $reportedUser);\n\n        $reportManager = $this->reportManager;\n        $report = $reportManager->report(ReportDto::create($entry, 'I don\\'t like it'), $user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write moderate:magazine:reports:action');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('POST', \"/api/moderate/magazine/{$magazine->getId()}/reports/{$report->getId()}/accept\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCannotRejectReportIfNotMod(): void\n    {\n        $user = $this->getUserByUsername('JohnDoe');\n        $this->client->loginUser($user);\n        self::createOAuth2AuthCodeClient();\n        $magazine = $this->getMagazineByName('test', $this->getUserByUsername('JaneDoe'));\n        $reportedUser = $this->getUserByUsername('testuser');\n        $entry = $this->getEntryByTitle('Report test', body: 'This is gonna be reported', magazine: $magazine, user: $reportedUser);\n\n        $reportManager = $this->reportManager;\n        $report = $reportManager->report(ReportDto::create($entry, 'I don\\'t like it'), $user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write moderate:magazine:reports:action');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('POST', \"/api/moderate/magazine/{$magazine->getId()}/reports/{$report->getId()}/reject\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    #[Group(name: 'NonThreadSafe')]\n    public function testApiCanAcceptReport(): void\n    {\n        $user = $this->getUserByUsername('JohnDoe');\n        $this->client->loginUser($user);\n        self::createOAuth2AuthCodeClient();\n        $magazine = $this->getMagazineByName('test');\n        $reportedUser = $this->getUserByUsername('testuser');\n        $entry = $this->getEntryByTitle('Report test', body: 'This is gonna be reported', magazine: $magazine, user: $reportedUser);\n\n        $reportManager = $this->reportManager;\n        $report = $reportManager->report(ReportDto::create($entry, 'I don\\'t like it'), $user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write moderate:magazine:reports:action');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n        $consideredAt = new \\DateTimeImmutable();\n\n        $this->client->jsonRequest('POST', \"/api/moderate/magazine/{$magazine->getId()}/reports/{$report->getId()}/accept\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(MagazineRetrieveReportsApiTest::REPORT_RESPONSE_KEYS, $jsonData);\n        self::assertEquals('entry_report', $jsonData['type']);\n        self::assertEquals($report->reason, $jsonData['reason']);\n        self::assertArrayKeysMatch(MagazineRetrieveApiTest::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']);\n        self::assertSame($magazine->getId(), $jsonData['magazine']['magazineId']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['reported']);\n        self::assertSame($reportedUser->getId(), $jsonData['reported']['userId']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['reporting']);\n        self::assertSame($user->getId(), $jsonData['reporting']['userId']);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['subject']);\n        self::assertEquals($entry->getId(), $jsonData['subject']['entryId']);\n        self::assertEquals('trashed', $jsonData['subject']['visibility']);\n        self::assertEquals($entry->body, $jsonData['subject']['body']);\n        self::assertEquals('approved', $jsonData['status']);\n        self::assertSame(1, $jsonData['weight']);\n        self::assertEqualsWithDelta($report->createdAt->getTimestamp(), \\DateTimeImmutable::createFromFormat(\\DateTimeImmutable::ATOM, $jsonData['createdAt'])->getTimestamp(), 10.0);\n        self::assertEqualsWithDelta($consideredAt->getTimestamp(), \\DateTimeImmutable::createFromFormat(\\DateTimeImmutable::ATOM, $jsonData['consideredAt'])->getTimestamp(), 10.0);\n        self::assertNotNull($jsonData['consideredBy']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['consideredBy']);\n        self::assertSame($user->getId(), $jsonData['consideredBy']['userId']);\n    }\n\n    public function testApiCanRejectReport(): void\n    {\n        $user = $this->getUserByUsername('JohnDoe');\n        $this->client->loginUser($user);\n        self::createOAuth2AuthCodeClient();\n        $magazine = $this->getMagazineByName('test');\n        $reportedUser = $this->getUserByUsername('testuser');\n        $entry = $this->getEntryByTitle('Report test', body: 'This is gonna be reported', magazine: $magazine, user: $reportedUser);\n\n        $reportManager = $this->reportManager;\n        $report = $reportManager->report(ReportDto::create($entry, 'I don\\'t like it'), $user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write moderate:magazine:reports:action');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('POST', \"/api/moderate/magazine/{$magazine->getId()}/reports/{$report->getId()}/reject\", server: ['HTTP_AUTHORIZATION' => $token]);\n        $consideredAt = new \\DateTimeImmutable();\n        $adjustedConsideredAt = floor($consideredAt->getTimestamp() / 1000);\n\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n        $adjustedReceivedConsideredAt = floor(\\DateTimeImmutable::createFromFormat(\\DateTimeImmutable::ATOM, $jsonData['createdAt'])->getTimestamp() / 1000);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(MagazineRetrieveReportsApiTest::REPORT_RESPONSE_KEYS, $jsonData);\n        self::assertEquals('entry_report', $jsonData['type']);\n        self::assertEquals($report->reason, $jsonData['reason']);\n        self::assertArrayKeysMatch(MagazineRetrieveApiTest::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']);\n        self::assertSame($magazine->getId(), $jsonData['magazine']['magazineId']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['reported']);\n        self::assertSame($reportedUser->getId(), $jsonData['reported']['userId']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['reporting']);\n        self::assertSame($user->getId(), $jsonData['reporting']['userId']);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['subject']);\n        self::assertEquals($entry->getId(), $jsonData['subject']['entryId']);\n        self::assertEquals('visible', $jsonData['subject']['visibility']);\n        self::assertEquals($entry->body, $jsonData['subject']['body']);\n        self::assertEquals('rejected', $jsonData['status']);\n        self::assertSame(1, $jsonData['weight']);\n        self::assertEqualsWithDelta($report->createdAt->getTimestamp(), \\DateTimeImmutable::createFromFormat(\\DateTimeImmutable::ATOM, $jsonData['createdAt'])->getTimestamp(), 10.0);\n        self::assertEqualsWithDelta($adjustedConsideredAt, $adjustedReceivedConsideredAt, 10.0);\n        self::assertNotNull($jsonData['consideredBy']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['consideredBy']);\n        self::assertSame($user->getId(), $jsonData['consideredBy']['userId']);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/Magazine/Moderate/MagazineBanApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\Magazine\\Moderate;\n\nuse App\\DTO\\MagazineBanDto;\nuse App\\Tests\\Functional\\Controller\\Api\\Magazine\\MagazineRetrieveApiTest;\nuse App\\Tests\\WebTestCase;\n\nclass MagazineBanApiTest extends WebTestCase\n{\n    public function testApiCannotCreateMagazineBanAnonymous(): void\n    {\n        $magazine = $this->getMagazineByName('test');\n        $user = $this->getUserByUsername('testuser');\n        $this->client->request('POST', \"/api/moderate/magazine/{$magazine->getId()}/ban/{$user->getId()}\");\n\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotCreateMagazineBanWithoutScope(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n        self::createOAuth2AuthCodeClient();\n        $magazine = $this->getMagazineByName('test');\n        $user = $this->getUserByUsername('testuser');\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client);\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('POST', \"/api/moderate/magazine/{$magazine->getId()}/ban/{$user->getId()}\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCannotCreateMagazineBanIfNotMod(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n        self::createOAuth2AuthCodeClient();\n        $magazine = $this->getMagazineByName('test', $this->getUserByUsername('JaneDoe'));\n        $user = $this->getUserByUsername('testuser');\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write moderate:magazine:ban:create');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('POST', \"/api/moderate/magazine/{$magazine->getId()}/ban/{$user->getId()}\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanCreateMagazineBan(): void\n    {\n        $user = $this->getUserByUsername('JohnDoe');\n        $this->client->loginUser($user);\n        self::createOAuth2AuthCodeClient();\n        $magazine = $this->getMagazineByName('test');\n\n        $bannedUser = $this->getUserByUsername('hapless_fool');\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write moderate:magazine:ban:create');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $reason = 'you got banned through the API, how does that make you feel?';\n        $expiredAt = (new \\DateTimeImmutable('+1 hour'))->format(\\DateTimeImmutable::ATOM);\n\n        $this->client->jsonRequest(\n            'POST', \"/api/moderate/magazine/{$magazine->getId()}/ban/{$bannedUser->getId()}\",\n            parameters: [\n                'reason' => $reason,\n                'expiredAt' => $expiredAt,\n            ],\n            server: ['HTTP_AUTHORIZATION' => $token]\n        );\n\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(MagazineRetrieveBansApiTest::BAN_RESPONSE_KEYS, $jsonData);\n        self::assertEquals($reason, $jsonData['reason']);\n        self::assertArrayKeysMatch(MagazineRetrieveApiTest::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']);\n        self::assertSame($magazine->getId(), $jsonData['magazine']['magazineId']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['bannedUser']);\n        self::assertSame($bannedUser->getId(), $jsonData['bannedUser']['userId']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['bannedBy']);\n        self::assertSame($user->getId(), $jsonData['bannedBy']['userId']);\n        self::assertEquals($expiredAt, $jsonData['expiredAt']);\n        self::assertFalse($jsonData['expired']);\n    }\n\n    public function testApiCannotDeleteMagazineBanAnonymous(): void\n    {\n        $magazine = $this->getMagazineByName('test');\n        $user = $this->getUserByUsername('testuser');\n        $this->client->request('DELETE', \"/api/moderate/magazine/{$magazine->getId()}/ban/{$user->getId()}\");\n\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotDeleteMagazineBanWithoutScope(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n        self::createOAuth2AuthCodeClient();\n        $magazine = $this->getMagazineByName('test');\n        $user = $this->getUserByUsername('testuser');\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client);\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('DELETE', \"/api/moderate/magazine/{$magazine->getId()}/ban/{$user->getId()}\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCannotDeleteMagazineBanIfNotMod(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n        self::createOAuth2AuthCodeClient();\n        $magazine = $this->getMagazineByName('test', $this->getUserByUsername('JaneDoe'));\n        $user = $this->getUserByUsername('testuser');\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write moderate:magazine:ban:delete');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('DELETE', \"/api/moderate/magazine/{$magazine->getId()}/ban/{$user->getId()}\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanDeleteMagazineBan(): void\n    {\n        $user = $this->getUserByUsername('JohnDoe');\n        $this->client->loginUser($user);\n        self::createOAuth2AuthCodeClient();\n        $magazine = $this->getMagazineByName('test');\n        $bannedUser = $this->getUserByUsername('hapless_fool');\n\n        $magazineManager = $this->magazineManager;\n        $ban = MagazineBanDto::create('test ban <3');\n        $magazineManager->ban($magazine, $bannedUser, $user, $ban);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write moderate:magazine:ban:delete');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $expiredAt = new \\DateTimeImmutable();\n\n        $this->client->request('DELETE', \"/api/moderate/magazine/{$magazine->getId()}/ban/{$bannedUser->getId()}\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(MagazineRetrieveBansApiTest::BAN_RESPONSE_KEYS, $jsonData);\n        self::assertEquals($ban->reason, $jsonData['reason']);\n        self::assertArrayKeysMatch(MagazineRetrieveApiTest::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']);\n        self::assertSame($magazine->getId(), $jsonData['magazine']['magazineId']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['bannedUser']);\n        self::assertSame($bannedUser->getId(), $jsonData['bannedUser']['userId']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['bannedBy']);\n        self::assertSame($user->getId(), $jsonData['bannedBy']['userId']);\n\n        $actualExpiry = \\DateTimeImmutable::createFromFormat(\\DateTimeImmutable::ATOM, $jsonData['expiredAt']);\n        // Hopefully the API responds fast enough that there is only a max delta of 10 second between these two timestamps\n        self::assertEqualsWithDelta($expiredAt->getTimestamp(), $actualExpiry->getTimestamp(), 10.0);\n        self::assertTrue($jsonData['expired']);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/Magazine/Moderate/MagazineModOwnerRequestApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\Magazine\\Moderate;\n\nuse App\\Entity\\MagazineOwnershipRequest;\nuse App\\Entity\\ModeratorRequest;\nuse App\\Tests\\WebTestCase;\n\nclass MagazineModOwnerRequestApiTest extends WebTestCase\n{\n    public function testApiCannotToggleModRequestAnonymously(): void\n    {\n        $magazine = $this->getMagazineByName('test');\n\n        $this->client->request('PUT', \"/api/moderate/magazine/{$magazine->getId()}/modRequest/toggle\");\n\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotToggleModRequestWithoutScope(): void\n    {\n        $magazine = $this->getMagazineByName('test');\n\n        $user = $this->getUserByUsername('JohnDoe');\n        $this->client->loginUser($user);\n        self::createOAuth2AuthCodeClient();\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client);\n        $token = $codes['token_type'].' '.$codes['access_token'];\n        $this->client->request('PUT', \"/api/moderate/magazine/{$magazine->getId()}/modRequest/toggle\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanToggleModRequest(): void\n    {\n        $magazine = $this->getMagazineByName('test');\n\n        $user = $this->getUserByUsername('JohnDoe');\n        $this->client->loginUser($user);\n        self::createOAuth2AuthCodeClient();\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'magazine:subscribe');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('PUT', \"/api/moderate/magazine/{$magazine->getId()}/modRequest/toggle\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(['created'], $jsonData);\n        self::assertTrue($jsonData['created']);\n\n        $this->client->request('PUT', \"/api/moderate/magazine/{$magazine->getId()}/modRequest/toggle\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(['created'], $jsonData);\n        self::assertFalse($jsonData['created']);\n    }\n\n    public function testApiCannotAcceptModRequestAnonymously(): void\n    {\n        $magazine = $this->getMagazineByName('test');\n        $user = $this->getUserByUsername('JohnDoe');\n        $this->magazineManager->toggleModeratorRequest($magazine, $user);\n\n        $this->client->request('PUT', \"/api/moderate/magazine/{$magazine->getId()}/modRequest/accept/{$user->getId()}\");\n\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotAcceptModRequestWithoutScope(): void\n    {\n        $magazine = $this->getMagazineByName('test');\n        $user = $this->getUserByUsername('JohnDoe');\n        $this->magazineManager->toggleModeratorRequest($magazine, $user);\n\n        $adminUser = $this->getUserByUsername('Admin');\n        $this->setAdmin($adminUser);\n        $this->client->loginUser($adminUser);\n        self::createOAuth2AuthCodeClient();\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client);\n        $token = $codes['token_type'].' '.$codes['access_token'];\n        $this->client->request('PUT', \"/api/moderate/magazine/{$magazine->getId()}/modRequest/accept/{$user->getId()}\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanAcceptModRequest(): void\n    {\n        $magazine = $this->getMagazineByName('test');\n        $user = $this->getUserByUsername('JohnDoeTheSecond');\n        $this->magazineManager->toggleModeratorRequest($magazine, $user);\n\n        $adminUser = $this->getUserByUsername('Admin');\n        $this->setAdmin($adminUser);\n        $this->client->loginUser($adminUser);\n        self::createOAuth2AuthCodeClient();\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'admin:magazine:moderate');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('PUT', \"/api/moderate/magazine/{$magazine->getId()}/modRequest/accept/{$user->getId()}\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(204);\n        self::assertSame('', $this->client->getResponse()->getContent());\n\n        $modRequest = $this->entityManager->getRepository(ModeratorRequest::class)->findOneBy([\n            'magazine' => $magazine,\n            'user' => $user,\n        ]);\n        self::assertNull($modRequest);\n\n        $magazine = $this->magazineRepository->findOneBy(['id' => $magazine->getId()]);\n        $user = $this->userRepository->findOneBy(['id' => $user->getId()]);\n        self::assertTrue($magazine->userIsModerator($user));\n    }\n\n    public function testApiCanRejectModRequest(): void\n    {\n        $magazine = $this->getMagazineByName('test');\n        $user = $this->getUserByUsername('JohnDoeTheSecond');\n        $this->magazineManager->toggleModeratorRequest($magazine, $user);\n\n        $adminUser = $this->getUserByUsername('Admin');\n        $this->setAdmin($adminUser);\n        $this->client->loginUser($adminUser);\n        self::createOAuth2AuthCodeClient();\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'admin:magazine:moderate');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('PUT', \"/api/moderate/magazine/{$magazine->getId()}/modRequest/reject/{$user->getId()}\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(204);\n        self::assertSame('', $this->client->getResponse()->getContent());\n\n        $modRequest = $this->entityManager->getRepository(ModeratorRequest::class)->findOneBy([\n            'magazine' => $magazine,\n            'user' => $user,\n        ]);\n        self::assertNull($modRequest);\n\n        $magazine = $this->magazineRepository->findOneBy(['id' => $magazine->getId()]);\n        $user = $this->userRepository->findOneBy(['id' => $user->getId()]);\n        self::assertFalse($magazine->userIsModerator($user));\n    }\n\n    public function testApiCannotListModRequestsAnonymously(): void\n    {\n        $this->client->request('GET', '/api/moderate/modRequest/list');\n\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotListModRequestsWithoutScope(): void\n    {\n        $user = $this->getUserByUsername('JohnDoe');\n        $this->client->loginUser($user);\n        self::createOAuth2AuthCodeClient();\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client);\n        $token = $codes['token_type'].' '.$codes['access_token'];\n        $this->client->request('GET', '/api/moderate/modRequest/list', server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCannotListModRequestsForInvalidMagazineId(): void\n    {\n        $adminUser = $this->getUserByUsername('Admin');\n        $this->setAdmin($adminUser);\n        $this->client->loginUser($adminUser);\n        self::createOAuth2AuthCodeClient();\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'admin:magazine:moderate');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', '/api/moderate/modRequest/list?magazine=a', server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(404);\n    }\n\n    public function testApiCannotListModRequestsForMissingMagazine(): void\n    {\n        $adminUser = $this->getUserByUsername('Admin');\n        $this->setAdmin($adminUser);\n        $this->client->loginUser($adminUser);\n        self::createOAuth2AuthCodeClient();\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'admin:magazine:moderate');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', '/api/moderate/modRequest/list?magazine=99', server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(404);\n    }\n\n    public function testApiCanListModRequestsForMagazine(): void\n    {\n        $magazine1 = $this->getMagazineByName('Magazine 1');\n        $magazine2 = $this->getMagazineByName('Magazine 2');\n        $magazine3 = $this->getMagazineByName('Magazine 3');\n        $user1 = $this->getUserByUsername('User 1');\n        $user2 = $this->getUserByUsername('User 2');\n\n        $this->magazineManager->toggleModeratorRequest($magazine1, $user1);\n        $this->magazineManager->toggleModeratorRequest($magazine1, $user2);\n        $this->magazineManager->toggleModeratorRequest($magazine2, $user2);\n\n        $adminUser = $this->getUserByUsername('Admin');\n        $this->setAdmin($adminUser);\n        $this->client->loginUser($adminUser);\n        self::createOAuth2AuthCodeClient();\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'admin:magazine:moderate');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/moderate/modRequest/list?magazine={$magazine1->getId()}\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n        self::assertIsArray($jsonData);\n\n        self::assertCount(2, $jsonData);\n        self::assertTrue(array_all($jsonData, function ($item) use ($magazine1, $user1, $user2) {\n            return $item['magazine']['magazineId'] === $magazine1->getId()\n                && ($item['user']['userId'] === $user1->getId() || $item['user']['userId'] === $user2->getId());\n        }));\n        self::assertNotSame($jsonData[0]['user']['userId'], $jsonData[1]['user']['userId']);\n    }\n\n    public function testApiCanListModRequestsForAllMagazines(): void\n    {\n        $magazine1 = $this->getMagazineByName('Magazine 1');\n        $magazine2 = $this->getMagazineByName('Magazine 2');\n        $magazine3 = $this->getMagazineByName('Magazine 3');\n        $user1 = $this->getUserByUsername('User 1');\n        $user2 = $this->getUserByUsername('User 2');\n\n        $this->magazineManager->toggleModeratorRequest($magazine1, $user1);\n        $this->magazineManager->toggleModeratorRequest($magazine1, $user2);\n        $this->magazineManager->toggleModeratorRequest($magazine2, $user2);\n\n        $adminUser = $this->getUserByUsername('Admin');\n        $this->setAdmin($adminUser);\n        $this->client->loginUser($adminUser);\n        self::createOAuth2AuthCodeClient();\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'admin:magazine:moderate');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', '/api/moderate/modRequest/list', server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n        self::assertIsArray($jsonData);\n\n        self::assertCount(3, $jsonData);\n        self::assertTrue(array_all($jsonData, function ($item) use ($magazine1, $magazine2, $user1, $user2) {\n            return ($item['magazine']['magazineId'] === $magazine1->getId() && $item['user']['userId'] === $user1->getId())\n                || ($item['magazine']['magazineId'] === $magazine1->getId() && $item['user']['userId'] === $user2->getId())\n                || ($item['magazine']['magazineId'] === $magazine2->getId() && $item['user']['userId'] === $user2->getId());\n        }));\n    }\n\n    public function testApiCannotToggleOwnerRequestAnonymously(): void\n    {\n        $magazine = $this->getMagazineByName('test');\n\n        $this->client->request('PUT', \"/api/moderate/magazine/{$magazine->getId()}/ownerRequest/toggle\");\n\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotToggleOwnerRequestWithoutScope(): void\n    {\n        $magazine = $this->getMagazineByName('test');\n\n        $user = $this->getUserByUsername('JohnDoe');\n        $this->client->loginUser($user);\n        self::createOAuth2AuthCodeClient();\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client);\n        $token = $codes['token_type'].' '.$codes['access_token'];\n        $this->client->request('PUT', \"/api/moderate/magazine/{$magazine->getId()}/ownerRequest/toggle\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanToggleOwnerRequest(): void\n    {\n        $magazine = $this->getMagazineByName('test');\n\n        $user = $this->getUserByUsername('JohnDoe');\n        $this->client->loginUser($user);\n        self::createOAuth2AuthCodeClient();\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'magazine:subscribe');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('PUT', \"/api/moderate/magazine/{$magazine->getId()}/ownerRequest/toggle\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(['created'], $jsonData);\n        self::assertTrue($jsonData['created']);\n\n        $this->client->request('PUT', \"/api/moderate/magazine/{$magazine->getId()}/ownerRequest/toggle\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(['created'], $jsonData);\n        self::assertFalse($jsonData['created']);\n    }\n\n    public function testApiCannotAcceptOwnerRequestAnonymously(): void\n    {\n        $magazine = $this->getMagazineByName('test');\n        $user = $this->getUserByUsername('JohnDoe');\n        $this->magazineManager->toggleModeratorRequest($magazine, $user);\n\n        $this->client->request('PUT', \"/api/moderate/magazine/{$magazine->getId()}/ownerRequest/accept/{$user->getId()}\");\n\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotAcceptOwnerRequestWithoutScope(): void\n    {\n        $magazine = $this->getMagazineByName('test');\n        $user = $this->getUserByUsername('JohnDoe');\n        $this->magazineManager->toggleModeratorRequest($magazine, $user);\n\n        $adminUser = $this->getUserByUsername('Admin');\n        $this->setAdmin($adminUser);\n        $this->client->loginUser($adminUser);\n        self::createOAuth2AuthCodeClient();\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client);\n        $token = $codes['token_type'].' '.$codes['access_token'];\n        $this->client->request('PUT', \"/api/moderate/magazine/{$magazine->getId()}/ownerRequest/accept/{$user->getId()}\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanAcceptOwnerRequest(): void\n    {\n        $magazine = $this->getMagazineByName('test');\n        $user = $this->getUserByUsername('JohnDoeTheSecond');\n        $this->magazineManager->toggleOwnershipRequest($magazine, $user);\n\n        $adminUser = $this->getUserByUsername('Admin');\n        $this->setAdmin($adminUser);\n        $this->client->loginUser($adminUser);\n        self::createOAuth2AuthCodeClient();\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'admin:magazine:moderate');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('PUT', \"/api/moderate/magazine/{$magazine->getId()}/ownerRequest/accept/{$user->getId()}\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(204);\n        self::assertSame('', $this->client->getResponse()->getContent());\n\n        $ownerRequest = $this->entityManager->getRepository(MagazineOwnershipRequest::class)->findOneBy([\n            'magazine' => $magazine,\n            'user' => $user,\n        ]);\n        self::assertNull($ownerRequest);\n\n        $magazine = $this->magazineRepository->findOneBy(['id' => $magazine->getId()]);\n        $user = $this->userRepository->findOneBy(['id' => $user->getId()]);\n        self::assertTrue($magazine->userIsOwner($user));\n    }\n\n    public function testApiCanRejectOwnerRequest(): void\n    {\n        $magazine = $this->getMagazineByName('test');\n        $user = $this->getUserByUsername('JohnDoeTheSecond');\n        $this->magazineManager->toggleOwnershipRequest($magazine, $user);\n\n        $adminUser = $this->getUserByUsername('Admin');\n        $this->setAdmin($adminUser);\n        $this->client->loginUser($adminUser);\n        self::createOAuth2AuthCodeClient();\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'admin:magazine:moderate');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('PUT', \"/api/moderate/magazine/{$magazine->getId()}/ownerRequest/reject/{$user->getId()}\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(204);\n        self::assertSame('', $this->client->getResponse()->getContent());\n\n        $ownerRequest = $this->entityManager->getRepository(ModeratorRequest::class)->findOneBy([\n            'magazine' => $magazine,\n            'user' => $user,\n        ]);\n        self::assertNull($ownerRequest);\n\n        $magazine = $this->magazineRepository->findOneBy(['id' => $magazine->getId()]);\n        $user = $this->userRepository->findOneBy(['id' => $user->getId()]);\n        self::assertFalse($magazine->userIsOwner($user));\n        self::assertFalse($magazine->userIsModerator($user));\n    }\n\n    public function testApiCannotListOwnerRequestsAnonymously(): void\n    {\n        $this->client->request('GET', '/api/moderate/ownerRequest/list');\n\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotListOwnerRequestsWithoutScope(): void\n    {\n        $user = $this->getUserByUsername('JohnDoe');\n        $this->client->loginUser($user);\n        self::createOAuth2AuthCodeClient();\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client);\n        $token = $codes['token_type'].' '.$codes['access_token'];\n        $this->client->request('GET', '/api/moderate/ownerRequest/list', server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCannotListOwnerRequestsForInvalidMagazineId(): void\n    {\n        $adminUser = $this->getUserByUsername('Admin');\n        $this->setAdmin($adminUser);\n        $this->client->loginUser($adminUser);\n        self::createOAuth2AuthCodeClient();\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'admin:magazine:moderate');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', '/api/moderate/ownerRequest/list?magazine=a', server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(404);\n    }\n\n    public function testApiCannotListOwnerRequestsForMissingMagazine(): void\n    {\n        $adminUser = $this->getUserByUsername('Admin');\n        $this->setAdmin($adminUser);\n        $this->client->loginUser($adminUser);\n        self::createOAuth2AuthCodeClient();\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'admin:magazine:moderate');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', '/api/moderate/ownerRequest/list?magazine=99', server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(404);\n    }\n\n    public function testApiCanListOwnerRequestsForMagazine(): void\n    {\n        $magazine1 = $this->getMagazineByName('Magazine 1');\n        $magazine2 = $this->getMagazineByName('Magazine 2');\n        $magazine3 = $this->getMagazineByName('Magazine 3');\n        $user1 = $this->getUserByUsername('User 1');\n        $user2 = $this->getUserByUsername('User 2');\n\n        $this->magazineManager->toggleOwnershipRequest($magazine1, $user1);\n        $this->magazineManager->toggleOwnershipRequest($magazine1, $user2);\n        $this->magazineManager->toggleOwnershipRequest($magazine2, $user2);\n\n        $adminUser = $this->getUserByUsername('Admin');\n        $this->setAdmin($adminUser);\n        $this->client->loginUser($adminUser);\n        self::createOAuth2AuthCodeClient();\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'admin:magazine:moderate');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/moderate/ownerRequest/list?magazine={$magazine1->getId()}\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n        self::assertIsArray($jsonData);\n\n        self::assertCount(2, $jsonData);\n        self::assertTrue(array_all($jsonData, function ($item) use ($magazine1, $user1, $user2) {\n            return $item['magazine']['magazineId'] === $magazine1->getId()\n                && ($item['user']['userId'] === $user1->getId() || $item['user']['userId'] === $user2->getId());\n        }));\n        self::assertNotSame($jsonData[0]['user']['userId'], $jsonData[1]['user']['userId']);\n    }\n\n    public function testApiCanListOwnerRequestsForAllMagazines(): void\n    {\n        $magazine1 = $this->getMagazineByName('Magazine 1');\n        $magazine2 = $this->getMagazineByName('Magazine 2');\n        $magazine3 = $this->getMagazineByName('Magazine 3');\n        $user1 = $this->getUserByUsername('User 1');\n        $user2 = $this->getUserByUsername('User 2');\n\n        $this->magazineManager->toggleOwnershipRequest($magazine1, $user1);\n        $this->magazineManager->toggleOwnershipRequest($magazine1, $user2);\n        $this->magazineManager->toggleOwnershipRequest($magazine2, $user2);\n\n        $adminUser = $this->getUserByUsername('Admin');\n        $this->setAdmin($adminUser);\n        $this->client->loginUser($adminUser);\n        self::createOAuth2AuthCodeClient();\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'admin:magazine:moderate');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', '/api/moderate/ownerRequest/list', server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n        self::assertIsArray($jsonData);\n\n        self::assertCount(3, $jsonData);\n        self::assertTrue(array_all($jsonData, function ($item) use ($magazine1, $magazine2, $user1, $user2) {\n            return ($item['magazine']['magazineId'] === $magazine1->getId() && $item['user']['userId'] === $user1->getId())\n                || ($item['magazine']['magazineId'] === $magazine1->getId() && $item['user']['userId'] === $user2->getId())\n                || ($item['magazine']['magazineId'] === $magazine2->getId() && $item['user']['userId'] === $user2->getId());\n        }));\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/Magazine/Moderate/MagazineRetrieveBansApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\Magazine\\Moderate;\n\nuse App\\DTO\\MagazineBanDto;\nuse App\\Tests\\Functional\\Controller\\Api\\Magazine\\MagazineRetrieveApiTest;\nuse App\\Tests\\WebTestCase;\n\nclass MagazineRetrieveBansApiTest extends WebTestCase\n{\n    public const BAN_RESPONSE_KEYS = ['banId', 'reason', 'expired', 'expiredAt', 'bannedUser', 'bannedBy', 'magazine'];\n\n    public function testApiCannotRetrieveMagazineBansAnonymous(): void\n    {\n        $magazine = $this->getMagazineByName('test');\n        $this->client->request('GET', \"/api/moderate/magazine/{$magazine->getId()}/bans\");\n\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotRetrieveMagazineBansWithoutScope(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n        self::createOAuth2AuthCodeClient();\n        $magazine = $this->getMagazineByName('test');\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client);\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/moderate/magazine/{$magazine->getId()}/bans\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCannotRetrieveMagazineBansIfNotMod(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n        self::createOAuth2AuthCodeClient();\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write moderate:magazine:ban:read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $magazine = $this->getMagazineByName('test', $this->getUserByUsername('JaneDoe'));\n        $this->client->request('GET', \"/api/moderate/magazine/{$magazine->getId()}/bans\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanRetrieveMagazineBans(): void\n    {\n        $user = $this->getUserByUsername('JohnDoe');\n        $this->client->loginUser($user);\n        self::createOAuth2AuthCodeClient();\n        $magazine = $this->getMagazineByName('test');\n\n        $bannedUser = $this->getUserByUsername('hapless_fool');\n        $magazineManager = $this->magazineManager;\n        $ban = MagazineBanDto::create('test ban :)');\n        $magazineManager->ban($magazine, $bannedUser, $user, $ban);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write moderate:magazine:ban:read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/moderate/magazine/{$magazine->getId()}/bans\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(1, $jsonData['pagination']['count']);\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(1, $jsonData['items']);\n        self::assertArrayKeysMatch(self::BAN_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertEquals($ban->reason, $jsonData['items'][0]['reason']);\n        self::assertArrayKeysMatch(MagazineRetrieveApiTest::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['magazine']);\n        self::assertSame($magazine->getId(), $jsonData['items'][0]['magazine']['magazineId']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['bannedUser']);\n        self::assertSame($bannedUser->getId(), $jsonData['items'][0]['bannedUser']['userId']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['bannedBy']);\n        self::assertSame($user->getId(), $jsonData['items'][0]['bannedBy']['userId']);\n        self::assertNull($jsonData['items'][0]['expiredAt']);\n        self::assertFalse($jsonData['items'][0]['expired']);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/Magazine/Moderate/MagazineRetrieveReportsApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\Magazine\\Moderate;\n\nuse App\\DTO\\ReportDto;\nuse App\\Tests\\Functional\\Controller\\Api\\Magazine\\MagazineRetrieveApiTest;\nuse App\\Tests\\WebTestCase;\n\nclass MagazineRetrieveReportsApiTest extends WebTestCase\n{\n    public const REPORT_RESPONSE_KEYS = ['reportId', 'type', 'magazine', 'reason', 'reported', 'reporting', 'subject', 'status', 'weight', 'createdAt', 'consideredAt', 'consideredBy'];\n\n    public function testApiCannotRetrieveMagazineReportByIdAnonymous(): void\n    {\n        $user = $this->getUserByUsername('JohnDoe');\n        $magazine = $this->getMagazineByName('test');\n        $reportedUser = $this->getUserByUsername('hapless_fool');\n        $entry = $this->getEntryByTitle('Report test', body: 'This is gonna be reported', magazine: $magazine, user: $reportedUser);\n\n        $reportManager = $this->reportManager;\n        $report = $reportManager->report(ReportDto::create($entry, 'I don\\'t like it'), $user);\n        $this->client->request('GET', \"/api/moderate/magazine/{$magazine->getId()}/reports/{$report->getId()}\");\n\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotRetrieveMagazineReportByIdWithoutScope(): void\n    {\n        $user = $this->getUserByUsername('JohnDoe');\n        $this->client->loginUser($user);\n        self::createOAuth2AuthCodeClient();\n        $magazine = $this->getMagazineByName('test');\n        $reportedUser = $this->getUserByUsername('hapless_fool');\n        $entry = $this->getEntryByTitle('Report test', body: 'This is gonna be reported', magazine: $magazine, user: $reportedUser);\n\n        $reportManager = $this->reportManager;\n        $report = $reportManager->report(ReportDto::create($entry, 'I don\\'t like it'), $user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client);\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/moderate/magazine/{$magazine->getId()}/reports/{$report->getId()}\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCannotRetrieveMagazineReportByIdIfNotMod(): void\n    {\n        $user = $this->getUserByUsername('JohnDoe');\n        $this->client->loginUser($user);\n        self::createOAuth2AuthCodeClient();\n        $magazine = $this->getMagazineByName('test', $this->getUserByUsername('JaneDoe'));\n        $reportedUser = $this->getUserByUsername('hapless_fool');\n        $entry = $this->getEntryByTitle('Report test', body: 'This is gonna be reported', magazine: $magazine, user: $reportedUser);\n\n        $reportManager = $this->reportManager;\n        $report = $reportManager->report(ReportDto::create($entry, 'I don\\'t like it'), $user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write moderate:magazine:reports:read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/moderate/magazine/{$magazine->getId()}/reports/{$report->getId()}\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanRetrieveMagazineReportById(): void\n    {\n        $user = $this->getUserByUsername('JohnDoe');\n        $this->client->loginUser($user);\n        self::createOAuth2AuthCodeClient();\n        $magazine = $this->getMagazineByName('test');\n        $reportedUser = $this->getUserByUsername('hapless_fool');\n        $entry = $this->getEntryByTitle('Report test', body: 'This is gonna be reported', magazine: $magazine, user: $reportedUser);\n\n        $reportManager = $this->reportManager;\n        $report = $reportManager->report(ReportDto::create($entry, 'I don\\'t like it'), $user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write moderate:magazine:reports:read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/moderate/magazine/{$magazine->getId()}/reports/{$report->getId()}\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::REPORT_RESPONSE_KEYS, $jsonData);\n        self::assertEquals($report->reason, $jsonData['reason']);\n        self::assertEquals('entry_report', $jsonData['type']);\n        self::assertArrayKeysMatch(MagazineRetrieveApiTest::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']);\n        self::assertSame($magazine->getId(), $jsonData['magazine']['magazineId']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['reported']);\n        self::assertSame($reportedUser->getId(), $jsonData['reported']['userId']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['reporting']);\n        self::assertSame($user->getId(), $jsonData['reporting']['userId']);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['subject']);\n        self::assertSame($entry->getId(), $jsonData['subject']['entryId']);\n        self::assertEquals('pending', $jsonData['status']);\n        self::assertSame(1, $jsonData['weight']);\n        self::assertNull($jsonData['consideredAt']);\n        self::assertNull($jsonData['consideredBy']);\n        self::assertEqualsWithDelta($report->createdAt->getTimestamp(), \\DateTimeImmutable::createFromFormat(\\DateTimeImmutable::ATOM, $jsonData['createdAt'])->getTimestamp(), 10.0);\n    }\n\n    public function testApiCannotRetrieveMagazineReportsAnonymous(): void\n    {\n        $magazine = $this->getMagazineByName('test');\n        $this->client->request('GET', \"/api/moderate/magazine/{$magazine->getId()}/reports\");\n\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotRetrieveMagazineReportsWithoutScope(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n        self::createOAuth2AuthCodeClient();\n        $magazine = $this->getMagazineByName('test');\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client);\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/moderate/magazine/{$magazine->getId()}/reports\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCannotRetrieveMagazineReportsIfNotMod(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n        self::createOAuth2AuthCodeClient();\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write moderate:magazine:reports:read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $magazine = $this->getMagazineByName('test', $this->getUserByUsername('JaneDoe'));\n        $this->client->request('GET', \"/api/moderate/magazine/{$magazine->getId()}/reports\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanRetrieveMagazineReports(): void\n    {\n        $user = $this->getUserByUsername('JohnDoe');\n        $this->client->loginUser($user);\n        self::createOAuth2AuthCodeClient();\n        $magazine = $this->getMagazineByName('test');\n\n        $reportedUser = $this->getUserByUsername('hapless_fool');\n        $entry = $this->getEntryByTitle('Report test', body: 'This is gonna be reported', magazine: $magazine, user: $reportedUser);\n\n        $reportManager = $this->reportManager;\n        $report = $reportManager->report(ReportDto::create($entry, 'I don\\'t like it'), $user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write moderate:magazine:reports:read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/moderate/magazine/{$magazine->getId()}/reports\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(1, $jsonData['pagination']['count']);\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(1, $jsonData['items']);\n\n        self::assertArrayKeysMatch(self::REPORT_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertEquals($report->reason, $jsonData['items'][0]['reason']);\n        self::assertEquals('entry_report', $jsonData['items'][0]['type']);\n        self::assertArrayKeysMatch(MagazineRetrieveApiTest::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['magazine']);\n        self::assertSame($magazine->getId(), $jsonData['items'][0]['magazine']['magazineId']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['reported']);\n        self::assertSame($reportedUser->getId(), $jsonData['items'][0]['reported']['userId']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['reporting']);\n        self::assertSame($user->getId(), $jsonData['items'][0]['reporting']['userId']);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][0]['subject']);\n        self::assertSame($entry->getId(), $jsonData['items'][0]['subject']['entryId']);\n        self::assertEquals('pending', $jsonData['items'][0]['status']);\n        self::assertSame(1, $jsonData['items'][0]['weight']);\n        self::assertNull($jsonData['items'][0]['consideredAt']);\n        self::assertNull($jsonData['items'][0]['consideredBy']);\n        self::assertEqualsWithDelta($report->createdAt->getTimestamp(), \\DateTimeImmutable::createFromFormat(\\DateTimeImmutable::ATOM, $jsonData['items'][0]['createdAt'])->getTimestamp(), 10.0);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/Magazine/Moderate/MagazineRetrieveTrashApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\Magazine\\Moderate;\n\nuse App\\Entity\\Contracts\\VisibilityInterface;\nuse App\\Tests\\Functional\\Controller\\Api\\Magazine\\MagazineRetrieveApiTest;\nuse App\\Tests\\WebTestCase;\n\nclass MagazineRetrieveTrashApiTest extends WebTestCase\n{\n    public function testApiCannotRetrieveMagazineTrashAnonymous(): void\n    {\n        $magazine = $this->getMagazineByName('test');\n        $this->client->request('GET', \"/api/moderate/magazine/{$magazine->getId()}/trash\");\n\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotRetrieveMagazineTrashWithoutScope(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n        self::createOAuth2AuthCodeClient();\n        $magazine = $this->getMagazineByName('test');\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client);\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/moderate/magazine/{$magazine->getId()}/trash\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCannotRetrieveMagazineTrashIfNotMod(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n        self::createOAuth2AuthCodeClient();\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write moderate:magazine:trash:read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $magazine = $this->getMagazineByName('test', $this->getUserByUsername('JaneDoe'));\n        $this->client->request('GET', \"/api/moderate/magazine/{$magazine->getId()}/trash\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanRetrieveMagazineTrash(): void\n    {\n        $user = $this->getUserByUsername('JohnDoe');\n        $this->client->loginUser($user);\n        self::createOAuth2AuthCodeClient();\n        $magazine = $this->getMagazineByName('test');\n\n        $reportedUser = $this->getUserByUsername('hapless_fool');\n        $entry = $this->getEntryByTitle('Delete test', body: 'This is gonna be deleted', magazine: $magazine, user: $reportedUser);\n\n        $entryManager = $this->entryManager;\n        $entryManager->delete($user, $entry);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read write moderate:magazine:trash:read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/moderate/magazine/{$magazine->getId()}/trash\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(1, $jsonData['pagination']['count']);\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(1, $jsonData['items']);\n\n        $trashedEntryResponseKeys = array_merge(self::ENTRY_RESPONSE_KEYS, ['itemType']);\n\n        self::assertArrayKeysMatch($trashedEntryResponseKeys, $jsonData['items'][0]);\n        self::assertArrayKeysMatch(MagazineRetrieveApiTest::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['magazine']);\n        self::assertSame($magazine->getId(), $jsonData['items'][0]['magazine']['magazineId']);\n        self::assertSame($entry->getId(), $jsonData['items'][0]['entryId']);\n        self::assertEquals($entry->body, $jsonData['items'][0]['body']);\n        self::assertEquals(VisibilityInterface::VISIBILITY_TRASHED, $jsonData['items'][0]['visibility']);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/Message/MessageReadApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\Message;\n\nuse App\\Entity\\Message;\nuse App\\Tests\\WebTestCase;\n\nclass MessageReadApiTest extends WebTestCase\n{\n    public function testApiCannotMarkMessagesReadAnonymous(): void\n    {\n        $message = $this->createMessage($this->getUserByUsername('JohnDoe'), $this->getUserByUsername('JaneDoe'), 'test message');\n\n        $this->client->request('PUT', \"/api/messages/{$message->getId()}/read\");\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotMarkMessagesReadWithoutScope(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $user = $this->getUserByUsername('JohnDoe');\n        $this->client->loginUser($user);\n        $message = $this->createMessage($this->getUserByUsername('JohnDoe'), $this->getUserByUsername('JaneDoe'), 'test message');\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('PUT', \"/api/messages/{$message->getId()}/read\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCannotMarkOtherUsersMessagesRead(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $user = $this->getUserByUsername('JohnDoe');\n        $messagingUser = $this->getUserByUsername('JaneDoe');\n        $messagedUser = $this->getUserByUsername('JamesDoe');\n\n        $message = $this->createMessage($messagedUser, $messagingUser, 'test message');\n\n        $this->client->loginUser($user);\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:message:read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('PUT', \"/api/messages/{$message->getId()}/read\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanMarkMessagesRead(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $user = $this->getUserByUsername('JohnDoe');\n        $messagingUser = $this->getUserByUsername('JaneDoe');\n\n        $thread = $this->createMessageThread($user, $messagingUser, 'test message');\n        /** @var Message $message */\n        $message = $thread->messages->get(0);\n\n        $this->client->loginUser($user);\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:message:read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('PUT', \"/api/messages/{$message->getId()}/read\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::MESSAGE_RESPONSE_KEYS, $jsonData);\n        self::assertSame($message->getId(), $jsonData['messageId']);\n        self::assertSame($thread->getId(), $jsonData['threadId']);\n        self::assertEquals('test message', $jsonData['body']);\n        self::assertEquals(Message::STATUS_READ, $jsonData['status']);\n        self::assertSame($message->createdAt->getTimestamp(), \\DateTimeImmutable::createFromFormat(\\DateTimeImmutable::ATOM, $jsonData['createdAt'])->getTimestamp());\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['sender']);\n        self::assertSame($messagingUser->getId(), $jsonData['sender']['userId']);\n    }\n\n    public function testApiCannotMarkMessagesUnreadAnonymous(): void\n    {\n        $message = $this->createMessage($this->getUserByUsername('JohnDoe'), $this->getUserByUsername('JaneDoe'), 'test message');\n\n        $this->client->request('PUT', \"/api/messages/{$message->getId()}/unread\");\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotMarkMessagesUnreadWithoutScope(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $user = $this->getUserByUsername('JohnDoe');\n        $this->client->loginUser($user);\n        $message = $this->createMessage($this->getUserByUsername('JohnDoe'), $this->getUserByUsername('JaneDoe'), 'test message');\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('PUT', \"/api/messages/{$message->getId()}/unread\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCannotMarkOtherUsersMessagesUnread(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $user = $this->getUserByUsername('JohnDoe');\n        $messagingUser = $this->getUserByUsername('JaneDoe');\n        $messagedUser = $this->getUserByUsername('JamesDoe');\n\n        $message = $this->createMessage($messagedUser, $messagingUser, 'test message');\n\n        $this->client->loginUser($user);\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:message:read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('PUT', \"/api/messages/{$message->getId()}/unread\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanMarkMessagesUnread(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $user = $this->getUserByUsername('JohnDoe');\n        $messagingUser = $this->getUserByUsername('JaneDoe');\n\n        $thread = $this->createMessageThread($user, $messagingUser, 'test message');\n        /** @var Message $message */\n        $message = $thread->messages->get(0);\n        $messageManager = $this->messageManager;\n        $messageManager->readMessage($message, $user, flush: true);\n\n        $this->client->loginUser($user);\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:message:read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('PUT', \"/api/messages/{$message->getId()}/unread\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::MESSAGE_RESPONSE_KEYS, $jsonData);\n        self::assertSame($message->getId(), $jsonData['messageId']);\n        self::assertSame($thread->getId(), $jsonData['threadId']);\n        self::assertEquals('test message', $jsonData['body']);\n        self::assertEquals(Message::STATUS_NEW, $jsonData['status']);\n        self::assertSame($message->createdAt->getTimestamp(), \\DateTimeImmutable::createFromFormat(\\DateTimeImmutable::ATOM, $jsonData['createdAt'])->getTimestamp());\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['sender']);\n        self::assertSame($messagingUser->getId(), $jsonData['sender']['userId']);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/Message/MessageRetrieveApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\Message;\n\nuse App\\DTO\\MessageDto;\nuse App\\Entity\\Message;\nuse App\\Tests\\WebTestCase;\n\nclass MessageRetrieveApiTest extends WebTestCase\n{\n    public const MESSAGE_THREAD_RESPONSE_KEYS = ['threadId', 'participants', 'messageCount', 'messages'];\n\n    public function testApiCannotGetMessagesAnonymous(): void\n    {\n        $this->client->request('GET', '/api/messages');\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotGetMessagesWithoutScope(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $user = $this->getUserByUsername('JohnDoe');\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', '/api/messages', server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanGetMessages(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $user = $this->getUserByUsername('JohnDoe');\n        $messagingUser = $this->getUserByUsername('JaneDoe');\n\n        $messageManager = $this->messageManager;\n        $dto = new MessageDto();\n        $dto->body = 'test message';\n        $thread = $messageManager->toThread($dto, $messagingUser, $user);\n        /** @var Message $message */\n        $message = $thread->messages->get(0);\n\n        $this->client->loginUser($user);\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:message:read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', '/api/messages', server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n\n        self::assertCount(1, $jsonData['items']);\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(self::MESSAGE_THREAD_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertSame($thread->getId(), $jsonData['items'][0]['threadId']);\n        self::assertSame(1, $jsonData['items'][0]['messageCount']);\n\n        self::assertIsArray($jsonData['items'][0]['messages']);\n        self::assertCount(1, $jsonData['items'][0]['messages']);\n        self::assertArrayKeysMatch(self::MESSAGE_RESPONSE_KEYS, $jsonData['items'][0]['messages'][0]);\n        self::assertSame($message->getId(), $jsonData['items'][0]['messages'][0]['messageId']);\n        self::assertSame($thread->getId(), $jsonData['items'][0]['messages'][0]['threadId']);\n        self::assertEquals('test message', $jsonData['items'][0]['messages'][0]['body']);\n        self::assertEquals('new', $jsonData['items'][0]['messages'][0]['status']);\n        self::assertSame($message->createdAt->getTimestamp(), \\DateTimeImmutable::createFromFormat(\\DateTimeImmutable::ATOM, $jsonData['items'][0]['messages'][0]['createdAt'])->getTimestamp());\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['messages'][0]['sender']);\n        self::assertSame($messagingUser->getId(), $jsonData['items'][0]['messages'][0]['sender']['userId']);\n    }\n\n    public function testApiCannotGetMessageByIdAnonymous(): void\n    {\n        $this->client->request('GET', '/api/messages/1');\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotGetMessageByIdWithoutScope(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $user = $this->getUserByUsername('JohnDoe');\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', '/api/messages/1', server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCannotGetOtherUsersMessageById(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $user = $this->getUserByUsername('JohnDoe');\n        $messagingUser = $this->getUserByUsername('JaneDoe');\n        $messagedUser = $this->getUserByUsername('JamesDoe');\n\n        $messageManager = $this->messageManager;\n        $dto = new MessageDto();\n        $dto->body = 'test message';\n        $thread = $messageManager->toThread($dto, $messagingUser, $messagedUser);\n        /** @var Message $message */\n        $message = $thread->messages->get(0);\n\n        $this->client->loginUser($user);\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:message:read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/messages/{$message->getId()}\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanGetMessageById(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $user = $this->getUserByUsername('JohnDoe');\n        $messagingUser = $this->getUserByUsername('JaneDoe');\n\n        $messageManager = $this->messageManager;\n        $dto = new MessageDto();\n        $dto->body = 'test message';\n        $thread = $messageManager->toThread($dto, $messagingUser, $user);\n        /** @var Message $message */\n        $message = $thread->messages->get(0);\n\n        $this->client->loginUser($user);\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:message:read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/messages/{$message->getId()}\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::MESSAGE_RESPONSE_KEYS, $jsonData);\n        self::assertSame($message->getId(), $jsonData['messageId']);\n        self::assertSame($thread->getId(), $jsonData['threadId']);\n        self::assertEquals('test message', $jsonData['body']);\n        self::assertEquals('new', $jsonData['status']);\n        self::assertSame($message->createdAt->getTimestamp(), \\DateTimeImmutable::createFromFormat(\\DateTimeImmutable::ATOM, $jsonData['createdAt'])->getTimestamp());\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['sender']);\n        self::assertSame($messagingUser->getId(), $jsonData['sender']['userId']);\n    }\n\n    public function testApiCannotGetMessageThreadByIdAnonymous(): void\n    {\n        $messagingUser = $this->getUserByUsername('JaneDoe');\n        $messagedUser = $this->getUserByUsername('JamesDoe');\n\n        $messageManager = $this->messageManager;\n        $dto = new MessageDto();\n        $dto->body = 'test message';\n        $thread = $messageManager->toThread($dto, $messagingUser, $messagedUser);\n\n        $this->client->request('GET', \"/api/messages/thread/{$thread->getId()}\");\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotGetMessageThreadByIdWithoutScope(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $user = $this->getUserByUsername('JohnDoe');\n        $messagingUser = $this->getUserByUsername('JaneDoe');\n        $messagedUser = $this->getUserByUsername('JamesDoe');\n        $this->client->loginUser($user);\n\n        $messageManager = $this->messageManager;\n        $dto = new MessageDto();\n        $dto->body = 'test message';\n        $thread = $messageManager->toThread($dto, $messagingUser, $messagedUser);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/messages/thread/{$thread->getId()}\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCannotGetOtherUsersMessageThreadById(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $user = $this->getUserByUsername('JohnDoe');\n        $messagingUser = $this->getUserByUsername('JaneDoe');\n        $messagedUser = $this->getUserByUsername('JamesDoe');\n\n        $messageManager = $this->messageManager;\n        $dto = new MessageDto();\n        $dto->body = 'test message';\n        $thread = $messageManager->toThread($dto, $messagingUser, $messagedUser);\n\n        $this->client->loginUser($user);\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:message:read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/messages/thread/{$thread->getId()}\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanGetMessageThreadById(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $user = $this->getUserByUsername('JohnDoe');\n        $messagingUser = $this->getUserByUsername('JaneDoe');\n\n        $messageManager = $this->messageManager;\n        $dto = new MessageDto();\n        $dto->body = 'test message';\n        $thread = $messageManager->toThread($dto, $messagingUser, $user);\n        /** @var Message $message */\n        $message = $thread->messages->get(0);\n\n        $this->client->loginUser($user);\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:message:read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/messages/thread/{$thread->getId()}\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(array_merge(self::PAGINATED_KEYS, ['participants']), $jsonData);\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(1, $jsonData['items']);\n        self::assertIsArray($jsonData['participants']);\n        self::assertCount(2, $jsonData['participants']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n\n        self::assertSame($message->getId(), $jsonData['items'][0]['messageId']);\n        self::assertSame($thread->getId(), $jsonData['items'][0]['threadId']);\n        self::assertEquals('test message', $jsonData['items'][0]['body']);\n        self::assertEquals('new', $jsonData['items'][0]['status']);\n        self::assertSame($message->createdAt->getTimestamp(), \\DateTimeImmutable::createFromFormat(\\DateTimeImmutable::ATOM, $jsonData['items'][0]['createdAt'])->getTimestamp());\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['sender']);\n        self::assertSame($messagingUser->getId(), $jsonData['items'][0]['sender']['userId']);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/Message/MessageThreadCreateApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\Message;\n\nuse App\\Entity\\Message;\nuse App\\Tests\\WebTestCase;\n\nclass MessageThreadCreateApiTest extends WebTestCase\n{\n    public function testApiCannotCreateThreadAnonymous(): void\n    {\n        $messagedUser = $this->getUserByUsername('JohnDoe');\n\n        $this->client->jsonRequest('POST', \"/api/users/{$messagedUser->getId()}/message\", parameters: ['body' => 'test message']);\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotCreateThreadWithoutScope(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $user = $this->getUserByUsername('JohnDoe');\n        $this->client->loginUser($user);\n\n        $messagedUser = $this->getUserByUsername('JaneDoe');\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('POST', \"/api/users/{$messagedUser->getId()}/message\", parameters: ['body' => 'test message'], server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanCreateThread(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $user = $this->getUserByUsername('JohnDoe');\n        $messagedUser = $this->getUserByUsername('JaneDoe');\n\n        $this->client->loginUser($user);\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:message:create');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('POST', \"/api/users/{$messagedUser->getId()}/message\", parameters: ['body' => 'test message'], server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(MessageRetrieveApiTest::MESSAGE_THREAD_RESPONSE_KEYS, $jsonData);\n        self::assertIsArray($jsonData['participants']);\n        self::assertCount(2, $jsonData['participants']);\n        self::assertArrayKeysMatch(self::USER_RESPONSE_KEYS, $jsonData['participants'][0]);\n        self::assertTrue($user->getId() === $jsonData['participants'][0]['userId'] || $messagedUser->getId() === $jsonData['participants'][0]['userId']);\n        self::assertArrayKeysMatch(self::USER_RESPONSE_KEYS, $jsonData['participants'][1]);\n        self::assertTrue($user->getId() === $jsonData['participants'][1]['userId'] || $messagedUser->getId() === $jsonData['participants'][1]['userId']);\n\n        self::assertSame(1, $jsonData['messageCount']);\n        self::assertNotNull($jsonData['threadId']);\n\n        self::assertIsArray($jsonData['messages']);\n        self::assertCount(1, $jsonData['messages']);\n        self::assertArrayKeysMatch(self::MESSAGE_RESPONSE_KEYS, $jsonData['messages'][0]);\n\n        self::assertEquals('test message', $jsonData['messages'][0]['body']);\n        self::assertEquals(Message::STATUS_NEW, $jsonData['messages'][0]['status']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['messages'][0]['sender']);\n        self::assertSame($user->getId(), $jsonData['messages'][0]['sender']['userId']);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/Message/MessageThreadReplyApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\Message;\n\nuse App\\Entity\\Message;\nuse App\\Tests\\WebTestCase;\n\nclass MessageThreadReplyApiTest extends WebTestCase\n{\n    public function testApiCannotReplyToThreadAnonymous(): void\n    {\n        $to = $this->getUserByUsername('JohnDoe');\n        $from = $this->getUserByUsername('JaneDoe');\n        $thread = $this->createMessageThread($to, $from, 'starting a thread');\n\n        $this->client->jsonRequest('POST', \"/api/messages/thread/{$thread->getId()}/reply\", parameters: ['body' => 'test message']);\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotReplyToThreadWithoutScope(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $user = $this->getUserByUsername('JohnDoe');\n        $this->client->loginUser($user);\n\n        $from = $this->getUserByUsername('JaneDoe');\n        $thread = $this->createMessageThread($user, $from, 'starting a thread');\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('POST', \"/api/messages/thread/{$thread->getId()}/reply\", parameters: ['body' => 'test message'], server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanReplyToThread(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $user = $this->getUserByUsername('JohnDoe');\n\n        $from = $this->getUserByUsername('JaneDoe');\n        $thread = $this->createMessageThread($user, $from, 'starting a thread');\n        // Fake when the message was created at so that the newest to oldest order can be reliably determined\n        $thread->messages->get(0)->createdAt = new \\DateTimeImmutable('-5 seconds');\n        $entityManager = $this->entityManager;\n        $entityManager->persist($thread);\n        $entityManager->flush();\n\n        $this->client->loginUser($user);\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:message:create');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('POST', \"/api/messages/thread/{$thread->getId()}/reply\", parameters: ['body' => 'test message'], server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(MessageRetrieveApiTest::MESSAGE_THREAD_RESPONSE_KEYS, $jsonData);\n        self::assertIsArray($jsonData['participants']);\n        self::assertCount(2, $jsonData['participants']);\n        self::assertArrayKeysMatch(self::USER_RESPONSE_KEYS, $jsonData['participants'][0]);\n        self::assertTrue($user->getId() === $jsonData['participants'][0]['userId'] || $from->getId() === $jsonData['participants'][0]['userId']);\n        self::assertArrayKeysMatch(self::USER_RESPONSE_KEYS, $jsonData['participants'][1]);\n        self::assertTrue($user->getId() === $jsonData['participants'][1]['userId'] || $from->getId() === $jsonData['participants'][1]['userId']);\n\n        self::assertSame(2, $jsonData['messageCount']);\n        self::assertNotNull($jsonData['threadId']);\n\n        self::assertIsArray($jsonData['messages']);\n        self::assertCount(2, $jsonData['messages']);\n        self::assertArrayKeysMatch(self::MESSAGE_RESPONSE_KEYS, $jsonData['messages'][0]);\n\n        // Newest first\n        self::assertEquals('test message', $jsonData['messages'][0]['body']);\n        self::assertEquals(Message::STATUS_NEW, $jsonData['messages'][0]['status']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['messages'][0]['sender']);\n        self::assertSame($user->getId(), $jsonData['messages'][0]['sender']['userId']);\n\n        self::assertEquals('starting a thread', $jsonData['messages'][1]['body']);\n        self::assertEquals(Message::STATUS_NEW, $jsonData['messages'][1]['status']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['messages'][1]['sender']);\n        self::assertSame($from->getId(), $jsonData['messages'][1]['sender']['userId']);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/Notification/AdminNotificationRetrieveApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\Notification;\n\nuse App\\DTO\\UserDto;\nuse App\\Tests\\WebTestCase;\n\nclass AdminNotificationRetrieveApiTest extends WebTestCase\n{\n    public const array USER_SIGNUP_RESPONSE_KEYS = ['userId', 'username', 'isBot', 'createdAt', 'email', 'applicationText'];\n\n    public function testApiCanReturnNotificationForUserSignup()\n    {\n        self::createOAuth2AuthCodeClient();\n        $user = $this->getUserByUsername('JohnDoe', isAdmin: true);\n        $this->client->loginUser($user);\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:notification:read user:message:read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $createdAt = new \\DateTimeImmutable();\n        $createDto = UserDto::create('new_here', email: 'user@example.com', createdAt: $createdAt, applicationText: 'hello there');\n        $createDto->plainPassword = '1234';\n        $this->userManager->create($createDto, false, false, false);\n\n        $this->client->request('GET', '/api/notifications/all', server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertCount(1, $jsonData['items']);\n\n        $item = $jsonData['items'][0];\n        self::assertArrayKeysMatch(NotificationRetrieveApiTest::NOTIFICATION_RESPONSE_KEYS, $item);\n        self::assertEquals('new_signup', $item['type']);\n        self::assertEquals('new', $item['status']);\n        self::assertNull($item['reportId']);\n\n        $subject = $item['subject'];\n        self::assertIsArray($subject);\n        self::assertArrayKeysMatch(self::USER_SIGNUP_RESPONSE_KEYS, $subject);\n        self::assertNotEquals(0, $subject['userId']);\n        self::assertEquals('new_here', $subject['username']);\n        self::assertEquals('user@example.com', $subject['email']);\n        self::assertEquals($createdAt->format(\\DateTimeInterface::ATOM), $subject['createdAt']);\n        self::assertEquals('hello there', $subject['applicationText']);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/Notification/NotificationDeleteApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\Notification;\n\nuse App\\Tests\\WebTestCase;\n\nclass NotificationDeleteApiTest extends WebTestCase\n{\n    public function testApiCannotDeleteNotificationByIdAnonymous(): void\n    {\n        $notification = $this->createMessageNotification();\n\n        $this->client->request('DELETE', \"/api/notifications/{$notification->getId()}\");\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotDeleteNotificationByIdWithoutScope(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $user = $this->getUserByUsername('JohnDoe');\n        $notification = $this->createMessageNotification();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('DELETE', \"/api/notifications/{$notification->getId()}\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCannotDeleteOtherUsersNotificationById(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $user = $this->getUserByUsername('JohnDoe');\n        $messagedUser = $this->getUserByUsername('JamesDoe');\n        $notification = $this->createMessageNotification($messagedUser);\n\n        $this->client->loginUser($user);\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:notification:delete');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('DELETE', \"/api/notifications/{$notification->getId()}\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanDeleteNotificationById(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $user = $this->getUserByUsername('JohnDoe');\n        $notification = $this->createMessageNotification();\n\n        $this->client->loginUser($user);\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:notification:delete');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('DELETE', \"/api/notifications/{$notification->getId()}\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(204);\n\n        $notificationRepository = $this->notificationRepository;\n        $notification = $notificationRepository->find($notification->getId());\n        self::assertNull($notification);\n    }\n\n    public function testApiCannotDeleteAllNotificationsAnonymous(): void\n    {\n        $this->createMessageNotification();\n\n        $this->client->request('DELETE', '/api/notifications');\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotDeleteAllNotificationsWithoutScope(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $user = $this->getUserByUsername('JohnDoe');\n        $this->client->loginUser($user);\n\n        $this->createMessageNotification();\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('DELETE', '/api/notifications', server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanDeleteAllNotifications(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $user = $this->getUserByUsername('JohnDoe');\n\n        $notification = $this->createMessageNotification();\n\n        $this->client->loginUser($user);\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:notification:delete');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('DELETE', '/api/notifications', server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(204);\n\n        $notificationRepository = $this->notificationRepository;\n        $notification = $notificationRepository->find($notification->getId());\n        self::assertNull($notification);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/Notification/NotificationReadApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\Notification;\n\nuse App\\Entity\\Notification;\nuse App\\Tests\\WebTestCase;\n\nclass NotificationReadApiTest extends WebTestCase\n{\n    public function testApiCannotMarkNotificationReadAnonymous(): void\n    {\n        $notification = $this->createMessageNotification();\n\n        $this->client->request('PUT', \"/api/notifications/{$notification->getId()}/read\");\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotMarkNotificationReadWithoutScope(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $user = $this->getUserByUsername('JohnDoe');\n        $notification = $this->createMessageNotification();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('PUT', \"/api/notifications/{$notification->getId()}/read\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCannotMarkOtherUsersNotificationRead(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $user = $this->getUserByUsername('JohnDoe');\n        $messagedUser = $this->getUserByUsername('JamesDoe');\n        $notification = $this->createMessageNotification($messagedUser);\n\n        $this->client->loginUser($user);\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:notification:read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('PUT', \"/api/notifications/{$notification->getId()}/read\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanMarkNotificationRead(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $user = $this->getUserByUsername('JohnDoe');\n        $notification = $this->createMessageNotification();\n\n        $this->client->loginUser($user);\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:notification:read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('PUT', \"/api/notifications/{$notification->getId()}/read\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(NotificationRetrieveApiTest::NOTIFICATION_RESPONSE_KEYS, $jsonData);\n        self::assertEquals('read', $jsonData['status']);\n        self::assertEquals('message_notification', $jsonData['type']);\n\n        self::assertIsArray($jsonData['subject']);\n        self::assertArrayKeysMatch(self::MESSAGE_RESPONSE_KEYS, $jsonData['subject']);\n        self::assertNull($jsonData['subject']['messageId']);\n        self::assertNull($jsonData['subject']['threadId']);\n        self::assertNull($jsonData['subject']['sender']);\n        self::assertNull($jsonData['subject']['status']);\n        self::assertNull($jsonData['subject']['createdAt']);\n        self::assertEquals('This app has not received permission to read your messages.', $jsonData['subject']['body']);\n    }\n\n    public function testApiCannotMarkNotificationUnreadAnonymous(): void\n    {\n        $notification = $this->createMessageNotification();\n\n        $this->client->request('PUT', \"/api/notifications/{$notification->getId()}/unread\");\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotMarkNotificationUnreadWithoutScope(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $user = $this->getUserByUsername('JohnDoe');\n        $notification = $this->createMessageNotification();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('PUT', \"/api/notifications/{$notification->getId()}/unread\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCannotMarkOtherUsersNotificationUnread(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $user = $this->getUserByUsername('JohnDoe');\n        $messagedUser = $this->getUserByUsername('JamesDoe');\n        $notification = $this->createMessageNotification($messagedUser);\n\n        $this->client->loginUser($user);\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:notification:read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('PUT', \"/api/notifications/{$notification->getId()}/unread\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanMarkNotificationUnread(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $user = $this->getUserByUsername('JohnDoe');\n        $notification = $this->createMessageNotification();\n        $notification->status = Notification::STATUS_READ;\n        $entityManager = $this->entityManager;\n        $entityManager->persist($notification);\n        $entityManager->flush();\n\n        $this->client->loginUser($user);\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:notification:read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('PUT', \"/api/notifications/{$notification->getId()}/unread\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(NotificationRetrieveApiTest::NOTIFICATION_RESPONSE_KEYS, $jsonData);\n        self::assertEquals('new', $jsonData['status']);\n        self::assertEquals('message_notification', $jsonData['type']);\n\n        self::assertIsArray($jsonData['subject']);\n        self::assertArrayKeysMatch(self::MESSAGE_RESPONSE_KEYS, $jsonData['subject']);\n        self::assertNull($jsonData['subject']['messageId']);\n        self::assertNull($jsonData['subject']['threadId']);\n        self::assertNull($jsonData['subject']['sender']);\n        self::assertNull($jsonData['subject']['status']);\n        self::assertNull($jsonData['subject']['createdAt']);\n        self::assertEquals('This app has not received permission to read your messages.', $jsonData['subject']['body']);\n    }\n\n    public function testApiCannotMarkAllNotificationsReadAnonymous(): void\n    {\n        $this->createMessageNotification();\n\n        $this->client->request('PUT', '/api/notifications/read');\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotMarkAllNotificationsReadWithoutScope(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $user = $this->getUserByUsername('JohnDoe');\n        $this->client->loginUser($user);\n\n        $this->createMessageNotification();\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('PUT', '/api/notifications/read', server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanMarkAllNotificationsRead(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $user = $this->getUserByUsername('JohnDoe');\n\n        $notification = $this->createMessageNotification();\n\n        $this->client->loginUser($user);\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:notification:read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('PUT', '/api/notifications/read', server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(204);\n\n        $notificationRepository = $this->notificationRepository;\n        $notification = $notificationRepository->find($notification->getId());\n        self::assertNotNull($notification);\n        self::assertEquals('read', $notification->status);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/Notification/NotificationRetrieveApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\Notification;\n\nuse App\\DTO\\MessageDto;\nuse App\\Entity\\Message;\nuse App\\Tests\\WebTestCase;\n\nclass NotificationRetrieveApiTest extends WebTestCase\n{\n    public const NOTIFICATION_RESPONSE_KEYS = ['notificationId', 'status', 'type', 'subject'];\n\n    public function testApiCannotGetNotificationsByStatusAnonymous(): void\n    {\n        $this->client->request('GET', '/api/notifications/all');\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotGetNotificationsByStatusWithoutScope(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $user = $this->getUserByUsername('JohnDoe');\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', '/api/notifications/all', server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanGetNotificationsByStatusMessagesRedactedWithoutScope(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $user = $this->getUserByUsername('JohnDoe');\n        $messagingUser = $this->getUserByUsername('JaneDoe');\n\n        $messageManager = $this->messageManager;\n        $dto = new MessageDto();\n        $dto->body = 'test message';\n        $thread = $messageManager->toThread($dto, $messagingUser, $user);\n        /** @var Message $message */\n        $message = $thread->messages->get(0);\n        $notificationManager = $this->notificationManager;\n        $notificationManager->readMessageNotification($message, $user);\n        // Create unread notification\n        $thread = $messageManager->toThread($dto, $messagingUser, $user);\n\n        $this->client->loginUser($user);\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:notification:read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', '/api/notifications/all', server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(2, $jsonData['items']);\n        self::assertArrayKeysMatch(self::NOTIFICATION_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertEquals('new', $jsonData['items'][0]['status']);\n        self::assertEquals('message_notification', $jsonData['items'][0]['type']);\n        self::assertArrayKeysMatch(self::NOTIFICATION_RESPONSE_KEYS, $jsonData['items'][1]);\n        self::assertEquals('read', $jsonData['items'][1]['status']);\n        self::assertEquals('message_notification', $jsonData['items'][1]['type']);\n\n        self::assertIsArray($jsonData['items'][0]['subject']);\n        self::assertArrayKeysMatch(self::MESSAGE_RESPONSE_KEYS, $jsonData['items'][0]['subject']);\n        self::assertNull($jsonData['items'][0]['subject']['messageId']);\n        self::assertNull($jsonData['items'][0]['subject']['threadId']);\n        self::assertNull($jsonData['items'][0]['subject']['sender']);\n        self::assertNull($jsonData['items'][0]['subject']['status']);\n        self::assertNull($jsonData['items'][0]['subject']['createdAt']);\n        self::assertEquals('This app has not received permission to read your messages.', $jsonData['items'][0]['subject']['body']);\n    }\n\n    public function testApiCanGetNotificationsByStatusAll(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $user = $this->getUserByUsername('JohnDoe');\n        $messagingUser = $this->getUserByUsername('JaneDoe');\n\n        $messageManager = $this->messageManager;\n        $dto = new MessageDto();\n        $dto->body = 'test message';\n        $thread = $messageManager->toThread($dto, $messagingUser, $user);\n        /** @var Message $message */\n        $message = $thread->messages->get(0);\n\n        $this->client->loginUser($user);\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:notification:read user:message:read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', '/api/notifications/all', server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(1, $jsonData['items']);\n        self::assertArrayKeysMatch(self::NOTIFICATION_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertEquals('new', $jsonData['items'][0]['status']);\n        self::assertEquals('message_notification', $jsonData['items'][0]['type']);\n\n        self::assertIsArray($jsonData['items'][0]['subject']);\n        self::assertArrayKeysMatch(self::MESSAGE_RESPONSE_KEYS, $jsonData['items'][0]['subject']);\n        self::assertSame($message->getId(), $jsonData['items'][0]['subject']['messageId']);\n        self::assertSame($message->thread->getId(), $jsonData['items'][0]['subject']['threadId']);\n        self::assertIsArray($jsonData['items'][0]['subject']['sender']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['subject']['sender']);\n        self::assertSame($messagingUser->getId(), $jsonData['items'][0]['subject']['sender']['userId']);\n        self::assertEquals('new', $jsonData['items'][0]['subject']['status']);\n        self::assertNotNull($jsonData['items'][0]['subject']['createdAt']);\n        self::assertEquals($message->body, $jsonData['items'][0]['subject']['body']);\n    }\n\n    public function testApiCanGetNotificationsFromThreads(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $user = $this->getUserByUsername('JohnDoe');\n        $messagingUser = $this->getUserByUsername('JaneDoe');\n        $magazine = $this->getMagazineByName('acme');\n        $entry = $this->getEntryByTitle('Test notification entry', body: 'Test body', magazine: $magazine, user: $messagingUser);\n        $userEntry = $this->getEntryByTitle('Test entry', body: 'Test body', magazine: $magazine, user: $user);\n        $comment = $this->createEntryComment('Test notification comment', $userEntry, $messagingUser);\n        $commentTwo = $this->createEntryComment('Test notification comment 2', $userEntry, $messagingUser, $comment);\n        $parent = $this->createEntryComment('Test parent comment', $entry, $user);\n        $reply = $this->createEntryComment('Test reply comment', $entry, $messagingUser, $parent);\n        $this->createEntryComment('Test not notified comment', $entry, $messagingUser);\n\n        $this->client->loginUser($user);\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:notification:read user:message:read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', '/api/notifications/all', server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(4, $jsonData['items']);\n        self::assertArrayKeysMatch(self::NOTIFICATION_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertEquals('new', $jsonData['items'][0]['status']);\n        self::assertEquals('entry_comment_reply_notification', $jsonData['items'][0]['type']);\n        self::assertArrayKeysMatch(self::NOTIFICATION_RESPONSE_KEYS, $jsonData['items'][1]);\n        self::assertEquals('new', $jsonData['items'][1]['status']);\n        self::assertEquals('entry_comment_created_notification', $jsonData['items'][1]['type']);\n        self::assertArrayKeysMatch(self::NOTIFICATION_RESPONSE_KEYS, $jsonData['items'][2]);\n        self::assertEquals('new', $jsonData['items'][2]['status']);\n        self::assertEquals('entry_comment_created_notification', $jsonData['items'][2]['type']);\n        self::assertArrayKeysMatch(self::NOTIFICATION_RESPONSE_KEYS, $jsonData['items'][3]);\n        self::assertEquals('new', $jsonData['items'][3]['status']);\n        self::assertEquals('entry_created_notification', $jsonData['items'][3]['type']);\n\n        self::assertIsArray($jsonData['items'][0]['subject']);\n        self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData['items'][0]['subject']);\n        self::assertSame($reply->getId(), $jsonData['items'][0]['subject']['commentId']);\n        self::assertIsArray($jsonData['items'][1]['subject']);\n        self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData['items'][1]['subject']);\n        self::assertSame($commentTwo->getId(), $jsonData['items'][1]['subject']['commentId']);\n        self::assertIsArray($jsonData['items'][2]['subject']);\n        self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $jsonData['items'][2]['subject']);\n        self::assertSame($comment->getId(), $jsonData['items'][2]['subject']['commentId']);\n        self::assertIsArray($jsonData['items'][3]['subject']);\n        self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $jsonData['items'][3]['subject']);\n        self::assertSame($entry->getId(), $jsonData['items'][3]['subject']['entryId']);\n    }\n\n    public function testApiCanGetNotificationsFromPosts(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $user = $this->getUserByUsername('JohnDoe');\n        $messagingUser = $this->getUserByUsername('JaneDoe');\n        $magazine = $this->getMagazineByName('acme');\n        $post = $this->createPost('Test notification post', magazine: $magazine, user: $messagingUser);\n        $userPost = $this->createPost('Test not notified body', magazine: $magazine, user: $user);\n        $comment = $this->createPostComment('Test notification comment', $userPost, $messagingUser);\n        $commentTwo = $this->createPostCommentReply('Test notification comment 2', $userPost, $messagingUser, $comment);\n        $parent = $this->createPostComment('Test parent comment', $post, $user);\n        $reply = $this->createPostCommentReply('Test reply comment', $post, $messagingUser, $parent);\n        $this->createPostComment('Test not notified comment', $post, $messagingUser);\n\n        $this->client->loginUser($user);\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:notification:read user:message:read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', '/api/notifications/all', server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(4, $jsonData['items']);\n        self::assertArrayKeysMatch(self::NOTIFICATION_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertEquals('new', $jsonData['items'][0]['status']);\n        self::assertEquals('post_comment_reply_notification', $jsonData['items'][0]['type']);\n        self::assertArrayKeysMatch(self::NOTIFICATION_RESPONSE_KEYS, $jsonData['items'][1]);\n        self::assertEquals('new', $jsonData['items'][1]['status']);\n        self::assertEquals('post_comment_created_notification', $jsonData['items'][1]['type']);\n        self::assertArrayKeysMatch(self::NOTIFICATION_RESPONSE_KEYS, $jsonData['items'][2]);\n        self::assertEquals('new', $jsonData['items'][2]['status']);\n        self::assertEquals('post_comment_created_notification', $jsonData['items'][2]['type']);\n        self::assertArrayKeysMatch(self::NOTIFICATION_RESPONSE_KEYS, $jsonData['items'][3]);\n        self::assertEquals('new', $jsonData['items'][3]['status']);\n        self::assertEquals('post_created_notification', $jsonData['items'][3]['type']);\n\n        self::assertIsArray($jsonData['items'][0]['subject']);\n        self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $jsonData['items'][0]['subject']);\n        self::assertSame($reply->getId(), $jsonData['items'][0]['subject']['commentId']);\n        self::assertIsArray($jsonData['items'][1]['subject']);\n        self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $jsonData['items'][1]['subject']);\n        self::assertSame($commentTwo->getId(), $jsonData['items'][1]['subject']['commentId']);\n        self::assertIsArray($jsonData['items'][2]['subject']);\n        self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $jsonData['items'][2]['subject']);\n        self::assertSame($comment->getId(), $jsonData['items'][2]['subject']['commentId']);\n        self::assertIsArray($jsonData['items'][3]['subject']);\n        self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][3]['subject']);\n        self::assertSame($post->getId(), $jsonData['items'][3]['subject']['postId']);\n    }\n\n    public function testApiCanGetNotificationsByStatusRead(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $user = $this->getUserByUsername('JohnDoe');\n        $messagingUser = $this->getUserByUsername('JaneDoe');\n\n        $messageManager = $this->messageManager;\n        $dto = new MessageDto();\n        $dto->body = 'test message';\n        $thread = $messageManager->toThread($dto, $messagingUser, $user);\n        /** @var Message $message */\n        $message = $thread->messages->get(0);\n        $notificationManager = $this->notificationManager;\n        $notificationManager->readMessageNotification($message, $user);\n        // Create unread notification\n        $thread = $messageManager->toThread($dto, $messagingUser, $user);\n\n        $this->client->loginUser($user);\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:notification:read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', '/api/notifications/read', server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(1, $jsonData['items']);\n        self::assertArrayKeysMatch(self::NOTIFICATION_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertEquals('read', $jsonData['items'][0]['status']);\n        self::assertEquals('message_notification', $jsonData['items'][0]['type']);\n\n        self::assertIsArray($jsonData['items'][0]['subject']);\n        self::assertArrayKeysMatch(self::MESSAGE_RESPONSE_KEYS, $jsonData['items'][0]['subject']);\n        self::assertNull($jsonData['items'][0]['subject']['messageId']);\n        self::assertNull($jsonData['items'][0]['subject']['threadId']);\n        self::assertNull($jsonData['items'][0]['subject']['sender']);\n        self::assertNull($jsonData['items'][0]['subject']['status']);\n        self::assertNull($jsonData['items'][0]['subject']['createdAt']);\n        self::assertEquals('This app has not received permission to read your messages.', $jsonData['items'][0]['subject']['body']);\n    }\n\n    public function testApiCanGetNotificationsByStatusNew(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $user = $this->getUserByUsername('JohnDoe');\n        $messagingUser = $this->getUserByUsername('JaneDoe');\n\n        $messageManager = $this->messageManager;\n        $dto = new MessageDto();\n        $dto->body = 'test message';\n        $thread = $messageManager->toThread($dto, $messagingUser, $user);\n        /** @var Message $message */\n        $message = $thread->messages->get(0);\n        $notificationManager = $this->notificationManager;\n        $notificationManager->readMessageNotification($message, $user);\n        // Create unread notification\n        $thread = $messageManager->toThread($dto, $messagingUser, $user);\n\n        $this->client->loginUser($user);\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:notification:read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', '/api/notifications/new', server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(1, $jsonData['items']);\n        self::assertArrayKeysMatch(self::NOTIFICATION_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertEquals('new', $jsonData['items'][0]['status']);\n        self::assertEquals('message_notification', $jsonData['items'][0]['type']);\n\n        self::assertIsArray($jsonData['items'][0]['subject']);\n        self::assertArrayKeysMatch(self::MESSAGE_RESPONSE_KEYS, $jsonData['items'][0]['subject']);\n        self::assertNull($jsonData['items'][0]['subject']['messageId']);\n        self::assertNull($jsonData['items'][0]['subject']['threadId']);\n        self::assertNull($jsonData['items'][0]['subject']['sender']);\n        self::assertNull($jsonData['items'][0]['subject']['status']);\n        self::assertNull($jsonData['items'][0]['subject']['createdAt']);\n        self::assertEquals('This app has not received permission to read your messages.', $jsonData['items'][0]['subject']['body']);\n    }\n\n    public function testApiCannotGetNotificationsByInvalidStatus(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $user = $this->getUserByUsername('JohnDoe');\n        $messagingUser = $this->getUserByUsername('JaneDoe');\n\n        $messageManager = $this->messageManager;\n        $dto = new MessageDto();\n        $dto->body = 'test message';\n        $thread = $messageManager->toThread($dto, $messagingUser, $user);\n        /** @var Message $message */\n        $message = $thread->messages->get(0);\n        $notificationManager = $this->notificationManager;\n        $notificationManager->readMessageNotification($message, $user);\n        // Create unread notification\n        $thread = $messageManager->toThread($dto, $messagingUser, $user);\n\n        $this->client->loginUser($user);\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:notification:read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', '/api/notifications/invalid', server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(400);\n    }\n\n    public function testApiCannotGetNotificationCountAnonymous(): void\n    {\n        $this->client->request('GET', '/api/notifications/count');\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotGetNotificationCountWithoutScope(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $user = $this->getUserByUsername('JohnDoe');\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', '/api/notifications/count', server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanGetNotificationCount(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $user = $this->getUserByUsername('JohnDoe');\n        $messagingUser = $this->getUserByUsername('JaneDoe');\n        $magazine = $this->getMagazineByName('acme');\n        $this->getEntryByTitle('Test notification entry', body: 'Test body', magazine: $magazine, user: $messagingUser);\n        $this->createPost('Test notification post body', magazine: $magazine, user: $messagingUser);\n\n        $messageManager = $this->messageManager;\n        $dto = new MessageDto();\n        $dto->body = 'test message';\n        $thread = $messageManager->toThread($dto, $messagingUser, $user);\n        /** @var Message $message */\n        $message = $thread->messages->get(0);\n\n        $this->client->loginUser($user);\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:notification:read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', '/api/notifications/count', server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(['count'], $jsonData);\n        self::assertSame(3, $jsonData['count']);\n    }\n\n    public function testApiCannotGetNotificationByIdAnonymous(): void\n    {\n        $notification = $this->createMessageNotification();\n        self::assertNotNull($notification);\n\n        $this->client->request('GET', \"/api/notification/{$notification->getId()}\");\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotGetNotificationByIdWithoutScope(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $user = $this->getUserByUsername('JohnDoe');\n        $this->client->loginUser($user);\n\n        $notification = $this->createMessageNotification();\n        self::assertNotNull($notification);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/notification/{$notification->getId()}\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCannotGetOtherUsersNotificationById(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $user = $this->getUserByUsername('JohnDoe');\n        $messagedUser = $this->getUserByUsername('JamesDoe');\n\n        $notification = $this->createMessageNotification($messagedUser);\n        self::assertNotNull($notification);\n\n        $this->client->loginUser($user);\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:notification:read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/notification/{$notification->getId()}\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanGetNotificationById(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $user = $this->getUserByUsername('JohnDoe');\n\n        $notification = $this->createMessageNotification();\n        self::assertNotNull($notification);\n\n        $this->client->loginUser($user);\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:notification:read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/notification/{$notification->getId()}\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::NOTIFICATION_RESPONSE_KEYS, $jsonData);\n        self::assertSame($notification->getId(), $jsonData['notificationId']);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/Notification/NotificationUpdateApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\Notification;\n\nuse App\\Entity\\User;\nuse App\\Tests\\WebTestCase;\n\nclass NotificationUpdateApiTest extends WebTestCase\n{\n    private User $user;\n    private string $token;\n\n    public function setUp(): void\n    {\n        parent::setUp();\n        $this->user = $this->getUserByUsername('user');\n        $this->client->loginUser($this->user);\n        self::createOAuth2PublicAuthCodeClient();\n        $codes = self::getPublicAuthorizationCodeTokenResponse($this->client, scopes: 'read user:notification:edit');\n        $this->token = $codes['token_type'].' '.$codes['access_token'];\n        // it seems that the oauth flow detaches the user object from the entity manager, so fetch it again\n        $this->user = $this->userRepository->findOneByUsername('user');\n    }\n\n    public function testSetEntryNotificationSetting(): void\n    {\n        $entry = $this->getEntryByTitle('entry');\n        $this->testAllSettings(\"/api/entry/{$entry->getId()}\", \"/api/notification/update/entry/{$entry->getId()}\");\n    }\n\n    public function testSetPostNotificationSetting(): void\n    {\n        $post = $this->createPost('post');\n        $this->testAllSettings(\"/api/post/{$post->getId()}\", \"/api/notification/update/post/{$post->getId()}\");\n    }\n\n    public function testSetUserNotificationSetting(): void\n    {\n        $user2 = $this->getUserByUsername('test');\n        $this->testAllSettings(\"/api/users/{$user2->getId()}\", \"/api/notification/update/user/{$user2->getId()}\");\n    }\n\n    public function testSetMagazineNotificationSetting(): void\n    {\n        $magazine = $this->getMagazineByName('test');\n        $this->testAllSettings(\"/api/magazine/{$magazine->getId()}\", \"/api/notification/update/magazine/{$magazine->getId()}\");\n    }\n\n    private function testAllSettings(string $retrieveUrl, string $updateUrl): void\n    {\n        $this->client->request('GET', $retrieveUrl, server: ['HTTP_AUTHORIZATION' => $this->token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n        self::assertEquals('Default', $jsonData['notificationStatus']);\n\n        $this->client->request('PUT', \"$updateUrl/Loud\", server: ['HTTP_AUTHORIZATION' => $this->token]);\n        self::assertResponseIsSuccessful();\n\n        $this->client->request('GET', $retrieveUrl, server: ['HTTP_AUTHORIZATION' => $this->token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n        self::assertEquals('Loud', $jsonData['notificationStatus']);\n\n        $this->client->request('PUT', \"$updateUrl/Muted\", server: ['HTTP_AUTHORIZATION' => $this->token]);\n        self::assertResponseIsSuccessful();\n\n        $this->client->request('GET', $retrieveUrl, server: ['HTTP_AUTHORIZATION' => $this->token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n        self::assertEquals('Muted', $jsonData['notificationStatus']);\n\n        $this->client->request('PUT', \"$updateUrl/Default\", server: ['HTTP_AUTHORIZATION' => $this->token]);\n        self::assertResponseIsSuccessful();\n\n        $this->client->request('GET', $retrieveUrl, server: ['HTTP_AUTHORIZATION' => $this->token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n        self::assertEquals('Default', $jsonData['notificationStatus']);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/OAuth2/OAuth2ClientApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\OAuth2;\n\nuse App\\Tests\\WebTestCase;\nuse PHPUnit\\Framework\\Attributes\\Group;\nuse Symfony\\Component\\HttpFoundation\\File\\UploadedFile;\n\nclass OAuth2ClientApiTest extends WebTestCase\n{\n    public const CLIENT_RESPONSE_KEYS = [\n        'identifier',\n        'secret',\n        'name',\n        'contactEmail',\n        'description',\n        'user',\n        'redirectUris',\n        'grants',\n        'scopes',\n        'image',\n    ];\n\n    public function testApiCanCreateWorkingClient(): void\n    {\n        $requestData = [\n            'name' => '/kbin API Created Test Client',\n            'description' => 'An OAuth2 client for testing purposes, created via the API',\n            'contactEmail' => 'test@kbin.test',\n            'redirectUris' => [\n                'https://localhost:3002',\n            ],\n            'grants' => [\n                'authorization_code',\n                'refresh_token',\n            ],\n            'scopes' => [\n                'read',\n                'write',\n                'admin:oauth_clients:read',\n            ],\n        ];\n\n        $this->client->jsonRequest('POST', '/api/client', $requestData);\n\n        self::assertResponseIsSuccessful();\n\n        $clientData = self::getJsonResponse($this->client);\n        self::assertIsArray($clientData);\n        self::assertArrayKeysMatch(self::CLIENT_RESPONSE_KEYS, $clientData);\n        self::assertNotNull($clientData['identifier']);\n        self::assertNotNull($clientData['secret']);\n        self::assertEquals($requestData['name'], $clientData['name']);\n        self::assertEquals($requestData['contactEmail'], $clientData['contactEmail']);\n        self::assertEquals($requestData['description'], $clientData['description']);\n        self::assertNull($clientData['user']);\n        self::assertIsArray($clientData['redirectUris']);\n        self::assertEquals($requestData['redirectUris'], $clientData['redirectUris']);\n        self::assertIsArray($clientData['grants']);\n        self::assertEquals($requestData['grants'], $clientData['grants']);\n        self::assertIsArray($clientData['scopes']);\n        self::assertEquals($requestData['scopes'], $clientData['scopes']);\n        self::assertNull($clientData['image']);\n\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n\n        $jsonData = self::getAuthorizationCodeTokenResponse(\n            $this->client,\n            clientId: $clientData['identifier'],\n            clientSecret: $clientData['secret'],\n            redirectUri: $clientData['redirectUris'][0],\n        );\n\n        self::assertResponseIsSuccessful();\n        self::assertIsArray($jsonData);\n    }\n\n    public function testApiCanCreateWorkingPublicClient(): void\n    {\n        $requestData = [\n            'name' => '/kbin API Created Test Client',\n            'description' => 'An OAuth2 client for testing purposes, created via the API',\n            'contactEmail' => 'test@kbin.test',\n            'public' => true,\n            'redirectUris' => [\n                'https://localhost:3001',\n            ],\n            'grants' => [\n                'authorization_code',\n                'refresh_token',\n            ],\n            'scopes' => [\n                'read',\n                'write',\n                'admin:oauth_clients:read',\n            ],\n        ];\n\n        $this->client->jsonRequest('POST', '/api/client', $requestData);\n\n        self::assertResponseIsSuccessful();\n\n        $clientData = self::getJsonResponse($this->client);\n        self::assertIsArray($clientData);\n        self::assertArrayKeysMatch(self::CLIENT_RESPONSE_KEYS, $clientData);\n        self::assertNotNull($clientData['identifier']);\n        self::assertNull($clientData['secret']);\n        self::assertEquals($requestData['name'], $clientData['name']);\n        self::assertEquals($requestData['contactEmail'], $clientData['contactEmail']);\n        self::assertEquals($requestData['description'], $clientData['description']);\n        self::assertNull($clientData['user']);\n        self::assertIsArray($clientData['redirectUris']);\n        self::assertEquals($requestData['redirectUris'], $clientData['redirectUris']);\n        self::assertIsArray($clientData['grants']);\n        self::assertEquals($requestData['grants'], $clientData['grants']);\n        self::assertIsArray($clientData['scopes']);\n        self::assertEquals($requestData['scopes'], $clientData['scopes']);\n        self::assertNull($clientData['image']);\n\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n\n        $jsonData = self::getPublicAuthorizationCodeTokenResponse(\n            $this->client,\n            clientId: $clientData['identifier'],\n            redirectUri: $clientData['redirectUris'][0],\n        );\n\n        self::assertResponseIsSuccessful();\n        self::assertIsArray($jsonData);\n    }\n\n    #[Group(name: 'NonThreadSafe')]\n    public function testApiCanCreateWorkingClientWithImage(): void\n    {\n        $requestData = [\n            'name' => '/kbin API Created Test Client',\n            'description' => 'An OAuth2 client for testing purposes, created via the API',\n            'contactEmail' => 'test@kbin.test',\n            'redirectUris' => [\n                'https://localhost:3002',\n            ],\n            'grants' => [\n                'authorization_code',\n                'refresh_token',\n            ],\n            'scopes' => [\n                'read',\n                'write',\n                'admin:oauth_clients:read',\n            ],\n        ];\n\n        // Uploading a file appears to delete the file at the given path, so make a copy before upload\n        copy($this->kibbyPath, $this->kibbyPath.'.tmp');\n        $image = new UploadedFile($this->kibbyPath.'.tmp', 'kibby_emoji.png', 'image/png');\n\n        $this->client->request('POST', '/api/client-with-logo', $requestData, files: ['uploadImage' => $image]);\n\n        self::assertResponseIsSuccessful();\n\n        $clientData = self::getJsonResponse($this->client);\n        self::assertIsArray($clientData);\n        self::assertArrayKeysMatch(self::CLIENT_RESPONSE_KEYS, $clientData);\n        self::assertNotNull($clientData['identifier']);\n        self::assertNotNull($clientData['secret']);\n        self::assertEquals($requestData['name'], $clientData['name']);\n        self::assertEquals($requestData['contactEmail'], $clientData['contactEmail']);\n        self::assertEquals($requestData['description'], $clientData['description']);\n        self::assertNull($clientData['user']);\n        self::assertIsArray($clientData['redirectUris']);\n        self::assertEquals($requestData['redirectUris'], $clientData['redirectUris']);\n        self::assertIsArray($clientData['grants']);\n        self::assertEquals($requestData['grants'], $clientData['grants']);\n        self::assertIsArray($clientData['scopes']);\n        self::assertEquals($requestData['scopes'], $clientData['scopes']);\n        self::assertisArray($clientData['image']);\n        self::assertArrayKeysMatch(self::IMAGE_KEYS, $clientData['image']);\n\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n\n        self::runAuthorizationCodeFlowToConsentPage($this->client, 'read write', 'oauth2state', $clientData['identifier'], $clientData['redirectUris'][0]);\n\n        self::assertSelectorExists('img.oauth-client-logo');\n        $logo = $this->client->getCrawler()->filter('img.oauth-client-logo')->first();\n        self::assertStringContainsString($clientData['image']['filePath'], $logo->attr('src'));\n\n        self::runAuthorizationCodeFlowToRedirectUri($this->client, 'read write', 'yes', 'oauth2state', $clientData['identifier'], $clientData['redirectUris'][0]);\n\n        $jsonData = self::runAuthorizationCodeTokenFlow($this->client, $clientData['identifier'], $clientData['secret'], $clientData['redirectUris'][0]);\n\n        self::assertResponseIsSuccessful();\n        self::assertIsArray($jsonData);\n    }\n\n    public function testApiCanDeletePrivateClient(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n        self::createOAuth2AuthCodeClient();\n\n        $query = http_build_query([\n            'client_id' => 'testclient',\n            'client_secret' => 'testsecret',\n        ]);\n\n        $this->client->request('DELETE', '/api/client?'.$query);\n\n        self::assertResponseStatusCodeSame(204);\n\n        $jsonData = self::getAuthorizationCodeTokenResponse($this->client);\n\n        self::assertResponseStatusCodeSame(401);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayHasKey('error', $jsonData);\n        self::assertEquals('invalid_client', $jsonData['error']);\n        self::assertArrayHasKey('error_description', $jsonData);\n    }\n\n    public function testAdminApiCanAccessClientStats(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe', isAdmin: true));\n        self::createOAuth2AuthCodeClient();\n\n        $jsonData = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'admin:oauth_clients:read');\n\n        self::assertResponseIsSuccessful();\n        self::assertIsArray($jsonData);\n        self::assertArrayHasKey('access_token', $jsonData);\n\n        $token = 'Bearer '.$jsonData['access_token'];\n\n        $query = http_build_query([\n            'resolution' => 'day',\n        ]);\n\n        $this->client->request('GET', '/api/clients/stats?'.$query, server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseIsSuccessful();\n\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayHasKey('data', $jsonData);\n        self::assertIsArray($jsonData['data']);\n        self::assertCount(1, $jsonData['data']);\n        self::assertIsArray($jsonData['data'][0]);\n        self::assertArrayHasKey('client', $jsonData['data'][0]);\n        self::assertEquals('/kbin Test Client', $jsonData['data'][0]['client']);\n        self::assertArrayHasKey('datetime', $jsonData['data'][0]);\n\n        // If tests are run near midnight UTC we might get unlucky with a failure, but that\n        // should be unlikely.\n        $today = (new \\DateTime())->setTime(0, 0)->format('Y-m-d H:i:s');\n\n        self::assertEquals($today, $jsonData['data'][0]['datetime']);\n        self::assertArrayHasKey('count', $jsonData['data'][0]);\n        self::assertEquals(1, $jsonData['data'][0]['count']);\n    }\n\n    public function testAdminApiCannotAccessClientStatsWithoutScope(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe', isAdmin: true));\n        self::createOAuth2AuthCodeClient();\n\n        $jsonData = self::getAuthorizationCodeTokenResponse($this->client);\n\n        self::assertResponseIsSuccessful();\n        self::assertIsArray($jsonData);\n        self::assertArrayHasKey('access_token', $jsonData);\n\n        $token = 'Bearer '.$jsonData['access_token'];\n\n        $query = http_build_query([\n            'resolution' => 'day',\n        ]);\n\n        $this->client->request('GET', '/api/clients/stats?'.$query, server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n\n        $jsonData = self::getJsonResponse($this->client);\n        self::assertIsArray($jsonData);\n        self::assertArrayHasKey('type', $jsonData);\n        self::assertEquals('https://tools.ietf.org/html/rfc2616#section-10', $jsonData['type']);\n        self::assertArrayHasKey('title', $jsonData);\n        self::assertEquals('An error occurred', $jsonData['title']);\n        self::assertArrayHasKey('status', $jsonData);\n        self::assertEquals(403, $jsonData['status']);\n        self::assertArrayHasKey('detail', $jsonData);\n    }\n\n    public function testAdminApiCanAccessClientList(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe', isAdmin: true));\n        self::createOAuth2AuthCodeClient();\n\n        $jsonData = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'admin:oauth_clients:read');\n\n        self::assertResponseIsSuccessful();\n        self::assertIsArray($jsonData);\n        self::assertArrayHasKey('access_token', $jsonData);\n\n        $token = 'Bearer '.$jsonData['access_token'];\n\n        $this->client->request('GET', '/api/clients', server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseIsSuccessful();\n\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayHasKey('items', $jsonData);\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(1, $jsonData['items']);\n\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayHasKey('identifier', $jsonData['items'][0]);\n        self::assertArrayNotHasKey('secret', $jsonData['items'][0]);\n        self::assertEquals('testclient', $jsonData['items'][0]['identifier']);\n        self::assertArrayHasKey('name', $jsonData['items'][0]);\n        self::assertEquals('/kbin Test Client', $jsonData['items'][0]['name']);\n        self::assertArrayHasKey('contactEmail', $jsonData['items'][0]);\n        self::assertEquals('test@kbin.test', $jsonData['items'][0]['contactEmail']);\n        self::assertArrayHasKey('description', $jsonData['items'][0]);\n        self::assertEquals('An OAuth2 client for testing purposes', $jsonData['items'][0]['description']);\n        self::assertArrayHasKey('user', $jsonData['items'][0]);\n        self::assertNull($jsonData['items'][0]['user']);\n        self::assertArrayHasKey('active', $jsonData['items'][0]);\n        self::assertEquals(true, $jsonData['items'][0]['active']);\n        self::assertArrayHasKey('createdAt', $jsonData['items'][0]);\n        self::assertNotNull($jsonData['items'][0]['createdAt']);\n        self::assertArrayHasKey('redirectUris', $jsonData['items'][0]);\n        self::assertIsArray($jsonData['items'][0]['redirectUris']);\n        self::assertCount(1, $jsonData['items'][0]['redirectUris']);\n        self::assertArrayHasKey('grants', $jsonData['items'][0]);\n        self::assertIsArray($jsonData['items'][0]['grants']);\n        self::assertCount(2, $jsonData['items'][0]['grants']);\n        self::assertArrayHasKey('scopes', $jsonData['items'][0]);\n        self::assertIsArray($jsonData['items'][0]['scopes']);\n\n        self::assertArrayHasKey('pagination', $jsonData);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayHasKey('count', $jsonData['pagination']);\n        self::assertEquals(1, $jsonData['pagination']['count']);\n        self::assertArrayHasKey('currentPage', $jsonData['pagination']);\n        self::assertEquals(1, $jsonData['pagination']['currentPage']);\n        self::assertArrayHasKey('maxPage', $jsonData['pagination']);\n        self::assertEquals(1, $jsonData['pagination']['maxPage']);\n        self::assertArrayHasKey('perPage', $jsonData['pagination']);\n        self::assertEquals(15, $jsonData['pagination']['perPage']);\n    }\n\n    public function testAdminApiCannotAccessClientListWithoutScope(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe', isAdmin: true));\n        self::createOAuth2AuthCodeClient();\n\n        $jsonData = self::getAuthorizationCodeTokenResponse($this->client);\n\n        self::assertResponseIsSuccessful();\n        self::assertIsArray($jsonData);\n        self::assertArrayHasKey('access_token', $jsonData);\n\n        $token = 'Bearer '.$jsonData['access_token'];\n\n        $this->client->request('GET', '/api/clients', server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayHasKey('type', $jsonData);\n        self::assertEquals('https://tools.ietf.org/html/rfc2616#section-10', $jsonData['type']);\n        self::assertArrayHasKey('title', $jsonData);\n        self::assertEquals('An error occurred', $jsonData['title']);\n        self::assertArrayHasKey('status', $jsonData);\n        self::assertEquals(403, $jsonData['status']);\n        self::assertArrayHasKey('detail', $jsonData);\n    }\n\n    public function testAdminApiCanAccessClientByIdentifier(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe', isAdmin: true));\n        self::createOAuth2AuthCodeClient();\n\n        $jsonData = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'admin:oauth_clients:read');\n\n        self::assertResponseIsSuccessful();\n        self::assertIsArray($jsonData);\n        self::assertArrayHasKey('access_token', $jsonData);\n\n        $token = 'Bearer '.$jsonData['access_token'];\n\n        $this->client->request('GET', '/api/clients/testclient', server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseIsSuccessful();\n\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayHasKey('identifier', $jsonData);\n        self::assertArrayNotHasKey('secret', $jsonData);\n        self::assertEquals('testclient', $jsonData['identifier']);\n        self::assertArrayHasKey('name', $jsonData);\n        self::assertEquals('/kbin Test Client', $jsonData['name']);\n        self::assertArrayHasKey('contactEmail', $jsonData);\n        self::assertEquals('test@kbin.test', $jsonData['contactEmail']);\n        self::assertArrayHasKey('description', $jsonData);\n        self::assertEquals('An OAuth2 client for testing purposes', $jsonData['description']);\n        self::assertArrayHasKey('user', $jsonData);\n        self::assertNull($jsonData['user']);\n        self::assertArrayHasKey('active', $jsonData);\n        self::assertEquals(true, $jsonData['active']);\n        self::assertArrayHasKey('createdAt', $jsonData);\n        self::assertNotNull($jsonData['createdAt']);\n        self::assertArrayHasKey('redirectUris', $jsonData);\n        self::assertIsArray($jsonData['redirectUris']);\n        self::assertCount(1, $jsonData['redirectUris']);\n        self::assertArrayHasKey('grants', $jsonData);\n        self::assertIsArray($jsonData['grants']);\n        self::assertCount(2, $jsonData['grants']);\n        self::assertArrayHasKey('scopes', $jsonData);\n        self::assertIsArray($jsonData['scopes']);\n    }\n\n    public function testApiCanRevokeTokens(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe', isAdmin: true));\n        self::createOAuth2AuthCodeClient();\n\n        $tokenData = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'admin:oauth_clients:read');\n\n        self::assertResponseIsSuccessful();\n        self::assertIsArray($tokenData);\n        self::assertArrayHasKey('access_token', $tokenData);\n        self::assertArrayHasKey('refresh_token', $tokenData);\n\n        $token = 'Bearer '.$tokenData['access_token'];\n\n        $this->client->request('GET', '/api/clients/testclient', server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseIsSuccessful();\n\n        $this->client->request('POST', '/api/revoke', server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(204);\n\n        $this->client->request('GET', '/api/clients/testclient', server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(401);\n\n        $jsonData = self::getRefreshTokenResponse($this->client, $tokenData['refresh_token']);\n\n        self::assertResponseStatusCodeSame(400);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayHasKey('error', $jsonData);\n        self::assertEquals('invalid_grant', $jsonData['error']);\n        self::assertArrayHasKey('error_description', $jsonData);\n        self::assertEquals('The refresh token is invalid.', $jsonData['error_description']);\n        self::assertArrayHasKey('hint', $jsonData);\n        self::assertEquals('Token has been revoked', $jsonData['hint']);\n    }\n\n    public function testAdminApiCannotAccessClientByIdentifierWithoutScope(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe', isAdmin: true));\n        self::createOAuth2AuthCodeClient();\n\n        $jsonData = self::getAuthorizationCodeTokenResponse($this->client);\n\n        self::assertResponseIsSuccessful();\n        self::assertIsArray($jsonData);\n        self::assertArrayHasKey('access_token', $jsonData);\n\n        $token = 'Bearer '.$jsonData['access_token'];\n\n        $this->client->request('GET', '/api/clients/testclient', server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayHasKey('type', $jsonData);\n        self::assertEquals('https://tools.ietf.org/html/rfc2616#section-10', $jsonData['type']);\n        self::assertArrayHasKey('title', $jsonData);\n        self::assertEquals('An error occurred', $jsonData['title']);\n        self::assertArrayHasKey('status', $jsonData);\n        self::assertEquals(403, $jsonData['status']);\n        self::assertArrayHasKey('detail', $jsonData);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/Post/Admin/PostPurgeApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\Post\\Admin;\n\nuse App\\Tests\\WebTestCase;\n\nclass PostPurgeApiTest extends WebTestCase\n{\n    public function testApiCannotPurgeArticlePostAnonymous(): void\n    {\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $post = $this->createPost('test post', magazine: $magazine);\n\n        $this->client->request('DELETE', \"/api/admin/post/{$post->getId()}/purge\");\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotPurgeArticlePostWithoutScope(): void\n    {\n        $user = $this->getUserByUsername('user', isAdmin: true);\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $post = $this->createPost('test post', user: $user, magazine: $magazine);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('DELETE', \"/api/admin/post/{$post->getId()}/purge\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiNonAdminCannotPurgeArticlePost(): void\n    {\n        $otherUser = $this->getUserByUsername('somebody');\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $post = $this->createPost('test post', user: $otherUser, magazine: $magazine);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read admin:post:purge');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('DELETE', \"/api/admin/post/{$post->getId()}/purge\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanPurgeArticlePost(): void\n    {\n        $admin = $this->getUserByUsername('admin', isAdmin: true);\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $post = $this->createPost('test post', user: $user, magazine: $magazine);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($admin);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read admin:post:purge');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('DELETE', \"/api/admin/post/{$post->getId()}/purge\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(204);\n    }\n\n    public function testApiCannotPurgeImagePostAnonymous(): void\n    {\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n\n        $imageDto = $this->getKibbyImageDto();\n        $post = $this->createPost('test image', imageDto: $imageDto, magazine: $magazine);\n\n        $this->client->request('DELETE', \"/api/admin/post/{$post->getId()}/purge\");\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotPurgeImagePostWithoutScope(): void\n    {\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $user = $this->getUserByUsername('user', isAdmin: true);\n\n        $imageDto = $this->getKibbyImageDto();\n        $post = $this->createPost('test image', imageDto: $imageDto, user: $user, magazine: $magazine);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('DELETE', \"/api/admin/post/{$post->getId()}/purge\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiNonAdminCannotPurgeImagePost(): void\n    {\n        $otherUser = $this->getUserByUsername('somebody');\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n\n        $imageDto = $this->getKibbyImageDto();\n        $post = $this->createPost('test image', imageDto: $imageDto, user: $otherUser, magazine: $magazine);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read admin:post:purge');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('DELETE', \"/api/admin/post/{$post->getId()}/purge\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanPurgeImagePost(): void\n    {\n        $admin = $this->getUserByUsername('admin', isAdmin: true);\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n\n        $imageDto = $this->getKibbyImageDto();\n        $post = $this->createPost('test image', imageDto: $imageDto, user: $user, magazine: $magazine);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($admin);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read admin:post:purge');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('DELETE', \"/api/admin/post/{$post->getId()}/purge\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(204);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/Post/Comment/Admin/PostCommentPurgeApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\Post\\Comment\\Admin;\n\nuse App\\Tests\\WebTestCase;\n\nclass PostCommentPurgeApiTest extends WebTestCase\n{\n    public function testApiCannotPurgeCommentAnonymous(): void\n    {\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $post = $this->createPost('test article', magazine: $magazine);\n        $comment = $this->createPostComment('test comment', $post);\n\n        $commentRepository = $this->postCommentRepository;\n\n        $this->client->request('DELETE', \"/api/admin/post-comment/{$comment->getId()}/purge\");\n        self::assertResponseStatusCodeSame(401);\n\n        $comment = $commentRepository->find($comment->getId());\n        self::assertNotNull($comment);\n    }\n\n    public function testApiCannotPurgeCommentWithoutScope(): void\n    {\n        $user = $this->getUserByUsername('user', isAdmin: true);\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $post = $this->createPost('test article', user: $user, magazine: $magazine);\n        $comment = $this->createPostComment('test comment', $post);\n\n        $commentRepository = $this->postCommentRepository;\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('DELETE', \"/api/admin/post-comment/{$comment->getId()}/purge\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n\n        $comment = $commentRepository->find($comment->getId());\n        self::assertNotNull($comment);\n    }\n\n    public function testApiNonAdminCannotPurgeComment(): void\n    {\n        $otherUser = $this->getUserByUsername('somebody');\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $post = $this->createPost('test article', user: $otherUser, magazine: $magazine);\n        $comment = $this->createPostComment('test comment', $post);\n\n        $commentRepository = $this->postCommentRepository;\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read admin:post_comment:purge');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('DELETE', \"/api/admin/post-comment/{$comment->getId()}/purge\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n\n        $comment = $commentRepository->find($comment->getId());\n        self::assertNotNull($comment);\n    }\n\n    public function testApiCanPurgeComment(): void\n    {\n        $admin = $this->getUserByUsername('admin', isAdmin: true);\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $post = $this->createPost('test article', user: $user, magazine: $magazine);\n        $comment = $this->createPostComment('test comment', $post);\n\n        $commentRepository = $this->postCommentRepository;\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($admin);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read admin:post_comment:purge');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('DELETE', \"/api/admin/post-comment/{$comment->getId()}/purge\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(204);\n\n        $comment = $commentRepository->find($comment->getId());\n        self::assertNull($comment);\n    }\n\n    public function testApiCannotPurgeImageCommentAnonymous(): void\n    {\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n\n        $imageDto = $this->getKibbyImageDto();\n        $post = $this->createPost('test image', magazine: $magazine);\n        $comment = $this->createPostComment('test comment', $post, imageDto: $imageDto);\n\n        $commentRepository = $this->postCommentRepository;\n\n        $this->client->request('DELETE', \"/api/admin/post-comment/{$comment->getId()}/purge\");\n        self::assertResponseStatusCodeSame(401);\n\n        $comment = $commentRepository->find($comment->getId());\n        self::assertNotNull($comment);\n    }\n\n    public function testApiCannotPurgeImageCommentWithoutScope(): void\n    {\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $user = $this->getUserByUsername('user', isAdmin: true);\n\n        $imageDto = $this->getKibbyImageDto();\n        $post = $this->createPost('test image', magazine: $magazine);\n        $comment = $this->createPostComment('test comment', $post, imageDto: $imageDto);\n\n        $commentRepository = $this->postCommentRepository;\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('DELETE', \"/api/admin/post-comment/{$comment->getId()}/purge\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n\n        $comment = $commentRepository->find($comment->getId());\n        self::assertNotNull($comment);\n    }\n\n    public function testApiNonAdminCannotPurgeImageComment(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n\n        $imageDto = $this->getKibbyImageDto();\n        $post = $this->createPost('test image', magazine: $magazine);\n        $comment = $this->createPostComment('test comment', $post, imageDto: $imageDto);\n\n        $commentRepository = $this->postCommentRepository;\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read admin:post_comment:purge');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('DELETE', \"/api/admin/post-comment/{$comment->getId()}/purge\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n\n        $comment = $commentRepository->find($comment->getId());\n        self::assertNotNull($comment);\n    }\n\n    public function testApiCanPurgeImageComment(): void\n    {\n        $admin = $this->getUserByUsername('admin', isAdmin: true);\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n\n        $imageDto = $this->getKibbyImageDto();\n        $post = $this->createPost('test image', magazine: $magazine);\n        $comment = $this->createPostComment('test comment', $post, imageDto: $imageDto);\n\n        $commentRepository = $this->postCommentRepository;\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($admin);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read admin:post_comment:purge');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('DELETE', \"/api/admin/post-comment/{$comment->getId()}/purge\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(204);\n\n        $comment = $commentRepository->find($comment->getId());\n        self::assertNull($comment);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/Post/Comment/Moderate/PostCommentSetAdultApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\Post\\Comment\\Moderate;\n\nuse App\\DTO\\ModeratorDto;\nuse App\\Tests\\WebTestCase;\n\nclass PostCommentSetAdultApiTest extends WebTestCase\n{\n    public function testApiCannotSetCommentAdultAnonymous(): void\n    {\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $post = $this->createPost('a post', $magazine);\n        $comment = $this->createPostComment('test comment', $post);\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/post-comment/{$comment->getId()}/adult/true\");\n\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotSetCommentAdultWithoutScope(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $admin = $this->getUserByUsername('admin', isAdmin: true);\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $user2 = $this->getUserByUsername('user2');\n        $post = $this->createPost('a post', magazine: $magazine);\n        $comment = $this->createPostComment('test comment', $post, $user2);\n\n        $magazineManager = $this->magazineManager;\n        $moderator = new ModeratorDto($magazine);\n        $moderator->user = $user;\n        $moderator->addedBy = $admin;\n        $magazineManager->addModerator($moderator);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/post-comment/{$comment->getId()}/adult/true\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiNonModCannotSetCommentAdult(): void\n    {\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $user = $this->getUserByUsername('user');\n        $user2 = $this->getUserByUsername('user2');\n        $post = $this->createPost('a post', $magazine);\n        $comment = $this->createPostComment('test comment', $post, $user2);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:post_comment:set_adult');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/post-comment/{$comment->getId()}/adult/true\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanSetCommentAdult(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $admin = $this->getUserByUsername('admin', isAdmin: true);\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $user2 = $this->getUserByUsername('other');\n        $post = $this->createPost('a post', magazine: $magazine);\n        $comment = $this->createPostComment('test comment', $post, $user2);\n\n        $magazineManager = $this->magazineManager;\n        $moderator = new ModeratorDto($magazine);\n        $moderator->user = $user;\n        $moderator->addedBy = $admin;\n        $magazineManager->addModerator($moderator);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:post_comment:set_adult');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/post-comment/{$comment->getId()}/adult/true\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(200);\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $jsonData);\n        self::assertTrue($jsonData['isAdult']);\n    }\n\n    public function testApiCannotUnsetCommentAdultAnonymous(): void\n    {\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $post = $this->createPost('a post', $magazine);\n        $comment = $this->createPostComment('test comment', $post);\n\n        $entityManager = $this->entityManager;\n        $comment->isAdult = true;\n        $entityManager->persist($comment);\n        $entityManager->flush();\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/post-comment/{$comment->getId()}/adult/false\");\n\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotUnsetCommentAdultWithoutScope(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $admin = $this->getUserByUsername('admin', isAdmin: true);\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $user2 = $this->getUserByUsername('user2');\n        $post = $this->createPost('a post', magazine: $magazine);\n        $comment = $this->createPostComment('test comment', $post, $user2);\n\n        $magazineManager = $this->magazineManager;\n        $moderator = new ModeratorDto($magazine);\n        $moderator->user = $user;\n        $moderator->addedBy = $admin;\n        $magazineManager->addModerator($moderator);\n\n        $entityManager = $this->entityManager;\n        $comment->isAdult = true;\n        $entityManager->persist($comment);\n        $entityManager->flush();\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/post-comment/{$comment->getId()}/adult/false\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiNonModCannotUnsetCommentAdult(): void\n    {\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $user = $this->getUserByUsername('user');\n        $user2 = $this->getUserByUsername('user2');\n        $post = $this->createPost('a post', $magazine);\n        $comment = $this->createPostComment('test comment', $post, $user2);\n\n        $entityManager = $this->entityManager;\n        $comment->isAdult = true;\n        $entityManager->persist($comment);\n        $entityManager->flush();\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:post_comment:set_adult');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/post-comment/{$comment->getId()}/adult/false\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanUnsetCommentAdult(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $admin = $this->getUserByUsername('admin', isAdmin: true);\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $user2 = $this->getUserByUsername('other');\n        $post = $this->createPost('a post', magazine: $magazine);\n        $comment = $this->createPostComment('test comment', $post, $user2);\n\n        $magazineManager = $this->magazineManager;\n        $moderator = new ModeratorDto($magazine);\n        $moderator->user = $user;\n        $moderator->addedBy = $admin;\n        $magazineManager->addModerator($moderator);\n\n        $entityManager = $this->entityManager;\n        $comment->isAdult = true;\n        $entityManager->persist($comment);\n        $entityManager->flush();\n\n        $commentRepository = $this->postCommentRepository;\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:post_comment:set_adult');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/post-comment/{$comment->getId()}/adult/false\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(200);\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $jsonData);\n        self::assertFalse($jsonData['isAdult']);\n\n        $comment = $commentRepository->find($comment->getId());\n        self::assertFalse($comment->isAdult);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/Post/Comment/Moderate/PostCommentSetLanguageApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\Post\\Comment\\Moderate;\n\nuse App\\DTO\\ModeratorDto;\nuse App\\Tests\\WebTestCase;\n\nclass PostCommentSetLanguageApiTest extends WebTestCase\n{\n    public function testApiCannotSetCommentLanguageAnonymous(): void\n    {\n        $post = $this->createPost('a post');\n        $comment = $this->createPostComment('test comment', $post);\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/post-comment/{$comment->getId()}/de\");\n\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotSetCommentLanguageWithoutScope(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $admin = $this->getUserByUsername('admin', isAdmin: true);\n        $magazine = $this->getMagazineByName('acme');\n        $user2 = $this->getUserByUsername('user2');\n        $post = $this->createPost('a post', magazine: $magazine);\n        $comment = $this->createPostComment('test comment', $post, $user2);\n\n        $magazineManager = $this->magazineManager;\n        $moderator = new ModeratorDto($magazine);\n        $moderator->user = $user;\n        $moderator->addedBy = $admin;\n        $magazineManager->addModerator($moderator);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/post-comment/{$comment->getId()}/de\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiNonModCannotSetCommentLanguage(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $user2 = $this->getUserByUsername('user2');\n        $post = $this->createPost('a post');\n        $comment = $this->createPostComment('test comment', $post, $user2);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:post_comment:language');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/post-comment/{$comment->getId()}/de\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanSetCommentLanguage(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $admin = $this->getUserByUsername('admin', isAdmin: true);\n        $magazine = $this->getMagazineByName('acme');\n        $user2 = $this->getUserByUsername('other');\n        $post = $this->createPost('a post', magazine: $magazine);\n        $comment = $this->createPostComment('test comment', $post, $user2);\n\n        $magazineManager = $this->magazineManager;\n        $moderator = new ModeratorDto($magazine);\n        $moderator->user = $user;\n        $moderator->addedBy = $admin;\n        $magazineManager->addModerator($moderator);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:post_comment:language');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/post-comment/{$comment->getId()}/de\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(200);\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $jsonData);\n        self::assertSame($comment->getId(), $jsonData['commentId']);\n        self::assertSame('test comment', $jsonData['body']);\n        self::assertSame('de', $jsonData['lang']);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/Post/Comment/Moderate/PostCommentTrashApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\Post\\Comment\\Moderate;\n\nuse App\\DTO\\ModeratorDto;\nuse App\\Tests\\WebTestCase;\n\nclass PostCommentTrashApiTest extends WebTestCase\n{\n    public function testApiCannotTrashCommentAnonymous(): void\n    {\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $post = $this->createPost('a post', $magazine);\n        $comment = $this->createPostComment('test comment', $post);\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/post-comment/{$comment->getId()}/trash\");\n\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotTrashCommentWithoutScope(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $admin = $this->getUserByUsername('admin', isAdmin: true);\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $user2 = $this->getUserByUsername('user2');\n        $post = $this->createPost('a post', magazine: $magazine);\n        $comment = $this->createPostComment('test comment', $post, $user2);\n\n        $magazineManager = $this->magazineManager;\n        $moderator = new ModeratorDto($magazine);\n        $moderator->user = $user;\n        $moderator->addedBy = $admin;\n        $magazineManager->addModerator($moderator);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/post-comment/{$comment->getId()}/trash\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiNonModCannotTrashComment(): void\n    {\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $user = $this->getUserByUsername('user');\n        $user2 = $this->getUserByUsername('user2');\n        $post = $this->createPost('a post', $magazine);\n        $comment = $this->createPostComment('test comment', $post, $user2);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:post_comment:trash');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/post-comment/{$comment->getId()}/trash\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanTrashComment(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $admin = $this->getUserByUsername('admin', isAdmin: true);\n        $magazine = $this->getMagazineByName('acme');\n        $user2 = $this->getUserByUsername('other');\n        $post = $this->createPost('a post', magazine: $magazine);\n        $comment = $this->createPostComment('test comment', $post, $user2);\n\n        $magazineManager = $this->magazineManager;\n        $moderator = new ModeratorDto($magazine);\n        $moderator->user = $user;\n        $moderator->addedBy = $admin;\n        $magazineManager->addModerator($moderator);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:post_comment:trash');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/post-comment/{$comment->getId()}/trash\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(200);\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $jsonData);\n        self::assertSame($comment->getId(), $jsonData['commentId']);\n        self::assertSame('test comment', $jsonData['body']);\n        self::assertSame('trashed', $jsonData['visibility']);\n    }\n\n    public function testApiCannotRestoreCommentAnonymous(): void\n    {\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $post = $this->createPost('a post', $magazine);\n        $comment = $this->createPostComment('test comment', $post);\n\n        $postCommentManager = $this->postCommentManager;\n        $postCommentManager->trash($this->getUserByUsername('user'), $comment);\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/post-comment/{$comment->getId()}/restore\");\n\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotRestoreCommentWithoutScope(): void\n    {\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $user = $this->getUserByUsername('user');\n        $user2 = $this->getUserByUsername('user2');\n        $post = $this->createPost('a post', $magazine);\n        $comment = $this->createPostComment('test comment', $post, $user2);\n\n        $postCommentManager = $this->postCommentManager;\n        $postCommentManager->trash($user, $comment);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/post-comment/{$comment->getId()}/restore\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiNonModCannotRestoreComment(): void\n    {\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $user = $this->getUserByUsername('user');\n        $user2 = $this->getUserByUsername('user2');\n        $post = $this->createPost('a post', $magazine);\n        $comment = $this->createPostComment('test comment', $post, $user2);\n\n        $postCommentManager = $this->postCommentManager;\n        $postCommentManager->trash($user, $comment);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:post_comment:trash');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/post-comment/{$comment->getId()}/restore\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanRestoreComment(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $admin = $this->getUserByUsername('admin', isAdmin: true);\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $user2 = $this->getUserByUsername('other');\n        $post = $this->createPost('a post', magazine: $magazine);\n        $comment = $this->createPostComment('test comment', $post, $user2);\n\n        $magazineManager = $this->magazineManager;\n        $moderator = new ModeratorDto($magazine);\n        $moderator->user = $user;\n        $moderator->addedBy = $admin;\n        $magazineManager->addModerator($moderator);\n\n        $postCommentManager = $this->postCommentManager;\n        $postCommentManager->trash($user, $comment);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:post_comment:trash');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/post-comment/{$comment->getId()}/restore\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(200);\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $jsonData);\n        self::assertSame($comment->getId(), $jsonData['commentId']);\n        self::assertSame('test comment', $jsonData['body']);\n        self::assertSame('visible', $jsonData['visibility']);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/Post/Comment/PostCommentCreateApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\Post\\Comment;\n\nuse App\\Tests\\WebTestCase;\nuse Symfony\\Component\\HttpFoundation\\File\\UploadedFile;\n\nclass PostCommentCreateApiTest extends WebTestCase\n{\n    public function testApiCannotCreateCommentAnonymous(): void\n    {\n        $post = $this->createPost('a post');\n\n        $comment = [\n            'body' => 'Test comment',\n            'lang' => 'en',\n            'isAdult' => false,\n        ];\n\n        $this->client->jsonRequest(\n            'POST', \"/api/posts/{$post->getId()}/comments\",\n            parameters: $comment\n        );\n\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotCreateCommentWithoutScope(): void\n    {\n        $post = $this->createPost('a post');\n\n        $comment = [\n            'body' => 'Test comment',\n            'lang' => 'en',\n            'isAdult' => false,\n        ];\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest(\n            'POST', \"/api/posts/{$post->getId()}/comments\",\n            parameters: $comment, server: ['HTTP_AUTHORIZATION' => $token]\n        );\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanCreateComment(): void\n    {\n        $post = $this->createPost('a post');\n\n        $comment = [\n            'body' => 'Test comment',\n            'lang' => 'en',\n            'isAdult' => false,\n        ];\n\n        self::createOAuth2AuthCodeClient();\n        $user = $this->getUserByUsername('user');\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read post_comment:create');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest(\n            'POST', \"/api/posts/{$post->getId()}/comments\",\n            parameters: $comment, server: ['HTTP_AUTHORIZATION' => $token]\n        );\n\n        self::assertResponseStatusCodeSame(201);\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $jsonData);\n        self::assertSame($comment['body'], $jsonData['body']);\n        self::assertSame($comment['lang'], $jsonData['lang']);\n        self::assertSame($comment['isAdult'], $jsonData['isAdult']);\n        self::assertSame($post->getId(), $jsonData['postId']);\n        self::assertIsArray($jsonData['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']);\n        self::assertSame($post->magazine->getId(), $jsonData['magazine']['magazineId']);\n        self::assertIsArray($jsonData['user']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']);\n        self::assertSame($user->getId(), $jsonData['user']['userId']);\n        self::assertNull($jsonData['rootId']);\n        self::assertNull($jsonData['parentId']);\n    }\n\n    public function testApiCannotCreateCommentReplyAnonymous(): void\n    {\n        $post = $this->createPost('a post');\n        $postComment = $this->createPostComment('a comment', $post);\n\n        $comment = [\n            'body' => 'Test comment',\n            'lang' => 'en',\n            'isAdult' => false,\n        ];\n\n        $this->client->jsonRequest(\n            'POST', \"/api/posts/{$post->getId()}/comments/{$postComment->getId()}/reply\",\n            parameters: $comment\n        );\n\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotCreateCommentReplyWithoutScope(): void\n    {\n        $post = $this->createPost('a post');\n        $postComment = $this->createPostComment('a comment', $post);\n\n        $comment = [\n            'body' => 'Test comment',\n            'lang' => 'en',\n            'isAdult' => false,\n        ];\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest(\n            'POST', \"/api/posts/{$post->getId()}/comments/{$postComment->getId()}/reply\",\n            parameters: $comment, server: ['HTTP_AUTHORIZATION' => $token]\n        );\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanCreateCommentReply(): void\n    {\n        $post = $this->createPost('a post');\n        $postComment = $this->createPostComment('a comment', $post);\n\n        $comment = [\n            'body' => 'Test comment',\n            'lang' => 'en',\n            'isAdult' => false,\n        ];\n\n        self::createOAuth2AuthCodeClient();\n        $user = $this->getUserByUsername('user');\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read post_comment:create');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest(\n            'POST', \"/api/posts/{$post->getId()}/comments/{$postComment->getId()}/reply\",\n            parameters: $comment, server: ['HTTP_AUTHORIZATION' => $token]\n        );\n\n        self::assertResponseStatusCodeSame(201);\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $jsonData);\n        self::assertSame($comment['body'], $jsonData['body']);\n        self::assertSame($comment['lang'], $jsonData['lang']);\n        self::assertSame($comment['isAdult'], $jsonData['isAdult']);\n        self::assertSame($post->getId(), $jsonData['postId']);\n        self::assertIsArray($jsonData['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']);\n        self::assertSame($post->magazine->getId(), $jsonData['magazine']['magazineId']);\n        self::assertIsArray($jsonData['user']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']);\n        self::assertSame($user->getId(), $jsonData['user']['userId']);\n        self::assertSame($postComment->getId(), $jsonData['rootId']);\n        self::assertSame($postComment->getId(), $jsonData['parentId']);\n    }\n\n    public function testApiCannotCreateImageCommentAnonymous(): void\n    {\n        $post = $this->createPost('a post');\n\n        $comment = [\n            'body' => 'Test comment',\n            'lang' => 'en',\n            'isAdult' => false,\n            'alt' => 'It\\'s Kibby!',\n        ];\n\n        // Uploading a file appears to delete the file at the given path, so make a copy before upload\n        copy($this->kibbyPath, $this->kibbyPath.'.tmp');\n        $image = new UploadedFile($this->kibbyPath.'.tmp', 'kibby_emoji.png', 'image/png');\n\n        $this->client->request(\n            'POST', \"/api/posts/{$post->getId()}/comments/image\",\n            parameters: $comment, files: ['uploadImage' => $image]\n        );\n\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotCreateImageCommentWithoutScope(): void\n    {\n        $post = $this->createPost('a post');\n\n        $comment = [\n            'body' => 'Test comment',\n            'lang' => 'en',\n            'isAdult' => false,\n            'alt' => 'It\\'s Kibby!',\n        ];\n\n        // Uploading a file appears to delete the file at the given path, so make a copy before upload\n        $tmpPath = bin2hex(random_bytes(32));\n        copy($this->kibbyPath, $tmpPath.'.png');\n        $image = new UploadedFile($tmpPath.'.png', 'kibby_emoji.png', 'image/png');\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request(\n            'POST', \"/api/posts/{$post->getId()}/comments/image\",\n            parameters: $comment, files: ['uploadImage' => $image],\n            server: ['HTTP_AUTHORIZATION' => $token]\n        );\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanCreateImageComment(): void\n    {\n        $post = $this->createPost('a post');\n\n        $comment = [\n            'body' => 'Test comment',\n            'lang' => 'en',\n            'isAdult' => false,\n            'alt' => 'It\\'s Kibby!',\n        ];\n\n        // Uploading a file appears to delete the file at the given path, so make a copy before upload\n        copy($this->kibbyPath, $this->kibbyPath.'.tmp');\n        $image = new UploadedFile($this->kibbyPath.'.tmp', 'kibby_emoji.png', 'image/png');\n\n        self::createOAuth2AuthCodeClient();\n        $user = $this->getUserByUsername('user');\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read post_comment:create');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request(\n            'POST', \"/api/posts/{$post->getId()}/comments/image\",\n            parameters: $comment, files: ['uploadImage' => $image],\n            server: ['HTTP_AUTHORIZATION' => $token]\n        );\n\n        self::assertResponseStatusCodeSame(201);\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $jsonData);\n        self::assertSame($comment['body'], $jsonData['body']);\n        self::assertSame($comment['lang'], $jsonData['lang']);\n        self::assertSame($comment['isAdult'], $jsonData['isAdult']);\n        self::assertSame($post->getId(), $jsonData['postId']);\n        self::assertIsArray($jsonData['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']);\n        self::assertSame($post->magazine->getId(), $jsonData['magazine']['magazineId']);\n        self::assertIsArray($jsonData['user']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']);\n        self::assertSame($user->getId(), $jsonData['user']['userId']);\n        self::assertNull($jsonData['rootId']);\n        self::assertNull($jsonData['parentId']);\n    }\n\n    public function testApiCannotCreateImageCommentReplyAnonymous(): void\n    {\n        $post = $this->createPost('a post');\n        $postComment = $this->createPostComment('a comment', $post);\n\n        $comment = [\n            'body' => 'Test comment',\n            'lang' => 'en',\n            'isAdult' => false,\n            'alt' => 'It\\'s Kibby!',\n        ];\n\n        // Uploading a file appears to delete the file at the given path, so make a copy before upload\n        copy($this->kibbyPath, $this->kibbyPath.'.tmp');\n        $image = new UploadedFile($this->kibbyPath.'.tmp', 'kibby_emoji.png', 'image/png');\n\n        $this->client->request(\n            'POST', \"/api/posts/{$post->getId()}/comments/{$postComment->getId()}/reply/image\",\n            parameters: $comment, files: ['uploadImage' => $image]\n        );\n\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotCreateImageCommentReplyWithoutScope(): void\n    {\n        $post = $this->createPost('a post');\n        $postComment = $this->createPostComment('a comment', $post);\n\n        $comment = [\n            'body' => 'Test comment',\n            'lang' => 'en',\n            'isAdult' => false,\n        ];\n\n        // Uploading a file appears to delete the file at the given path, so make a copy before upload\n        $tmpPath = bin2hex(random_bytes(32));\n        copy($this->kibbyPath, $tmpPath.'.tmp');\n        $image = new UploadedFile($tmpPath.'.tmp', 'kibby_emoji.png', 'image/png');\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request(\n            'POST', \"/api/posts/{$post->getId()}/comments/{$postComment->getId()}/reply/image\",\n            parameters: $comment, files: ['uploadImage' => $image],\n            server: ['HTTP_AUTHORIZATION' => $token]\n        );\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanCreateImageCommentReply(): void\n    {\n        $post = $this->createPost('a post');\n        $postComment = $this->createPostComment('a comment', $post);\n\n        $comment = [\n            'body' => 'Test comment',\n            'lang' => 'en',\n            'isAdult' => false,\n            'alt' => 'It\\'s Kibby!',\n        ];\n\n        // Uploading a file appears to delete the file at the given path, so make a copy before upload\n        $tmpPath = bin2hex(random_bytes(32));\n        copy($this->kibbyPath, $tmpPath.'.png');\n        $image = new UploadedFile($tmpPath.'.png', 'kibby_emoji.png', 'image/png');\n\n        $imageManager = $this->imageManager;\n        $expectedPath = $imageManager->getFilePath($image->getFilename());\n\n        self::createOAuth2AuthCodeClient();\n        $user = $this->getUserByUsername('user');\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read post_comment:create');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request(\n            'POST', \"/api/posts/{$post->getId()}/comments/{$postComment->getId()}/reply/image\",\n            parameters: $comment, files: ['uploadImage' => $image],\n            server: ['HTTP_AUTHORIZATION' => $token]\n        );\n\n        self::assertResponseStatusCodeSame(201);\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $jsonData);\n        self::assertSame($comment['body'], $jsonData['body']);\n        self::assertSame($comment['lang'], $jsonData['lang']);\n        self::assertSame($comment['isAdult'], $jsonData['isAdult']);\n        self::assertSame($post->getId(), $jsonData['postId']);\n        self::assertIsArray($jsonData['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']);\n        self::assertSame($post->magazine->getId(), $jsonData['magazine']['magazineId']);\n        self::assertIsArray($jsonData['user']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']);\n        self::assertSame($user->getId(), $jsonData['user']['userId']);\n        self::assertSame($postComment->getId(), $jsonData['rootId']);\n        self::assertSame($postComment->getId(), $jsonData['parentId']);\n        self::assertIsArray($jsonData['image']);\n        self::assertArrayKeysMatch(self::IMAGE_KEYS, $jsonData['image']);\n        self::assertEquals($expectedPath, $jsonData['image']['filePath']);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/Post/Comment/PostCommentDeleteApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\Post\\Comment;\n\nuse App\\Tests\\WebTestCase;\n\nclass PostCommentDeleteApiTest extends WebTestCase\n{\n    public function testApiCannotDeleteCommentAnonymous(): void\n    {\n        $post = $this->createPost('a post');\n        $comment = $this->createPostComment('test comment', $post);\n\n        $this->client->request('DELETE', \"/api/post-comments/{$comment->getId()}\");\n\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotDeleteCommentWithoutScope(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $post = $this->createPost('a post');\n        $comment = $this->createPostComment('test comment', $post, $user);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('DELETE', \"/api/post-comments/{$comment->getId()}\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCannotDeleteOtherUsersComment(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $user2 = $this->getUserByUsername('other');\n        $post = $this->createPost('a post');\n        $comment = $this->createPostComment('test comment', $post, $user2);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read post_comment:delete');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('DELETE', \"/api/post-comments/{$comment->getId()}\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanDeleteComment(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $post = $this->createPost('a post');\n        $comment = $this->createPostComment('test comment', $post, $user);\n\n        $commentRepository = $this->postCommentRepository;\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read post_comment:delete');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('DELETE', \"/api/post-comments/{$comment->getId()}\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(204);\n        $comment = $commentRepository->find($comment->getId());\n        self::assertNull($comment);\n    }\n\n    public function testApiCanSoftDeleteComment(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $post = $this->createPost('a post');\n        $comment = $this->createPostComment('test comment', $post, $user);\n        $this->createPostComment('test comment', $post, $user, parent: $comment);\n\n        $commentRepository = $this->postCommentRepository;\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read post_comment:delete');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('DELETE', \"/api/post-comments/{$comment->getId()}\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(204);\n        $comment = $commentRepository->find($comment->getId());\n        self::assertNotNull($comment);\n        self::assertTrue($comment->isSoftDeleted());\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/Post/Comment/PostCommentReportApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\Post\\Comment;\n\nuse App\\Tests\\WebTestCase;\n\nclass PostCommentReportApiTest extends WebTestCase\n{\n    public function testApiCannotReportCommentAnonymous(): void\n    {\n        $post = $this->createPost('a post');\n        $comment = $this->createPostComment('test comment', $post);\n\n        $report = [\n            'reason' => 'This comment breaks the rules!',\n        ];\n\n        $this->client->jsonRequest('POST', \"/api/post-comments/{$comment->getId()}/report\", $report);\n\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotReportCommentWithoutScope(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $post = $this->createPost('a post');\n        $comment = $this->createPostComment('test comment', $post, $user);\n\n        $report = [\n            'reason' => 'This comment breaks the rules!',\n        ];\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('POST', \"/api/post-comments/{$comment->getId()}/report\", $report, server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanReportOtherUsersComment(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $user2 = $this->getUserByUsername('other');\n        $post = $this->createPost('a post');\n        $comment = $this->createPostComment('test comment', $post, $user2);\n\n        $reportRepository = $this->reportRepository;\n\n        $report = [\n            'reason' => 'This comment breaks the rules!',\n        ];\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read post_comment:report');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('POST', \"/api/post-comments/{$comment->getId()}/report\", $report, server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(204);\n        $report = $reportRepository->findBySubject($comment);\n        self::assertNotNull($report);\n        self::assertSame('This comment breaks the rules!', $report->reason);\n        self::assertSame($user->getId(), $report->reporting->getId());\n    }\n\n    public function testApiCanReportOwnComment(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $post = $this->createPost('a post');\n        $comment = $this->createPostComment('test comment', $post, $user);\n\n        $reportRepository = $this->reportRepository;\n\n        $report = [\n            'reason' => 'This comment breaks the rules!',\n        ];\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read post_comment:report');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('POST', \"/api/post-comments/{$comment->getId()}/report\", $report, server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(204);\n        $report = $reportRepository->findBySubject($comment);\n        self::assertNotNull($report);\n        self::assertSame('This comment breaks the rules!', $report->reason);\n        self::assertSame($user->getId(), $report->reporting->getId());\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/Post/Comment/PostCommentRetrieveApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\Post\\Comment;\n\nuse App\\Tests\\WebTestCase;\n\nclass PostCommentRetrieveApiTest extends WebTestCase\n{\n    public function testApiCanGetPostCommentsAnonymous(): void\n    {\n        $post = $this->createPost('test post');\n        for ($i = 0; $i < 5; ++$i) {\n            $this->createPostComment(\"test parent comment {$i}\", $post);\n        }\n\n        $this->client->request('GET', \"/api/posts/{$post->getId()}/comments\");\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(5, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(5, $jsonData['pagination']['count']);\n\n        foreach ($jsonData['items'] as $comment) {\n            self::assertIsArray($comment);\n            self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $comment);\n            self::assertIsArray($comment['user']);\n            self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $comment['user']);\n            self::assertIsArray($comment['magazine']);\n            self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $comment['magazine']);\n            self::assertSame($post->getId(), $comment['postId']);\n            self::assertStringContainsString('test parent comment', $comment['body']);\n            self::assertSame('en', $comment['lang']);\n            self::assertSame(0, $comment['uv']);\n            self::assertSame(0, $comment['favourites']);\n            self::assertSame(0, $comment['childCount']);\n            self::assertSame('visible', $comment['visibility']);\n            self::assertIsArray($comment['mentions']);\n            self::assertEmpty($comment['mentions']);\n            self::assertIsArray($comment['children']);\n            self::assertEmpty($comment['children']);\n            self::assertFalse($comment['isAdult']);\n            self::assertNull($comment['image']);\n            self::assertNull($comment['parentId']);\n            self::assertNull($comment['rootId']);\n            self::assertNull($comment['isFavourited']);\n            self::assertNull($comment['userVote']);\n            self::assertNull($comment['apId']);\n            self::assertEmpty($comment['tags']);\n            self::assertNull($comment['editedAt']);\n            self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $comment['createdAt'], 'createdAt date format invalid');\n            self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $comment['lastActive'], 'lastActive date format invalid');\n            self::assertNull($comment['bookmarks']);\n        }\n    }\n\n    public function testApiCannotGetPostCommentsByPreferredLangAnonymous(): void\n    {\n        $post = $this->createPost('test post');\n        for ($i = 0; $i < 5; ++$i) {\n            $this->createPostComment(\"test parent comment {$i}\", $post);\n        }\n\n        $this->client->request('GET', \"/api/posts/{$post->getId()}/comments?usePreferredLangs=true\");\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanGetPostCommentsByPreferredLang(): void\n    {\n        $post = $this->createPost('test post');\n        for ($i = 0; $i < 5; ++$i) {\n            $this->createPostComment(\"test parent comment {$i}\", $post);\n            $this->createPostComment(\"test german parent comment {$i}\", $post, lang: 'de');\n            $this->createPostComment(\"test dutch parent comment {$i}\", $post, lang: 'nl');\n        }\n\n        self::createOAuth2AuthCodeClient();\n        $user = $this->getUserByUsername('user');\n        $user->preferredLanguages = ['en', 'de'];\n\n        $entityManager = $this->entityManager;\n        $entityManager->persist($user);\n        $entityManager->flush();\n\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/posts/{$post->getId()}/comments?usePreferredLangs=true\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(10, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(10, $jsonData['pagination']['count']);\n\n        foreach ($jsonData['items'] as $comment) {\n            self::assertIsArray($comment);\n            self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $comment);\n            self::assertIsArray($comment['user']);\n            self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $comment['user']);\n            self::assertIsArray($comment['magazine']);\n            self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $comment['magazine']);\n            self::assertSame($post->getId(), $comment['postId']);\n            self::assertStringContainsString('parent comment', $comment['body']);\n            self::assertTrue('en' === $comment['lang'] || 'de' === $comment['lang']);\n            self::assertSame(0, $comment['uv']);\n            self::assertSame(0, $comment['favourites']);\n            self::assertSame(0, $comment['childCount']);\n            self::assertSame('visible', $comment['visibility']);\n            self::assertIsArray($comment['mentions']);\n            self::assertEmpty($comment['mentions']);\n            self::assertIsArray($comment['children']);\n            self::assertEmpty($comment['children']);\n            self::assertFalse($comment['isAdult']);\n            self::assertNull($comment['image']);\n            self::assertNull($comment['parentId']);\n            self::assertNull($comment['rootId']);\n            // No scope granted so these should be null\n            self::assertNull($comment['isFavourited']);\n            self::assertNull($comment['userVote']);\n            self::assertNull($comment['apId']);\n            self::assertEmpty($comment['tags']);\n            self::assertNull($comment['editedAt']);\n            self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $comment['createdAt'], 'createdAt date format invalid');\n            self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $comment['lastActive'], 'lastActive date format invalid');\n            self::assertIsArray($comment['bookmarks']);\n            self::assertEmpty($comment['bookmarks']);\n        }\n    }\n\n    public function testApiCanGetPostCommentsWithLanguageAnonymous(): void\n    {\n        $post = $this->createPost('test post');\n        for ($i = 0; $i < 5; ++$i) {\n            $this->createPostComment(\"test parent comment {$i}\", $post);\n            $this->createPostComment(\"test german parent comment {$i}\", $post, lang: 'de');\n            $this->createPostComment(\"test dutch comment {$i}\", $post, lang: 'nl');\n        }\n\n        $this->client->request('GET', \"/api/posts/{$post->getId()}/comments?lang[]=en&lang[]=de\");\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(10, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(10, $jsonData['pagination']['count']);\n\n        foreach ($jsonData['items'] as $comment) {\n            self::assertIsArray($comment);\n            self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $comment);\n            self::assertIsArray($comment['user']);\n            self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $comment['user']);\n            self::assertIsArray($comment['magazine']);\n            self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $comment['magazine']);\n            self::assertSame($post->getId(), $comment['postId']);\n            self::assertStringContainsString('parent comment', $comment['body']);\n            self::assertTrue('en' === $comment['lang'] || 'de' === $comment['lang']);\n            self::assertSame(0, $comment['uv']);\n            self::assertSame(0, $comment['favourites']);\n            self::assertSame(0, $comment['childCount']);\n            self::assertSame('visible', $comment['visibility']);\n            self::assertIsArray($comment['mentions']);\n            self::assertEmpty($comment['mentions']);\n            self::assertIsArray($comment['children']);\n            self::assertEmpty($comment['children']);\n            self::assertFalse($comment['isAdult']);\n            self::assertNull($comment['image']);\n            self::assertNull($comment['parentId']);\n            self::assertNull($comment['rootId']);\n            self::assertNull($comment['isFavourited']);\n            self::assertNull($comment['userVote']);\n            self::assertNull($comment['apId']);\n            self::assertEmpty($comment['tags']);\n            self::assertNull($comment['editedAt']);\n            self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $comment['createdAt'], 'createdAt date format invalid');\n            self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $comment['lastActive'], 'lastActive date format invalid');\n            self::assertNull($comment['bookmarks']);\n        }\n    }\n\n    public function testApiCanGetPostCommentsWithLanguage(): void\n    {\n        $post = $this->createPost('test post');\n        for ($i = 0; $i < 5; ++$i) {\n            $this->createPostComment(\"test parent comment {$i}\", $post);\n            $this->createPostComment(\"test german parent comment {$i}\", $post, lang: 'de');\n            $this->createPostComment(\"test dutch parent comment {$i}\", $post, lang: 'nl');\n        }\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/posts/{$post->getId()}/comments?lang[]=en&lang[]=de\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(10, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(10, $jsonData['pagination']['count']);\n\n        foreach ($jsonData['items'] as $comment) {\n            self::assertIsArray($comment);\n            self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $comment);\n            self::assertIsArray($comment['user']);\n            self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $comment['user']);\n            self::assertIsArray($comment['magazine']);\n            self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $comment['magazine']);\n            self::assertSame($post->getId(), $comment['postId']);\n            self::assertStringContainsString('parent comment', $comment['body']);\n            self::assertTrue('en' === $comment['lang'] || 'de' === $comment['lang']);\n            self::assertSame(0, $comment['uv']);\n            self::assertSame(0, $comment['favourites']);\n            self::assertSame(0, $comment['childCount']);\n            self::assertSame('visible', $comment['visibility']);\n            self::assertIsArray($comment['mentions']);\n            self::assertEmpty($comment['mentions']);\n            self::assertIsArray($comment['children']);\n            self::assertEmpty($comment['children']);\n            self::assertFalse($comment['isAdult']);\n            self::assertNull($comment['image']);\n            self::assertNull($comment['parentId']);\n            self::assertNull($comment['rootId']);\n            // No scope granted so these should be null\n            self::assertNull($comment['isFavourited']);\n            self::assertNull($comment['userVote']);\n            self::assertNull($comment['apId']);\n            self::assertEmpty($comment['tags']);\n            self::assertNull($comment['editedAt']);\n            self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $comment['createdAt'], 'createdAt date format invalid');\n            self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $comment['lastActive'], 'lastActive date format invalid');\n            self::assertIsArray($comment['bookmarks']);\n            self::assertEmpty($comment['bookmarks']);\n        }\n    }\n\n    public function testApiCanGetPostComments(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $post = $this->createPost('test post');\n        for ($i = 0; $i < 5; ++$i) {\n            $this->createPostComment(\"test parent comment {$i} #tag @user\", $post);\n        }\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/posts/{$post->getId()}/comments\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(5, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(5, $jsonData['pagination']['count']);\n\n        foreach ($jsonData['items'] as $comment) {\n            self::assertIsArray($comment);\n            self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $comment);\n            self::assertIsArray($comment['user']);\n            self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $comment['user']);\n            self::assertIsArray($comment['magazine']);\n            self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $comment['magazine']);\n            self::assertSame($post->getId(), $comment['postId']);\n            self::assertStringContainsString('test parent comment', $comment['body']);\n            self::assertSame('en', $comment['lang']);\n            self::assertSame(0, $comment['uv']);\n            self::assertSame(0, $comment['favourites']);\n            self::assertSame(0, $comment['childCount']);\n            self::assertSame('visible', $comment['visibility']);\n            self::assertIsArray($comment['mentions']);\n            self::assertSame(['@user'], $comment['mentions']);\n            self::assertIsArray($comment['tags']);\n            self::assertSame(['tag'], $comment['tags']);\n            self::assertIsArray($comment['children']);\n            self::assertEmpty($comment['children']);\n            self::assertFalse($comment['isAdult']);\n            self::assertNull($comment['image']);\n            self::assertNull($comment['parentId']);\n            self::assertNull($comment['rootId']);\n            // No scope granted so these should be null\n            self::assertNull($comment['isFavourited']);\n            self::assertNull($comment['userVote']);\n            self::assertNull($comment['apId']);\n            self::assertNull($comment['editedAt']);\n            self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $comment['createdAt'], 'createdAt date format invalid');\n            self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $comment['lastActive'], 'lastActive date format invalid');\n            self::assertIsArray($comment['bookmarks']);\n            self::assertEmpty($comment['bookmarks']);\n        }\n    }\n\n    public function testApiCanGetPostCommentsWithChildren(): void\n    {\n        $post = $this->createPost('test post');\n        for ($i = 0; $i < 5; ++$i) {\n            $comment = $this->createPostComment(\"test parent comment {$i}\", $post);\n            $this->createPostComment(\"test child comment {$i}\", $post, parent: $comment);\n        }\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/posts/{$post->getId()}/comments\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(5, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(5, $jsonData['pagination']['count']);\n\n        foreach ($jsonData['items'] as $comment) {\n            self::assertIsArray($comment);\n            self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $comment);\n            self::assertIsArray($comment['user']);\n            self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $comment['user']);\n            self::assertIsArray($comment['magazine']);\n            self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $comment['magazine']);\n            self::assertSame($post->getId(), $comment['postId']);\n            self::assertStringContainsString('test parent comment', $comment['body']);\n            self::assertSame('en', $comment['lang']);\n            self::assertSame(0, $comment['uv']);\n            self::assertSame(0, $comment['favourites']);\n            self::assertSame(1, $comment['childCount']);\n            self::assertSame('visible', $comment['visibility']);\n            self::assertIsArray($comment['mentions']);\n            self::assertEmpty($comment['mentions']);\n            self::assertIsArray($comment['children']);\n            self::assertCount(1, $comment['children']);\n            self::assertIsArray($comment['children'][0]);\n            self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $comment['children'][0]);\n            self::assertStringContainsString('test child comment', $comment['children'][0]['body']);\n            self::assertFalse($comment['isAdult']);\n            self::assertNull($comment['image']);\n            self::assertNull($comment['parentId']);\n            self::assertNull($comment['rootId']);\n            // No scope granted so these should be null\n            self::assertNull($comment['isFavourited']);\n            self::assertNull($comment['userVote']);\n            self::assertNull($comment['apId']);\n            self::assertEmpty($comment['tags']);\n            self::assertNull($comment['editedAt']);\n            self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $comment['createdAt'], 'createdAt date format invalid');\n            self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $comment['lastActive'], 'lastActive date format invalid');\n            self::assertIsArray($comment['bookmarks']);\n            self::assertEmpty($comment['bookmarks']);\n        }\n    }\n\n    public function testApiCanGetPostCommentsLimitedDepth(): void\n    {\n        $post = $this->createPost('test post');\n        for ($i = 0; $i < 2; ++$i) {\n            $comment = $this->createPostComment(\"test parent comment {$i}\", $post);\n            $parent = $comment;\n            for ($j = 1; $j <= 5; ++$j) {\n                $parent = $this->createPostComment(\"test child comment {$i} depth {$j}\", $post, parent: $parent);\n            }\n        }\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/posts/{$post->getId()}/comments?d=3\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(2, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(2, $jsonData['pagination']['count']);\n\n        foreach ($jsonData['items'] as $comment) {\n            self::assertIsArray($comment);\n            self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $comment);\n            self::assertIsArray($comment['user']);\n            self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $comment['user']);\n            self::assertIsArray($comment['magazine']);\n            self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $comment['magazine']);\n            self::assertSame($post->getId(), $comment['postId']);\n            self::assertStringContainsString('test parent comment', $comment['body']);\n            self::assertSame('en', $comment['lang']);\n            self::assertSame(0, $comment['uv']);\n            self::assertSame(0, $comment['favourites']);\n            self::assertSame(5, $comment['childCount']);\n            self::assertSame('visible', $comment['visibility']);\n            self::assertIsArray($comment['mentions']);\n            self::assertEmpty($comment['mentions']);\n            self::assertIsArray($comment['children']);\n            self::assertCount(1, $comment['children']);\n            $depth = 0;\n            $current = $comment;\n            while (\\count($current['children']) > 0) {\n                self::assertIsArray($current['children'][0]);\n                self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $current['children'][0]);\n                self::assertStringContainsString('test child comment', $current['children'][0]['body']);\n                self::assertSame(5 - ($depth + 1), $current['children'][0]['childCount']);\n                $current = $current['children'][0];\n                ++$depth;\n            }\n            self::assertSame(3, $depth);\n            self::assertFalse($comment['isAdult']);\n            self::assertNull($comment['image']);\n            self::assertNull($comment['parentId']);\n            self::assertNull($comment['rootId']);\n            // No scope granted so these should be null\n            self::assertNull($comment['isFavourited']);\n            self::assertNull($comment['userVote']);\n            self::assertNull($comment['apId']);\n            self::assertEmpty($comment['tags']);\n            self::assertNull($comment['editedAt']);\n            self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $comment['createdAt'], 'createdAt date format invalid');\n            self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $comment['lastActive'], 'lastActive date format invalid');\n            self::assertIsArray($comment['bookmarks']);\n            self::assertEmpty($comment['bookmarks']);\n        }\n    }\n\n    public function testApiCanGetPostCommentsNewest(): void\n    {\n        $post = $this->createPost('post');\n        $first = $this->createPostComment('first', $post);\n        $second = $this->createPostComment('second', $post);\n        $third = $this->createPostComment('third', $post);\n\n        $first->createdAt = new \\DateTimeImmutable('-1 hour');\n        $second->createdAt = new \\DateTimeImmutable('-1 second');\n        $third->createdAt = new \\DateTimeImmutable();\n\n        $entityManager = $this->entityManager;\n        $entityManager->persist($first);\n        $entityManager->persist($second);\n        $entityManager->persist($third);\n        $entityManager->flush();\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/posts/{$post->getId()}/comments?sort=newest\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(3, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(3, $jsonData['pagination']['count']);\n\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertSame($third->getId(), $jsonData['items'][0]['commentId']);\n\n        self::assertIsArray($jsonData['items'][1]);\n        self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $jsonData['items'][1]);\n        self::assertSame($second->getId(), $jsonData['items'][1]['commentId']);\n\n        self::assertIsArray($jsonData['items'][2]);\n        self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $jsonData['items'][2]);\n        self::assertSame($first->getId(), $jsonData['items'][2]['commentId']);\n    }\n\n    public function testApiCanGetPostCommentsOldest(): void\n    {\n        $post = $this->createPost('post');\n        $first = $this->createPostComment('first', $post);\n        $second = $this->createPostComment('second', $post);\n        $third = $this->createPostComment('third', $post);\n\n        $first->createdAt = new \\DateTimeImmutable('-1 hour');\n        $second->createdAt = new \\DateTimeImmutable('-1 second');\n        $third->createdAt = new \\DateTimeImmutable();\n\n        $entityManager = $this->entityManager;\n        $entityManager->persist($first);\n        $entityManager->persist($second);\n        $entityManager->persist($third);\n        $entityManager->flush();\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/posts/{$post->getId()}/comments?sort=oldest\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(3, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(3, $jsonData['pagination']['count']);\n\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertSame($first->getId(), $jsonData['items'][0]['commentId']);\n\n        self::assertIsArray($jsonData['items'][1]);\n        self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $jsonData['items'][1]);\n        self::assertSame($second->getId(), $jsonData['items'][1]['commentId']);\n\n        self::assertIsArray($jsonData['items'][2]);\n        self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $jsonData['items'][2]);\n        self::assertSame($third->getId(), $jsonData['items'][2]['commentId']);\n    }\n\n    public function testApiCanGetPostCommentsActive(): void\n    {\n        $post = $this->createPost('post');\n        $first = $this->createPostComment('first', $post);\n        $second = $this->createPostComment('second', $post);\n        $third = $this->createPostComment('third', $post);\n\n        $first->lastActive = new \\DateTime('-1 hour');\n        $second->lastActive = new \\DateTime('-1 second');\n        $third->lastActive = new \\DateTime();\n\n        $entityManager = $this->entityManager;\n        $entityManager->persist($first);\n        $entityManager->persist($second);\n        $entityManager->persist($third);\n        $entityManager->flush();\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/posts/{$post->getId()}/comments?sort=active\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(3, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(3, $jsonData['pagination']['count']);\n\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertSame($third->getId(), $jsonData['items'][0]['commentId']);\n\n        self::assertIsArray($jsonData['items'][1]);\n        self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $jsonData['items'][1]);\n        self::assertSame($second->getId(), $jsonData['items'][1]['commentId']);\n\n        self::assertIsArray($jsonData['items'][2]);\n        self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $jsonData['items'][2]);\n        self::assertSame($first->getId(), $jsonData['items'][2]['commentId']);\n    }\n\n    public function testApiCanGetPostCommentsHot(): void\n    {\n        $post = $this->createPost('post');\n        $first = $this->createPostComment('first', $post);\n        $second = $this->createPostComment('second', $post);\n        $third = $this->createPostComment('third', $post);\n\n        $voteManager = $this->voteManager;\n        $voteManager->vote(1, $first, $this->getUserByUsername('voter1'), rateLimit: false);\n        $voteManager->vote(1, $first, $this->getUserByUsername('voter2'), rateLimit: false);\n        $voteManager->vote(1, $second, $this->getUserByUsername('voter1'), rateLimit: false);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/posts/{$post->getId()}/comments?sort=hot\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(3, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(3, $jsonData['pagination']['count']);\n\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertSame($first->getId(), $jsonData['items'][0]['commentId']);\n        self::assertSame(2, $jsonData['items'][0]['uv']);\n\n        self::assertIsArray($jsonData['items'][1]);\n        self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $jsonData['items'][1]);\n        self::assertSame($second->getId(), $jsonData['items'][1]['commentId']);\n        self::assertSame(1, $jsonData['items'][1]['uv']);\n\n        self::assertIsArray($jsonData['items'][2]);\n        self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $jsonData['items'][2]);\n        self::assertSame($third->getId(), $jsonData['items'][2]['commentId']);\n        self::assertSame(0, $jsonData['items'][2]['uv']);\n    }\n\n    public function testApiCanGetPostCommentByIdAnonymous(): void\n    {\n        $post = $this->createPost('test post');\n        $comment = $this->createPostComment('test parent comment', $post);\n\n        $this->client->request('GET', \"/api/post-comments/{$comment->getId()}\");\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $jsonData);\n        self::assertIsArray($jsonData['user']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']);\n        self::assertIsArray($jsonData['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']);\n        self::assertSame($post->getId(), $jsonData['postId']);\n        self::assertStringContainsString('test parent comment', $jsonData['body']);\n        self::assertSame('en', $jsonData['lang']);\n        self::assertSame(0, $jsonData['uv']);\n        self::assertSame(0, $jsonData['favourites']);\n        self::assertSame(0, $jsonData['childCount']);\n        self::assertSame('visible', $jsonData['visibility']);\n        self::assertIsArray($jsonData['mentions']);\n        self::assertEmpty($jsonData['mentions']);\n        self::assertIsArray($jsonData['children']);\n        self::assertEmpty($jsonData['children']);\n        self::assertFalse($jsonData['isAdult']);\n        self::assertNull($jsonData['image']);\n        self::assertNull($jsonData['parentId']);\n        self::assertNull($jsonData['rootId']);\n        self::assertNull($jsonData['isFavourited']);\n        self::assertNull($jsonData['userVote']);\n        self::assertNull($jsonData['apId']);\n        self::assertEmpty($jsonData['tags']);\n        self::assertNull($jsonData['editedAt']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid');\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid');\n        self::assertNull($jsonData['bookmarks']);\n    }\n\n    public function testApiCanGetPostCommentById(): void\n    {\n        $post = $this->createPost('test post');\n        $comment = $this->createPostComment('test parent comment', $post);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/post-comments/{$comment->getId()}\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $jsonData);\n        self::assertIsArray($jsonData['user']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']);\n        self::assertIsArray($jsonData['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']);\n        self::assertSame($post->getId(), $jsonData['postId']);\n        self::assertStringContainsString('test parent comment', $jsonData['body']);\n        self::assertSame('en', $jsonData['lang']);\n        self::assertSame(0, $jsonData['uv']);\n        self::assertSame(0, $jsonData['favourites']);\n        self::assertSame(0, $jsonData['childCount']);\n        self::assertSame('visible', $jsonData['visibility']);\n        self::assertIsArray($jsonData['mentions']);\n        self::assertEmpty($jsonData['mentions']);\n        self::assertIsArray($jsonData['children']);\n        self::assertEmpty($jsonData['children']);\n        self::assertFalse($jsonData['isAdult']);\n        self::assertNull($jsonData['image']);\n        self::assertNull($jsonData['parentId']);\n        self::assertNull($jsonData['rootId']);\n        // No scope granted so these should be null\n        self::assertNull($jsonData['isFavourited']);\n        self::assertNull($jsonData['userVote']);\n        self::assertNull($jsonData['apId']);\n        self::assertEmpty($jsonData['tags']);\n        self::assertNull($jsonData['editedAt']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid');\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid');\n        self::assertIsArray($jsonData['bookmarks']);\n        self::assertEmpty($jsonData['bookmarks']);\n    }\n\n    public function testApiCanGetPostCommentByIdWithDepth(): void\n    {\n        $post = $this->createPost('test post');\n        $comment = $this->createPostComment('test parent comment', $post);\n        $parent = $comment;\n        for ($i = 0; $i < 5; ++$i) {\n            $parent = $this->createPostComment('test nested reply', $post, parent: $parent);\n        }\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/post-comments/{$comment->getId()}?d=2\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $jsonData);\n        self::assertIsArray($jsonData['user']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']);\n        self::assertIsArray($jsonData['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']);\n        self::assertSame($post->getId(), $jsonData['postId']);\n        self::assertStringContainsString('test parent comment', $jsonData['body']);\n        self::assertSame('en', $jsonData['lang']);\n        self::assertSame(0, $jsonData['uv']);\n        self::assertSame(0, $jsonData['favourites']);\n        self::assertSame(5, $jsonData['childCount']);\n        self::assertSame('visible', $jsonData['visibility']);\n        self::assertIsArray($jsonData['mentions']);\n        self::assertEmpty($jsonData['mentions']);\n        self::assertIsArray($jsonData['children']);\n        self::assertCount(1, $jsonData['children']);\n        self::assertFalse($jsonData['isAdult']);\n        self::assertNull($jsonData['image']);\n        self::assertNull($jsonData['parentId']);\n        self::assertNull($jsonData['rootId']);\n        // No scope granted so these should be null\n        self::assertNull($jsonData['isFavourited']);\n        self::assertNull($jsonData['userVote']);\n        self::assertNull($jsonData['apId']);\n        self::assertEmpty($jsonData['tags']);\n        self::assertNull($jsonData['editedAt']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid');\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid');\n        self::assertIsArray($jsonData['bookmarks']);\n        self::assertEmpty($jsonData['bookmarks']);\n\n        $depth = 0;\n        $current = $jsonData;\n        while (\\count($current['children']) > 0) {\n            self::assertIsArray($current['children'][0]);\n            self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $current['children'][0]);\n            ++$depth;\n            $current = $current['children'][0];\n        }\n\n        self::assertSame(2, $depth);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/Post/Comment/PostCommentUpdateApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\Post\\Comment;\n\nuse App\\Tests\\WebTestCase;\n\nclass PostCommentUpdateApiTest extends WebTestCase\n{\n    public function testApiCannotUpdateCommentAnonymous(): void\n    {\n        $post = $this->createPost('a post');\n        $comment = $this->createPostComment('test comment', $post);\n\n        $update = [\n            'body' => 'updated body',\n            'lang' => 'de',\n            'isAdult' => true,\n        ];\n\n        $this->client->jsonRequest('PUT', \"/api/post-comments/{$comment->getId()}\", $update);\n\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotUpdateCommentWithoutScope(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $post = $this->createPost('a post');\n        $comment = $this->createPostComment('test comment', $post, $user);\n\n        $update = [\n            'body' => 'updated body',\n            'lang' => 'de',\n            'isAdult' => true,\n        ];\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/post-comments/{$comment->getId()}\", $update, server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCannotUpdateOtherUsersComment(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $user2 = $this->getUserByUsername('other');\n        $post = $this->createPost('a post');\n        $comment = $this->createPostComment('test comment', $post, $user2);\n\n        $update = [\n            'body' => 'updated body',\n            'lang' => 'de',\n            'isAdult' => true,\n        ];\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read post_comment:edit');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/post-comments/{$comment->getId()}\", $update, server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanUpdateComment(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $post = $this->createPost('a post');\n        $comment = $this->createPostComment('test comment', $post, $user);\n        $parent = $comment;\n        for ($i = 0; $i < 5; ++$i) {\n            $parent = $this->createPostComment('test reply', $post, $user, parent: $parent);\n        }\n\n        $update = [\n            'body' => 'updated body',\n            'lang' => 'de',\n            'isAdult' => true,\n        ];\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read post_comment:edit');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/post-comments/{$comment->getId()}?d=2\", $update, server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(200);\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $jsonData);\n        self::assertSame($comment->getId(), $jsonData['commentId']);\n        self::assertSame($update['body'], $jsonData['body']);\n        self::assertSame($update['lang'], $jsonData['lang']);\n        self::assertSame($update['isAdult'], $jsonData['isAdult']);\n        self::assertSame(5, $jsonData['childCount']);\n\n        $depth = 0;\n        $current = $jsonData;\n        while (\\count($current['children']) > 0) {\n            self::assertIsArray($current['children'][0]);\n            self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $current['children'][0]);\n            ++$depth;\n            $current = $current['children'][0];\n        }\n\n        self::assertSame(2, $depth);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/Post/Comment/PostCommentVoteApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\Post\\Comment;\n\nuse App\\Tests\\WebTestCase;\n\nclass PostCommentVoteApiTest extends WebTestCase\n{\n    public function testApiCannotUpvoteCommentAnonymous(): void\n    {\n        $post = $this->createPost('a post');\n        $comment = $this->createPostComment('test comment', $post);\n\n        $this->client->request('PUT', \"/api/post-comments/{$comment->getId()}/vote/1\");\n\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotUpvoteCommentWithoutScope(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $post = $this->createPost('a post');\n        $comment = $this->createPostComment('test comment', $post, $user);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('PUT', \"/api/post-comments/{$comment->getId()}/vote/1\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanUpvoteComment(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $post = $this->createPost('a post');\n        $comment = $this->createPostComment('test comment', $post, $user);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read post_comment:vote');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('PUT', \"/api/post-comments/{$comment->getId()}/vote/1\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(200);\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $jsonData);\n        self::assertSame(1, $jsonData['uv']);\n        self::assertSame(0, $jsonData['dv']);\n        self::assertSame(0, $jsonData['favourites']);\n        self::assertSame(1, $jsonData['userVote']);\n        self::assertFalse($jsonData['isFavourited']);\n    }\n\n    public function testApiCannotDownvoteCommentAnonymous(): void\n    {\n        $post = $this->createPost('a post');\n        $comment = $this->createPostComment('test comment', $post);\n\n        $this->client->request('PUT', \"/api/post-comments/{$comment->getId()}/vote/-1\");\n\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotDownvoteCommentWithoutScope(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $post = $this->createPost('a post');\n        $comment = $this->createPostComment('test comment', $post, $user);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('PUT', \"/api/post-comments/{$comment->getId()}/vote/-1\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCannotDownvoteComment(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $post = $this->createPost('a post');\n        $comment = $this->createPostComment('test comment', $post, $user);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read post_comment:vote');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('PUT', \"/api/post-comments/{$comment->getId()}/vote/-1\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(400);\n    }\n\n    public function testApiCannotRemoveVoteCommentAnonymous(): void\n    {\n        $post = $this->createPost('a post');\n        $comment = $this->createPostComment('test comment', $post);\n\n        $voteManager = $this->voteManager;\n        $voteManager->vote(1, $comment, $this->getUserByUsername('user'), rateLimit: false);\n\n        $this->client->request('PUT', \"/api/post-comments/{$comment->getId()}/vote/0\");\n\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotRemoveVoteCommentWithoutScope(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $post = $this->createPost('a post');\n        $comment = $this->createPostComment('test comment', $post, $user);\n\n        $voteManager = $this->voteManager;\n        $voteManager->vote(1, $comment, $user, rateLimit: false);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('PUT', \"/api/post-comments/{$comment->getId()}/vote/0\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanRemoveVoteComment(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $post = $this->createPost('a post');\n        $comment = $this->createPostComment('test comment', $post, $user);\n\n        $voteManager = $this->voteManager;\n        $voteManager->vote(1, $comment, $user, rateLimit: false);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read post_comment:vote');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('PUT', \"/api/post-comments/{$comment->getId()}/vote/0\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(200);\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $jsonData);\n        self::assertSame(0, $jsonData['uv']);\n        self::assertSame(0, $jsonData['dv']);\n        self::assertSame(0, $jsonData['favourites']);\n        self::assertSame(0, $jsonData['userVote']);\n        self::assertFalse($jsonData['isFavourited']);\n    }\n\n    public function testApiCannotFavouriteCommentAnonymous(): void\n    {\n        $post = $this->createPost('a post');\n        $comment = $this->createPostComment('test comment', $post);\n\n        $this->client->request('PUT', \"/api/post-comments/{$comment->getId()}/favourite\");\n\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotFavouriteCommentWithoutScope(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $post = $this->createPost('a post');\n        $comment = $this->createPostComment('test comment', $post, $user);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('PUT', \"/api/post-comments/{$comment->getId()}/favourite\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanFavouriteComment(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $post = $this->createPost('a post');\n        $comment = $this->createPostComment('test comment', $post, $user);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read post_comment:vote');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('PUT', \"/api/post-comments/{$comment->getId()}/favourite\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(200);\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $jsonData);\n        self::assertSame(0, $jsonData['uv']);\n        self::assertSame(0, $jsonData['dv']);\n        self::assertSame(1, $jsonData['favourites']);\n        self::assertSame(0, $jsonData['userVote']);\n        self::assertTrue($jsonData['isFavourited']);\n    }\n\n    public function testApiCannotUnfavouriteCommentWithoutScope(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $post = $this->createPost('a post');\n        $comment = $this->createPostComment('test comment', $post, $user);\n\n        $favouriteManager = $this->favouriteManager;\n        $favouriteManager->toggle($user, $comment);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('PUT', \"/api/post-comments/{$comment->getId()}/favourite\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanUnfavouriteComment(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $post = $this->createPost('a post');\n        $comment = $this->createPostComment('test comment', $post, $user);\n\n        $favouriteManager = $this->favouriteManager;\n        $favouriteManager->toggle($user, $comment);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read post_comment:vote');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('PUT', \"/api/post-comments/{$comment->getId()}/favourite\", server: ['HTTP_AUTHORIZATION' => $token]);\n\n        self::assertResponseStatusCodeSame(200);\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $jsonData);\n        self::assertSame(0, $jsonData['uv']);\n        self::assertSame(0, $jsonData['dv']);\n        self::assertSame(0, $jsonData['favourites']);\n        self::assertSame(0, $jsonData['userVote']);\n        self::assertFalse($jsonData['isFavourited']);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/Post/Comment/PostCommentsActivityApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\Post\\Comment;\n\nuse App\\DTO\\UserSmallResponseDto;\nuse App\\Tests\\Functional\\Controller\\Api\\Entry\\EntriesActivityApiTest;\nuse App\\Tests\\WebTestCase;\n\nclass PostCommentsActivityApiTest extends WebTestCase\n{\n    public function testEmpty()\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $post = $this->createPost('test post', user: $user, magazine: $magazine);\n        $comment = $this->createPostComment('test comment', $post, $user);\n\n        $this->client->jsonRequest('GET', \"/api/post-comments/{$comment->getId()}/activity\");\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(EntriesActivityApiTest::ACTIVITIES_RESPONSE_DTO_KEYS, $jsonData);\n        self::assertSame([], $jsonData['boosts']);\n        self::assertSame([], $jsonData['upvotes']);\n        self::assertSame(null, $jsonData['downvotes']);\n    }\n\n    public function testUpvotes()\n    {\n        $author = $this->getUserByUsername('userA');\n        $user1 = $this->getUserByUsername('user1');\n        $user2 = $this->getUserByUsername('user2');\n        $this->getUserByUsername('user3');\n\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $post = $this->createPost('test post', user: $author, magazine: $magazine);\n        $comment = $this->createPostComment('test comment', $post, $author);\n\n        $this->favouriteManager->toggle($user1, $comment);\n        $this->favouriteManager->toggle($user2, $comment);\n\n        $this->client->jsonRequest('GET', \"/api/post-comments/{$comment->getId()}/activity\");\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(EntriesActivityApiTest::ACTIVITIES_RESPONSE_DTO_KEYS, $jsonData);\n        self::assertSame([], $jsonData['boosts']);\n        self::assertSame(null, $jsonData['downvotes']);\n\n        self::assertCount(2, $jsonData['upvotes']);\n        self::assertTrue(array_all($jsonData['upvotes'], function ($u) use ($user1, $user2) {\n            /* @var UserSmallResponseDto $u */\n            return $u['userId'] === $user1->getId() || $u['userId'] === $user2->getId();\n        }), serialize($jsonData['upvotes']));\n    }\n\n    public function testBoosts()\n    {\n        $author = $this->getUserByUsername('userA');\n        $user1 = $this->getUserByUsername('user1');\n        $user2 = $this->getUserByUsername('user2');\n        $this->getUserByUsername('user3');\n\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $post = $this->createPost('test post', user: $author, magazine: $magazine);\n        $comment = $this->createPostComment('test comment', $post, $author);\n\n        $this->voteManager->upvote($comment, $user1);\n        $this->voteManager->upvote($comment, $user2);\n\n        $this->client->jsonRequest('GET', \"/api/post-comments/{$comment->getId()}/activity\");\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(EntriesActivityApiTest::ACTIVITIES_RESPONSE_DTO_KEYS, $jsonData);\n        self::assertSame([], $jsonData['upvotes']);\n        self::assertSame(null, $jsonData['downvotes']);\n\n        self::assertCount(2, $jsonData['boosts']);\n        self::assertTrue(array_all($jsonData['boosts'], function ($u) use ($user1, $user2) {\n            /* @var UserSmallResponseDto $u */\n            return $u['userId'] === $user1->getId() || $u['userId'] === $user2->getId();\n        }), serialize($jsonData['boosts']));\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/Post/Comment/UserPostCommentRetrieveApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\Post\\Comment;\n\nuse App\\Tests\\WebTestCase;\n\nclass UserPostCommentRetrieveApiTest extends WebTestCase\n{\n    public function testApiCanGetUserPostCommentsAnonymous(): void\n    {\n        $this->createPost('a post');\n        $magazine = $this->getMagazineByNameNoRSAKey('somemag');\n        $post = $this->createPost('another post', magazine: $magazine);\n        $comment = $this->createPostComment('test comment', $post);\n        $user = $post->user;\n\n        $this->client->request('GET', \"/api/users/{$user->getId()}/post-comments\");\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(1, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(1, $jsonData['pagination']['count']);\n\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertEquals('test comment', $jsonData['items'][0]['body']);\n        self::assertIsArray($jsonData['items'][0]['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['magazine']);\n        self::assertSame($magazine->getId(), $jsonData['items'][0]['magazine']['magazineId']);\n        self::assertSame(0, $jsonData['items'][0]['childCount']);\n        self::assertIsArray($jsonData['items'][0]['children']);\n        self::assertEmpty($jsonData['items'][0]['children']);\n        self::assertSame($comment->getId(), $jsonData['items'][0]['commentId']);\n        self::assertSame($post->getId(), $jsonData['items'][0]['postId']);\n    }\n\n    public function testApiCanGetUserPostComments(): void\n    {\n        $this->createPost('a post');\n        $magazine = $this->getMagazineByNameNoRSAKey('somemag');\n        $post = $this->createPost('another post', magazine: $magazine);\n        $comment = $this->createPostComment('test comment', $post);\n        $user = $post->user;\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/users/{$user->getId()}/post-comments\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(1, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(1, $jsonData['pagination']['count']);\n\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertEquals('test comment', $jsonData['items'][0]['body']);\n        self::assertIsArray($jsonData['items'][0]['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['magazine']);\n        self::assertSame($magazine->getId(), $jsonData['items'][0]['magazine']['magazineId']);\n        self::assertSame(0, $jsonData['items'][0]['childCount']);\n        self::assertIsArray($jsonData['items'][0]['children']);\n        self::assertEmpty($jsonData['items'][0]['children']);\n        self::assertSame($comment->getId(), $jsonData['items'][0]['commentId']);\n        self::assertSame($post->getId(), $jsonData['items'][0]['postId']);\n    }\n\n    public function testApiCanGetUserPostCommentsDepth(): void\n    {\n        $this->createPost('a post');\n        $magazine = $this->getMagazineByNameNoRSAKey('somemag');\n        $post = $this->createPost('another post', magazine: $magazine);\n        $comment = $this->createPostComment('test comment', $post);\n        $nested1 = $this->createPostComment('test comment nested 1', $post, parent: $comment);\n        $nested2 = $this->createPostComment('test comment nested 2', $post, parent: $nested1);\n        $nested3 = $this->createPostComment('test comment nested 3', $post, parent: $nested2);\n        $user = $post->user;\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/users/{$user->getId()}/post-comments?d=2\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(4, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(4, $jsonData['pagination']['count']);\n\n        foreach ($jsonData['items'] as $comment) {\n            self::assertIsArray($comment);\n            self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $comment);\n            self::assertTrue(\\count($comment['children']) <= 1);\n            $depth = 0;\n            $current = $comment;\n            while (\\count($current['children']) > 0) {\n                ++$depth;\n                $current = $current['children'][0];\n                self::assertIsArray($current);\n                self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $current);\n            }\n            self::assertTrue($depth <= 2);\n        }\n    }\n\n    public function testApiCanGetUserPostCommentsNewest(): void\n    {\n        $post = $this->createPost('post');\n        $first = $this->createPostComment('first', $post);\n        $second = $this->createPostComment('second', $post);\n        $third = $this->createPostComment('third', $post);\n        $user = $post->user;\n\n        $first->createdAt = new \\DateTimeImmutable('-1 hour');\n        $second->createdAt = new \\DateTimeImmutable('-1 second');\n        $third->createdAt = new \\DateTimeImmutable();\n\n        $entityManager = $this->entityManager;\n        $entityManager->persist($first);\n        $entityManager->persist($second);\n        $entityManager->persist($third);\n        $entityManager->flush();\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/users/{$user->getId()}/post-comments?sort=newest\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(3, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(3, $jsonData['pagination']['count']);\n\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertSame($third->getId(), $jsonData['items'][0]['commentId']);\n\n        self::assertIsArray($jsonData['items'][1]);\n        self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $jsonData['items'][1]);\n        self::assertSame($second->getId(), $jsonData['items'][1]['commentId']);\n\n        self::assertIsArray($jsonData['items'][2]);\n        self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $jsonData['items'][2]);\n        self::assertSame($first->getId(), $jsonData['items'][2]['commentId']);\n    }\n\n    public function testApiCanGetUserPostCommentsOldest(): void\n    {\n        $post = $this->createPost('post');\n        $first = $this->createPostComment('first', $post);\n        $second = $this->createPostComment('second', $post);\n        $third = $this->createPostComment('third', $post);\n        $user = $post->user;\n\n        $first->createdAt = new \\DateTimeImmutable('-1 hour');\n        $second->createdAt = new \\DateTimeImmutable('-1 second');\n        $third->createdAt = new \\DateTimeImmutable();\n\n        $entityManager = $this->entityManager;\n        $entityManager->persist($first);\n        $entityManager->persist($second);\n        $entityManager->persist($third);\n        $entityManager->flush();\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/users/{$user->getId()}/post-comments?sort=oldest\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(3, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(3, $jsonData['pagination']['count']);\n\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertSame($first->getId(), $jsonData['items'][0]['commentId']);\n\n        self::assertIsArray($jsonData['items'][1]);\n        self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $jsonData['items'][1]);\n        self::assertSame($second->getId(), $jsonData['items'][1]['commentId']);\n\n        self::assertIsArray($jsonData['items'][2]);\n        self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $jsonData['items'][2]);\n        self::assertSame($third->getId(), $jsonData['items'][2]['commentId']);\n    }\n\n    public function testApiCanGetUserPostCommentsActive(): void\n    {\n        $post = $this->createPost('post');\n        $first = $this->createPostComment('first', $post);\n        $second = $this->createPostComment('second', $post);\n        $third = $this->createPostComment('third', $post);\n        $user = $post->user;\n\n        $first->lastActive = new \\DateTime('-1 hour');\n        $second->lastActive = new \\DateTime('-1 second');\n        $third->lastActive = new \\DateTime();\n\n        $entityManager = $this->entityManager;\n        $entityManager->persist($first);\n        $entityManager->persist($second);\n        $entityManager->persist($third);\n        $entityManager->flush();\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/users/{$user->getId()}/post-comments?sort=active\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(3, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(3, $jsonData['pagination']['count']);\n\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertSame($third->getId(), $jsonData['items'][0]['commentId']);\n\n        self::assertIsArray($jsonData['items'][1]);\n        self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $jsonData['items'][1]);\n        self::assertSame($second->getId(), $jsonData['items'][1]['commentId']);\n\n        self::assertIsArray($jsonData['items'][2]);\n        self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $jsonData['items'][2]);\n        self::assertSame($first->getId(), $jsonData['items'][2]['commentId']);\n    }\n\n    public function testApiCanGetUserPostCommentsHot(): void\n    {\n        $post = $this->createPost('post');\n        $first = $this->createPostComment('first', $post);\n        $second = $this->createPostComment('second', $post);\n        $third = $this->createPostComment('third', $post);\n        $user = $post->user;\n\n        $voteManager = $this->voteManager;\n        $voteManager->vote(1, $first, $this->getUserByUsername('voter1'), rateLimit: false);\n        $voteManager->vote(1, $first, $this->getUserByUsername('voter2'), rateLimit: false);\n        $voteManager->vote(1, $second, $this->getUserByUsername('voter1'), rateLimit: false);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/users/{$user->getId()}/post-comments?sort=hot\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(3, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(3, $jsonData['pagination']['count']);\n\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertSame($first->getId(), $jsonData['items'][0]['commentId']);\n        self::assertSame(2, $jsonData['items'][0]['uv']);\n\n        self::assertIsArray($jsonData['items'][1]);\n        self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $jsonData['items'][1]);\n        self::assertSame($second->getId(), $jsonData['items'][1]['commentId']);\n        self::assertSame(1, $jsonData['items'][1]['uv']);\n\n        self::assertIsArray($jsonData['items'][2]);\n        self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $jsonData['items'][2]);\n        self::assertSame($third->getId(), $jsonData['items'][2]['commentId']);\n        self::assertSame(0, $jsonData['items'][2]['uv']);\n    }\n\n    public function testApiCanGetUserPostCommentsWithUserVoteStatus(): void\n    {\n        $this->createPost('a post');\n        $magazine = $this->getMagazineByNameNoRSAKey('somemag');\n        $post = $this->createPost('another post', magazine: $magazine);\n        $comment = $this->createPostComment('test comment', $post);\n        $user = $post->user;\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read vote');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/users/{$user->getId()}/post-comments\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(1, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(1, $jsonData['pagination']['count']);\n\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertSame($comment->getId(), $jsonData['items'][0]['commentId']);\n        self::assertSame($post->getId(), $jsonData['items'][0]['postId']);\n        self::assertEquals('test comment', $jsonData['items'][0]['body']);\n        self::assertIsArray($jsonData['items'][0]['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['magazine']);\n        self::assertSame($magazine->getId(), $jsonData['items'][0]['magazine']['magazineId']);\n        self::assertIsArray($jsonData['items'][0]['user']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['user']);\n        self::assertNull($jsonData['items'][0]['image']);\n        self::assertEquals('en', $jsonData['items'][0]['lang']);\n        self::assertEmpty($jsonData['items'][0]['tags']);\n        self::assertSame(0, $jsonData['items'][0]['childCount']);\n        self::assertIsArray($jsonData['items'][0]['children']);\n        self::assertEmpty($jsonData['items'][0]['children']);\n        self::assertSame(0, $jsonData['items'][0]['uv']);\n        self::assertSame(0, $jsonData['items'][0]['dv']);\n        self::assertSame(0, $jsonData['items'][0]['favourites']);\n        self::assertFalse($jsonData['items'][0]['isFavourited']);\n        self::assertSame(0, $jsonData['items'][0]['userVote']);\n        self::assertFalse($jsonData['items'][0]['isAdult']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['items'][0]['createdAt'], 'createdAt date format invalid');\n        self::assertNull($jsonData['items'][0]['editedAt']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['items'][0]['lastActive'], 'lastActive date format invalid');\n        self::assertNull($jsonData['items'][0]['apId']);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/Post/MagazinePostRetrieveApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\Post;\n\nuse App\\Tests\\WebTestCase;\n\nclass MagazinePostRetrieveApiTest extends WebTestCase\n{\n    public function testApiCanGetMagazinePostsAnonymous(): void\n    {\n        $post = $this->createPost('a post');\n        $this->createPostComment('up the ranking', $post);\n        $magazine = $this->getMagazineByNameNoRSAKey('somemag');\n        $this->createPost('another post', magazine: $magazine);\n\n        $this->client->request('GET', \"/api/magazine/{$magazine->getId()}/posts\");\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(1, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(1, $jsonData['pagination']['count']);\n\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertEquals('another post', $jsonData['items'][0]['body']);\n        self::assertIsArray($jsonData['items'][0]['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['magazine']);\n        self::assertSame($magazine->getId(), $jsonData['items'][0]['magazine']['magazineId']);\n\n        self::assertSame(0, $jsonData['items'][0]['comments']);\n    }\n\n    public function testApiCanGetMagazinePosts(): void\n    {\n        $post = $this->createPost('a post');\n        $this->createPostComment('up the ranking', $post);\n        $magazine = $this->getMagazineByNameNoRSAKey('somemag');\n        $this->createPost('another post', magazine: $magazine);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/magazine/{$magazine->getId()}/posts\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(1, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(1, $jsonData['pagination']['count']);\n\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertEquals('another post', $jsonData['items'][0]['body']);\n        self::assertIsArray($jsonData['items'][0]['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['magazine']);\n        self::assertSame($magazine->getId(), $jsonData['items'][0]['magazine']['magazineId']);\n        self::assertSame(0, $jsonData['items'][0]['comments']);\n    }\n\n    public function testApiCanGetMagazinePostsPinnedFirst(): void\n    {\n        $voteManager = $this->voteManager;\n        $postManager = $this->postManager;\n        $voter = $this->getUserByUsername('voter');\n        $first = $this->createPost('a post');\n        $this->createPostComment('up the ranking', $first);\n        $magazine = $this->getMagazineByNameNoRSAKey('somemag');\n        $second = $this->createPost('another post', magazine: $magazine);\n        // Upvote and comment on $second so it should come first, but then pin $third so it actually comes first\n        $voteManager->vote(1, $second, $voter, rateLimit: false);\n        $this->createPostComment('test', $second, $voter);\n        $third = $this->createPost('a pinned post', magazine: $magazine);\n        $postManager->pin($third);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/magazine/{$magazine->getId()}/posts\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(2, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(2, $jsonData['pagination']['count']);\n\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertEquals('a pinned post', $jsonData['items'][0]['body']);\n        self::assertIsArray($jsonData['items'][0]['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['magazine']);\n        self::assertSame($magazine->getId(), $jsonData['items'][0]['magazine']['magazineId']);\n\n        self::assertSame(0, $jsonData['items'][0]['comments']);\n        self::assertSame(0, $jsonData['items'][0]['uv']);\n        self::assertTrue($jsonData['items'][0]['isPinned']);\n\n        self::assertIsArray($jsonData['items'][1]);\n        self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][1]);\n        self::assertEquals('another post', $jsonData['items'][1]['body']);\n        self::assertIsArray($jsonData['items'][1]['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][1]['magazine']);\n        self::assertSame($magazine->getId(), $jsonData['items'][1]['magazine']['magazineId']);\n        self::assertSame(1, $jsonData['items'][1]['comments']);\n        self::assertSame(1, $jsonData['items'][1]['uv']);\n        self::assertFalse($jsonData['items'][1]['isPinned']);\n    }\n\n    public function testApiCanGetMagazinePostsNewest(): void\n    {\n        $first = $this->createPost('first');\n        $second = $this->createPost('second');\n        $third = $this->createPost('third');\n        $magazine = $first->magazine;\n\n        $first->createdAt = new \\DateTimeImmutable('-1 hour');\n        $second->createdAt = new \\DateTimeImmutable('-1 second');\n        $third->createdAt = new \\DateTimeImmutable();\n\n        $entityManager = $this->entityManager;\n        $entityManager->persist($first);\n        $entityManager->persist($second);\n        $entityManager->persist($third);\n        $entityManager->flush();\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/magazine/{$magazine->getId()}/posts?sort=newest\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(3, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(3, $jsonData['pagination']['count']);\n\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertSame($third->getId(), $jsonData['items'][0]['postId']);\n\n        self::assertIsArray($jsonData['items'][1]);\n        self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][1]);\n        self::assertSame($second->getId(), $jsonData['items'][1]['postId']);\n\n        self::assertIsArray($jsonData['items'][2]);\n        self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][2]);\n        self::assertSame($first->getId(), $jsonData['items'][2]['postId']);\n    }\n\n    public function testApiCanGetMagazinePostsOldest(): void\n    {\n        $first = $this->createPost('first');\n        $second = $this->createPost('second');\n        $third = $this->createPost('third');\n        $magazine = $first->magazine;\n\n        $first->createdAt = new \\DateTimeImmutable('-1 hour');\n        $second->createdAt = new \\DateTimeImmutable('-1 second');\n        $third->createdAt = new \\DateTimeImmutable();\n\n        $entityManager = $this->entityManager;\n        $entityManager->persist($first);\n        $entityManager->persist($second);\n        $entityManager->persist($third);\n        $entityManager->flush();\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/magazine/{$magazine->getId()}/posts?sort=oldest\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(3, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(3, $jsonData['pagination']['count']);\n\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertSame($first->getId(), $jsonData['items'][0]['postId']);\n\n        self::assertIsArray($jsonData['items'][1]);\n        self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][1]);\n        self::assertSame($second->getId(), $jsonData['items'][1]['postId']);\n\n        self::assertIsArray($jsonData['items'][2]);\n        self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][2]);\n        self::assertSame($third->getId(), $jsonData['items'][2]['postId']);\n    }\n\n    public function testApiCanGetMagazinePostsCommented(): void\n    {\n        $first = $this->createPost('first');\n        $this->createPostComment('comment 1', $first);\n        $this->createPostComment('comment 2', $first);\n        $second = $this->createPost('second');\n        $this->createPostComment('comment 1', $second);\n        $third = $this->createPost('third');\n        $magazine = $first->magazine;\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/magazine/{$magazine->getId()}/posts?sort=commented\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(3, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(3, $jsonData['pagination']['count']);\n\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertSame($first->getId(), $jsonData['items'][0]['postId']);\n        self::assertSame(2, $jsonData['items'][0]['comments']);\n\n        self::assertIsArray($jsonData['items'][1]);\n        self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][1]);\n        self::assertSame($second->getId(), $jsonData['items'][1]['postId']);\n        self::assertSame(1, $jsonData['items'][1]['comments']);\n\n        self::assertIsArray($jsonData['items'][2]);\n        self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][2]);\n        self::assertSame($third->getId(), $jsonData['items'][2]['postId']);\n        self::assertSame(0, $jsonData['items'][2]['comments']);\n    }\n\n    public function testApiCanGetMagazinePostsActive(): void\n    {\n        $first = $this->createPost('first');\n        $second = $this->createPost('second');\n        $third = $this->createPost('third');\n        $magazine = $first->magazine;\n\n        $first->lastActive = new \\DateTime('-1 hour');\n        $second->lastActive = new \\DateTime('-1 second');\n        $third->lastActive = new \\DateTime();\n\n        $entityManager = $this->entityManager;\n        $entityManager->persist($first);\n        $entityManager->persist($second);\n        $entityManager->persist($third);\n        $entityManager->flush();\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/magazine/{$magazine->getId()}/posts?sort=active\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(3, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(3, $jsonData['pagination']['count']);\n\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertSame($third->getId(), $jsonData['items'][0]['postId']);\n\n        self::assertIsArray($jsonData['items'][1]);\n        self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][1]);\n        self::assertSame($second->getId(), $jsonData['items'][1]['postId']);\n\n        self::assertIsArray($jsonData['items'][2]);\n        self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][2]);\n        self::assertSame($first->getId(), $jsonData['items'][2]['postId']);\n    }\n\n    public function testApiCanGetMagazinePostsTop(): void\n    {\n        $first = $this->createPost('first');\n        $second = $this->createPost('second');\n        $third = $this->createPost('third');\n        $magazine = $first->magazine;\n\n        $voteManager = $this->voteManager;\n        $voteManager->vote(1, $first, $this->getUserByUsername('voter1'), rateLimit: false);\n        $voteManager->vote(1, $first, $this->getUserByUsername('voter2'), rateLimit: false);\n        $voteManager->vote(1, $second, $this->getUserByUsername('voter1'), rateLimit: false);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/magazine/{$magazine->getId()}/posts?sort=top\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(3, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(3, $jsonData['pagination']['count']);\n\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertSame($first->getId(), $jsonData['items'][0]['postId']);\n        self::assertSame(2, $jsonData['items'][0]['uv']);\n\n        self::assertIsArray($jsonData['items'][1]);\n        self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][1]);\n        self::assertSame($second->getId(), $jsonData['items'][1]['postId']);\n        self::assertSame(1, $jsonData['items'][1]['uv']);\n\n        self::assertIsArray($jsonData['items'][2]);\n        self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][2]);\n        self::assertSame($third->getId(), $jsonData['items'][2]['postId']);\n        self::assertSame(0, $jsonData['items'][2]['uv']);\n    }\n\n    public function testApiCanGetMagazinePostsWithUserVoteStatus(): void\n    {\n        $first = $this->createPost('an post');\n        $this->createPostComment('up the ranking', $first);\n        $magazine = $this->getMagazineByNameNoRSAKey('somemag');\n        $post = $this->createPost('another post', magazine: $magazine);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read vote');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/magazine/{$magazine->getId()}/posts\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(1, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(1, $jsonData['pagination']['count']);\n\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertSame($post->getId(), $jsonData['items'][0]['postId']);\n        self::assertEquals('another post', $jsonData['items'][0]['body']);\n        self::assertIsArray($jsonData['items'][0]['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['magazine']);\n        self::assertSame($magazine->getId(), $jsonData['items'][0]['magazine']['magazineId']);\n        self::assertIsArray($jsonData['items'][0]['user']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['user']);\n        self::assertNull($jsonData['items'][0]['image']);\n        self::assertEquals('en', $jsonData['items'][0]['lang']);\n        self::assertEmpty($jsonData['items'][0]['tags']);\n        self::assertNull($jsonData['items'][0]['mentions']);\n        self::assertSame(0, $jsonData['items'][0]['comments']);\n        self::assertSame(0, $jsonData['items'][0]['uv']);\n        self::assertSame(0, $jsonData['items'][0]['dv']);\n        self::assertSame(0, $jsonData['items'][0]['favourites']);\n        self::assertFalse($jsonData['items'][0]['isFavourited']);\n        self::assertSame(0, $jsonData['items'][0]['userVote']);\n        self::assertFalse($jsonData['items'][0]['isAdult']);\n        self::assertFalse($jsonData['items'][0]['isPinned']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['items'][0]['createdAt'], 'createdAt date format invalid');\n        self::assertNull($jsonData['items'][0]['editedAt']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['items'][0]['lastActive'], 'lastActive date format invalid');\n        self::assertEquals('another-post', $jsonData['items'][0]['slug']);\n        self::assertNull($jsonData['items'][0]['apId']);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/Post/Moderate/PostLockApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\Post\\Moderate;\n\nuse App\\Tests\\WebTestCase;\n\nclass PostLockApiTest extends WebTestCase\n{\n    public function testApiCannotLockPostAnonymous(): void\n    {\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $post = $this->createPost('test article', magazine: $magazine);\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/post/{$post->getId()}/lock\");\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiNonModeratorCannotLockPost(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $user2 = $this->getUserByUsername('user2');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $post = $this->createPost('test article', user: $user2, magazine: $magazine);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:post:lock');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/post/{$post->getId()}/lock\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCannotLockPostWithoutScope(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme', $user);\n        $post = $this->createPost('test article', user: $user, magazine: $magazine);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/post/{$post->getId()}/lock\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanLockPost(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme', $user);\n        $post = $this->createPost('test article', user: $user, magazine: $magazine);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:post:lock');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/post/{$post->getId()}/lock\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData);\n        self::assertSame($post->getId(), $jsonData['postId']);\n        self::assertIsArray($jsonData['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']);\n        self::assertSame($magazine->getId(), $jsonData['magazine']['magazineId']);\n        self::assertIsArray($jsonData['user']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']);\n        self::assertSame($user->getId(), $jsonData['user']['userId']);\n        self::assertEquals($post->body, $jsonData['body']);\n        self::assertNull($jsonData['image']);\n        self::assertEquals($post->lang, $jsonData['lang']);\n        self::assertEmpty($jsonData['tags']);\n        self::assertNull($jsonData['mentions']);\n        self::assertSame(0, $jsonData['comments']);\n        self::assertSame(0, $jsonData['uv']);\n        self::assertSame(0, $jsonData['dv']);\n        self::assertSame(0, $jsonData['favourites']);\n        // No scope for seeing votes granted\n        self::assertNull($jsonData['isFavourited']);\n        self::assertNull($jsonData['userVote']);\n        self::assertFalse($jsonData['isAdult']);\n        self::assertFalse($jsonData['isPinned']);\n        self::assertTrue($jsonData['isLocked']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid');\n        self::assertNull($jsonData['editedAt']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid');\n        self::assertEquals('test-article', $jsonData['slug']);\n        self::assertNull($jsonData['apId']);\n    }\n\n    public function testApiAuthorNonModeratorCanLockPost(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $post = $this->createPost('test article', user: $user, magazine: $magazine);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:post:lock');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/post/{$post->getId()}/lock\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData);\n        self::assertSame($post->getId(), $jsonData['postId']);\n        self::assertIsArray($jsonData['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']);\n        self::assertSame($magazine->getId(), $jsonData['magazine']['magazineId']);\n        self::assertIsArray($jsonData['user']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']);\n        self::assertSame($user->getId(), $jsonData['user']['userId']);\n        self::assertEquals($post->body, $jsonData['body']);\n        self::assertNull($jsonData['image']);\n        self::assertEquals($post->lang, $jsonData['lang']);\n        self::assertEmpty($jsonData['tags']);\n        self::assertNull($jsonData['mentions']);\n        self::assertSame(0, $jsonData['comments']);\n        self::assertSame(0, $jsonData['uv']);\n        self::assertSame(0, $jsonData['dv']);\n        self::assertSame(0, $jsonData['favourites']);\n        // No scope for seeing votes granted\n        self::assertNull($jsonData['isFavourited']);\n        self::assertNull($jsonData['userVote']);\n        self::assertFalse($jsonData['isAdult']);\n        self::assertFalse($jsonData['isPinned']);\n        self::assertTrue($jsonData['isLocked']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid');\n        self::assertNull($jsonData['editedAt']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid');\n        self::assertEquals('test-article', $jsonData['slug']);\n        self::assertNull($jsonData['apId']);\n    }\n\n    public function testApiCannotUnlockPostAnonymous(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $post = $this->createPost('test article', magazine: $magazine);\n\n        $postManager = $this->postManager;\n        $postManager->toggleLock($post, $user);\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/post/{$post->getId()}/lock\");\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiNonModeratorCannotUnpinPost(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $user2 = $this->getUserByUsername('user2');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $post = $this->createPost('test article', user: $user2, magazine: $magazine);\n\n        $postManager = $this->postManager;\n        $postManager->toggleLock($post, $user);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:post:lock');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/post/{$post->getId()}/lock\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCannotUnpinPostWithoutScope(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme', $user);\n        $post = $this->createPost('test article', user: $user, magazine: $magazine);\n\n        $postManager = $this->postManager;\n        $postManager->toggleLock($post, $user);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/post/{$post->getId()}/lock\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanUnpinPost(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme', $user);\n        $post = $this->createPost('test article', user: $user, magazine: $magazine);\n\n        $postManager = $this->postManager;\n        $postManager->toggleLock($post, $user);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:post:lock');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/post/{$post->getId()}/lock\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData);\n        self::assertSame($post->getId(), $jsonData['postId']);\n        self::assertIsArray($jsonData['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']);\n        self::assertSame($magazine->getId(), $jsonData['magazine']['magazineId']);\n        self::assertIsArray($jsonData['user']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']);\n        self::assertSame($user->getId(), $jsonData['user']['userId']);\n        self::assertEquals($post->body, $jsonData['body']);\n        self::assertNull($jsonData['image']);\n        self::assertEquals($post->lang, $jsonData['lang']);\n        self::assertEmpty($jsonData['tags']);\n        self::assertNull($jsonData['mentions']);\n        self::assertSame(0, $jsonData['comments']);\n        self::assertSame(0, $jsonData['uv']);\n        self::assertSame(0, $jsonData['dv']);\n        self::assertSame(0, $jsonData['favourites']);\n        // No scope for seeing votes granted\n        self::assertNull($jsonData['isFavourited']);\n        self::assertNull($jsonData['userVote']);\n        self::assertFalse($jsonData['isAdult']);\n        self::assertFalse($jsonData['isPinned']);\n        self::assertFalse($jsonData['isLocked']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid');\n        self::assertNull($jsonData['editedAt']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid');\n        self::assertEquals('test-article', $jsonData['slug']);\n        self::assertNull($jsonData['apId']);\n    }\n\n    public function testApiAuthorNonModeratorCanUnpinPost(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $post = $this->createPost('test article', user: $user, magazine: $magazine);\n\n        $postManager = $this->postManager;\n        $postManager->toggleLock($post, $user);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:post:lock');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/post/{$post->getId()}/lock\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData);\n        self::assertSame($post->getId(), $jsonData['postId']);\n        self::assertIsArray($jsonData['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']);\n        self::assertSame($magazine->getId(), $jsonData['magazine']['magazineId']);\n        self::assertIsArray($jsonData['user']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']);\n        self::assertSame($user->getId(), $jsonData['user']['userId']);\n        self::assertEquals($post->body, $jsonData['body']);\n        self::assertNull($jsonData['image']);\n        self::assertEquals($post->lang, $jsonData['lang']);\n        self::assertEmpty($jsonData['tags']);\n        self::assertNull($jsonData['mentions']);\n        self::assertSame(0, $jsonData['comments']);\n        self::assertSame(0, $jsonData['uv']);\n        self::assertSame(0, $jsonData['dv']);\n        self::assertSame(0, $jsonData['favourites']);\n        // No scope for seeing votes granted\n        self::assertNull($jsonData['isFavourited']);\n        self::assertNull($jsonData['userVote']);\n        self::assertFalse($jsonData['isAdult']);\n        self::assertFalse($jsonData['isPinned']);\n        self::assertFalse($jsonData['isLocked']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid');\n        self::assertNull($jsonData['editedAt']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid');\n        self::assertEquals('test-article', $jsonData['slug']);\n        self::assertNull($jsonData['apId']);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/Post/Moderate/PostPinApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\Post\\Moderate;\n\nuse App\\Tests\\WebTestCase;\n\nclass PostPinApiTest extends WebTestCase\n{\n    public function testApiCannotPinPostAnonymous(): void\n    {\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $post = $this->createPost('test article', magazine: $magazine);\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/post/{$post->getId()}/pin\");\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiNonModeratorCannotPinPost(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $post = $this->createPost('test article', user: $user, magazine: $magazine);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:post:pin');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/post/{$post->getId()}/pin\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCannotPinPostWithoutScope(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme', $user);\n        $post = $this->createPost('test article', user: $user, magazine: $magazine);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/post/{$post->getId()}/pin\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanPinPost(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme', $user);\n        $post = $this->createPost('test article', user: $user, magazine: $magazine);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:post:pin');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/post/{$post->getId()}/pin\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData);\n        self::assertSame($post->getId(), $jsonData['postId']);\n        self::assertIsArray($jsonData['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']);\n        self::assertSame($magazine->getId(), $jsonData['magazine']['magazineId']);\n        self::assertIsArray($jsonData['user']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']);\n        self::assertSame($user->getId(), $jsonData['user']['userId']);\n        self::assertEquals($post->body, $jsonData['body']);\n        self::assertNull($jsonData['image']);\n        self::assertEquals($post->lang, $jsonData['lang']);\n        self::assertEmpty($jsonData['tags']);\n        self::assertNull($jsonData['mentions']);\n        self::assertSame(0, $jsonData['comments']);\n        self::assertSame(0, $jsonData['uv']);\n        self::assertSame(0, $jsonData['dv']);\n        self::assertSame(0, $jsonData['favourites']);\n        // No scope for seeing votes granted\n        self::assertNull($jsonData['isFavourited']);\n        self::assertNull($jsonData['userVote']);\n        self::assertFalse($jsonData['isAdult']);\n        self::assertTrue($jsonData['isPinned']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid');\n        self::assertNull($jsonData['editedAt']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid');\n        self::assertEquals('test-article', $jsonData['slug']);\n        self::assertNull($jsonData['apId']);\n    }\n\n    public function testApiCannotUnpinPostAnonymous(): void\n    {\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $post = $this->createPost('test article', magazine: $magazine);\n\n        $postManager = $this->postManager;\n        $postManager->pin($post);\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/post/{$post->getId()}/pin\");\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiNonModeratorCannotUnpinPost(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $post = $this->createPost('test article', user: $user, magazine: $magazine);\n\n        $postManager = $this->postManager;\n        $postManager->pin($post);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:post:pin');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/post/{$post->getId()}/pin\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCannotUnpinPostWithoutScope(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme', $user);\n        $post = $this->createPost('test article', user: $user, magazine: $magazine);\n\n        $postManager = $this->postManager;\n        $postManager->pin($post);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/post/{$post->getId()}/pin\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanUnpinPost(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme', $user);\n        $post = $this->createPost('test article', user: $user, magazine: $magazine);\n\n        $postManager = $this->postManager;\n        $postManager->pin($post);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:post:pin');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/post/{$post->getId()}/pin\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData);\n        self::assertSame($post->getId(), $jsonData['postId']);\n        self::assertIsArray($jsonData['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']);\n        self::assertSame($magazine->getId(), $jsonData['magazine']['magazineId']);\n        self::assertIsArray($jsonData['user']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']);\n        self::assertSame($user->getId(), $jsonData['user']['userId']);\n        self::assertEquals($post->body, $jsonData['body']);\n        self::assertNull($jsonData['image']);\n        self::assertEquals($post->lang, $jsonData['lang']);\n        self::assertEmpty($jsonData['tags']);\n        self::assertNull($jsonData['mentions']);\n        self::assertSame(0, $jsonData['comments']);\n        self::assertSame(0, $jsonData['uv']);\n        self::assertSame(0, $jsonData['dv']);\n        self::assertSame(0, $jsonData['favourites']);\n        // No scope for seeing votes granted\n        self::assertNull($jsonData['isFavourited']);\n        self::assertNull($jsonData['userVote']);\n        self::assertFalse($jsonData['isAdult']);\n        self::assertFalse($jsonData['isPinned']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid');\n        self::assertNull($jsonData['editedAt']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid');\n        self::assertEquals('test-article', $jsonData['slug']);\n        self::assertNull($jsonData['apId']);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/Post/Moderate/PostSetAdultApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\Post\\Moderate;\n\nuse App\\DTO\\ModeratorDto;\nuse App\\Tests\\WebTestCase;\n\nclass PostSetAdultApiTest extends WebTestCase\n{\n    public function testApiCannotSetPostAdultAnonymous(): void\n    {\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $post = $this->createPost('test article', magazine: $magazine);\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/post/{$post->getId()}/adult/true\");\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiNonModeratorCannotSetPostAdult(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $post = $this->createPost('test article', user: $user, magazine: $magazine);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:post:set_adult');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/post/{$post->getId()}/adult/true\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCannotSetPostAdultWithoutScope(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme', $user);\n        $post = $this->createPost('test article', user: $user, magazine: $magazine);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/post/{$post->getId()}/adult/true\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanSetPostAdult(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $admin = $this->getUserByUsername('admin', isAdmin: true);\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $post = $this->createPost('test article', user: $user, magazine: $magazine);\n\n        $magazineManager = $this->magazineManager;\n        $moderator = new ModeratorDto($magazine);\n        $moderator->user = $user;\n        $moderator->addedBy = $admin;\n        $magazineManager->addModerator($moderator);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:post:set_adult');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/post/{$post->getId()}/adult/true\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData);\n        self::assertSame($post->getId(), $jsonData['postId']);\n        self::assertIsArray($jsonData['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']);\n        self::assertSame($magazine->getId(), $jsonData['magazine']['magazineId']);\n        self::assertIsArray($jsonData['user']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']);\n        self::assertSame($user->getId(), $jsonData['user']['userId']);\n        self::assertEquals($post->body, $jsonData['body']);\n        self::assertNull($jsonData['image']);\n        self::assertEquals($post->lang, $jsonData['lang']);\n        self::assertEmpty($jsonData['tags']);\n        self::assertNull($jsonData['mentions']);\n        self::assertSame(0, $jsonData['comments']);\n        self::assertSame(0, $jsonData['uv']);\n        self::assertSame(0, $jsonData['dv']);\n        self::assertSame(0, $jsonData['favourites']);\n        // No scope for seeing votes granted\n        self::assertNull($jsonData['isFavourited']);\n        self::assertNull($jsonData['userVote']);\n        self::assertTrue($jsonData['isAdult']);\n        self::assertFalse($jsonData['isPinned']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid');\n        self::assertNull($jsonData['editedAt']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid');\n        self::assertEquals('visible', $jsonData['visibility']);\n        self::assertEquals('test-article', $jsonData['slug']);\n        self::assertNull($jsonData['apId']);\n    }\n\n    public function testApiCannotSetPostNotAdultAnonymous(): void\n    {\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $post = $this->createPost('test article', magazine: $magazine);\n\n        $entityManager = $this->entityManager;\n        $post->isAdult = true;\n        $entityManager->persist($post);\n        $entityManager->flush();\n\n        $this->client->request('PUT', \"/api/moderate/post/{$post->getId()}/adult/false\");\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiNonModeratorCannotSetPostNotAdult(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $post = $this->createPost('test article', user: $user, magazine: $magazine);\n\n        $entityManager = $this->entityManager;\n        $post->isAdult = true;\n        $entityManager->persist($post);\n        $entityManager->flush();\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:post:set_adult');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/post/{$post->getId()}/adult/false\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCannotSetPostNotAdultWithoutScope(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme', $user);\n        $post = $this->createPost('test article', user: $user, magazine: $magazine);\n\n        $entityManager = $this->entityManager;\n        $post->isAdult = true;\n        $entityManager->persist($post);\n        $entityManager->flush();\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/post/{$post->getId()}/adult/false\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanSetPostNotAdult(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $admin = $this->getUserByUsername('admin', isAdmin: true);\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $post = $this->createPost('test article', user: $user, magazine: $magazine);\n\n        $magazineManager = $this->magazineManager;\n        $moderator = new ModeratorDto($magazine);\n        $moderator->user = $user;\n        $moderator->addedBy = $admin;\n        $magazineManager->addModerator($moderator);\n\n        $entityManager = $this->entityManager;\n        $post->isAdult = true;\n        $entityManager->persist($post);\n        $entityManager->flush();\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:post:set_adult');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/post/{$post->getId()}/adult/false\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData);\n        self::assertSame($post->getId(), $jsonData['postId']);\n        self::assertIsArray($jsonData['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']);\n        self::assertSame($magazine->getId(), $jsonData['magazine']['magazineId']);\n        self::assertIsArray($jsonData['user']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']);\n        self::assertSame($user->getId(), $jsonData['user']['userId']);\n        self::assertEquals($post->body, $jsonData['body']);\n        self::assertNull($jsonData['image']);\n        self::assertEquals($post->lang, $jsonData['lang']);\n        self::assertEmpty($jsonData['tags']);\n        self::assertNull($jsonData['mentions']);\n        self::assertSame(0, $jsonData['comments']);\n        self::assertSame(0, $jsonData['uv']);\n        self::assertSame(0, $jsonData['dv']);\n        self::assertSame(0, $jsonData['favourites']);\n        // No scope for seeing votes granted\n        self::assertNull($jsonData['isFavourited']);\n        self::assertNull($jsonData['userVote']);\n        self::assertFalse($jsonData['isAdult']);\n        self::assertFalse($jsonData['isPinned']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid');\n        self::assertNull($jsonData['editedAt']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid');\n        self::assertEquals('visible', $jsonData['visibility']);\n        self::assertEquals('test-article', $jsonData['slug']);\n        self::assertNull($jsonData['apId']);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/Post/Moderate/PostSetLanguageApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\Post\\Moderate;\n\nuse App\\DTO\\ModeratorDto;\nuse App\\Tests\\WebTestCase;\n\nclass PostSetLanguageApiTest extends WebTestCase\n{\n    public function testApiCannotSetPostLanguageAnonymous(): void\n    {\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $post = $this->createPost('test post', magazine: $magazine);\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/post/{$post->getId()}/de\");\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiNonModeratorCannotSetPostLanguage(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $post = $this->createPost('test post', user: $user, magazine: $magazine);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:post:language');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/post/{$post->getId()}/de\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCannotSetPostLanguageWithoutScope(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme', $user);\n        $post = $this->createPost('test post', user: $user, magazine: $magazine);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/post/{$post->getId()}/de\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCannotSetPostLanguageInvalid(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $admin = $this->getUserByUsername('admin', isAdmin: true);\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $post = $this->createPost('test post', user: $user, magazine: $magazine);\n\n        $magazineManager = $this->magazineManager;\n        $moderator = new ModeratorDto($magazine);\n        $moderator->user = $user;\n        $moderator->addedBy = $admin;\n        $magazineManager->addModerator($moderator);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:post:language');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/post/{$post->getId()}/fake\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(400);\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/post/{$post->getId()}/ac\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(400);\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/post/{$post->getId()}/aaa\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(400);\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/post/{$post->getId()}/a\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(400);\n    }\n\n    public function testApiCanSetPostLanguage(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $admin = $this->getUserByUsername('admin', isAdmin: true);\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $post = $this->createPost('test post', user: $user, magazine: $magazine);\n\n        $magazineManager = $this->magazineManager;\n        $moderator = new ModeratorDto($magazine);\n        $moderator->user = $user;\n        $moderator->addedBy = $admin;\n        $magazineManager->addModerator($moderator);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:post:language');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/post/{$post->getId()}/de\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData);\n        self::assertSame($post->getId(), $jsonData['postId']);\n        self::assertIsArray($jsonData['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']);\n        self::assertSame($magazine->getId(), $jsonData['magazine']['magazineId']);\n        self::assertIsArray($jsonData['user']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']);\n        self::assertSame($user->getId(), $jsonData['user']['userId']);\n        self::assertEquals($post->body, $jsonData['body']);\n        self::assertNull($jsonData['image']);\n        self::assertEquals('de', $jsonData['lang']);\n        self::assertEmpty($jsonData['tags']);\n        self::assertNull($jsonData['mentions']);\n        self::assertSame(0, $jsonData['comments']);\n        self::assertSame(0, $jsonData['uv']);\n        self::assertSame(0, $jsonData['dv']);\n        self::assertSame(0, $jsonData['favourites']);\n        // No scope for seeing votes granted\n        self::assertNull($jsonData['isFavourited']);\n        self::assertNull($jsonData['userVote']);\n        self::assertFalse($jsonData['isAdult']);\n        self::assertFalse($jsonData['isPinned']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid');\n        self::assertNull($jsonData['editedAt']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid');\n        self::assertEquals('visible', $jsonData['visibility']);\n        self::assertEquals('test-post', $jsonData['slug']);\n        self::assertNull($jsonData['apId']);\n    }\n\n    public function testApiCanSetPostLanguage3Letter(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $admin = $this->getUserByUsername('admin', isAdmin: true);\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $post = $this->createPost('test post', user: $user, magazine: $magazine);\n\n        $magazineManager = $this->magazineManager;\n        $moderator = new ModeratorDto($magazine);\n        $moderator->user = $user;\n        $moderator->addedBy = $admin;\n        $magazineManager->addModerator($moderator);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:post:language');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/post/{$post->getId()}/elx\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData);\n        self::assertSame($post->getId(), $jsonData['postId']);\n        self::assertIsArray($jsonData['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']);\n        self::assertSame($magazine->getId(), $jsonData['magazine']['magazineId']);\n        self::assertIsArray($jsonData['user']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']);\n        self::assertSame($user->getId(), $jsonData['user']['userId']);\n        self::assertEquals($post->body, $jsonData['body']);\n        self::assertNull($jsonData['image']);\n        self::assertEquals('elx', $jsonData['lang']);\n        self::assertEmpty($jsonData['tags']);\n        self::assertNull($jsonData['mentions']);\n        self::assertSame(0, $jsonData['comments']);\n        self::assertSame(0, $jsonData['uv']);\n        self::assertSame(0, $jsonData['dv']);\n        self::assertSame(0, $jsonData['favourites']);\n        // No scope for seeing votes granted\n        self::assertNull($jsonData['isFavourited']);\n        self::assertNull($jsonData['userVote']);\n        self::assertFalse($jsonData['isAdult']);\n        self::assertFalse($jsonData['isPinned']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid');\n        self::assertNull($jsonData['editedAt']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid');\n        self::assertEquals('visible', $jsonData['visibility']);\n        self::assertEquals('test-post', $jsonData['slug']);\n        self::assertNull($jsonData['apId']);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/Post/Moderate/PostTrashApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\Post\\Moderate;\n\nuse App\\DTO\\ModeratorDto;\nuse App\\Tests\\WebTestCase;\n\nclass PostTrashApiTest extends WebTestCase\n{\n    public function testApiCannotTrashPostAnonymous(): void\n    {\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $post = $this->createPost('test post', magazine: $magazine);\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/post/{$post->getId()}/trash\");\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiNonModeratorCannotTrashPost(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $post = $this->createPost('test post', user: $user, magazine: $magazine);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:post:trash');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/post/{$post->getId()}/trash\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCannotTrashPostWithoutScope(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme', $user);\n        $post = $this->createPost('test post', user: $user, magazine: $magazine);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/post/{$post->getId()}/trash\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanTrashPost(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $admin = $this->getUserByUsername('admin', isAdmin: true);\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $post = $this->createPost('test post', user: $user, magazine: $magazine);\n\n        $magazineManager = $this->magazineManager;\n        $moderator = new ModeratorDto($magazine);\n        $moderator->user = $user;\n        $moderator->addedBy = $admin;\n        $magazineManager->addModerator($moderator);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:post:trash');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/post/{$post->getId()}/trash\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData);\n        self::assertSame($post->getId(), $jsonData['postId']);\n        self::assertIsArray($jsonData['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']);\n        self::assertSame($magazine->getId(), $jsonData['magazine']['magazineId']);\n        self::assertIsArray($jsonData['user']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']);\n        self::assertSame($user->getId(), $jsonData['user']['userId']);\n        self::assertEquals($post->body, $jsonData['body']);\n        self::assertNull($jsonData['image']);\n        self::assertEquals($post->lang, $jsonData['lang']);\n        self::assertEmpty($jsonData['tags']);\n        self::assertNull($jsonData['mentions']);\n        self::assertSame(0, $jsonData['comments']);\n        self::assertSame(0, $jsonData['uv']);\n        self::assertSame(0, $jsonData['dv']);\n        self::assertSame(0, $jsonData['favourites']);\n        // No scope for seeing votes granted\n        self::assertNull($jsonData['isFavourited']);\n        self::assertNull($jsonData['userVote']);\n        self::assertFalse($jsonData['isAdult']);\n        self::assertFalse($jsonData['isPinned']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid');\n        self::assertNull($jsonData['editedAt']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid');\n        self::assertEquals('trashed', $jsonData['visibility']);\n        self::assertEquals('test-post', $jsonData['slug']);\n        self::assertNull($jsonData['apId']);\n    }\n\n    public function testApiCannotRestorePostAnonymous(): void\n    {\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $user = $this->getUserByUsername('user');\n        $post = $this->createPost('test post', magazine: $magazine);\n\n        $postManager = $this->postManager;\n        $postManager->trash($user, $post);\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/post/{$post->getId()}/restore\");\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiNonModeratorCannotRestorePost(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $post = $this->createPost('test post', user: $user, magazine: $magazine);\n\n        $postManager = $this->postManager;\n        $postManager->trash($user, $post);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:post:trash');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/post/{$post->getId()}/restore\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCannotRestorePostWithoutScope(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme', $user);\n        $post = $this->createPost('test post', user: $user, magazine: $magazine);\n\n        $postManager = $this->postManager;\n        $postManager->trash($user, $post);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/post/{$post->getId()}/restore\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanRestorePost(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $admin = $this->getUserByUsername('admin', isAdmin: true);\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $post = $this->createPost('test post', user: $user, magazine: $magazine);\n\n        $magazineManager = $this->magazineManager;\n        $moderator = new ModeratorDto($magazine);\n        $moderator->user = $user;\n        $moderator->addedBy = $admin;\n        $magazineManager->addModerator($moderator);\n\n        $postManager = $this->postManager;\n        $postManager->trash($user, $post);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:post:trash');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/moderate/post/{$post->getId()}/restore\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData);\n        self::assertSame($post->getId(), $jsonData['postId']);\n        self::assertIsArray($jsonData['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']);\n        self::assertSame($magazine->getId(), $jsonData['magazine']['magazineId']);\n        self::assertIsArray($jsonData['user']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']);\n        self::assertSame($user->getId(), $jsonData['user']['userId']);\n        self::assertEquals($post->body, $jsonData['body']);\n        self::assertNull($jsonData['image']);\n        self::assertEquals($post->lang, $jsonData['lang']);\n        self::assertEmpty($jsonData['tags']);\n        self::assertNull($jsonData['mentions']);\n        self::assertSame(0, $jsonData['comments']);\n        self::assertSame(0, $jsonData['uv']);\n        self::assertSame(0, $jsonData['dv']);\n        self::assertSame(0, $jsonData['favourites']);\n        // No scope for seeing votes granted\n        self::assertNull($jsonData['isFavourited']);\n        self::assertNull($jsonData['userVote']);\n        self::assertFalse($jsonData['isAdult']);\n        self::assertFalse($jsonData['isPinned']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid');\n        self::assertNull($jsonData['editedAt']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid');\n        self::assertEquals('visible', $jsonData['visibility']);\n        self::assertEquals('test-post', $jsonData['slug']);\n        self::assertNull($jsonData['apId']);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/Post/PostCreateApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\Post;\n\nuse App\\Tests\\WebTestCase;\nuse Symfony\\Component\\HttpFoundation\\File\\UploadedFile;\n\nclass PostCreateApiTest extends WebTestCase\n{\n    public function testApiCannotCreatePostAnonymous(): void\n    {\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $postRequest = [\n            'body' => 'This is a microblog',\n            'lang' => 'en',\n            'isAdult' => false,\n        ];\n\n        $this->client->jsonRequest('POST', \"/api/magazine/{$magazine->getId()}/posts\", parameters: $postRequest);\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotCreatePostWithoutScope(): void\n    {\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $postRequest = [\n            'body' => 'No scope post',\n            'lang' => 'en',\n            'isAdult' => false,\n        ];\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('POST', \"/api/magazine/{$magazine->getId()}/posts\", parameters: $postRequest, server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanCreatePost(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $postRequest = [\n            'body' => 'This is a microblog #test @user',\n            'lang' => 'en',\n            'isAdult' => false,\n        ];\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read post:create');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('POST', \"/api/magazine/{$magazine->getId()}/posts\", parameters: $postRequest, server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(201);\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData);\n        self::assertNotNull($jsonData['postId']);\n        self::assertIsArray($jsonData['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']);\n        self::assertSame($magazine->getId(), $jsonData['magazine']['magazineId']);\n        self::assertIsArray($jsonData['user']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']);\n        self::assertSame($user->getId(), $jsonData['user']['userId']);\n        self::assertEquals('This is a microblog #test @user', $jsonData['body']);\n        self::assertNull($jsonData['image']);\n        self::assertEquals('en', $jsonData['lang']);\n        self::assertIsArray($jsonData['tags']);\n        self::assertSame(['test'], $jsonData['tags']);\n        self::assertIsArray($jsonData['mentions']);\n        self::assertSame(['@user'], $jsonData['mentions']);\n        self::assertSame(0, $jsonData['comments']);\n        self::assertSame(0, $jsonData['uv']);\n        self::assertSame(0, $jsonData['dv']);\n        self::assertSame(0, $jsonData['favourites']);\n        // No scope for seeing votes granted\n        self::assertNull($jsonData['isFavourited']);\n        self::assertNull($jsonData['userVote']);\n        self::assertFalse($jsonData['isAdult']);\n        self::assertFalse($jsonData['isPinned']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid');\n        self::assertNull($jsonData['editedAt']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid');\n        self::assertNull($jsonData['apId']);\n        self::assertEquals('This-is-a-microblog-test-at-user', $jsonData['slug']);\n    }\n\n    public function testApiCannotCreateImagePostAnonymous(): void\n    {\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $postRequest = [\n            'alt' => 'It\\'s kibby!',\n            'lang' => 'en',\n            'isAdult' => false,\n        ];\n\n        // Uploading a file appears to delete the file at the given path, so make a copy before upload\n        copy($this->kibbyPath, $this->kibbyPath.'.tmp');\n        $image = new UploadedFile($this->kibbyPath.'.tmp', 'kibby_emoji.png', 'image/png');\n\n        $this->client->request(\n            'POST', \"/api/magazine/{$magazine->getId()}/posts/image\",\n            parameters: $postRequest, files: ['uploadImage' => $image],\n        );\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotCreateImagePostWithoutScope(): void\n    {\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $postRequest = [\n            'alt' => 'It\\'s kibby!',\n            'lang' => 'en',\n            'isAdult' => false,\n        ];\n\n        // Uploading a file appears to delete the file at the given path, so make a copy before upload\n        $tmpPath = bin2hex(random_bytes(32));\n        copy($this->kibbyPath, $tmpPath.'.png');\n        $image = new UploadedFile($tmpPath.'.png', 'kibby_emoji.png', 'image/png');\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request(\n            'POST', \"/api/magazine/{$magazine->getId()}/posts/image\",\n            parameters: $postRequest, files: ['uploadImage' => $image],\n            server: ['HTTP_AUTHORIZATION' => $token]\n        );\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanCreateImagePost(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $postRequest = [\n            'alt' => 'It\\'s kibby!',\n            'lang' => 'en',\n            'isAdult' => false,\n        ];\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        // Uploading a file appears to delete the file at the given path, so make a copy before upload\n        $tmpPath = bin2hex(random_bytes(32));\n        copy($this->kibbyPath, $tmpPath.'.png');\n        $image = new UploadedFile($tmpPath.'.png', 'kibby_emoji.png', 'image/png');\n\n        $imageManager = $this->imageManager;\n        $expectedPath = $imageManager->getFilePath($image->getFilename());\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read post:create');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request(\n            'POST', \"/api/magazine/{$magazine->getId()}/posts/image\",\n            parameters: $postRequest, files: ['uploadImage' => $image],\n            server: ['HTTP_AUTHORIZATION' => $token]\n        );\n        self::assertResponseStatusCodeSame(201);\n        $jsonData = self::getJsonResponse($this->client);\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData);\n        self::assertNotNull($jsonData['postId']);\n        self::assertIsArray($jsonData['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']);\n        self::assertSame($magazine->getId(), $jsonData['magazine']['magazineId']);\n        self::assertIsArray($jsonData['user']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']);\n        self::assertSame($user->getId(), $jsonData['user']['userId']);\n        self::assertEquals('', $jsonData['body']);\n        self::assertIsArray($jsonData['image']);\n        self::assertArrayKeysMatch(self::IMAGE_KEYS, $jsonData['image']);\n        self::assertStringContainsString($expectedPath, $jsonData['image']['filePath']);\n        self::assertEquals('It\\'s kibby!', $jsonData['image']['altText']);\n        self::assertEquals('en', $jsonData['lang']);\n        self::assertEmpty($jsonData['tags']);\n        self::assertNull($jsonData['mentions']);\n        self::assertSame(0, $jsonData['comments']);\n        self::assertSame(0, $jsonData['uv']);\n        self::assertSame(0, $jsonData['dv']);\n        self::assertSame(0, $jsonData['favourites']);\n        // No scope for seeing votes granted\n        self::assertNull($jsonData['isFavourited']);\n        self::assertNull($jsonData['userVote']);\n        self::assertFalse($jsonData['isAdult']);\n        self::assertFalse($jsonData['isPinned']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid');\n        self::assertNull($jsonData['editedAt']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid');\n        self::assertNull($jsonData['apId']);\n        self::assertEquals('acme-It-s-kibby', $jsonData['slug']);\n    }\n\n    public function testApiCannotCreatePostWithoutMagazine(): void\n    {\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $invalidId = $magazine->getId() + 1;\n        $postRequest = [\n            'lang' => 'en',\n            'isAdult' => false,\n        ];\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read post:create');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('POST', \"/api/magazine/{$invalidId}/posts\", parameters: $postRequest, server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(404);\n\n        $this->client->request('POST', \"/api/magazine/{$invalidId}/posts/image\", parameters: $postRequest, server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(404);\n    }\n\n    public function testApiCannotCreatePostWithoutBodyOrImage(): void\n    {\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $postRequest = [\n            'lang' => 'en',\n            'isAdult' => false,\n        ];\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read post:create');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('POST', \"/api/magazine/{$magazine->getId()}/posts\", parameters: $postRequest, server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(400);\n\n        $this->client->request('POST', \"/api/magazine/{$magazine->getId()}/posts/image\", parameters: $postRequest, server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(400);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/Post/PostDeleteApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\Post;\n\nuse App\\Tests\\WebTestCase;\n\nclass PostDeleteApiTest extends WebTestCase\n{\n    public function testApiCannotDeletePostAnonymous(): void\n    {\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $post = $this->createPost(body: 'test for deletion', magazine: $magazine);\n\n        $this->client->request('DELETE', \"/api/post/{$post->getId()}\");\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotDeletePostWithoutScope(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $post = $this->createPost(body: 'test for deletion', user: $user, magazine: $magazine);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('DELETE', \"/api/post/{$post->getId()}\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCannotDeleteOtherUsersPost(): void\n    {\n        $otherUser = $this->getUserByUsername('somebody');\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $post = $this->createPost(body: 'test for deletion', user: $otherUser, magazine: $magazine);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read post:delete');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('DELETE', \"/api/post/{$post->getId()}\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanDeletePost(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $post = $this->createPost(body: 'test for deletion', user: $user, magazine: $magazine);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read post:delete');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('DELETE', \"/api/post/{$post->getId()}\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(204);\n    }\n\n    public function testApiCannotDeleteImagePostAnonymous(): void\n    {\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n\n        $imageDto = $this->getKibbyImageDto();\n        $post = $this->createPost('test image', imageDto: $imageDto, magazine: $magazine);\n\n        $this->client->request('DELETE', \"/api/post/{$post->getId()}\");\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotDeleteImagePostWithoutScope(): void\n    {\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $user = $this->getUserByUsername('user');\n\n        $imageDto = $this->getKibbyImageDto();\n        $post = $this->createPost('test image', imageDto: $imageDto, user: $user, magazine: $magazine);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('DELETE', \"/api/post/{$post->getId()}\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCannotDeleteOtherUsersImagePost(): void\n    {\n        $otherUser = $this->getUserByUsername('somebody');\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n\n        $imageDto = $this->getKibbyImageDto();\n        $post = $this->createPost('test image', imageDto: $imageDto, user: $otherUser, magazine: $magazine);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read post:delete');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('DELETE', \"/api/post/{$post->getId()}\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanDeleteImagePost(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n\n        $imageDto = $this->getKibbyImageDto();\n        $post = $this->createPost('test image', imageDto: $imageDto, user: $user, magazine: $magazine);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read post:delete');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('DELETE', \"/api/post/{$post->getId()}\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(204);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/Post/PostFavouriteApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\Post;\n\nuse App\\Tests\\WebTestCase;\n\nclass PostFavouriteApiTest extends WebTestCase\n{\n    public function testApiCannotFavouritePostAnonymous(): void\n    {\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $post = $this->createPost('test for favourite', magazine: $magazine);\n\n        $this->client->jsonRequest('PUT', \"/api/post/{$post->getId()}/favourite\");\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotFavouritePostWithoutScope(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $post = $this->createPost('test for favourite', user: $user, magazine: $magazine);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/post/{$post->getId()}/favourite\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanFavouritePost(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $post = $this->createPost('test for favourite', user: $user, magazine: $magazine);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read post:vote');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/post/{$post->getId()}/favourite\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData);\n        self::assertSame($post->getId(), $jsonData['postId']);\n        self::assertIsArray($jsonData['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']);\n        self::assertSame($magazine->getId(), $jsonData['magazine']['magazineId']);\n        self::assertIsArray($jsonData['user']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']);\n        self::assertSame($user->getId(), $jsonData['user']['userId']);\n        self::assertEquals($post->body, $jsonData['body']);\n        self::assertNull($jsonData['image']);\n        self::assertEquals($post->lang, $jsonData['lang']);\n        self::assertEmpty($jsonData['tags']);\n        self::assertNull($jsonData['mentions']);\n        self::assertSame(0, $jsonData['comments']);\n        self::assertSame(0, $jsonData['uv']);\n        self::assertSame(0, $jsonData['dv']);\n        self::assertSame(1, $jsonData['favourites']);\n        self::assertTrue($jsonData['isFavourited']);\n        self::assertSame(0, $jsonData['userVote']);\n        self::assertFalse($jsonData['isAdult']);\n        self::assertFalse($jsonData['isPinned']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid');\n        self::assertNull($jsonData['editedAt']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid');\n        self::assertEquals('test-for-favourite', $jsonData['slug']);\n        self::assertNull($jsonData['apId']);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/Post/PostReportApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\Post;\n\nuse App\\Entity\\Report;\nuse App\\Tests\\WebTestCase;\n\nclass PostReportApiTest extends WebTestCase\n{\n    public function testApiCannotReportPostAnonymous(): void\n    {\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $post = $this->createPost('test for report', magazine: $magazine);\n\n        $reportRequest = [\n            'reason' => 'Test reporting',\n        ];\n\n        $this->client->jsonRequest('POST', \"/api/post/{$post->getId()}/report\", $reportRequest);\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotReportPostWithoutScope(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $post = $this->createPost('test for report', user: $user, magazine: $magazine);\n\n        $reportRequest = [\n            'reason' => 'Test reporting',\n        ];\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('POST', \"/api/post/{$post->getId()}/report\", $reportRequest, server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanReportPost(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $otherUser = $this->getUserByUsername('somebody');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $post = $this->createPost('test for report', user: $otherUser, magazine: $magazine);\n\n        $reportRequest = [\n            'reason' => 'Test reporting',\n        ];\n\n        $magazineRepository = $this->magazineRepository;\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read post:report');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('POST', \"/api/post/{$post->getId()}/report\", $reportRequest, server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(204);\n\n        $magazine = $magazineRepository->find($magazine->getId());\n        $reports = $magazineRepository->findReports($magazine);\n        self::assertSame(1, $reports->count());\n\n        /** @var Report $report */\n        $report = $reports->getCurrentPageResults()[0];\n\n        self::assertEquals('Test reporting', $report->reason);\n        self::assertSame($user->getId(), $report->reporting->getId());\n        self::assertSame($otherUser->getId(), $report->reported->getId());\n        self::assertSame($post->getId(), $report->getSubject()->getId());\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/Post/PostRetrieveApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\Post;\n\nuse App\\Tests\\WebTestCase;\n\nclass PostRetrieveApiTest extends WebTestCase\n{\n    public function testApiCannotGetSubscribedPostsAnonymous(): void\n    {\n        $this->client->request('GET', '/api/posts/subscribed');\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotGetSubscribedPostsWithoutScope(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'write');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', '/api/posts/subscribed', server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanGetSubscribedPosts(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $this->createPost('a post');\n        $magazine = $this->getMagazineByNameNoRSAKey('somemag', $user);\n        $post = $this->createPost('another post', magazine: $magazine);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', '/api/posts/subscribed', server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(1, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(1, $jsonData['pagination']['count']);\n\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertSame($post->getId(), $jsonData['items'][0]['postId']);\n        self::assertEquals('another post', $jsonData['items'][0]['body']);\n        self::assertIsArray($jsonData['items'][0]['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['magazine']);\n        self::assertSame($magazine->getId(), $jsonData['items'][0]['magazine']['magazineId']);\n        self::assertIsArray($jsonData['items'][0]['user']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['user']);\n        self::assertNull($jsonData['items'][0]['image']);\n        self::assertEquals('en', $jsonData['items'][0]['lang']);\n        self::assertEmpty($jsonData['items'][0]['tags']);\n        self::assertNull($jsonData['items'][0]['mentions']);\n        self::assertSame(0, $jsonData['items'][0]['comments']);\n        self::assertSame(0, $jsonData['items'][0]['uv']);\n        self::assertSame(0, $jsonData['items'][0]['dv']);\n        self::assertSame(0, $jsonData['items'][0]['favourites']);\n        // No scope for seeing votes granted\n        self::assertNull($jsonData['items'][0]['isFavourited']);\n        self::assertNull($jsonData['items'][0]['userVote']);\n        self::assertFalse($jsonData['items'][0]['isAdult']);\n        self::assertFalse($jsonData['items'][0]['isPinned']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['items'][0]['createdAt'], 'createdAt date format invalid');\n        self::assertNull($jsonData['items'][0]['editedAt']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['items'][0]['lastActive'], 'lastActive date format invalid');\n        self::assertEquals('another-post', $jsonData['items'][0]['slug']);\n        self::assertNull($jsonData['items'][0]['apId']);\n        self::assertIsArray($jsonData['items'][0]['bookmarks']);\n        self::assertEmpty($jsonData['items'][0]['bookmarks']);\n    }\n\n    public function testApiCanGetSubscribedPostsWithBoosts(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $userFollowing = $this->getUserByUsername('user2');\n        $user3 = $this->getUserByUsername('user3');\n\n        $this->userManager->follow($user, $userFollowing, false);\n\n        $postFollowed = $this->createPost('a post', user: $userFollowing);\n        $postBoosted = $this->createPost('third user post', user: $user3);\n        $this->createPost('unrelated post', user: $user3);\n        $commentFollowed = $this->createPostComment('a comment', $postBoosted, $userFollowing);\n        $commentBoosted = $this->createPostComment('a boosted comment', $postBoosted, $user3);\n        $this->createPostComment('unrelated comment', $postBoosted, $user3);\n\n        $this->voteManager->upvote($postBoosted, $userFollowing);\n        $this->voteManager->upvote($commentBoosted, $userFollowing);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', '/api/posts/subscribedWithBoosts', server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(4, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(4, $jsonData['pagination']['count']);\n\n        $retrievedPostIds = array_map(function ($item) {\n            if (null !== $item['post']) {\n                self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $item['post']);\n\n                return $item['post']['postId'];\n            } else {\n                return null;\n            }\n        }, $jsonData['items']);\n        $retrievedPostIds = array_filter($retrievedPostIds, function ($item) { return null !== $item; });\n        sort($retrievedPostIds);\n\n        $retrievedPostCommentIds = array_map(function ($item) {\n            if (null !== $item['postComment']) {\n                self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $item['postComment']);\n\n                return $item['postComment']['commentId'];\n            } else {\n                return null;\n            }\n        }, $jsonData['items']);\n        $retrievedPostCommentIds = array_filter($retrievedPostCommentIds, function ($item) { return null !== $item; });\n        sort($retrievedPostCommentIds);\n\n        $expectedPostIds = [$postFollowed->getId(), $postBoosted->getId()];\n        sort($expectedPostIds);\n        $expectedPostCommentIds = [$commentFollowed->getId(), $commentBoosted->getId()];\n        sort($expectedPostCommentIds);\n        self::assertEquals($retrievedPostIds, $expectedPostIds);\n        self::assertEquals($expectedPostCommentIds, $expectedPostCommentIds);\n    }\n\n    public function testApiCannotGetModeratedPostsAnonymous(): void\n    {\n        $this->client->request('GET', '/api/posts/moderated');\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotGetModeratedPostsWithoutScope(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', '/api/posts/moderated', server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanGetModeratedPosts(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $this->createPost('a post');\n        $magazine = $this->getMagazineByNameNoRSAKey('somemag', $user);\n        $post = $this->createPost('another post', magazine: $magazine);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read moderate:post');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', '/api/posts/moderated', server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(1, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(1, $jsonData['pagination']['count']);\n\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertSame($post->getId(), $jsonData['items'][0]['postId']);\n        self::assertIsArray($jsonData['items'][0]['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['magazine']);\n        self::assertSame($magazine->getId(), $jsonData['items'][0]['magazine']['magazineId']);\n        self::assertIsArray($jsonData['items'][0]['user']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['user']);\n        self::assertEquals('another post', $jsonData['items'][0]['body']);\n        self::assertNull($jsonData['items'][0]['image']);\n        self::assertEquals('en', $jsonData['items'][0]['lang']);\n        self::assertEmpty($jsonData['items'][0]['tags']);\n        self::assertNull($jsonData['items'][0]['mentions']);\n        self::assertSame(0, $jsonData['items'][0]['comments']);\n        self::assertSame(0, $jsonData['items'][0]['uv']);\n        self::assertSame(0, $jsonData['items'][0]['dv']);\n        self::assertSame(0, $jsonData['items'][0]['favourites']);\n        // No scope for seeing votes granted\n        self::assertNull($jsonData['items'][0]['isFavourited']);\n        self::assertNull($jsonData['items'][0]['userVote']);\n        self::assertFalse($jsonData['items'][0]['isAdult']);\n        self::assertFalse($jsonData['items'][0]['isPinned']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['items'][0]['createdAt'], 'createdAt date format invalid');\n        self::assertNull($jsonData['items'][0]['editedAt']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['items'][0]['lastActive'], 'lastActive date format invalid');\n        self::assertEquals('another-post', $jsonData['items'][0]['slug']);\n        self::assertNull($jsonData['items'][0]['apId']);\n        self::assertIsArray($jsonData['items'][0]['bookmarks']);\n        self::assertEmpty($jsonData['items'][0]['bookmarks']);\n    }\n\n    public function testApiCannotGetFavouritedPostsAnonymous(): void\n    {\n        $this->client->request('GET', '/api/posts/favourited');\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotGetFavouritedPostsWithoutScope(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', '/api/posts/favourited', server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanGetFavouritedPosts(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $post = $this->createPost('a post');\n        $magazine = $this->getMagazineByNameNoRSAKey('somemag');\n        $this->createPost('another post', magazine: $magazine);\n\n        $favouriteManager = $this->favouriteManager;\n        $favouriteManager->toggle($user, $post);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read post:vote');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', '/api/posts/favourited', server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(1, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(1, $jsonData['pagination']['count']);\n\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertSame($post->getId(), $jsonData['items'][0]['postId']);\n        self::assertEquals('a post', $jsonData['items'][0]['body']);\n        self::assertIsArray($jsonData['items'][0]['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['magazine']);\n        self::assertIsArray($jsonData['items'][0]['user']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['user']);\n        self::assertNull($jsonData['items'][0]['image']);\n        self::assertEquals('en', $jsonData['items'][0]['lang']);\n        self::assertEmpty($jsonData['items'][0]['tags']);\n        self::assertNull($jsonData['items'][0]['mentions']);\n        self::assertSame(0, $jsonData['items'][0]['comments']);\n        self::assertSame(0, $jsonData['items'][0]['uv']);\n        self::assertSame(0, $jsonData['items'][0]['dv']);\n        self::assertSame(1, $jsonData['items'][0]['favourites']);\n        // No scope for seeing votes granted\n        self::assertTrue($jsonData['items'][0]['isFavourited']);\n        self::assertSame(0, $jsonData['items'][0]['userVote']);\n        self::assertFalse($jsonData['items'][0]['isAdult']);\n        self::assertFalse($jsonData['items'][0]['isPinned']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['items'][0]['createdAt'], 'createdAt date format invalid');\n        self::assertNull($jsonData['items'][0]['editedAt']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['items'][0]['lastActive'], 'lastActive date format invalid');\n        self::assertEquals('a-post', $jsonData['items'][0]['slug']);\n        self::assertNull($jsonData['items'][0]['apId']);\n        self::assertIsArray($jsonData['items'][0]['bookmarks']);\n        self::assertEmpty($jsonData['items'][0]['bookmarks']);\n    }\n\n    public function testApiCanGetPostsAnonymous(): void\n    {\n        $post = $this->createPost('a post');\n        $this->createPostComment('up the ranking', $post);\n        $magazine = $this->getMagazineByNameNoRSAKey('somemag');\n        $second = $this->createPost('another post', magazine: $magazine);\n        // Check that pinned posts don't get pinned to the top of the instance, just the magazine\n        $postManager = $this->postManager;\n        $postManager->pin($second);\n\n        $this->client->request('GET', '/api/posts');\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(2, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(2, $jsonData['pagination']['count']);\n\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertSame($post->getId(), $jsonData['items'][0]['postId']);\n        self::assertEquals('a post', $jsonData['items'][0]['body']);\n        self::assertIsArray($jsonData['items'][0]['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['magazine']);\n        self::assertIsArray($jsonData['items'][0]['user']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['user']);\n        self::assertNull($jsonData['items'][0]['image']);\n        self::assertEquals('en', $jsonData['items'][0]['lang']);\n        self::assertEmpty($jsonData['items'][0]['tags']);\n        self::assertNull($jsonData['items'][0]['mentions']);\n        self::assertSame(1, $jsonData['items'][0]['comments']);\n        self::assertSame(0, $jsonData['items'][0]['uv']);\n        self::assertSame(0, $jsonData['items'][0]['dv']);\n        self::assertSame(0, $jsonData['items'][0]['favourites']);\n        self::assertNull($jsonData['items'][0]['isFavourited']);\n        self::assertNull($jsonData['items'][0]['userVote']);\n        self::assertFalse($jsonData['items'][0]['isAdult']);\n        self::assertFalse($jsonData['items'][0]['isPinned']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['items'][0]['createdAt'], 'createdAt date format invalid');\n        self::assertNull($jsonData['items'][0]['editedAt']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['items'][0]['lastActive'], 'lastActive date format invalid');\n        self::assertEquals('a-post', $jsonData['items'][0]['slug']);\n        self::assertNull($jsonData['items'][0]['apId']);\n        self::assertNull($jsonData['items'][0]['bookmarks']);\n\n        self::assertIsArray($jsonData['items'][1]);\n        self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][1]);\n        self::assertEquals('another post', $jsonData['items'][1]['body']);\n        self::assertIsArray($jsonData['items'][1]['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][1]['magazine']);\n        self::assertSame($magazine->getId(), $jsonData['items'][1]['magazine']['magazineId']);\n        self::assertSame(0, $jsonData['items'][1]['comments']);\n    }\n\n    public function testApiCanGetPosts(): void\n    {\n        $post = $this->createPost('a post');\n        $this->createPostComment('up the ranking', $post);\n        $magazine = $this->getMagazineByNameNoRSAKey('somemag');\n        $this->createPost('another post', magazine: $magazine);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', '/api/posts', server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(2, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(2, $jsonData['pagination']['count']);\n\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertSame($post->getId(), $jsonData['items'][0]['postId']);\n        self::assertEquals('a post', $jsonData['items'][0]['body']);\n        self::assertIsArray($jsonData['items'][0]['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['magazine']);\n        self::assertIsArray($jsonData['items'][0]['user']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['user']);\n        self::assertNull($jsonData['items'][0]['image']);\n        self::assertEquals('en', $jsonData['items'][0]['lang']);\n        self::assertEmpty($jsonData['items'][0]['tags']);\n        self::assertNull($jsonData['items'][0]['mentions']);\n        self::assertSame(1, $jsonData['items'][0]['comments']);\n        self::assertSame(0, $jsonData['items'][0]['uv']);\n        self::assertSame(0, $jsonData['items'][0]['dv']);\n        self::assertSame(0, $jsonData['items'][0]['favourites']);\n        // No scope for seeing votes granted\n        self::assertNull($jsonData['items'][0]['isFavourited']);\n        self::assertNull($jsonData['items'][0]['userVote']);\n        self::assertFalse($jsonData['items'][0]['isAdult']);\n        self::assertFalse($jsonData['items'][0]['isPinned']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['items'][0]['createdAt'], 'createdAt date format invalid');\n        self::assertNull($jsonData['items'][0]['editedAt']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['items'][0]['lastActive'], 'lastActive date format invalid');\n        self::assertEquals('a-post', $jsonData['items'][0]['slug']);\n        self::assertNull($jsonData['items'][0]['apId']);\n        self::assertIsArray($jsonData['items'][0]['bookmarks']);\n        self::assertEmpty($jsonData['items'][0]['bookmarks']);\n\n        self::assertIsArray($jsonData['items'][1]);\n        self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][1]);\n        self::assertEquals('another post', $jsonData['items'][1]['body']);\n        self::assertIsArray($jsonData['items'][1]['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][1]['magazine']);\n        self::assertSame($magazine->getId(), $jsonData['items'][1]['magazine']['magazineId']);\n        self::assertSame(0, $jsonData['items'][1]['comments']);\n    }\n\n    public function testApiCanGetPostsWithLanguageAnonymous(): void\n    {\n        $post = $this->createPost('a post');\n        $this->createPostComment('up the ranking', $post);\n        $magazine = $this->getMagazineByNameNoRSAKey('somemag');\n        $second = $this->createPost('another post', magazine: $magazine, lang: 'de');\n        $this->createPost('a dutch post', magazine: $magazine, lang: 'nl');\n        // Check that pinned posts don't get pinned to the top of the instance, just the magazine\n        $postManager = $this->postManager;\n        $postManager->pin($second);\n\n        $this->client->request('GET', '/api/posts?lang[]=en&lang[]=de');\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(2, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(2, $jsonData['pagination']['count']);\n\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertSame($post->getId(), $jsonData['items'][0]['postId']);\n        self::assertEquals('a post', $jsonData['items'][0]['body']);\n        self::assertIsArray($jsonData['items'][0]['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['magazine']);\n        self::assertIsArray($jsonData['items'][0]['user']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['user']);\n        self::assertNull($jsonData['items'][0]['image']);\n        self::assertEquals('en', $jsonData['items'][0]['lang']);\n        self::assertEmpty($jsonData['items'][0]['tags']);\n        self::assertNull($jsonData['items'][0]['mentions']);\n        self::assertSame(1, $jsonData['items'][0]['comments']);\n        self::assertSame(0, $jsonData['items'][0]['uv']);\n        self::assertSame(0, $jsonData['items'][0]['dv']);\n        self::assertSame(0, $jsonData['items'][0]['favourites']);\n        self::assertNull($jsonData['items'][0]['isFavourited']);\n        self::assertNull($jsonData['items'][0]['userVote']);\n        self::assertFalse($jsonData['items'][0]['isAdult']);\n        self::assertFalse($jsonData['items'][0]['isPinned']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['items'][0]['createdAt'], 'createdAt date format invalid');\n        self::assertNull($jsonData['items'][0]['editedAt']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['items'][0]['lastActive'], 'lastActive date format invalid');\n        self::assertEquals('a-post', $jsonData['items'][0]['slug']);\n        self::assertNull($jsonData['items'][0]['apId']);\n        self::assertNull($jsonData['items'][0]['bookmarks']);\n\n        self::assertIsArray($jsonData['items'][1]);\n        self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][1]);\n        self::assertEquals('another post', $jsonData['items'][1]['body']);\n        self::assertIsArray($jsonData['items'][1]['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][1]['magazine']);\n        self::assertSame($magazine->getId(), $jsonData['items'][1]['magazine']['magazineId']);\n        self::assertEquals('de', $jsonData['items'][1]['lang']);\n        self::assertSame(0, $jsonData['items'][1]['comments']);\n    }\n\n    public function testApiCanGetPostsWithLanguage(): void\n    {\n        $post = $this->createPost('a post');\n        $this->createPostComment('up the ranking', $post);\n        $magazine = $this->getMagazineByNameNoRSAKey('somemag');\n        $this->createPost('another post', magazine: $magazine, lang: 'de');\n        $this->createPost('a dutch post', magazine: $magazine, lang: 'nl');\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', '/api/posts?lang[]=en&lang[]=de', server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(2, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(2, $jsonData['pagination']['count']);\n\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertSame($post->getId(), $jsonData['items'][0]['postId']);\n        self::assertEquals('a post', $jsonData['items'][0]['body']);\n        self::assertIsArray($jsonData['items'][0]['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['magazine']);\n        self::assertIsArray($jsonData['items'][0]['user']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['user']);\n        self::assertNull($jsonData['items'][0]['image']);\n        self::assertEquals('en', $jsonData['items'][0]['lang']);\n        self::assertEmpty($jsonData['items'][0]['tags']);\n        self::assertNull($jsonData['items'][0]['mentions']);\n        self::assertSame(1, $jsonData['items'][0]['comments']);\n        self::assertSame(0, $jsonData['items'][0]['uv']);\n        self::assertSame(0, $jsonData['items'][0]['dv']);\n        self::assertSame(0, $jsonData['items'][0]['favourites']);\n        // No scope for seeing votes granted\n        self::assertNull($jsonData['items'][0]['isFavourited']);\n        self::assertNull($jsonData['items'][0]['userVote']);\n        self::assertFalse($jsonData['items'][0]['isAdult']);\n        self::assertFalse($jsonData['items'][0]['isPinned']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['items'][0]['createdAt'], 'createdAt date format invalid');\n        self::assertNull($jsonData['items'][0]['editedAt']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['items'][0]['lastActive'], 'lastActive date format invalid');\n        self::assertEquals('a-post', $jsonData['items'][0]['slug']);\n        self::assertNull($jsonData['items'][0]['apId']);\n        self::assertIsArray($jsonData['items'][0]['bookmarks']);\n        self::assertEmpty($jsonData['items'][0]['bookmarks']);\n\n        self::assertIsArray($jsonData['items'][1]);\n        self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][1]);\n        self::assertEquals('another post', $jsonData['items'][1]['body']);\n        self::assertIsArray($jsonData['items'][1]['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][1]['magazine']);\n        self::assertSame($magazine->getId(), $jsonData['items'][1]['magazine']['magazineId']);\n        self::assertEquals('de', $jsonData['items'][1]['lang']);\n        self::assertSame(0, $jsonData['items'][1]['comments']);\n    }\n\n    public function testApiCannotGetPostsByPreferredLangAnonymous(): void\n    {\n        $post = $this->createPost('a post');\n        $this->createPostComment('up the ranking', $post);\n        $magazine = $this->getMagazineByNameNoRSAKey('somemag');\n        $second = $this->createPost('another post', magazine: $magazine);\n        // Check that pinned posts don't get pinned to the top of the instance, just the magazine\n        $postManager = $this->postManager;\n        $postManager->pin($second);\n\n        $this->client->request('GET', '/api/posts?usePreferredLangs=true');\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanGetPostsByPreferredLang(): void\n    {\n        $post = $this->createPost('a post');\n        $this->createPostComment('up the ranking', $post);\n        $magazine = $this->getMagazineByNameNoRSAKey('somemag');\n        $this->createPost('another post', magazine: $magazine);\n        $this->createPost('German post', lang: 'de');\n\n        $user = $this->getUserByUsername('user');\n        $user->preferredLanguages = ['en'];\n        $entityManager = $this->entityManager;\n        $entityManager->persist($user);\n        $entityManager->flush();\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', '/api/posts?usePreferredLangs=true', server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(2, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(2, $jsonData['pagination']['count']);\n\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertSame($post->getId(), $jsonData['items'][0]['postId']);\n        self::assertEquals('a post', $jsonData['items'][0]['body']);\n        self::assertIsArray($jsonData['items'][0]['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['magazine']);\n        self::assertIsArray($jsonData['items'][0]['user']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['user']);\n        self::assertNull($jsonData['items'][0]['image']);\n        self::assertEquals('en', $jsonData['items'][0]['lang']);\n        self::assertEmpty($jsonData['items'][0]['tags']);\n        self::assertNull($jsonData['items'][0]['mentions']);\n        self::assertSame(1, $jsonData['items'][0]['comments']);\n        self::assertSame(0, $jsonData['items'][0]['uv']);\n        self::assertSame(0, $jsonData['items'][0]['dv']);\n        self::assertSame(0, $jsonData['items'][0]['favourites']);\n        // No scope for seeing votes granted\n        self::assertNull($jsonData['items'][0]['isFavourited']);\n        self::assertNull($jsonData['items'][0]['userVote']);\n        self::assertFalse($jsonData['items'][0]['isAdult']);\n        self::assertFalse($jsonData['items'][0]['isPinned']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['items'][0]['createdAt'], 'createdAt date format invalid');\n        self::assertNull($jsonData['items'][0]['editedAt']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['items'][0]['lastActive'], 'lastActive date format invalid');\n        self::assertEquals('a-post', $jsonData['items'][0]['slug']);\n        self::assertNull($jsonData['items'][0]['apId']);\n        self::assertIsArray($jsonData['items'][0]['bookmarks']);\n        self::assertEmpty($jsonData['items'][0]['bookmarks']);\n\n        self::assertIsArray($jsonData['items'][1]);\n        self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][1]);\n        self::assertEquals('another post', $jsonData['items'][1]['body']);\n        self::assertIsArray($jsonData['items'][1]['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][1]['magazine']);\n        self::assertSame($magazine->getId(), $jsonData['items'][1]['magazine']['magazineId']);\n        self::assertEquals('en', $jsonData['items'][1]['lang']);\n        self::assertSame(0, $jsonData['items'][1]['comments']);\n    }\n\n    public function testApiCanGetPostsNewest(): void\n    {\n        $first = $this->createPost('first');\n        $second = $this->createPost('second');\n        $third = $this->createPost('third');\n\n        $first->createdAt = new \\DateTimeImmutable('-1 hour');\n        $second->createdAt = new \\DateTimeImmutable('-1 second');\n        $third->createdAt = new \\DateTimeImmutable();\n\n        $entityManager = $this->entityManager;\n        $entityManager->persist($first);\n        $entityManager->persist($second);\n        $entityManager->persist($third);\n        $entityManager->flush();\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', '/api/posts?sort=newest', server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(3, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(3, $jsonData['pagination']['count']);\n\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertSame($third->getId(), $jsonData['items'][0]['postId']);\n\n        self::assertIsArray($jsonData['items'][1]);\n        self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][1]);\n        self::assertSame($second->getId(), $jsonData['items'][1]['postId']);\n\n        self::assertIsArray($jsonData['items'][2]);\n        self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][2]);\n        self::assertSame($first->getId(), $jsonData['items'][2]['postId']);\n    }\n\n    public function testApiCanGetPostsOldest(): void\n    {\n        $first = $this->createPost('first');\n        $second = $this->createPost('second');\n        $third = $this->createPost('third');\n\n        $first->createdAt = new \\DateTimeImmutable('-1 hour');\n        $second->createdAt = new \\DateTimeImmutable('-1 second');\n        $third->createdAt = new \\DateTimeImmutable();\n\n        $entityManager = $this->entityManager;\n        $entityManager->persist($first);\n        $entityManager->persist($second);\n        $entityManager->persist($third);\n        $entityManager->flush();\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', '/api/posts?sort=oldest', server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(3, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(3, $jsonData['pagination']['count']);\n\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertSame($first->getId(), $jsonData['items'][0]['postId']);\n\n        self::assertIsArray($jsonData['items'][1]);\n        self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][1]);\n        self::assertSame($second->getId(), $jsonData['items'][1]['postId']);\n\n        self::assertIsArray($jsonData['items'][2]);\n        self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][2]);\n        self::assertSame($third->getId(), $jsonData['items'][2]['postId']);\n    }\n\n    public function testApiCanGetPostsCommented(): void\n    {\n        $first = $this->createPost('first');\n        $this->createPostComment('comment 1', $first);\n        $this->createPostComment('comment 2', $first);\n        $second = $this->createPost('second');\n        $this->createPostComment('comment 1', $second);\n        $third = $this->createPost('third');\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', '/api/posts?sort=commented', server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(3, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(3, $jsonData['pagination']['count']);\n\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertSame($first->getId(), $jsonData['items'][0]['postId']);\n        self::assertSame(2, $jsonData['items'][0]['comments']);\n\n        self::assertIsArray($jsonData['items'][1]);\n        self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][1]);\n        self::assertSame($second->getId(), $jsonData['items'][1]['postId']);\n        self::assertSame(1, $jsonData['items'][1]['comments']);\n\n        self::assertIsArray($jsonData['items'][2]);\n        self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][2]);\n        self::assertSame($third->getId(), $jsonData['items'][2]['postId']);\n        self::assertSame(0, $jsonData['items'][2]['comments']);\n    }\n\n    public function testApiCanGetPostsActive(): void\n    {\n        $first = $this->createPost('first');\n        $second = $this->createPost('second');\n        $third = $this->createPost('third');\n\n        $first->lastActive = new \\DateTime('-1 hour');\n        $second->lastActive = new \\DateTime('-1 second');\n        $third->lastActive = new \\DateTime();\n\n        $entityManager = $this->entityManager;\n        $entityManager->persist($first);\n        $entityManager->persist($second);\n        $entityManager->persist($third);\n        $entityManager->flush();\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', '/api/posts?sort=active', server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(3, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(3, $jsonData['pagination']['count']);\n\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertSame($third->getId(), $jsonData['items'][0]['postId']);\n\n        self::assertIsArray($jsonData['items'][1]);\n        self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][1]);\n        self::assertSame($second->getId(), $jsonData['items'][1]['postId']);\n\n        self::assertIsArray($jsonData['items'][2]);\n        self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][2]);\n        self::assertSame($first->getId(), $jsonData['items'][2]['postId']);\n    }\n\n    public function testApiCanGetPostsTop(): void\n    {\n        $first = $this->createPost('first');\n        $second = $this->createPost('second');\n        $third = $this->createPost('third');\n\n        $voteManager = $this->voteManager;\n        $voteManager->vote(1, $first, $this->getUserByUsername('voter1'), rateLimit: false);\n        $voteManager->vote(1, $first, $this->getUserByUsername('voter2'), rateLimit: false);\n        $voteManager->vote(1, $second, $this->getUserByUsername('voter1'), rateLimit: false);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', '/api/posts?sort=top', server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(3, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(3, $jsonData['pagination']['count']);\n\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertSame($first->getId(), $jsonData['items'][0]['postId']);\n        self::assertSame(2, $jsonData['items'][0]['uv']);\n\n        self::assertIsArray($jsonData['items'][1]);\n        self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][1]);\n        self::assertSame($second->getId(), $jsonData['items'][1]['postId']);\n        self::assertSame(1, $jsonData['items'][1]['uv']);\n\n        self::assertIsArray($jsonData['items'][2]);\n        self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][2]);\n        self::assertSame($third->getId(), $jsonData['items'][2]['postId']);\n        self::assertSame(0, $jsonData['items'][2]['uv']);\n    }\n\n    public function testApiCanGetPostsWithUserVoteStatus(): void\n    {\n        $post = $this->createPost('a post');\n        $this->createPostComment('up the ranking', $post);\n        $magazine = $this->getMagazineByNameNoRSAKey('somemag');\n        $this->createPost('another post', magazine: $magazine);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read vote');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', '/api/posts', server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(2, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(2, $jsonData['pagination']['count']);\n\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertSame($post->getId(), $jsonData['items'][0]['postId']);\n        self::assertEquals('a post', $jsonData['items'][0]['body']);\n        self::assertIsArray($jsonData['items'][0]['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['magazine']);\n        self::assertIsArray($jsonData['items'][0]['user']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['user']);\n        self::assertNull($jsonData['items'][0]['image']);\n        self::assertEquals('en', $jsonData['items'][0]['lang']);\n        self::assertEmpty($jsonData['items'][0]['tags']);\n        self::assertSame(1, $jsonData['items'][0]['comments']);\n        self::assertSame(0, $jsonData['items'][0]['uv']);\n        self::assertSame(0, $jsonData['items'][0]['dv']);\n        self::assertSame(0, $jsonData['items'][0]['favourites']);\n        self::assertFalse($jsonData['items'][0]['isFavourited']);\n        self::assertSame(0, $jsonData['items'][0]['userVote']);\n        self::assertFalse($jsonData['items'][0]['isAdult']);\n        self::assertFalse($jsonData['items'][0]['isPinned']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['items'][0]['createdAt'], 'createdAt date format invalid');\n        self::assertNull($jsonData['items'][0]['editedAt']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['items'][0]['lastActive'], 'lastActive date format invalid');\n        self::assertEquals('a-post', $jsonData['items'][0]['slug']);\n        self::assertNull($jsonData['items'][0]['apId']);\n        self::assertIsArray($jsonData['items'][0]['bookmarks']);\n        self::assertEmpty($jsonData['items'][0]['bookmarks']);\n\n        self::assertIsArray($jsonData['items'][1]);\n        self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][1]);\n        self::assertEquals('another post', $jsonData['items'][1]['body']);\n        self::assertIsArray($jsonData['items'][1]['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][1]['magazine']);\n        self::assertSame($magazine->getId(), $jsonData['items'][1]['magazine']['magazineId']);\n        self::assertSame(0, $jsonData['items'][1]['comments']);\n    }\n\n    public function testApiCanGetPostByIdAnonymous(): void\n    {\n        $post = $this->createPost('a post');\n\n        $this->client->request('GET', \"/api/post/{$post->getId()}\");\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData);\n        self::assertSame($post->getId(), $jsonData['postId']);\n        self::assertEquals('a post', $jsonData['body']);\n        self::assertIsArray($jsonData['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']);\n        self::assertIsArray($jsonData['user']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']);\n        self::assertNull($jsonData['image']);\n        self::assertEquals('en', $jsonData['lang']);\n        self::assertEmpty($jsonData['tags']);\n        self::assertNull($jsonData['mentions']);\n        self::assertSame(0, $jsonData['comments']);\n        self::assertSame(0, $jsonData['uv']);\n        self::assertSame(0, $jsonData['dv']);\n        self::assertSame(0, $jsonData['favourites']);\n        self::assertNull($jsonData['isFavourited']);\n        self::assertNull($jsonData['userVote']);\n        self::assertFalse($jsonData['isAdult']);\n        self::assertFalse($jsonData['isPinned']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid');\n        self::assertNull($jsonData['editedAt']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid');\n        self::assertEquals('a-post', $jsonData['slug']);\n        self::assertNull($jsonData['apId']);\n        self::assertNull($jsonData['bookmarks']);\n    }\n\n    public function testApiCanGetPostById(): void\n    {\n        $post = $this->createPost('a post');\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/post/{$post->getId()}\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData);\n        self::assertSame($post->getId(), $jsonData['postId']);\n        self::assertEquals('a post', $jsonData['body']);\n        self::assertIsArray($jsonData['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']);\n        self::assertIsArray($jsonData['user']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']);\n        self::assertNull($jsonData['image']);\n        self::assertEquals('en', $jsonData['lang']);\n        self::assertEmpty($jsonData['tags']);\n        self::assertSame(0, $jsonData['comments']);\n        self::assertSame(0, $jsonData['uv']);\n        self::assertSame(0, $jsonData['dv']);\n        self::assertSame(0, $jsonData['favourites']);\n        // No scope for seeing votes granted\n        self::assertNull($jsonData['isFavourited']);\n        self::assertNull($jsonData['userVote']);\n        self::assertFalse($jsonData['isAdult']);\n        self::assertFalse($jsonData['isPinned']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid');\n        self::assertNull($jsonData['editedAt']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid');\n        self::assertEquals('a-post', $jsonData['slug']);\n        self::assertNull($jsonData['apId']);\n        self::assertIsArray($jsonData['bookmarks']);\n        self::assertEmpty($jsonData['bookmarks']);\n    }\n\n    public function testApiCanGetPostByIdWithUserVoteStatus(): void\n    {\n        $post = $this->createPost('a post');\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read vote');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/post/{$post->getId()}\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData);\n        self::assertSame($post->getId(), $jsonData['postId']);\n        self::assertEquals('a post', $jsonData['body']);\n        self::assertIsArray($jsonData['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']);\n        self::assertIsArray($jsonData['user']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']);\n        self::assertNull($jsonData['image']);\n        self::assertEquals('en', $jsonData['lang']);\n        self::assertEmpty($jsonData['tags']);\n        self::assertSame(0, $jsonData['comments']);\n        self::assertSame(0, $jsonData['uv']);\n        self::assertSame(0, $jsonData['dv']);\n        self::assertSame(0, $jsonData['favourites']);\n        self::assertFalse($jsonData['isFavourited']);\n        self::assertSame(0, $jsonData['userVote']);\n        self::assertFalse($jsonData['isAdult']);\n        self::assertFalse($jsonData['isPinned']);\n        // This API creates a view when used\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid');\n        self::assertNull($jsonData['editedAt']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid');\n        self::assertEquals('a-post', $jsonData['slug']);\n        self::assertNull($jsonData['apId']);\n        self::assertIsArray($jsonData['bookmarks']);\n        self::assertEmpty($jsonData['bookmarks']);\n    }\n\n    public function testApiCanGetPostsLocal(): void\n    {\n        $first = $this->createPost('first');\n        $second = $this->createPost('second');\n\n        $second->apId = 'https://some.url';\n\n        $entityManager = $this->entityManager;\n        $entityManager->persist($first);\n        $entityManager->persist($second);\n        $entityManager->flush();\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', '/api/posts?federation=local', server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(1, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(1, $jsonData['pagination']['count']);\n\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertSame($first->getId(), $jsonData['items'][0]['postId']);\n    }\n\n    public function testApiCanGetPostsFederated(): void\n    {\n        $first = $this->createPost('first');\n        $second = $this->createPost('second');\n\n        $second->apId = 'https://some.url';\n\n        $entityManager = $this->entityManager;\n        $entityManager->persist($first);\n        $entityManager->persist($second);\n        $entityManager->flush();\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', '/api/posts?federation=federated', server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(1, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(1, $jsonData['pagination']['count']);\n\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertSame($second->getId(), $jsonData['items'][0]['postId']);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/Post/PostUpdateApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\Post;\n\nuse App\\Tests\\WebTestCase;\nuse PHPUnit\\Framework\\Attributes\\Group;\n\nclass PostUpdateApiTest extends WebTestCase\n{\n    public function testApiCannotUpdatePostAnonymous(): void\n    {\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $post = $this->createPost('test article', magazine: $magazine);\n\n        $updateRequest = [\n            'body' => 'Updated body',\n            'lang' => 'nl',\n            'isAdult' => true,\n        ];\n\n        $this->client->jsonRequest('PUT', \"/api/post/{$post->getId()}\", $updateRequest);\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotUpdatePostWithoutScope(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $post = $this->createPost('test article', user: $user, magazine: $magazine);\n\n        $updateRequest = [\n            'body' => 'Updated body',\n            'lang' => 'nl',\n            'isAdult' => true,\n        ];\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/post/{$post->getId()}\", $updateRequest, server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCannotUpdateOtherUsersPost(): void\n    {\n        $otherUser = $this->getUserByUsername('somebody');\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $post = $this->createPost('test article', user: $otherUser, magazine: $magazine);\n\n        $updateRequest = [\n            'body' => 'Updated body',\n            'lang' => 'nl',\n            'isAdult' => true,\n        ];\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read post:edit');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/post/{$post->getId()}\", $updateRequest, server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanUpdatePost(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $post = $this->createPost('test article', user: $user, magazine: $magazine);\n\n        $updateRequest = [\n            'body' => 'Updated #body @user',\n            'lang' => 'nl',\n            'isAdult' => true,\n        ];\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read post:edit');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/post/{$post->getId()}\", $updateRequest, server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData);\n        self::assertSame($post->getId(), $jsonData['postId']);\n        self::assertIsArray($jsonData['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']);\n        self::assertSame($magazine->getId(), $jsonData['magazine']['magazineId']);\n        self::assertIsArray($jsonData['user']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']);\n        self::assertSame($user->getId(), $jsonData['user']['userId']);\n        self::assertEquals($updateRequest['body'], $jsonData['body']);\n        self::assertNull($jsonData['image']);\n        self::assertEquals($updateRequest['lang'], $jsonData['lang']);\n        self::assertIsArray($jsonData['tags']);\n        self::assertSame(['body'], $jsonData['tags']);\n        self::assertIsArray($jsonData['mentions']);\n        self::assertSame(['@user'], $jsonData['mentions']);\n        self::assertSame(0, $jsonData['comments']);\n        self::assertSame(0, $jsonData['uv']);\n        self::assertSame(0, $jsonData['dv']);\n        self::assertSame(0, $jsonData['favourites']);\n        // No scope for seeing votes granted\n        self::assertNull($jsonData['isFavourited']);\n        self::assertNull($jsonData['userVote']);\n        self::assertTrue($jsonData['isAdult']);\n        self::assertFalse($jsonData['isPinned']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid');\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['editedAt'], 'editedAt date format invalid');\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid');\n        self::assertEquals('Updated-body-at-user', $jsonData['slug']);\n        self::assertNull($jsonData['apId']);\n    }\n\n    public function testApiCannotUpdateImagePostAnonymous(): void\n    {\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n\n        $imageDto = $this->getKibbyImageDto();\n        $post = $this->createPost('test image', imageDto: $imageDto, magazine: $magazine);\n\n        $updateRequest = [\n            'body' => 'Updated body',\n            'lang' => 'nl',\n            'isAdult' => true,\n        ];\n\n        $this->client->jsonRequest('PUT', \"/api/post/{$post->getId()}\", $updateRequest);\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotUpdateImagePostWithoutScope(): void\n    {\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $user = $this->getUserByUsername('user');\n\n        $imageDto = $this->getKibbyImageDto();\n        $post = $this->createPost('test image', imageDto: $imageDto, user: $user, magazine: $magazine);\n\n        $updateRequest = [\n            'body' => 'Updated body',\n            'lang' => 'nl',\n            'isAdult' => true,\n        ];\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/post/{$post->getId()}\", $updateRequest, server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCannotUpdateOtherUsersImagePost(): void\n    {\n        $otherUser = $this->getUserByUsername('somebody');\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n\n        $imageDto = $this->getKibbyImageDto();\n        $post = $this->createPost('test image', imageDto: $imageDto, user: $otherUser, magazine: $magazine);\n\n        $updateRequest = [\n            'body' => 'Updated body',\n            'lang' => 'nl',\n            'isAdult' => true,\n        ];\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read post:edit');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/post/{$post->getId()}\", $updateRequest, server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    #[Group(name: 'NonThreadSafe')]\n    public function testApiCanUpdateImagePost(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n\n        $imageDto = $this->getKibbyImageDto();\n        $post = $this->createPost('test image', imageDto: $imageDto, user: $user, magazine: $magazine);\n\n        $updateRequest = [\n            'body' => 'Updated #body @user',\n            'lang' => 'nl',\n            'isAdult' => true,\n        ];\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read post:edit');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/post/{$post->getId()}\", $updateRequest, server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData);\n        self::assertSame($post->getId(), $jsonData['postId']);\n        self::assertIsArray($jsonData['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']);\n        self::assertSame($magazine->getId(), $jsonData['magazine']['magazineId']);\n        self::assertIsArray($jsonData['user']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']);\n        self::assertSame($user->getId(), $jsonData['user']['userId']);\n        self::assertEquals($updateRequest['body'], $jsonData['body']);\n        self::assertIsArray($jsonData['image']);\n        self::assertArrayKeysMatch(self::IMAGE_KEYS, $jsonData['image']);\n        self::assertStringContainsString($imageDto->filePath, $jsonData['image']['filePath']);\n        self::assertEquals($updateRequest['lang'], $jsonData['lang']);\n        self::assertIsArray($jsonData['tags']);\n        self::assertSame(['body'], $jsonData['tags']);\n        self::assertIsArray($jsonData['mentions']);\n        self::assertSame(['@user'], $jsonData['mentions']);\n        self::assertSame(0, $jsonData['comments']);\n        self::assertSame(0, $jsonData['uv']);\n        self::assertSame(0, $jsonData['dv']);\n        self::assertSame(0, $jsonData['favourites']);\n        // No scope for seeing votes granted\n        self::assertNull($jsonData['isFavourited']);\n        self::assertNull($jsonData['userVote']);\n        self::assertTrue($jsonData['isAdult']);\n        self::assertFalse($jsonData['isPinned']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid');\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['editedAt'], 'editedAt date format invalid');\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid');\n        self::assertEquals('Updated-body-at-user', $jsonData['slug']);\n        self::assertNull($jsonData['apId']);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/Post/PostVoteApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\Post;\n\nuse App\\Tests\\WebTestCase;\n\nclass PostVoteApiTest extends WebTestCase\n{\n    public function testApiCannotUpvotePostAnonymous(): void\n    {\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $post = $this->createPost('test post', magazine: $magazine);\n\n        $this->client->jsonRequest('PUT', \"/api/post/{$post->getId()}/vote/1\");\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotUpvotePostWithoutScope(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $post = $this->createPost('test post', user: $user, magazine: $magazine);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/post/{$post->getId()}/vote/1\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanUpvotePost(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $post = $this->createPost('test post', user: $user, magazine: $magazine);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read post:vote');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/post/{$post->getId()}/vote/1\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData);\n        self::assertSame($post->getId(), $jsonData['postId']);\n        self::assertIsArray($jsonData['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']);\n        self::assertSame($magazine->getId(), $jsonData['magazine']['magazineId']);\n        self::assertIsArray($jsonData['user']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']);\n        self::assertSame($user->getId(), $jsonData['user']['userId']);\n        self::assertEquals($post->body, $jsonData['body']);\n        self::assertNull($jsonData['image']);\n        self::assertEquals($post->lang, $jsonData['lang']);\n        self::assertEmpty($jsonData['tags']);\n        self::assertNull($jsonData['mentions']);\n        self::assertSame(0, $jsonData['comments']);\n        self::assertSame(1, $jsonData['uv']);\n        self::assertSame(0, $jsonData['dv']);\n        self::assertSame(0, $jsonData['favourites']);\n        // No scope for seeing votes granted\n        self::assertFalse($jsonData['isFavourited']);\n        self::assertSame(1, $jsonData['userVote']);\n        self::assertFalse($jsonData['isAdult']);\n        self::assertFalse($jsonData['isPinned']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid');\n        self::assertNull($jsonData['editedAt']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid');\n        self::assertEquals('test-post', $jsonData['slug']);\n        self::assertNull($jsonData['apId']);\n    }\n\n    public function testApiCannotDownvotePostAnonymous(): void\n    {\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $post = $this->createPost('test post', magazine: $magazine);\n\n        $this->client->jsonRequest('PUT', \"/api/post/{$post->getId()}/vote/-1\");\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotDownvotePostWithoutScope(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $post = $this->createPost('test post', user: $user, magazine: $magazine);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/post/{$post->getId()}/vote/-1\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCannotDownvotePost(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $post = $this->createPost('test post', user: $user, magazine: $magazine);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read post:vote');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/post/{$post->getId()}/vote/-1\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(400);\n    }\n\n    public function testApiCannotClearVotePostAnonymous(): void\n    {\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $post = $this->createPost('test post', magazine: $magazine);\n\n        $this->client->jsonRequest('PUT', \"/api/post/{$post->getId()}/vote/0\");\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testApiCannotClearVotePostWithoutScope(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $post = $this->createPost('test post', user: $user, magazine: $magazine);\n\n        $voteManager = $this->voteManager;\n        $voteManager->vote(1, $post, $user, rateLimit: false);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/post/{$post->getId()}/vote/0\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanClearVotePost(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $post = $this->createPost('test post', user: $user, magazine: $magazine);\n\n        $voteManager = $this->voteManager;\n        $voteManager->vote(1, $post, $user, rateLimit: false);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($user);\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read post:vote');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('PUT', \"/api/post/{$post->getId()}/vote/0\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData);\n        self::assertSame($post->getId(), $jsonData['postId']);\n        self::assertIsArray($jsonData['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['magazine']);\n        self::assertSame($magazine->getId(), $jsonData['magazine']['magazineId']);\n        self::assertIsArray($jsonData['user']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['user']);\n        self::assertSame($user->getId(), $jsonData['user']['userId']);\n        self::assertEquals($post->body, $jsonData['body']);\n        self::assertNull($jsonData['image']);\n        self::assertEquals($post->lang, $jsonData['lang']);\n        self::assertEmpty($jsonData['tags']);\n        self::assertNull($jsonData['mentions']);\n        self::assertSame(0, $jsonData['comments']);\n        self::assertSame(0, $jsonData['uv']);\n        self::assertSame(0, $jsonData['dv']);\n        self::assertSame(0, $jsonData['favourites']);\n        // No scope for seeing votes granted\n        self::assertFalse($jsonData['isFavourited']);\n        self::assertSame(0, $jsonData['userVote']);\n        self::assertFalse($jsonData['isAdult']);\n        self::assertFalse($jsonData['isPinned']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['createdAt'], 'createdAt date format invalid');\n        self::assertNull($jsonData['editedAt']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['lastActive'], 'lastActive date format invalid');\n        self::assertEquals('test-post', $jsonData['slug']);\n        self::assertNull($jsonData['apId']);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/Post/PostsActivityApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\Post;\n\nuse App\\DTO\\UserSmallResponseDto;\nuse App\\Tests\\Functional\\Controller\\Api\\Entry\\EntriesActivityApiTest;\nuse App\\Tests\\WebTestCase;\n\nclass PostsActivityApiTest extends WebTestCase\n{\n    public function testEmpty()\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $post = $this->createPost('test post', user: $user, magazine: $magazine);\n\n        $this->client->jsonRequest('GET', \"/api/post/{$post->getId()}/activity\");\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(EntriesActivityApiTest::ACTIVITIES_RESPONSE_DTO_KEYS, $jsonData);\n        self::assertSame([], $jsonData['boosts']);\n        self::assertSame([], $jsonData['upvotes']);\n        self::assertSame(null, $jsonData['downvotes']);\n    }\n\n    public function testUpvotes()\n    {\n        $author = $this->getUserByUsername('userA');\n        $user1 = $this->getUserByUsername('user1');\n        $user2 = $this->getUserByUsername('user2');\n        $this->getUserByUsername('user3');\n\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $post = $this->createPost('test post', user: $author, magazine: $magazine);\n\n        $this->favouriteManager->toggle($user1, $post);\n        $this->favouriteManager->toggle($user2, $post);\n\n        $this->client->jsonRequest('GET', \"/api/post/{$post->getId()}/activity\");\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(EntriesActivityApiTest::ACTIVITIES_RESPONSE_DTO_KEYS, $jsonData);\n        self::assertSame([], $jsonData['boosts']);\n        self::assertSame(null, $jsonData['downvotes']);\n\n        self::assertCount(2, $jsonData['upvotes']);\n        self::assertTrue(array_all($jsonData['upvotes'], function ($u) use ($user1, $user2) {\n            /* @var UserSmallResponseDto $u */\n            return $u['userId'] === $user1->getId() || $u['userId'] === $user2->getId();\n        }), serialize($jsonData['upvotes']));\n    }\n\n    public function testBoosts()\n    {\n        $author = $this->getUserByUsername('userA');\n        $user1 = $this->getUserByUsername('user1');\n        $user2 = $this->getUserByUsername('user2');\n        $this->getUserByUsername('user3');\n\n        $magazine = $this->getMagazineByNameNoRSAKey('acme');\n        $post = $this->createPost('test post', user: $author, magazine: $magazine);\n\n        $this->voteManager->upvote($post, $user1);\n        $this->voteManager->upvote($post, $user2);\n\n        $this->client->jsonRequest('GET', \"/api/post/{$post->getId()}/activity\");\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(EntriesActivityApiTest::ACTIVITIES_RESPONSE_DTO_KEYS, $jsonData);\n        self::assertSame([], $jsonData['upvotes']);\n        self::assertSame(null, $jsonData['downvotes']);\n\n        self::assertCount(2, $jsonData['boosts']);\n        self::assertTrue(array_all($jsonData['boosts'], function ($u) use ($user1, $user2) {\n            /* @var UserSmallResponseDto $u */\n            return $u['userId'] === $user1->getId() || $u['userId'] === $user2->getId();\n        }), serialize($jsonData['boosts']));\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/Post/UserPostRetrieveApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\Post;\n\nuse App\\Tests\\WebTestCase;\n\nclass UserPostRetrieveApiTest extends WebTestCase\n{\n    public function testApiCanGetUserEntriesAnonymous(): void\n    {\n        $post = $this->createPost('a post');\n        $this->createPostComment('up the ranking', $post);\n        $magazine = $this->getMagazineByNameNoRSAKey('somemag');\n        $otherUser = $this->getUserByUsername('somebody');\n        $this->createPost('another post', magazine: $magazine, user: $otherUser);\n\n        $this->client->request('GET', \"/api/users/{$otherUser->getId()}/posts\");\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(1, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(1, $jsonData['pagination']['count']);\n\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertEquals('another post', $jsonData['items'][0]['body']);\n        self::assertIsArray($jsonData['items'][0]['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['magazine']);\n        self::assertSame($magazine->getId(), $jsonData['items'][0]['magazine']['magazineId']);\n        self::assertIsArray($jsonData['items'][0]['user']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['user']);\n        self::assertSame($otherUser->getId(), $jsonData['items'][0]['user']['userId']);\n        self::assertSame(0, $jsonData['items'][0]['comments']);\n    }\n\n    public function testApiCanGetUserEntries(): void\n    {\n        $post = $this->createPost('a post');\n        $this->createPostComment('up the ranking', $post);\n        $magazine = $this->getMagazineByNameNoRSAKey('somemag');\n        $otherUser = $this->getUserByUsername('somebody');\n        $this->createPost('another post', magazine: $magazine, user: $otherUser);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/users/{$otherUser->getId()}/posts\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(1, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(1, $jsonData['pagination']['count']);\n\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertEquals('another post', $jsonData['items'][0]['body']);\n        self::assertIsArray($jsonData['items'][0]['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['magazine']);\n        self::assertSame($magazine->getId(), $jsonData['items'][0]['magazine']['magazineId']);\n        self::assertIsArray($jsonData['items'][0]['user']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['user']);\n        self::assertSame($otherUser->getId(), $jsonData['items'][0]['user']['userId']);\n        self::assertSame(0, $jsonData['items'][0]['comments']);\n    }\n\n    public function testApiCanGetUserEntriesNewest(): void\n    {\n        $first = $this->createPost('first');\n        $second = $this->createPost('second');\n        $third = $this->createPost('third');\n        $otherUser = $first->user;\n\n        $first->createdAt = new \\DateTimeImmutable('-1 hour');\n        $second->createdAt = new \\DateTimeImmutable('-1 second');\n        $third->createdAt = new \\DateTimeImmutable();\n\n        $entityManager = $this->entityManager;\n        $entityManager->persist($first);\n        $entityManager->persist($second);\n        $entityManager->persist($third);\n        $entityManager->flush();\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/users/{$otherUser->getId()}/posts?sort=newest\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(3, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(3, $jsonData['pagination']['count']);\n\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertSame($third->getId(), $jsonData['items'][0]['postId']);\n\n        self::assertIsArray($jsonData['items'][1]);\n        self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][1]);\n        self::assertSame($second->getId(), $jsonData['items'][1]['postId']);\n\n        self::assertIsArray($jsonData['items'][2]);\n        self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][2]);\n        self::assertSame($first->getId(), $jsonData['items'][2]['postId']);\n    }\n\n    public function testApiCanGetUserEntriesOldest(): void\n    {\n        $first = $this->createPost('first');\n        $second = $this->createPost('second');\n        $third = $this->createPost('third');\n        $otherUser = $first->user;\n\n        $first->createdAt = new \\DateTimeImmutable('-1 hour');\n        $second->createdAt = new \\DateTimeImmutable('-1 second');\n        $third->createdAt = new \\DateTimeImmutable();\n\n        $entityManager = $this->entityManager;\n        $entityManager->persist($first);\n        $entityManager->persist($second);\n        $entityManager->persist($third);\n        $entityManager->flush();\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/users/{$otherUser->getId()}/posts?sort=oldest\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(3, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(3, $jsonData['pagination']['count']);\n\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertSame($first->getId(), $jsonData['items'][0]['postId']);\n\n        self::assertIsArray($jsonData['items'][1]);\n        self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][1]);\n        self::assertSame($second->getId(), $jsonData['items'][1]['postId']);\n\n        self::assertIsArray($jsonData['items'][2]);\n        self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][2]);\n        self::assertSame($third->getId(), $jsonData['items'][2]['postId']);\n    }\n\n    public function testApiCanGetUserEntriesCommented(): void\n    {\n        $first = $this->createPost('first');\n        $this->createPostComment('comment 1', $first);\n        $this->createPostComment('comment 2', $first);\n        $second = $this->createPost('second');\n        $this->createPostComment('comment 1', $second);\n        $third = $this->createPost('third');\n        $otherUser = $first->user;\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/users/{$otherUser->getId()}/posts?sort=commented\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(3, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(3, $jsonData['pagination']['count']);\n\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertSame($first->getId(), $jsonData['items'][0]['postId']);\n        self::assertSame(2, $jsonData['items'][0]['comments']);\n\n        self::assertIsArray($jsonData['items'][1]);\n        self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][1]);\n        self::assertSame($second->getId(), $jsonData['items'][1]['postId']);\n        self::assertSame(1, $jsonData['items'][1]['comments']);\n\n        self::assertIsArray($jsonData['items'][2]);\n        self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][2]);\n        self::assertSame($third->getId(), $jsonData['items'][2]['postId']);\n        self::assertSame(0, $jsonData['items'][2]['comments']);\n    }\n\n    public function testApiCanGetUserEntriesActive(): void\n    {\n        $first = $this->createPost('first');\n        $second = $this->createPost('second');\n        $third = $this->createPost('third');\n        $otherUser = $first->user;\n\n        $first->lastActive = new \\DateTime('-1 hour');\n        $second->lastActive = new \\DateTime('-1 second');\n        $third->lastActive = new \\DateTime();\n\n        $entityManager = $this->entityManager;\n        $entityManager->persist($first);\n        $entityManager->persist($second);\n        $entityManager->persist($third);\n        $entityManager->flush();\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/users/{$otherUser->getId()}/posts?sort=active\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(3, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(3, $jsonData['pagination']['count']);\n\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertSame($third->getId(), $jsonData['items'][0]['postId']);\n\n        self::assertIsArray($jsonData['items'][1]);\n        self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][1]);\n        self::assertSame($second->getId(), $jsonData['items'][1]['postId']);\n\n        self::assertIsArray($jsonData['items'][2]);\n        self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][2]);\n        self::assertSame($first->getId(), $jsonData['items'][2]['postId']);\n    }\n\n    public function testApiCanGetUserEntriesTop(): void\n    {\n        $first = $this->createPost('first');\n        $second = $this->createPost('second');\n        $third = $this->createPost('third');\n        $otherUser = $first->user;\n\n        $voteManager = $this->voteManager;\n        $voteManager->vote(1, $first, $this->getUserByUsername('voter1'), rateLimit: false);\n        $voteManager->vote(1, $first, $this->getUserByUsername('voter2'), rateLimit: false);\n        $voteManager->vote(1, $second, $this->getUserByUsername('voter1'), rateLimit: false);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/users/{$otherUser->getId()}/posts?sort=top\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(3, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(3, $jsonData['pagination']['count']);\n\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertSame($first->getId(), $jsonData['items'][0]['postId']);\n        self::assertSame(2, $jsonData['items'][0]['uv']);\n\n        self::assertIsArray($jsonData['items'][1]);\n        self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][1]);\n        self::assertSame($second->getId(), $jsonData['items'][1]['postId']);\n        self::assertSame(1, $jsonData['items'][1]['uv']);\n\n        self::assertIsArray($jsonData['items'][2]);\n        self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][2]);\n        self::assertSame($third->getId(), $jsonData['items'][2]['postId']);\n        self::assertSame(0, $jsonData['items'][2]['uv']);\n    }\n\n    public function testApiCanGetUserEntriesWithUserVoteStatus(): void\n    {\n        $this->createPost('a post');\n        $otherUser = $this->getUserByUsername('somebody');\n        $magazine = $this->getMagazineByNameNoRSAKey('somemag');\n        $post = $this->createPost('another post', magazine: $magazine, user: $otherUser);\n\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read vote');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->request('GET', \"/api/users/{$otherUser->getId()}/posts\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(1, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(1, $jsonData['pagination']['count']);\n\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertSame($post->getId(), $jsonData['items'][0]['postId']);\n        self::assertEquals('another post', $jsonData['items'][0]['body']);\n        self::assertIsArray($jsonData['items'][0]['magazine']);\n        self::assertArrayKeysMatch(self::MAGAZINE_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['magazine']);\n        self::assertSame($magazine->getId(), $jsonData['items'][0]['magazine']['magazineId']);\n        self::assertIsArray($jsonData['items'][0]['user']);\n        self::assertArrayKeysMatch(self::USER_SMALL_RESPONSE_KEYS, $jsonData['items'][0]['user']);\n        self::assertSame($otherUser->getId(), $jsonData['items'][0]['user']['userId']);\n        self::assertNull($jsonData['items'][0]['image']);\n        self::assertEquals('en', $jsonData['items'][0]['lang']);\n        self::assertEmpty($jsonData['items'][0]['tags']);\n        self::assertNull($jsonData['items'][0]['mentions']);\n        self::assertSame(0, $jsonData['items'][0]['comments']);\n        self::assertSame(0, $jsonData['items'][0]['uv']);\n        self::assertSame(0, $jsonData['items'][0]['dv']);\n        self::assertSame(0, $jsonData['items'][0]['favourites']);\n        self::assertFalse($jsonData['items'][0]['isFavourited']);\n        self::assertSame(0, $jsonData['items'][0]['userVote']);\n        self::assertFalse($jsonData['items'][0]['isAdult']);\n        self::assertFalse($jsonData['items'][0]['isPinned']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['items'][0]['createdAt'], 'createdAt date format invalid');\n        self::assertNull($jsonData['items'][0]['editedAt']);\n        self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $jsonData['items'][0]['lastActive'], 'lastActive date format invalid');\n        self::assertEquals('another-post', $jsonData['items'][0]['slug']);\n        self::assertNull($jsonData['items'][0]['apId']);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/Search/SearchApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\Search;\n\nuse App\\Entity\\Entry;\nuse App\\Entity\\Magazine;\nuse App\\Entity\\User;\nuse App\\Tests\\Functional\\ActivityPub\\ActivityPubFunctionalTestCase;\nuse PHPUnit\\Framework\\Attributes\\Group;\n\n#[Group(name: 'NonThreadSafe')]\nclass SearchApiTest extends ActivityPubFunctionalTestCase\n{\n    public const SEARCH_PAGINATED_KEYS = ['items', 'pagination', 'apResults'];\n    public const SEARCH_ITEM_KEYS = ['entry', 'entryComment', 'post', 'postComment', 'magazine', 'user'];\n\n    private const string TEST_USER_NAME = 'someremoteuser';\n    private const string TEST_USER_HANDLE = self::TEST_USER_NAME.'@remote.mbin';\n    private const string TEST_USER_URL = 'https://remote.mbin/u/'.self::TEST_USER_NAME;\n    private const string TEST_MAGAZINE_NAME = 'someremotemagazine';\n    private const string TEST_MAGAZINE_HANDLE = self::TEST_MAGAZINE_NAME.'@remote.mbin';\n    private const string TEST_MAGAZINE_URL = 'https://remote.mbin/m/'.self::TEST_MAGAZINE_NAME;\n\n    private string $testEntryUrl;\n\n    private User $someUser;\n    private Magazine $someMagazine;\n\n    public function setUp(): void\n    {\n        parent::setUp();\n\n        $this->someUser = $this->getUserByUsername('JohnDoe2', email: 'jd@test.tld');\n        $this->someMagazine = $this->getMagazineByName('acme2', $this->someUser);\n    }\n\n    public function setUpRemoteEntities(): void\n    {\n        $this->createRemoteEntryInRemoteMagazine($this->remoteMagazine, $this->remoteUser, function (Entry $entry) {\n            $this->testEntryUrl = 'https://remote.mbin/m/someremotemagazine/t/'.$entry->getId();\n        });\n    }\n\n    protected function setUpRemoteActors(): void\n    {\n        parent::setUpRemoteActors();\n\n        $this->remoteUser = $this->getUserByUsername(self::TEST_USER_NAME, addImage: false);\n        $this->registerActor($this->remoteUser, $this->remoteDomain, true);\n\n        $this->remoteMagazine = $this->getMagazineByName(self::TEST_MAGAZINE_NAME);\n        $this->registerActor($this->remoteMagazine, $this->remoteDomain, true);\n    }\n\n    public function testApiCannotSearchWithNoQuery(): void\n    {\n        $this->client->request('GET', '/api/search/v2');\n\n        self::assertResponseStatusCodeSame(400);\n    }\n\n    public function testApiCanFindEntryByTitleAnonymous(): void\n    {\n        $entry = $this->getEntryByTitle('A test title to search for', magazine: $this->someMagazine, user: $this->someUser);\n        $this->getEntryByTitle('Cannot find this', magazine: $this->someMagazine, user: $this->someUser);\n\n        $this->client->request('GET', '/api/search/v2?q=title');\n\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::validateResponseOuterData($jsonData, 1, 0);\n        self::validateResponseItemData($jsonData['items'][0], 'entry', $entry->getId());\n    }\n\n    public function testApiCanFindContentByBodyAnonymous(): void\n    {\n        $entry = $this->getEntryByTitle('A test title to search for', body: 'This is the body we\\'re finding', magazine: $this->someMagazine, user: $this->someUser);\n        $this->getEntryByTitle('Cannot find this', body: 'No keywords here!', magazine: $this->someMagazine, user: $this->someUser);\n        $post = $this->createPost('Lets get a post with its body in there too!', magazine: $this->someMagazine, user: $this->someUser);\n        $this->createPost('But not this one.', magazine: $this->someMagazine, user: $this->someUser);\n\n        $this->client->request('GET', '/api/search/v2?q=body');\n\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::validateResponseOuterData($jsonData, 2, 0);\n\n        foreach ($jsonData['items'] as $item) {\n            if (null !== $item['entry']) {\n                $type = 'entry';\n                $id = $entry->getId();\n            } else {\n                $type = 'post';\n                $id = $post->getId();\n            }\n\n            self::validateResponseItemData($item, $type, $id);\n        }\n    }\n\n    public function testApiCanFindCommentsByBodyAnonymous(): void\n    {\n        $entry = $this->getEntryByTitle('Cannot find this', body: 'No keywords here!', magazine: $this->someMagazine, user: $this->someUser);\n        $post = $this->createPost('But not this one.', magazine: $this->someMagazine, user: $this->someUser);\n        $entryComment = $this->createEntryComment('Some comment on a thread', $entry, user: $this->someUser);\n        $postComment = $this->createPostComment('Some comment on a post', $post, user: $this->someUser);\n\n        $this->client->request('GET', '/api/search/v2?q=comment');\n\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::validateResponseOuterData($jsonData, 2, 0);\n\n        foreach ($jsonData['items'] as $item) {\n            if (null !== $item['entryComment']) {\n                $type = 'entryComment';\n                $id = $entryComment->getId();\n            } else {\n                $type = 'postComment';\n                $id = $postComment->getId();\n            }\n\n            self::validateResponseItemData($item, $type, $id);\n        }\n    }\n\n    public function testApiCannotFindRemoteUserAnonymousWhenOptionSet(): void\n    {\n        $settingsManager = $this->settingsManager;\n        $value = $settingsManager->get('KBIN_FEDERATED_SEARCH_ONLY_LOGGEDIN');\n        $settingsManager->set('KBIN_FEDERATED_SEARCH_ONLY_LOGGEDIN', true);\n\n        $this->client->request('GET', '/api/search/v2?q='.self::TEST_USER_HANDLE);\n\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::validateResponseOuterData($jsonData, 0, 0);\n\n        // Seems like settings can persist in the test environment? Might only be for bare metal setups\n        $settingsManager->set('KBIN_FEDERATED_SEARCH_ONLY_LOGGEDIN', $value);\n    }\n\n    public function testApiCannotFindRemoteMagazineAnonymousWhenOptionSet(): void\n    {\n        $settingsManager = $this->settingsManager;\n        $value = $settingsManager->get('KBIN_FEDERATED_SEARCH_ONLY_LOGGEDIN');\n        $settingsManager->set('KBIN_FEDERATED_SEARCH_ONLY_LOGGEDIN', true);\n\n        $this->client->request('GET', '/api/search/v2?q='.self::TEST_MAGAZINE_HANDLE);\n\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::validateResponseOuterData($jsonData, 0, 0);\n\n        // Seems like settings can persist in the test environment? Might only be for bare metal setups\n        $settingsManager->set('KBIN_FEDERATED_SEARCH_ONLY_LOGGEDIN', $value);\n    }\n\n    public function testApiCanFindRemoteUserByHandleAnonymous(): void\n    {\n        $settingsManager = $this->settingsManager;\n        $value = $settingsManager->get('KBIN_FEDERATED_SEARCH_ONLY_LOGGEDIN');\n        $settingsManager->set('KBIN_FEDERATED_SEARCH_ONLY_LOGGEDIN', false);\n        $this->getUserByUsername('test');\n\n        $this->client->request('GET', '/api/search/v2?q=@'.self::TEST_USER_HANDLE);\n\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::validateResponseOuterData($jsonData, 0, 1);\n        self::validateResponseItemData($jsonData['apResults'][0], 'user', null, self::TEST_USER_HANDLE, self::TEST_USER_URL);\n\n        $this->client->request('GET', '/api/search/v2?q='.self::TEST_USER_HANDLE);\n\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::validateResponseOuterData($jsonData, 1, 1);\n        self::assertSame(self::TEST_USER_URL, $jsonData['items'][0]['user']['apProfileId']);\n        self::validateResponseItemData($jsonData['apResults'][0], 'user', null, self::TEST_USER_HANDLE, self::TEST_USER_URL);\n\n        // Seems like settings can persist in the test environment? Might only be for bare metal setups.\n        $settingsManager->set('KBIN_FEDERATED_SEARCH_ONLY_LOGGEDIN', $value);\n    }\n\n    public function testApiCanFindRemoteMagazineByHandleAnonymous(): void\n    {\n        // Admin user must exist to retrieve a remote magazine since remote mods aren't federated (yet)\n        $this->getUserByUsername('admin', isAdmin: true);\n\n        $settingsManager = $this->settingsManager;\n        $value = $settingsManager->get('KBIN_FEDERATED_SEARCH_ONLY_LOGGEDIN');\n        $settingsManager->set('KBIN_FEDERATED_SEARCH_ONLY_LOGGEDIN', false);\n        $this->getMagazineByName('testMag', user: $this->someUser);\n\n        $this->client->request('GET', '/api/search/v2?q=!'.self::TEST_MAGAZINE_HANDLE);\n\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::validateResponseOuterData($jsonData, 0, 1);\n        self::validateResponseItemData($jsonData['apResults'][0], 'magazine', null, self::TEST_MAGAZINE_HANDLE, self::TEST_MAGAZINE_URL);\n\n        // Seems like settings can persist in the test environment? Might only be for bare metal setups\n        $settingsManager->set('KBIN_FEDERATED_SEARCH_ONLY_LOGGEDIN', $value);\n    }\n\n    public function testApiCanFindRemoteUserByUrl(): void\n    {\n        $settingsManager = $this->settingsManager;\n        $value = $settingsManager->get('KBIN_FEDERATED_SEARCH_ONLY_LOGGEDIN');\n        $settingsManager->set('KBIN_FEDERATED_SEARCH_ONLY_LOGGEDIN', true);\n        $this->getUserByUsername('test');\n\n        $this->client->loginUser($this->localUser);\n\n        $this->client->request('GET', '/api/search/v2?q='.urlencode(self::TEST_USER_URL));\n\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::validateResponseOuterData($jsonData, 0, 1);\n        self::validateResponseItemData($jsonData['apResults'][0], 'user', null, self::TEST_USER_HANDLE, self::TEST_USER_URL);\n\n        // Seems like settings can persist in the test environment? Might only be for bare metal setups\n        $settingsManager->set('KBIN_FEDERATED_SEARCH_ONLY_LOGGEDIN', $value);\n    }\n\n    public function testApiCanFindRemoteMagazineByUrl(): void\n    {\n        $this->getUserByUsername('admin', isAdmin: true);\n\n        $settingsManager = $this->settingsManager;\n        $value = $settingsManager->get('KBIN_FEDERATED_SEARCH_ONLY_LOGGEDIN');\n        $settingsManager->set('KBIN_FEDERATED_SEARCH_ONLY_LOGGEDIN', true);\n\n        $this->client->loginUser($this->localUser);\n\n        $this->getMagazineByName('testMag', user: $this->someUser);\n\n        $this->client->request('GET', '/api/search/v2?q='.urlencode(self::TEST_MAGAZINE_URL));\n\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::validateResponseOuterData($jsonData, 0, 1);\n        self::validateResponseItemData($jsonData['apResults'][0], 'magazine', null, self::TEST_MAGAZINE_HANDLE, self::TEST_MAGAZINE_URL);\n\n        // Seems like settings can persist in the test environment? Might only be for bare metal setups\n        $settingsManager->set('KBIN_FEDERATED_SEARCH_ONLY_LOGGEDIN', $value);\n    }\n\n    public function testApiCanFindRemotePostByUrl(): void\n    {\n        $this->getUserByUsername('admin', isAdmin: true);\n\n        $settingsManager = $this->settingsManager;\n        $value = $settingsManager->get('KBIN_FEDERATED_SEARCH_ONLY_LOGGEDIN');\n        $settingsManager->set('KBIN_FEDERATED_SEARCH_ONLY_LOGGEDIN', true);\n\n        $this->client->loginUser($this->localUser);\n\n        $this->getMagazineByName('testMag', user: $this->someUser);\n\n        $this->client->request('GET', '/api/search/v2?q='.urlencode($this->testEntryUrl));\n\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::validateResponseOuterData($jsonData, 0, 1);\n        self::validateResponseItemData($jsonData['apResults'][0], 'entry', null, $this->testEntryUrl);\n\n        // Seems like settings can persist in the test environment? Might only be for bare metal setups\n        $settingsManager->set('KBIN_FEDERATED_SEARCH_ONLY_LOGGEDIN', $value);\n    }\n\n    private static function validateResponseOuterData(array $data, int $expectedLength, int $expectedApLength): void\n    {\n        self::assertIsArray($data);\n        self::assertArrayKeysMatch(self::SEARCH_PAGINATED_KEYS, $data);\n        self::assertIsArray($data['items']);\n        self::assertCount($expectedLength, $data['items']);\n        self::assertIsArray($data['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $data['pagination']);\n        self::assertSame($expectedLength, $data['pagination']['count']);\n        self::assertIsArray($data['apResults']);\n        self::assertCount($expectedApLength, $data['apResults']);\n    }\n\n    private static function validateResponseItemData(array $data, string $expectedType, ?int $expectedId = null, ?string $expectedApId = null, ?string $apProfileId = null): void\n    {\n        self::assertIsArray($data);\n        self::assertArrayKeysMatch(self::SEARCH_ITEM_KEYS, $data);\n\n        switch ($expectedType) {\n            case 'entry':\n                self::assertNotNull($data['entry']);\n                self::assertNull($data['entryComment']);\n                self::assertNull($data['post']);\n                self::assertNull($data['postComment']);\n                self::assertNull($data['magazine']);\n                self::assertNull($data['user']);\n                self::assertArrayKeysMatch(self::ENTRY_RESPONSE_KEYS, $data['entry']);\n                if (null !== $expectedId) {\n                    self::assertSame($expectedId, $data['entry']['entryId']);\n                } else {\n                    self::assertSame($expectedApId, $data['entry']['apId']);\n                }\n                break;\n            case 'entryComment':\n                self::assertNotNull($data['entryComment']);\n                self::assertNull($data['entry']);\n                self::assertNull($data['post']);\n                self::assertNull($data['postComment']);\n                self::assertNull($data['magazine']);\n                self::assertNull($data['user']);\n                self::assertArrayKeysMatch(self::ENTRY_COMMENT_RESPONSE_KEYS, $data['entryComment']);\n                if (null !== $expectedId) {\n                    self::assertSame($expectedId, $data['entryComment']['commentId']);\n                } else {\n                    self::assertSame($expectedApId, $data['entryComment']['apId']);\n                }\n                break;\n            case 'post':\n                self::assertNotNull($data['post']);\n                self::assertNull($data['entry']);\n                self::assertNull($data['entryComment']);\n                self::assertNull($data['postComment']);\n                self::assertNull($data['magazine']);\n                self::assertNull($data['user']);\n                self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $data['post']);\n                if (null !== $expectedId) {\n                    self::assertSame($expectedId, $data['post']['postId']);\n                } else {\n                    self::assertSame($expectedApId, $data['post']['apId']);\n                }\n                break;\n            case 'postComment':\n                self::assertNotNull($data['postComment']);\n                self::assertNull($data['entry']);\n                self::assertNull($data['entryComment']);\n                self::assertNull($data['post']);\n                self::assertNull($data['magazine']);\n                self::assertNull($data['user']);\n                self::assertArrayKeysMatch(self::POST_COMMENT_RESPONSE_KEYS, $data['postComment']);\n                if (null !== $expectedId) {\n                    self::assertSame($expectedId, $data['postComment']['commentId']);\n                } else {\n                    self::assertSame($expectedApId, $data['postComment']['apId']);\n                }\n                break;\n            case 'magazine':\n                self::assertNotNull($data['magazine']);\n                self::assertNull($data['entry']);\n                self::assertNull($data['entryComment']);\n                self::assertNull($data['post']);\n                self::assertNull($data['postComment']);\n                self::assertNull($data['user']);\n                self::assertArrayKeysMatch(self::MAGAZINE_RESPONSE_KEYS, $data['magazine']);\n                if (null !== $expectedId) {\n                    self::assertSame($expectedId, $data['magazine']['magazineId']);\n                } else {\n                    self::assertSame($expectedApId, $data['magazine']['apId']);\n                }\n                if (null !== $apProfileId) {\n                    self::assertSame($apProfileId, $data['magazine']['apProfileId']);\n                }\n                break;\n            case 'user':\n                self::assertNotNull($data['user']);\n                self::assertNull($data['entry']);\n                self::assertNull($data['entryComment']);\n                self::assertNull($data['post']);\n                self::assertNull($data['postComment']);\n                self::assertNull($data['magazine']);\n                self::assertArrayKeysMatch(self::USER_RESPONSE_KEYS, $data['user']);\n                if (null !== $expectedId) {\n                    self::assertSame($expectedId, $data['user']['userId']);\n                } else {\n                    self::assertSame($expectedApId, $data['user']['apId']);\n                }\n                if (null !== $apProfileId) {\n                    self::assertSame($apProfileId, $data['user']['apProfileId']);\n                }\n                break;\n            default:\n                throw new \\AssertionError();\n        }\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/User/Admin/UserBanApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\User\\Admin;\n\nuse App\\Tests\\WebTestCase;\n\nclass UserBanApiTest extends WebTestCase\n{\n    public function testApiCannotBanUserWithoutScope(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $testUser = $this->getUserByUsername('UserWithoutAbout', isAdmin: true);\n        $bannedUser = $this->getUserByUsername('JohnDoe');\n        $this->client->loginUser($testUser);\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n\n        $this->client->request('POST', '/api/admin/users/'.(string) $bannedUser->getId().'/ban', server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]);\n        self::assertResponseStatusCodeSame(403);\n\n        $repository = $this->userRepository;\n        $bannedUser = $repository->find($bannedUser->getId());\n        self::assertFalse($bannedUser->isBanned);\n    }\n\n    public function testApiCannotUnbanUserWithoutScope(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $testUser = $this->getUserByUsername('UserWithoutAbout', isAdmin: true);\n        $bannedUser = $this->getUserByUsername('JohnDoe');\n        $this->userManager->ban($bannedUser, $testUser, null);\n        $this->client->loginUser($testUser);\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n\n        $this->client->request('POST', '/api/admin/users/'.(string) $bannedUser->getId().'/unban', server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]);\n        self::assertResponseStatusCodeSame(403);\n\n        $repository = $this->userRepository;\n        $bannedUser = $repository->find($bannedUser->getId());\n        self::assertTrue($bannedUser->isBanned);\n    }\n\n    public function testApiCannotBanUserWithoutAdminAccount(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $testUser = $this->getUserByUsername('UserWithoutAbout', isAdmin: false);\n        $bannedUser = $this->getUserByUsername('JohnDoe');\n        $this->client->loginUser($testUser);\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read admin:user:ban');\n\n        $this->client->request('POST', '/api/admin/users/'.(string) $bannedUser->getId().'/ban', server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]);\n        self::assertResponseStatusCodeSame(403);\n\n        $repository = $this->userRepository;\n        $bannedUser = $repository->find($bannedUser->getId());\n        self::assertFalse($bannedUser->isBanned);\n    }\n\n    public function testApiCannotUnbanUserWithoutAdminAccount(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $testUser = $this->getUserByUsername('UserWithoutAbout', isAdmin: false);\n        $bannedUser = $this->getUserByUsername('JohnDoe');\n        $this->userManager->ban($bannedUser, $testUser, null);\n        $this->client->loginUser($testUser);\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read admin:user:ban');\n\n        $this->client->request('POST', '/api/admin/users/'.(string) $bannedUser->getId().'/unban', server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]);\n        self::assertResponseStatusCodeSame(403);\n\n        $repository = $this->userRepository;\n        $bannedUser = $repository->find($bannedUser->getId());\n        self::assertTrue($bannedUser->isBanned);\n    }\n\n    public function testApiCanBanUser(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $testUser = $this->getUserByUsername('UserWithoutAbout', isAdmin: true);\n        $bannedUser = $this->getUserByUsername('JohnDoe');\n        $this->client->loginUser($testUser);\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read admin:user:ban');\n\n        $this->client->request('POST', '/api/admin/users/'.(string) $bannedUser->getId().'/ban', server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]);\n        self::assertResponseIsSuccessful();\n\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertArrayKeysMatch(array_merge(self::USER_RESPONSE_KEYS, ['isBanned']), $jsonData);\n        self::assertTrue($jsonData['isBanned']);\n\n        $repository = $this->userRepository;\n        $bannedUser = $repository->find($bannedUser->getId());\n        self::assertTrue($bannedUser->isBanned);\n    }\n\n    public function testApiCanUnbanUser(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $testUser = $this->getUserByUsername('UserWithoutAbout', isAdmin: true);\n        $bannedUser = $this->getUserByUsername('JohnDoe');\n\n        $this->userManager->ban($bannedUser, $testUser, null);\n\n        $this->client->loginUser($testUser);\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read admin:user:ban');\n\n        $this->client->request('POST', '/api/admin/users/'.(string) $bannedUser->getId().'/unban', server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]);\n        self::assertResponseIsSuccessful();\n\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertArrayKeysMatch(array_merge(self::USER_RESPONSE_KEYS, ['isBanned']), $jsonData);\n        self::assertFalse($jsonData['isBanned']);\n\n        $repository = $this->userRepository;\n        $bannedUser = $repository->find($bannedUser->getId());\n        self::assertFalse($bannedUser->isBanned);\n    }\n\n    public function testBanApiReturns404IfUserNotFound(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $testUser = $this->getUserByUsername('UserWithoutAbout', isAdmin: true);\n        $bannedUser = $this->getUserByUsername('JohnDoe');\n        $this->client->loginUser($testUser);\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read admin:user:ban');\n\n        $this->client->request('POST', '/api/admin/users/'.(string) ($bannedUser->getId() * 10).'/ban', server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]);\n        self::assertResponseStatusCodeSame(404);\n\n        $repository = $this->userRepository;\n        $bannedUser = $repository->find($bannedUser->getId());\n        self::assertFalse($bannedUser->isBanned);\n    }\n\n    public function testUnbanApiReturns404IfUserNotFound(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $testUser = $this->getUserByUsername('UserWithoutAbout', isAdmin: true);\n        $bannedUser = $this->getUserByUsername('JohnDoe');\n\n        $this->userManager->ban($bannedUser, $testUser, null);\n\n        $this->client->loginUser($testUser);\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read admin:user:ban');\n\n        $this->client->request('POST', '/api/admin/users/'.(string) ($bannedUser->getId() * 10).'/unban', server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]);\n        self::assertResponseStatusCodeSame(404);\n\n        $repository = $this->userRepository;\n        $bannedUser = $repository->find($bannedUser->getId());\n        self::assertTrue($bannedUser->isBanned);\n    }\n\n    public function testBanApiReturns401IfTokenNotProvided(): void\n    {\n        $bannedUser = $this->getUserByUsername('JohnDoe');\n\n        $this->client->request('POST', '/api/admin/users/'.(string) $bannedUser->getId().'/ban');\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testUnbanApiReturns401IfTokenNotProvided(): void\n    {\n        $bannedUser = $this->getUserByUsername('JohnDoe');\n\n        $this->client->request('POST', '/api/admin/users/'.(string) $bannedUser->getId().'/unban');\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testBanApiIsIdempotent(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $testUser = $this->getUserByUsername('UserWithoutAbout', isAdmin: true);\n        $bannedUser = $this->getUserByUsername('JohnDoe');\n\n        $this->userManager->ban($bannedUser, $testUser, null);\n\n        $this->client->loginUser($testUser);\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read admin:user:ban');\n\n        // Ban user a second time with the API\n        $this->client->request('POST', '/api/admin/users/'.(string) $bannedUser->getId().'/ban', server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]);\n        self::assertResponseIsSuccessful();\n\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertArrayKeysMatch(array_merge(self::USER_RESPONSE_KEYS, ['isBanned']), $jsonData);\n        self::assertTrue($jsonData['isBanned']);\n\n        $repository = $this->userRepository;\n        $bannedUser = $repository->find($bannedUser->getId());\n        self::assertTrue($bannedUser->isBanned);\n    }\n\n    public function testUnbanApiIsIdempotent(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $testUser = $this->getUserByUsername('UserWithoutAbout', isAdmin: true);\n        $bannedUser = $this->getUserByUsername('JohnDoe');\n\n        // Do not ban user\n\n        $this->client->loginUser($testUser);\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read admin:user:ban');\n\n        $this->client->request('POST', '/api/admin/users/'.(string) $bannedUser->getId().'/unban', server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]);\n        self::assertResponseIsSuccessful();\n\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertArrayKeysMatch(array_merge(self::USER_RESPONSE_KEYS, ['isBanned']), $jsonData);\n        self::assertFalse($jsonData['isBanned']);\n\n        $repository = $this->userRepository;\n        $bannedUser = $repository->find($bannedUser->getId());\n        self::assertFalse($bannedUser->isBanned);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/User/Admin/UserDeleteApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\User\\Admin;\n\nuse App\\Tests\\WebTestCase;\n\nclass UserDeleteApiTest extends WebTestCase\n{\n    public function testApiCannotDeleteUserWithoutScope(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $testUser = $this->getUserByUsername('UserWithoutAbout', isAdmin: true);\n        $deletedUser = $this->getUserByUsername('JohnDoe');\n        $this->client->loginUser($testUser);\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n\n        $this->client->request(\n            'DELETE',\n            '/api/admin/users/'.(string) $deletedUser->getId().'/delete_account',\n            server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]\n        );\n        self::assertResponseStatusCodeSame(403);\n\n        $repository = $this->userRepository;\n        $deletedUser = $repository->find($deletedUser->getId());\n        self::assertFalse($deletedUser->isAccountDeleted());\n    }\n\n    public function testApiCannotDeleteUserWithoutAdminAccount(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $testUser = $this->getUserByUsername('UserWithoutAbout', isAdmin: false);\n        $deletedUser = $this->getUserByUsername('JohnDoe');\n        $this->client->loginUser($testUser);\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read admin:user:delete');\n\n        $this->client->request(\n            'DELETE',\n            '/api/admin/users/'.(string) $deletedUser->getId().'/delete_account',\n            server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]\n        );\n        self::assertResponseStatusCodeSame(403);\n\n        $repository = $this->userRepository;\n        $deletedUser = $repository->find($deletedUser->getId());\n        self::assertFalse($deletedUser->isAccountDeleted());\n    }\n\n    public function testApiCanDeleteUser(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $testUser = $this->getUserByUsername('UserWithoutAbout', isAdmin: true);\n        $deletedUser = $this->getUserByUsername('JohnDoe');\n        $this->client->loginUser($testUser);\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read admin:user:delete');\n\n        $this->client->request(\n            'DELETE',\n            '/api/admin/users/'.(string) $deletedUser->getId().'/delete_account',\n            server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]\n        );\n        self::assertResponseIsSuccessful();\n\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertArrayKeysMatch(self::USER_RESPONSE_KEYS, $jsonData);\n\n        $repository = $this->userRepository;\n        $deletedUser = $repository->find($deletedUser->getId());\n        self::assertTrue($deletedUser->isAccountDeleted());\n    }\n\n    public function testDeleteApiReturns404IfUserNotFound(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $testUser = $this->getUserByUsername('UserWithoutAbout', isAdmin: true);\n        $deletedUser = $this->getUserByUsername('JohnDoe');\n        $this->client->loginUser($testUser);\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read admin:user:delete');\n\n        $this->client->request(\n            'DELETE',\n            '/api/admin/users/'.(string) ($deletedUser->getId() * 10).'/delete_account',\n            server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]\n        );\n        self::assertResponseStatusCodeSame(404);\n\n        $repository = $this->userRepository;\n        $deletedUser = $repository->find($deletedUser->getId());\n        self::assertFalse($deletedUser->isBanned);\n    }\n\n    public function testDeleteApiReturns401IfTokenNotProvided(): void\n    {\n        $deletedUser = $this->getUserByUsername('JohnDoe');\n\n        $this->client->request('DELETE', '/api/admin/users/'.(string) $deletedUser->getId().'/delete_account');\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testDeleteApiIsNotIdempotent(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $testUser = $this->getUserByUsername('UserWithoutAbout', isAdmin: true);\n        $deletedUser = $this->getUserByUsername('JohnDoe');\n        $deleteId = $deletedUser->getId();\n        $this->userManager->delete($deletedUser);\n\n        $this->client->loginUser($testUser);\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read admin:user:delete');\n\n        // Ban user a second time with the API\n        $this->client->request(\n            'DELETE',\n            '/api/admin/users/'.(string) $deleteId.'/delete_account',\n            server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]\n        );\n        self::assertResponseStatusCodeSame(404);\n\n        $repository = $this->userRepository;\n        $deletedUser = $repository->find($deleteId);\n        self::assertNull($deletedUser);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/User/Admin/UserPurgeApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\User\\Admin;\n\nuse App\\Tests\\WebTestCase;\n\nclass UserPurgeApiTest extends WebTestCase\n{\n    public function testApiCannotPurgeUserWithoutScope(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $testUser = $this->getUserByUsername('UserWithoutAbout', isAdmin: true);\n        $purgedUser = $this->getUserByUsername('JohnDoe');\n        $this->client->loginUser($testUser);\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n\n        $this->client->request('DELETE', '/api/admin/users/'.(string) $purgedUser->getId().'/purge_account', server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]);\n        self::assertResponseStatusCodeSame(403);\n\n        $repository = $this->userRepository;\n        $purgedUser = $repository->find($purgedUser->getId());\n        self::assertNotNull($purgedUser);\n    }\n\n    public function testApiCannotPurgeUserWithoutAdminAccount(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $testUser = $this->getUserByUsername('UserWithoutAbout', isAdmin: false);\n        $purgedUser = $this->getUserByUsername('JohnDoe');\n        $this->client->loginUser($testUser);\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read admin:user:purge');\n\n        $this->client->request('DELETE', '/api/admin/users/'.(string) $purgedUser->getId().'/purge_account', server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]);\n\n        self::assertResponseStatusCodeSame(403);\n\n        $repository = $this->userRepository;\n        $purgedUser = $repository->find($purgedUser->getId());\n        self::assertNotNull($purgedUser);\n    }\n\n    public function testApiCanPurgeUser(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $testUser = $this->getUserByUsername('UserWithoutAbout', isAdmin: true);\n        $purgedUser = $this->getUserByUsername('JohnDoe');\n        $this->client->loginUser($testUser);\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read admin:user:purge');\n\n        $this->client->request('DELETE', '/api/admin/users/'.(string) $purgedUser->getId().'/purge_account', server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]);\n        self::assertResponseStatusCodeSame(204);\n\n        $repository = $this->userRepository;\n        $purgedUser = $repository->find($purgedUser->getId());\n        self::assertNull($purgedUser);\n    }\n\n    public function testPurgeApiReturns404IfUserNotFound(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $testUser = $this->getUserByUsername('UserWithoutAbout', isAdmin: true);\n        $purgedUser = $this->getUserByUsername('JohnDoe');\n        $this->client->loginUser($testUser);\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read admin:user:purge');\n\n        $this->client->request('DELETE', '/api/admin/users/'.(string) ($purgedUser->getId() * 10).'/purge_account', server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]);\n        self::assertResponseStatusCodeSame(404);\n\n        $repository = $this->userRepository;\n        $purgedUser = $repository->find($purgedUser->getId());\n        self::assertNotNull($purgedUser);\n    }\n\n    public function testPurgeApiReturns401IfTokenNotProvided(): void\n    {\n        $purgedUser = $this->getUserByUsername('JohnDoe');\n\n        $this->client->request('DELETE', '/api/admin/users/'.(string) $purgedUser->getId().'/purge_account');\n        self::assertResponseStatusCodeSame(401);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/User/Admin/UserRetrieveBannedApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\User\\Admin;\n\nuse App\\Tests\\WebTestCase;\n\nclass UserRetrieveBannedApiTest extends WebTestCase\n{\n    public function testApiCannotRetrieveBannedUsersWithoutScope(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $testUser = $this->getUserByUsername('UserWithoutAbout', isAdmin: true);\n        $bannedUser = $this->getUserByUsername('JohnDoe');\n        $this->userManager->ban($bannedUser, $testUser, null);\n        $this->client->loginUser($testUser);\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n\n        $this->client->request('GET', '/api/admin/users/banned', server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCannotRetrieveBannedUsersWithoutAdminAccount(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $testUser = $this->getUserByUsername('UserWithoutAbout', isAdmin: false);\n        $bannedUser = $this->getUserByUsername('JohnDoe');\n        $this->userManager->ban($bannedUser, $testUser, null);\n        $this->client->loginUser($testUser);\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read admin:user:ban');\n\n        $this->client->request('GET', '/api/admin/users/banned', server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanRetrieveBannedUsers(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $testUser = $this->getUserByUsername('UserWithoutAbout', isAdmin: true);\n        $bannedUser = $this->getUserByUsername('JohnDoe');\n        $this->userManager->ban($bannedUser, $testUser, null);\n        $this->client->loginUser($testUser);\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read admin:user:ban');\n\n        $this->client->request('GET', '/api/admin/users/banned', server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]);\n        self::assertResponseIsSuccessful();\n\n        $jsonData = self::getJsonResponse($this->client);\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(1, $jsonData['pagination']['count']);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(1, $jsonData['items']);\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(array_merge(self::USER_RESPONSE_KEYS, ['isBanned']), $jsonData['items'][0]);\n        self::assertSame($bannedUser->getId(), $jsonData['items'][0]['userId']);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/User/Admin/UserVerifyApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\User\\Admin;\n\nuse App\\Tests\\WebTestCase;\n\nclass UserVerifyApiTest extends WebTestCase\n{\n    public function testApiCannotVerifyUserWithoutScope(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $testUser = $this->getUserByUsername('UserWithoutAbout', isAdmin: true);\n        $unverifiedUser = $this->getUserByUsername('JohnDoe', active: false);\n        $this->client->loginUser($testUser);\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n\n        $this->client->request('PUT', '/api/admin/users/'.(string) $unverifiedUser->getId().'/verify', server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]);\n        self::assertResponseStatusCodeSame(403);\n\n        $repository = $this->userRepository;\n        $unverifiedUser = $repository->find($unverifiedUser->getId());\n        self::assertFalse($unverifiedUser->isVerified);\n    }\n\n    public function testApiCannotVerifyUserWithoutAdminAccount(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $testUser = $this->getUserByUsername('UserWithoutAbout', isAdmin: false);\n        $unverifiedUser = $this->getUserByUsername('JohnDoe', active: false);\n        $this->client->loginUser($testUser);\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read admin:user:verify');\n\n        $this->client->request('PUT', '/api/admin/users/'.(string) $unverifiedUser->getId().'/verify', server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]);\n        self::assertResponseStatusCodeSame(403);\n\n        $repository = $this->userRepository;\n        $unverifiedUser = $repository->find($unverifiedUser->getId());\n        self::assertFalse($unverifiedUser->isVerified);\n    }\n\n    public function testApiCanVerifyUser(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $testUser = $this->getUserByUsername('UserWithoutAbout', isAdmin: true);\n        $unverifiedUser = $this->getUserByUsername('JohnDoe', active: false);\n        $this->client->loginUser($testUser);\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read admin:user:verify');\n\n        $this->client->request('PUT', '/api/admin/users/'.(string) $unverifiedUser->getId().'/verify', server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]);\n        self::assertResponseStatusCodeSame(200);\n\n        $jsonData = self::getJsonResponse($this->client);\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(array_merge(self::USER_RESPONSE_KEYS, ['isVerified']), $jsonData);\n        self::assertTrue($jsonData['isVerified']);\n\n        $repository = $this->userRepository;\n        $unverifiedUser = $repository->find($unverifiedUser->getId());\n        self::assertTrue($unverifiedUser->isVerified);\n    }\n\n    public function testVerifyApiReturns404IfUserNotFound(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $testUser = $this->getUserByUsername('UserWithoutAbout', isAdmin: true);\n        $unverifiedUser = $this->getUserByUsername('JohnDoe', active: false);\n        $this->client->loginUser($testUser);\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read admin:user:verify');\n\n        $this->client->request('PUT', '/api/admin/users/'.(string) ($unverifiedUser->getId() * 10).'/verify', server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]);\n        self::assertResponseStatusCodeSame(404);\n    }\n\n    public function testVerifyApiReturns401IfTokenNotProvided(): void\n    {\n        $unverifiedUser = $this->getUserByUsername('JohnDoe', active: false);\n\n        $this->client->request('PUT', '/api/admin/users/'.(string) $unverifiedUser->getId().'/verify');\n        self::assertResponseStatusCodeSame(401);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/User/UserBlockApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\User;\n\nuse App\\Tests\\WebTestCase;\nuse PHPUnit\\Framework\\Attributes\\Group;\n\nclass UserBlockApiTest extends WebTestCase\n{\n    public function testApiCannotBlockUserWithoutScope(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $testUser = $this->getUserByUsername('UserWithoutAbout');\n        $blockedUser = $this->getUserByUsername('JohnDoe');\n        $this->client->loginUser($testUser);\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n\n        $this->client->request('PUT', '/api/users/'.(string) $blockedUser->getId().'/block', server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCannotUnblockUserWithoutScope(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $testUser = $this->getUserByUsername('UserWithoutAbout');\n        $blockedUser = $this->getUserByUsername('JohnDoe');\n        $this->client->loginUser($testUser);\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n\n        $this->client->request('PUT', '/api/users/'.(string) $blockedUser->getId().'/unblock', server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    #[Group(name: 'NonThreadSafe')]\n    public function testApiCanBlockUser(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $testUser = $this->getUserByUsername('UserWithoutAbout');\n        $followedUser = $this->getUserByUsername('JohnDoe');\n        $this->client->loginUser($testUser);\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:follow user:block');\n\n        $this->client->request('PUT', '/api/users/'.(string) $followedUser->getId().'/block', server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]);\n        self::assertResponseIsSuccessful();\n\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayHasKey('userId', $jsonData);\n        self::assertArrayHasKey('username', $jsonData);\n        self::assertArrayHasKey('about', $jsonData);\n        self::assertArrayHasKey('avatar', $jsonData);\n        self::assertArrayHasKey('cover', $jsonData);\n        self::assertArrayNotHasKey('lastActive', $jsonData);\n        self::assertArrayHasKey('createdAt', $jsonData);\n        self::assertArrayHasKey('followersCount', $jsonData);\n        self::assertArrayHasKey('apId', $jsonData);\n        self::assertArrayHasKey('apProfileId', $jsonData);\n        self::assertArrayHasKey('isBot', $jsonData);\n        self::assertArrayHasKey('isFollowedByUser', $jsonData);\n        self::assertArrayHasKey('isFollowerOfUser', $jsonData);\n        self::assertArrayHasKey('isBlockedByUser', $jsonData);\n\n        self::assertSame(0, $jsonData['followersCount']);\n        self::assertFalse($jsonData['isFollowedByUser']);\n        self::assertFalse($jsonData['isFollowerOfUser']);\n        self::assertTrue($jsonData['isBlockedByUser']);\n    }\n\n    #[Group(name: 'NonThreadSafe')]\n    public function testApiCanUnblockUser(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $testUser = $this->getUserByUsername('UserWithoutAbout');\n        $blockedUser = $this->getUserByUsername('JohnDoe');\n\n        $testUser->block($blockedUser);\n\n        $manager = $this->entityManager;\n\n        $manager->persist($testUser);\n        $manager->flush();\n\n        $this->client->loginUser($testUser);\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:follow user:block');\n\n        $this->client->request('PUT', '/api/users/'.(string) $blockedUser->getId().'/unblock', server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]);\n        self::assertResponseIsSuccessful();\n\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayHasKey('userId', $jsonData);\n        self::assertArrayHasKey('username', $jsonData);\n        self::assertArrayHasKey('about', $jsonData);\n        self::assertArrayHasKey('avatar', $jsonData);\n        self::assertArrayHasKey('cover', $jsonData);\n        self::assertArrayNotHasKey('lastActive', $jsonData);\n        self::assertArrayHasKey('createdAt', $jsonData);\n        self::assertArrayHasKey('followersCount', $jsonData);\n        self::assertArrayHasKey('apId', $jsonData);\n        self::assertArrayHasKey('apProfileId', $jsonData);\n        self::assertArrayHasKey('isBot', $jsonData);\n        self::assertArrayHasKey('isFollowedByUser', $jsonData);\n        self::assertArrayHasKey('isFollowerOfUser', $jsonData);\n        self::assertArrayHasKey('isBlockedByUser', $jsonData);\n\n        self::assertSame(0, $jsonData['followersCount']);\n        self::assertFalse($jsonData['isFollowedByUser']);\n        self::assertFalse($jsonData['isFollowerOfUser']);\n        self::assertFalse($jsonData['isBlockedByUser']);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/User/UserContentApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\User;\n\nuse App\\Tests\\WebTestCase;\n\nclass UserContentApiTest extends WebTestCase\n{\n    public function testCanGetUserContent()\n    {\n        $user = $this->getUserByUsername('JohnDoe');\n        $dummyUser = $this->getUserByUsername('dummy');\n        $magazine = $this->getMagazineByName('test');\n        $entry1 = $this->createEntry('e 1', $magazine, $user);\n        $entry2 = $this->createEntry('e 2', $magazine, $user);\n        $entryDummy = $this->createEntry('dummy', $magazine, $dummyUser);\n        $post1 = $this->createPost('p 1', $magazine, $user);\n        $post2 = $this->createPost('p 2', $magazine, $user);\n        $this->createPost('dummy', $magazine, $dummyUser);\n        $comment1 = $this->createEntryComment('c 1', $entryDummy, $user);\n        $comment2 = $this->createEntryComment('c 2', $entryDummy, $user);\n        $this->createEntryComment('dummy', $entryDummy, $dummyUser);\n        $reply1 = $this->createPostComment('r 1', $post1, $user);\n        $reply2 = $this->createPostComment('r 2', $post1, $user);\n        $this->createPostComment('dummy', $post1, $dummyUser);\n\n        $this->client->request('GET', \"/api/users/{$user->getId()}/content\");\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(8, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(8, $jsonData['pagination']['count']);\n\n        self::assertTrue(array_all($jsonData['items'], function ($item) use ($entry1, $entry2, $post1, $post2, $comment1, $comment2, $reply1, $reply2) {\n            return\n                (null !== $item['entry'] && ($item['entry']['entryId'] === $entry1->getId() || $item['entry']['entryId'] === $entry2->getId()))\n                || (null !== $item['post'] && ($item['post']['postId'] === $post1->getId() || $item['post']['postId'] === $post2->getId()))\n                || (null !== $item['entryComment'] && ($item['entryComment']['commentId'] === $comment1->getId() || $item['entryComment']['commentId'] === $comment2->getId()))\n                || (null !== $item['postComment'] && ($item['postComment']['commentId'] === $reply1->getId() || $item['postComment']['commentId'] === $reply2->getId()))\n            ;\n        }));\n    }\n\n    public function testCanGetUserContentHideAdult()\n    {\n        $user = $this->getUserByUsername('JohnDoe');\n        $dummyUser = $this->getUserByUsername('dummy');\n        $magazine = $this->getMagazineByName('test');\n        $entry1 = $this->createEntry('e 1', $magazine, $user);\n        $entry2 = $this->createEntry('e 2', $magazine, $user);\n        $entryDummy = $this->createEntry('dummy', $magazine, $dummyUser);\n        $post1 = $this->createPost('p 1', $magazine, $user);\n        $post2 = $this->createPost('p 2', $magazine, $user);\n        $this->createPost('dummy', $magazine, $dummyUser);\n        $comment1 = $this->createEntryComment('c 1', $entryDummy, $user);\n        $comment2 = $this->createEntryComment('c 2', $entryDummy, $user);\n        $this->createEntryComment('dummy', $entryDummy, $dummyUser);\n        $reply1 = $this->createPostComment('r 1', $post1, $user);\n        $reply2 = $this->createPostComment('r 2', $post1, $user);\n        $this->createPostComment('dummy', $post1, $dummyUser);\n\n        $entry2->isAdult = true;\n        $post2->isAdult = true;\n        $comment2->isAdult = true;\n        $reply2->isAdult = true;\n        $this->entityManager->persist($entry2);\n        $this->entityManager->persist($post2);\n        $this->entityManager->persist($comment2);\n        $this->entityManager->persist($reply2);\n        $this->entityManager->flush();\n\n        $this->client->request('GET', \"/api/users/{$user->getId()}/content?hideAdult=true\");\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(4, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(4, $jsonData['pagination']['count']);\n\n        self::assertTrue(array_all($jsonData['items'], function ($item) use ($entry1, $post1, $comment1, $reply1) {\n            return\n                (null !== $item['entry'] && $item['entry']['entryId'] === $entry1->getId())\n                || (null !== $item['post'] && $item['post']['postId'] === $post1->getId())\n                || (null !== $item['entryComment'] && $item['entryComment']['commentId'] === $comment1->getId())\n                || (null !== $item['postComment'] && $item['postComment']['commentId'] === $reply1->getId())\n            ;\n        }));\n    }\n\n    public function testCanGetUserBoosts()\n    {\n        $user = $this->getUserByUsername('JohnDoe');\n        $dummyUser = $this->getUserByUsername('dummy');\n        $magazine = $this->getMagazineByName('test');\n        $entry1 = $this->createEntry('e 1', $magazine, $dummyUser);\n        $entry2 = $this->createEntry('e 2', $magazine, $dummyUser);\n        $entryDummy = $this->createEntry('dummy', $magazine, $dummyUser);\n        $post1 = $this->createPost('p 1', $magazine, $dummyUser);\n        $post2 = $this->createPost('p 2', $magazine, $dummyUser);\n        $this->createPost('dummy', $magazine, $dummyUser);\n        $comment1 = $this->createEntryComment('c 1', $entryDummy, $dummyUser);\n        $comment2 = $this->createEntryComment('c 2', $entryDummy, $dummyUser);\n        $this->createEntryComment('dummy', $entryDummy, $dummyUser);\n        $reply1 = $this->createPostComment('r 1', $post1, $dummyUser);\n        $reply2 = $this->createPostComment('r 2', $post1, $dummyUser);\n        $this->createPostComment('dummy', $post1, $dummyUser);\n\n        $this->voteManager->upvote($entry1, $user);\n        $this->voteManager->upvote($entry2, $user);\n        $this->voteManager->upvote($post1, $user);\n        $this->voteManager->upvote($post2, $user);\n        $this->voteManager->upvote($comment1, $user);\n        $this->voteManager->upvote($comment2, $user);\n        $this->voteManager->upvote($reply1, $user);\n        $this->voteManager->upvote($reply2, $user);\n\n        $this->client->request('GET', \"/api/users/{$user->getId()}/boosts\");\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(8, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(8, $jsonData['pagination']['count']);\n\n        self::assertTrue(array_all($jsonData['items'], function ($item) use ($entry1, $entry2, $post1, $post2, $comment1, $comment2, $reply1, $reply2) {\n            return\n                (null !== $item['entry'] && ($item['entry']['entryId'] === $entry1->getId() || $item['entry']['entryId'] === $entry2->getId()))\n                || (null !== $item['post'] && ($item['post']['postId'] === $post1->getId() || $item['post']['postId'] === $post2->getId()))\n                || (null !== $item['entryComment'] && ($item['entryComment']['commentId'] === $comment1->getId() || $item['entryComment']['commentId'] === $comment2->getId()))\n                || (null !== $item['postComment'] && ($item['postComment']['commentId'] === $reply1->getId() || $item['postComment']['commentId'] === $reply2->getId()))\n            ;\n        }));\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/User/UserFilterListApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\User;\n\nuse App\\Entity\\User;\nuse App\\Entity\\UserFilterList;\nuse App\\Tests\\WebTestCase;\n\nclass UserFilterListApiTest extends WebTestCase\n{\n    public const array USER_FILTER_LIST_KEYS = [\n        'id',\n        'name',\n        'expirationDate',\n        'feeds',\n        'comments',\n        'profile',\n        'words',\n    ];\n\n    private User $listUser;\n\n    private User $otherUser;\n\n    private UserFilterList $list;\n\n    public function testUserRetrieve(): void\n    {\n        $token = $this->getListUserToken();\n        $this->client->request('GET', '/api/users/filterLists', server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n\n        $data = self::getJsonResponse($this->client);\n        self::assertArrayHasKey('items', $data);\n        self::assertCount(1, $data['items']);\n        $list = $data['items'][0];\n        self::assertArrayKeysMatch(self::USER_FILTER_LIST_KEYS, $list);\n    }\n\n    public function testAnonymousCannotRetrieve(): void\n    {\n        $this->client->request('GET', '/api/users/filterLists');\n        self::assertResponseStatusCodeSame(401);\n    }\n\n    public function testUserCanEditList(): void\n    {\n        $token = $this->getListUserToken();\n        $requestParams = [\n            'name' => 'Some new Name',\n            'expirationDate' => (new \\DateTimeImmutable('now - 5 days'))->format(DATE_ATOM),\n            'feeds' => false,\n            'profile' => false,\n            'comments' => false,\n            'words' => [\n                [\n                    'exactMatch' => true,\n                    'word' => 'newWord',\n                ],\n                [\n                    'exactMatch' => false,\n                    'word' => 'sOmEnEwWoRd',\n                ],\n            ],\n        ];\n\n        $this->client->jsonRequest('PUT', '/api/users/filterLists/'.$this->list->getId(), $requestParams, server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n\n        $data = self::getJsonResponse($this->client);\n        self::assertArrayIsEqualToArrayIgnoringListOfKeys($requestParams, $data, ['id']);\n    }\n\n    public function testOtherUserCannotEditList(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->otherUser);\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'user:profile:edit');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n        $requestParams = [\n            'name' => 'Some new Name',\n            'expirationDate' => null,\n            'feeds' => false,\n            'profile' => false,\n            'comments' => false,\n            'words' => $this->list->words,\n        ];\n\n        $this->client->jsonRequest('PUT', '/api/users/filterLists/'.$this->list->getId(), $requestParams, server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testUserCanDeleteList(): void\n    {\n        $token = $this->getListUserToken();\n\n        $this->client->jsonRequest('DELETE', '/api/users/filterLists/'.$this->list->getId(), server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n\n        $freshList = $this->entityManager->getRepository(UserFilterList::class)->find($this->list->getId());\n        self::assertNull($freshList);\n    }\n\n    public function testOtherUserCannotDeleteList(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->otherUser);\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'user:profile:edit');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        $this->client->jsonRequest('DELETE', '/api/users/filterLists/'.$this->list->getId(), server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseStatusCodeSame(403);\n\n        $freshList = $this->entityManager->getRepository(UserFilterList::class)->find($this->list->getId());\n        self::assertNotNull($freshList);\n    }\n\n    public function testFilteredHomePage(): void\n    {\n        $token = $this->getListUserToken();\n\n        $this->deactivateFilterList($token);\n\n        $entry = $this->getEntryByTitle('Cringe entry', body: 'some entry');\n        $entry2 = $this->getEntryByTitle('Some entry', body: 'some entry');\n        $entry2->createdAt = new \\DateTimeImmutable('now - 1 minutes');\n        $entry3 = $this->getEntryByTitle('Some other entry', body: 'some entry with a cringe body');\n        $entry3->createdAt = new \\DateTimeImmutable('now - 2 minutes');\n        $post = $this->createPost('Cringe body');\n        $post->createdAt = new \\DateTimeImmutable('now - 3 minutes');\n        $post2 = $this->createPost('Body with a cringe text');\n        $post2->createdAt = new \\DateTimeImmutable('now - 4 minutes');\n        $post3 = $this->createPost('Some post');\n        $post3->createdAt = new \\DateTimeImmutable('now - 5 minutes');\n        $this->entityManager->flush();\n\n        $this->client->jsonRequest('GET', '/api/combined?sort=newest', server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $data = self::getJsonResponse($this->client);\n        self::assertIsArray($data);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $data);\n\n        self::assertIsArray($data['items']);\n        self::assertCount(6, $data['items']);\n        self::assertEquals($entry->getId(), $data['items'][0]['entry']['entryId']);\n        self::assertEquals($entry2->getId(), $data['items'][1]['entry']['entryId']);\n        self::assertEquals($entry3->getId(), $data['items'][2]['entry']['entryId']);\n        self::assertEquals($post->getId(), $data['items'][3]['post']['postId']);\n        self::assertEquals($post2->getId(), $data['items'][4]['post']['postId']);\n        self::assertEquals($post3->getId(), $data['items'][5]['post']['postId']);\n\n        // activate list\n        $this->activateFilterList($token);\n\n        $this->client->jsonRequest('GET', '/api/combined?sortBy=newest', server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $data = self::getJsonResponse($this->client);\n        self::assertIsArray($data);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $data);\n\n        self::assertIsArray($data['items']);\n        self::assertCount(2, $data['items']);\n        self::assertEquals($entry2->getId(), $data['items'][0]['entry']['entryId']);\n        self::assertEquals($post3->getId(), $data['items'][1]['post']['postId']);\n    }\n\n    public function testFilteredHomePageExact(): void\n    {\n        $token = $this->getListUserToken();\n        $this->deactivateFilterList($token);\n\n        $entry = $this->getEntryByTitle('TEST entry', body: 'some entry');\n        $entry2 = $this->getEntryByTitle('Some entry', body: 'some test entry');\n        $entry2->createdAt = new \\DateTimeImmutable('now - 1 minutes');\n        $entry3 = $this->getEntryByTitle('Some other entry', body: 'some entry with a TEST body');\n        $entry3->createdAt = new \\DateTimeImmutable('now - 2 minutes');\n        $post = $this->createPost('TEST body');\n        $post->createdAt = new \\DateTimeImmutable('now - 3 minutes');\n        $post2 = $this->createPost('Body with a TEST text');\n        $post2->createdAt = new \\DateTimeImmutable('now - 4 minutes');\n        $post3 = $this->createPost('Some test post');\n        $post3->createdAt = new \\DateTimeImmutable('now - 5 minutes');\n        $this->entityManager->flush();\n\n        $this->client->jsonRequest('GET', '/api/combined?sort=newest', server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $data = self::getJsonResponse($this->client);\n        self::assertIsArray($data);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $data);\n\n        self::assertIsArray($data['items']);\n        self::assertCount(6, $data['items']);\n        self::assertEquals($entry->getId(), $data['items'][0]['entry']['entryId']);\n        self::assertEquals($entry2->getId(), $data['items'][1]['entry']['entryId']);\n        self::assertEquals($entry3->getId(), $data['items'][2]['entry']['entryId']);\n        self::assertEquals($post->getId(), $data['items'][3]['post']['postId']);\n        self::assertEquals($post2->getId(), $data['items'][4]['post']['postId']);\n        self::assertEquals($post3->getId(), $data['items'][5]['post']['postId']);\n\n        $this->activateFilterList($token);\n\n        $this->client->jsonRequest('GET', '/api/combined?sortBy=newest', server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $data = self::getJsonResponse($this->client);\n        self::assertIsArray($data);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $data);\n\n        self::assertIsArray($data['items']);\n        self::assertCount(2, $data['items']);\n        self::assertEquals($entry2->getId(), $data['items'][0]['entry']['entryId']);\n        self::assertEquals($post3->getId(), $data['items'][1]['post']['postId']);\n    }\n\n    public function testFilteredEntryComments(): void\n    {\n        $token = $this->getListUserToken();\n\n        $entry = $this->getEntryByTitle('Some Entry');\n        $comment1 = $this->createEntryComment('Some normal comment', $entry);\n        $comment1->createdAt = new \\DateTimeImmutable('now - 1 minutes');\n        $subComment1 = $this->createEntryComment('Some sub comment', $entry, parent: $comment1);\n        $subComment1->createdAt = new \\DateTimeImmutable('now - 1 minutes');\n        $subComment2 = $this->createEntryComment('Some Cringe sub comment', $entry, parent: $comment1);\n        $subComment2->createdAt = new \\DateTimeImmutable('now - 2 minutes');\n        $subComment3 = $this->createEntryComment('Some other Cringe sub comment', $entry, parent: $comment1);\n        $subComment3->createdAt = new \\DateTimeImmutable('now - 3 minutes');\n        $comment2 = $this->createEntryComment('Some cringe comment', $entry);\n        $comment2->createdAt = new \\DateTimeImmutable('now - 2 minutes');\n        $comment3 = $this->createEntryComment('Some other Cringe comment', $entry);\n        $comment3->createdAt = new \\DateTimeImmutable('now - 3 minutes');\n        $this->entityManager->flush();\n\n        $this->deactivateFilterList($token);\n\n        $this->client->request('GET', \"/api/entry/{$entry->getId()}/comments?sortBy=newest\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(3, $jsonData['items']);\n        self::assertEquals($comment1->getId(), $jsonData['items'][0]['commentId']);\n        self::assertEquals($comment2->getId(), $jsonData['items'][1]['commentId']);\n        self::assertEquals($comment3->getId(), $jsonData['items'][2]['commentId']);\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(3, $jsonData['items'][0]['children']);\n        self::assertEquals($subComment1->getId(), $jsonData['items'][0]['children'][0]['commentId']);\n        self::assertEquals($subComment2->getId(), $jsonData['items'][0]['children'][1]['commentId']);\n        self::assertEquals($subComment3->getId(), $jsonData['items'][0]['children'][2]['commentId']);\n\n        $this->activateFilterList($token);\n\n        $this->client->request('GET', \"/api/entry/{$entry->getId()}/comments?sortBy=newest\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(1, $jsonData['items']);\n        self::assertEquals($comment1->getId(), $jsonData['items'][0]['commentId']);\n        self::assertCount(1, $jsonData['items'][0]['children']);\n        self::assertEquals($subComment1->getId(), $jsonData['items'][0]['children'][0]['commentId']);\n    }\n\n    public function testFilteredEntryCommentsExact(): void\n    {\n        $token = $this->getListUserToken();\n\n        $entry = $this->getEntryByTitle('Some Entry');\n        $comment1 = $this->createEntryComment('Some normal comment', $entry);\n        $comment1->createdAt = new \\DateTimeImmutable('now - 1 minutes');\n        $subComment1 = $this->createEntryComment('Some sub comment', $entry, parent: $comment1);\n        $subComment1->createdAt = new \\DateTimeImmutable('now - 1 minutes');\n        $subComment2 = $this->createEntryComment('Some TEST sub comment', $entry, parent: $comment1);\n        $subComment2->createdAt = new \\DateTimeImmutable('now - 2 minutes');\n        $subComment3 = $this->createEntryComment('Some other test sub comment', $entry, parent: $comment1);\n        $subComment3->createdAt = new \\DateTimeImmutable('now - 3 minutes');\n        $comment2 = $this->createEntryComment('Some TEST comment', $entry);\n        $comment2->createdAt = new \\DateTimeImmutable('now - 2 minutes');\n        $comment3 = $this->createEntryComment('Some other test comment', $entry);\n        $comment3->createdAt = new \\DateTimeImmutable('now - 3 minutes');\n        $this->entityManager->flush();\n\n        $this->deactivateFilterList($token);\n\n        $this->client->request('GET', \"/api/entry/{$entry->getId()}/comments?sortBy=newest\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(3, $jsonData['items']);\n        self::assertEquals($comment1->getId(), $jsonData['items'][0]['commentId']);\n        self::assertEquals($comment2->getId(), $jsonData['items'][1]['commentId']);\n        self::assertEquals($comment3->getId(), $jsonData['items'][2]['commentId']);\n        self::assertCount(3, $jsonData['items'][0]['children']);\n        self::assertEquals($subComment1->getId(), $jsonData['items'][0]['children'][0]['commentId']);\n        self::assertEquals($subComment2->getId(), $jsonData['items'][0]['children'][1]['commentId']);\n        self::assertEquals($subComment3->getId(), $jsonData['items'][0]['children'][2]['commentId']);\n\n        $this->activateFilterList($token);\n\n        $this->client->request('GET', \"/api/entry/{$entry->getId()}/comments?sortBy=newest\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(2, $jsonData['items']);\n        self::assertEquals($comment1->getId(), $jsonData['items'][0]['commentId']);\n        self::assertCount(2, $jsonData['items'][0]['children']);\n        self::assertEquals($subComment1->getId(), $jsonData['items'][0]['children'][0]['commentId']);\n        self::assertEquals($subComment3->getId(), $jsonData['items'][0]['children'][1]['commentId']);\n    }\n\n    public function testFilteredPostComments(): void\n    {\n        $token = $this->getListUserToken();\n\n        $post = $this->createPost('Some Post');\n        $comment1 = $this->createPostComment('Some normal comment', $post);\n        $comment1->createdAt = new \\DateTimeImmutable('now - 1 minutes');\n        $subComment1 = $this->createPostComment('Some sub comment', $post, parent: $comment1);\n        $subComment1->createdAt = new \\DateTimeImmutable('now - 1 minutes');\n        $subComment2 = $this->createPostComment('Some Cringe sub comment', $post, parent: $comment1);\n        $subComment2->createdAt = new \\DateTimeImmutable('now - 2 minutes');\n        $subComment3 = $this->createPostComment('Some other Cringe sub comment', $post, parent: $comment1);\n        $subComment3->createdAt = new \\DateTimeImmutable('now - 3 minutes');\n        $comment2 = $this->createPostComment('Some cringe comment', $post);\n        $comment2->createdAt = new \\DateTimeImmutable('now - 2 minutes');\n        $comment3 = $this->createPostComment('Some other Cringe comment', $post);\n        $comment3->createdAt = new \\DateTimeImmutable('now - 3 minutes');\n        $this->entityManager->flush();\n\n        $this->deactivateFilterList($token);\n\n        $this->client->request('GET', \"/api/posts/{$post->getId()}/comments?sortBy=newest\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(3, $jsonData['items']);\n        self::assertEquals($comment1->getId(), $jsonData['items'][0]['commentId']);\n        self::assertEquals($comment2->getId(), $jsonData['items'][1]['commentId']);\n        self::assertEquals($comment3->getId(), $jsonData['items'][2]['commentId']);\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(3, $jsonData['items'][0]['children']);\n        self::assertEquals($subComment1->getId(), $jsonData['items'][0]['children'][0]['commentId']);\n        self::assertEquals($subComment2->getId(), $jsonData['items'][0]['children'][1]['commentId']);\n        self::assertEquals($subComment3->getId(), $jsonData['items'][0]['children'][2]['commentId']);\n\n        $this->activateFilterList($token);\n\n        $this->client->request('GET', \"/api/posts/{$post->getId()}/comments?sortBy=newest\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(1, $jsonData['items']);\n        self::assertEquals($comment1->getId(), $jsonData['items'][0]['commentId']);\n        self::assertCount(1, $jsonData['items'][0]['children']);\n        self::assertEquals($subComment1->getId(), $jsonData['items'][0]['children'][0]['commentId']);\n    }\n\n    public function testFilteredPostCommentsExact(): void\n    {\n        $token = $this->getListUserToken();\n\n        $post = $this->createPost('Some Post');\n        $comment1 = $this->createPostComment('Some normal comment', $post);\n        $comment1->createdAt = new \\DateTimeImmutable('now - 1 minutes');\n        $subComment1 = $this->createPostComment('Some sub comment', $post, parent: $comment1);\n        $subComment1->createdAt = new \\DateTimeImmutable('now - 1 minutes');\n        $subComment2 = $this->createPostComment('Some TEST sub comment', $post, parent: $comment1);\n        $subComment2->createdAt = new \\DateTimeImmutable('now - 2 minutes');\n        $subComment3 = $this->createPostComment('Some other test sub comment', $post, parent: $comment1);\n        $subComment3->createdAt = new \\DateTimeImmutable('now - 3 minutes');\n        $comment2 = $this->createPostComment('Some TEST comment', $post);\n        $comment2->createdAt = new \\DateTimeImmutable('now - 2 minutes');\n        $comment3 = $this->createPostComment('Some other test comment', $post);\n        $comment3->createdAt = new \\DateTimeImmutable('now - 3 minutes');\n        $this->entityManager->flush();\n\n        $this->deactivateFilterList($token);\n\n        $this->client->request('GET', \"/api/posts/{$post->getId()}/comments?sortBy=newest\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(3, $jsonData['items']);\n        self::assertEquals($comment1->getId(), $jsonData['items'][0]['commentId']);\n        self::assertEquals($comment2->getId(), $jsonData['items'][1]['commentId']);\n        self::assertEquals($comment3->getId(), $jsonData['items'][2]['commentId']);\n        self::assertCount(3, $jsonData['items'][0]['children']);\n        self::assertEquals($subComment1->getId(), $jsonData['items'][0]['children'][0]['commentId']);\n        self::assertEquals($subComment2->getId(), $jsonData['items'][0]['children'][1]['commentId']);\n        self::assertEquals($subComment3->getId(), $jsonData['items'][0]['children'][2]['commentId']);\n\n        $this->activateFilterList($token);\n\n        $this->client->request('GET', \"/api/posts/{$post->getId()}/comments?sortBy=newest\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(2, $jsonData['items']);\n        self::assertEquals($comment1->getId(), $jsonData['items'][0]['commentId']);\n        self::assertCount(2, $jsonData['items'][0]['children']);\n        self::assertEquals($subComment1->getId(), $jsonData['items'][0]['children'][0]['commentId']);\n        self::assertEquals($subComment3->getId(), $jsonData['items'][0]['children'][1]['commentId']);\n    }\n\n    public function testFilteredProfile(): void\n    {\n        $token = $this->getListUserToken();\n        $otherUser = $this->userRepository->findOneByUsername('otherUser');\n        $magazine = $this->getMagazineByName('someMag');\n        $entry = $this->createEntry('Some Entry', $magazine, $otherUser);\n        $entry->createdAt = new \\DateTimeImmutable('now - 10 minutes');\n        $entryComment1 = $this->createEntryComment('Some comment', $entry, user: $otherUser);\n        $entryComment1->createdAt = new \\DateTimeImmutable('now - 9 minutes');\n        $entryComment2 = $this->createEntryComment('Some cringe comment', $entry, user: $otherUser);\n        $entryComment2->createdAt = new \\DateTimeImmutable('now - 8 minutes');\n        $entryComment3 = $this->createEntryComment('Some Cringe comment', $entry, user: $otherUser);\n        $entryComment3->createdAt = new \\DateTimeImmutable('now - 7 minutes');\n        $entry2 = $this->getEntryByTitle('Some cringe Entry', user: $otherUser);\n        $entry2->createdAt = new \\DateTimeImmutable('now - 6 minutes');\n        $post = $this->createPost('Some Post', user: $otherUser);\n        $post->createdAt = new \\DateTimeImmutable('now - 5 minutes');\n        $postComment1 = $this->createPostComment('Some comment', $post, user: $otherUser);\n        $postComment1->createdAt = new \\DateTimeImmutable('now - 4 minutes');\n        $postComment2 = $this->createPostComment('Some cringe comment', $post, user: $otherUser);\n        $postComment2->createdAt = new \\DateTimeImmutable('now - 3 minutes');\n        $postComment3 = $this->createPostComment('Some Cringe comment', $post, user: $otherUser);\n        $postComment3->createdAt = new \\DateTimeImmutable('now - 2 minutes');\n        $post2 = $this->createPost('Some Cringe Post', user: $otherUser);\n        $post2->createdAt = new \\DateTimeImmutable('now - 1 minutes');\n\n        $this->entityManager->flush();\n\n        $this->deactivateFilterList($token);\n\n        $this->client->jsonRequest('GET', \"/api/users/{$otherUser->getId()}/content\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n\n        $jsonData = self::getJsonResponse($this->client);\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(10, $jsonData['items']);\n        self::assertEquals($entry->getId(), $jsonData['items'][9]['entry']['entryId']);\n        self::assertEquals($entryComment1->getId(), $jsonData['items'][8]['entryComment']['commentId']);\n        self::assertEquals($entryComment2->getId(), $jsonData['items'][7]['entryComment']['commentId']);\n        self::assertEquals($entryComment3->getId(), $jsonData['items'][6]['entryComment']['commentId']);\n        self::assertEquals($entry2->getId(), $jsonData['items'][5]['entry']['entryId']);\n        self::assertEquals($post->getId(), $jsonData['items'][4]['post']['postId']);\n        self::assertEquals($postComment1->getId(), $jsonData['items'][3]['postComment']['commentId']);\n        self::assertEquals($postComment2->getId(), $jsonData['items'][2]['postComment']['commentId']);\n        self::assertEquals($postComment3->getId(), $jsonData['items'][1]['postComment']['commentId']);\n        self::assertEquals($post2->getId(), $jsonData['items'][0]['post']['postId']);\n\n        $this->activateFilterList($token);\n\n        $this->client->jsonRequest('GET', \"/api/users/{$otherUser->getId()}/content\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n\n        $jsonData = self::getJsonResponse($this->client);\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(4, $jsonData['items']);\n        self::assertEquals($entry->getId(), $jsonData['items'][3]['entry']['entryId']);\n        self::assertEquals($entryComment1->getId(), $jsonData['items'][2]['entryComment']['commentId']);\n        self::assertEquals($post->getId(), $jsonData['items'][1]['post']['postId']);\n        self::assertEquals($postComment1->getId(), $jsonData['items'][0]['postComment']['commentId']);\n    }\n\n    public function testFilteredProfileExact(): void\n    {\n        $token = $this->getListUserToken();\n        $otherUser = $this->userRepository->findOneByUsername('otherUser');\n        $magazine = $this->getMagazineByName('someMag');\n        $entry = $this->createEntry('Some Entry', $magazine, $otherUser);\n        $entry->createdAt = new \\DateTimeImmutable('now - 10 minutes');\n        $entryComment1 = $this->createEntryComment('Some comment', $entry, user: $otherUser);\n        $entryComment1->createdAt = new \\DateTimeImmutable('now - 9 minutes');\n        $entryComment2 = $this->createEntryComment('Some TEST comment', $entry, user: $otherUser);\n        $entryComment2->createdAt = new \\DateTimeImmutable('now - 8 minutes');\n        $entryComment3 = $this->createEntryComment('Some test comment', $entry, user: $otherUser);\n        $entryComment3->createdAt = new \\DateTimeImmutable('now - 7 minutes');\n        $entry2 = $this->getEntryByTitle('Some TEST Entry', user: $otherUser);\n        $entry2->createdAt = new \\DateTimeImmutable('now - 6 minutes');\n        $post = $this->createPost('Some Post', user: $otherUser);\n        $post->createdAt = new \\DateTimeImmutable('now - 5 minutes');\n        $postComment1 = $this->createPostComment('Some comment', $post, user: $otherUser);\n        $postComment1->createdAt = new \\DateTimeImmutable('now - 4 minutes');\n        $postComment2 = $this->createPostComment('Some TEST comment', $post, user: $otherUser);\n        $postComment2->createdAt = new \\DateTimeImmutable('now - 3 minutes');\n        $postComment3 = $this->createPostComment('Some test comment', $post, user: $otherUser);\n        $postComment3->createdAt = new \\DateTimeImmutable('now - 2 minutes');\n        $post2 = $this->createPost('Some TEST Post', user: $otherUser);\n        $post2->createdAt = new \\DateTimeImmutable('now - 1 minutes');\n\n        $this->entityManager->flush();\n\n        $this->deactivateFilterList($token);\n\n        $this->client->jsonRequest('GET', \"/api/users/{$otherUser->getId()}/content\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n\n        $jsonData = self::getJsonResponse($this->client);\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(10, $jsonData['items']);\n        self::assertEquals($entry->getId(), $jsonData['items'][9]['entry']['entryId']);\n        self::assertEquals($entryComment1->getId(), $jsonData['items'][8]['entryComment']['commentId']);\n        self::assertEquals($entryComment2->getId(), $jsonData['items'][7]['entryComment']['commentId']);\n        self::assertEquals($entryComment3->getId(), $jsonData['items'][6]['entryComment']['commentId']);\n        self::assertEquals($entry2->getId(), $jsonData['items'][5]['entry']['entryId']);\n        self::assertEquals($post->getId(), $jsonData['items'][4]['post']['postId']);\n        self::assertEquals($postComment1->getId(), $jsonData['items'][3]['postComment']['commentId']);\n        self::assertEquals($postComment2->getId(), $jsonData['items'][2]['postComment']['commentId']);\n        self::assertEquals($postComment3->getId(), $jsonData['items'][1]['postComment']['commentId']);\n        self::assertEquals($post2->getId(), $jsonData['items'][0]['post']['postId']);\n\n        $this->activateFilterList($token);\n\n        $this->client->jsonRequest('GET', \"/api/users/{$otherUser->getId()}/content\", server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n\n        $jsonData = self::getJsonResponse($this->client);\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(6, $jsonData['items']);\n        self::assertEquals($entry->getId(), $jsonData['items'][5]['entry']['entryId']);\n        self::assertEquals($entryComment1->getId(), $jsonData['items'][4]['entryComment']['commentId']);\n        self::assertEquals($entryComment3->getId(), $jsonData['items'][3]['entryComment']['commentId']);\n        self::assertEquals($post->getId(), $jsonData['items'][2]['post']['postId']);\n        self::assertEquals($postComment1->getId(), $jsonData['items'][1]['postComment']['commentId']);\n        self::assertEquals($postComment3->getId(), $jsonData['items'][0]['postComment']['commentId']);\n    }\n\n    public function setUp(): void\n    {\n        parent::setUp();\n\n        $this->listUser = $this->getUserByUsername('listOwner');\n        $this->otherUser = $this->getUserByUsername('otherUser');\n        $this->list = new UserFilterList();\n        $this->list->name = 'Test List';\n        $this->list->user = $this->listUser;\n        $this->list->expirationDate = null;\n        $this->list->feeds = true;\n        $this->list->profile = true;\n        $this->list->comments = true;\n        $this->list->words = [\n            [\n                'exactMatch' => true,\n                'word' => 'TEST',\n            ],\n            [\n                'exactMatch' => false,\n                'word' => 'Cringe',\n            ],\n        ];\n        $this->entityManager->persist($this->list);\n        $this->entityManager->flush();\n    }\n\n    private function deactivateFilterList(string $token): void\n    {\n        $dto = $this->getFilterListDto();\n        $this->client->jsonRequest('PUT', '/api/users/filterLists/'.$this->list->getId(), $dto, server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n    }\n\n    private function activateFilterList(string $token): void\n    {\n        $dto = $this->getFilterListDto();\n        $dto['expirationDate'] = null;\n        $this->client->jsonRequest('PUT', '/api/users/filterLists/'.$this->list->getId(), $dto, server: ['HTTP_AUTHORIZATION' => $token]);\n        self::assertResponseIsSuccessful();\n    }\n\n    private function getFilterListDto(): array\n    {\n        return [\n            'name' => $this->list->name,\n            'expirationDate' => (new \\DateTimeImmutable('now - 1 day'))->format(DATE_ATOM),\n            'feeds' => true,\n            'profile' => true,\n            'comments' => true,\n            'words' => $this->list->words,\n        ];\n    }\n\n    private function getListUserToken(): string\n    {\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->listUser);\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'user:profile:edit');\n        $token = $codes['token_type'].' '.$codes['access_token'];\n\n        return $token;\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/User/UserFollowApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\User;\n\nuse App\\Tests\\WebTestCase;\nuse PHPUnit\\Framework\\Attributes\\Group;\n\nclass UserFollowApiTest extends WebTestCase\n{\n    public function testApiCannotFollowUserWithoutScope(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $testUser = $this->getUserByUsername('UserWithoutAbout');\n        $followedUser = $this->getUserByUsername('JohnDoe');\n        $this->client->loginUser($testUser);\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n\n        $this->client->request('PUT', '/api/users/'.(string) $followedUser->getId().'/follow', server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCannotUnfollowUserWithoutScope(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $testUser = $this->getUserByUsername('UserWithoutAbout');\n        $followedUser = $this->getUserByUsername('JohnDoe');\n        $this->client->loginUser($testUser);\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n\n        $this->client->request('PUT', '/api/users/'.(string) $followedUser->getId().'/unfollow', server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    #[Group(name: 'NonThreadSafe')]\n    public function testApiCanFollowUser(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $testUser = $this->getUserByUsername('UserWithoutAbout');\n        $followedUser = $this->getUserByUsername('JohnDoe');\n        $this->client->loginUser($testUser);\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:follow user:block');\n\n        $this->client->request('PUT', '/api/users/'.(string) $followedUser->getId().'/follow', server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]);\n        self::assertResponseIsSuccessful();\n\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayHasKey('userId', $jsonData);\n        self::assertArrayHasKey('username', $jsonData);\n        self::assertArrayHasKey('about', $jsonData);\n        self::assertArrayHasKey('avatar', $jsonData);\n        self::assertArrayHasKey('cover', $jsonData);\n        self::assertArrayNotHasKey('lastActive', $jsonData);\n        self::assertArrayHasKey('createdAt', $jsonData);\n        self::assertArrayHasKey('followersCount', $jsonData);\n        self::assertArrayHasKey('apId', $jsonData);\n        self::assertArrayHasKey('apProfileId', $jsonData);\n        self::assertArrayHasKey('isBot', $jsonData);\n        self::assertArrayHasKey('isFollowedByUser', $jsonData);\n        self::assertArrayHasKey('isFollowerOfUser', $jsonData);\n        self::assertArrayHasKey('isBlockedByUser', $jsonData);\n\n        self::assertSame(1, $jsonData['followersCount']);\n        self::assertTrue($jsonData['isFollowedByUser']);\n        self::assertFalse($jsonData['isFollowerOfUser']);\n        self::assertFalse($jsonData['isBlockedByUser']);\n    }\n\n    public function testApiCanUnfollowUser(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $testUser = $this->getUserByUsername('UserWithoutAbout');\n        $followedUser = $this->getUserByUsername('JohnDoe');\n\n        $testUser->follow($followedUser);\n\n        $manager = $this->entityManager;\n\n        $manager->persist($testUser);\n        $manager->flush();\n\n        $this->client->loginUser($testUser);\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:follow user:block');\n\n        $this->client->request('PUT', '/api/users/'.(string) $followedUser->getId().'/unfollow', server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]);\n        self::assertResponseIsSuccessful();\n\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayHasKey('userId', $jsonData);\n        self::assertArrayHasKey('username', $jsonData);\n        self::assertArrayHasKey('about', $jsonData);\n        self::assertArrayHasKey('avatar', $jsonData);\n        self::assertArrayHasKey('cover', $jsonData);\n        self::assertArrayNotHasKey('lastActive', $jsonData);\n        self::assertArrayHasKey('createdAt', $jsonData);\n        self::assertArrayHasKey('followersCount', $jsonData);\n        self::assertArrayHasKey('apId', $jsonData);\n        self::assertArrayHasKey('apProfileId', $jsonData);\n        self::assertArrayHasKey('isBot', $jsonData);\n        self::assertArrayHasKey('isFollowedByUser', $jsonData);\n        self::assertArrayHasKey('isFollowerOfUser', $jsonData);\n        self::assertArrayHasKey('isBlockedByUser', $jsonData);\n\n        self::assertSame(0, $jsonData['followersCount']);\n        self::assertFalse($jsonData['isFollowedByUser']);\n        self::assertFalse($jsonData['isFollowerOfUser']);\n        self::assertFalse($jsonData['isBlockedByUser']);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/User/UserModeratesApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\User;\n\nuse App\\DTO\\ModeratorDto;\nuse App\\Tests\\WebTestCase;\n\nclass UserModeratesApiTest extends WebTestCase\n{\n    public function testApiCanRetrieveUserModeratedMagazines()\n    {\n        $owner = $this->getUserByUsername('JohnDoe');\n        $user = $this->getUserByUsername('user');\n        $magazine1 = $this->getMagazineByName('m 1');\n        $magazine2 = $this->getMagazineByName('m 2');\n        $this->getMagazineByName('dummy');\n\n        $this->magazineManager->addModerator(new ModeratorDto($magazine1, $user, $owner));\n        $this->magazineManager->addModerator(new ModeratorDto($magazine2, $user, $owner));\n\n        $this->client->request('GET', \"/api/users/{$user->getId()}/moderatedMagazines\");\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertCount(2, $jsonData['items']);\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(2, $jsonData['pagination']['count']);\n\n        self::assertTrue(array_all($jsonData['items'], function ($item) use ($magazine1, $magazine2) {\n            return $item['magazineId'] === $magazine1->getId() || $item['magazineId'] === $magazine2->getId();\n        }));\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/User/UserRetrieveApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\User;\n\nuse App\\Repository\\UserRepository;\nuse App\\Tests\\WebTestCase;\n\nclass UserRetrieveApiTest extends WebTestCase\n{\n    public const USER_SETTINGS_KEYS = [\n        'notifyOnNewEntry',\n        'notifyOnNewEntryReply',\n        'notifyOnNewEntryCommentReply',\n        'notifyOnNewPost',\n        'notifyOnNewPostReply',\n        'notifyOnNewPostCommentReply',\n        'hideAdult',\n        'showProfileSubscriptions',\n        'showProfileFollowings',\n        'addMentionsEntries',\n        'addMentionsPosts',\n        'homepage',\n        'frontDefaultSort',\n        'commentDefaultSort',\n        'featuredMagazines',\n        'preferredLanguages',\n        'customCss',\n        'ignoreMagazinesCustomCss',\n        'notifyOnUserSignup',\n        'directMessageSetting',\n        'frontDefaultContent',\n        'discoverable',\n        'indexable',\n    ];\n    public const NUM_USERS = 10;\n\n    public function testApiCanRetrieveUsersWithAboutAnonymous(): void\n    {\n        $users = [];\n        for ($i = 0; $i < self::NUM_USERS; ++$i) {\n            $users[] = $this->getUserByUsername('user'.(string) ($i + 1), about: 'Test user '.(string) ($i + 1));\n        }\n        $this->getUserByUsername('userWithoutAbout');\n\n        $this->client->request('GET', '/api/users?withAbout=1');\n        self::assertResponseIsSuccessful();\n\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n\n        self::assertSame(self::NUM_USERS, $jsonData['pagination']['count']);\n        self::assertSame(1, $jsonData['pagination']['currentPage']);\n        self::assertSame(1, $jsonData['pagination']['maxPage']);\n        // Default perPage count should be used since no perPage value was specified\n        self::assertSame(UserRepository::PER_PAGE, $jsonData['pagination']['perPage']);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertSame(self::NUM_USERS, \\count($jsonData['items']));\n    }\n\n    public function testApiCanRetrieveAdminsAnonymous(): void\n    {\n        $users = [];\n        for ($i = 0; $i < self::NUM_USERS; ++$i) {\n            $users[] = $this->getUserByUsername('admin'.(string) ($i + 1), isAdmin: true);\n        }\n\n        $this->client->request('GET', '/api/users/admins');\n        self::assertResponseIsSuccessful();\n\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertIsArray($jsonData['items']);\n        self::assertSame(self::NUM_USERS, \\count($jsonData['items']));\n    }\n\n    public function testApiCanRetrieveModeratorsAnonymous(): void\n    {\n        $users = [];\n        for ($i = 0; $i < self::NUM_USERS; ++$i) {\n            $users[] = $this->getUserByUsername('moderator'.(string) ($i + 1), isModerator: true);\n        }\n\n        $this->client->request('GET', '/api/users/moderators');\n        self::assertResponseIsSuccessful();\n\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertIsArray($jsonData['items']);\n        self::assertSame(self::NUM_USERS, \\count($jsonData['items']));\n    }\n\n    public function testApiCanRetrieveUsersWithAbout(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $this->client->loginUser($this->getUserByUsername('UserWithoutAbout'));\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n\n        $users = [];\n        for ($i = 0; $i < self::NUM_USERS; ++$i) {\n            $users[] = $this->getUserByUsername('user'.(string) ($i + 1), about: 'Test user '.(string) ($i + 1));\n        }\n\n        $this->client->request('GET', '/api/users?withAbout=1', server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]);\n        self::assertResponseIsSuccessful();\n\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n        self::assertSame(self::NUM_USERS, $jsonData['pagination']['count']);\n    }\n\n    public function testApiCanRetrieveUserByIdAnonymous(): void\n    {\n        $testUser = $this->getUserByUsername('UserWithoutAbout');\n\n        $this->client->request('GET', '/api/users/'.(string) $testUser->getId());\n        self::assertResponseIsSuccessful();\n\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::USER_RESPONSE_KEYS, $jsonData);\n\n        self::assertSame($testUser->getId(), $jsonData['userId']);\n        self::assertSame('UserWithoutAbout', $jsonData['username']);\n        self::assertNull($jsonData['about']);\n        self::assertNotNull($jsonData['createdAt']);\n        self::assertFalse($jsonData['isBot']);\n        self::assertNull($jsonData['apId']);\n        // Follow and block scopes not assigned, so these flags should be null\n        self::assertNull($jsonData['isFollowedByUser']);\n        self::assertNull($jsonData['isFollowerOfUser']);\n        self::assertNull($jsonData['isBlockedByUser']);\n    }\n\n    public function testApiCanRetrieveUserById(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $testUser = $this->getUserByUsername('UserWithoutAbout');\n        $this->client->loginUser($testUser);\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n\n        $this->client->request('GET', '/api/users/'.(string) $testUser->getId(), server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]);\n        self::assertResponseIsSuccessful();\n\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::USER_RESPONSE_KEYS, $jsonData);\n\n        self::assertSame($testUser->getId(), $jsonData['userId']);\n        self::assertSame('UserWithoutAbout', $jsonData['username']);\n        self::assertNull($jsonData['about']);\n        self::assertNotNull($jsonData['createdAt']);\n        self::assertFalse($jsonData['isBot']);\n        self::assertNull($jsonData['apId']);\n        // Follow and block scopes not assigned, so these flags should be null\n        self::assertNull($jsonData['isFollowedByUser']);\n        self::assertNull($jsonData['isFollowerOfUser']);\n        self::assertNull($jsonData['isBlockedByUser']);\n    }\n\n    public function testApiCanRetrieveUserByNameAnonymous(): void\n    {\n        $testUser = $this->getUserByUsername('UserWithoutAbout');\n\n        $this->client->request('GET', '/api/users/name/'.$testUser->getUsername());\n        self::assertResponseIsSuccessful();\n\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::USER_RESPONSE_KEYS, $jsonData);\n\n        self::assertSame($testUser->getId(), $jsonData['userId']);\n        self::assertSame('UserWithoutAbout', $jsonData['username']);\n        self::assertNull($jsonData['about']);\n        self::assertNotNull($jsonData['createdAt']);\n        self::assertFalse($jsonData['isBot']);\n        self::assertNull($jsonData['apId']);\n        // Follow and block scopes not assigned, so these flags should be null\n        self::assertNull($jsonData['isFollowedByUser']);\n        self::assertNull($jsonData['isFollowerOfUser']);\n        self::assertNull($jsonData['isBlockedByUser']);\n    }\n\n    public function testApiCanRetrieveUserByName(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $testUser = $this->getUserByUsername('UserWithoutAbout');\n        $this->client->loginUser($testUser);\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n\n        $this->client->request('GET', '/api/users/name/'.$testUser->getUsername(), server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]);\n        self::assertResponseIsSuccessful();\n\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::USER_RESPONSE_KEYS, $jsonData);\n\n        self::assertSame($testUser->getId(), $jsonData['userId']);\n        self::assertSame('UserWithoutAbout', $jsonData['username']);\n        self::assertNull($jsonData['about']);\n        self::assertNotNull($jsonData['createdAt']);\n        self::assertFalse($jsonData['isBot']);\n        self::assertNull($jsonData['apId']);\n        // Follow and block scopes not assigned, so these flags should be null\n        self::assertNull($jsonData['isFollowedByUser']);\n        self::assertNull($jsonData['isFollowerOfUser']);\n        self::assertNull($jsonData['isBlockedByUser']);\n    }\n\n    public function testApiCannotRetrieveCurrentUserWithoutScope(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $testUser = $this->getUserByUsername('UserWithoutAbout');\n        $this->client->loginUser($testUser);\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n\n        $this->client->request('GET', '/api/users/me', server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanRetrieveCurrentUser(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $testUser = $this->getUserByUsername('UserWithoutAbout');\n        $this->client->loginUser($testUser);\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:profile:read');\n\n        $this->client->request('GET', '/api/users/me', server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]);\n        self::assertResponseIsSuccessful();\n\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::USER_RESPONSE_KEYS, $jsonData);\n\n        self::assertSame($testUser->getId(), $jsonData['userId']);\n        self::assertSame('UserWithoutAbout', $jsonData['username']);\n        self::assertNull($jsonData['about']);\n        self::assertNotNull($jsonData['createdAt']);\n        self::assertFalse($jsonData['isBot']);\n        self::assertNull($jsonData['apId']);\n        // Follow and block scopes not assigned, so these flags should be null\n        self::assertNull($jsonData['isFollowedByUser']);\n        self::assertNull($jsonData['isFollowerOfUser']);\n        self::assertNull($jsonData['isBlockedByUser']);\n    }\n\n    public function testApiCanRetrieveUserFlagsWithScopes(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $testUser = $this->getUserByUsername('UserWithoutAbout');\n        $follower = $this->getUserByUsername('follower');\n\n        $follower->follow($testUser);\n\n        $manager = $this->entityManager;\n\n        $manager->persist($follower);\n        $manager->flush();\n\n        $this->client->loginUser($testUser);\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:follow user:block');\n\n        $this->client->request('GET', '/api/users/'.(string) $follower->getId(), server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]);\n        self::assertResponseIsSuccessful();\n\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::USER_RESPONSE_KEYS, $jsonData);\n        // Follow and block scopes assigned, so these flags should not be null\n        self::assertFalse($jsonData['isFollowedByUser']);\n        self::assertTrue($jsonData['isFollowerOfUser']);\n        self::assertFalse($jsonData['isBlockedByUser']);\n    }\n\n    public function testApiCanGetBlockedUsers(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $testUser = $this->getUserByUsername('UserWithoutAbout');\n        $blockedUser = $this->getUserByUsername('JohnDoe');\n\n        $testUser->block($blockedUser);\n\n        $manager = $this->entityManager;\n\n        $manager->persist($testUser);\n        $manager->flush();\n\n        $this->client->loginUser($testUser);\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:follow user:block');\n\n        $this->client->request('GET', '/api/users/blocked', server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]);\n        self::assertResponseIsSuccessful();\n\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n\n        self::assertSame(1, $jsonData['pagination']['count']);\n        self::assertSame(1, \\count($jsonData['items']));\n        self::assertArrayKeysMatch(self::USER_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertSame($blockedUser->getId(), $jsonData['items'][0]['userId']);\n    }\n\n    public function testApiCannotGetFollowedUsersWithoutScope(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $testUser = $this->getUserByUsername('UserWithoutAbout');\n\n        $this->client->loginUser($testUser);\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n\n        $this->client->request('GET', '/api/users/followed', server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCannotGetFollowersWithoutScope(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $testUser = $this->getUserByUsername('UserWithoutAbout');\n\n        $this->client->loginUser($testUser);\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n\n        $this->client->request('GET', '/api/users/followers', server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanGetFollowedUsers(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $testUser = $this->getUserByUsername('UserWithoutAbout');\n        $followedUser = $this->getUserByUsername('JohnDoe');\n\n        $testUser->follow($followedUser);\n\n        $manager = $this->entityManager;\n\n        $manager->persist($testUser);\n        $manager->flush();\n\n        $this->client->loginUser($testUser);\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:follow user:block');\n\n        $this->client->request('GET', '/api/users/followed', server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]);\n        self::assertResponseIsSuccessful();\n\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n\n        self::assertSame(1, $jsonData['pagination']['count']);\n        self::assertSame(1, \\count($jsonData['items']));\n        self::assertArrayKeysMatch(self::USER_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertSame($followedUser->getId(), $jsonData['items'][0]['userId']);\n    }\n\n    public function testApiCanGetFollowers(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $testUser = $this->getUserByUsername('UserWithoutAbout');\n        $followingUser = $this->getUserByUsername('JohnDoe');\n\n        $followingUser->follow($testUser);\n\n        $manager = $this->entityManager;\n\n        $manager->persist($testUser);\n        $manager->flush();\n\n        $this->client->loginUser($testUser);\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:follow user:block');\n\n        $this->client->request('GET', '/api/users/followers', server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]);\n        self::assertResponseIsSuccessful();\n\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n\n        self::assertSame(1, $jsonData['pagination']['count']);\n        self::assertSame(1, \\count($jsonData['items']));\n        self::assertArrayKeysMatch(self::USER_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertSame($followingUser->getId(), $jsonData['items'][0]['userId']);\n    }\n\n    public function testApiCannotGetFollowedUsersByIdIfNotShared(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $testUser = $this->getUserByUsername('UserWithoutAbout');\n        $followedUser = $this->getUserByUsername('JohnDoe');\n\n        $testUser->follow($followedUser);\n        $testUser->showProfileFollowings = false;\n\n        $manager = $this->entityManager;\n\n        $manager->persist($testUser);\n        $manager->flush();\n\n        $this->client->loginUser($followedUser);\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:follow user:block');\n\n        $this->client->request('GET', '/api/users/'.(string) $testUser->getId().'/followed', server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanGetFollowedUsersById(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $testUser = $this->getUserByUsername('UserWithoutAbout');\n        $followedUser = $this->getUserByUsername('JohnDoe');\n\n        $testUser->follow($followedUser);\n\n        $manager = $this->entityManager;\n\n        $manager->persist($testUser);\n        $manager->flush();\n\n        $this->client->loginUser($followedUser);\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:follow user:block');\n\n        $this->client->request('GET', '/api/users/'.(string) $testUser->getId().'/followed', server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]);\n        self::assertResponseIsSuccessful();\n\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n\n        self::assertSame(1, $jsonData['pagination']['count']);\n        self::assertSame(1, \\count($jsonData['items']));\n        self::assertArrayKeysMatch(self::USER_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertSame($followedUser->getId(), $jsonData['items'][0]['userId']);\n    }\n\n    public function testApiCanGetFollowersById(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $testUser = $this->getUserByUsername('UserWithoutAbout');\n        $followingUser = $this->getUserByUsername('JohnDoe');\n\n        $followingUser->follow($testUser);\n\n        $manager = $this->entityManager;\n\n        $manager->persist($testUser);\n        $manager->flush();\n\n        $this->client->loginUser($followingUser);\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:follow user:block');\n\n        $this->client->request('GET', '/api/users/'.(string) $testUser->getId().'/followers', server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]);\n        self::assertResponseIsSuccessful();\n\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n\n        self::assertSame(1, $jsonData['pagination']['count']);\n        self::assertSame(1, \\count($jsonData['items']));\n        self::assertArrayKeysMatch(self::USER_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertSame($followingUser->getId(), $jsonData['items'][0]['userId']);\n    }\n\n    public function testApiCannotGetSettingsWithoutScope(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $testUser = $this->getUserByUsername('JohnDoe');\n\n        $this->client->loginUser($testUser);\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read');\n\n        $this->client->request('GET', '/api/users/settings', server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanGetSettings(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $testUser = $this->getUserByUsername('JohnDoe');\n\n        $this->client->loginUser($testUser);\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:profile:read');\n\n        $this->client->request('GET', '/api/users/settings', server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]);\n        self::assertResponseIsSuccessful();\n\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::USER_SETTINGS_KEYS, $jsonData);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/User/UserRetrieveOAuthConsentsApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\User;\n\nuse App\\DTO\\OAuth2ClientDto;\nuse App\\Tests\\WebTestCase;\n\nclass UserRetrieveOAuthConsentsApiTest extends WebTestCase\n{\n    public const CONSENT_RESPONSE_KEYS = [\n        'consentId',\n        'client',\n        'description',\n        'clientLogo',\n        'scopesGranted',\n        'scopesAvailable',\n    ];\n\n    public function testApiCannotGetConsentsWithoutScope(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $testUser = $this->getUserByUsername('someuser');\n\n        $this->client->loginUser($testUser);\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:follow user:block');\n\n        $this->client->request('GET', '/api/users/consents', server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanGetConsents(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $testUser = $this->getUserByUsername('someuser');\n\n        $this->client->loginUser($testUser);\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:oauth_clients:read user:follow user:block');\n\n        $this->client->request('GET', '/api/users/consents', server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]);\n        self::assertResponseIsSuccessful();\n\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['pagination']);\n        self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']);\n\n        self::assertSame(1, $jsonData['pagination']['count']);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertSame(1, \\count($jsonData['items']));\n\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(self::CONSENT_RESPONSE_KEYS, $jsonData['items'][0]);\n        self::assertEquals(\n            ['read', 'user:oauth_clients:read', 'user:follow', 'user:block'],\n            $jsonData['items'][0]['scopesGranted']\n        );\n        self::assertEquals(\n            OAuth2ClientDto::AVAILABLE_SCOPES,\n            $jsonData['items'][0]['scopesAvailable']\n        );\n        self::assertEquals('/kbin Test Client', $jsonData['items'][0]['client']);\n        self::assertEquals('An OAuth2 client for testing purposes', $jsonData['items'][0]['description']);\n        self::assertNull($jsonData['items'][0]['clientLogo']);\n    }\n\n    public function testApiCannotGetOtherUsersConsentsById(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $testUser = $this->getUserByUsername('someuser');\n        $testUser2 = $this->getUserByUsername('someuser2');\n\n        $this->client->loginUser($testUser);\n        $codes1 = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:oauth_clients:read user:follow user:block');\n\n        $this->client->loginUser($testUser2);\n        $codes2 = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:oauth_clients:read user:follow user:block');\n\n        $this->client->request('GET', '/api/users/consents', server: ['HTTP_AUTHORIZATION' => $codes1['token_type'].' '.$codes1['access_token']]);\n        self::assertResponseIsSuccessful();\n\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertSame(1, \\count($jsonData['items']));\n\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(self::CONSENT_RESPONSE_KEYS, $jsonData['items'][0]);\n\n        $this->client->request(\n            'GET', '/api/users/consents/'.(string) $jsonData['items'][0]['consentId'],\n            server: ['HTTP_AUTHORIZATION' => $codes2['token_type'].' '.$codes2['access_token']]\n        );\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanGetConsentsById(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $testUser = $this->getUserByUsername('someuser');\n\n        $this->client->loginUser($testUser);\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:oauth_clients:read user:follow user:block');\n\n        $this->client->request('GET', '/api/users/consents', server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]);\n        self::assertResponseIsSuccessful();\n\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['items']);\n        self::assertSame(1, \\count($jsonData['items']));\n\n        self::assertIsArray($jsonData['items'][0]);\n        self::assertArrayKeysMatch(self::CONSENT_RESPONSE_KEYS, $jsonData['items'][0]);\n\n        $consent = $jsonData['items'][0];\n\n        $this->client->request(\n            'GET', '/api/users/consents/'.(string) $consent['consentId'],\n            server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]\n        );\n        self::assertResponseIsSuccessful();\n\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertEquals($consent, $jsonData);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/User/UserUpdateApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\User;\n\nuse App\\DTO\\UserSettingsDto;\nuse App\\Entity\\User;\nuse App\\Enums\\EDirectMessageSettings;\nuse App\\Enums\\EFrontContentOptions;\nuse App\\Repository\\Criteria;\nuse App\\Tests\\WebTestCase;\n\nclass UserUpdateApiTest extends WebTestCase\n{\n    public function testApiCannotUpdateCurrentUserProfileWithoutScope(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $testUser = $this->getUserByUsername('JohnDoe');\n        $this->client->loginUser($testUser);\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:profile:read');\n\n        $this->client->jsonRequest(\n            'PUT', '/api/users/profile',\n            parameters: [\n                'about' => 'Updated during test',\n            ],\n            server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]\n        );\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanUpdateCurrentUserProfile(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $testUser = $this->getUserByUsername('JohnDoe');\n        $this->client->loginUser($testUser);\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:profile:edit user:profile:read');\n\n        $this->client->request('GET', '/api/users/'.(string) $testUser->getId(), server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]);\n        self::assertResponseIsSuccessful();\n\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::USER_RESPONSE_KEYS, $jsonData);\n        self::assertSame($testUser->getId(), $jsonData['userId']);\n        self::assertNull($jsonData['about']);\n\n        $this->client->jsonRequest(\n            'PUT', '/api/users/profile',\n            parameters: [\n                'about' => 'Updated during test',\n            ],\n            server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]\n        );\n        self::assertResponseIsSuccessful();\n\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::USER_RESPONSE_KEYS, $jsonData);\n        self::assertSame($testUser->getId(), $jsonData['userId']);\n        self::assertEquals('Updated during test', $jsonData['about']);\n\n        $this->client->request('GET', '/api/users/'.(string) $testUser->getId(), server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]);\n        self::assertResponseIsSuccessful();\n\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::USER_RESPONSE_KEYS, $jsonData);\n        self::assertSame($testUser->getId(), $jsonData['userId']);\n        self::assertEquals('Updated during test', $jsonData['about']);\n    }\n\n    public function testApiCanUpdateCurrentUserTitle(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $testUser = $this->getUserByUsername('JohnDoe');\n        $this->client->loginUser($testUser);\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:profile:edit user:profile:read');\n\n        $this->client->request('GET', '/api/users/'.$testUser->getId(), server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]);\n        self::assertResponseIsSuccessful();\n\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::USER_RESPONSE_KEYS, $jsonData);\n        self::assertSame($testUser->getId(), $jsonData['userId']);\n        self::assertNull($jsonData['title']);\n\n        // region set title\n        $this->client->jsonRequest(\n            'PUT', '/api/users/profile',\n            parameters: [\n                'title' => 'Custom user-name',\n            ],\n            server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]\n        );\n        self::assertResponseIsSuccessful();\n\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::USER_RESPONSE_KEYS, $jsonData);\n        self::assertSame($testUser->getId(), $jsonData['userId']);\n        self::assertEquals('Custom user-name', $jsonData['title']);\n\n        $this->client->request('GET', '/api/users/'.$testUser->getId(), server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]);\n        self::assertResponseIsSuccessful();\n\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::USER_RESPONSE_KEYS, $jsonData);\n        self::assertSame($testUser->getId(), $jsonData['userId']);\n        self::assertEquals('Custom user-name', $jsonData['title']);\n        // endregion\n\n        // region reset title\n        $this->client->jsonRequest(\n            'PUT', '/api/users/profile',\n            parameters: [],\n            server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]\n        );\n        self::assertResponseIsSuccessful();\n\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::USER_RESPONSE_KEYS, $jsonData);\n        self::assertSame($testUser->getId(), $jsonData['userId']);\n        self::assertNull($jsonData['title']);\n\n        $this->client->request('GET', '/api/users/'.$testUser->getId(), server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]);\n        self::assertResponseIsSuccessful();\n\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::USER_RESPONSE_KEYS, $jsonData);\n        self::assertSame($testUser->getId(), $jsonData['userId']);\n        self::assertNull($jsonData['title']);\n        // endregion\n    }\n\n    public function testApiCannotUpdateCurrentUserTitleWithWhitespaces()\n    {\n        self::createOAuth2AuthCodeClient();\n        $testUser = $this->getUserByUsername('JohnDoe');\n        $this->client->loginUser($testUser);\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:profile:edit user:profile:read');\n\n        $this->client->request('GET', '/api/users/'.$testUser->getId(), server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]);\n        self::assertResponseIsSuccessful();\n\n        $this->client->jsonRequest(\n            'PUT', '/api/users/profile',\n            parameters: [\n                'title' => \"    \\t\",\n            ],\n            server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]\n        );\n        self::assertResponseStatusCodeSame(400);\n\n        $this->client->jsonRequest(\n            'PUT', '/api/users/profile',\n            parameters: [\n                'title' => '',\n            ],\n            server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]\n        );\n        self::assertResponseStatusCodeSame(400);\n\n        $this->client->jsonRequest(\n            'PUT', '/api/users/profile',\n            parameters: [\n                'title' => '  .  ',\n            ],\n            server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]\n        );\n        self::assertResponseStatusCodeSame(400);\n    }\n\n    public function testApiCannotUpdateCurrentUserSettingsWithoutScope(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $testUser = $this->getUserByUsername('JohnDoe');\n        $this->client->loginUser($testUser);\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:profile:read');\n\n        $settings = (new UserSettingsDto(\n            false,\n            false,\n            false,\n            false,\n            false,\n            false,\n            false,\n            false,\n            false,\n            false,\n            false,\n            User::HOMEPAGE_MOD,\n            Criteria::SORT_HOT,\n            Criteria::SORT_HOT,\n            false,\n            ['test'],\n            ['en'],\n            directMessageSetting: EDirectMessageSettings::Everyone->value,\n            frontDefaultContent: EFrontContentOptions::Combined->value,\n        ))->jsonSerialize();\n\n        $this->client->jsonRequest(\n            'PUT', '/api/users/settings',\n            parameters: $settings,\n            server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]\n        );\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanUpdateCurrentUserSettings(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $testUser = $this->getUserByUsername('JohnDoe');\n        $this->client->loginUser($testUser);\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:profile:edit user:profile:read');\n\n        $settings = (new UserSettingsDto(\n            false,\n            false,\n            false,\n            false,\n            false,\n            false,\n            false,\n            false,\n            false,\n            false,\n            false,\n            User::HOMEPAGE_MOD,\n            Criteria::SORT_NEW,\n            Criteria::SORT_TOP,\n            false,\n            ['test'],\n            ['en'],\n            directMessageSetting: EDirectMessageSettings::FollowersOnly->value,\n            frontDefaultContent: EFrontContentOptions::Threads->value,\n            discoverable: false,\n            indexable: false,\n        ))->jsonSerialize();\n\n        $this->client->jsonRequest(\n            'PUT', '/api/users/settings',\n            parameters: $settings,\n            server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]\n        );\n        self::assertResponseIsSuccessful();\n\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(UserRetrieveApiTest::USER_SETTINGS_KEYS, $jsonData);\n\n        self::assertFalse($jsonData['notifyOnNewEntry']);\n        self::assertFalse($jsonData['notifyOnNewEntryReply']);\n        self::assertFalse($jsonData['notifyOnNewEntryCommentReply']);\n        self::assertFalse($jsonData['notifyOnNewPost']);\n        self::assertFalse($jsonData['notifyOnNewPostReply']);\n        self::assertFalse($jsonData['notifyOnNewPostCommentReply']);\n        self::assertFalse($jsonData['hideAdult']);\n        self::assertFalse($jsonData['showProfileSubscriptions']);\n        self::assertFalse($jsonData['showProfileFollowings']);\n        self::assertFalse($jsonData['addMentionsEntries']);\n        self::assertFalse($jsonData['addMentionsPosts']);\n        self::assertFalse($jsonData['discoverable']);\n        self::assertEquals(User::HOMEPAGE_MOD, $jsonData['homepage']);\n        self::assertEquals(Criteria::SORT_NEW, $jsonData['frontDefaultSort']);\n        self::assertEquals(Criteria::SORT_TOP, $jsonData['commentDefaultSort']);\n        self::assertEquals(['test'], $jsonData['featuredMagazines']);\n        self::assertEquals(['en'], $jsonData['preferredLanguages']);\n        self::assertEquals(EDirectMessageSettings::FollowersOnly->value, $jsonData['directMessageSetting']);\n        self::assertEquals(EFrontContentOptions::Threads->value, $jsonData['frontDefaultContent']);\n        self::assertFalse($jsonData['indexable']);\n\n        $this->client->request('GET', '/api/users/settings', server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]);\n        self::assertResponseIsSuccessful();\n\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(UserRetrieveApiTest::USER_SETTINGS_KEYS, $jsonData);\n\n        self::assertFalse($jsonData['notifyOnNewEntry']);\n        self::assertFalse($jsonData['notifyOnNewEntryReply']);\n        self::assertFalse($jsonData['notifyOnNewEntryCommentReply']);\n        self::assertFalse($jsonData['notifyOnNewPost']);\n        self::assertFalse($jsonData['notifyOnNewPostReply']);\n        self::assertFalse($jsonData['notifyOnNewPostCommentReply']);\n        self::assertFalse($jsonData['hideAdult']);\n        self::assertFalse($jsonData['showProfileSubscriptions']);\n        self::assertFalse($jsonData['showProfileFollowings']);\n        self::assertFalse($jsonData['addMentionsEntries']);\n        self::assertFalse($jsonData['addMentionsPosts']);\n        self::assertFalse($jsonData['discoverable']);\n        self::assertEquals(User::HOMEPAGE_MOD, $jsonData['homepage']);\n        self::assertEquals(Criteria::SORT_NEW, $jsonData['frontDefaultSort']);\n        self::assertEquals(Criteria::SORT_TOP, $jsonData['commentDefaultSort']);\n        self::assertEquals(['test'], $jsonData['featuredMagazines']);\n        self::assertEquals(['en'], $jsonData['preferredLanguages']);\n        self::assertEquals(EDirectMessageSettings::FollowersOnly->value, $jsonData['directMessageSetting']);\n        self::assertEquals(EFrontContentOptions::Threads->value, $jsonData['frontDefaultContent']);\n        self::assertFalse($jsonData['indexable']);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/User/UserUpdateImagesApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\User;\n\nuse App\\Tests\\WebTestCase;\nuse Symfony\\Component\\HttpFoundation\\File\\UploadedFile;\n\nclass UserUpdateImagesApiTest extends WebTestCase\n{\n    public string $kibbyPath;\n\n    public function setUp(): void\n    {\n        parent::setUp();\n        $this->kibbyPath = \\dirname(__FILE__, 5).'/assets/kibby_emoji.png';\n    }\n\n    public function testApiCannotUpdateCurrentUserAvatarWithoutScope(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $testUser = $this->getUserByUsername('JohnDoe');\n        $this->client->loginUser($testUser);\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:profile:read');\n\n        // Uploading a file appears to delete the file at the given path, so make a copy before upload\n        copy($this->kibbyPath, $this->kibbyPath.'.tmp');\n        $image = new UploadedFile($this->kibbyPath.'.tmp', 'kibby_emoji.png', 'image/png');\n\n        $this->client->request(\n            'POST', '/api/users/avatar',\n            files: ['uploadImage' => $image],\n            server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]\n        );\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCannotUpdateCurrentUserCoverWithoutScope(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $testUser = $this->getUserByUsername('JohnDoe');\n        $this->client->loginUser($testUser);\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:profile:read');\n\n        // Uploading a file appears to delete the file at the given path, so make a copy before upload\n        copy($this->kibbyPath, $this->kibbyPath.'.tmp');\n        $image = new UploadedFile($this->kibbyPath.'.tmp', 'kibby_emoji.png', 'image/png');\n\n        $this->client->request(\n            'POST', '/api/users/cover',\n            files: ['uploadImage' => $image],\n            server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]\n        );\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCannotDeleteCurrentUserAvatarWithoutScope(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $testUser = $this->getUserByUsername('JohnDoe');\n        $this->client->loginUser($testUser);\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:profile:read');\n\n        $this->client->request('DELETE', '/api/users/avatar', server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCannotDeleteCurrentUserCoverWithoutScope(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $testUser = $this->getUserByUsername('JohnDoe');\n        $this->client->loginUser($testUser);\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:profile:read');\n\n        $this->client->request('DELETE', '/api/users/cover', server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]);\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanUpdateAndDeleteCurrentUserAvatar(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $testUser = $this->getUserByUsername('JohnDoe');\n        $this->client->loginUser($testUser);\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:profile:edit user:profile:read');\n\n        // Uploading a file appears to delete the file at the given path, so make a copy before upload\n        $tmpPath = bin2hex(random_bytes(32));\n        copy($this->kibbyPath, $tmpPath.'.png');\n        $image = new UploadedFile($tmpPath.'.png', 'kibby_emoji.png', 'image/png');\n\n        $imageManager = $this->imageManager;\n        $expectedPath = $imageManager->getFilePath($image->getFilename());\n\n        $this->client->request(\n            'POST', '/api/users/avatar',\n            files: ['uploadImage' => $image],\n            server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]\n        );\n        self::assertResponseIsSuccessful();\n\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::USER_RESPONSE_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['avatar']);\n        self::assertArrayKeysMatch(self::IMAGE_KEYS, $jsonData['avatar']);\n        self::assertSame(96, $jsonData['avatar']['width']);\n        self::assertSame(96, $jsonData['avatar']['height']);\n        self::assertEquals($expectedPath, $jsonData['avatar']['filePath']);\n\n        // Clean up test data as well as checking that DELETE works\n        //      This isn't great, but since people could have their media directory\n        //      pretty much anywhere, its difficult to reliably clean up uploaded files\n        //      otherwise. This is certainly something that could be improved.\n        $this->client->request('DELETE', '/api/users/avatar', server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]);\n        self::assertResponseIsSuccessful();\n\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::USER_RESPONSE_KEYS, $jsonData);\n        self::assertNull($jsonData['avatar']);\n    }\n\n    public function testApiCanUpdateAndDeleteCurrentUserCover(): void\n    {\n        $imageManager = $this->imageManager;\n        self::createOAuth2AuthCodeClient();\n        $testUser = $this->getUserByUsername('JohnDoe');\n        $this->client->loginUser($testUser);\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:profile:edit user:profile:read');\n\n        // Uploading a file appears to delete the file at the given path, so make a copy before upload\n        $tmpPath = bin2hex(random_bytes(32));\n        copy($this->kibbyPath, $tmpPath.'.png');\n        $image = new UploadedFile($tmpPath.'.png', 'kibby_emoji.png', 'image/png');\n        $expectedPath = $imageManager->getFilePath($image->getFilename());\n\n        $this->client->request(\n            'POST', '/api/users/cover',\n            files: ['uploadImage' => $image],\n            server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]\n        );\n        self::assertResponseIsSuccessful();\n\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::USER_RESPONSE_KEYS, $jsonData);\n\n        self::assertIsArray($jsonData['cover']);\n        self::assertArrayKeysMatch(self::IMAGE_KEYS, $jsonData['cover']);\n        self::assertSame(96, $jsonData['cover']['width']);\n        self::assertSame(96, $jsonData['cover']['height']);\n        self::assertEquals($expectedPath, $jsonData['cover']['filePath']);\n\n        // Clean up test data as well as checking that DELETE works\n        //      This isn't great, but since people could have their media directory\n        //      pretty much anywhere, its difficult to reliably clean up uploaded files\n        //      otherwise. This is certainly something that could be improved.\n        $this->client->request('DELETE', '/api/users/cover', server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]);\n        self::assertResponseIsSuccessful();\n\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::USER_RESPONSE_KEYS, $jsonData);\n        self::assertNull($jsonData['cover']);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Api/User/UserUpdateOAuthConsentsApiTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Api\\User;\n\nuse App\\Tests\\WebTestCase;\n\nclass UserUpdateOAuthConsentsApiTest extends WebTestCase\n{\n    public function testApiCannotUpdateConsentsWithoutScope(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $testUser = $this->getUserByUsername('someuser');\n\n        $this->client->loginUser($testUser);\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:oauth_clients:read');\n\n        $this->client->request('GET', '/api/users/consents', server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]);\n        self::assertResponseIsSuccessful();\n\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertCount(1, $jsonData['items']);\n        self::assertArrayKeysMatch(UserRetrieveOAuthConsentsApiTest::CONSENT_RESPONSE_KEYS, $jsonData['items'][0]);\n\n        $this->client->jsonRequest(\n            'PUT', '/api/users/consents/'.(string) $jsonData['items'][0]['consentId'],\n            server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]\n        );\n        self::assertResponseStatusCodeSame(403);\n    }\n\n    public function testApiCanUpdateConsents(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $testUser = $this->getUserByUsername('someuser');\n\n        $this->client->loginUser($testUser);\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:oauth_clients:read user:oauth_clients:edit user:follow');\n\n        $this->client->request('GET', '/api/users/consents', server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]);\n        self::assertResponseIsSuccessful();\n\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertCount(1, $jsonData['items']);\n        self::assertArrayKeysMatch(UserRetrieveOAuthConsentsApiTest::CONSENT_RESPONSE_KEYS, $jsonData['items'][0]);\n\n        self::assertEquals([\n            'read',\n            'user:oauth_clients:read',\n            'user:oauth_clients:edit',\n            'user:follow',\n        ], $jsonData['items'][0]['scopesGranted']);\n\n        $this->client->jsonRequest(\n            'PUT', '/api/users/consents/'.(string) $jsonData['items'][0]['consentId'],\n            parameters: ['scopes' => [\n                'read',\n                'user:oauth_clients:read',\n                'user:oauth_clients:edit',\n            ]],\n            server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]\n        );\n        self::assertResponseIsSuccessful();\n\n        $jsonData = self::getJsonResponse($this->client);\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(UserRetrieveOAuthConsentsApiTest::CONSENT_RESPONSE_KEYS, $jsonData);\n        self::assertEquals([\n            'read',\n            'user:oauth_clients:read',\n            'user:oauth_clients:edit',\n        ], $jsonData['scopesGranted']);\n    }\n\n    public function testApiUpdatingConsentsDoesNotAffectExistingKeys(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $testUser = $this->getUserByUsername('someuser');\n\n        $this->client->loginUser($testUser);\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:oauth_clients:read user:oauth_clients:edit user:follow');\n\n        $this->client->request('GET', '/api/users/consents', server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]);\n        self::assertResponseIsSuccessful();\n\n        $jsonData = self::getJsonResponse($this->client);\n        $this->client->jsonRequest(\n            'PUT', '/api/users/consents/'.(string) $jsonData['items'][0]['consentId'],\n            parameters: ['scopes' => [\n                'read',\n                'user:oauth_clients:edit',\n            ]],\n            server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]\n        );\n        self::assertResponseIsSuccessful();\n        $jsonData = self::getJsonResponse($this->client);\n\n        // Existing token still has permission to read oauth consents despite client consent being revoked.\n        $this->client->jsonRequest(\n            'GET', '/api/users/consents/'.(string) $jsonData['consentId'],\n            server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]\n        );\n        self::assertResponseIsSuccessful();\n\n        $jsonData = self::getJsonResponse($this->client);\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(UserRetrieveOAuthConsentsApiTest::CONSENT_RESPONSE_KEYS, $jsonData);\n        self::assertEquals([\n            'read',\n            'user:oauth_clients:edit',\n        ], $jsonData['scopesGranted']);\n    }\n\n    public function testApiCannotAddConsents(): void\n    {\n        self::createOAuth2AuthCodeClient();\n        $testUser = $this->getUserByUsername('someuser');\n\n        $this->client->loginUser($testUser);\n        $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read user:oauth_clients:read user:oauth_clients:edit user:follow');\n\n        $this->client->request('GET', '/api/users/consents', server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]);\n        self::assertResponseIsSuccessful();\n\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData);\n\n        self::assertCount(1, $jsonData['items']);\n        self::assertArrayKeysMatch(UserRetrieveOAuthConsentsApiTest::CONSENT_RESPONSE_KEYS, $jsonData['items'][0]);\n\n        self::assertEquals([\n            'read',\n            'user:oauth_clients:read',\n            'user:oauth_clients:edit',\n            'user:follow',\n        ], $jsonData['items'][0]['scopesGranted']);\n\n        $this->client->jsonRequest(\n            'PUT', '/api/users/consents/'.(string) $jsonData['items'][0]['consentId'],\n            parameters: ['scopes' => [\n                'read',\n                'user:oauth_clients:read',\n                'user:oauth_clients:edit',\n                'user:block',\n            ]],\n            server: ['HTTP_AUTHORIZATION' => $codes['token_type'].' '.$codes['access_token']]\n        );\n        self::assertResponseStatusCodeSame(403);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Domain/DomainBlockControllerTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Domain;\n\nuse App\\Tests\\WebTestCase;\nuse PHPUnit\\Framework\\Attributes\\Group;\n\nclass DomainBlockControllerTest extends WebTestCase\n{\n    #[Group(name: 'NonThreadSafe')]\n    public function testUserCanBlockAndUnblockDomain(): void\n    {\n        $entry = $this->createEntry(\n            'test entry 1',\n            $this->getMagazineByName('acme'),\n            $this->getUserByUsername('JohnDoe'),\n            'http://kbin.pub/instances'\n        );\n\n        $this->client->loginUser($this->getUserByUsername('JaneDoe'));\n\n        $crawler = $this->client->request('GET', '/d/kbin.pub');\n\n        // Block\n        $this->client->submit($crawler->filter('#sidebar form[name=domain_block] button')->form());\n        $crawler = $this->client->followRedirect();\n\n        $this->assertSelectorExists('#sidebar form[name=domain_block] .active');\n\n        // Unblock\n        $this->client->submit($crawler->filter('#sidebar form[name=domain_block] button')->form());\n        $this->client->followRedirect();\n\n        $this->assertSelectorNotExists('#sidebar form[name=domain_block] .active');\n    }\n\n    #[Group(name: 'NonThreadSafe')]\n    public function testXmlUserCanBlockDomain(): void\n    {\n        $entry = $this->createEntry(\n            'test entry 1',\n            $this->getMagazineByName('acme'),\n            $this->getUserByUsername('JohnDoe'),\n            'http://kbin.pub/instances'\n        );\n\n        $this->client->loginUser($this->getUserByUsername('JaneDoe'));\n\n        $crawler = $this->client->request('GET', '/d/kbin.pub');\n\n        $this->client->setServerParameter('HTTP_X-Requested-With', 'XMLHttpRequest');\n        $this->client->submit($crawler->filter('#sidebar form[name=domain_block] button')->form());\n\n        $this->assertStringContainsString('{\"html\":', $this->client->getResponse()->getContent());\n        $this->assertStringContainsString('active', $this->client->getResponse()->getContent());\n    }\n\n    #[Group(name: 'NonThreadSafe')]\n    public function testXmlUserCanUnblockDomain(): void\n    {\n        $entry = $this->createEntry(\n            'test entry 1',\n            $this->getMagazineByName('acme'),\n            $this->getUserByUsername('JohnDoe'),\n            'http://kbin.pub/instances'\n        );\n\n        $this->client->loginUser($this->getUserByUsername('JaneDoe'));\n\n        $crawler = $this->client->request('GET', '/d/kbin.pub');\n\n        // Block\n        $this->client->submit($crawler->filter('#sidebar form[name=domain_block] button')->form());\n        $crawler = $this->client->followRedirect();\n\n        // Unblock\n        $this->client->setServerParameter('HTTP_X-Requested-With', 'XMLHttpRequest');\n        $this->client->submit($crawler->filter('#sidebar form[name=domain_block] button')->form());\n\n        $this->assertStringContainsString('{\"html\":', $this->client->getResponse()->getContent());\n        $this->assertStringNotContainsString('active', $this->client->getResponse()->getContent());\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Domain/DomainCommentFrontControllerTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Domain;\n\nuse App\\Tests\\WebTestCase;\n\nclass DomainCommentFrontControllerTest extends WebTestCase\n{\n    public function testDomainCommentFrontPage(): void\n    {\n        $entry = $this->createEntry(\n            'test entry 1',\n            $this->getMagazineByName('acme'),\n            $this->getUserByUsername('JohnDoe'),\n            'http://kbin.pub/instances'\n        );\n        $this->createEntryComment('test comment 1', $entry);\n\n        $crawler = $this->client->request('GET', '/d/kbin.pub');\n        $crawler = $this->client->click($crawler->filter('#header')->selectLink('Comments')->link());\n\n        $this->assertSelectorTextContains('#header', '/d/kbin.pub');\n        $this->assertSelectorTextContains('blockquote header', 'JohnDoe');\n        $this->assertSelectorTextContains('blockquote header', 'to acme in test entry 1');\n        $this->assertSelectorTextContains('blockquote .content', 'test comment 1');\n\n        foreach ($this->getSortOptions() as $sortOption) {\n            $crawler = $this->client->click($crawler->filter('.options__filter')->selectLink($sortOption)->link());\n            $this->assertSelectorTextContains('.options__filter', $sortOption);\n            $this->assertSelectorTextContains('h1', 'kbin.pub');\n            $this->assertSelectorTextContains('h2', ucfirst($sortOption));\n        }\n    }\n\n    private function getSortOptions(): array\n    {\n        return ['Hot', 'Newest', 'Active', 'Oldest'];\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Domain/DomainFrontControllerTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Domain;\n\nuse App\\Tests\\WebTestCase;\n\nclass DomainFrontControllerTest extends WebTestCase\n{\n    public function testDomainCommentFrontPage(): void\n    {\n        $this->createEntry(\n            'test entry 1',\n            $this->getMagazineByName('acme'),\n            $this->getUserByUsername('JohnDoe'),\n            'http://kbin.pub/instances'\n        );\n\n        $crawler = $this->client->request('GET', '/');\n        $crawler = $this->client->click($crawler->filter('#content article')->selectLink('More from domain')->link());\n\n        $this->assertSelectorTextContains('#header', '/d/kbin.pub');\n        $this->assertSelectorTextContains('.entry__meta', 'JohnDoe');\n        $this->assertSelectorTextContains('.entry__meta', 'to acme');\n\n        foreach ($this->getSortOptions() as $sortOption) {\n            $crawler = $this->client->click($crawler->filter('.options__filter')->selectLink($sortOption)->link());\n            $this->assertSelectorTextContains('.options__filter', $sortOption);\n            $this->assertSelectorTextContains('h1', 'kbin.pub');\n            $this->assertSelectorTextContains('h2', ucfirst($sortOption));\n        }\n    }\n\n    private function getSortOptions(): array\n    {\n        return ['Top', 'Hot', 'Newest', 'Active', 'Commented'];\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Domain/DomainSubControllerTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Domain;\n\nuse App\\Tests\\WebTestCase;\n\nclass DomainSubControllerTest extends WebTestCase\n{\n    public function testUserCanSubAndUnsubDomain(): void\n    {\n        $this->createEntry(\n            'test entry 1',\n            $this->getMagazineByName('acme'),\n            $this->getUserByUsername('JohnDoe'),\n            'http://kbin.pub/instances'\n        );\n\n        $this->client->loginUser($this->getUserByUsername('JaneDoe'));\n\n        $crawler = $this->client->request('GET', '/d/kbin.pub');\n\n        // Subscribe\n        $this->client->submit($crawler->filter('#sidebar .domain')->selectButton('Subscribe')->form());\n        $crawler = $this->client->followRedirect();\n\n        $this->assertSelectorExists('#sidebar form[name=domain_subscribe] .active');\n        $this->assertSelectorTextContains('#sidebar .domain', 'Unsubscribe');\n        $this->assertSelectorTextContains('#sidebar .domain', '1');\n\n        // Unsubscribe\n        $this->client->submit($crawler->filter('#sidebar .domain')->selectButton('Unsubscribe')->form());\n        $this->client->followRedirect();\n\n        $this->assertSelectorNotExists('#sidebar form[name=domain_subscribe] .active');\n        $this->assertSelectorTextContains('#sidebar .domain', 'Subscribe');\n        $this->assertSelectorTextContains('#sidebar .domain', '0');\n    }\n\n    public function testXmlUserCanSubDomain(): void\n    {\n        $this->createEntry(\n            'test entry 1',\n            $this->getMagazineByName('acme'),\n            $this->getUserByUsername('JohnDoe'),\n            'http://kbin.pub/instances'\n        );\n\n        $this->client->loginUser($this->getUserByUsername('JaneDoe'));\n\n        $crawler = $this->client->request('GET', '/d/kbin.pub');\n\n        // Subscribe\n        $this->client->setServerParameter('HTTP_X-Requested-With', 'XMLHttpRequest');\n        $this->client->submit($crawler->filter('#sidebar .domain')->selectButton('Subscribe')->form());\n\n        $this->assertStringContainsString('{\"html\":', $this->client->getResponse()->getContent());\n        $this->assertStringContainsString('Unsubscribe', $this->client->getResponse()->getContent());\n    }\n\n    public function testXmlUserCanUnsubDomain(): void\n    {\n        $this->createEntry(\n            'test entry 1',\n            $this->getMagazineByName('acme'),\n            $this->getUserByUsername('JohnDoe'),\n            'http://kbin.pub/instances'\n        );\n\n        $this->client->loginUser($this->getUserByUsername('JaneDoe'));\n\n        $crawler = $this->client->request('GET', '/d/kbin.pub');\n\n        // Subscribe\n        $this->client->submit($crawler->filter('#sidebar .domain')->selectButton('Subscribe')->form());\n        $crawler = $this->client->followRedirect();\n\n        // Unsubscribe\n        $this->client->setServerParameter('HTTP_X-Requested-With', 'XMLHttpRequest');\n        $this->client->submit($crawler->filter('#sidebar .domain')->selectButton('Unsubscribe')->form());\n\n        $this->assertStringContainsString('{\"html\":', $this->client->getResponse()->getContent());\n        $this->assertStringContainsString('Subscribe', $this->client->getResponse()->getContent());\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Entry/Comment/EntryCommentBoostControllerTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Entry\\Comment;\n\nuse App\\Tests\\WebTestCase;\n\nclass EntryCommentBoostControllerTest extends WebTestCase\n{\n    public function testLoggedUserCanAddToBoostsEntryComment(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n\n        $entry = $this->getEntryByTitle(\n            'test entry 1',\n            'https://kbin.pub',\n            null,\n            null,\n            $this->getUserByUsername('JaneDoe')\n        );\n        $this->createEntryComment('test comment 1', $entry, $this->getUserByUsername('JaneDoe'));\n\n        $crawler = $this->client->request('GET', \"/m/acme/t/{$entry->getId()}/test-entry-1\");\n        $this->client->submit(\n            $crawler->filter('#main .entry-comment')->selectButton('Boost')->form()\n        );\n        $this->client->followRedirect();\n        self::assertResponseIsSuccessful();\n\n        $crawler = $this->client->request('GET', \"/m/acme/t/{$entry->getId()}/test-entry-1\");\n\n        $this->assertSelectorTextContains('#main .entry-comment', 'Boost (1)');\n\n        $crawler = $this->client->click($crawler->filter('#main .entry-comment')->selectLink('Activity')->link());\n\n        $this->client->click($crawler->filter('#main #activity')->selectLink('Boosts (1)')->link());\n\n        $this->assertSelectorTextContains('#main .users-columns', 'JohnDoe');\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Entry/Comment/EntryCommentChangeLangControllerTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Entry\\Comment;\n\nuse App\\Tests\\WebTestCase;\n\nclass EntryCommentChangeLangControllerTest extends WebTestCase\n{\n    public function testModCanChangeLanguage(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n\n        $comment = $this->createEntryComment('test comment 1');\n\n        $crawler = $this->client->request('GET', \"/m/acme/t/{$comment->entry->getId()}/-/comment/{$comment->getId()}/moderate\");\n\n        $form = $crawler->filter('.moderate-panel')->selectButton('lang[submit]')->form();\n\n        $this->assertSame($form['lang']['lang']->getValue(), 'en');\n\n        $form['lang']['lang']->select('fr');\n\n        $this->client->submit($form);\n        $this->client->followRedirect();\n\n        $this->assertSelectorTextContains('#main .badge-lang', 'French');\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Entry/Comment/EntryCommentCreateControllerTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Entry\\Comment;\n\nuse App\\Tests\\WebTestCase;\nuse PHPUnit\\Framework\\Attributes\\Group;\n\nclass EntryCommentCreateControllerTest extends WebTestCase\n{\n    public function setUp(): void\n    {\n        parent::setUp();\n        $this->kibbyPath = \\dirname(__FILE__, 5).'/assets/kibby_emoji.png';\n    }\n\n    public function testUserCanCreateEntryComment(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n\n        $entry = $this->getEntryByTitle('test entry 1', 'https://kbin.pub');\n\n        $crawler = $this->client->request('GET', \"/m/acme/t/{$entry->getId()}/test-entry-1\");\n\n        $this->client->submit(\n            $crawler->filter('form[name=entry_comment]')->selectButton('Add comment')->form(\n                [\n                    'entry_comment[body]' => 'test comment 1',\n                ]\n            )\n        );\n\n        $this->assertResponseRedirects('/m/acme/t/'.$entry->getId().'/test-entry-1');\n        $this->client->followRedirect();\n\n        $this->assertSelectorTextContains('#main blockquote', 'test comment 1');\n    }\n\n    public function testUserCannotCreateEntryCommentInLockedEntry(): void\n    {\n        $user = $this->getUserByUsername('JohnDoe');\n        $this->client->loginUser($user);\n\n        $entry = $this->getEntryByTitle('test entry 1', 'https://kbin.pub');\n        $this->entryManager->toggleLock($entry, $user);\n\n        $this->client->request('GET', \"/m/acme/t/{$entry->getId()}/test-entry-1\");\n\n        self::assertSelectorTextNotContains('#main', 'Add comment');\n    }\n\n    #[Group(name: 'NonThreadSafe')]\n    public function testUserCanCreateEntryCommentWithImage(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n\n        $entry = $this->getEntryByTitle('test entry 1', 'https://kbin.pub');\n\n        $crawler = $this->client->request('GET', \"/m/acme/t/{$entry->getId()}/test-entry-1\");\n\n        $form = $crawler->filter('form[name=entry_comment]')->selectButton('entry_comment[submit]')->form();\n        $form->get('entry_comment[body]')->setValue('test comment 1');\n        $form->get('entry_comment[image]')->upload($this->kibbyPath);\n        // Needed since we require this global to be set when validating entries but the client doesn't actually set it\n        $_FILES = $form->getPhpFiles();\n        $this->client->submit($form);\n\n        $this->assertResponseRedirects('/m/acme/t/'.$entry->getId().'/test-entry-1');\n        $crawler = $this->client->followRedirect();\n\n        $this->assertSelectorTextContains('#main blockquote', 'test comment 1');\n        $this->assertSelectorExists('blockquote footer figure img');\n        $imgSrc = $crawler->filter('blockquote footer figure img')->getNode(0)->attributes->getNamedItem('src')->textContent;\n        $this->assertStringContainsString(self::KIBBY_PNG_URL_RESULT, $imgSrc);\n        $_FILES = [];\n    }\n\n    #[Group(name: 'NonThreadSafe')]\n    public function testUserCanReplyEntryComment(): void\n    {\n        $comment = $this->createEntryComment(\n            'test comment 1',\n            $entry = $this->getEntryByTitle('test entry 1', 'https://kbin.pub'),\n            $this->getUserByUsername('JaneDoe')\n        );\n\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n\n        $crawler = $this->client->request('GET', \"/m/acme/t/{$entry->getId()}/test-entry-1\");\n        $crawler = $this->client->click($crawler->filter('#entry-comment-'.$comment->getId())->selectLink('Reply')->link());\n\n        $this->assertSelectorTextContains('#main blockquote', 'test comment 1');\n\n        $crawler = $this->client->submit(\n            $crawler->filter('form[name=entry_comment]')->selectButton('Add comment')->form(\n                [\n                    'entry_comment[body]' => 'test comment 2',\n                ]\n            )\n        );\n\n        $this->assertResponseRedirects('/m/acme/t/'.$entry->getId().'/test-entry-1');\n        $crawler = $this->client->followRedirect();\n\n        $this->assertEquals(2, $crawler->filter('#main blockquote')->count());\n    }\n\n    #[Group(name: 'NonThreadSafe')]\n    public function testUserCantCreateInvalidEntryComment(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n\n        $entry = $this->getEntryByTitle('test entry 1', 'https://kbin.pub');\n\n        $crawler = $this->client->request('GET', \"/m/acme/t/{$entry->getId()}/test-entry-1\");\n\n        $this->client->submit(\n            $crawler->filter('form[name=entry_comment]')->selectButton('Add comment')->form(\n                [\n                    'entry_comment[body]' => '',\n                ]\n            )\n        );\n\n        $this->assertSelectorTextContains(\n            '#content',\n            'This value should not be blank.'\n        );\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Entry/Comment/EntryCommentDeleteControllerTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Entry\\Comment;\n\nuse App\\Tests\\WebTestCase;\n\nclass EntryCommentDeleteControllerTest extends WebTestCase\n{\n    public function testUserCanDeleteEntryComment()\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByName('acme');\n        $entry = $this->getEntryByTitle('comment deletion test', body: 'a comment will be deleted', magazine: $magazine, user: $user);\n        $comment = $this->createEntryComment('Delete me!', $entry, $user);\n        $this->client->loginUser($user);\n\n        $crawler = $this->client->request('GET', \"/m/acme/t/{$entry->getId()}/comment-deletion-test\");\n\n        $this->assertSelectorExists('#comments form[action$=\"delete\"]');\n        $this->client->submit(\n            $crawler->filter('#comments form[action$=\"delete\"]')->selectButton('Delete')->form()\n        );\n\n        $this->assertResponseRedirects();\n    }\n\n    public function testUserCanSoftDeleteEntryComment()\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByName('acme');\n        $entry = $this->getEntryByTitle('comment deletion test', body: 'a comment will be deleted', magazine: $magazine, user: $user);\n        $comment = $this->createEntryComment('Delete me!', $entry, $user);\n        $reply = $this->createEntryComment('Are you deleted yet?', $entry, $user, $comment);\n        $this->client->loginUser($user);\n\n        $crawler = $this->client->request('GET', \"/m/acme/t/{$entry->getId()}/comment-deletion-test\");\n\n        $this->assertSelectorExists(\"#entry-comment-{$comment->getId()} form[action$=\\\"delete\\\"]\");\n        $this->client->submit(\n            $crawler->filter(\"#entry-comment-{$comment->getId()} form[action$=\\\"delete\\\"]\")->selectButton('Delete')->form()\n        );\n\n        $this->assertResponseRedirects();\n        $crawler = $this->client->followRedirect();\n\n        $translator = $this->translator;\n\n        $this->assertSelectorTextContains(\"#entry-comment-{$comment->getId()} .content\", $translator->trans('deleted_by_author'));\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Entry/Comment/EntryCommentEditControllerTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Entry\\Comment;\n\nuse App\\Tests\\WebTestCase;\nuse PHPUnit\\Framework\\Attributes\\Group;\n\nclass EntryCommentEditControllerTest extends WebTestCase\n{\n    public function testAuthorCanEditOwnEntryComment(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n\n        $entry = $this->getEntryByTitle('test entry 1', 'https://kbin.pub');\n        $this->createEntryComment('test comment 1', $entry);\n\n        $crawler = $this->client->request('GET', \"/m/acme/t/{$entry->getId()}/test-entry-1\");\n\n        $crawler = $this->client->click($crawler->filter('#main .entry-comment')->selectLink('Edit')->link());\n\n        $this->assertSelectorExists('#main .entry-comment');\n\n        $this->assertSelectorTextContains('#main .entry-comment', 'test comment 1');\n\n        $this->client->submit(\n            $crawler->filter('form[name=entry_comment]')->selectButton('Update comment')->form(\n                [\n                    'entry_comment[body]' => 'test comment 2 body',\n                ]\n            )\n        );\n\n        $this->client->followRedirect();\n\n        $this->assertSelectorTextContains('#main .entry-comment', 'test comment 2 body');\n    }\n\n    #[Group(name: 'NonThreadSafe')]\n    public function testAuthorCanEditOwnEntryCommentWithImage(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n\n        $imageDto = $this->getKibbyImageDto();\n        $entry = $this->getEntryByTitle('test entry 1', 'https://kbin.pub');\n        $this->createEntryComment('test comment 1', $entry, imageDto: $imageDto);\n\n        $crawler = $this->client->request('GET', \"/m/acme/t/{$entry->getId()}/test-entry-1\");\n\n        $crawler = $this->client->click($crawler->filter('#main .entry-comment')->selectLink('Edit')->link());\n\n        $this->assertSelectorExists('#main .entry-comment');\n\n        $this->assertSelectorTextContains('#main .entry-comment', 'test comment 1');\n        $this->assertSelectorExists('#main .entry-comment img');\n        $node = $crawler->selectImage('kibby')->getNode(0);\n        $this->assertNotNull($node);\n        $this->assertStringContainsString($imageDto->filePath, $node->attributes->getNamedItem('src')->textContent);\n\n        $this->client->submit(\n            $crawler->filter('form[name=entry_comment]')->selectButton('Update comment')->form(\n                [\n                    'entry_comment[body]' => 'test comment 2 body',\n                ]\n            )\n        );\n\n        $crawler = $this->client->followRedirect();\n\n        $this->assertSelectorTextContains('#main .entry-comment', 'test comment 2 body');\n        $this->assertSelectorExists('#main .entry-comment img');\n        $node = $crawler->selectImage('kibby')->getNode(0);\n        $this->assertNotNull($node);\n        $this->assertStringContainsString($imageDto->filePath, $node->attributes->getNamedItem('src')->textContent);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Entry/Comment/EntryCommentFrontControllerTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Entry\\Comment;\n\nuse App\\DTO\\ModeratorDto;\nuse App\\Service\\MagazineManager;\nuse App\\Tests\\WebTestCase;\nuse Symfony\\Bundle\\FrameworkBundle\\KernelBrowser;\n\nclass EntryCommentFrontControllerTest extends WebTestCase\n{\n    public function testFrontPage(): void\n    {\n        $this->client = $this->prepareEntries();\n\n        $this->client->request('GET', '/comments');\n        $this->assertSelectorTextContains('h1', 'Hot');\n\n        $crawler = $this->client->request('GET', '/comments/newest');\n\n        $this->assertSelectorTextContains('blockquote header', 'JohnDoe');\n        $this->assertSelectorTextContains('blockquote header', 'to kbin in test entry 2');\n        $this->assertSelectorTextContains('blockquote .content', 'test comment 3');\n\n        $this->assertcount(3, $crawler->filter('.comment'));\n\n        foreach ($this->getSortOptions() as $sortOption) {\n            $crawler = $this->client->click($crawler->filter('.options__filter')->selectLink($sortOption)->link());\n            $this->assertSelectorTextContains('.options__filter', $sortOption);\n            $this->assertSelectorTextContains('h1', ucfirst($sortOption));\n        }\n    }\n\n    public function testMagazinePage(): void\n    {\n        $this->client = $this->prepareEntries();\n\n        $this->client->request('GET', '/m/acme/comments');\n        $this->assertSelectorTextContains('h2', 'Hot');\n\n        $crawler = $this->client->request('GET', '/m/acme/comments/newest');\n\n        $this->assertSelectorTextContains('blockquote header', 'JohnDoe');\n        $this->assertSelectorTextNotContains('blockquote header', 'to acme');\n        $this->assertSelectorTextContains('blockquote header', 'in test entry 1');\n        $this->assertSelectorTextContains('blockquote .content', 'test comment 2');\n\n        $this->assertSelectorTextContains('.head-title', '/m/acme');\n        $this->assertSelectorTextContains('#sidebar .magazine', 'acme');\n\n        $this->assertcount(2, $crawler->filter('.comment'));\n\n        foreach ($this->getSortOptions() as $sortOption) {\n            $crawler = $this->client->click($crawler->filter('.options__filter')->selectLink($sortOption)->link());\n            $this->assertSelectorTextContains('.options__filter', $sortOption);\n            $this->assertSelectorTextContains('h1', 'acme');\n            $this->assertSelectorTextContains('h2', ucfirst($sortOption));\n        }\n    }\n\n    public function testSubPage(): void\n    {\n        $this->client = $this->prepareEntries();\n\n        $magazineManager = $this->client->getContainer()->get(MagazineManager::class);\n        $magazineManager->subscribe($this->getMagazineByName('acme'), $this->getUserByUsername('Actor'));\n\n        $this->client->loginUser($this->getUserByUsername('Actor'));\n\n        $this->client->request('GET', '/sub/comments');\n        $this->assertSelectorTextContains('h1', 'Hot');\n\n        $crawler = $this->client->request('GET', '/sub/comments/newest');\n\n        $this->assertSelectorTextContains('blockquote header', 'JohnDoe');\n        $this->assertSelectorTextContains('blockquote header', 'to acme in test entry 1');\n        $this->assertSelectorTextContains('blockquote .content', 'test comment 2');\n\n        $this->assertSelectorTextContains('.head-title', '/sub');\n\n        $this->assertcount(2, $crawler->filter('.comment'));\n\n        foreach ($this->getSortOptions() as $sortOption) {\n            $crawler = $this->client->click($crawler->filter('.options__filter')->selectLink($sortOption)->link());\n            $this->assertSelectorTextContains('.options__filter', $sortOption);\n            $this->assertSelectorTextContains('h1', ucfirst($sortOption));\n        }\n    }\n\n    public function testModPage(): void\n    {\n        $this->client = $this->prepareEntries();\n        $admin = $this->getUserByUsername('admin', isAdmin: true);\n\n        $magazineManager = $this->client->getContainer()->get(MagazineManager::class);\n        $moderator = new ModeratorDto($this->getMagazineByName('acme'));\n        $moderator->user = $this->getUserByUsername('Actor');\n        $moderator->addedBy = $admin;\n        $magazineManager->addModerator($moderator);\n\n        $this->client->loginUser($this->getUserByUsername('Actor'));\n\n        $this->client->request('GET', '/mod/comments');\n        $this->assertSelectorTextContains('h1', 'Hot');\n\n        $crawler = $this->client->request('GET', '/mod/comments/newest');\n\n        $this->assertSelectorTextContains('blockquote header', 'JohnDoe');\n        $this->assertSelectorTextContains('blockquote header', 'to acme in test entry 1');\n        $this->assertSelectorTextContains('blockquote .content', 'test comment 2');\n\n        $this->assertSelectorTextContains('.head-title', '/mod');\n\n        $this->assertcount(2, $crawler->filter('.comment'));\n\n        foreach ($this->getSortOptions() as $sortOption) {\n            $crawler = $this->client->click($crawler->filter('.options__filter')->selectLink($sortOption)->link());\n            $this->assertSelectorTextContains('.options__filter', $sortOption);\n            $this->assertSelectorTextContains('h1', ucfirst($sortOption));\n        }\n    }\n\n    public function testFavPage(): void\n    {\n        $this->client = $this->prepareEntries();\n\n        $favouriteManager = $this->favouriteManager;\n        $favouriteManager->toggle(\n            $this->getUserByUsername('Actor'),\n            $this->createEntryComment('test comment 1', $this->getEntryByTitle('test entry 1'))\n        );\n\n        $this->client->loginUser($this->getUserByUsername('Actor'));\n\n        $this->client->request('GET', '/fav/comments');\n        $this->assertSelectorTextContains('h1', 'Hot');\n\n        $crawler = $this->client->request('GET', '/fav/comments/newest');\n\n        $this->assertSelectorTextContains('blockquote header', 'JohnDoe');\n        $this->assertSelectorTextContains('blockquote header', 'to acme in test entry 1');\n        $this->assertSelectorTextContains('blockquote .content', 'test comment 1');\n\n        $this->assertcount(1, $crawler->filter('.comment'));\n\n        foreach ($this->getSortOptions() as $sortOption) {\n            $crawler = $this->client->click($crawler->filter('.options__filter')->selectLink($sortOption)->link());\n            $this->assertSelectorTextContains('.options__filter', $sortOption);\n            $this->assertSelectorTextContains('h1', ucfirst($sortOption));\n        }\n    }\n\n    private function prepareEntries(): KernelBrowser\n    {\n        $this->createEntryComment(\n            'test comment 1',\n            $this->getEntryByTitle('test entry 1', 'https://kbin.pub'),\n            $this->getUserByUsername('JohnDoe')\n        );\n        $this->createEntryComment(\n            'test comment 2',\n            $this->getEntryByTitle('test entry 1', 'https://kbin.pub'),\n            $this->getUserByUsername('JohnDoe')\n        );\n        $this->createEntryComment(\n            'test comment 3',\n            $this->getEntryByTitle('test entry 2', 'https://kbin.pub', null, $this->getMagazineByName('kbin')),\n            $this->getUserByUsername('JohnDoe')\n        );\n\n        return $this->client;\n    }\n\n    private function getSortOptions(): array\n    {\n        return ['Hot', 'Newest', 'Active', 'Oldest'];\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Entry/Comment/EntryCommentModerateControllerTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Entry\\Comment;\n\nuse App\\Tests\\WebTestCase;\n\nclass EntryCommentModerateControllerTest extends WebTestCase\n{\n    public function testModCanShowPanel(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n\n        $comment = $this->createEntryComment('test comment 1');\n\n        $crawler = $this->client->request('get', \"/m/{$comment->magazine->name}/t/{$comment->entry->getId()}\");\n        $this->client->click($crawler->filter('#entry-comment-'.$comment->getId())->selectLink('Moderate')->link());\n\n        $this->assertSelectorTextContains('.moderate-panel', 'Ban');\n    }\n\n    public function testXmlModCanShowPanel(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n\n        $comment = $this->createEntryComment('test comment 1');\n\n        $crawler = $this->client->request('get', \"/m/{$comment->magazine->name}/t/{$comment->entry->getId()}\");\n        $this->client->setServerParameter('HTTP_X-Requested-With', 'XMLHttpRequest');\n        $this->client->click($crawler->filter('#entry-comment-'.$comment->getId())->selectLink('Moderate')->link());\n\n        $this->assertStringContainsString('moderate-panel', $this->client->getResponse()->getContent());\n    }\n\n    public function testUnauthorizedCanNotShowPanel(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JaneDoe'));\n\n        $comment = $this->createEntryComment('test comment 1');\n\n        $this->client->request('get', \"/m/{$comment->magazine->name}/t/{$comment->entry->getId()}\");\n        $this->assertSelectorTextNotContains('#entry-comment-'.$comment->getId(), 'Moderate');\n\n        $this->client->request(\n            'get',\n            \"/m/{$comment->magazine->name}/t/{$comment->entry->getId()}/-/comment/{$comment->getId()}/moderate\"\n        );\n\n        $this->assertResponseStatusCodeSame(403);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Entry/EntryBoostControllerTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Entry;\n\nuse App\\Tests\\WebTestCase;\n\nclass EntryBoostControllerTest extends WebTestCase\n{\n    public function testLoggedUserCanBoostEntry(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n\n        $entry = $this->getEntryByTitle(\n            'test entry 1',\n            'https://kbin.pub',\n            null,\n            null,\n            $this->getUserByUsername('JaneDoe')\n        );\n\n        $crawler = $this->client->request('GET', \"/m/acme/t/{$entry->getId()}/test-entry-1\");\n\n        $this->client->submit(\n            $crawler->filter('#main .entry')->selectButton('Boost')->form([])\n        );\n\n        $crawler = $this->client->followRedirect();\n\n        $this->assertSelectorTextContains('#main .entry', 'Boost (1)');\n\n        $this->client->click($crawler->filter('#activity')->selectLink('Boosts (1)')->link());\n\n        $this->assertSelectorTextContains('#main .users-columns', 'JohnDoe');\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Entry/EntryChangeAdultControllerTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Entry;\n\nuse App\\Tests\\WebTestCase;\n\nclass EntryChangeAdultControllerTest extends WebTestCase\n{\n    public function testModCanMarkAsAdultContent(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n\n        $entry = $this->getEntryByTitle(\n            'test entry 1',\n            'https://kbin.pub',\n        );\n\n        $crawler = $this->client->request('GET', \"/m/acme/t/{$entry->getId()}/-/moderate\");\n        $this->client->submit(\n            $crawler->filter('.moderate-panel')->selectButton('Mark NSFW')->form([\n                'adult' => 'on',\n            ])\n        );\n        $this->client->followRedirect();\n        $this->assertSelectorTextContains('#main .entry .badge', '18+');\n\n        $crawler = $this->client->request('GET', \"/m/acme/t/{$entry->getId()}/-/moderate\");\n\n        $this->client->submit(\n            $crawler->filter('.moderate-panel')->selectButton('Unmark NSFW')->form([\n                'adult' => 'off',\n            ])\n        );\n        $this->client->followRedirect();\n        $this->assertSelectorTextNotContains('#main .entry', '18+');\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Entry/EntryChangeLangControllerTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Entry;\n\nuse App\\Tests\\WebTestCase;\n\nclass EntryChangeLangControllerTest extends WebTestCase\n{\n    public function testModCanChangeLanguage(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n\n        $entry = $this->getEntryByTitle(\n            'test entry 1',\n            'https://kbin.pub',\n        );\n\n        $crawler = $this->client->request('GET', \"/m/acme/t/{$entry->getId()}/-/moderate\");\n\n        $form = $crawler->filter('.moderate-panel')->selectButton('Change language')->form();\n\n        $this->assertSame($form['lang']['lang']->getValue(), 'en');\n\n        $form['lang']['lang']->select('fr');\n\n        $this->client->submit($form);\n        $this->client->followRedirect();\n\n        $this->assertSelectorTextContains('#main .badge-lang', 'French');\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Entry/EntryChangeMagazineControllerTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Entry;\n\nuse App\\Tests\\WebTestCase;\n\nclass EntryChangeMagazineControllerTest extends WebTestCase\n{\n    public function testAdminCanChangeMagazine(): void\n    {\n        $user = $this->getUserByUsername('JohnDoe');\n        $this->setAdmin($user);\n        $this->client->loginUser($user);\n\n        $this->getMagazineByName('kbin');\n\n        $entry = $this->getEntryByTitle(\n            'test entry 1',\n            'https://kbin.pub',\n        );\n\n        $crawler = $this->client->request('GET', \"/m/acme/t/{$entry->getId()}/-/moderate\");\n\n        $this->client->submit(\n            $crawler->filter('form[name=change_magazine]')->selectButton('Change magazine')->form(\n                [\n                    'change_magazine[new_magazine]' => 'kbin',\n                ]\n            )\n        );\n\n        $this->client->followRedirect();\n        $this->client->followRedirect();\n\n        $this->assertSelectorTextContains('.head-title', 'kbin');\n    }\n\n    public function testUnauthorizedUserCantChangeMagazine(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n\n        $this->getMagazineByName('kbin');\n\n        $entry = $this->getEntryByTitle(\n            'test entry 1',\n            'https://kbin.pub',\n        );\n\n        $this->client->request('GET', \"/m/acme/t/{$entry->getId()}/-/moderate\");\n\n        $this->assertSelectorTextNotContains('.moderate-panel', 'Change magazine');\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Entry/EntryCreateControllerTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Entry;\n\nuse App\\Tests\\WebTestCase;\nuse PHPUnit\\Framework\\Attributes\\Group;\n\nclass EntryCreateControllerTest extends WebTestCase\n{\n    public string $kibbyPath;\n\n    public function setUp(): void\n    {\n        parent::setUp();\n        $this->kibbyPath = \\dirname(__FILE__, 4).'/assets/kibby_emoji.png';\n    }\n\n    public function testUserCanCreateEntry()\n    {\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $this->client->request('GET', '/m/acme/new_entry');\n\n        $this->assertSelectorExists('form[name=entry]');\n    }\n\n    public function testUserCanCreateEntryLinkFromMagazinePage(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $this->getMagazineByName('acme');\n\n        $crawler = $this->client->request('GET', '/m/acme/new_entry');\n\n        $this->client->submit(\n            $crawler->filter('form[name=entry]')->selectButton('Add new thread')->form(\n                [\n                    'entry[url]' => 'https://kbin.pub',\n                    'entry[title]' => 'Test entry 1',\n                    'entry[body]' => 'Test body',\n                ]\n            )\n        );\n\n        $this->assertResponseRedirects('/m/acme/default/newest');\n        $this->client->followRedirect();\n\n        $this->assertSelectorTextContains('article h2', 'Test entry 1');\n    }\n\n    public function testUserCanCreateEntryArticleFromMagazinePage()\n    {\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $this->getMagazineByName('acme');\n\n        $crawler = $this->client->request('GET', '/m/acme/new_entry');\n\n        $this->client->submit(\n            $crawler->filter('form[name=entry]')->selectButton('Add new thread')->form(\n                [\n                    'entry[title]' => 'Test entry 1',\n                    'entry[body]' => 'Test body',\n                ]\n            )\n        );\n\n        $this->assertResponseRedirects('/m/acme/default/newest');\n        $this->client->followRedirect();\n\n        $this->assertSelectorTextContains('article h2', 'Test entry 1');\n    }\n\n    #[Group(name: 'NonThreadSafe')]\n    public function testUserCanCreateEntryPhotoFromMagazinePage()\n    {\n        $this->client->loginUser($this->getUserByUsername('user'));\n\n        $this->getMagazineByName('acme');\n        $repository = $this->entryRepository;\n\n        $crawler = $this->client->request('GET', '/m/acme/new_entry');\n\n        $this->assertSelectorExists('form[name=entry]');\n\n        $form = $crawler->filter('#main form[name=entry]')->selectButton('Add new thread')->form([\n            'entry[title]' => 'Test image 1',\n            'entry[image]' => $this->kibbyPath,\n        ]);\n        // Needed since we require this global to be set when validating entries but the client doesn't actually set it\n        $_FILES = $form->getPhpFiles();\n        $this->client->submit($form);\n\n        $this->assertResponseRedirects('/m/acme/default/newest');\n\n        $crawler = $this->client->followRedirect();\n\n        $this->assertSelectorTextContains('article h2', 'Test image 1');\n        $this->assertSelectorExists('figure img');\n        $imgSrc = $crawler->filter('figure img.thumb-subject')->getNode(0)->attributes->getNamedItem('src')->textContent;\n        $this->assertStringContainsString(self::KIBBY_PNG_URL_RESULT, $imgSrc);\n\n        $user = $this->getUserByUsername('user');\n        $entry = $repository->findOneBy(['user' => $user]);\n        $this->assertNotNull($entry);\n        $this->assertNotNull($entry->image);\n        $this->assertStringContainsString(self::KIBBY_PNG_URL_RESULT, $entry->image->filePath);\n        $_FILES = [];\n    }\n\n    public function testUserCanCreateEntryArticleForAdults()\n    {\n        $this->client->loginUser($this->getUserByUsername('user', hideAdult: false));\n\n        $this->getMagazineByName('acme');\n\n        $crawler = $this->client->request('GET', '/m/acme/new_entry');\n\n        $this->client->submit(\n            $crawler->filter('form[name=entry]')->selectButton('Add new thread')->form(\n                [\n                    'entry[title]' => 'Test entry 1',\n                    'entry[body]' => 'Test body',\n                    'entry[isAdult]' => '1',\n                ]\n            )\n        );\n\n        $this->assertResponseRedirects('/m/acme/default/newest');\n        $this->client->followRedirect();\n\n        $this->assertSelectorTextContains('article h2', 'Test entry 1');\n        $this->assertSelectorTextContains('article h2 .danger', '18+');\n    }\n\n    public function testPresetValues()\n    {\n        $this->client->loginUser($this->getUserByUsername('user', hideAdult: false));\n\n        $this->getMagazineByName('acme');\n\n        $crawler = $this->client->request('GET',\n            '/m/acme/new_entry?'\n                .'title=test'\n                .'&url='.urlencode('https://example.com#title')\n                .'&body='.urlencode(\"**Test**\\nbody\")\n                .'&imageAlt=alt'\n                .'&isNsfw=1'\n                .'&isOc=1'\n                .'&tags[]=1&tags[]=2'\n        );\n\n        $this->assertFormValue('form[name=entry]', 'entry[title]', 'test');\n        $this->assertFormValue('form[name=entry]', 'entry[url]', 'https://example.com#title');\n        $this->assertFormValue('form[name=entry]', 'entry[body]', \"**Test**\\nbody\");\n        $this->assertFormValue('form[name=entry]', 'entry[imageAlt]', 'alt');\n        $this->assertFormValue('form[name=entry]', 'entry[isAdult]', '1');\n        $this->assertFormValue('form[name=entry]', 'entry[isOc]', '1');\n        $this->assertFormValue('form[name=entry]', 'entry[tags]', '1 2');\n    }\n\n    public function testPresetImage()\n    {\n        $user = $this->getUserByUsername('user');\n        $this->client->loginUser($user);\n\n        $magazine = $this->getMagazineByName('acme');\n\n        $imgEntry = $this->createEntry('img', $magazine, $user, imageDto: $this->getKibbyImageDto());\n        $imgHash = strtok($imgEntry->image->fileName, '.');\n\n        // this  is necessary so the second entry is guaranteed to be newer than the first\n        sleep(1);\n\n        $crawler = $this->client->request('GET',\n            '/m/acme/new_entry?'\n            .'title=test'\n            .'&imageHash='.$imgHash\n        );\n\n        $this->client->submit($crawler->filter('form[name=entry]')->form());\n        $crawler = $this->client->followRedirect();\n\n        $this->assertSelectorTextContains('article h2', 'test');\n        $this->assertSelectorExists('figure img');\n        $imgSrc = $crawler->filter('figure img.thumb-subject')->getNode(0)->attributes->getNamedItem('src')->textContent;\n        $this->assertStringContainsString(self::KIBBY_PNG_URL_RESULT, $imgSrc);\n    }\n\n    public function testPresetImageNotFound()\n    {\n        $user = $this->getUserByUsername('user');\n        $this->client->loginUser($user);\n\n        $magazine = $this->getMagazineByName('acme');\n\n        $imgEntry = $this->createEntry('img', $magazine, $user, imageDto: $this->getKibbyImageDto());\n        $imgHash = strtok($imgEntry->image->fileName, '.');\n        $imgHash = substr($imgHash, 0, \\strlen($imgHash) - 1).'0';\n\n        // this  is necessary so the second entry is guaranteed to be newer than the first\n        sleep(1);\n\n        $crawler = $this->client->request('GET',\n            '/m/acme/new_entry?'\n            .'title=test'\n            .'&imageHash='.$imgHash\n        );\n\n        $this->assertSelectorTextContains('.alert.alert__danger', 'The image referenced by \\'imageHash\\' could not be found.');\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Entry/EntryDeleteControllerTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Entry;\n\nuse App\\Tests\\WebTestCase;\n\nclass EntryDeleteControllerTest extends WebTestCase\n{\n    public function testUserCanDeleteEntry()\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByName('acme');\n        $entry = $this->getEntryByTitle('deletion test', body: 'will be deleted', magazine: $magazine, user: $user);\n        $this->client->loginUser($user);\n\n        $crawler = $this->client->request('GET', '/m/acme');\n\n        $this->assertSelectorExists('form[action$=\"delete\"]');\n        $this->client->submit(\n            $crawler->filter('form[action$=\"delete\"]')->selectButton('Delete')->form()\n        );\n\n        $this->assertResponseRedirects();\n    }\n\n    public function testUserCanSoftDeleteEntry()\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByName('acme');\n        $entry = $this->getEntryByTitle('deletion test', body: 'will be deleted', magazine: $magazine, user: $user);\n        $comment = $this->createEntryComment('only softly', $entry, $user);\n        $this->client->loginUser($user);\n\n        $crawler = $this->client->request('GET', '/m/acme');\n\n        $this->assertSelectorExists('form[action$=\"delete\"]');\n        $this->client->submit(\n            $crawler->filter('form[action$=\"delete\"]')->selectButton('Delete')->form()\n        );\n\n        $this->assertResponseRedirects();\n        $this->client->request('GET', \"/m/acme/t/{$entry->getId()}/deletion-test\");\n\n        $translator = $this->translator;\n\n        $this->assertSelectorTextContains(\"#entry-{$entry->getId()} header\", $translator->trans('deleted_by_author'));\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Entry/EntryEditControllerTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Entry;\n\nuse App\\Tests\\WebTestCase;\nuse PHPUnit\\Framework\\Attributes\\Group;\n\nclass EntryEditControllerTest extends WebTestCase\n{\n    public function testAuthorCanEditOwnEntryLink(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n\n        $entry = $this->getEntryByTitle('test entry 1', 'https://kbin.pub');\n        $crawler = $this->client->request('GET', \"/m/acme/t/{$entry->getId()}/test-entry-1\");\n\n        $crawler = $this->client->click($crawler->filter('#main .entry')->selectLink('Edit')->link());\n\n        $this->assertSelectorExists('#main .entry_edit');\n\n        $this->assertInputValueSame('entry_edit[url]', 'https://kbin.pub');\n        $this->assertEquals('disabled', $crawler->filter('#entry_edit_magazine')->attr('disabled'));\n\n        $this->client->submit(\n            $crawler->filter('form[name=entry_edit]')->selectButton('Edit thread')->form(\n                [\n                    'entry_edit[title]' => 'test entry 2 title',\n                    'entry_edit[body]' => 'test entry 2 body',\n                ]\n            )\n        );\n\n        $this->client->followRedirect();\n\n        $this->assertSelectorTextContains('#main .entry header', 'test entry 2 title');\n        $this->assertSelectorTextContains('#main .entry .entry__body', 'test entry 2 body');\n    }\n\n    public function testAuthorCanEditOwnEntryArticle(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n\n        $entry = $this->getEntryByTitle('test entry 1', null, 'entry content test entry 1');\n        $crawler = $this->client->request('GET', \"/m/acme/t/{$entry->getId()}/test-entry-1\");\n\n        $crawler = $this->client->click($crawler->filter('#main .entry')->selectLink('Edit')->link());\n\n        $this->assertSelectorExists('#main .entry_edit');\n\n        $this->assertEquals('disabled', $crawler->filter('#entry_edit_magazine')->attr('disabled'));\n\n        $this->client->submit(\n            $crawler->filter('form[name=entry_edit]')->selectButton('Edit thread')->form(\n                [\n                    'entry_edit[title]' => 'test entry 2 title',\n                    'entry_edit[body]' => 'test entry 2 body',\n                ]\n            )\n        );\n\n        $this->client->followRedirect();\n\n        $this->assertSelectorTextContains('#main .entry header', 'test entry 2 title');\n        $this->assertSelectorTextContains('#main .entry .entry__body', 'test entry 2 body');\n    }\n\n    #[Group(name: 'NonThreadSafe')]\n    public function testAuthorCanEditOwnEntryImage(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n        $imageDto = $this->getKibbyImageDto();\n        $entry = $this->getEntryByTitle('test entry 1', image: $imageDto);\n        $crawler = $this->client->request('GET', \"/m/acme/t/{$entry->getId()}/test-entry-1\");\n        $this->assertResponseIsSuccessful();\n\n        $crawler = $this->client->click($crawler->filter('#main .entry')->selectLink('Edit')->link());\n        $this->assertResponseIsSuccessful();\n\n        $this->assertSelectorExists('#main .entry_edit');\n        $this->assertSelectorExists('#main .entry_edit img');\n        $node = $crawler->selectImage('kibby')->getNode(0);\n        $this->assertNotNull($node);\n        $this->assertStringContainsString($imageDto->filePath, $node->attributes->getNamedItem('src')->textContent);\n\n        $this->assertEquals('disabled', $crawler->filter('#entry_edit_magazine')->attr('disabled'));\n\n        $this->client->submit(\n            $crawler->filter('form[name=entry_edit]')->selectButton('Edit thread')->form(\n                [\n                    'entry_edit[title]' => 'test entry 2 title',\n                ]\n            )\n        );\n\n        $crawler = $this->client->followRedirect();\n\n        $this->assertSelectorTextContains('#main .entry header', 'test entry 2 title');\n        $this->assertSelectorExists('#main .entry img');\n        $node = $crawler->selectImage('kibby')->getNode(0);\n        $this->assertNotNull($node);\n        $this->assertStringContainsString($imageDto->filePath, $node->attributes->getNamedItem('src')->textContent);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Entry/EntryFrontControllerTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Entry;\n\nuse App\\DTO\\ModeratorDto;\nuse App\\Enums\\ESortOptions;\nuse App\\Service\\MagazineManager;\nuse App\\Tests\\WebTestCase;\nuse Symfony\\Bundle\\FrameworkBundle\\KernelBrowser;\n\nclass EntryFrontControllerTest extends WebTestCase\n{\n    public function testRootPage(): void\n    {\n        $this->client = $this->prepareEntries();\n\n        $this->client->request('GET', '/');\n        $this->assertSelectorTextContains('h1', 'Hot');\n\n        $crawler = $this->client->request('GET', '/newest');\n\n        $this->assertSelectorTextContains('.entry__meta', 'JohnDoe');\n        $this->assertSelectorTextContains('.entry__meta', 'to acme');\n\n        $this->assertcount(2, $crawler->filter('.entry'));\n\n        foreach ($this->getSortOptions() as $sortOption) {\n            $crawler = $this->client->click($crawler->filter('.options__filter')->selectLink($sortOption)->link());\n            $this->assertSelectorTextContains('.options__filter', $sortOption);\n            $this->assertSelectorTextContains('h1', ucfirst($sortOption));\n        }\n    }\n\n    public function testXmlRootPage(): void\n    {\n        $this->getEntryByTitle('test entry 1', 'https://kbin.pub');\n\n        $this->client->setServerParameter('HTTP_X-Requested-With', 'XMLHttpRequest');\n        $this->client->request('GET', '/');\n\n        $this->assertStringContainsString('{\"html\":', $this->client->getResponse()->getContent());\n    }\n\n    public function testXmlRootPageIsFrontPage(): void\n    {\n        $this->getEntryByTitle('test entry 1', 'https://kbin.pub');\n\n        $this->client->setServerParameter('HTTP_X-Requested-With', 'XMLHttpRequest');\n        $this->client->request('GET', '/');\n\n        $root_content = self::removeTimeElements($this->clearTokens($this->client->getResponse()->getContent()));\n\n        $this->client->request('GET', '/all');\n        $frontContent = self::removeTimeElements($this->clearTokens($this->client->getResponse()->getContent()));\n\n        $this->assertSame($root_content, $frontContent);\n    }\n\n    public function testFrontPage(): void\n    {\n        $this->client = $this->prepareEntries();\n\n        $this->client->request('GET', '/all');\n        $this->assertSelectorTextContains('h1', 'Hot');\n\n        $crawler = $this->client->request('GET', '/all/newest');\n\n        $this->assertSelectorTextContains('.entry__meta', 'JohnDoe');\n        $this->assertSelectorTextContains('.entry__meta', 'to acme');\n\n        $this->assertcount(2, $crawler->filter('.entry'));\n\n        foreach ($this->getSortOptions() as $sortOption) {\n            $crawler = $this->client->click($crawler->filter('.options__filter')->selectLink($sortOption)->link());\n            $this->assertSelectorTextContains('.options__filter', $sortOption);\n            $this->assertSelectorTextContains('h1', ucfirst($sortOption));\n        }\n    }\n\n    public function testXmlFrontPage(): void\n    {\n        $this->getEntryByTitle('test entry 1', 'https://kbin.pub');\n\n        $this->client->setServerParameter('HTTP_X-Requested-With', 'XMLHttpRequest');\n        $this->client->request('GET', '/all');\n\n        $this->assertStringContainsString('{\"html\":', $this->client->getResponse()->getContent());\n    }\n\n    public function testMagazinePage(): void\n    {\n        $this->client = $this->prepareEntries();\n\n        $this->client->request('GET', '/m/acme');\n        $this->assertSelectorTextContains('h2', 'Hot');\n\n        $this->client->request('GET', '/m/ACME');\n        $this->assertSelectorTextContains('h2', 'Hot');\n\n        $crawler = $this->client->request('GET', '/m/acme/threads/newest');\n\n        $this->assertSelectorTextContains('.entry__meta', 'JohnDoe');\n        $this->assertSelectorTextNotContains('.entry__meta', 'to acme');\n\n        $this->assertSelectorTextContains('.head-title', '/m/acme');\n        $this->assertSelectorTextContains('#sidebar .magazine', 'acme');\n\n        $this->assertSelectorTextContains('#header .active', 'Threads');\n\n        $this->assertcount(1, $crawler->filter('.entry'));\n\n        foreach ($this->getSortOptions() as $sortOption) {\n            $crawler = $this->client->click($crawler->filter('.options__filter')->selectLink($sortOption)->link());\n            $this->assertSelectorTextContains('.options__filter', $sortOption);\n            $this->assertSelectorTextContains('h1', 'acme');\n            $this->assertSelectorTextContains('h2', ucfirst($sortOption));\n        }\n    }\n\n    public function testXmlMagazinePage(): void\n    {\n        $this->getEntryByTitle('test entry 1', 'https://kbin.pub');\n\n        $this->client->setServerParameter('HTTP_X-Requested-With', 'XMLHttpRequest');\n        $this->client->request('GET', '/m/acme/newest');\n\n        self::assertResponseIsSuccessful();\n\n        $this->assertStringContainsString('{\"html\":', $this->client->getResponse()->getContent());\n    }\n\n    public function testSubPage(): void\n    {\n        $this->client = $this->prepareEntries();\n\n        $magazineManager = $this->client->getContainer()->get(MagazineManager::class);\n        $magazineManager->subscribe($this->getMagazineByName('acme'), $this->getUserByUsername('Actor'));\n\n        $this->client->loginUser($this->getUserByUsername('Actor'));\n\n        $this->client->request('GET', '/sub');\n        $this->assertSelectorTextContains('h1', 'Hot');\n\n        $crawler = $this->client->request('GET', '/sub/threads/newest');\n\n        $this->assertSelectorTextContains('.entry__meta', 'JohnDoe');\n        $this->assertSelectorTextContains('.entry__meta', 'to acme');\n\n        $this->assertSelectorTextContains('.head-title', '/sub');\n\n        $this->assertSelectorTextContains('#header .active', 'Threads');\n\n        $this->assertcount(1, $crawler->filter('.entry'));\n\n        foreach ($this->getSortOptions() as $sortOption) {\n            $crawler = $this->client->click($crawler->filter('.options__filter')->selectLink($sortOption)->link());\n            $this->assertSelectorTextContains('.options__filter', $sortOption);\n            $this->assertSelectorTextContains('h1', ucfirst($sortOption));\n        }\n    }\n\n    public function testXmlSubPage(): void\n    {\n        $this->getEntryByTitle('test entry 1', 'https://kbin.pub');\n\n        $magazineManager = $this->client->getContainer()->get(MagazineManager::class);\n        $magazineManager->subscribe($this->getMagazineByName('acme'), $this->getUserByUsername('Actor'));\n\n        $this->client->loginUser($this->getUserByUsername('Actor'));\n\n        $this->client->setServerParameter('HTTP_X-Requested-With', 'XMLHttpRequest');\n        $this->client->request('GET', '/sub');\n\n        $this->assertStringContainsString('{\"html\":', $this->client->getResponse()->getContent());\n    }\n\n    public function testModPage(): void\n    {\n        $this->client = $this->prepareEntries();\n        $admin = $this->getUserByUsername('admin', isAdmin: true);\n\n        $magazineManager = $this->client->getContainer()->get(MagazineManager::class);\n        $moderator = new ModeratorDto($this->getMagazineByName('acme'));\n        $moderator->user = $this->getUserByUsername('Actor');\n        $moderator->addedBy = $admin;\n        $magazineManager->addModerator($moderator);\n\n        $this->client->loginUser($this->getUserByUsername('Actor'));\n\n        $this->client->request('GET', '/mod');\n        $this->assertSelectorTextContains('h1', 'Hot');\n\n        $crawler = $this->client->request('GET', '/mod/threads/newest');\n\n        $this->assertSelectorTextContains('.entry__meta', 'JohnDoe');\n        $this->assertSelectorTextContains('.entry__meta', 'to acme');\n\n        $this->assertSelectorTextContains('.head-title', '/mod');\n\n        $this->assertSelectorTextContains('#header .active', 'Threads');\n\n        $this->assertcount(1, $crawler->filter('.entry'));\n\n        foreach ($this->getSortOptions() as $sortOption) {\n            $crawler = $this->client->click($crawler->filter('.options__filter')->selectLink($sortOption)->link());\n            $this->assertSelectorTextContains('.options__filter', $sortOption);\n            $this->assertSelectorTextContains('h1', ucfirst($sortOption));\n        }\n    }\n\n    public function testXmlModPage(): void\n    {\n        $admin = $this->getUserByUsername('admin', isAdmin: true);\n\n        $this->getEntryByTitle('test entry 1', 'https://kbin.pub');\n\n        $magazineManager = $this->client->getContainer()->get(MagazineManager::class);\n        $moderator = new ModeratorDto($this->getMagazineByName('acme'));\n        $moderator->user = $this->getUserByUsername('Actor');\n        $moderator->addedBy = $admin;\n        $magazineManager->addModerator($moderator);\n\n        $this->client->loginUser($this->getUserByUsername('Actor'));\n\n        $this->client->setServerParameter('HTTP_X-Requested-With', 'XMLHttpRequest');\n        $this->client->request('GET', '/mod');\n\n        $this->assertStringContainsString('{\"html\":', $this->client->getResponse()->getContent());\n    }\n\n    public function testFavPage(): void\n    {\n        $this->client = $this->prepareEntries();\n\n        $favouriteManager = $this->favouriteManager;\n        $favouriteManager->toggle(\n            $this->getUserByUsername('Actor'),\n            $this->getEntryByTitle('test entry 1', 'https://kbin.pub')\n        );\n\n        $this->client->loginUser($this->getUserByUsername('Actor'));\n\n        $this->client->request('GET', '/fav');\n        $this->assertSelectorTextContains('h1', 'Hot');\n\n        $crawler = $this->client->request('GET', '/fav/threads/newest');\n\n        $this->assertSelectorTextContains('.entry__meta', 'JaneDoe');\n        $this->assertSelectorTextContains('.entry__meta', 'to kbin');\n\n        $this->assertSelectorTextContains('.head-title', '/fav');\n\n        $this->assertSelectorTextContains('#header .active', 'Threads');\n\n        $this->assertcount(1, $crawler->filter('.entry'));\n\n        foreach ($this->getSortOptions() as $sortOption) {\n            $crawler = $this->client->click($crawler->filter('.options__filter')->selectLink($sortOption)->link());\n            $this->assertSelectorTextContains('.options__filter', $sortOption);\n            $this->assertSelectorTextContains('h1', ucfirst($sortOption));\n        }\n    }\n\n    public function testXmlFavPage(): void\n    {\n        $this->getEntryByTitle('test entry 1', 'https://kbin.pub');\n\n        $favouriteManager = $this->favouriteManager;\n        $favouriteManager->toggle(\n            $this->getUserByUsername('Actor'),\n            $this->getEntryByTitle('test entry 1', 'https://kbin.pub')\n        );\n\n        $this->client->loginUser($this->getUserByUsername('Actor'));\n\n        $this->client->setServerParameter('HTTP_X-Requested-With', 'XMLHttpRequest');\n        $this->client->request('GET', '/fav');\n\n        $this->assertStringContainsString('{\"html\":', $this->client->getResponse()->getContent());\n    }\n\n    public function testCustomDefaultSort(): void\n    {\n        $older = $this->getEntryByTitle('Older entry');\n        $older->createdAt = new \\DateTimeImmutable('now - 1 day');\n        $older->updateRanking();\n        $this->entityManager->flush();\n        $newer = $this->getEntryByTitle('Newer entry');\n        $comment = $this->createEntryComment('someone was here', entry: $older);\n        self::assertGreaterThan($older->getRanking(), $newer->getRanking());\n\n        $user = $this->getUserByUsername('user');\n        $user->frontDefaultSort = ESortOptions::Newest->value;\n        $this->entityManager->flush();\n\n        $this->client->loginUser($user);\n        $crawler = $this->client->request('GET', '/');\n\n        self::assertResponseIsSuccessful();\n        self::assertSelectorTextContains('.options__filter button', $this->translator->trans(ESortOptions::Newest->value));\n\n        $iterator = $crawler->filter('#content div')->children()->getIterator();\n        /** @var \\DOMElement $firstNode */\n        $firstNode = $iterator->current();\n        $firstId = $firstNode->attributes->getNamedItem('id')->nodeValue;\n        self::assertEquals(\"entry-{$newer->getId()}\", $firstId);\n        $iterator->next();\n        $secondNode = $iterator->current();\n        $secondId = $secondNode->attributes->getNamedItem('id')->nodeValue;\n        self::assertEquals(\"entry-{$older->getId()}\", $secondId);\n\n        $user->frontDefaultSort = ESortOptions::Commented->value;\n        $this->entityManager->flush();\n\n        $crawler = $this->client->request('GET', '/');\n\n        self::assertResponseIsSuccessful();\n        self::assertSelectorTextContains('.options__filter button', $this->translator->trans(ESortOptions::Commented->value));\n\n        $iterator = $crawler->filter('#content div')->children()->getIterator();\n        /** @var \\DOMElement $firstNode */\n        $firstNode = $iterator->current();\n        $firstId = $firstNode->attributes->getNamedItem('id')->nodeValue;\n        self::assertEquals(\"entry-{$older->getId()}\", $firstId);\n        $iterator->next();\n        $secondNode = $iterator->current();\n        $secondId = $secondNode->attributes->getNamedItem('id')->nodeValue;\n        self::assertEquals(\"entry-{$newer->getId()}\", $secondId);\n    }\n\n    private function prepareEntries(): KernelBrowser\n    {\n        $older = $this->getEntryByTitle(\n            'test entry 1',\n            'https://kbin.pub',\n            null,\n            $this->getMagazineByName('kbin', $this->getUserByUsername('JaneDoe')),\n            $this->getUserByUsername('JaneDoe')\n        );\n        $older->createdAt = new \\DateTimeImmutable('now - 1 minute');\n\n        $this->getEntryByTitle('test entry 2', 'https://kbin.pub');\n\n        return $this->client;\n    }\n\n    private function getSortOptions(): array\n    {\n        return ['Top', 'Hot', 'Newest', 'Active', 'Commented'];\n    }\n\n    private function clearTokens(string $responseContent): string\n    {\n        return preg_replace(\n            '#name=\"token\" value=\".+\"#',\n            '',\n            json_decode($responseContent, true, 512, JSON_THROW_ON_ERROR),\n        )['html'];\n    }\n\n    private function clearDateTimes(string $responseContent): string\n    {\n        return preg_replace(\n            '/<time ?[ \\w=\\\"\\'\\-:+\\n]*>[ \\w\\n]*<\\/time>/m',\n            '',\n            $responseContent\n        );\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Entry/EntryLockControllerTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Entry;\n\nuse App\\Tests\\WebTestCase;\n\nclass EntryLockControllerTest extends WebTestCase\n{\n    public function testModCanLockEntry(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n\n        $entry = $this->getEntryByTitle('test entry 1', 'https://kbin.pub');\n\n        $crawler = $this->client->request('GET', \"/m/acme/t/{$entry->getId()}/-/moderate\");\n\n        $this->client->submit($crawler->filter('#main .moderate-panel')->selectButton('Lock')->form([]));\n        $crawler = $this->client->followRedirect();\n        $this->assertSelectorExists('#main .entry footer span .fa-lock');\n\n        $this->client->submit($crawler->filter('#main .moderate-panel')->selectButton('Unlock')->form([]));\n        $this->client->followRedirect();\n        $this->assertSelectorNotExists('#main .entry footer span .fa-lock');\n    }\n\n    public function testAuthorCanLockEntry(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $this->client->loginUser($user);\n\n        $entry = $this->getEntryByTitle('test entry 1', 'https://kbin.pub', user: $user);\n        $crawler = $this->client->request('GET', \"/m/acme/t/{$entry->getId()}\");\n        $this->assertSelectorExists('#main .entry footer .dropdown .fa-lock');\n\n        $this->client->submit($crawler->filter('#main .entry footer .dropdown')->selectButton('Lock')->form([]));\n        $this->client->followRedirect();\n        $this->assertSelectorExists('#main .entry footer span .fa-lock');\n    }\n\n    public function testModCanUnlockEntry(): void\n    {\n        $user = $this->getUserByUsername('JohnDoe');\n        $this->client->loginUser($user);\n\n        $entry = $this->getEntryByTitle('test entry 1', 'https://kbin.pub');\n        $this->entryManager->toggleLock($entry, $user);\n\n        $crawler = $this->client->request('GET', \"/m/acme/t/{$entry->getId()}/-/moderate\");\n        $this->assertSelectorExists('#main .entry footer span .fa-lock');\n\n        $this->client->submit($crawler->filter('#main .moderate-panel')->selectButton('Unlock')->form([]));\n        $this->client->followRedirect();\n        $this->assertSelectorNotExists('#main .entry footer span .fa-lock');\n    }\n\n    public function testAuthorCanUnlockEntry(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $this->client->loginUser($user);\n\n        $entry = $this->getEntryByTitle('test entry 1', 'https://kbin.pub', user: $user);\n        $this->entryManager->toggleLock($entry, $user);\n\n        $crawler = $this->client->request('GET', \"/m/acme/t/{$entry->getId()}\");\n        $this->assertSelectorExists('#main .entry footer span .fa-lock');\n\n        $this->client->submit($crawler->filter('#main .entry footer .dropdown')->selectButton('Unlock')->form([]));\n        $this->client->followRedirect();\n        $this->assertSelectorNotExists('#main .entry footer span .fa-lock');\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Entry/EntryModerateControllerTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Entry;\n\nuse App\\Tests\\WebTestCase;\n\nclass EntryModerateControllerTest extends WebTestCase\n{\n    public function testModCanShowPanel(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n\n        $entry = $this->getEntryByTitle('test entry 1', 'https://kbin.pub');\n\n        $crawler = $this->client->request('get', '/');\n        $this->client->click($crawler->filter('#entry-'.$entry->getId())->selectLink('Moderate')->link());\n\n        $this->assertSelectorTextContains('.moderate-panel', 'ban');\n    }\n\n    public function testXmlModCanShowPanel(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n\n        $entry = $this->getEntryByTitle('test entry 1', 'https://kbin.pub');\n\n        $crawler = $this->client->request('get', '/');\n        $this->client->setServerParameter('HTTP_X-Requested-With', 'XMLHttpRequest');\n        $this->client->click($crawler->filter('#entry-'.$entry->getId())->selectLink('Moderate')->link());\n\n        $this->assertStringContainsString('moderate-panel', $this->client->getResponse()->getContent());\n    }\n\n    public function testUnauthorizedCanNotShowPanel(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JaneDoe'));\n\n        $entry = $this->getEntryByTitle('test entry 1', 'https://kbin.pub');\n\n        $this->client->request('get', \"/m/{$entry->magazine->name}/t/{$entry->getId()}\");\n        $this->assertSelectorTextNotContains('#entry-'.$entry->getId(), 'Moderate');\n\n        $this->client->request(\n            'get',\n            \"/m/{$entry->magazine->name}/t/{$entry->getId()}/-/moderate\"\n        );\n\n        $this->assertResponseStatusCodeSame(403);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Entry/EntryPinControllerTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Entry;\n\nuse App\\Tests\\WebTestCase;\n\nclass EntryPinControllerTest extends WebTestCase\n{\n    public function testModCanPinEntry(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n\n        $entry = $this->getEntryByTitle(\n            'test entry 1',\n            'https://kbin.pub',\n        );\n\n        $crawler = $this->client->request('GET', \"/m/acme/t/{$entry->getId()}/-/moderate\");\n\n        $this->client->submit($crawler->filter('#main .moderate-panel')->selectButton('Pin')->form([]));\n        $crawler = $this->client->followRedirect();\n        $this->assertSelectorExists('#main .entry .fa-thumbtack');\n\n        $this->client->submit($crawler->filter('#main .moderate-panel')->selectButton('Unpin')->form([]));\n        $this->client->followRedirect();\n        $this->assertSelectorNotExists('#main .entry .fa-thumbtack');\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Entry/EntrySingleControllerTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Entry;\n\nuse App\\Entity\\Contracts\\VotableInterface;\nuse App\\Enums\\ESortOptions;\nuse App\\Service\\FavouriteManager;\nuse App\\Service\\VoteManager;\nuse App\\Tests\\WebTestCase;\n\nclass EntrySingleControllerTest extends WebTestCase\n{\n    public function testUserCanGoToEntryFromFrontpage(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n\n        $this->getEntryByTitle('test entry 1', 'https://kbin.pub');\n\n        $crawler = $this->client->request('GET', '/');\n\n        $this->client->click($crawler->selectLink('test entry 1')->link());\n\n        $this->assertSelectorTextContains('.head-title', '/m/acme');\n        $this->assertSelectorTextContains('#header nav .active', 'Threads');\n        $this->assertSelectorTextContains('article h1', 'test entry 1');\n        $this->assertSelectorTextContains('#main', 'No comments');\n        $this->assertSelectorTextContains('#sidebar .entry-info', 'Thread');\n        $this->assertSelectorTextContains('#sidebar .magazine', 'Magazine');\n        $this->assertSelectorTextContains('#sidebar .user-list', 'Moderators');\n    }\n\n    public function testUserCanSeeArticle(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n\n        $entry = $this->getEntryByTitle('test entry 1', null, 'Test entry content');\n\n        $this->client->request('GET', \"/m/acme/t/{$entry->getId()}/test-entry-1\");\n\n        $this->assertSelectorTextContains('article h1', 'test entry 1');\n        $this->assertSelectorNotExists('article h1 > a');\n        $this->assertSelectorTextContains('article', 'Test entry content');\n    }\n\n    public function testUserCanSeeLink(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n\n        $entry = $this->getEntryByTitle('test entry 1', 'https://kbin.pub');\n\n        $this->client->request('GET', \"/m/acme/t/{$entry->getId()}/test-entry-1\");\n        $this->assertSelectorExists('article h1 a[href=\"https://kbin.pub\"]', 'test entry 1');\n    }\n\n    public function testPostActivityCounter(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n\n        $entry = $this->getEntryByTitle('test entry 1', 'https://kbin.pub');\n\n        $manager = $this->client->getContainer()->get(VoteManager::class);\n        $manager->vote(VotableInterface::VOTE_DOWN, $entry, $this->getUserByUsername('JaneDoe'));\n\n        $manager = $this->client->getContainer()->get(FavouriteManager::class);\n        $manager->toggle($this->getUserByUsername('JohnDoe'), $entry);\n        $manager->toggle($this->getUserByUsername('JaneDoe'), $entry);\n\n        $this->client->request('GET', \"/m/acme/t/{$entry->getId()}/test-entry-1\");\n\n        $this->assertSelectorTextContains('.options-activity', 'Activity (2)');\n    }\n\n    public function testCanSortComments()\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n\n        $entry = $this->getEntryByTitle('test entry 1', 'https://kbin.pub');\n        $this->createEntryComment('test comment 1', $entry);\n        $this->createEntryComment('test comment 2', $entry);\n\n        $crawler = $this->client->request('GET', \"/m/acme/t/{$entry->getId()}/test-entry-1\");\n        foreach ($this->getSortOptions() as $sortOption) {\n            $crawler = $this->client->click($crawler->filter('.options__filter')->selectLink($sortOption)->link());\n            $this->assertSelectorTextContains('.options__filter', $sortOption);\n        }\n    }\n\n    public function testCommentsDefaultSortOption(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $entry = $this->getEntryByTitle('entry');\n        $older = $this->createEntryComment('older comment', entry: $entry);\n        $older->createdAt = new \\DateTimeImmutable('now - 1 day');\n        $newer = $this->createEntryComment('newer comment', entry: $entry);\n\n        $user->commentDefaultSort = ESortOptions::Oldest->value;\n        $this->entityManager->flush();\n\n        $this->client->loginUser($user);\n        $crawler = $this->client->request('GET', \"/m/{$entry->magazine->name}/t/{$entry->getId()}/-\");\n        self::assertResponseIsSuccessful();\n        $this->assertSelectorTextContains('.options__filter .active', $this->translator->trans(ESortOptions::Oldest->value));\n\n        $iterator = $crawler->filter('#comments div')->children()->getIterator();\n        /** @var \\DOMElement $firstNode */\n        $firstNode = $iterator->current();\n        $firstId = $firstNode->attributes->getNamedItem('id')->nodeValue;\n        self::assertEquals(\"entry-comment-{$older->getId()}\", $firstId);\n        $iterator->next();\n        $secondNode = $iterator->current();\n        $secondId = $secondNode->attributes->getNamedItem('id')->nodeValue;\n        self::assertEquals(\"entry-comment-{$newer->getId()}\", $secondId);\n\n        $user->commentDefaultSort = ESortOptions::Newest->value;\n        $this->entityManager->flush();\n\n        $crawler = $this->client->request('GET', \"/m/{$entry->magazine->name}/t/{$entry->getId()}/-\");\n        self::assertResponseIsSuccessful();\n        $this->assertSelectorTextContains('.options__filter .active', $this->translator->trans(ESortOptions::Newest->value));\n\n        $iterator = $crawler->filter('#comments div')->children()->getIterator();\n        /** @var \\DOMElement $firstNode */\n        $firstNode = $iterator->current();\n        $firstId = $firstNode->attributes->getNamedItem('id')->nodeValue;\n        self::assertEquals(\"entry-comment-{$newer->getId()}\", $firstId);\n        $iterator->next();\n        $secondNode = $iterator->current();\n        $secondId = $secondNode->attributes->getNamedItem('id')->nodeValue;\n        self::assertEquals(\"entry-comment-{$older->getId()}\", $secondId);\n    }\n\n    private function getSortOptions(): array\n    {\n        return ['Top', 'Hot', 'Newest', 'Active', 'Oldest'];\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Entry/EntryVotersControllerTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Entry;\n\nuse App\\Entity\\Contracts\\VotableInterface;\nuse App\\Service\\VoteManager;\nuse App\\Tests\\WebTestCase;\n\nclass EntryVotersControllerTest extends WebTestCase\n{\n    public function testUserCanSeeUpVoters(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n\n        $entry = $this->getEntryByTitle('test entry 1', 'https://kbin.pub');\n\n        $manager = $this->client->getContainer()->get(VoteManager::class);\n        $manager->vote(VotableInterface::VOTE_UP, $entry, $this->getUserByUsername('JaneDoe'));\n\n        $crawler = $this->client->request('GET', \"/m/acme/t/{$entry->getId()}/test-entry-1\");\n\n        $this->client->click($crawler->filter('.options-activity')->selectLink('Boosts (1)')->link());\n\n        $this->assertSelectorTextContains('#main .users-columns', 'JaneDoe');\n    }\n\n    public function testUserCannotSeeDownVoters(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n\n        $entry = $this->getEntryByTitle('test entry 1', 'https://kbin.pub');\n\n        $manager = $this->client->getContainer()->get(VoteManager::class);\n        $manager->vote(VotableInterface::VOTE_DOWN, $entry, $this->getUserByUsername('JaneDoe'));\n\n        $crawler = $this->client->request('GET', \"/m/acme/t/{$entry->getId()}/test-entry-1\");\n\n        $crawler = $crawler->filter('.options-activity')->selectLink('Reduces (1)');\n        self::assertEquals(0, $crawler->count());\n\n        $this->assertSelectorTextContains('.options-activity', 'Reduces (1)');\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Magazine/MagazineBlockControllerTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Magazine;\n\nuse App\\Tests\\WebTestCase;\n\nclass MagazineBlockControllerTest extends WebTestCase\n{\n    public function testUserCanBlockAndUnblockMagazine(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JaneDoe'));\n\n        $this->getMagazineByName('acme');\n\n        $crawler = $this->client->request('GET', '/m/acme');\n\n        // Block magazine\n        $this->client->submit($crawler->filter('#sidebar form[name=magazine_block] button')->form());\n        $crawler = $this->client->followRedirect();\n        $this->assertSelectorExists('#sidebar form[name=magazine_block] .active');\n\n        // Unblock magazine\n        $this->client->submit($crawler->filter('#sidebar form[name=magazine_block] button')->form());\n        $this->client->followRedirect();\n        $this->assertSelectorNotExists('#sidebar form[name=magazine_block] .active');\n    }\n\n    public function testXmlUserCanBlockMagazine(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JaneDoe'));\n\n        $this->getMagazineByName('acme');\n\n        $crawler = $this->client->request('GET', '/m/acme');\n\n        $this->client->submit($crawler->filter('#sidebar form[name=magazine_block] button')->form());\n        $this->client->setServerParameter('HTTP_X-Requested-With', 'XMLHttpRequest');\n        $this->client->submit($crawler->filter('#sidebar form[name=magazine_block] button')->form());\n\n        $this->assertStringContainsString('{\"html\":', $this->client->getResponse()->getContent());\n        $this->assertStringContainsString('active', $this->client->getResponse()->getContent());\n    }\n\n    public function testXmlUserCanUnblockMagazine(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JaneDoe'));\n\n        $this->getMagazineByName('acme');\n\n        $crawler = $this->client->request('GET', '/m/acme');\n\n        // Block magazine\n        $this->client->submit($crawler->filter('#sidebar form[name=magazine_block] button')->form());\n        $crawler = $this->client->followRedirect();\n\n        // Unblock magazine\n        $this->client->submit($crawler->filter('#sidebar form[name=magazine_block] button')->form());\n        $this->client->setServerParameter('HTTP_X-Requested-With', 'XMLHttpRequest');\n        $this->client->submit($crawler->filter('#sidebar form[name=magazine_block] button')->form());\n\n        $this->assertStringContainsString('{\"html\":', $this->client->getResponse()->getContent());\n        $this->assertStringNotContainsString('active', $this->client->getResponse()->getContent());\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Magazine/MagazineCreateControllerTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Magazine;\n\nuse App\\Tests\\WebTestCase;\n\nclass MagazineCreateControllerTest extends WebTestCase\n{\n    public function testUserCanCreateMagazine(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n\n        $crawler = $this->client->request('GET', '/newMagazine');\n\n        $this->client->submit(\n            $crawler->filter('form[name=magazine]')->selectButton('Create new magazine')->form(\n                [\n                    'magazine[name]' => 'TestMagazine',\n                    'magazine[title]' => 'Test magazine title',\n                ]\n            )\n        );\n\n        $this->assertResponseRedirects('/m/TestMagazine');\n\n        $this->client->followRedirect();\n\n        $this->assertSelectorTextContains('.head-title', '/m/TestMagazine');\n        $this->assertSelectorTextContains('#content', 'Empty');\n    }\n\n    public function testUserCantCreateInvalidMagazine(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n\n        $crawler = $this->client->request('GET', '/newMagazine');\n\n        $this->client->submit(\n            $crawler->filter('form[name=magazine]')->selectButton('Create new magazine')->form(\n                [\n                    'magazine[name]' => 't',\n                    'magazine[title]' => 'Test magazine title',\n                ]\n            )\n        );\n\n        $this->assertSelectorTextContains('#content', 'This value is too short. It should have 2 characters or more.');\n    }\n\n    public function testUserCantCreateTwoSameMagazines(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JaneDoe'));\n\n        $this->getMagazineByName('acme');\n\n        $crawler = $this->client->request('GET', '/newMagazine');\n\n        $this->client->submit(\n            $crawler->filter('form[name=magazine]')->selectButton('Create new magazine')->form(\n                [\n                    'magazine[name]' => 'acme',\n                    'magazine[title]' => 'Test magazine title',\n                ]\n            )\n        );\n\n        $this->assertSelectorTextContains('#content', 'This value is already used.');\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Magazine/MagazineListControllerTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Magazine;\n\nuse App\\Tests\\WebTestCase;\nuse PHPUnit\\Framework\\Attributes\\DataProvider;\nuse Symfony\\Component\\DomCrawler\\Crawler;\n\nclass MagazineListControllerTest extends WebTestCase\n{\n    #[DataProvider('magazines')]\n    public function testMagazineListIsFiltered(array $queryParams, array $expectedMagazines): void\n    {\n        $this->loadExampleMagazines();\n\n        $crawler = $this->client->request('GET', '/magazines');\n\n        $crawler = $this->client->submit(\n            $crawler->filter('form[method=get]')->selectButton('')->form($queryParams)\n        );\n\n        $actualMagazines = $crawler->filter('#content .table-responsive .magazine-inline')->each(fn (Crawler $node) => $node->innerText());\n\n        $this->assertSame(\n            sort($expectedMagazines),\n            sort($actualMagazines),\n        );\n    }\n\n    public static function magazines(): iterable\n    {\n        return [\n            [['query' => 'test'], []],\n            [['query' => 'acme'], ['Magazyn polityczny']],\n            [['query' => '', 'adult' => 'only'], ['Adult only']],\n            [['query' => 'acme', 'adult' => 'only'], []],\n            [['query' => 'foobar', 'fields' => 'names_descriptions'], ['Magazyn polityczny']],\n            [['adult' => 'show'], ['Magazyn polityczny', 'kbin devlog', 'Adult only', 'starwarsmemes@republic.new']],\n            [['federation' => 'local'], ['Magazyn polityczny', 'kbin devlog', 'Adult only']],\n            [['query' => 'starwars', 'federation' => 'local'], []],\n            [['query' => 'starwars', 'federation' => 'all'], ['starwarsmemes@republic.new']],\n            [['query' => 'trap', 'fields' => 'names_descriptions'], ['starwarsmemes@republic.new']],\n        ];\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Magazine/MagazinePeopleControllerTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Magazine;\n\nuse App\\Tests\\WebTestCase;\n\nclass MagazinePeopleControllerTest extends WebTestCase\n{\n    public function testMagazinePeoplePage(): void\n    {\n        $user = $this->getUserByUsername('JohnDoe');\n        $this->createPost('test post content');\n\n        $user->about = 'Loerm ipsum';\n        $this->entityManager->flush();\n\n        $crawler = $this->client->request('GET', '/m/acme/people');\n\n        $this->assertEquals(1, $crawler->filter('#main .user-box')->count());\n        $this->assertSelectorTextContains('#main .users .user-box', 'Loerm ipsum');\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Magazine/MagazineSubControllerTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Magazine;\n\nuse App\\Tests\\WebTestCase;\n\nclass MagazineSubControllerTest extends WebTestCase\n{\n    public function testUserCanSubAndUnsubMagazine(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JaneDoe'));\n\n        $this->getMagazineByName('acme');\n\n        $crawler = $this->client->request('GET', '/m/acme');\n\n        // Sub magazine\n        $this->client->submit($crawler->filter('#sidebar .magazine')->selectButton('Subscribe')->form());\n        $crawler = $this->client->followRedirect();\n\n        $this->assertSelectorExists('#sidebar form[name=magazine_subscribe] .active');\n        $this->assertSelectorTextContains('#sidebar .magazine', 'Unsubscribe');\n        $this->assertSelectorTextContains('#sidebar .magazine', '2');\n\n        // Unsub magazine\n        $this->client->submit($crawler->filter('#sidebar .magazine')->selectButton('Unsubscribe')->form());\n        $this->client->followRedirect();\n        $this->assertSelectorTextContains('#sidebar .magazine', '1');\n    }\n\n    public function testXmlUserCanSubMagazine(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JaneDoe'));\n\n        $this->getMagazineByName('acme');\n\n        $crawler = $this->client->request('GET', '/m/acme');\n\n        $this->client->setServerParameter('HTTP_X-Requested-With', 'XMLHttpRequest');\n        $this->client->submit($crawler->filter('#sidebar .magazine')->selectButton('Subscribe')->form());\n\n        $this->assertStringContainsString('{\"html\":', $this->client->getResponse()->getContent());\n        $this->assertStringContainsString('Unsubscribe', $this->client->getResponse()->getContent());\n    }\n\n    public function testXmlUserCanUnsubMagazine(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JaneDoe'));\n\n        $this->getMagazineByName('acme');\n\n        $crawler = $this->client->request('GET', '/m/acme');\n\n        // Sub magazine\n        $this->client->submit($crawler->filter('#sidebar .magazine')->selectButton('Subscribe')->form());\n        $crawler = $this->client->followRedirect();\n\n        // Unsub magazine\n        $this->client->setServerParameter('HTTP_X-Requested-With', 'XMLHttpRequest');\n        $this->client->submit($crawler->filter('#sidebar .magazine')->selectButton('Unsubscribe')->form());\n\n        $this->assertStringContainsString('{\"html\":', $this->client->getResponse()->getContent());\n        $this->assertStringContainsString('Subscribe', $this->client->getResponse()->getContent());\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Magazine/Panel/MagazineAppearanceControllerTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Magazine\\Panel;\n\nuse App\\Tests\\WebTestCase;\n\nclass MagazineAppearanceControllerTest extends WebTestCase\n{\n    public function testOwnerCanEditMagazineTheme(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n        $this->getMagazineByName('acme');\n\n        $crawler = $this->client->request('GET', '/m/acme/panel/appearance');\n        $this->assertSelectorTextContains('#main .options__main a.active', 'Appearance');\n        $form = $crawler->filter('#main form[name=magazine_theme]')->selectButton('Done')->form();\n        $form['magazine_theme[icon]']->upload($this->kibbyPath);\n        $crawler = $this->client->submit($form);\n\n        $this->assertResponseIsSuccessful();\n        $this->assertSelectorExists('#sidebar .magazine img');\n        $node = $crawler->filter('#sidebar .magazine img')->getNode(0);\n        $this->assertStringContainsString(self::KIBBY_PNG_URL_RESULT, $node->attributes->getNamedItem('src')->textContent);\n    }\n\n    public function testOwnerCanEditMagazineCSS(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n        $this->getMagazineByName('acme');\n\n        $crawler = $this->client->request('GET', '/m/acme/panel/appearance');\n        $this->assertSelectorTextContains('#main .options__main a.active', 'Appearance');\n        $form = $crawler->filter('#main form[name=magazine_theme]')->selectButton('Done')->form();\n        $form['magazine_theme[customCss]']->setValue('#middle { display: none; }');\n        $crawler = $this->client->submit($form);\n\n        $this->assertResponseIsSuccessful();\n    }\n\n    public function testUnauthorizedUserCannotEditMagazineTheme(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JaneDoe'));\n\n        $this->getMagazineByName('acme');\n\n        $this->client->request('GET', '/m/acme/panel/appearance');\n\n        $this->assertResponseStatusCodeSame(403);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Magazine/Panel/MagazineBadgeControllerTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Magazine\\Panel;\n\nuse App\\Tests\\WebTestCase;\n\nclass MagazineBadgeControllerTest extends WebTestCase\n{\n    public function testModCanAddAndRemoveBadge(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n        $this->getMagazineByName('acme');\n\n        // Add badge\n        $crawler = $this->client->request('GET', '/m/acme/panel/badges');\n        $this->assertSelectorTextContains('#main .options__main a.active', 'Badges');\n        $this->client->submit(\n            $crawler->filter('#main form[name=badge]')->selectButton('Add badge')->form([\n                'badge[name]' => 'test',\n            ])\n        );\n\n        $crawler = $this->client->followRedirect();\n        $this->assertSelectorTextContains('#main .badges', 'test');\n\n        // Remove badge\n        $this->client->submit(\n            $crawler->filter('#main .badges')->selectButton('Delete')->form()\n        );\n\n        $this->client->followRedirect();\n        $this->assertSelectorTextContains('#main .section--muted', 'Empty');\n    }\n\n    public function testUnauthorizedUserCannotAddBadge(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JaneDoe'));\n\n        $this->getMagazineByName('acme');\n\n        $this->client->request('GET', '/m/acme/panel/badges');\n\n        $this->assertResponseStatusCodeSame(403);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Magazine/Panel/MagazineBanControllerTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Magazine\\Panel;\n\nuse App\\Tests\\WebTestCase;\n\nclass MagazineBanControllerTest extends WebTestCase\n{\n    public function testModCanAddAndRemoveBan(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n        $this->getUserByUsername('JaneDoe');\n        $this->getMagazineByName('acme');\n\n        // Add ban\n        $crawler = $this->client->request('GET', '/m/acme/panel/bans');\n        $this->assertSelectorTextContains('#main .options__main a.active', 'Bans');\n        $crawler = $this->client->submit(\n            $crawler->filter('#main form[name=ban]')->selectButton('Add ban')->form([\n                'username' => 'JaneDoe',\n            ])\n        );\n\n        $this->client->submit(\n            $crawler->filter('#main form[name=magazine_ban]')->selectButton('Ban')->form([\n                'magazine_ban[reason]' => 'Reason test',\n                'magazine_ban[expiredAt]' => (new \\DateTimeImmutable('+2 weeks'))->format('Y-m-d H:i:s'),\n            ])\n        );\n\n        $crawler = $this->client->followRedirect();\n\n        $this->assertSelectorTextContains('#main .bans-table', 'JaneDoe');\n\n        // Remove ban\n        $this->client->submit(\n            $crawler->filter('#main .bans-table')->selectButton('Delete')->form()\n        );\n\n        $this->client->followRedirect();\n        $this->assertSelectorTextContains('#main', 'Empty');\n    }\n\n    public function testUnauthorizedUserCannotAddBan(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JaneDoe'));\n\n        $this->getMagazineByName('acme');\n\n        $this->client->request('GET', '/m/acme/panel/bans');\n\n        $this->assertResponseStatusCodeSame(403);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Magazine/Panel/MagazineEditControllerTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Magazine\\Panel;\n\nuse App\\DTO\\ModeratorDto;\nuse App\\Tests\\WebTestCase;\n\nclass MagazineEditControllerTest extends WebTestCase\n{\n    public function testModCannotSeePanelLink(): void\n    {\n        $mod = $this->getUserByUsername('JohnDoe');\n        $admin = $this->getUserByUsername('admin', isAdmin: true);\n        $this->client->loginUser($mod);\n        $magazine = $this->getMagazineByName('acme', $admin);\n\n        $manager = $this->magazineManager;\n        $dto = new ModeratorDto($magazine, $mod, $admin);\n        $manager->addModerator($dto);\n\n        $this->client->request('GET', '/m/acme');\n        $this->assertSelectorTextNotContains('#sidebar .magazine', 'Magazine panel');\n    }\n\n    public function testOwnerCanEditMagazine(): void\n    {\n        $owner = $this->getUserByUsername('JohnDoe');\n        $this->client->loginUser($owner);\n        $magazine = $this->getMagazineByName('acme', $owner);\n        $magazine->rules = 'init rules';\n        $this->entityManager->persist($magazine);\n        $this->entityManager->flush();\n\n        $crawler = $this->client->request('GET', '/m/acme/panel/general');\n        self::assertResponseIsSuccessful();\n        $this->assertSelectorTextContains('#main .options__main a.active', 'General');\n        $this->client->submit(\n            $crawler->filter('#main form[name=magazine]')->selectButton('Done')->form([\n                'magazine[description]' => 'test description edit',\n                'magazine[rules]' => 'test rules edit',\n                'magazine[isAdult]' => true,\n            ])\n        );\n\n        $this->client->followRedirect();\n        $this->assertSelectorTextContains('#sidebar .magazine', 'test description edit');\n        $this->assertSelectorTextContains('#sidebar .magazine', 'test rules edit');\n    }\n\n    public function testCannotEditRulesWhenEmpty(): void\n    {\n        $owner = $this->getUserByUsername('JohnDoe');\n        $this->client->loginUser($owner);\n        $this->getMagazineByName('acme', $owner);\n\n        $crawler = $this->client->request('GET', '/m/acme/panel/general');\n        self::assertResponseIsSuccessful();\n        $exception = null;\n        try {\n            $crawler->filter('#main form[name=magazine]')->selectButton('Done')->form([\n                'magazine[rules]' => 'test rules edit',\n            ]);\n        } catch (\\Exception $e) {\n            $exception = $e;\n        }\n        self::assertNotNull($exception);\n        self::assertStringContainsString('Unreachable field \"rules\"', $exception->getMessage());\n    }\n\n    public function testUnauthorizedUserCannotEditMagazine(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JaneDoe'));\n\n        $this->getMagazineByName('acme');\n\n        $this->client->request('GET', '/m/acme/panel/general');\n\n        $this->assertResponseStatusCodeSame(403);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Magazine/Panel/MagazineModeratorControllerTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Magazine\\Panel;\n\nuse App\\Tests\\WebTestCase;\n\nclass MagazineModeratorControllerTest extends WebTestCase\n{\n    public function testOwnerCanAddAndRemoveModerator(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n        $this->getUserByUsername('JaneDoe');\n        $this->getMagazineByName('acme');\n\n        // Add moderator\n        $crawler = $this->client->request('GET', '/m/acme/panel/moderators');\n        $this->assertSelectorTextContains('#main .options__main a.active', 'Moderators');\n        $crawler = $this->client->submit(\n            $crawler->filter('#main form[name=moderator]')->selectButton('Add moderator')->form([\n                'moderator[user]' => 'JaneDoe',\n            ])\n        );\n        $this->assertSelectorTextContains('#main .users-columns', 'JaneDoe');\n        $this->assertEquals(2, $crawler->filter('#main .users-columns ul li')->count());\n\n        // Remove moderator\n        $this->client->submit(\n            $crawler->filter('#main .users-columns')->selectButton('Delete')->form()\n        );\n\n        $crawler = $this->client->followRedirect();\n        $this->assertSelectorTextNotContains('#main .users-columns', 'JaneDoe');\n        $this->assertEquals(1, $crawler->filter('#main .users-columns ul li')->count());\n    }\n\n    public function testUnauthorizedUserCannotAddModerator(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JaneDoe'));\n\n        $this->getMagazineByName('acme');\n\n        $this->client->request('GET', '/m/acme/panel/moderators');\n\n        $this->assertResponseStatusCodeSame(403);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Magazine/Panel/MagazineReportControllerTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Magazine\\Panel;\n\nuse App\\DTO\\ReportDto;\nuse App\\Tests\\WebTestCase;\n\nclass MagazineReportControllerTest extends WebTestCase\n{\n    public function testModCanSeeEntryReports(): void\n    {\n        $this->client->loginUser($user = $this->getUserByUsername('JohnDoe'));\n        $user2 = $this->getUserByUsername('JaneDoe');\n\n        $entryComment = $this->createEntryComment('Test comment 1');\n        $postComment = $this->createPostComment('Test post 1');\n\n        foreach ([$entryComment, $postComment, $entryComment->entry, $postComment->post] as $subject) {\n            $this->reportManager->report(\n                ReportDto::create($subject, 'test reason'),\n                $user\n            );\n        }\n\n        $this->client->request('GET', '/');\n        $crawler = $this->client->request('GET', '/m/acme/panel/reports');\n\n        $this->assertSelectorTextContains('#main .options__main a.active', 'Reports');\n        $this->assertEquals(\n            4,\n            $crawler->filter('#main .report')->count()\n        );\n    }\n\n    public function testUnauthorizedUserCannotSeeReports(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JaneDoe'));\n\n        $this->getMagazineByName('acme');\n\n        $this->client->request('GET', '/m/acme/panel/reports');\n\n        $this->assertResponseStatusCodeSame(403);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Magazine/Panel/MagazineTrashControllerTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Magazine\\Panel;\n\nuse App\\Tests\\WebTestCase;\n\nclass MagazineTrashControllerTest extends WebTestCase\n{\n    public function testModCanSeeEntryInTrash(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n        $this->getMagazineByName('acme');\n\n        $entry = $this->getEntryByTitle(\n            'Test entry 1',\n            'https://kbin.pub',\n            null,\n            null,\n            $this->getUserByUsername('JaneDoe')\n        );\n\n        $crawler = $this->client->request('GET', '/m/acme/t/'.$entry->getId().'/test-entry-1/moderate');\n        $this->client->submit($crawler->filter('#main .moderate-panel')->selectButton('Delete')->form([]));\n\n        $this->client->request('GET', '/m/acme/panel/trash');\n        $this->assertSelectorTextContains('#main .options__main a.active', 'Trash');\n        $this->assertSelectorTextContains('#main .entry', 'Test entry 1');\n    }\n\n    public function testModCanSeeEntryCommentInTrash(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n        $this->getMagazineByName('acme');\n\n        $comment = $this->createEntryComment(\n            'Test comment 1',\n            null,\n            $this->getUserByUsername('JaneDoe')\n        );\n\n        $crawler = $this->client->request(\n            'GET',\n            '/m/acme/t/'.$comment->entry->getId().'/test-entry-1/comment/'.$comment->getId().'/moderate'\n        );\n        $this->client->submit($crawler->filter('#main .moderate-panel')->selectButton('Delete')->form([]));\n\n        $this->client->request('GET', '/m/acme/panel/trash');\n        $this->assertSelectorTextContains('#main .comment', 'Test comment 1');\n    }\n\n    public function testModCanSeePostInTrash(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n        $this->getMagazineByName('acme');\n\n        $post = $this->createPost(\n            'Test post 1',\n            null,\n            $this->getUserByUsername('JaneDoe')\n        );\n\n        $crawler = $this->client->request(\n            'GET',\n            '/m/acme/p/'.$post->getId().'/-/moderate'\n        );\n        $this->client->submit($crawler->filter('#main .moderate-panel')->selectButton('Delete')->form([]));\n\n        $this->client->request('GET', '/m/acme/panel/trash');\n        $this->assertSelectorTextContains('#main .post', 'Test post 1');\n    }\n\n    public function testModCanSeePostCommentInTrash(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n        $this->getMagazineByName('acme');\n\n        $comment = $this->createPostComment(\n            'Test comment 1',\n            null,\n            $this->getUserByUsername('JaneDoe')\n        );\n\n        $crawler = $this->client->request(\n            'GET',\n            '/m/acme/p/'.$comment->post->getId().'/test-entry-1/reply/'.$comment->getId().'/moderate'\n        );\n        $this->client->submit($crawler->filter('#main .moderate-panel')->selectButton('Delete')->form([]));\n\n        $this->client->request('GET', '/m/acme/panel/trash');\n        $this->assertSelectorTextContains('#main .comment', 'Test comment 1');\n    }\n\n    public function testUnauthorizedUserCannotSeeTrash(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JaneDoe'));\n\n        $this->getMagazineByName('acme');\n\n        $this->client->request('GET', '/m/acme/panel/trash');\n\n        $this->assertResponseStatusCodeSame(403);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Moderator/ModeratorSignupRequestsControllerTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Moderator;\n\nuse App\\Tests\\WebTestCase;\n\nclass ModeratorSignupRequestsControllerTest extends WebTestCase\n{\n    public function testModeratorCanViewSignupRequests(): void\n    {\n        $this->settingsManager->set('MBIN_NEW_USERS_NEED_APPROVAL', true);\n\n        $this->client->loginUser($this->getUserByUsername('moderator', isModerator: true));\n\n        $crawler = $this->client->request('GET', '/');\n        $this->client->click($crawler->filter('#header menu')->selectLink('Signup requests')->link());\n\n        self::assertResponseIsSuccessful();\n        $this->assertSelectorTextContains('#main h3', 'Signup Requests');\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/People/FrontControllerTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\People;\n\nuse App\\Tests\\WebTestCase;\n\nclass FrontControllerTest extends WebTestCase\n{\n    public function testFrontPeoplePage(): void\n    {\n        $user = $this->getUserByUsername('JohnDoe');\n\n        $user->about = 'Loerm ipsum';\n        $this->entityManager->flush();\n\n        $crawler = $this->client->request('GET', '/people');\n\n        $this->assertEquals(1, $crawler->filter('#main .user-box')->count());\n        $this->assertSelectorTextContains('#main .users .user-box', 'Loerm ipsum');\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Post/Comment/PostCommentBoostControllerTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Post\\Comment;\n\nuse App\\Tests\\WebTestCase;\n\nclass PostCommentBoostControllerTest extends WebTestCase\n{\n    public function testLoggedUserBoostComment(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n\n        $post = $this->createPost('test post 1', null, $this->getUserByUsername('JaneDoe'));\n        $comment = $this->createPostComment('test comment 1', $post, $this->getUserByUsername('JaneDoe'));\n\n        $crawler = $this->client->request('GET', \"/m/acme/p/{$post->getId()}/test-post-1\");\n\n        $crawler = $this->client->submit(\n            $crawler->filter(\"#post-comment-{$comment->getId()}\")->selectButton('Boost')->form()\n        );\n\n        $crawler = $this->client->followRedirect();\n        self::assertResponseIsSuccessful();\n\n        $crawler = $this->client->request('GET', \"/m/acme/p/{$post->getId()}/test-post-1\");\n\n        // $this->assertSelectorTextContains(\"#post-comment-{$comment->getId()}\", 'Boost (1)');\n\n        $crawler = $this->client->click($crawler->filter(\"#post-comment-{$comment->getId()}\")->selectLink('Activity')->link());\n\n        $this->assertSelectorTextContains('#main #activity', 'Boosts (1)');\n        $this->client->click($crawler->filter('#main #activity')->selectLink('Boosts (1)')->link());\n\n        $this->assertSelectorTextContains('#main .users-columns', 'JohnDoe');\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Post/Comment/PostCommentChangeLangControllerTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Post\\Comment;\n\nuse App\\Tests\\WebTestCase;\n\nclass PostCommentChangeLangControllerTest extends WebTestCase\n{\n    public function testModCanChangeLanguage(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n\n        $comment = $this->createPostComment('test comment 1');\n\n        $crawler = $this->client->request('GET', \"/m/acme/p/{$comment->post->getId()}/-/reply/{$comment->getId()}/moderate\");\n\n        $form = $crawler->filter('.moderate-panel')->selectButton('Change language')->form();\n\n        $this->assertSame($form['lang']['lang']->getValue(), 'en');\n\n        $form['lang']['lang']->select('fr');\n\n        $this->client->submit($form);\n        $this->client->followRedirect();\n\n        $this->assertSelectorTextContains('#main .badge-lang', 'French');\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Post/Comment/PostCommentCreateControllerTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Post\\Comment;\n\nuse App\\Tests\\WebTestCase;\nuse PHPUnit\\Framework\\Attributes\\Group;\n\nclass PostCommentCreateControllerTest extends WebTestCase\n{\n    public function setUp(): void\n    {\n        parent::setUp();\n        $this->kibbyPath = \\dirname(__FILE__, 5).'/assets/kibby_emoji.png';\n    }\n\n    public function testUserCanCreatePostComment(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n\n        $post = $this->createPost('test post 1');\n\n        $crawler = $this->client->request('GET', '/m/acme/p/'.$post->getId().'/test-post-1/reply');\n\n        $this->client->submit(\n            $crawler->filter('form[name=post_comment]')->selectButton('Add comment')->form(\n                [\n                    'post_comment[body]' => 'test comment 1',\n                ]\n            )\n        );\n\n        $this->assertResponseRedirects('/m/acme/p/'.$post->getId().'/test-post-1');\n        $this->client->followRedirect();\n\n        $this->assertSelectorTextContains('#comments .content', 'test comment 1');\n    }\n\n    public function testUserCannotCreatePostCommentInLockedPost(): void\n    {\n        $user = $this->getUserByUsername('JohnDoe');\n        $this->client->loginUser($user);\n\n        $post = $this->createPost('test post 1');\n        $this->postManager->toggleLock($post, $user);\n\n        $crawler = $this->client->request('GET', '/m/acme/p/'.$post->getId().'/test-post-1/reply');\n\n        $this->client->submit(\n            $crawler->filter('form[name=post_comment]')->selectButton('Add comment')->form(\n                [\n                    'post_comment[body]' => 'test comment 1',\n                ]\n            )\n        );\n\n        self::assertResponseIsSuccessful();\n        $this->assertSelectorTextContains('#main', 'Failed to create comment');\n    }\n\n    #[Group(name: 'NonThreadSafe')]\n    public function testUserCanCreatePostCommentWithImage(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n\n        $post = $this->createPost('test post 1');\n\n        $crawler = $this->client->request('GET', \"/m/acme/p/{$post->getId()}/test-post-1/reply\");\n\n        $form = $crawler->filter('form[name=post_comment]')->selectButton('Add comment')->form();\n        $form->get('post_comment[body]')->setValue('Test comment 1');\n        $form->get('post_comment[image]')->upload($this->kibbyPath);\n        // Needed since we require this global to be set when validating entries but the client doesn't actually set it\n        $_FILES = $form->getPhpFiles();\n        $this->client->submit($form);\n\n        $this->assertResponseRedirects(\"/m/acme/p/{$post->getId()}/test-post-1\");\n        $crawler = $this->client->followRedirect();\n\n        $this->assertSelectorTextContains('#comments .content', 'Test comment 1');\n        $this->assertSelectorExists('#comments footer figure img');\n        $imgSrc = $crawler->filter('#comments footer figure img')->getNode(0)->attributes->getNamedItem('src')->textContent;\n        $this->assertStringContainsString(self::KIBBY_PNG_URL_RESULT, $imgSrc);\n        $_FILES = [];\n    }\n\n    #[Group(name: 'NonThreadSafe')]\n    public function testUserCannotCreateInvalidPostComment(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n\n        $post = $this->createPost('test post 1');\n\n        $crawler = $this->client->request('GET', '/m/acme/p/'.$post->getId().'/test-post-1/reply');\n\n        $crawler = $this->client->submit(\n            $crawler->filter('form[name=post_comment]')->selectButton('Add comment')->form(\n                [\n                    'post_comment[body]' => '',\n                ]\n            )\n        );\n\n        $this->assertSelectorTextContains('#content', 'This value should not be blank.');\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Post/Comment/PostCommentDeleteControllerTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Post\\Comment;\n\nuse App\\Tests\\WebTestCase;\n\nclass PostCommentDeleteControllerTest extends WebTestCase\n{\n    public function testUserCannotPurgePostComment()\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByName('acme', $user);\n        $post = $this->createPost('deletion test', magazine: $magazine, user: $user);\n        $comment = $this->createPostComment('delete me!', $post, $user);\n        $this->client->loginUser($user);\n\n        $crawler = $this->client->request('GET', \"/m/acme/p/{$post->getId()}/deletion-test\");\n        self::assertResponseIsSuccessful();\n\n        $link = $crawler->filter('#comments .post-comment footer')->selectLink('Moderate')->link();\n        $crawler = $this->client->click($link);\n        self::assertResponseIsSuccessful();\n\n        $this->assertSelectorNotExists('.moderate-panel form[action$=\"purge\"]');\n    }\n\n    public function testAdminCanPurgePostComment()\n    {\n        $user = $this->getUserByUsername('user');\n        $admin = $this->getUserByUsername('admin', isAdmin: true);\n        $magazine = $this->getMagazineByName('acme', $user);\n        $post = $this->createPost('deletion test', magazine: $magazine, user: $user);\n        $comment = $this->createPostComment('delete me!', $post, $user);\n        $this->client->loginUser($admin);\n        self::assertTrue($admin->isAdmin());\n\n        $crawler = $this->client->request('GET', \"/m/acme/p/{$post->getId()}/deletion-test\");\n        self::assertResponseIsSuccessful();\n\n        $link = $crawler->filter(\"#comments #post-comment-{$comment->getId()} footer\")->selectLink('Moderate')->link();\n        $crawler = $this->client->click($link);\n        self::assertResponseIsSuccessful();\n\n        self::assertSelectorExists('.moderate-panel');\n        $this->assertSelectorExists('.moderate-panel form[action$=\"purge\"]');\n        $this->client->submit(\n            $crawler->filter('.moderate-panel')->selectButton('Purge')->form()\n        );\n\n        $this->assertResponseRedirects();\n    }\n\n    public function testUserCanSoftDeletePostComment()\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByName('acme', $user);\n        $post = $this->createPost('deletion test', magazine: $magazine, user: $user);\n        $comment = $this->createPostComment('delete me!', $post, $user);\n        $reply = $this->createPostCommentReply('Are you deleted yet?', $post, $user, $comment);\n        $this->client->loginUser($user);\n\n        $crawler = $this->client->request('GET', \"/m/acme/p/{$post->getId()}/deletion-test\");\n        self::assertResponseIsSuccessful();\n\n        $link = $crawler->filter('#comments .post-comment footer')->selectLink('Moderate')->link();\n        $crawler = $this->client->click($link);\n        self::assertResponseIsSuccessful();\n\n        $this->assertSelectorExists('.moderate-panel form[action$=\"delete\"]');\n        $this->client->submit(\n            $crawler->filter('.moderate-panel')->selectButton('Delete')->form()\n        );\n\n        $this->assertResponseRedirects();\n        $this->client->request('GET', \"/m/acme/p/{$post->getId()}/deletion-test\");\n\n        $translator = $this->translator;\n        $this->assertSelectorTextContains(\"#post-comment-{$comment->getId()} .content\", $translator->trans('deleted_by_author'));\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Post/Comment/PostCommentEditControllerTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Post\\Comment;\n\nuse App\\Tests\\WebTestCase;\nuse PHPUnit\\Framework\\Attributes\\Group;\n\nclass PostCommentEditControllerTest extends WebTestCase\n{\n    public function testAuthorCanEditOwnPostComment(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n\n        $post = $this->createPost('test post 1');\n        $this->createPostComment('test comment 1', $post);\n\n        $crawler = $this->client->request('GET', \"/m/acme/p/{$post->getId()}/test-post-1\");\n\n        $crawler = $this->client->click($crawler->filter('#main .post-comment')->selectLink('Edit')->link());\n\n        $this->assertSelectorExists('#main .post-comment');\n        $this->assertSelectorTextContains('textarea[name=\"post_comment[body]\"]', 'test comment 1');\n\n        $this->client->submit(\n            $crawler->filter('form[name=post_comment]')->selectButton('Save changes')->form(\n                [\n                    'post_comment[body]' => 'test comment 2 body',\n                ]\n            )\n        );\n\n        $this->client->followRedirect();\n\n        $this->assertSelectorTextContains('#main .post-comment', 'test comment 2 body');\n    }\n\n    #[Group(name: 'NonThreadSafe')]\n    public function testAuthorCanEditOwnPostCommentWithImage(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n\n        $post = $this->createPost('test post 1');\n        $imageDto = $this->getKibbyImageDto();\n        $this->createPostComment('test comment 1', $post, imageDto: $imageDto);\n\n        $crawler = $this->client->request('GET', \"/m/acme/p/{$post->getId()}/test-post-1\");\n\n        $crawler = $this->client->click($crawler->filter('#main .post-comment')->selectLink('Edit')->link());\n\n        $this->assertSelectorExists('#main .post-comment');\n        $this->assertSelectorTextContains('textarea[name=\"post_comment[body]\"]', 'test comment 1');\n        $this->assertSelectorExists('#main .post-comment img');\n        $node = $crawler->selectImage('kibby')->getNode(0);\n        $this->assertNotNull($node);\n        $this->assertStringContainsString($imageDto->filePath, $node->attributes->getNamedItem('src')->textContent);\n\n        $this->client->submit(\n            $crawler->filter('form[name=post_comment]')->selectButton('Save changes')->form(\n                [\n                    'post_comment[body]' => 'test comment 2 body',\n                ]\n            )\n        );\n\n        $crawler = $this->client->followRedirect();\n\n        $this->assertSelectorTextContains('#main .post-comment', 'test comment 2 body');\n        $this->assertSelectorExists('#main .post-comment img');\n        $node = $crawler->selectImage('kibby')->getNode(0);\n        $this->assertNotNull($node);\n        $this->assertStringContainsString($imageDto->filePath, $node->attributes->getNamedItem('src')->textContent);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Post/Comment/PostCommentModerateControllerTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Post\\Comment;\n\nuse App\\Tests\\WebTestCase;\n\nclass PostCommentModerateControllerTest extends WebTestCase\n{\n    public function testModCanShowPanel(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n\n        $comment = $this->createPostComment('test comment 1');\n\n        $crawler = $this->client->request('get', \"/m/{$comment->magazine->name}/p/{$comment->post->getId()}\");\n        $this->client->click($crawler->filter('#post-comment-'.$comment->getId())->selectLink('Moderate')->link());\n\n        $this->assertSelectorTextContains('.moderate-panel', 'ban');\n    }\n\n    public function testXmlModCanShowPanel(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n\n        $comment = $this->createPostComment('test comment 1');\n\n        $crawler = $this->client->request('get', \"/m/{$comment->magazine->name}/p/{$comment->post->getId()}\");\n        $this->client->setServerParameter('HTTP_X-Requested-With', 'XMLHttpRequest');\n        $this->client->click($crawler->filter('#post-comment-'.$comment->getId())->selectLink('Moderate')->link());\n\n        $this->assertStringContainsString('moderate-panel', $this->client->getResponse()->getContent());\n    }\n\n    public function testUnauthorizedCanNotShowPanel(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JaneDoe'));\n\n        $comment = $this->createPostComment('test comment 1');\n\n        $this->client->request('get', \"/m/{$comment->magazine->name}/p/{$comment->post->getId()}\");\n        $this->assertSelectorTextNotContains('#post-comment-'.$comment->getId(), 'moderate');\n\n        $this->client->request(\n            'get',\n            \"/m/{$comment->magazine->name}/p/{$comment->post->getId()}/-/reply/{$comment->getId()}/moderate\"\n        );\n\n        $this->assertResponseStatusCodeSame(403);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Post/PostBoostControllerTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Post;\n\nuse App\\Tests\\WebTestCase;\n\nclass PostBoostControllerTest extends WebTestCase\n{\n    public function testLoggedUserBoostPost(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n\n        $post = $this->createPost('test post 1', null, $this->getUserByUsername('JaneDoe'));\n\n        $crawler = $this->client->request('GET', \"/m/acme/p/{$post->getId()}/test-post-1\");\n\n        $this->client->submit(\n            $crawler->filter('#main .post')->selectButton('Boost')->form([])\n        );\n\n        $crawler = $this->client->followRedirect();\n\n        $this->assertSelectorTextContains('#main .post', 'Boost (1)');\n\n        $this->client->click($crawler->filter('#activity')->selectLink('Boosts (1)')->link());\n\n        $this->assertSelectorTextContains('#main .users-columns', 'JohnDoe');\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Post/PostChangeAdultControllerTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Post;\n\nuse App\\Tests\\WebTestCase;\n\nclass PostChangeAdultControllerTest extends WebTestCase\n{\n    public function testModCanMarkAsAdultContent(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n\n        $post = $this->createPost('test post 1');\n\n        $crawler = $this->client->request('GET', \"/m/acme/p/{$post->getId()}/-/moderate\");\n        $this->client->submit(\n            $crawler->filter('.moderate-panel')->selectButton('Mark NSFW')->form([\n                'adult' => 'on',\n            ])\n        );\n        $this->client->followRedirect();\n        $this->assertSelectorTextContains('#main .post .badge', '18+');\n\n        $this->client->submit(\n            $crawler->filter('.moderate-panel')->selectButton('Mark NSFW')->form([\n                'adult' => false,\n            ])\n        );\n        $this->client->followRedirect();\n        $this->assertSelectorTextNotContains('#main .post', '18+');\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Post/PostChangeLangControllerTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Post;\n\nuse App\\Tests\\WebTestCase;\n\nclass PostChangeLangControllerTest extends WebTestCase\n{\n    public function testModCanChangeLanguage(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n\n        $post = $this->createPost('test post 1');\n\n        $crawler = $this->client->request('GET', \"/m/acme/p/{$post->getId()}/-/moderate\");\n\n        $form = $crawler->filter('.moderate-panel')->selectButton('Change language')->form();\n\n        $this->assertSame($form['lang']['lang']->getValue(), 'en');\n\n        $form['lang']['lang']->select('fr');\n\n        $this->client->submit($form);\n        $this->client->followRedirect();\n\n        $this->assertSelectorTextContains('#main .badge-lang', 'French');\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Post/PostChangeMagazineControllerTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Post;\n\nuse App\\Tests\\WebTestCase;\n\nclass PostChangeMagazineControllerTest extends WebTestCase\n{\n    public function testAdminCanChangeMagazine(): void\n    {\n        $user = $this->getUserByUsername('JohnDoe');\n        $this->setAdmin($user);\n        $this->client->loginUser($user);\n\n        $this->getMagazineByName('kbin');\n\n        $post = $this->createPost(\n            'test post 1',\n        );\n\n        $crawler = $this->client->request('GET', \"/m/acme/p/{$post->getId()}/-/moderate\");\n\n        $this->client->submit(\n            $crawler->filter('form[name=change_magazine]')->selectButton('Change magazine')->form(\n                [\n                    'change_magazine[new_magazine]' => 'kbin',\n                ]\n            )\n        );\n\n        $this->client->followRedirect();\n        $this->client->followRedirect();\n\n        $this->assertSelectorTextContains('.head-title', 'kbin');\n    }\n\n    public function testUnauthorizedUserCantChangeMagazine(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n\n        $this->getMagazineByName('kbin');\n\n        $entry = $this->createPost(\n            'test post 1',\n        );\n\n        $this->client->request('GET', \"/m/acme/p/{$entry->getId()}/-/moderate\");\n\n        $this->assertSelectorTextNotContains('.moderate-panel', 'Change magazine');\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Post/PostCreateControllerTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Post;\n\nuse App\\Tests\\WebTestCase;\nuse PHPUnit\\Framework\\Attributes\\Group;\n\nclass PostCreateControllerTest extends WebTestCase\n{\n    public function setUp(): void\n    {\n        parent::setUp();\n        $this->kibbyPath = \\dirname(__FILE__, 4).'/assets/kibby_emoji.png';\n    }\n\n    public function testUserCanCreatePost(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n\n        $this->getMagazineByName('acme');\n\n        $crawler = $this->client->request('GET', '/m/acme/microblog');\n\n        $this->client->submit(\n            $crawler->filter('form[name=post]')->selectButton('Add post')->form(\n                [\n                    'post[body]' => 'test post 1',\n                ]\n            )\n        );\n\n        $this->assertResponseRedirects('/m/acme/microblog/newest');\n        $this->client->followRedirect();\n\n        $this->assertSelectorTextContains('#content .post', 'test post 1');\n    }\n\n    #[Group(name: 'NonThreadSafe')]\n    public function testUserCanCreatePostWithImage(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n\n        $this->getMagazineByName('acme');\n\n        $crawler = $this->client->request('GET', '/m/acme/microblog');\n\n        $form = $crawler->filter('form[name=post]')->selectButton('Add post')->form();\n        $form->get('post[body]')->setValue('test post 1');\n        $form->get('post[image]')->upload($this->kibbyPath);\n        // Needed since we require this global to be set when validating entries but the client doesn't actually set it\n        $_FILES = $form->getPhpFiles();\n        $this->client->submit($form);\n\n        $this->assertResponseRedirects('/m/acme/microblog/newest');\n        $crawler = $this->client->followRedirect();\n\n        $this->assertSelectorTextContains('#content .post', 'test post 1');\n        $this->assertSelectorExists('#content .post div.content figure img');\n        $imgSrc = $crawler->filter('#content .post div.content figure img')->getNode(0)->attributes->getNamedItem('src')->textContent;\n        $this->assertStringContainsString(self::KIBBY_PNG_URL_RESULT, $imgSrc);\n        $_FILES = [];\n    }\n\n    #[Group(name: 'NonThreadSafe')]\n    public function testUserCannotCreateInvalidPost(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n\n        $this->getMagazineByName('acme');\n\n        $crawler = $this->client->request('GET', '/m/acme/microblog');\n\n        $crawler = $this->client->submit(\n            $crawler->filter('form[name=post]')->selectButton('Add post')->form(\n                [\n                    'post[body]' => '',\n                ]\n            )\n        );\n\n        self::assertResponseIsSuccessful();\n        $this->assertSelectorTextContains('#content', 'This value should not be blank.');\n    }\n\n    public function testCreatedPostIsMarkedAsForAdults(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe', hideAdult: false));\n\n        $this->getMagazineByName('acme');\n\n        $crawler = $this->client->request('GET', '/m/acme/microblog');\n\n        $this->client->submit(\n            $crawler->filter('form[name=post]')->selectButton('Add post')->form(\n                [\n                    'post[body]' => 'test nsfw 1',\n                    'post[isAdult]' => '1',\n                ]\n            )\n        );\n\n        $this->assertResponseRedirects('/m/acme/microblog/newest');\n        $this->client->followRedirect();\n\n        $this->assertSelectorTextContains('blockquote header .danger', '18+');\n    }\n\n    #[Group(name: 'NonThreadSafe')]\n    public function testPostCreatedInAdultMagazineIsAutomaticallyMarkedAsForAdults(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe', hideAdult: false));\n\n        $this->getMagazineByName('adult', isAdult: true);\n\n        $crawler = $this->client->request('GET', '/m/adult/microblog');\n\n        $this->client->submit(\n            $crawler->filter('form[name=post]')->selectButton('Add post')->form(\n                [\n                    'post[body]' => 'test nsfw 1',\n                ]\n            )\n        );\n\n        $this->assertResponseRedirects('/m/adult/microblog/newest');\n        $this->client->followRedirect();\n\n        $this->assertSelectorTextContains('blockquote header .danger', '18+');\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Post/PostDeleteControllerTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Post;\n\nuse App\\Tests\\WebTestCase;\n\nclass PostDeleteControllerTest extends WebTestCase\n{\n    public function testUserCanDeletePost()\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByName('acme');\n        $post = $this->createPost('deletion test', magazine: $magazine, user: $user);\n        $this->client->loginUser($user);\n\n        $crawler = $this->client->request('GET', \"/m/acme/p/{$post->getId()}/deletion-test\");\n\n        $this->assertSelectorExists('form[action$=\"delete\"]');\n        $this->client->submit(\n            $crawler->filter('form[action$=\"delete\"]')->selectButton('Delete')->form()\n        );\n\n        $this->assertResponseRedirects();\n    }\n\n    public function testUserCanSoftDeletePost()\n    {\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByName('acme');\n        $post = $this->createPost('deletion test', magazine: $magazine, user: $user);\n        $comment = $this->createPostComment('really?', $post, $user);\n        $this->client->loginUser($user);\n\n        $crawler = $this->client->request('GET', \"/m/acme/p/{$post->getId()}/deletion-test\");\n\n        $this->assertSelectorExists(\"#post-{$post->getId()} form[action$=\\\"delete\\\"]\");\n        $this->client->submit(\n            $crawler->filter(\"#post-{$post->getId()} form[action$=\\\"delete\\\"]\")->selectButton('Delete')->form()\n        );\n\n        $this->assertResponseRedirects();\n        $this->client->request('GET', \"/m/acme/p/{$post->getId()}/deletion-test\");\n        $translator = $this->translator;\n        $this->assertSelectorTextContains(\"#post-{$post->getId()} .content\", $translator->trans('deleted_by_author'));\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Post/PostEditControllerTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Post;\n\nuse App\\Tests\\WebTestCase;\nuse PHPUnit\\Framework\\Attributes\\Group;\n\nclass PostEditControllerTest extends WebTestCase\n{\n    public function testAuthorCanEditOwnPost(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n\n        $post = $this->createPost('test post 1');\n        $crawler = $this->client->request('GET', \"/m/acme/p/{$post->getId()}/test-post-1\");\n\n        $crawler = $this->client->click($crawler->filter('#main .post')->selectLink('Edit')->link());\n\n        $this->assertSelectorExists('#main .post');\n        $this->assertSelectorTextContains('#post_body', 'test post 1');\n        //        $this->assertEquals('disabled', $crawler->filter('#post_magazine_autocomplete')->attr('disabled')); @todo\n\n        $this->client->submit(\n            $crawler->filter('form[name=post]')->selectButton('Edit post')->form(\n                [\n                    'post[body]' => 'test post 2 body',\n                ]\n            )\n        );\n\n        $this->client->followRedirect();\n\n        $this->assertSelectorTextContains('#main .post .content', 'test post 2 body');\n    }\n\n    #[Group(name: 'NonThreadSafe')]\n    public function testAuthorCanEditOwnPostWithImage(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n\n        $imageDto = $this->getKibbyImageDto();\n        $post = $this->createPost('test post 1', imageDto: $imageDto);\n        $crawler = $this->client->request('GET', \"/m/acme/p/{$post->getId()}/test-post-1\");\n\n        $crawler = $this->client->click($crawler->filter('#main .post')->selectLink('Edit')->link());\n\n        $this->assertSelectorExists('#main .post');\n        $this->assertSelectorTextContains('#post_body', 'test post 1');\n        //        $this->assertEquals('disabled', $crawler->filter('#post_magazine_autocomplete')->attr('disabled')); @todo\n        $this->assertSelectorExists('#main .post img');\n        $node = $crawler->selectImage('kibby')->getNode(0);\n        $this->assertNotNull($node);\n        $this->assertStringContainsString($imageDto->filePath, $node->attributes->getNamedItem('src')->textContent);\n\n        $this->client->submit(\n            $crawler->filter('form[name=post]')->selectButton('Edit post')->form(\n                [\n                    'post[body]' => 'test post 2 body',\n                ]\n            )\n        );\n\n        $crawler = $this->client->followRedirect();\n\n        $this->assertSelectorTextContains('#main .post .content', 'test post 2 body');\n        $this->assertSelectorExists('#main .post img');\n        $node = $crawler->selectImage('kibby')->getNode(0);\n        $this->assertNotNull($node);\n        $this->assertStringContainsString($imageDto->filePath, $node->attributes->getNamedItem('src')->textContent);\n    }\n\n    public function testAuthorCanEditPostToMarkItIsForAdults(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n\n        $post = $this->createPost('test post 1');\n        $crawler = $this->client->request('GET', \"/m/acme/p/{$post->getId()}/test-post-1/edit\");\n\n        $crawler = $this->client->click($crawler->filter('#main .post')->selectLink('Edit')->link());\n\n        $this->assertSelectorExists('#main .post');\n\n        $this->client->submit(\n            $crawler->filter('form[name=post]')->selectButton('Edit post')->form(\n                [\n                    'post[isAdult]' => '1',\n                ]\n            )\n        );\n\n        $this->client->followRedirect();\n\n        $this->assertSelectorTextContains('blockquote header .danger', '18+');\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Post/PostFrontControllerTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Post;\n\nuse App\\DTO\\ModeratorDto;\nuse App\\Enums\\ESortOptions;\nuse App\\Service\\MagazineManager;\nuse App\\Tests\\WebTestCase;\nuse Symfony\\Bundle\\FrameworkBundle\\KernelBrowser;\n\nclass PostFrontControllerTest extends WebTestCase\n{\n    public function testFrontPage(): void\n    {\n        $this->client = $this->prepareEntries();\n\n        $this->client->request('GET', '/microblog');\n        $this->assertSelectorTextContains('h1', 'Hot');\n\n        $crawler = $this->client->request('GET', '/microblog/newest');\n\n        $this->assertSelectorTextContains('.post header', 'JohnDoe');\n        $this->assertSelectorTextContains('.post header', 'to acme');\n\n        $this->assertSelectorTextContains('#header .active', 'Microblog');\n\n        $this->assertcount(2, $crawler->filter('.post'));\n\n        foreach ($this->getSortOptions() as $sortOption) {\n            $crawler = $this->client->click($crawler->filter('.options__filter')->selectLink($sortOption)->link());\n            $this->assertSelectorTextContains('.options__filter', $sortOption);\n            $this->assertSelectorTextContains('h1', ucfirst($sortOption));\n        }\n    }\n\n    public function testMagazinePage(): void\n    {\n        $this->client = $this->prepareEntries();\n\n        $this->client->request('GET', '/m/acme/microblog');\n        $this->assertSelectorTextContains('h2', 'Hot');\n\n        $crawler = $this->client->request('GET', '/m/acme/microblog/newest');\n\n        $this->assertSelectorTextContains('.post header', 'JohnDoe');\n        $this->assertSelectorTextNotContains('.post header', 'to acme');\n\n        $this->assertSelectorTextContains('.head-title', '/m/acme');\n        $this->assertSelectorTextContains('#sidebar .magazine', 'acme');\n\n        $this->assertSelectorTextContains('#header .active', 'Microblog');\n\n        $this->assertcount(1, $crawler->filter('.post'));\n\n        foreach ($this->getSortOptions() as $sortOption) {\n            $crawler = $this->client->click($crawler->filter('.options__filter')->selectLink($sortOption)->link());\n            $this->assertSelectorTextContains('.options__filter', $sortOption);\n            $this->assertSelectorTextContains('h1', 'acme');\n            $this->assertSelectorTextContains('h2', ucfirst($sortOption));\n        }\n    }\n\n    public function testSubPage(): void\n    {\n        $this->client = $this->prepareEntries();\n\n        $magazineManager = $this->magazineManager;\n        $magazineManager->subscribe($this->getMagazineByName('acme'), $this->getUserByUsername('Actor'));\n\n        $this->client->loginUser($this->getUserByUsername('Actor'));\n\n        $this->client->request('GET', '/sub/microblog');\n        $this->assertSelectorTextContains('h1', 'Hot');\n\n        $crawler = $this->client->request('GET', '/sub/microblog/newest');\n\n        $this->assertSelectorTextContains('.post header', 'JohnDoe');\n        $this->assertSelectorTextContains('.post header', 'to acme');\n\n        $this->assertSelectorTextContains('.head-title', '/sub');\n\n        $this->assertSelectorTextContains('#header .active', 'Microblog');\n\n        $this->assertcount(1, $crawler->filter('.post'));\n\n        foreach ($this->getSortOptions() as $sortOption) {\n            $crawler = $this->client->click($crawler->filter('.options__filter')->selectLink($sortOption)->link());\n            $this->assertSelectorTextContains('.options__filter', $sortOption);\n            $this->assertSelectorTextContains('h1', ucfirst($sortOption));\n        }\n    }\n\n    public function testModPage(): void\n    {\n        $this->client = $this->prepareEntries();\n        $admin = $this->getUserByUsername('admin', isAdmin: true);\n\n        $magazineManager = $this->client->getContainer()->get(MagazineManager::class);\n        $moderator = new ModeratorDto($this->getMagazineByName('acme'));\n        $moderator->user = $this->getUserByUsername('Actor');\n        $moderator->addedBy = $admin;\n        $magazineManager->addModerator($moderator);\n\n        $this->client->loginUser($this->getUserByUsername('Actor'));\n\n        $this->client->request('GET', '/mod/microblog');\n        $this->assertSelectorTextContains('h1', 'Hot');\n\n        $crawler = $this->client->request('GET', '/mod/microblog/newest');\n\n        $this->assertSelectorTextContains('.post header', 'JohnDoe');\n        $this->assertSelectorTextContains('.post header', 'to acme');\n\n        $this->assertSelectorTextContains('.head-title', '/mod');\n\n        $this->assertSelectorTextContains('#header .active', 'Microblog');\n\n        $this->assertcount(1, $crawler->filter('.post'));\n\n        foreach ($this->getSortOptions() as $sortOption) {\n            $crawler = $this->client->click($crawler->filter('.options__filter')->selectLink($sortOption)->link());\n            $this->assertSelectorTextContains('.options__filter', $sortOption);\n            $this->assertSelectorTextContains('h1', ucfirst($sortOption));\n        }\n    }\n\n    public function testFavPage(): void\n    {\n        $this->client = $this->prepareEntries();\n\n        $favouriteManager = $this->favouriteManager;\n        $favouriteManager->toggle($this->getUserByUsername('Actor'), $this->createPost('test post 3'));\n\n        $this->client->loginUser($this->getUserByUsername('Actor'));\n\n        $this->client->request('GET', '/fav/microblog');\n        $this->assertSelectorTextContains('h1', 'Hot');\n\n        $crawler = $this->client->request('GET', '/fav/microblog/newest');\n\n        $this->assertSelectorTextContains('.post header', 'JohnDoe');\n        $this->assertSelectorTextContains('.post header', 'to acme');\n\n        $this->assertSelectorTextContains('.head-title', '/fav');\n\n        $this->assertSelectorTextContains('#header .active', 'Microblog');\n\n        $this->assertcount(1, $crawler->filter('.post'));\n\n        foreach ($this->getSortOptions() as $sortOption) {\n            $crawler = $this->client->click($crawler->filter('.options__filter')->selectLink($sortOption)->link());\n            $this->assertSelectorTextContains('.options__filter', $sortOption);\n            $this->assertSelectorTextContains('h1', ucfirst($sortOption));\n        }\n    }\n\n    public function testCustomDefaultSort(): void\n    {\n        $older = $this->createPost('Older entry');\n        $older->createdAt = new \\DateTimeImmutable('now - 1 day');\n        $older->updateRanking();\n        $this->entityManager->flush();\n        $newer = $this->createPost('Newer entry');\n        $comment = $this->createPostComment('someone was here', post: $older);\n        self::assertGreaterThan($older->getRanking(), $newer->getRanking());\n\n        $user = $this->getUserByUsername('user');\n        $user->frontDefaultSort = ESortOptions::Newest->value;\n        $this->entityManager->flush();\n\n        $this->client->loginUser($user);\n        $crawler = $this->client->request('GET', '/microblog');\n\n        self::assertResponseIsSuccessful();\n        self::assertSelectorTextContains('.options__filter button', $this->translator->trans(ESortOptions::Newest->value));\n\n        $iterator = $crawler->filter('#content div')->children()->getIterator();\n        /** @var \\DOMElement $firstNode */\n        $firstNode = $iterator->current()->firstElementChild;\n        $firstId = $firstNode->attributes->getNamedItem('id')->nodeValue;\n        self::assertEquals(\"post-{$newer->getId()}\", $firstId);\n        $iterator->next();\n        $secondNode = $iterator->current()->firstElementChild;\n        $secondId = $secondNode->attributes->getNamedItem('id')->nodeValue;\n        self::assertEquals(\"post-{$older->getId()}\", $secondId);\n\n        $user->frontDefaultSort = ESortOptions::Commented->value;\n        $this->entityManager->flush();\n\n        $crawler = $this->client->request('GET', '/microblog');\n\n        self::assertResponseIsSuccessful();\n        self::assertSelectorTextContains('.options__filter button', $this->translator->trans(ESortOptions::Commented->value));\n\n        $children = $crawler->filter('#content div')->children();\n        $iterator = $children->getIterator();\n        /** @var \\DOMElement $firstNode */\n        $firstNode = $iterator->current()->firstElementChild;\n        $firstId = $firstNode->attributes->getNamedItem('id')->nodeValue;\n        self::assertEquals(\"post-{$older->getId()}\", $firstId);\n        $iterator->next();\n        $secondNode = $iterator->current()->firstElementChild;\n        $secondId = $secondNode->attributes->getNamedItem('id')->nodeValue;\n        self::assertEquals(\"post-{$newer->getId()}\", $secondId);\n    }\n\n    private function prepareEntries(): KernelBrowser\n    {\n        $this->createPost(\n            'test post 1',\n            $this->getMagazineByName('kbin', $this->getUserByUsername('JaneDoe')),\n            $this->getUserByUsername('JaneDoe')\n        );\n\n        // sleep so the creation time is actually 1 second apart for the sort to reliably be the same\n        sleep(1);\n\n        $this->createPost('test post 2');\n\n        return $this->client;\n    }\n\n    private function getSortOptions(): array\n    {\n        return ['Top', 'Hot', 'Newest', 'Active', 'Commented'];\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Post/PostLockControllerTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Post;\n\nuse App\\Tests\\WebTestCase;\n\nclass PostLockControllerTest extends WebTestCase\n{\n    public function testModCanLockPost(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n\n        $post = $this->createPost('test post 1', $this->getMagazineByName('acme'));\n\n        $crawler = $this->client->request('GET', \"/m/acme/p/{$post->getId()}/-/moderate\");\n\n        $this->client->submit($crawler->filter('#main .moderate-panel')->selectButton('Lock')->form([]));\n        $crawler = $this->client->followRedirect();\n        $this->assertSelectorExists('#main .post footer span .fa-lock');\n\n        $this->client->submit($crawler->filter('#main .moderate-panel')->selectButton('Unlock')->form([]));\n        $this->client->followRedirect();\n        $this->assertSelectorNotExists('#main .post footer span .fa-lock');\n    }\n\n    public function testAuthorCanLockEntry(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $this->client->loginUser($user);\n\n        $post = $this->createPost('test post 1', $this->getMagazineByName('acme'), user: $user);\n        $crawler = $this->client->request('GET', \"/m/acme/p/{$post->getId()}\");\n        $this->assertSelectorExists('#main .post footer .dropdown .fa-lock');\n\n        $this->client->submit($crawler->filter('#main .post footer .dropdown')->selectButton('Lock')->form([]));\n        $this->client->followRedirect();\n        $this->assertSelectorExists('#main .post footer span .fa-lock');\n    }\n\n    public function testModCanUnlockPost(): void\n    {\n        $user = $this->getUserByUsername('JohnDoe');\n        $this->client->loginUser($user);\n\n        $post = $this->createPost('test post 1', $this->getMagazineByName('acme'));\n        $this->postManager->toggleLock($post, $user);\n\n        $crawler = $this->client->request('GET', \"/m/acme/p/{$post->getId()}/-/moderate\");\n        $this->assertSelectorExists('#main .post footer span .fa-lock');\n\n        $this->client->submit($crawler->filter('#main .moderate-panel')->selectButton('Unlock')->form([]));\n        $this->client->followRedirect();\n        $this->assertSelectorNotExists('#main .post footer span .fa-lock');\n    }\n\n    public function testAuthorCanUnlockEntry(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $this->client->loginUser($user);\n\n        $post = $this->createPost('test post 1', $this->getMagazineByName('acme'), user: $user);\n        $this->postManager->toggleLock($post, $user);\n        $crawler = $this->client->request('GET', \"/m/acme/p/{$post->getId()}\");\n        $this->assertSelectorExists('#main .post footer .dropdown .fa-lock-open');\n\n        $this->client->submit($crawler->filter('#main .post footer .dropdown')->selectButton('Unlock')->form([]));\n        $this->client->followRedirect();\n        $this->assertSelectorNotExists('#main .post footer span .fa-lock');\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Post/PostModerateControllerTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Post;\n\nuse App\\Tests\\WebTestCase;\n\nclass PostModerateControllerTest extends WebTestCase\n{\n    public function testModCanShowPanel(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n\n        $post = $this->createPost('test post 1');\n\n        $crawler = $this->client->request('get', '/microblog');\n        $this->client->click($crawler->filter('#post-'.$post->getId())->selectLink('Moderate')->link());\n\n        $this->assertSelectorTextContains('.moderate-panel', 'ban');\n    }\n\n    public function testXmlModCanShowPanel(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n\n        $post = $this->createPost('test post 1');\n\n        $crawler = $this->client->request('get', '/microblog');\n        $this->client->setServerParameter('HTTP_X-Requested-With', 'XMLHttpRequest');\n        $this->client->click($crawler->filter('#post-'.$post->getId())->selectLink('Moderate')->link());\n\n        $this->assertStringContainsString('moderate-panel', $this->client->getResponse()->getContent());\n    }\n\n    public function testUnauthorizedCanNotShowPanel(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JaneDoe'));\n\n        $post = $this->createPost('test post 1');\n\n        $this->client->request('get', \"/m/{$post->magazine->name}/p/{$post->getId()}\");\n        $this->assertSelectorTextNotContains('#post-'.$post->getId(), 'Moderate');\n\n        $this->client->request(\n            'get',\n            \"/m/{$post->magazine->name}/p/{$post->getId()}/-/moderate\"\n        );\n\n        $this->assertResponseStatusCodeSame(403);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Post/PostPinControllerTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Post;\n\nuse App\\Tests\\WebTestCase;\n\nclass PostPinControllerTest extends WebTestCase\n{\n    public function testModCanPinEntry(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n\n        $post = $this->createPost(\n            'test post 1',\n            $this->getMagazineByName('acme'),\n        );\n\n        $crawler = $this->client->request('GET', \"/m/acme/p/{$post->getId()}/-/moderate\");\n\n        $this->client->submit($crawler->filter('#main .moderate-panel')->selectButton('Pin')->form([]));\n        $crawler = $this->client->followRedirect();\n        $this->assertSelectorExists('#main .post .fa-thumbtack');\n\n        $this->client->submit($crawler->filter('#main .moderate-panel')->selectButton('Unpin')->form([]));\n        $this->client->followRedirect();\n        $this->assertSelectorNotExists('#main .post .fa-thumbtack');\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Post/PostSingleControllerTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Post;\n\nuse App\\Entity\\Contracts\\VotableInterface;\nuse App\\Enums\\ESortOptions;\nuse App\\Service\\FavouriteManager;\nuse App\\Service\\VoteManager;\nuse App\\Tests\\WebTestCase;\n\nclass PostSingleControllerTest extends WebTestCase\n{\n    public function testUserCanGoToPostFromFrontpage(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n\n        $this->createPost('test post 1');\n\n        $crawler = $this->client->request('GET', '/microblog');\n        $this->client->click($crawler->filter('.link-muted')->link());\n\n        $this->assertSelectorTextContains('blockquote', 'test post 1');\n        $this->assertSelectorTextContains('#main', 'No comments');\n        $this->assertSelectorTextContains('#sidebar .magazine', 'Magazine');\n        $this->assertSelectorTextContains('#sidebar .user-list', 'Moderators');\n        $this->assertSelectorTextContains('.head-nav__menu .active', $this->translator->trans('microblog'));\n    }\n\n    public function testUserCanSeePost(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n\n        $post = $this->createPost('test post 1');\n\n        $this->client->request('GET', \"/m/acme/p/{$post->getId()}/test-post-1\");\n\n        $this->assertSelectorTextContains('blockquote', 'test post 1');\n        $this->assertSelectorTextContains('.head-nav__menu .active', $this->translator->trans('microblog'));\n    }\n\n    public function testPostActivityCounter(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n\n        $post = $this->createPost('test post 1');\n\n        $manager = $this->client->getContainer()->get(VoteManager::class);\n        $manager->vote(VotableInterface::VOTE_DOWN, $post, $this->getUserByUsername('JaneDoe'));\n\n        $manager = $this->client->getContainer()->get(FavouriteManager::class);\n        $manager->toggle($this->getUserByUsername('JohnDoe'), $post);\n        $manager->toggle($this->getUserByUsername('JaneDoe'), $post);\n\n        $this->client->request('GET', \"/m/acme/p/{$post->getId()}/test-post-1\");\n\n        $this->assertSelectorTextContains('.options-activity', 'Activity (2)');\n    }\n\n    public function testCommentsDefaultSortOption(): void\n    {\n        $user = $this->getUserByUsername('user');\n        $post = $this->createPost('entry');\n        $older = $this->createPostComment('older comment', post: $post);\n        $older->createdAt = new \\DateTimeImmutable('now - 1 day');\n        $newer = $this->createPostComment('newer comment', post: $post);\n\n        $user->commentDefaultSort = ESortOptions::Oldest->value;\n        $this->entityManager->flush();\n\n        $this->client->loginUser($user);\n        $crawler = $this->client->request('GET', \"/m/{$post->magazine->name}/p/{$post->getId()}/-\");\n        self::assertResponseIsSuccessful();\n        $this->assertSelectorTextContains('.options__main .active', $this->translator->trans(ESortOptions::Oldest->value));\n\n        $iterator = $crawler->filter('#comments div')->children()->getIterator();\n        /** @var \\DOMElement $firstNode */\n        $firstNode = $iterator->current();\n        $firstId = $firstNode->attributes->getNamedItem('id')->nodeValue;\n        self::assertEquals(\"post-comment-{$older->getId()}\", $firstId);\n        $iterator->next();\n        $secondNode = $iterator->current();\n        $secondId = $secondNode->attributes->getNamedItem('id')->nodeValue;\n        self::assertEquals(\"post-comment-{$newer->getId()}\", $secondId);\n\n        $user->commentDefaultSort = ESortOptions::Newest->value;\n        $this->entityManager->flush();\n\n        $crawler = $this->client->request('GET', \"/m/{$post->magazine->name}/p/{$post->getId()}/-\");\n        self::assertResponseIsSuccessful();\n        $this->assertSelectorTextContains('.options__main .active', $this->translator->trans(ESortOptions::Newest->value));\n\n        $iterator = $crawler->filter('#comments div')->children()->getIterator();\n        /** @var \\DOMElement $firstNode */\n        $firstNode = $iterator->current();\n        $firstId = $firstNode->attributes->getNamedItem('id')->nodeValue;\n        self::assertEquals(\"post-comment-{$newer->getId()}\", $firstId);\n        $iterator->next();\n        $secondNode = $iterator->current();\n        $secondId = $secondNode->attributes->getNamedItem('id')->nodeValue;\n        self::assertEquals(\"post-comment-{$older->getId()}\", $secondId);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Post/PostVotersControllerTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Post;\n\nuse App\\Entity\\Contracts\\VotableInterface;\nuse App\\Service\\VoteManager;\nuse App\\Tests\\WebTestCase;\n\nclass PostVotersControllerTest extends WebTestCase\n{\n    public function testUserCanSeeVoters(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n\n        $post = $this->createPost('test post 1');\n\n        $manager = $this->client->getContainer()->get(VoteManager::class);\n        $manager->vote(VotableInterface::VOTE_UP, $post, $this->getUserByUsername('JaneDoe'));\n\n        $crawler = $this->client->request('GET', \"/m/acme/p/{$post->getId()}/test-post-1\");\n\n        $this->client->click($crawler->filter('.options-activity')->selectLink('Boosts (1)')->link());\n\n        $this->assertSelectorTextContains('#main .users-columns', 'JaneDoe');\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/PrivacyPolicyControllerTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller;\n\nuse App\\Tests\\WebTestCase;\n\nclass PrivacyPolicyControllerTest extends WebTestCase\n{\n    public function testPrivacyPolicyPage(): void\n    {\n        $crawler = $this->client->request('GET', '/');\n        $this->client->click($crawler->filter('.about.section a[href=\"/privacy-policy\"]')->link());\n\n        $this->assertSelectorTextContains('h1', 'Privacy policy');\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/ReportControllerControllerTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller;\n\nuse App\\Tests\\WebTestCase;\n\nclass ReportControllerControllerTest extends WebTestCase\n{\n    public function testLoggedUserCanReportEntry(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n\n        $entry = $this->getEntryByTitle(\n            'test entry 1',\n            'https://kbin.pub',\n            null,\n            null,\n            $this->getUserByUsername('JaneDoe')\n        );\n\n        $crawler = $this->client->request('GET', \"/m/acme/t/{$entry->getId()}/test-entry-1\");\n        $crawler = $this->client->click($crawler->filter('#main .entry menu')->selectLink('Report')->link());\n\n        $this->assertSelectorExists('#main .entry');\n\n        $this->client->submit(\n            $crawler->filter('form[name=report]')->selectButton('Report')->form(\n                [\n                    'report[reason]' => 'test reason 1',\n                ]\n            )\n        );\n\n        $repo = $this->reportRepository;\n\n        $this->assertEquals(1, $repo->count([]));\n    }\n\n    public function testLoggedUserCanReportEntryComment(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n\n        $entry = $this->getEntryByTitle(\n            'test entry 1',\n            'https://kbin.pub',\n            null,\n            null,\n            $this->getUserByUsername('JaneDoe')\n        );\n        $this->createEntryComment('test comment 1', $entry, $this->getUserByUsername('JaneDoe'));\n\n        $crawler = $this->client->request('GET', \"/m/acme/t/{$entry->getId()}/test-entry-1\");\n        $crawler = $this->client->click($crawler->filter('#main .entry-comment')->selectLink('Report')->link());\n\n        $this->assertSelectorExists('#main .entry-comment');\n\n        $this->client->submit(\n            $crawler->filter('form[name=report]')->selectButton('Report')->form(\n                [\n                    'report[reason]' => 'test reason 1',\n                ]\n            )\n        );\n\n        $repo = $this->reportRepository;\n\n        $this->assertEquals(1, $repo->count([]));\n    }\n\n    public function testLoggedUserCanReportPost(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n\n        $post = $this->createPost('test post 1', null, $this->getUserByUsername('JaneDoe'));\n\n        $crawler = $this->client->request('GET', \"/m/acme/p/{$post->getId()}/test-post-1\");\n        $crawler = $this->client->click($crawler->filter('#main .post menu')->selectLink('Report')->link());\n\n        $this->assertSelectorExists('#main .post');\n\n        $this->client->submit(\n            $crawler->filter('form[name=report]')->selectButton('Report')->form(\n                [\n                    'report[reason]' => 'test reason 1',\n                ]\n            )\n        );\n\n        $repo = $this->reportRepository;\n\n        $this->assertEquals(1, $repo->count([]));\n    }\n\n    public function testLoggedUserCanReportPostComment(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n\n        $post = $this->createPost('test post 1', null, $this->getUserByUsername('JaneDoe'));\n        $this->createPostComment('test comment 1', $post, $this->getUserByUsername('JaneDoe'));\n\n        $crawler = $this->client->request('GET', \"/m/acme/p/{$post->getId()}/test-post-1\");\n        $crawler = $this->client->click($crawler->filter('#main .post-comment menu')->selectLink('Report')->link());\n\n        $this->assertSelectorExists('#main .post-comment');\n\n        $this->client->submit(\n            $crawler->filter('form[name=report]')->selectButton('Report')->form(\n                [\n                    'report[reason]' => 'test reason 1',\n                ]\n            )\n        );\n\n        $repo = $this->reportRepository;\n\n        $this->assertEquals(1, $repo->count([]));\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Security/LoginControllerTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Security;\n\nuse App\\Tests\\WebTestCase;\nuse PHPUnit\\Framework\\Attributes\\Group;\n\nclass LoginControllerTest extends WebTestCase\n{\n    #[Group(name: 'NonThreadSafe')]\n    public function testUserCanLogin(): void\n    {\n        $this->client = $this->register(true);\n\n        $crawler = $this->client->request('get', '/');\n        $crawler = $this->client->click($crawler->filter('header')->selectLink('Log in')->link());\n\n        $this->client->submit(\n            $crawler->selectButton('Log in')->form(\n                [\n                    'email' => 'JohnDoe',\n                    'password' => 'secret',\n                ]\n            )\n        );\n\n        $crawler = $this->client->followRedirect();\n\n        $this->assertSelectorTextContains('#header', 'JohnDoe');\n    }\n\n    #[Group(name: 'NonThreadSafe')]\n    public function testUserCannotLoginWithoutActivation(): void\n    {\n        $this->client = $this->register();\n\n        $crawler = $this->client->request('get', '/');\n        $crawler = $this->client->click($crawler->filter('header')->selectLink('Log in')->link());\n\n        $this->client->submit(\n            $crawler->selectButton('Log in')->form(\n                [\n                    'email' => 'JohnDoe',\n                    'password' => 'secret',\n                ]\n            )\n        );\n\n        $this->client->followRedirect();\n\n        $this->assertSelectorTextContains('#main', 'Please check your email for account activation instructions or request a new account activation email');\n    }\n\n    public function testUserCantLoginWithWrongPassword(): void\n    {\n        $this->getUserByUsername('JohnDoe');\n\n        $crawler = $this->client->request('GET', '/');\n        $crawler = $this->client->click($crawler->filter('header')->selectLink('Log in')->link());\n\n        $this->client->submit(\n            $crawler->selectButton('Log in')->form(\n                [\n                    'email' => 'JohnDoe',\n                    'password' => 'wrongpassword',\n                ]\n            )\n        );\n\n        $this->client->followRedirect();\n\n        $this->assertSelectorTextContains('.alert__danger', 'Invalid credentials.'); // @todo\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Security/OAuth2ConsentControllerTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Security;\n\nuse App\\Tests\\WebTestCase;\n\nclass OAuth2ConsentControllerTest extends WebTestCase\n{\n    public function testUserCanConsent(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n        self::createOAuth2AuthCodeClient();\n\n        self::runAuthorizationCodeFlowToConsentPage($this->client, 'read write', 'oauth2state');\n\n        self::assertSelectorTextContains(\"li[id='oauth2.grant.read.general']\", 'Read all content you have access to.');\n        self::assertSelectorTextContains(\"li[id='oauth2.grant.write.general']\", 'Create or edit any of your threads, posts, or comments.');\n\n        self::runAuthorizationCodeFlowToRedirectUri($this->client, 'read write', 'yes', 'oauth2state');\n\n        $response = $this->client->getResponse();\n\n        $parsedUrl = parse_url($response->headers->get('Location'));\n        self::assertEquals('https', $parsedUrl['scheme']);\n        self::assertEquals('localhost', $parsedUrl['host']);\n        self::assertEquals('3001', $parsedUrl['port']);\n        self::assertStringContainsString('code', $parsedUrl['query']);\n    }\n\n    public function testUserCanDissent(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n        self::createOAuth2AuthCodeClient();\n\n        self::runAuthorizationCodeFlowToConsentPage($this->client, 'read write', 'oauth2state');\n\n        self::assertSelectorTextContains(\"li[id='oauth2.grant.read.general']\", 'Read all content you have access to.');\n        self::assertSelectorTextContains(\"li[id='oauth2.grant.write.general']\", 'Create or edit any of your threads, posts, or comments.');\n\n        self::runAuthorizationCodeFlowToRedirectUri($this->client, 'read write', 'no', 'oauth2state');\n\n        $response = $this->client->getResponse();\n\n        $parsedUrl = parse_url($response->headers->get('Location'));\n        self::assertEquals('https', $parsedUrl['scheme']);\n        self::assertEquals('localhost', $parsedUrl['host']);\n        self::assertEquals('3001', $parsedUrl['port']);\n        self::assertStringContainsString('error=access_denied', $parsedUrl['query']);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Security/OAuth2TokenControllerTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Security;\n\nuse App\\Tests\\WebTestCase;\n\nclass OAuth2TokenControllerTest extends WebTestCase\n{\n    public function testCanGetTokenWithValidClientCredentials(): void\n    {\n        self::createOAuth2ClientCredsClient();\n\n        $this->client->request('POST', '/token', [\n            'grant_type' => 'client_credentials',\n            'client_id' => 'testclient',\n            'client_secret' => 'testsecret',\n            'scope' => 'read write',\n        ]);\n\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayHasKey('token_type', $jsonData);\n        self::assertEquals('Bearer', $jsonData['token_type']);\n        self::assertArrayHasKey('expires_in', $jsonData);\n        self::assertIsInt($jsonData['expires_in']);\n        self::assertArrayHasKey('access_token', $jsonData);\n        self::assertMatchesRegularExpression(self::JWT_REGEX, $jsonData['access_token']);\n        self::assertArrayNotHasKey('refresh_token', $jsonData);\n    }\n\n    public function testCanGetTokenWithValidAuthorizationCode(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n        self::createOAuth2AuthCodeClient();\n\n        $jsonData = self::getAuthorizationCodeTokenResponse($this->client);\n\n        self::assertResponseIsSuccessful();\n        self::assertIsArray($jsonData);\n        self::assertArrayHasKey('token_type', $jsonData);\n        self::assertEquals('Bearer', $jsonData['token_type']);\n        self::assertArrayHasKey('expires_in', $jsonData);\n        self::assertIsInt($jsonData['expires_in']);\n        self::assertArrayHasKey('access_token', $jsonData);\n        self::assertMatchesRegularExpression(self::JWT_REGEX, $jsonData['access_token']);\n        self::assertArrayHasKey('refresh_token', $jsonData);\n        self::assertMatchesRegularExpression(self::CODE_REGEX, $jsonData['refresh_token']);\n    }\n\n    public function testCanGetTokenWithValidRefreshToken(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n        self::createOAuth2AuthCodeClient();\n\n        $jsonData = self::getAuthorizationCodeTokenResponse($this->client);\n\n        self::assertResponseIsSuccessful();\n        self::assertIsArray($jsonData);\n        self::assertArrayHasKey('refresh_token', $jsonData);\n\n        $jsonData = self::getRefreshTokenResponse($this->client, $jsonData['refresh_token']);\n        self::assertResponseIsSuccessful();\n        self::assertIsArray($jsonData);\n        self::assertArrayHasKey('token_type', $jsonData);\n        self::assertEquals('Bearer', $jsonData['token_type']);\n        self::assertArrayHasKey('expires_in', $jsonData);\n        self::assertIsInt($jsonData['expires_in']);\n        self::assertArrayHasKey('access_token', $jsonData);\n        self::assertMatchesRegularExpression(self::JWT_REGEX, $jsonData['access_token']);\n        self::assertArrayHasKey('refresh_token', $jsonData);\n        self::assertMatchesRegularExpression(self::CODE_REGEX, $jsonData['refresh_token']);\n    }\n\n    public function testCanGetTokenWithValidAuthorizationCodePKCE(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n        self::createOAuth2PublicAuthCodeClient();\n\n        $jsonData = self::getPublicAuthorizationCodeTokenResponse($this->client);\n\n        self::assertResponseIsSuccessful();\n        self::assertIsArray($jsonData);\n        self::assertArrayHasKey('token_type', $jsonData);\n        self::assertEquals('Bearer', $jsonData['token_type']);\n        self::assertArrayHasKey('expires_in', $jsonData);\n        self::assertIsInt($jsonData['expires_in']);\n        self::assertArrayHasKey('access_token', $jsonData);\n        self::assertMatchesRegularExpression(self::JWT_REGEX, $jsonData['access_token']);\n        self::assertArrayHasKey('refresh_token', $jsonData);\n        self::assertMatchesRegularExpression(self::CODE_REGEX, $jsonData['refresh_token']);\n    }\n\n    public function testCannotGetTokenWithInvalidVerifierAuthorizationCodePKCE(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n        self::createOAuth2PublicAuthCodeClient();\n\n        $pkceCodes = self::runPublicAuthorizationCodeFlow($this->client, 'yes');\n        self::runPublicAuthorizationCodeTokenFetch($this->client, $pkceCodes['verifier'].'fail');\n\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertResponseStatusCodeSame(400);\n        self::assertIsArray($jsonData);\n        self::assertArrayHasKey('error', $jsonData);\n        self::assertEquals('invalid_grant', $jsonData['error']);\n        self::assertArrayHasKey('error_description', $jsonData);\n        self::assertArrayHasKey('hint', $jsonData);\n    }\n\n    public function testCannotGetTokenWithoutChallengeAuthorizationCodePKCE(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n        self::createOAuth2PublicAuthCodeClient();\n\n        $query = self::buildPrivateAuthCodeQuery('testpublicclient', 'read write', 'oauth2state', 'https://localhost:3001');\n\n        $uri = '/authorize?'.$query;\n\n        $this->client->request('GET', $uri);\n\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertResponseStatusCodeSame(400);\n        self::assertIsArray($jsonData);\n        self::assertArrayHasKey('error', $jsonData);\n        self::assertEquals('invalid_request', $jsonData['error']);\n        self::assertArrayHasKey('error_description', $jsonData);\n        self::assertArrayHasKey('hint', $jsonData);\n        self::assertStringContainsStringIgnoringCase('code challenge', $jsonData['hint']);\n    }\n\n    public function testReceiveErrorWithInvalidAuthorizationCode(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n        self::createOAuth2AuthCodeClient();\n\n        $this->client->request('POST', '/token', [\n            'grant_type' => 'authorization_code',\n            'client_id' => 'testclient',\n            'client_secret' => 'testsecret',\n            'code' => 'deadbeefc0de',\n            'redirect_uri' => 'https://localhost:3001',\n        ]);\n\n        self::assertResponseStatusCodeSame(400);\n\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayHasKey('error', $jsonData);\n        self::assertEquals('invalid_grant', $jsonData['error']);\n        self::assertArrayHasKey('error_description', $jsonData);\n        self::assertArrayHasKey('hint', $jsonData);\n    }\n\n    public function testReceiveErrorWithInvalidClientId(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n        self::createOAuth2AuthCodeClient();\n\n        $query = self::buildPrivateAuthCodeQuery('testclientfake', 'read write', 'oauth2state', 'https://localhost:3001');\n\n        $uri = '/authorize?'.$query;\n\n        $this->client->request('GET', $uri);\n\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertResponseStatusCodeSame(401);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayHasKey('error', $jsonData);\n        self::assertEquals('invalid_client', $jsonData['error']);\n        self::assertArrayHasKey('error_description', $jsonData);\n    }\n\n    public function testReceiveErrorWithInvalidClientSecret(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n        self::createOAuth2AuthCodeClient();\n\n        $jsonData = self::getAuthorizationCodeTokenResponse($this->client, clientSecret: 'testsecretfake');\n\n        self::assertResponseStatusCodeSame(401);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayHasKey('error', $jsonData);\n        self::assertEquals('invalid_client', $jsonData['error']);\n        self::assertArrayHasKey('error_description', $jsonData);\n    }\n\n    public function testReceiveErrorWithInvalidRedirectUri(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n        self::createOAuth2AuthCodeClient();\n\n        $query = self::buildPrivateAuthCodeQuery('testclient', 'read write', 'oauth2state', 'https://invalid.com');\n\n        $uri = '/authorize?'.$query;\n\n        $this->client->request('GET', $uri);\n\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertResponseStatusCodeSame(401);\n\n        self::assertIsArray($jsonData);\n        self::assertArrayHasKey('error', $jsonData);\n        self::assertEquals('invalid_client', $jsonData['error']);\n        self::assertArrayHasKey('error_description', $jsonData);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/Security/RegisterControllerTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\Security;\n\nuse App\\Tests\\WebTestCase;\nuse Symfony\\Bridge\\Twig\\Mime\\TemplatedEmail;\nuse Symfony\\Bundle\\FrameworkBundle\\KernelBrowser;\nuse Symfony\\Component\\DomCrawler\\Crawler;\n\nclass RegisterControllerTest extends WebTestCase\n{\n    public function testUserCanVerifyAccount(): void\n    {\n        $this->registerUserAccount($this->client);\n\n        $this->assertEmailCount(1);\n\n        /** @var TemplatedEmail $email */\n        $email = $this->getMailerMessage();\n\n        $this->assertEmailHeaderSame($email, 'To', 'johndoe@kbin.pub');\n\n        $verificationLink = (new Crawler($email->getHtmlBody()))\n            ->filter('a.btn.btn__primary')\n            ->attr('href')\n        ;\n\n        $this->client->request('GET', $verificationLink);\n        $crawler = $this->client->followRedirect();\n\n        $this->client->submit(\n            $crawler->selectButton('Log in')->form(\n                [\n                    'email' => 'JohnDoe',\n                    'password' => 'secret',\n                ]\n            )\n        );\n\n        $this->client->followRedirect();\n\n        $this->assertSelectorTextNotContains('#header', 'Log in');\n    }\n\n    private function registerUserAccount(KernelBrowser $client): void\n    {\n        $crawler = $client->request('GET', '/register');\n\n        $client->submit(\n            $crawler->filter('form[name=user_register]')->selectButton('Register')->form(\n                [\n                    'user_register[username]' => 'JohnDoe',\n                    'user_register[email]' => 'johndoe@kbin.pub',\n                    'user_register[plainPassword][first]' => 'secret',\n                    'user_register[plainPassword][second]' => 'secret',\n                    'user_register[agreeTerms]' => true,\n                ]\n            )\n        );\n    }\n\n    public function testUserCannotLoginWithoutConfirmation()\n    {\n        $this->registerUserAccount($this->client);\n\n        $crawler = $this->client->followRedirect();\n\n        $crawler = $this->client->click($crawler->filter('#header')->selectLink('Log in')->link());\n\n        $this->client->submit(\n            $crawler->selectButton('Log in')->form(\n                [\n                    'email' => 'JohnDoe',\n                    'password' => 'wrong_password',\n                ]\n            )\n        );\n\n        $this->client->followRedirect();\n\n        $this->assertSelectorTextContains('.alert__danger', 'Your account has not been activated.');\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/TermsControllerTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller;\n\nuse App\\Tests\\WebTestCase;\n\nclass TermsControllerTest extends WebTestCase\n{\n    public function testTermsPage(): void\n    {\n        $crawler = $this->client->request('GET', '/');\n        $this->client->click($crawler->filter('.about.section a[href=\"/terms\"]')->link());\n\n        $this->assertSelectorTextContains('h1', 'Terms');\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/User/Admin/UserDeleteControllerTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\User\\Admin;\n\nuse App\\Tests\\WebTestCase;\n\nclass UserDeleteControllerTest extends WebTestCase\n{\n    public function testAdminCanDeleteUser()\n    {\n        $user = $this->getUserByUsername('user');\n        $entry = $this->getEntryByTitle('An entry', body: 'test', user: $user);\n        $entryComment = $this->createEntryComment('A comment', $entry, $user);\n        $post = $this->createPost('A post', user: $user);\n        $postComment = $this->createPostComment('A comment', $post, $user);\n        $admin = $this->getUserByUsername('admin', isAdmin: true);\n        $this->client->loginUser($admin);\n\n        $crawler = $this->client->request('GET', '/u/user');\n\n        $this->assertSelectorExists('#sidebar .panel form[action$=\"delete_account\"]');\n        $this->client->submit(\n            $crawler->filter('#sidebar .panel form[action$=\"delete_account\"]')->selectButton('Delete account')->form()\n        );\n\n        $this->assertResponseRedirects();\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/User/Profile/UserBlockControllerTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\User\\Profile;\n\nuse App\\Tests\\WebTestCase;\n\nclass UserBlockControllerTest extends WebTestCase\n{\n    public function testUserCanSeeBlockedMagazines()\n    {\n        $this->client->loginUser($user = $this->getUserByUsername('JaneDoe'));\n        $magazine = $this->getMagazineByName('acme');\n\n        $this->magazineManager->block($magazine, $user);\n\n        $crawler = $this->client->request('GET', '/settings/blocked/magazines');\n        $this->client->click($crawler->filter('#main .pills')->selectLink('Magazines')->link());\n\n        $this->assertSelectorTextContains('#main .pills .active', 'Magazines');\n        $this->assertSelectorTextContains('#main .magazines', 'acme');\n    }\n\n    public function testUserCanSeeBlockedUsers()\n    {\n        $this->client->loginUser($user = $this->getUserByUsername('JaneDoe'));\n\n        $this->userManager->block($user, $this->getUserByUsername('JohnDoe'));\n\n        $crawler = $this->client->request('GET', '/settings/blocked/people');\n        $this->client->click($crawler->filter('#main .pills')->selectLink('People')->link());\n\n        $this->assertSelectorTextContains('#main .pills .active', 'People');\n        $this->assertSelectorTextContains('#main .users', 'JohnDoe');\n    }\n\n    public function testUserCanSeeBlockedDomains()\n    {\n        $this->client->loginUser($user = $this->getUserByUsername('JaneDoe'));\n\n        $entry = $this->getEntryByTitle('test1', 'https://kbin.pub');\n\n        $this->domainManager->block($entry->domain, $user);\n\n        $crawler = $this->client->request('GET', '/settings/blocked/domains');\n        $this->client->click($crawler->filter('#main .pills')->selectLink('Domains')->link());\n\n        $this->assertSelectorTextContains('#main .pills .active', 'Domains');\n        $this->assertSelectorTextContains('#main', 'kbin.pub');\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/User/Profile/UserEditControllerTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\User\\Profile;\n\nuse App\\Tests\\WebTestCase;\nuse PHPUnit\\Framework\\Attributes\\Group;\nuse Symfony\\Bridge\\Twig\\Mime\\TemplatedEmail;\nuse Symfony\\Component\\DomCrawler\\Crawler;\n\nclass UserEditControllerTest extends WebTestCase\n{\n    public string $kibbyPath;\n\n    public function setUp(): void\n    {\n        parent::setUp();\n        $this->kibbyPath = \\dirname(__FILE__, 5).'/assets/kibby_emoji.png';\n    }\n\n    public function testUserCanSeeSettingsLink(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n\n        $crawler = $this->client->request('GET', '/');\n        $this->client->click($crawler->filter('#header menu')->selectLink('Settings')->link());\n\n        $this->assertSelectorTextContains('#main .options__main a.active', 'General');\n    }\n\n    public function testUserCanEditProfileAbout(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n\n        $crawler = $this->client->request('GET', '/settings/profile');\n        $this->assertSelectorTextContains('#main .options__main a.active', 'Profile');\n\n        $this->client->submit(\n            $crawler->filter('#main form[name=user_basic]')->selectButton('Save')->form([\n                'user_basic[about]' => 'test about',\n            ])\n        );\n\n        $this->client->followRedirect();\n        $this->assertSelectorTextContains('#main .user-box', 'test about');\n\n        $this->client->request('GET', '/people');\n\n        $this->assertSelectorTextContains('#main .user-box', 'JohnDoe');\n    }\n\n    public function testUserCanEditProfileTitle(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n\n        $crawler = $this->client->request('GET', '/settings/profile');\n        $this->assertSelectorTextContains('#main .options__main a.active', 'Profile');\n\n        $this->client->submit(\n            $crawler->filter('#main form[name=user_basic]')->selectButton('Save')->form([\n                'user_basic[title]' => 'custom name',\n            ])\n        );\n\n        $this->client->followRedirect();\n        $this->assertSelectorTextContains('#main .user-box h1', 'custom name');\n\n        $this->client->request('GET', '/people');\n\n        $this->assertSelectorTextContains('#main .user-box', 'JohnDoe');\n    }\n\n    public function testUserEditProfileTitleTrimsWhitespace(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n\n        $crawler = $this->client->request('GET', '/settings/profile');\n        $this->assertSelectorTextContains('#main .options__main a.active', 'Profile');\n\n        $this->client->submit(\n            $crawler->filter('#main form[name=user_basic]')->selectButton('Save')->form([\n                'user_basic[title]' => \"  custom name\\t\",\n            ])\n        );\n\n        $this->client->followRedirect();\n        $this->assertSelectorTextContains('#main .user-box h1', 'custom name');\n\n        $this->client->request('GET', '/people');\n\n        $this->assertSelectorTextContains('#main .user-box', 'JohnDoe');\n    }\n\n    #[Group(name: 'NonThreadSafe')]\n    public function testUserCanUploadAvatar(): void\n    {\n        $user = $this->getUserByUsername('JohnDoe');\n        $this->client->loginUser($user);\n        $repository = $this->userRepository;\n\n        $crawler = $this->client->request('GET', '/settings/profile');\n        $this->assertSelectorTextContains('#main .options__main a.active', 'Profile');\n        $this->assertStringContainsString('/dev/random', $user->avatar->filePath);\n\n        $form = $crawler->filter('#main form[name=user_basic]')->selectButton('Save')->form();\n        $form['user_basic[avatar]']->upload($this->kibbyPath);\n        $this->client->submit($form);\n\n        $user = $repository->find($user->getId());\n        $this->assertStringContainsString(self::KIBBY_PNG_URL_RESULT, $user->avatar->filePath);\n    }\n\n    #[Group(name: 'NonThreadSafe')]\n    public function testUserCanUploadCover(): void\n    {\n        $user = $this->getUserByUsername('JohnDoe');\n        $this->client->loginUser($user);\n        $repository = $this->userRepository;\n\n        $crawler = $this->client->request('GET', '/settings/profile');\n        $this->assertSelectorTextContains('#main .options__main a.active', 'Profile');\n        $this->assertNull($user->cover);\n\n        $form = $crawler->filter('#main form[name=user_basic]')->selectButton('Save')->form();\n        $form['user_basic[cover]']->upload($this->kibbyPath);\n        $this->client->submit($form);\n\n        $user = $repository->find($user->getId());\n        $this->assertStringContainsString(self::KIBBY_PNG_URL_RESULT, $user->cover->filePath);\n    }\n\n    public function testUserCanChangePassword(): void\n    {\n        $this->client = $this->register(true);\n\n        $this->client->loginUser($this->userRepository->findOneBy(['username' => 'JohnDoe']));\n\n        $crawler = $this->client->request('GET', '/settings/password');\n        $this->assertSelectorTextContains('#main .options__main a.active', 'Password');\n\n        $this->client->submit(\n            $crawler->filter('#main form[name=user_password]')->selectButton('Save')->form([\n                'user_password[currentPassword]' => 'secret',\n                'user_password[plainPassword][first]' => 'test123',\n                'user_password[plainPassword][second]' => 'test123',\n            ])\n        );\n\n        $this->client->followRedirect();\n\n        $crawler = $this->client->request('GET', '/login');\n\n        $this->client->submit(\n            $crawler->filter('#main form')->selectButton('Log in')->form([\n                'email' => 'JohnDoe',\n                'password' => 'test123',\n            ])\n        );\n\n        $this->client->followRedirect();\n\n        $this->assertSelectorTextContains('#header', 'JohnDoe');\n    }\n\n    public function testUserCanChangeEmail(): void\n    {\n        $this->client = $this->register(true);\n\n        $this->client->loginUser($this->userRepository->findOneBy(['username' => 'JohnDoe']));\n\n        $crawler = $this->client->request('GET', '/settings/email');\n        $this->assertSelectorTextContains('#main .options__main a.active', 'Email');\n\n        $this->client->submit(\n            $crawler->filter('#main form[name=user_email]')->selectButton('Save')->form([\n                'user_email[newEmail][first]' => 'acme@kbin.pub',\n                'user_email[newEmail][second]' => 'acme@kbin.pub',\n                'user_email[currentPassword]' => 'secret',\n            ])\n        );\n\n        $this->assertEmailCount(1);\n\n        /** @var TemplatedEmail $email */\n        $email = $this->getMailerMessage();\n\n        $this->assertEmailHeaderSame($email, 'To', 'acme@kbin.pub');\n\n        $verificationLink = (new Crawler($email->getHtmlBody()))\n            ->filter('a.btn.btn__primary')\n            ->attr('href')\n        ;\n\n        $this->client->request('GET', $verificationLink);\n\n        $crawler = $this->client->followRedirect();\n\n        $this->client->submit(\n            $crawler->filter('#main form')->selectButton('Log in')->form([\n                'email' => 'JohnDoe',\n                'password' => 'secret',\n            ])\n        );\n\n        $this->client->followRedirect();\n\n        $this->assertSelectorTextContains('#header', 'JohnDoe');\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/User/Profile/UserNotificationControllerTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\User\\Profile;\n\nuse App\\Tests\\WebTestCase;\n\nclass UserNotificationControllerTest extends WebTestCase\n{\n    public function testUserReceiveNotificationTest(): void\n    {\n        $this->client->loginUser($owner = $this->getUserByUsername('owner'));\n\n        $actor = $this->getUserByUsername('actor');\n\n        $this->magazineManager->subscribe($this->getMagazineByName('acme'), $owner);\n        $this->magazineManager->subscribe($this->getMagazineByName('acme'), $actor);\n\n        $this->loadNotificationsFixture();\n\n        $crawler = $this->client->request('GET', '/settings/notifications');\n        $this->assertCount(2, $crawler->filter('#main .notification'));\n\n        $this->client->restart();\n        $this->client->loginUser($actor);\n\n        $crawler = $this->client->request('GET', '/settings/notifications');\n        $this->assertCount(3, $crawler->filter('#main .notification'));\n\n        $this->client->restart();\n        $this->client->loginUser($this->getUserByUsername('JohnDoe'));\n\n        $crawler = $this->client->request('GET', '/settings/notifications');\n        $this->assertCount(2, $crawler->filter('#main .notification'));\n    }\n\n    public function testCanReadAllNotifications(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('owner'));\n\n        $this->magazineManager->subscribe(\n            $this->getMagazineByName('acme'),\n            $this->getUserByUsername('owner')\n        );\n        $this->magazineManager->subscribe(\n            $this->getMagazineByName('acme'),\n            $this->getUserByUsername('actor')\n        );\n\n        $this->loadNotificationsFixture();\n\n        $this->client->loginUser($this->getUserByUsername('owner'));\n\n        $crawler = $this->client->request('GET', '/settings/notifications');\n\n        $this->assertCount(2, $crawler->filter('#main .notification'));\n        $this->assertCount(0, $crawler->filter('#main .notification.opacity-50'));\n\n        $this->client->submit($crawler->selectButton('Read all')->form());\n\n        $crawler = $this->client->followRedirect();\n\n        $this->assertCount(2, $crawler->filter('#main .notification.opacity-50'));\n    }\n\n    public function testUserCanDeleteAllNotifications(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('owner'));\n\n        $this->magazineManager->subscribe(\n            $this->getMagazineByName('acme'),\n            $this->getUserByUsername('owner')\n        );\n        $this->magazineManager->subscribe(\n            $this->getMagazineByName('acme'),\n            $this->getUserByUsername('actor')\n        );\n\n        $this->loadNotificationsFixture();\n\n        $this->client->loginUser($this->getUserByUsername('owner'));\n\n        $crawler = $this->client->request('GET', '/settings/notifications');\n\n        $this->assertCount(2, $crawler->filter('#main .notification'));\n\n        $this->client->submit($crawler->selectButton('Purge')->form());\n\n        $crawler = $this->client->followRedirect();\n\n        $this->assertCount(0, $crawler->filter('#main .notification'));\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/User/Profile/UserSubControllerTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\User\\Profile;\n\nuse App\\Tests\\WebTestCase;\n\nclass UserSubControllerTest extends WebTestCase\n{\n    public function testUserCanSeeSubscribedMagazines()\n    {\n        $this->client->loginUser($user = $this->getUserByUsername('JaneDoe'));\n        $magazine = $this->getMagazineByName('acme');\n\n        $this->magazineManager->subscribe($magazine, $user);\n\n        $crawler = $this->client->request('GET', '/settings/subscriptions/magazines');\n        $this->client->click($crawler->filter('#main .pills')->selectLink('Magazines')->link());\n\n        $this->assertSelectorTextContains('#main .pills .active', 'Magazines');\n        $this->assertSelectorTextContains('#main .magazines', 'acme');\n    }\n\n    public function testUserCanSeeSubscribedUsers()\n    {\n        $this->client->loginUser($user = $this->getUserByUsername('JaneDoe'));\n\n        $this->userManager->follow($user, $this->getUserByUsername('JohnDoe'));\n\n        $crawler = $this->client->request('GET', '/settings/subscriptions/people');\n        $this->client->click($crawler->filter('#main .pills')->selectLink('People')->link());\n\n        $this->assertSelectorTextContains('#main .pills .active', 'People');\n        $this->assertSelectorTextContains('#main .users', 'JohnDoe');\n    }\n\n    public function testUserCanSeeSubscribedDomains()\n    {\n        $this->client->loginUser($user = $this->getUserByUsername('JaneDoe'));\n\n        $entry = $this->getEntryByTitle('test1', 'https://kbin.pub');\n\n        $this->domainManager->subscribe($entry->domain, $user);\n\n        $crawler = $this->client->request('GET', '/settings/subscriptions/domains');\n        $this->client->click($crawler->filter('#main .pills')->selectLink('Domains')->link());\n\n        $this->assertSelectorTextContains('#main .pills .active', 'Domains');\n        $this->assertSelectorTextContains('#main', 'kbin.pub');\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/User/UserBlockControllerTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\User;\n\nuse App\\Tests\\WebTestCase;\nuse PHPUnit\\Framework\\Attributes\\Group;\n\nclass UserBlockControllerTest extends WebTestCase\n{\n    public function testUserCanBlockAndUnblock(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JaneDoe'));\n\n        $entry = $this->getEntryByTitle('test entry 1', 'https://kbin.pub');\n\n        $crawler = $this->client->request('GET', '/m/acme/t/'.$entry->getId().'/test-entry-1');\n\n        // Block\n        $this->client->submit($crawler->filter('#sidebar form[name=user_block] button')->form());\n        $crawler = $this->client->followRedirect();\n\n        $this->assertSelectorExists('#sidebar form[name=user_block] .active');\n\n        // Unblock\n        $this->client->submit($crawler->filter('#sidebar form[name=user_block] button')->form());\n        $this->client->followRedirect();\n\n        $this->assertSelectorNotExists('#sidebar form[name=user_block] .active');\n    }\n\n    public function testXmlUserCanBlock(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JaneDoe'));\n\n        $entry = $this->getEntryByTitle('test entry 1', 'https://kbin.pub');\n\n        $crawler = $this->client->request('GET', '/m/acme/t/'.$entry->getId().'/test-entry-1');\n\n        // Block\n        $this->client->setServerParameter('HTTP_X-Requested-With', 'XMLHttpRequest');\n        $this->client->submit($crawler->filter('#sidebar form[name=user_block] button')->form());\n\n        $this->assertStringContainsString('{\"html\":', $this->client->getResponse()->getContent());\n        $this->assertStringContainsString('active', $this->client->getResponse()->getContent());\n    }\n\n    #[Group(name: 'NonThreadSafe')]\n    public function testXmlUserCanUnblock(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JaneDoe'));\n\n        $entry = $this->getEntryByTitle('test entry 1', 'https://kbin.pub');\n\n        $crawler = $this->client->request('GET', '/m/acme/t/'.$entry->getId().'/test-entry-1');\n\n        // Block\n        $this->client->submit($crawler->filter('#sidebar form[name=user_block] button')->form());\n        $crawler = $this->client->followRedirect();\n\n        // Unblock\n        $this->client->setServerParameter('HTTP_X-Requested-With', 'XMLHttpRequest');\n        $this->client->submit($crawler->filter('#sidebar form[name=user_block] button')->form());\n\n        $this->assertResponseIsSuccessful();\n        $this->assertStringContainsString('{\"html\":', $this->client->getResponse()->getContent());\n        $this->assertStringNotContainsString('active', $this->client->getResponse()->getContent());\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/User/UserFollowControllerTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\User;\n\nuse App\\Tests\\WebTestCase;\nuse PHPUnit\\Framework\\Attributes\\Group;\n\nclass UserFollowControllerTest extends WebTestCase\n{\n    #[Group(name: 'NonThreadSafe')]\n    public function testUserCanFollowAndUnfollow(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JaneDoe'));\n\n        $entry = $this->getEntryByTitle('test entry 1', 'https://kbin.pub');\n\n        $crawler = $this->client->request('GET', '/m/acme/t/'.$entry->getId());\n\n        // Follow\n        $this->client->submit($crawler->filter('#sidebar .entry-info')->selectButton('Follow')->form());\n        $crawler = $this->client->followRedirect();\n\n        $this->assertSelectorExists('#sidebar form[name=user_follow] .active');\n        $this->assertSelectorTextContains('#sidebar .entry-info', 'Unfollow');\n        $this->assertSelectorTextContains('#sidebar .entry-info', '1');\n\n        // Unfollow\n        $this->client->submit($crawler->filter('#sidebar .entry-info')->selectButton('Unfollow')->form());\n        $this->client->followRedirect();\n\n        $this->assertSelectorNotExists('#sidebar form[name=user_follow] .active');\n        $this->assertSelectorTextContains('#sidebar .entry-info', 'Follow');\n        $this->assertSelectorTextContains('#sidebar .entry-info', '0');\n    }\n\n    public function testXmlUserCanFollow(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JaneDoe'));\n\n        $entry = $this->getEntryByTitle('test entry 1', 'https://kbin.pub');\n\n        $crawler = $this->client->request('GET', '/m/acme/t/'.$entry->getId());\n\n        $this->client->setServerParameter('HTTP_X-Requested-With', 'XMLHttpRequest');\n        $this->client->submit($crawler->filter('#sidebar .entry-info')->selectButton('Follow')->form());\n\n        $this->assertStringContainsString('{\"html\":', $this->client->getResponse()->getContent());\n        $this->assertStringContainsString('Unfollow', $this->client->getResponse()->getContent());\n    }\n\n    #[Group(name: 'NonThreadSafe')]\n    public function testXmlUserCanUnfollow(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('JaneDoe'));\n\n        $entry = $this->getEntryByTitle('test entry 1', 'https://kbin.pub');\n\n        $crawler = $this->client->request('GET', '/m/acme/t/'.$entry->getId());\n\n        // Follow\n        $this->client->submit($crawler->filter('#sidebar .entry-info')->selectButton('Follow')->form());\n        $crawler = $this->client->followRedirect();\n\n        // Unfollow\n        $this->client->setServerParameter('HTTP_X-Requested-With', 'XMLHttpRequest');\n        $this->client->submit($crawler->filter('#sidebar .entry-info')->selectButton('Unfollow')->form());\n\n        $this->assertStringContainsString('{\"html\":', $this->client->getResponse()->getContent());\n        $this->assertStringContainsString('Follow', $this->client->getResponse()->getContent());\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/User/UserFrontControllerTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller\\User;\n\nuse App\\Tests\\WebTestCase;\nuse Symfony\\Bundle\\FrameworkBundle\\KernelBrowser;\n\nclass UserFrontControllerTest extends WebTestCase\n{\n    public function testOverview(): void\n    {\n        $this->client = $this->prepareEntries();\n\n        $crawler = $this->client->request('GET', '/u/JohnDoe');\n\n        $this->assertSelectorTextContains('.options.options .active', 'Overview');\n        $this->assertEquals(2, $crawler->filter('#main .entry')->count());\n        $this->assertEquals(2, $crawler->filter('#main .entry-comment')->count());\n        $this->assertEquals(2, $crawler->filter('#main .post')->count());\n        $this->assertEquals(2, $crawler->filter('#main .post-comment')->count());\n    }\n\n    public function testThreadsPage(): void\n    {\n        $this->client = $this->prepareEntries();\n\n        $crawler = $this->client->request('GET', '/u/JohnDoe');\n\n        self::assertResponseIsSuccessful();\n\n        $crawler = $this->client->click($crawler->filter('#main .options')->selectLink('Threads')->link());\n\n        $this->assertSelectorTextContains('.options.options--top .active', 'Threads (1)');\n        $this->assertEquals(1, $crawler->filter('#main .entry')->count());\n    }\n\n    public function testCommentsPage(): void\n    {\n        $this->client = $this->prepareEntries();\n\n        $crawler = $this->client->request('GET', '/u/JohnDoe');\n        self::assertResponseIsSuccessful();\n        $this->client->click($crawler->filter('#main .options')->selectLink('Comments')->link());\n\n        $this->assertSelectorTextContains('.options.options--top .active', 'Comments (2)');\n        $this->assertEquals(2, $crawler->filter('#main .entry-comment')->count());\n    }\n\n    public function testPostsPage(): void\n    {\n        $this->client = $this->prepareEntries();\n\n        $crawler = $this->client->request('GET', '/u/JohnDoe');\n        self::assertResponseIsSuccessful();\n        $crawler = $this->client->click($crawler->filter('#main .options')->selectLink('Posts')->link());\n\n        $this->assertSelectorTextContains('.options.options--top .active', 'Posts (1)');\n        $this->assertEquals(1, $crawler->filter('#main .post')->count());\n    }\n\n    public function testRepliesPage(): void\n    {\n        $this->client = $this->prepareEntries();\n\n        $crawler = $this->client->request('GET', '/u/JohnDoe');\n        self::assertResponseIsSuccessful();\n        $crawler = $this->client->click($crawler->filter('#main .options')->selectLink('Replies')->link());\n\n        $this->assertSelectorTextContains('.options.options--top .active', 'Replies (2)');\n        $this->assertEquals(2, $crawler->filter('#main .post-comment')->count());\n        $this->assertEquals(2, $crawler->filter('#main .post')->count());\n    }\n\n    public function createSubscriptionsPage()\n    {\n        $user = $this->getUserByUsername('JohnDoe');\n        $this->getMagazineByName('kbin');\n        $this->getMagazineByName('mag', $this->getUserByUsername('JaneDoe'));\n\n        $manager = $this->magazineManager;\n        $manager->subscribe($this->getMagazineByName('mag'), $user);\n\n        $this->client->loginUser($user);\n\n        $crawler = $this->client->request('GET', '/u/JohnDoe');\n        self::assertResponseIsSuccessful();\n        $crawler = $this->client->click($crawler->filter('#main .options')->selectLink('subscriptions')->link());\n\n        $this->assertSelectorTextContains('.options.options--top .active', 'subscriptions (2)');\n        $this->assertEquals(2, $crawler->filter('#main .magazines ul li')->count());\n    }\n\n    public function testFollowersPage(): void\n    {\n        $user1 = $this->getUserByUsername('JohnDoe');\n        $user2 = $this->getUserByUsername('JaneDoe');\n\n        $manager = $this->userManager;\n        $manager->follow($user2, $user1);\n\n        $this->client->loginUser($user1);\n\n        $crawler = $this->client->request('GET', '/u/JohnDoe');\n        self::assertResponseIsSuccessful();\n        $crawler = $this->client->click($crawler->filter('#main .options')->selectLink('Followers')->link());\n\n        $this->assertSelectorTextContains('.options.options--top .active', 'Followers (1)');\n        $this->assertEquals(1, $crawler->filter('#main .users ul li')->count());\n    }\n\n    public function testFollowingPage(): void\n    {\n        $user1 = $this->getUserByUsername('JohnDoe');\n        $user2 = $this->getUserByUsername('JaneDoe');\n\n        $manager = $this->userManager;\n        $manager->follow($user1, $user2);\n\n        $this->client->loginUser($user1);\n\n        $crawler = $this->client->request('GET', '/u/JohnDoe');\n        self::assertResponseIsSuccessful();\n        $crawler = $this->client->click($crawler->filter('#main .options')->selectLink('Following')->link());\n\n        $this->assertSelectorTextContains('.options.options--top .active', 'Following (1)');\n        $this->assertEquals(1, $crawler->filter('#main .users ul li')->count());\n    }\n\n    public function testNewIndicator(): void\n    {\n        $user = $this->getUserByUsername('JohnDoe');\n\n        $this->client->request('GET', '/u/JohnDoe');\n        $this->assertSelectorExists('#content.user-main h1 i.fa-solid.fa-leaf.new-user-icon');\n\n        $user->createdAt = new \\DateTimeImmutable('now - 31days');\n        $this->entityManager->flush();\n        $this->client->request('GET', '/u/JohnDoe');\n        $this->assertSelectorNotExists('#content.user-main h1 i.fa-solid.fa-leaf.new-user-icon');\n    }\n\n    public function testCakeDayIndicator(): void\n    {\n        $user = $this->getUserByUsername('JohnDoe');\n\n        $this->client->request('GET', '/u/JohnDoe');\n        $this->assertSelectorExists('#content.user-main h1 i.fa-solid.fa-cake-candles');\n\n        $user->createdAt = new \\DateTimeImmutable('now - 1days');\n        $this->entityManager->flush();\n        $this->client->request('GET', '/u/JohnDoe');\n        $this->assertSelectorNotExists('#content.user-main h1 i.fa-solid.fa-cake-candles');\n    }\n\n    private function prepareEntries(): KernelBrowser\n    {\n        $entry1 = $this->getEntryByTitle(\n            'test entry 1',\n            'https://kbin.pub',\n            null,\n            $this->getMagazineByName('mag', $this->getUserByUsername('JaneDoe')),\n            $this->getUserByUsername('JaneDoe')\n        );\n        $entry2 = $this->getEntryByTitle(\n            'test entry 2',\n            'https://kbin.pub',\n            null,\n            $this->getMagazineByName('mag', $this->getUserByUsername('JaneDoe')),\n            $this->getUserByUsername('JaneDoe')\n        );\n        $entry3 = $this->getEntryByTitle('test entry 3', 'https://kbin.pub');\n\n        $this->createEntryComment('test entry comment 1', $entry1);\n        $this->createEntryComment('test entry comment 2', $entry2, $this->getUserByUsername('JaneDoe'));\n        $this->createEntryComment('test entry comment 3', $entry3);\n\n        $post1 = $this->createPost(\n            'test post 1',\n            $this->getMagazineByName('mag', $this->getUserByUsername('JaneDoe')),\n            $this->getUserByUsername('JaneDoe')\n        );\n        $post2 = $this->createPost(\n            'test post 2',\n            $this->getMagazineByName('mag', $this->getUserByUsername('JaneDoe')),\n            $this->getUserByUsername('JaneDoe')\n        );\n        $post3 = $this->createPost('test post 3');\n\n        $this->createPostComment('test post comment 1', $post1);\n        $this->createPostComment('test post comment 2', $post2, $this->getUserByUsername('JaneDoe'));\n        $this->createPostComment('test post comment 3', $post3);\n\n        return $this->client;\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/VoteControllerTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller;\n\nuse App\\Tests\\WebTestCase;\n\nclass VoteControllerTest extends WebTestCase\n{\n    public function testUserCanVoteOnEntry(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('Actor'));\n\n        $entry = $this->getEntryByTitle('test entry 1', 'https://kbin.pub');\n\n        $u1 = $this->getUserByUsername('JohnDoe');\n        $u2 = $this->getUserByUsername('JaneDoe');\n\n        $this->createVote(1, $entry, $u1);\n        $this->createVote(1, $entry, $u2);\n\n        $this->client->request('GET', '/');\n        $crawler = $this->client->request('GET', '/m/acme/t/'.$entry->getId().'/-/comments');\n\n        $this->assertUpDownVoteActions($crawler);\n    }\n\n    public function testXmlUserCanVoteOnEntry(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('Actor'));\n\n        $this->getEntryByTitle('test entry 1', 'https://kbin.pub');\n\n        $crawler = $this->client->request('GET', '/');\n        $this->client->setServerParameter('HTTP_X-Requested-With', 'XMLHttpRequest');\n        $this->client->click($crawler->filter('.entry .vote__up')->form());\n\n        $this->assertStringContainsString('{\"html\":', $this->client->getResponse()->getContent());\n    }\n\n    public function testUserCanVoteOnEntryComment(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('Actor'));\n\n        $comment = $this->createEntryComment('test entry comment 1');\n\n        $u1 = $this->getUserByUsername('JohnDoe');\n        $u2 = $this->getUserByUsername('JaneDoe');\n\n        $this->createVote(1, $comment, $u1);\n        $this->createVote(1, $comment, $u2);\n\n        $this->client->request('GET', '/');\n        $crawler = $this->client->request('GET', '/m/acme/t/'.$comment->entry->getId().'/-/comments');\n\n        $this->assertUpDownVoteActions($crawler, '.comment');\n    }\n\n    public function testXmlUserCanVoteOnEntryComment(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('Actor'));\n\n        $comment = $this->createEntryComment('test entry comment 1');\n\n        $crawler = $this->client->request('GET', '/m/acme/t/'.$comment->entry->getId().'/-/comments');\n        $this->client->setServerParameter('HTTP_X-Requested-With', 'XMLHttpRequest');\n        $this->client->click($crawler->filter('.entry-comment .vote__up')->form());\n\n        $this->assertStringContainsString('{\"html\":', $this->client->getResponse()->getContent());\n    }\n\n    private function assertUpDownVoteActions($crawler, string $selector = ''): void\n    {\n        $this->assertSelectorTextContains($selector.' .vote__up', '2');\n        $this->assertSelectorTextContains($selector.' .vote__down', '0');\n\n        $this->client->click($crawler->filter($selector.' .vote__up')->form());\n        $crawler = $this->client->followRedirect();\n\n        $this->assertSelectorTextContains($selector.' .vote__up', '3');\n        $this->assertSelectorTextContains($selector.' .vote__down', '0');\n\n        $this->client->click($crawler->filter($selector.' .vote__down')->form());\n        $crawler = $this->client->followRedirect();\n\n        $this->assertSelectorTextContains($selector.' .vote__up', '2');\n        $this->assertSelectorTextContains($selector.' .vote__down', '1');\n\n        $this->client->click($crawler->filter($selector.' .vote__down')->form());\n        $crawler = $this->client->followRedirect();\n\n        $this->assertSelectorTextContains($selector.' .vote__up', '2');\n        $this->assertSelectorTextContains($selector.' .vote__down', '0');\n\n        $this->client->submit($crawler->filter($selector.' .vote__up')->form());\n        $crawler = $this->client->followRedirect();\n\n        $this->assertSelectorTextContains($selector.' .vote__up', '3');\n        $this->assertSelectorTextContains($selector.' .vote__down', '0');\n\n        $this->client->submit($crawler->filter($selector.' .vote__up')->form());\n        $this->client->followRedirect();\n\n        $this->assertSelectorTextContains($selector.' .vote__up', '2');\n        $this->assertSelectorTextContains($selector.' .vote__down', '0');\n    }\n\n    public function testUserCanVoteOnPost(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('Actor'));\n\n        $post = $this->createPost('test post 1');\n\n        $u1 = $this->getUserByUsername('JohnDoe');\n        $u2 = $this->getUserByUsername('JaneDoe');\n\n        $this->createVote(1, $post, $u1);\n        $this->createVote(1, $post, $u2);\n\n        $crawler = $this->client->request('GET', '/m/acme/p/'.$post->getId().'/-');\n        self::assertResponseIsSuccessful();\n\n        $this->assertUpVoteActions($crawler);\n    }\n\n    public function testXmlUserCanVoteOnPost(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('Actor'));\n\n        $this->createPost('test post 1');\n\n        $crawler = $this->client->request('GET', '/microblog');\n        $this->client->setServerParameter('HTTP_X-Requested-With', 'XMLHttpRequest');\n        $this->client->click($crawler->filter('.post .vote__up')->form());\n\n        $this->assertStringContainsString('{\"html\":', $this->client->getResponse()->getContent());\n    }\n\n    public function testUserCanVoteOnPostComment(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('Actor'));\n\n        $comment = $this->createPostComment('test post comment 1');\n\n        $u1 = $this->getUserByUsername('JohnDoe');\n        $u2 = $this->getUserByUsername('JaneDoe');\n\n        $this->createVote(1, $comment, $u1);\n        $this->createVote(1, $comment, $u2);\n\n        $crawler = $this->client->request('GET', '/m/acme/p/'.$comment->post->getId());\n\n        $this->assertUpVoteActions($crawler, '.comment');\n    }\n\n    public function testXmlUserCanVoteOnPostComment(): void\n    {\n        $this->client->loginUser($this->getUserByUsername('Actor'));\n\n        $comment = $this->createPostComment('test post comment 1');\n\n        $crawler = $this->client->request('GET', '/m/acme/p/'.$comment->post->getId().'/-');\n        $this->client->setServerParameter('HTTP_X-Requested-With', 'XMLHttpRequest');\n        $this->client->click($crawler->filter('.post-comment .vote__up')->form());\n\n        $this->assertStringContainsString('{\"html\":', $this->client->getResponse()->getContent());\n    }\n\n    private function assertUpVoteActions($crawler, string $selector = ''): void\n    {\n        $this->assertSelectorTextContains($selector.' .vote__up', '2');\n\n        $this->client->submit($crawler->filter($selector.' .vote__up')->form());\n        $crawler = $this->client->followRedirect();\n\n        $this->assertSelectorTextContains($selector.' .vote__up', '3');\n\n        $this->client->submit($crawler->filter($selector.' .vote__up')->form());\n        $this->client->followRedirect();\n\n        $this->assertSelectorTextContains($selector.' .vote__up', '2');\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Controller/WebfingerControllerTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Controller;\n\nuse App\\Tests\\WebTestCase;\n\nclass WebfingerControllerTest extends WebTestCase\n{\n    public function testInstanceActor(): void\n    {\n        $domain = $this->settingsManager->get('KBIN_DOMAIN');\n        $resource = \"acct:$domain@$domain\";\n        $resourceUrlEncoded = urlencode($resource);\n        $this->client->request('GET', \"https://$domain/.well-known/webfinger?resource=$resourceUrlEncoded\");\n        self::assertResponseIsSuccessful();\n        $jsonContent = self::getJsonResponse($this->client);\n        self::assertResponseIsSuccessful();\n\n        self::assertArrayHasKey('subject', $jsonContent);\n        self::assertEquals($resource, $jsonContent['subject']);\n        self::assertArrayHasKey('links', $jsonContent);\n        self::assertNotEmpty($jsonContent['links']);\n        $instanceActor = $jsonContent['links'][0];\n        self::assertArrayKeysMatch(['rel', 'href', 'type'], $instanceActor);\n\n        $this->client->request('GET', $instanceActor['href']);\n\n        self::assertResponseIsSuccessful();\n        $jsonContent = self::getJsonResponse($this->client);\n        self::assertNotNull($jsonContent);\n        $keys = ['id', 'type', 'preferredUsername', 'publicKey', 'name', 'manuallyApprovesFollowers'];\n        foreach ($keys as $key) {\n            self::assertArrayHasKey($key, $jsonContent);\n        }\n        self::assertEquals($instanceActor['href'], $jsonContent['id']);\n        self::assertEquals('Application', $jsonContent['type']);\n        self::assertEquals($domain, $jsonContent['preferredUsername']);\n        self::assertTrue($jsonContent['manuallyApprovesFollowers']);\n        self::assertNotEmpty($jsonContent['publicKey']);\n    }\n}\n"
  },
  {
    "path": "tests/Functional/Misc/Entry/CrosspostDetectionTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Functional\\Misc\\Entry;\n\nuse App\\Entity\\Entry;\nuse App\\Tests\\WebTestCase;\n\nclass CrosspostDetectionTest extends WebTestCase\n{\n    public function testCrosspostsNoCrosspost(): void\n    {\n        $user = $this->getUserByUsername('JohnDoe');\n        $magazine1 = $this->getMagazineByName('acme1');\n        $entry1 = $this->createEntry('article 001', $magazine1, $user);\n        sleep(1);\n        $magazine2 = $this->getMagazineByName('acme2');\n        $entry2 = $this->createEntry('article 002', $magazine2, $user);\n        $this->entityManager->persist($entry1);\n        $this->entityManager->persist($entry2);\n        $this->entityManager->flush();\n\n        $this->client->request('GET', '/api/entries?sort=oldest');\n        self::assertResponseIsSuccessful();\n\n        $jsonData = self::getJsonResponse($this->client);\n\n        self::assertSame(2, $jsonData['pagination']['count']);\n        self::assertNull($jsonData['items'][0]['crosspostedEntries']);\n        self::assertNull($jsonData['items'][1]['crosspostedEntries']);\n    }\n\n    public function testCrosspostsByTitle(): void\n    {\n        $user = $this->getUserByUsername('JohnDoe');\n        $magazine1 = $this->getMagazineByName('acme1');\n        $entry1 = $this->createEntry('article 001', $magazine1, $user);\n        sleep(1);\n        $magazine2 = $this->getMagazineByName('acme2');\n        $entry2 = $this->createEntry('article 001', $magazine2, $user);\n        sleep(1);\n        $magazine3 = $this->getMagazineByName('acme3');\n        $entry3 = $this->createEntry('article 001', $magazine3, $user);\n        sleep(1);\n        $magazine4 = $this->getMagazineByName('acme4');\n        $entry4 = $this->createEntry('article 002', $magazine4, $user);\n        $this->entityManager->persist($entry1);\n        $this->entityManager->persist($entry2);\n        $this->entityManager->persist($entry3);\n        $this->entityManager->persist($entry4);\n        $this->entityManager->flush();\n\n        $this->checkCrossposts([$entry1, $entry2, $entry3]);\n        $this->checkCrossposts([$entry4]);\n    }\n\n    public function testCrosspostsByUrl(): void\n    {\n        $user = $this->getUserByUsername('JohnDoe');\n        $magazine1 = $this->getMagazineByName('acme1');\n        $entry1 = $this->createEntry('article 001', $magazine1, $user, url: 'https://duckduckgo.com');\n        sleep(1);\n        $magazine2 = $this->getMagazineByName('acme2');\n        $entry2 = $this->createEntry('article 001', $magazine2, $user, url: 'https://duckduckgo.com');\n        sleep(1);\n        $magazine3 = $this->getMagazineByName('acme3');\n        $entry3 = $this->createEntry('article with url', $magazine3, $user, url: 'https://duckduckgo.com');\n        sleep(1);\n        $magazine4 = $this->getMagazineByName('acme4');\n        $entry4 = $this->createEntry('article 001', $magazine4, $user, url: 'https://google.com');\n        $this->entityManager->persist($entry1);\n        $this->entityManager->persist($entry2);\n        $this->entityManager->persist($entry3);\n        $this->entityManager->persist($entry4);\n        $this->entityManager->flush();\n\n        $this->checkCrossposts([$entry1, $entry2, $entry3]);\n        $this->checkCrossposts([$entry4]);\n    }\n\n    public function testCrosspostsByTitleWithImageFilter(): void\n    {\n        $user = $this->getUserByUsername('JohnDoe');\n        $img1 = $this->getKibbyImageDto();\n        $img2 = $this->getKibbyFlippedImageDto();\n\n        $magazine1 = $this->getMagazineByName('acme1');\n        $entry1 = $this->createEntry('article 001', $magazine1, $user, imageDto: $img1);\n        sleep(1);\n        $magazine2 = $this->getMagazineByName('acme2');\n        $entry2 = $this->createEntry('article 001', $magazine2, $user, imageDto: $img1);\n        sleep(1);\n        $magazine3 = $this->getMagazineByName('acme3');\n        $entry3 = $this->createEntry('article 001', $magazine3, $user, imageDto: $img2);\n        sleep(1);\n        $magazine4 = $this->getMagazineByName('acme4');\n        $entry4 = $this->createEntry('article 002', $magazine4, $user);\n        $this->entityManager->persist($entry1);\n        $this->entityManager->persist($entry2);\n        $this->entityManager->persist($entry3);\n        $this->entityManager->persist($entry4);\n        $this->entityManager->flush();\n\n        $this->checkCrossposts([$entry1, $entry2]);\n        $this->checkCrossposts([$entry3]);\n        $this->checkCrossposts([$entry4]);\n    }\n\n    /**\n     * @param Entry[] $expectedEntries\n     */\n    private function checkCrossposts(array $expectedEntries): void\n    {\n        $this->client->request('GET', '/api/entries?sort=oldest');\n        self::assertResponseIsSuccessful();\n\n        foreach ($expectedEntries as $entry) {\n            $this->client->request('GET', '/api/entry/'.$entry->getId());\n            self::assertResponseIsSuccessful();\n            $jsonData = self::getJsonResponse($this->client);\n\n            self::assertIsArray($jsonData['crosspostedEntries']);\n\n            $crossposts = array_filter($jsonData['crosspostedEntries'], function ($actual) use ($expectedEntries, $entry) {\n                $match = array_filter($expectedEntries, function ($expected) use ($actual, $entry) {\n                    return $actual['entryId'] !== $entry->getId()\n                        && $actual['entryId'] === $expected->getId();\n                });\n                $matchCount = \\count($match);\n                if (0 === $matchCount) {\n                    return false;\n                } elseif (1 === $matchCount) {\n                    return true;\n                } else {\n                    self::fail('crosspostedEntries contains duplicates');\n                }\n            });\n            self::assertCount(\\count($expectedEntries) - 1, $crossposts);\n        }\n    }\n}\n"
  },
  {
    "path": "tests/OAuth2FlowTrait.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests;\n\nuse Symfony\\Bundle\\FrameworkBundle\\KernelBrowser;\n\ntrait OAuth2FlowTrait\n{\n    protected const JWT_REGEX = '/[0-9a-zA-Z\\-_]+(\\.[0-9a-zA-Z\\-_]+){2}/';\n    protected const CODE_REGEX = '/[0-9a-f]{104,}/';\n\n    protected static function buildPrivateAuthCodeQuery(string $clientId, string $scopes, string $state, string $redirectUri): string\n    {\n        return strtr(\n            http_build_query(\n                [\n                    'response_type' => 'code',\n                    'client_id' => $clientId,\n                    'redirect_uri' => $redirectUri,\n                    'scope' => $scopes,\n                    'state' => $state,\n                ],\n                encoding_type: PHP_QUERY_RFC3986\n            ),\n            [\n                '%3A' => ':',\n                '%2F' => '/',\n            ]\n        );\n    }\n\n    protected static function runAuthorizationCodeFlowToConsentPage(KernelBrowser $client, string $scopes, string $state, string $clientId = 'testclient', string $redirectToUri = 'https://localhost:3001'): void\n    {\n        $query = self::buildPrivateAuthCodeQuery($clientId, $scopes, $state, $redirectToUri);\n\n        $uri = '/authorize?'.$query;\n\n        $client->request('GET', $uri);\n\n        $redirectUri = '/consent?'.$query;\n\n        self::assertResponseRedirects($redirectUri);\n        $client->followRedirect();\n    }\n\n    protected static function runAuthorizationCodeFlowToRedirectUri(KernelBrowser $client, string $scopes, string $consent, string $state, string $clientId = 'testclient', string $redirectUri = 'https://localhost:3001'): void\n    {\n        $crawler = $client->getCrawler();\n\n        $client->submit(\n            $crawler->selectButton('consent')->form(\n                [\n                    'consent' => $consent,\n                ]\n            )\n        );\n\n        $query = self::buildPrivateAuthCodeQuery($clientId, $scopes, $state, $redirectUri);\n\n        $redirectUri = '/authorize?'.$query;\n\n        self::assertResponseRedirects($redirectUri);\n\n        $client->followRedirect();\n\n        self::assertResponseRedirects();\n    }\n\n    public static function runAuthorizationCodeFlow(KernelBrowser $client, string $consent = 'yes', string $scopes = 'read write', string $state = 'oauth2state', string $clientId = 'testclient', string $redirectUri = 'https://localhost:3001'): void\n    {\n        self::runAuthorizationCodeFlowToConsentPage($client, $scopes, $state, $clientId, $redirectUri);\n        self::runAuthorizationCodeFlowToRedirectUri($client, $scopes, $consent, $state, $clientId, $redirectUri);\n    }\n\n    public static function runAuthorizationCodeTokenFlow(KernelBrowser $client, string $clientId = 'testclient', string $clientSecret = 'testsecret', string $redirectUri = 'https://localhost:3001'): array\n    {\n        $response = $client->getResponse();\n        $parsedUrl = parse_url($response->headers->get('Location'));\n\n        $result = [];\n        parse_str($parsedUrl['query'], $result);\n\n        self::assertArrayHasKey('code', $result);\n        self::assertMatchesRegularExpression(self::CODE_REGEX, $result['code']);\n        self::assertArrayHasKey('state', $result);\n        self::assertEquals('oauth2state', $result['state']);\n\n        $client->request('POST', '/token', [\n            'grant_type' => 'authorization_code',\n            'client_id' => $clientId,\n            'client_secret' => $clientSecret,\n            'code' => $result['code'],\n            'redirect_uri' => $redirectUri,\n        ]);\n\n        $response = $client->getResponse();\n\n        self::assertJson($response->getContent());\n\n        return json_decode($response->getContent(), associative: true);\n    }\n\n    private const VERIFIER_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';\n\n    protected static function getPKCECodes(): array\n    {\n        $toReturn = [];\n        $toReturn['verifier'] = implode(array_map(fn (string $byte) => self::VERIFIER_ALPHABET[\\ord($byte) % \\strlen(self::VERIFIER_ALPHABET)], str_split(random_bytes(64))));\n        $hash = hash('sha256', $toReturn['verifier'], binary: true);\n        $toReturn['challenge'] = rtrim(strtr(base64_encode($hash), '+/', '-_'), '=');\n\n        return $toReturn;\n    }\n\n    protected static function buildPublicAuthCodeQuery(string $clientId, string $challenge, string $challengeMethod, string $scopes, string $state, string $redirectUri): string\n    {\n        return strtr(\n            http_build_query(\n                [\n                    'response_type' => 'code',\n                    'client_id' => $clientId,\n                    'code_challenge' => $challenge,\n                    'code_challenge_method' => $challengeMethod,\n                    'redirect_uri' => $redirectUri,\n                    'scope' => $scopes,\n                    'state' => $state,\n                ],\n                encoding_type: PHP_QUERY_RFC3986\n            ),\n            [\n                '%3A' => ':',\n                '%2F' => '/',\n            ]\n        );\n    }\n\n    protected static function runPublicAuthorizationCodeFlowToConsentPage(KernelBrowser $client, string $scopes, string $state, string $challenge, string $challengeMethod = 'S256', string $clientId = 'testpublicclient', string $redirectUri = 'https://localhost:3001'): void\n    {\n        $query = self::buildPublicAuthCodeQuery($clientId, $challenge, $challengeMethod, $scopes, $state, $redirectUri);\n\n        $uri = '/authorize?'.$query;\n\n        $client->request('GET', $uri);\n\n        $redirectUri = '/consent?'.$query;\n\n        self::assertResponseRedirects($redirectUri);\n        $client->followRedirect();\n    }\n\n    protected static function runPublicAuthorizationCodeFlowToRedirectUri(KernelBrowser $client, string $scopes, string $consent, string $state, string $challenge, string $challengeMethod = 'S256', string $clientId = 'testpublicclient', string $redirectUri = 'https://localhost:3001'): void\n    {\n        $crawler = $client->getCrawler();\n\n        $client->submit(\n            $crawler->selectButton('consent')->form(\n                [\n                    'consent' => $consent,\n                ]\n            )\n        );\n\n        $query = self::buildPublicAuthCodeQuery($clientId, $challenge, $challengeMethod, $scopes, $state, $redirectUri);\n\n        $redirectUri = '/authorize?'.$query;\n\n        self::assertResponseRedirects($redirectUri);\n\n        $client->followRedirect();\n\n        self::assertResponseRedirects();\n    }\n\n    /**\n     * @return array Array with PKCE challenge and verifier codes in the 'challenge' and 'verifier' keys. Verifier needs to be passed when retrieving token\n     */\n    public static function runPublicAuthorizationCodeFlow(KernelBrowser $client, string $consent = 'yes', string $scopes = 'read write', string $state = 'oauth2state', string $clientId = 'testpublicclient', string $redirectUri = 'https://localhost:3001'): array\n    {\n        $codes = self::getPKCECodes();\n        self::runPublicAuthorizationCodeFlowToConsentPage($client, $scopes, $state, $codes['challenge'], clientId: $clientId, redirectUri: $redirectUri);\n        self::runPublicAuthorizationCodeFlowToRedirectUri($client, $scopes, $consent, $state, $codes['challenge'], clientId: $clientId, redirectUri: $redirectUri);\n\n        return $codes;\n    }\n\n    public static function getAuthorizationCodeTokenResponse(KernelBrowser $client, string $clientId = 'testclient', string $clientSecret = 'testsecret', string $redirectUri = 'https://localhost:3001', string $scopes = 'read write'): array\n    {\n        self::runAuthorizationCodeFlow($client, 'yes', $scopes, clientId: $clientId, redirectUri: $redirectUri);\n\n        return self::runAuthorizationCodeTokenFlow($client, $clientId, $clientSecret, $redirectUri);\n    }\n\n    public static function getRefreshTokenResponse(KernelBrowser $client, string $refreshToken, string $clientId = 'testclient', string $clientSecret = 'testsecret', string $redirectUri = 'https://localhost:3001', string $scopes = 'read write'): array\n    {\n        $client->request('POST', '/token', [\n            'grant_type' => 'refresh_token',\n            'client_id' => $clientId,\n            'client_secret' => $clientSecret,\n            'refresh_token' => $refreshToken,\n        ]);\n\n        $response = $client->getResponse();\n\n        self::assertJson($response->getContent());\n\n        return json_decode($response->getContent(), associative: true);\n    }\n\n    public static function runPublicAuthorizationCodeTokenFetch(KernelBrowser $client, string $verifier, string $clientId = 'testpublicclient', string $redirectUri = 'https://localhost:3001'): void\n    {\n        $response = $client->getResponse();\n        $parsedUrl = parse_url($response->headers->get('Location'));\n\n        $result = [];\n        parse_str($parsedUrl['query'], $result);\n\n        self::assertArrayHasKey('code', $result);\n        self::assertMatchesRegularExpression(self::CODE_REGEX, $result['code']);\n        self::assertArrayHasKey('state', $result);\n        self::assertEquals('oauth2state', $result['state']);\n\n        $client->request('POST', '/token', [\n            'grant_type' => 'authorization_code',\n            'client_id' => $clientId,\n            'code_verifier' => $verifier,\n            'code' => $result['code'],\n            'redirect_uri' => $redirectUri,\n        ]);\n    }\n\n    public static function getPublicAuthorizationCodeTokenResponse(KernelBrowser $client, string $clientId = 'testpublicclient', string $redirectUri = 'https://localhost:3001', string $scopes = 'read write'): array\n    {\n        $pkceCodes = self::runPublicAuthorizationCodeFlow($client, 'yes', $scopes, clientId: $clientId);\n\n        self::runPublicAuthorizationCodeTokenFetch($client, $pkceCodes['verifier'], $clientId, $redirectUri);\n\n        $response = $client->getResponse();\n\n        self::assertJson($response->getContent());\n\n        return json_decode($response->getContent(), associative: true);\n    }\n}\n"
  },
  {
    "path": "tests/Service/TestingApHttpClient.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Service;\n\nuse App\\Entity\\Magazine;\nuse App\\Entity\\User;\nuse App\\Service\\ActivityPub\\ApHttpClientInterface;\n\nclass TestingApHttpClient implements ApHttpClientInterface\n{\n    /**\n     * @phpstan-var array<string, array> $activityObjects\n     */\n    public array $activityObjects = [];\n\n    /**\n     * @phpstan-var array<string, array> $collectionObjects\n     */\n    public array $collectionObjects = [];\n\n    /**\n     * @phpstan-var array<string, array> $webfingerObjects\n     */\n    public array $webfingerObjects = [];\n\n    /**\n     * @phpstan-var array<string, array> $actorObjects\n     */\n    public array $actorObjects = [];\n\n    /**\n     * @var array<int, array{inboxUrl: string, payload: array, actor: User|Magazine}>\n     */\n    private array $postedObjects = [];\n\n    public function getActivityObject(string $url, bool $decoded = true): array|string|null\n    {\n        if (\\array_key_exists($url, $this->activityObjects)) {\n            return $this->activityObjects[$url];\n        }\n\n        return null;\n    }\n\n    public function getCollectionObject(string $apAddress): ?array\n    {\n        if (\\array_key_exists($apAddress, $this->collectionObjects)) {\n            return $this->collectionObjects[$apAddress];\n        }\n\n        return null;\n    }\n\n    public function getActorObject(string $apProfileId): ?array\n    {\n        if (\\array_key_exists($apProfileId, $this->actorObjects)) {\n            return $this->actorObjects[$apProfileId];\n        }\n\n        return null;\n    }\n\n    public function getWebfingerObject(string $url): ?array\n    {\n        if (\\array_key_exists($url, $this->webfingerObjects)) {\n            return $this->webfingerObjects[$url];\n        }\n\n        return null;\n    }\n\n    public function fetchInstanceNodeInfoEndpoints(string $domain, bool $decoded = true): array|string|null\n    {\n        return null;\n    }\n\n    public function fetchInstanceNodeInfo(string $url, bool $decoded = true): array|string|null\n    {\n        return null;\n    }\n\n    public function post(string $url, Magazine|User $actor, ?array $body = null, bool $useOldPrivateKey = false): void\n    {\n        $this->postedObjects[] = [\n            'inboxUrl' => $url,\n            'actor' => $actor,\n            'payload' => $body,\n        ];\n    }\n\n    /**\n     * @return array<int, array{inboxUrl: string, payload: array, actor: User|Magazine}>\n     */\n    public function getPostedObjects(): array\n    {\n        return $this->postedObjects;\n    }\n\n    public function getActivityObjectCacheKey(string $url): string\n    {\n        return 'SOME_TESTING_CACHE_KEY';\n    }\n\n    public function getInboxUrl(string $apProfileId): string\n    {\n        $actor = $this->getActorObject($apProfileId);\n        if (!empty($actor)) {\n            return $actor['endpoints']['sharedInbox'] ?? $actor['inbox'];\n        } else {\n            throw new \\LogicException(\"Unable to find AP actor (user or magazine) with URL: $apProfileId\");\n        }\n    }\n\n    public function invalidateActorObjectCache(string $apProfileId): void\n    {\n    }\n\n    public function invalidateCollectionObjectCache(string $apAddress): void\n    {\n    }\n\n    public function getInstancePublicKey(): string\n    {\n        return 'TESTING PUBLIC KEY';\n    }\n}\n"
  },
  {
    "path": "tests/Service/TestingImageManager.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Service;\n\nuse App\\Entity\\Image;\nuse App\\Repository\\ImageRepository;\nuse App\\Service\\ImageManager;\nuse App\\Service\\ImageManagerInterface;\nuse App\\Service\\SettingsManager;\nuse App\\Twig\\Runtime\\FormattingExtensionRuntime;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse League\\Flysystem\\FilesystemOperator;\nuse Liip\\ImagineBundle\\Imagine\\Cache\\CacheManager;\nuse Psr\\Log\\LoggerInterface;\nuse Symfony\\Component\\DependencyInjection\\Attribute\\When;\nuse Symfony\\Component\\Mime\\MimeTypesInterface;\nuse Symfony\\Component\\Validator\\Validator\\ValidatorInterface;\nuse Symfony\\Contracts\\HttpClient\\HttpClientInterface;\n\n#[When(env: 'test')]\nclass TestingImageManager implements ImageManagerInterface\n{\n    private ImageManager $innerImageManager;\n    private string $kibbyPath;\n\n    public function __construct(\n        string $storageUrl,\n        FilesystemOperator $publicUploadsFilesystem,\n        HttpClientInterface $httpClient,\n        MimeTypesInterface $mimeTypeGuesser,\n        ValidatorInterface $validator,\n        LoggerInterface $logger,\n        SettingsManager $settings,\n        FormattingExtensionRuntime $formattingExtensionRuntime,\n        float $imageCompressionQuality,\n        CacheManager $imagineCacheManager,\n        EntityManagerInterface $entityManager,\n    ) {\n        $this->innerImageManager = new ImageManager($storageUrl, $publicUploadsFilesystem, $httpClient, $mimeTypeGuesser, $validator, $logger, $settings, $formattingExtensionRuntime, $imageCompressionQuality, $imagineCacheManager, $entityManager);\n    }\n\n    public function setKibbyPath(string $kibbyPath): void\n    {\n        $this->kibbyPath = $kibbyPath;\n    }\n\n    public function store(string $source, string $filePath): bool\n    {\n        return $this->innerImageManager->store($source, $filePath);\n    }\n\n    public function download(string $url): ?string\n    {\n        // always return a copy of the kibby image path\n        if (!file_exists(\\dirname($this->kibbyPath).'/copy')) {\n            mkdir(\\dirname($this->kibbyPath).'/copy');\n        }\n        $tmpPath = \\dirname($this->kibbyPath).'/copy/'.bin2hex(random_bytes(32)).'.png';\n        $srcPath = \\dirname($this->kibbyPath).'/'.basename($this->kibbyPath, '.png').'.png';\n        if (!file_exists($srcPath)) {\n            throw new \\Exception('For some reason the kibby image got deleted');\n        }\n        copy($srcPath, $tmpPath);\n\n        return $tmpPath;\n    }\n\n    public function getFilePathAndName(string $file): array\n    {\n        return $this->innerImageManager->getFilePathAndName($file);\n    }\n\n    public function getFilePath(string $file): string\n    {\n        return $this->innerImageManager->getFilePath($file);\n    }\n\n    public function getFileName(string $file): string\n    {\n        return $this->innerImageManager->getFileName($file);\n    }\n\n    public function remove(string $path): void\n    {\n        $this->innerImageManager->remove($path);\n    }\n\n    public function getPath(Image $image): string\n    {\n        return $this->innerImageManager->getPath($image);\n    }\n\n    public function getUrl(?Image $image): ?string\n    {\n        return $this->innerImageManager->getUrl($image);\n    }\n\n    public function getMimetype(Image $image): string\n    {\n        return $this->innerImageManager->getMimetype($image);\n    }\n\n    public function deleteOrphanedFiles(ImageRepository $repository, bool $dryRun, array $ignoredPaths): iterable\n    {\n        foreach ($this->innerImageManager->deleteOrphanedFiles($repository, $dryRun, $ignoredPaths) as $deletedPath) {\n            yield $deletedPath;\n        }\n    }\n\n    public function compressUntilSize(string $filePath, string $extension, int $maxBytes): bool\n    {\n        return $this->innerImageManager->compressUntilSize($filePath, $extension, $maxBytes);\n    }\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/ActorHandleTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Unit\\ActivityPub;\n\nuse App\\ActivityPub\\ActorHandle;\nuse PHPUnit\\Framework\\Attributes\\DataProvider;\nuse PHPUnit\\Framework\\TestCase;\n\nclass ActorHandleTest extends TestCase\n{\n    #[DataProvider('handleProvider')]\n    public function testHandleIsRecognized(string $input, array $output): void\n    {\n        $this->assertNotNull(ActorHandle::parse($input));\n    }\n\n    #[DataProvider('handleProvider')]\n    public function testHandleIsParsedProperly(string $input, array $output): void\n    {\n        $handle = ActorHandle::parse($input);\n        $this->assertEquals($handle->prefix, $output['prefix']);\n        $this->assertEquals($handle->name, $output['name']);\n        $this->assertEquals($handle->host, $output['host']);\n        $this->assertEquals($handle->port, $output['port']);\n    }\n\n    public static function handleProvider(): array\n    {\n        $handleSamples = [\n            'user@mbin.instance' => [\n                'prefix' => null,\n                'name' => 'user',\n                'host' => 'mbin.instance',\n                'port' => null,\n            ],\n            '@someone-512@mbin.instance' => [\n                'prefix' => '@',\n                'name' => 'someone-512',\n                'host' => 'mbin.instance',\n                'port' => null,\n            ],\n            '!engineering@ds9.space' => [\n                'prefix' => '!',\n                'name' => 'engineering',\n                'host' => 'ds9.space',\n                'port' => null,\n            ],\n            '@leon@pink.brainrot.internal:11037' => [\n                'prefix' => '@',\n                'name' => 'leon',\n                'host' => 'pink.brainrot.internal',\n                'port' => 11037,\n            ],\n        ];\n\n        $inputs = array_keys($handleSamples);\n        $outputs = array_values($handleSamples);\n\n        return array_combine(\n            $inputs,\n            array_map(fn ($input, $output) => [$input, $output], $inputs, $outputs)\n        );\n    }\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/CollectionExtractionTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Unit\\ActivityPub;\n\nuse App\\Tests\\WebTestCase;\n\nclass CollectionExtractionTest extends WebTestCase\n{\n    private string $collectionUrl = 'https://some.server/some/collection';\n    private array $collection;\n    private string $incompleteCollectionUrl = 'https://some.server/some/collection2';\n    private array $incompleteCollection;\n\n    public function setUp(): void\n    {\n        parent::setUp();\n        $this->collection = [\n            'id' => $this->collectionUrl,\n            'type' => 'Collection',\n            'totalItems' => 3,\n        ];\n        $this->testingApHttpClient->collectionObjects[$this->collectionUrl] = $this->collection;\n        $this->incompleteCollection = [\n            'id' => $this->incompleteCollectionUrl,\n            'type' => 'Collection',\n        ];\n        $this->testingApHttpClient->collectionObjects[$this->incompleteCollectionUrl] = $this->incompleteCollection;\n    }\n\n    public function testCollectionId(): void\n    {\n        self::assertEquals(3, $this->activityPubManager->extractTotalAmountFromCollection($this->collection));\n    }\n\n    public function testCollectionArray(): void\n    {\n        self::assertEquals(3, $this->activityPubManager->extractTotalAmountFromCollection($this->collectionUrl));\n    }\n\n    public function testIncompleteCollectionId(): void\n    {\n        self::assertNull($this->activityPubManager->extractTotalAmountFromCollection($this->incompleteCollectionUrl));\n    }\n\n    public function testIncompleteCollectionArray(): void\n    {\n        self::assertNull($this->activityPubManager->extractTotalAmountFromCollection($this->incompleteCollection));\n    }\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/AddHandlerTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Unit\\ActivityPub\\Outbox;\n\nuse App\\Tests\\ActivityPubJsonDriver;\nuse App\\Tests\\ActivityPubTestCase;\nuse App\\Tests\\Unit\\ActivityPub\\Traits\\AddRemoveActivityGeneratorTrait;\n\nclass AddHandlerTest extends ActivityPubTestCase\n{\n    use AddRemoveActivityGeneratorTrait;\n\n    public function testAddModerator(): void\n    {\n        $json = $this->activityJsonBuilder->buildActivityJson($this->getAddModeratorActivity());\n\n        $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver());\n    }\n\n    public function testRemoveModerator(): void\n    {\n        $json = $this->activityJsonBuilder->buildActivityJson($this->getRemoveModeratorActivity());\n\n        $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver());\n    }\n\n    public function testAddPinnedPost(): void\n    {\n        $json = $this->activityJsonBuilder->buildActivityJson($this->getAddPinnedPostActivity());\n\n        $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver());\n    }\n\n    public function testRemovePinnedPost(): void\n    {\n        $json = $this->activityJsonBuilder->buildActivityJson($this->getRemovePinnedPostActivity());\n\n        $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver());\n    }\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/AnnounceTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Unit\\ActivityPub\\Outbox;\n\nuse App\\Tests\\ActivityPubJsonDriver;\nuse App\\Tests\\ActivityPubTestCase;\nuse App\\Tests\\Unit\\ActivityPub\\Traits\\AddRemoveActivityGeneratorTrait;\nuse App\\Tests\\Unit\\ActivityPub\\Traits\\AnnounceActivityGeneratorTrait;\nuse App\\Tests\\Unit\\ActivityPub\\Traits\\BlockActivityGeneratorTrait;\nuse App\\Tests\\Unit\\ActivityPub\\Traits\\CreateActivityGeneratorTrait;\nuse App\\Tests\\Unit\\ActivityPub\\Traits\\DeleteActivityGeneratorTrait;\nuse App\\Tests\\Unit\\ActivityPub\\Traits\\FollowActivityGeneratorTrait;\nuse App\\Tests\\Unit\\ActivityPub\\Traits\\LikeActivityGeneratorTrait;\nuse App\\Tests\\Unit\\ActivityPub\\Traits\\UndoActivityGeneratorTrait;\nuse App\\Tests\\Unit\\ActivityPub\\Traits\\UpdateActivityGeneratorTrait;\n\nclass AnnounceTest extends ActivityPubTestCase\n{\n    use AddRemoveActivityGeneratorTrait;\n    use AnnounceActivityGeneratorTrait;\n    use LikeActivityGeneratorTrait;\n    use FollowActivityGeneratorTrait;\n    use CreateActivityGeneratorTrait;\n    use DeleteActivityGeneratorTrait;\n    use UndoActivityGeneratorTrait;\n    use UpdateActivityGeneratorTrait;\n    use BlockActivityGeneratorTrait;\n\n    public function testAnnounceAddModerator(): void\n    {\n        $json = $this->activityJsonBuilder->buildActivityJson($this->getAnnounceAddModeratorActivity());\n\n        $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver());\n    }\n\n    public function testAnnounceRemoveModerator(): void\n    {\n        $json = $this->activityJsonBuilder->buildActivityJson($this->getAnnounceRemoveModeratorActivity());\n\n        $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver());\n    }\n\n    public function testAnnounceAddPinnedPost(): void\n    {\n        $json = $this->activityJsonBuilder->buildActivityJson($this->getAnnounceAddPinnedPostActivity());\n\n        $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver());\n    }\n\n    public function testAnnounceRemovePinnedPost(): void\n    {\n        $json = $this->activityJsonBuilder->buildActivityJson($this->getAnnounceRemovePinnedPostActivity());\n\n        $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver());\n    }\n\n    public function testAnnounceCreateEntry(): void\n    {\n        $json = $this->activityJsonBuilder->buildActivityJson($this->getAnnounceCreateEntryActivity());\n\n        $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver());\n    }\n\n    public function testAnnounceCreateEntryComment(): void\n    {\n        $json = $this->activityJsonBuilder->buildActivityJson($this->getAnnounceCreateEntryCommentActivity());\n\n        $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver());\n    }\n\n    public function testAnnounceCreateNestedEntryComment(): void\n    {\n        $json = $this->activityJsonBuilder->buildActivityJson($this->getAnnounceCreateNestedEntryCommentActivity());\n\n        $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver());\n    }\n\n    public function testAnnounceCreatePost(): void\n    {\n        $json = $this->activityJsonBuilder->buildActivityJson($this->getAnnounceCreatePostActivity());\n\n        $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver());\n    }\n\n    public function testAnnounceCreatePostComment(): void\n    {\n        $json = $this->activityJsonBuilder->buildActivityJson($this->getAnnounceCreatePostCommentActivity());\n\n        $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver());\n    }\n\n    public function testAnnounceCreateNestedPostComment(): void\n    {\n        $json = $this->activityJsonBuilder->buildActivityJson($this->getAnnounceCreateNestedPostCommentActivity());\n\n        $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver());\n    }\n\n    public function testAnnounceCreateMessage(): void\n    {\n        $json = $this->activityJsonBuilder->buildActivityJson($this->getAnnounceCreateMessageActivity());\n\n        $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver());\n    }\n\n    public function testAnnounceDeleteUser(): void\n    {\n        $json = $this->activityJsonBuilder->buildActivityJson($this->getAnnounceDeleteUserActivity());\n\n        $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver());\n    }\n\n    public function testAnnounceDeleteEntry(): void\n    {\n        $json = $this->activityJsonBuilder->buildActivityJson($this->getAnnounceDeleteEntryActivity());\n\n        $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver());\n    }\n\n    public function testAnnounceDeleteEntryByModerator(): void\n    {\n        $json = $this->activityJsonBuilder->buildActivityJson($this->getAnnounceDeleteEntryByModeratorActivity());\n\n        $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver());\n    }\n\n    public function testAnnounceDeleteEntryComment(): void\n    {\n        $json = $this->activityJsonBuilder->buildActivityJson($this->getAnnounceDeleteEntryCommentActivity());\n\n        $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver());\n    }\n\n    public function testAnnounceDeleteEntryCommentByModerator(): void\n    {\n        $json = $this->activityJsonBuilder->buildActivityJson($this->getAnnounceDeleteEntryCommentByModeratorActivity());\n\n        $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver());\n    }\n\n    public function testAnnounceDeletePost(): void\n    {\n        $json = $this->activityJsonBuilder->buildActivityJson($this->getAnnounceDeletePostActivity());\n\n        $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver());\n    }\n\n    public function testAnnounceDeletePostByModerator(): void\n    {\n        $json = $this->activityJsonBuilder->buildActivityJson($this->getAnnounceDeletePostByModeratorActivity());\n\n        $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver());\n    }\n\n    public function testAnnounceDeletePostComment(): void\n    {\n        $json = $this->activityJsonBuilder->buildActivityJson($this->getAnnounceDeletePostCommentActivity());\n\n        $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver());\n    }\n\n    public function testAnnounceDeletePostCommentByModerator(): void\n    {\n        $json = $this->activityJsonBuilder->buildActivityJson($this->getAnnounceDeletePostCommentByModeratorActivity());\n\n        $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver());\n    }\n\n    public function testUserBoostEntry(): void\n    {\n        $json = $this->activityJsonBuilder->buildActivityJson($this->getUserBoostEntryActivity());\n\n        $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver());\n    }\n\n    public function testMagazineBoostEntry(): void\n    {\n        $json = $this->activityJsonBuilder->buildActivityJson($this->getMagazineBoostEntryActivity());\n\n        $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver());\n    }\n\n    public function testUserBoostEntryComment(): void\n    {\n        $json = $this->activityJsonBuilder->buildActivityJson($this->getUserBoostEntryCommentActivity());\n\n        $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver());\n    }\n\n    public function testUserBoostNestedEntryComment(): void\n    {\n        $json = $this->activityJsonBuilder->buildActivityJson($this->getUserBoostNestedEntryCommentActivity());\n\n        $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver());\n    }\n\n    public function testMagazineBoostEntryComment(): void\n    {\n        $json = $this->activityJsonBuilder->buildActivityJson($this->getMagazineBoostEntryCommentActivity());\n\n        $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver());\n    }\n\n    public function testMagazineBoostNestedEntryComment(): void\n    {\n        $json = $this->activityJsonBuilder->buildActivityJson($this->getMagazineBoostNestedEntryCommentActivity());\n\n        $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver());\n    }\n\n    public function testUserBoostPost(): void\n    {\n        $json = $this->activityJsonBuilder->buildActivityJson($this->getUserBoostPostActivity());\n\n        $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver());\n    }\n\n    public function testMagazineBoostPost(): void\n    {\n        $json = $this->activityJsonBuilder->buildActivityJson($this->getMagazineBoostPostActivity());\n\n        $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver());\n    }\n\n    public function testUserBoostPostComment(): void\n    {\n        $json = $this->activityJsonBuilder->buildActivityJson($this->getUserBoostPostCommentActivity());\n\n        $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver());\n    }\n\n    public function testUserBoostNestedPostComment(): void\n    {\n        $json = $this->activityJsonBuilder->buildActivityJson($this->getUserBoostNestedPostCommentActivity());\n\n        $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver());\n    }\n\n    public function testMagazineBoostPostComment(): void\n    {\n        $json = $this->activityJsonBuilder->buildActivityJson($this->getMagazineBoostPostCommentActivity());\n\n        $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver());\n    }\n\n    public function testMagazineBoostNestedPostComment(): void\n    {\n        $json = $this->activityJsonBuilder->buildActivityJson($this->getMagazineBoostNestedPostCommentActivity());\n\n        $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver());\n    }\n\n    public function testAnnounceLikeEntry(): void\n    {\n        $json = $this->activityJsonBuilder->buildActivityJson($this->getAnnounceLikeEntryActivity());\n\n        $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver());\n    }\n\n    public function testAnnounceLikeEntryComment(): void\n    {\n        $json = $this->activityJsonBuilder->buildActivityJson($this->getAnnounceLikeEntryCommentActivity());\n\n        $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver());\n    }\n\n    public function testAnnounceLikeNestedEntryComment(): void\n    {\n        $json = $this->activityJsonBuilder->buildActivityJson($this->getAnnounceLikeNestedEntryCommentActivity());\n\n        $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver());\n    }\n\n    public function testAnnounceLikePost(): void\n    {\n        $json = $this->activityJsonBuilder->buildActivityJson($this->getAnnounceLikePostActivity());\n\n        $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver());\n    }\n\n    public function testAnnounceLikePostComment(): void\n    {\n        $json = $this->activityJsonBuilder->buildActivityJson($this->getAnnounceLikePostCommentActivity());\n\n        $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver());\n    }\n\n    public function testAnnounceLikeNestedPostComment(): void\n    {\n        $json = $this->activityJsonBuilder->buildActivityJson($this->getAnnounceLikeNestedPostCommentActivity());\n\n        $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver());\n    }\n\n    public function testAnnounceUndoLikeEntry(): void\n    {\n        $json = $this->activityJsonBuilder->buildActivityJson($this->getAnnounceUndoLikeEntryActivity());\n\n        $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver());\n    }\n\n    public function testAnnounceUndoLikeEntryComment(): void\n    {\n        $json = $this->activityJsonBuilder->buildActivityJson($this->getAnnounceUndoLikeEntryCommentActivity());\n\n        $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver());\n    }\n\n    public function testAnnounceUndoLikeNestedEntryComment(): void\n    {\n        $json = $this->activityJsonBuilder->buildActivityJson($this->getAnnounceUndoLikeNestedEntryCommentActivity());\n\n        $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver());\n    }\n\n    public function testAnnounceUndoLikePost(): void\n    {\n        $json = $this->activityJsonBuilder->buildActivityJson($this->getAnnounceUndoLikePostActivity());\n\n        $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver());\n    }\n\n    public function testAnnounceUndoLikePostComment(): void\n    {\n        $json = $this->activityJsonBuilder->buildActivityJson($this->getAnnounceUndoLikePostCommentActivity());\n\n        $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver());\n    }\n\n    public function testAnnounceUndoLikeNestedPostComment(): void\n    {\n        $json = $this->activityJsonBuilder->buildActivityJson($this->getAnnounceUndoLikeNestedPostCommentActivity());\n\n        $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver());\n    }\n\n    public function testAnnounceUpdateUser(): void\n    {\n        $json = $this->activityJsonBuilder->buildActivityJson($this->getAnnounceUpdateUserActivity());\n\n        $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver());\n    }\n\n    public function testAnnounceUpdateMagazine(): void\n    {\n        $json = $this->activityJsonBuilder->buildActivityJson($this->getAnnounceUpdateMagazineActivity());\n\n        $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver());\n    }\n\n    public function testAnnounceUpdateEntry(): void\n    {\n        $json = $this->activityJsonBuilder->buildActivityJson($this->getAnnounceUpdateEntryActivity());\n\n        $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver());\n    }\n\n    public function testAnnounceUpdateEntryComment(): void\n    {\n        $json = $this->activityJsonBuilder->buildActivityJson($this->getAnnounceUpdateEntryCommentActivity());\n\n        $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver());\n    }\n\n    public function testAnnounceUpdatePost(): void\n    {\n        $json = $this->activityJsonBuilder->buildActivityJson($this->getAnnounceUpdatePostActivity());\n\n        $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver());\n    }\n\n    public function testAnnounceUpdatePostComment(): void\n    {\n        $json = $this->activityJsonBuilder->buildActivityJson($this->getAnnounceUpdatePostCommentActivity());\n\n        $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver());\n    }\n\n    public function testAnnounceBlockUser(): void\n    {\n        $json = $this->activityJsonBuilder->buildActivityJson($this->getAnnounceBlockUserActivity());\n\n        $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver());\n    }\n\n    public function testAnnounceUndoBlockUser(): void\n    {\n        $json = $this->activityJsonBuilder->buildActivityJson($this->getAnnounceUndoBlockUserActivity());\n\n        $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver());\n    }\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/BlockTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Unit\\ActivityPub\\Outbox;\n\nuse App\\Tests\\ActivityPubJsonDriver;\nuse App\\Tests\\ActivityPubTestCase;\nuse App\\Tests\\Unit\\ActivityPub\\Traits\\BlockActivityGeneratorTrait;\n\nclass BlockTest extends ActivityPubTestCase\n{\n    use BlockActivityGeneratorTrait;\n\n    public function testBlockUser(): void\n    {\n        $json = $this->activityJsonBuilder->buildActivityJson($this->getBlockUserActivity());\n\n        $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver());\n    }\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/CreateTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Unit\\ActivityPub\\Outbox;\n\nuse App\\Tests\\ActivityPubJsonDriver;\nuse App\\Tests\\ActivityPubTestCase;\nuse App\\Tests\\Unit\\ActivityPub\\Traits\\CreateActivityGeneratorTrait;\n\nclass CreateTest extends ActivityPubTestCase\n{\n    use CreateActivityGeneratorTrait;\n\n    public function testCreateEntry(): void\n    {\n        $json = $this->activityJsonBuilder->buildActivityJson($this->getCreateEntryActivity());\n\n        $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver());\n    }\n\n    public function testCreateEntryWithUrlAndImage(): void\n    {\n        $json = $this->activityJsonBuilder->buildActivityJson($this->getCreateEntryActivityWithImageAndUrl());\n\n        $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver());\n    }\n\n    public function testCreateEntryComment(): void\n    {\n        $json = $this->activityJsonBuilder->buildActivityJson($this->getCreateEntryCommentActivity());\n\n        $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver());\n    }\n\n    public function testCreateNestedEntryComment(): void\n    {\n        $json = $this->activityJsonBuilder->buildActivityJson($this->getCreateNestedEntryCommentActivity());\n\n        $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver());\n    }\n\n    public function testCreatePost(): void\n    {\n        $json = $this->activityJsonBuilder->buildActivityJson($this->getCreatePostActivity());\n\n        $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver());\n    }\n\n    public function testCreatePostComment(): void\n    {\n        $json = $this->activityJsonBuilder->buildActivityJson($this->getCreatePostCommentActivity());\n\n        $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver());\n    }\n\n    public function testCreateNestedPostComment(): void\n    {\n        $json = $this->activityJsonBuilder->buildActivityJson($this->getCreateNestedPostCommentActivity());\n\n        $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver());\n    }\n\n    public function testCreateMessage(): void\n    {\n        $json = $this->activityJsonBuilder->buildActivityJson($this->getCreateMessageActivity());\n\n        $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver());\n    }\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/DeleteTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Unit\\ActivityPub\\Outbox;\n\nuse App\\Tests\\ActivityPubJsonDriver;\nuse App\\Tests\\ActivityPubTestCase;\nuse App\\Tests\\Unit\\ActivityPub\\Traits\\DeleteActivityGeneratorTrait;\n\nclass DeleteTest extends ActivityPubTestCase\n{\n    use DeleteActivityGeneratorTrait;\n\n    public function testDeleteUser(): void\n    {\n        $json = $this->activityJsonBuilder->buildActivityJson($this->getDeleteUserActivity());\n\n        $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver());\n    }\n\n    public function testDeleteEntry(): void\n    {\n        $json = $this->activityJsonBuilder->buildActivityJson($this->getDeleteEntryActivity());\n\n        $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver());\n    }\n\n    public function testDeleteEntryByModerator(): void\n    {\n        $json = $this->activityJsonBuilder->buildActivityJson($this->getDeleteEntryByModeratorActivity());\n\n        $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver());\n    }\n\n    public function testDeleteEntryComment(): void\n    {\n        $json = $this->activityJsonBuilder->buildActivityJson($this->getDeleteEntryCommentActivity());\n\n        $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver());\n    }\n\n    public function testDeleteEntryCommentByModerator(): void\n    {\n        $json = $this->activityJsonBuilder->buildActivityJson($this->getDeleteEntryCommentByModeratorActivity());\n\n        $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver());\n    }\n\n    public function testDeletePost(): void\n    {\n        $json = $this->activityJsonBuilder->buildActivityJson($this->getDeletePostActivity());\n\n        $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver());\n    }\n\n    public function testDeletePostByModerator(): void\n    {\n        $json = $this->activityJsonBuilder->buildActivityJson($this->getDeletePostByModeratorActivity());\n\n        $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver());\n    }\n\n    public function testDeletePostComment(): void\n    {\n        $json = $this->activityJsonBuilder->buildActivityJson($this->getDeletePostCommentActivity());\n\n        $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver());\n    }\n\n    public function testDeletePostCommentByModerator(): void\n    {\n        $json = $this->activityJsonBuilder->buildActivityJson($this->getDeletePostCommentByModeratorActivity());\n\n        $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver());\n    }\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/FlagTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Unit\\ActivityPub\\Outbox;\n\nuse App\\Tests\\ActivityPubJsonDriver;\nuse App\\Tests\\ActivityPubTestCase;\nuse App\\Tests\\Unit\\ActivityPub\\Traits\\FlagActivityGeneratorTrait;\n\nclass FlagTest extends ActivityPubTestCase\n{\n    use FlagActivityGeneratorTrait;\n\n    public function testFlagEntry(): void\n    {\n        $activity = $this->getFlagEntryActivity($this->getUserByUsername('reportingUser'));\n        $json = $this->activityJsonBuilder->buildActivityJson($activity);\n\n        $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver());\n    }\n\n    public function testFlagEntryComment(): void\n    {\n        $activity = $this->getFlagEntryCommentActivity($this->getUserByUsername('reportingUser'));\n        $json = $this->activityJsonBuilder->buildActivityJson($activity);\n\n        $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver());\n    }\n\n    public function testFlagNestedEntryComment(): void\n    {\n        $activity = $this->getFlagNestedEntryCommentActivity($this->getUserByUsername('reportingUser'));\n        $json = $this->activityJsonBuilder->buildActivityJson($activity);\n\n        $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver());\n    }\n\n    public function testFlagPost(): void\n    {\n        $activity = $this->getFlagPostActivity($this->getUserByUsername('reportingUser'));\n        $json = $this->activityJsonBuilder->buildActivityJson($activity);\n\n        $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver());\n    }\n\n    public function testFlagPostComment(): void\n    {\n        $activity = $this->getFlagPostCommentActivity($this->getUserByUsername('reportingUser'));\n        $json = $this->activityJsonBuilder->buildActivityJson($activity);\n\n        $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver());\n    }\n\n    public function testFlagNestedPostComment(): void\n    {\n        $activity = $this->getFlagNestedPostCommentActivity($this->getUserByUsername('reportingUser'));\n        $json = $this->activityJsonBuilder->buildActivityJson($activity);\n\n        $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver());\n    }\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/FollowTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Unit\\ActivityPub\\Outbox;\n\nuse App\\Tests\\ActivityPubJsonDriver;\nuse App\\Tests\\ActivityPubTestCase;\nuse App\\Tests\\Unit\\ActivityPub\\Traits\\FollowActivityGeneratorTrait;\n\nclass FollowTest extends ActivityPubTestCase\n{\n    use FollowActivityGeneratorTrait;\n\n    public function testFollowUser(): void\n    {\n        $json = $this->activityJsonBuilder->buildActivityJson($this->getFollowUserActivity());\n\n        $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver());\n    }\n\n    public function testAcceptFollowUser(): void\n    {\n        $json = $this->activityJsonBuilder->buildActivityJson($this->getAcceptFollowUserActivity());\n\n        $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver());\n    }\n\n    public function testRejectFollowUser(): void\n    {\n        $json = $this->activityJsonBuilder->buildActivityJson($this->getRejectFollowUserActivity());\n\n        $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver());\n    }\n\n    public function testAcceptFollowMagazine(): void\n    {\n        $json = $this->activityJsonBuilder->buildActivityJson($this->getAcceptFollowMagazineActivity());\n\n        $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver());\n    }\n\n    public function testRejectFollowMagazine(): void\n    {\n        $json = $this->activityJsonBuilder->buildActivityJson($this->getRejectFollowMagazineActivity());\n\n        $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver());\n    }\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/JsonSnapshots/AddHandlerTest__testAddModerator__1.json",
    "content": "{\n    \"@context\": [\n        \"https://www.w3.org/ns/activitystreams\",\n        \"https://w3id.org/security/v1\",\n        \"https://kbin.test/contexts\"\n    ],\n    \"id\": \"SCRUBBED_ID\",\n    \"actor\": \"https://kbin.test/u/owner\",\n    \"to\": [\n        \"https://www.w3.org/ns/activitystreams#Public\"\n    ],\n    \"object\": \"SCRUBBED_ID\",\n    \"cc\": [\n        \"https://kbin.test/m/test\"\n    ],\n    \"type\": \"Add\",\n    \"target\": \"https://kbin.test/m/test/moderators\",\n    \"audience\": \"https://kbin.test/m/test\"\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/JsonSnapshots/AddHandlerTest__testAddPinnedPost__1.json",
    "content": "{\n    \"@context\": [\n        \"https://www.w3.org/ns/activitystreams\",\n        \"https://w3id.org/security/v1\",\n        \"https://kbin.test/contexts\"\n    ],\n    \"id\": \"SCRUBBED_ID\",\n    \"actor\": \"https://kbin.test/u/owner\",\n    \"to\": [\n        \"https://www.w3.org/ns/activitystreams#Public\"\n    ],\n    \"object\": \"SCRUBBED_ID\",\n    \"cc\": [\n        \"https://kbin.test/m/test\"\n    ],\n    \"type\": \"Add\",\n    \"target\": \"https://kbin.test/m/test/pinned\",\n    \"audience\": \"https://kbin.test/m/test\"\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/JsonSnapshots/AddHandlerTest__testRemoveModerator__1.json",
    "content": "{\n    \"@context\": [\n        \"https://www.w3.org/ns/activitystreams\",\n        \"https://w3id.org/security/v1\",\n        \"https://kbin.test/contexts\"\n    ],\n    \"id\": \"SCRUBBED_ID\",\n    \"actor\": \"https://kbin.test/u/owner\",\n    \"to\": [\n        \"https://www.w3.org/ns/activitystreams#Public\"\n    ],\n    \"object\": \"SCRUBBED_ID\",\n    \"cc\": [\n        \"https://kbin.test/m/test\"\n    ],\n    \"type\": \"Remove\",\n    \"target\": \"https://kbin.test/m/test/moderators\",\n    \"audience\": \"https://kbin.test/m/test\"\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/JsonSnapshots/AddHandlerTest__testRemovePinnedPost__1.json",
    "content": "{\n    \"@context\": [\n        \"https://www.w3.org/ns/activitystreams\",\n        \"https://w3id.org/security/v1\",\n        \"https://kbin.test/contexts\"\n    ],\n    \"id\": \"SCRUBBED_ID\",\n    \"actor\": \"https://kbin.test/u/owner\",\n    \"to\": [\n        \"https://www.w3.org/ns/activitystreams#Public\"\n    ],\n    \"object\": \"SCRUBBED_ID\",\n    \"cc\": [\n        \"https://kbin.test/m/test\"\n    ],\n    \"type\": \"Remove\",\n    \"target\": \"https://kbin.test/m/test/pinned\",\n    \"audience\": \"https://kbin.test/m/test\"\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/JsonSnapshots/AnnounceTest__testAnnounceAddModerator__1.json",
    "content": "{\n    \"@context\": [\n        \"https://www.w3.org/ns/activitystreams\",\n        \"https://w3id.org/security/v1\",\n        \"https://kbin.test/contexts\"\n    ],\n    \"id\": \"SCRUBBED_ID\",\n    \"type\": \"Announce\",\n    \"actor\": \"https://kbin.test/m/test\",\n    \"object\": {\n        \"id\": \"SCRUBBED_ID\",\n        \"actor\": \"https://kbin.test/u/owner\",\n        \"to\": [\n            \"https://www.w3.org/ns/activitystreams#Public\"\n        ],\n        \"object\": \"SCRUBBED_ID\",\n        \"cc\": [\n            \"https://kbin.test/m/test\"\n        ],\n        \"type\": \"Add\",\n        \"target\": \"https://kbin.test/m/test/moderators\",\n        \"audience\": \"https://kbin.test/m/test\"\n    },\n    \"to\": [\n        \"https://www.w3.org/ns/activitystreams#Public\"\n    ],\n    \"cc\": [\n        \"https://kbin.test/m/test/followers\"\n    ],\n    \"published\": \"SCRUBBED_DATE\",\n    \"audience\": \"https://kbin.test/m/test\"\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/JsonSnapshots/AnnounceTest__testAnnounceAddPinnedPost__1.json",
    "content": "{\n    \"@context\": [\n        \"https://www.w3.org/ns/activitystreams\",\n        \"https://w3id.org/security/v1\",\n        \"https://kbin.test/contexts\"\n    ],\n    \"id\": \"SCRUBBED_ID\",\n    \"type\": \"Announce\",\n    \"actor\": \"https://kbin.test/m/test\",\n    \"object\": {\n        \"id\": \"SCRUBBED_ID\",\n        \"actor\": \"https://kbin.test/u/owner\",\n        \"to\": [\n            \"https://www.w3.org/ns/activitystreams#Public\"\n        ],\n        \"object\": \"SCRUBBED_ID\",\n        \"cc\": [\n            \"https://kbin.test/m/test\"\n        ],\n        \"type\": \"Add\",\n        \"target\": \"https://kbin.test/m/test/pinned\",\n        \"audience\": \"https://kbin.test/m/test\"\n    },\n    \"to\": [\n        \"https://www.w3.org/ns/activitystreams#Public\"\n    ],\n    \"cc\": [\n        \"https://kbin.test/m/test/followers\"\n    ],\n    \"published\": \"SCRUBBED_DATE\",\n    \"audience\": \"https://kbin.test/m/test\"\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/JsonSnapshots/AnnounceTest__testAnnounceBlockUser__1.json",
    "content": "{\n    \"@context\": [\n        \"https://www.w3.org/ns/activitystreams\",\n        \"https://w3id.org/security/v1\",\n        \"https://kbin.test/contexts\"\n    ],\n    \"id\": \"SCRUBBED_ID\",\n    \"type\": \"Announce\",\n    \"actor\": \"https://kbin.test/m/test\",\n    \"object\": {\n        \"id\": \"SCRUBBED_ID\",\n        \"type\": \"Block\",\n        \"actor\": \"https://kbin.test/u/owner\",\n        \"object\": \"SCRUBBED_ID\",\n        \"target\": \"https://kbin.test/m/test\",\n        \"summary\": \"some test\",\n        \"audience\": \"https://kbin.test/m/test\",\n        \"expires\": null,\n        \"to\": [\n            \"https://www.w3.org/ns/activitystreams#Public\"\n        ],\n        \"cc\": [\n            \"https://kbin.test/m/test\"\n        ]\n    },\n    \"to\": [\n        \"https://www.w3.org/ns/activitystreams#Public\"\n    ],\n    \"cc\": [\n        \"https://kbin.test/m/test/followers\"\n    ],\n    \"published\": \"SCRUBBED_DATE\",\n    \"audience\": \"https://kbin.test/m/test\"\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/JsonSnapshots/AnnounceTest__testAnnounceCreateEntryComment__1.json",
    "content": "{\n    \"@context\": [\n        \"https://www.w3.org/ns/activitystreams\",\n        \"https://w3id.org/security/v1\",\n        \"https://kbin.test/contexts\"\n    ],\n    \"id\": \"SCRUBBED_ID\",\n    \"type\": \"Announce\",\n    \"actor\": \"https://kbin.test/m/test\",\n    \"object\": {\n        \"id\": \"SCRUBBED_ID\",\n        \"type\": \"Create\",\n        \"actor\": \"https://kbin.test/u/user\",\n        \"published\": \"SCRUBBED_DATE\",\n        \"to\": [\n            \"https://www.w3.org/ns/activitystreams#Public\",\n            \"https://kbin.test/u/user\"\n        ],\n        \"cc\": [\n            \"https://kbin.test/m/test\",\n            \"https://kbin.test/u/user/followers\"\n        ],\n        \"object\": {\n            \"id\": \"SCRUBBED_ID\",\n            \"type\": \"Note\",\n            \"attributedTo\": \"https://kbin.test/u/user\",\n            \"inReplyTo\": \"SCRUBBED_ID\",\n            \"to\": [\n                \"https://www.w3.org/ns/activitystreams#Public\",\n                \"https://kbin.test/u/user\"\n            ],\n            \"cc\": [\n                \"https://kbin.test/m/test\",\n                \"https://kbin.test/u/user/followers\"\n            ],\n            \"audience\": \"https://kbin.test/m/test\",\n            \"sensitive\": false,\n            \"content\": \"<p>test</p>\\n\",\n            \"mediaType\": \"text/html\",\n            \"source\": {\n                \"content\": \"test\",\n                \"mediaType\": \"text/markdown\"\n            },\n            \"url\": \"SCRUBBED_ID\",\n            \"tag\": [\n                {\n                    \"type\": \"Hashtag\",\n                    \"href\": \"https://kbin.test/tag/test\",\n                    \"name\": \"#test\"\n                }\n            ],\n            \"published\": \"SCRUBBED_DATE\",\n            \"contentMap\": {\n                \"en\": \"<p>test</p>\\n\"\n            }\n        },\n        \"audience\": \"https://kbin.test/m/test\"\n    },\n    \"to\": [\n        \"https://www.w3.org/ns/activitystreams#Public\"\n    ],\n    \"cc\": [\n        \"https://kbin.test/m/test/followers\",\n        \"https://kbin.test/u/user/followers\"\n    ],\n    \"published\": \"SCRUBBED_DATE\",\n    \"audience\": \"https://kbin.test/m/test\"\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/JsonSnapshots/AnnounceTest__testAnnounceCreateEntry__1.json",
    "content": "{\n    \"@context\": [\n        \"https://www.w3.org/ns/activitystreams\",\n        \"https://w3id.org/security/v1\",\n        \"https://kbin.test/contexts\"\n    ],\n    \"id\": \"SCRUBBED_ID\",\n    \"type\": \"Announce\",\n    \"actor\": \"https://kbin.test/m/test\",\n    \"object\": {\n        \"id\": \"SCRUBBED_ID\",\n        \"type\": \"Create\",\n        \"actor\": \"https://kbin.test/u/user\",\n        \"published\": \"SCRUBBED_DATE\",\n        \"to\": [\n            \"https://kbin.test/m/test\",\n            \"https://www.w3.org/ns/activitystreams#Public\"\n        ],\n        \"cc\": [\n            \"https://kbin.test/u/user/followers\"\n        ],\n        \"object\": {\n            \"id\": \"SCRUBBED_ID\",\n            \"type\": \"Page\",\n            \"attributedTo\": \"https://kbin.test/u/user\",\n            \"inReplyTo\": null,\n            \"to\": [\n                \"https://kbin.test/m/test\",\n                \"https://www.w3.org/ns/activitystreams#Public\"\n            ],\n            \"cc\": [\n                \"https://kbin.test/u/user/followers\"\n            ],\n            \"name\": \"test\",\n            \"audience\": \"https://kbin.test/m/test\",\n            \"content\": null,\n            \"summary\": \"test #test\",\n            \"mediaType\": \"text/html\",\n            \"source\": null,\n            \"tag\": [\n                {\n                    \"type\": \"Hashtag\",\n                    \"href\": \"https://kbin.test/tag/test\",\n                    \"name\": \"#test\"\n                }\n            ],\n            \"commentsEnabled\": true,\n            \"sensitive\": false,\n            \"stickied\": false,\n            \"published\": \"SCRUBBED_DATE\",\n            \"contentMap\": {\n                \"en\": null\n            }\n        },\n        \"audience\": \"https://kbin.test/m/test\"\n    },\n    \"to\": [\n        \"https://www.w3.org/ns/activitystreams#Public\"\n    ],\n    \"cc\": [\n        \"https://kbin.test/m/test/followers\",\n        \"https://kbin.test/u/user/followers\"\n    ],\n    \"published\": \"SCRUBBED_DATE\",\n    \"audience\": \"https://kbin.test/m/test\"\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/JsonSnapshots/AnnounceTest__testAnnounceCreateMessage__1.json",
    "content": "{\n    \"@context\": [\n        \"https://www.w3.org/ns/activitystreams\",\n        \"https://w3id.org/security/v1\",\n        \"https://kbin.test/contexts\"\n    ],\n    \"id\": \"SCRUBBED_ID\",\n    \"type\": \"Announce\",\n    \"actor\": \"https://kbin.test/m/test\",\n    \"object\": {\n        \"id\": \"SCRUBBED_ID\",\n        \"type\": \"Create\",\n        \"actor\": \"https://kbin.test/u/user\",\n        \"published\": \"SCRUBBED_DATE\",\n        \"to\": [\n            \"https://kbin.test/u/user2\"\n        ],\n        \"cc\": [],\n        \"object\": {\n            \"id\": \"SCRUBBED_ID\",\n            \"attributedTo\": \"https://kbin.test/u/user\",\n            \"to\": [\n                \"https://kbin.test/u/user2\"\n            ],\n            \"cc\": [],\n            \"type\": \"ChatMessage\",\n            \"published\": \"SCRUBBED_DATE\",\n            \"content\": \"<p>some test message</p>\\n\",\n            \"mediaType\": \"text/html\",\n            \"source\": {\n                \"mediaType\": \"text/markdown\",\n                \"content\": \"some test message\"\n            }\n        }\n    },\n    \"to\": [\n        \"https://www.w3.org/ns/activitystreams#Public\"\n    ],\n    \"cc\": [\n        \"https://kbin.test/m/test/followers\"\n    ],\n    \"published\": \"SCRUBBED_DATE\",\n    \"audience\": \"https://kbin.test/m/test\"\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/JsonSnapshots/AnnounceTest__testAnnounceCreateNestedEntryComment__1.json",
    "content": "{\n    \"@context\": [\n        \"https://www.w3.org/ns/activitystreams\",\n        \"https://w3id.org/security/v1\",\n        \"https://kbin.test/contexts\"\n    ],\n    \"id\": \"SCRUBBED_ID\",\n    \"type\": \"Announce\",\n    \"actor\": \"https://kbin.test/m/test\",\n    \"object\": {\n        \"id\": \"SCRUBBED_ID\",\n        \"type\": \"Create\",\n        \"actor\": \"https://kbin.test/u/user\",\n        \"published\": \"SCRUBBED_DATE\",\n        \"to\": [\n            \"https://www.w3.org/ns/activitystreams#Public\",\n            \"https://kbin.test/u/user\"\n        ],\n        \"cc\": [\n            \"https://kbin.test/m/test\",\n            \"https://kbin.test/u/user/followers\"\n        ],\n        \"object\": {\n            \"id\": \"SCRUBBED_ID\",\n            \"type\": \"Note\",\n            \"attributedTo\": \"https://kbin.test/u/user\",\n            \"inReplyTo\": \"SCRUBBED_ID\",\n            \"to\": [\n                \"https://www.w3.org/ns/activitystreams#Public\",\n                \"https://kbin.test/u/user\"\n            ],\n            \"cc\": [\n                \"https://kbin.test/m/test\",\n                \"https://kbin.test/u/user/followers\"\n            ],\n            \"audience\": \"https://kbin.test/m/test\",\n            \"sensitive\": false,\n            \"content\": \"<p>test</p>\\n\",\n            \"mediaType\": \"text/html\",\n            \"source\": {\n                \"content\": \"test\",\n                \"mediaType\": \"text/markdown\"\n            },\n            \"url\": \"SCRUBBED_ID\",\n            \"tag\": [\n                {\n                    \"type\": \"Hashtag\",\n                    \"href\": \"https://kbin.test/tag/test\",\n                    \"name\": \"#test\"\n                }\n            ],\n            \"published\": \"SCRUBBED_DATE\",\n            \"contentMap\": {\n                \"en\": \"<p>test</p>\\n\"\n            }\n        },\n        \"audience\": \"https://kbin.test/m/test\"\n    },\n    \"to\": [\n        \"https://www.w3.org/ns/activitystreams#Public\"\n    ],\n    \"cc\": [\n        \"https://kbin.test/m/test/followers\",\n        \"https://kbin.test/u/user/followers\"\n    ],\n    \"published\": \"SCRUBBED_DATE\",\n    \"audience\": \"https://kbin.test/m/test\"\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/JsonSnapshots/AnnounceTest__testAnnounceCreateNestedPostComment__1.json",
    "content": "{\n    \"@context\": [\n        \"https://www.w3.org/ns/activitystreams\",\n        \"https://w3id.org/security/v1\",\n        \"https://kbin.test/contexts\"\n    ],\n    \"id\": \"SCRUBBED_ID\",\n    \"type\": \"Announce\",\n    \"actor\": \"https://kbin.test/m/test\",\n    \"object\": {\n        \"id\": \"SCRUBBED_ID\",\n        \"type\": \"Create\",\n        \"actor\": \"https://kbin.test/u/user\",\n        \"published\": \"SCRUBBED_DATE\",\n        \"to\": [\n            \"https://www.w3.org/ns/activitystreams#Public\",\n            \"https://kbin.test/u/user\"\n        ],\n        \"cc\": [\n            \"https://kbin.test/m/test\",\n            \"https://kbin.test/u/user/followers\"\n        ],\n        \"object\": {\n            \"id\": \"SCRUBBED_ID\",\n            \"type\": \"Note\",\n            \"attributedTo\": \"https://kbin.test/u/user\",\n            \"inReplyTo\": \"SCRUBBED_ID\",\n            \"to\": [\n                \"https://www.w3.org/ns/activitystreams#Public\",\n                \"https://kbin.test/u/user\"\n            ],\n            \"cc\": [\n                \"https://kbin.test/m/test\",\n                \"https://kbin.test/u/user/followers\"\n            ],\n            \"audience\": \"https://kbin.test/m/test\",\n            \"sensitive\": false,\n            \"content\": \"<p>test</p>\\n\",\n            \"mediaType\": \"text/html\",\n            \"source\": {\n                \"content\": \"test\",\n                \"mediaType\": \"text/markdown\"\n            },\n            \"url\": \"SCRUBBED_ID\",\n            \"tag\": [\n                {\n                    \"type\": \"Hashtag\",\n                    \"href\": \"https://kbin.test/tag/test\",\n                    \"name\": \"#test\"\n                }\n            ],\n            \"published\": \"SCRUBBED_DATE\",\n            \"contentMap\": {\n                \"en\": \"<p>test</p>\\n\"\n            }\n        },\n        \"audience\": \"https://kbin.test/m/test\"\n    },\n    \"to\": [\n        \"https://www.w3.org/ns/activitystreams#Public\"\n    ],\n    \"cc\": [\n        \"https://kbin.test/m/test/followers\",\n        \"https://kbin.test/u/user/followers\"\n    ],\n    \"published\": \"SCRUBBED_DATE\",\n    \"audience\": \"https://kbin.test/m/test\"\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/JsonSnapshots/AnnounceTest__testAnnounceCreatePostComment__1.json",
    "content": "{\n    \"@context\": [\n        \"https://www.w3.org/ns/activitystreams\",\n        \"https://w3id.org/security/v1\",\n        \"https://kbin.test/contexts\"\n    ],\n    \"id\": \"SCRUBBED_ID\",\n    \"type\": \"Announce\",\n    \"actor\": \"https://kbin.test/m/test\",\n    \"object\": {\n        \"id\": \"SCRUBBED_ID\",\n        \"type\": \"Create\",\n        \"actor\": \"https://kbin.test/u/user\",\n        \"published\": \"SCRUBBED_DATE\",\n        \"to\": [\n            \"https://www.w3.org/ns/activitystreams#Public\",\n            \"https://kbin.test/u/user\"\n        ],\n        \"cc\": [\n            \"https://kbin.test/m/test\",\n            \"https://kbin.test/u/user/followers\"\n        ],\n        \"object\": {\n            \"id\": \"SCRUBBED_ID\",\n            \"type\": \"Note\",\n            \"attributedTo\": \"https://kbin.test/u/user\",\n            \"inReplyTo\": \"SCRUBBED_ID\",\n            \"to\": [\n                \"https://www.w3.org/ns/activitystreams#Public\",\n                \"https://kbin.test/u/user\"\n            ],\n            \"cc\": [\n                \"https://kbin.test/m/test\",\n                \"https://kbin.test/u/user/followers\"\n            ],\n            \"audience\": \"https://kbin.test/m/test\",\n            \"sensitive\": false,\n            \"content\": \"<p>test</p>\\n\",\n            \"mediaType\": \"text/html\",\n            \"source\": {\n                \"content\": \"test\",\n                \"mediaType\": \"text/markdown\"\n            },\n            \"url\": \"SCRUBBED_ID\",\n            \"tag\": [\n                {\n                    \"type\": \"Hashtag\",\n                    \"href\": \"https://kbin.test/tag/test\",\n                    \"name\": \"#test\"\n                }\n            ],\n            \"published\": \"SCRUBBED_DATE\",\n            \"contentMap\": {\n                \"en\": \"<p>test</p>\\n\"\n            }\n        },\n        \"audience\": \"https://kbin.test/m/test\"\n    },\n    \"to\": [\n        \"https://www.w3.org/ns/activitystreams#Public\"\n    ],\n    \"cc\": [\n        \"https://kbin.test/m/test/followers\",\n        \"https://kbin.test/u/user/followers\"\n    ],\n    \"published\": \"SCRUBBED_DATE\",\n    \"audience\": \"https://kbin.test/m/test\"\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/JsonSnapshots/AnnounceTest__testAnnounceCreatePost__1.json",
    "content": "{\n    \"@context\": [\n        \"https://www.w3.org/ns/activitystreams\",\n        \"https://w3id.org/security/v1\",\n        \"https://kbin.test/contexts\"\n    ],\n    \"id\": \"SCRUBBED_ID\",\n    \"type\": \"Announce\",\n    \"actor\": \"https://kbin.test/m/test\",\n    \"object\": {\n        \"id\": \"SCRUBBED_ID\",\n        \"type\": \"Create\",\n        \"actor\": \"https://kbin.test/u/user\",\n        \"published\": \"SCRUBBED_DATE\",\n        \"to\": [\n            \"https://kbin.test/m/test\",\n            \"https://www.w3.org/ns/activitystreams#Public\"\n        ],\n        \"cc\": [\n            \"https://kbin.test/u/user/followers\"\n        ],\n        \"object\": {\n            \"id\": \"SCRUBBED_ID\",\n            \"type\": \"Note\",\n            \"attributedTo\": \"https://kbin.test/u/user\",\n            \"inReplyTo\": null,\n            \"to\": [\n                \"https://kbin.test/m/test\",\n                \"https://www.w3.org/ns/activitystreams#Public\"\n            ],\n            \"cc\": [\n                \"https://kbin.test/u/user/followers\"\n            ],\n            \"audience\": \"https://kbin.test/m/test\",\n            \"sensitive\": false,\n            \"stickied\": false,\n            \"content\": \"<p>test</p>\\n<p><a href=\\\"https://kbin.test/tag/test\\\">#test</a></p>\\n\",\n            \"mediaType\": \"text/html\",\n            \"source\": {\n                \"content\": \"test\\n\\n #test\",\n                \"mediaType\": \"text/markdown\"\n            },\n            \"url\": \"SCRUBBED_ID\",\n            \"tag\": [\n                {\n                    \"type\": \"Hashtag\",\n                    \"href\": \"https://kbin.test/tag/test\",\n                    \"name\": \"#test\"\n                }\n            ],\n            \"commentsEnabled\": true,\n            \"published\": \"SCRUBBED_DATE\",\n            \"contentMap\": {\n                \"en\": \"<p>test</p>\\n<p><a href=\\\"https://kbin.test/tag/test\\\">#test</a></p>\\n\"\n            }\n        },\n        \"audience\": \"https://kbin.test/m/test\"\n    },\n    \"to\": [\n        \"https://www.w3.org/ns/activitystreams#Public\"\n    ],\n    \"cc\": [\n        \"https://kbin.test/m/test/followers\",\n        \"https://kbin.test/u/user/followers\"\n    ],\n    \"published\": \"SCRUBBED_DATE\",\n    \"audience\": \"https://kbin.test/m/test\"\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/JsonSnapshots/AnnounceTest__testAnnounceDeleteEntryByModerator__1.json",
    "content": "{\n    \"@context\": [\n        \"https://www.w3.org/ns/activitystreams\",\n        \"https://w3id.org/security/v1\",\n        \"https://kbin.test/contexts\"\n    ],\n    \"id\": \"SCRUBBED_ID\",\n    \"type\": \"Announce\",\n    \"actor\": \"https://kbin.test/m/test\",\n    \"object\": {\n        \"id\": \"SCRUBBED_ID\",\n        \"type\": \"Delete\",\n        \"actor\": \"https://kbin.test/u/owner\",\n        \"object\": {\n            \"id\": \"SCRUBBED_ID\",\n            \"type\": \"Tombstone\"\n        },\n        \"to\": [\n            \"https://kbin.test/m/test\",\n            \"https://www.w3.org/ns/activitystreams#Public\"\n        ],\n        \"cc\": [\n            \"https://kbin.test/u/user/followers\"\n        ],\n        \"summary\": \" \"\n    },\n    \"to\": [\n        \"https://www.w3.org/ns/activitystreams#Public\"\n    ],\n    \"cc\": [\n        \"https://kbin.test/m/test/followers\",\n        \"https://kbin.test/u/user/followers\"\n    ],\n    \"published\": \"SCRUBBED_DATE\",\n    \"audience\": \"https://kbin.test/m/test\"\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/JsonSnapshots/AnnounceTest__testAnnounceDeleteEntryCommentByModerator__1.json",
    "content": "{\n    \"@context\": [\n        \"https://www.w3.org/ns/activitystreams\",\n        \"https://w3id.org/security/v1\",\n        \"https://kbin.test/contexts\"\n    ],\n    \"id\": \"SCRUBBED_ID\",\n    \"type\": \"Announce\",\n    \"actor\": \"https://kbin.test/m/test\",\n    \"object\": {\n        \"id\": \"SCRUBBED_ID\",\n        \"type\": \"Delete\",\n        \"actor\": \"https://kbin.test/u/owner\",\n        \"object\": {\n            \"id\": \"SCRUBBED_ID\",\n            \"type\": \"Tombstone\"\n        },\n        \"to\": [\n            \"https://www.w3.org/ns/activitystreams#Public\",\n            \"https://kbin.test/u/user\"\n        ],\n        \"cc\": [\n            \"https://kbin.test/m/test\",\n            \"https://kbin.test/u/user/followers\"\n        ],\n        \"summary\": \" \"\n    },\n    \"to\": [\n        \"https://www.w3.org/ns/activitystreams#Public\"\n    ],\n    \"cc\": [\n        \"https://kbin.test/m/test/followers\",\n        \"https://kbin.test/u/user/followers\"\n    ],\n    \"published\": \"SCRUBBED_DATE\",\n    \"audience\": \"https://kbin.test/m/test\"\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/JsonSnapshots/AnnounceTest__testAnnounceDeleteEntryComment__1.json",
    "content": "{\n    \"@context\": [\n        \"https://www.w3.org/ns/activitystreams\",\n        \"https://w3id.org/security/v1\",\n        \"https://kbin.test/contexts\"\n    ],\n    \"id\": \"SCRUBBED_ID\",\n    \"type\": \"Announce\",\n    \"actor\": \"https://kbin.test/m/test\",\n    \"object\": {\n        \"id\": \"SCRUBBED_ID\",\n        \"type\": \"Delete\",\n        \"actor\": \"https://kbin.test/u/user\",\n        \"object\": {\n            \"id\": \"SCRUBBED_ID\",\n            \"type\": \"Tombstone\"\n        },\n        \"to\": [\n            \"https://www.w3.org/ns/activitystreams#Public\",\n            \"https://kbin.test/u/user\"\n        ],\n        \"cc\": [\n            \"https://kbin.test/m/test\",\n            \"https://kbin.test/u/user/followers\"\n        ]\n    },\n    \"to\": [\n        \"https://www.w3.org/ns/activitystreams#Public\"\n    ],\n    \"cc\": [\n        \"https://kbin.test/m/test/followers\",\n        \"https://kbin.test/u/user/followers\"\n    ],\n    \"published\": \"SCRUBBED_DATE\",\n    \"audience\": \"https://kbin.test/m/test\"\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/JsonSnapshots/AnnounceTest__testAnnounceDeleteEntry__1.json",
    "content": "{\n    \"@context\": [\n        \"https://www.w3.org/ns/activitystreams\",\n        \"https://w3id.org/security/v1\",\n        \"https://kbin.test/contexts\"\n    ],\n    \"id\": \"SCRUBBED_ID\",\n    \"type\": \"Announce\",\n    \"actor\": \"https://kbin.test/m/test\",\n    \"object\": {\n        \"id\": \"SCRUBBED_ID\",\n        \"type\": \"Delete\",\n        \"actor\": \"https://kbin.test/u/user\",\n        \"object\": {\n            \"id\": \"SCRUBBED_ID\",\n            \"type\": \"Tombstone\"\n        },\n        \"to\": [\n            \"https://kbin.test/m/test\",\n            \"https://www.w3.org/ns/activitystreams#Public\"\n        ],\n        \"cc\": [\n            \"https://kbin.test/u/user/followers\"\n        ]\n    },\n    \"to\": [\n        \"https://www.w3.org/ns/activitystreams#Public\"\n    ],\n    \"cc\": [\n        \"https://kbin.test/m/test/followers\",\n        \"https://kbin.test/u/user/followers\"\n    ],\n    \"published\": \"SCRUBBED_DATE\",\n    \"audience\": \"https://kbin.test/m/test\"\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/JsonSnapshots/AnnounceTest__testAnnounceDeletePostByModerator__1.json",
    "content": "{\n    \"@context\": [\n        \"https://www.w3.org/ns/activitystreams\",\n        \"https://w3id.org/security/v1\",\n        \"https://kbin.test/contexts\"\n    ],\n    \"id\": \"SCRUBBED_ID\",\n    \"type\": \"Announce\",\n    \"actor\": \"https://kbin.test/m/test\",\n    \"object\": {\n        \"id\": \"SCRUBBED_ID\",\n        \"type\": \"Delete\",\n        \"actor\": \"https://kbin.test/u/owner\",\n        \"object\": {\n            \"id\": \"SCRUBBED_ID\",\n            \"type\": \"Tombstone\"\n        },\n        \"to\": [\n            \"https://kbin.test/m/test\",\n            \"https://www.w3.org/ns/activitystreams#Public\"\n        ],\n        \"cc\": [\n            \"https://kbin.test/u/user/followers\"\n        ],\n        \"summary\": \" \"\n    },\n    \"to\": [\n        \"https://www.w3.org/ns/activitystreams#Public\"\n    ],\n    \"cc\": [\n        \"https://kbin.test/m/test/followers\",\n        \"https://kbin.test/u/user/followers\"\n    ],\n    \"published\": \"SCRUBBED_DATE\",\n    \"audience\": \"https://kbin.test/m/test\"\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/JsonSnapshots/AnnounceTest__testAnnounceDeletePostCommentByModerator__1.json",
    "content": "{\n    \"@context\": [\n        \"https://www.w3.org/ns/activitystreams\",\n        \"https://w3id.org/security/v1\",\n        \"https://kbin.test/contexts\"\n    ],\n    \"id\": \"SCRUBBED_ID\",\n    \"type\": \"Announce\",\n    \"actor\": \"https://kbin.test/m/test\",\n    \"object\": {\n        \"id\": \"SCRUBBED_ID\",\n        \"type\": \"Delete\",\n        \"actor\": \"https://kbin.test/u/owner\",\n        \"object\": {\n            \"id\": \"SCRUBBED_ID\",\n            \"type\": \"Tombstone\"\n        },\n        \"to\": [\n            \"https://www.w3.org/ns/activitystreams#Public\",\n            \"https://kbin.test/u/user\"\n        ],\n        \"cc\": [\n            \"https://kbin.test/m/test\",\n            \"https://kbin.test/u/user/followers\"\n        ],\n        \"summary\": \" \"\n    },\n    \"to\": [\n        \"https://www.w3.org/ns/activitystreams#Public\"\n    ],\n    \"cc\": [\n        \"https://kbin.test/m/test/followers\",\n        \"https://kbin.test/u/user/followers\"\n    ],\n    \"published\": \"SCRUBBED_DATE\",\n    \"audience\": \"https://kbin.test/m/test\"\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/JsonSnapshots/AnnounceTest__testAnnounceDeletePostComment__1.json",
    "content": "{\n    \"@context\": [\n        \"https://www.w3.org/ns/activitystreams\",\n        \"https://w3id.org/security/v1\",\n        \"https://kbin.test/contexts\"\n    ],\n    \"id\": \"SCRUBBED_ID\",\n    \"type\": \"Announce\",\n    \"actor\": \"https://kbin.test/m/test\",\n    \"object\": {\n        \"id\": \"SCRUBBED_ID\",\n        \"type\": \"Delete\",\n        \"actor\": \"https://kbin.test/u/user\",\n        \"object\": {\n            \"id\": \"SCRUBBED_ID\",\n            \"type\": \"Tombstone\"\n        },\n        \"to\": [\n            \"https://www.w3.org/ns/activitystreams#Public\",\n            \"https://kbin.test/u/user\"\n        ],\n        \"cc\": [\n            \"https://kbin.test/m/test\",\n            \"https://kbin.test/u/user/followers\"\n        ]\n    },\n    \"to\": [\n        \"https://www.w3.org/ns/activitystreams#Public\"\n    ],\n    \"cc\": [\n        \"https://kbin.test/m/test/followers\",\n        \"https://kbin.test/u/user/followers\"\n    ],\n    \"published\": \"SCRUBBED_DATE\",\n    \"audience\": \"https://kbin.test/m/test\"\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/JsonSnapshots/AnnounceTest__testAnnounceDeletePost__1.json",
    "content": "{\n    \"@context\": [\n        \"https://www.w3.org/ns/activitystreams\",\n        \"https://w3id.org/security/v1\",\n        \"https://kbin.test/contexts\"\n    ],\n    \"id\": \"SCRUBBED_ID\",\n    \"type\": \"Announce\",\n    \"actor\": \"https://kbin.test/m/test\",\n    \"object\": {\n        \"id\": \"SCRUBBED_ID\",\n        \"type\": \"Delete\",\n        \"actor\": \"https://kbin.test/u/user\",\n        \"object\": {\n            \"id\": \"SCRUBBED_ID\",\n            \"type\": \"Tombstone\"\n        },\n        \"to\": [\n            \"https://kbin.test/m/test\",\n            \"https://www.w3.org/ns/activitystreams#Public\"\n        ],\n        \"cc\": [\n            \"https://kbin.test/u/user/followers\"\n        ]\n    },\n    \"to\": [\n        \"https://www.w3.org/ns/activitystreams#Public\"\n    ],\n    \"cc\": [\n        \"https://kbin.test/m/test/followers\",\n        \"https://kbin.test/u/user/followers\"\n    ],\n    \"published\": \"SCRUBBED_DATE\",\n    \"audience\": \"https://kbin.test/m/test\"\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/JsonSnapshots/AnnounceTest__testAnnounceDeleteUser__1.json",
    "content": "{\n    \"@context\": [\n        \"https://www.w3.org/ns/activitystreams\",\n        \"https://w3id.org/security/v1\",\n        \"https://kbin.test/contexts\"\n    ],\n    \"id\": \"SCRUBBED_ID\",\n    \"type\": \"Announce\",\n    \"actor\": \"https://kbin.test/m/test\",\n    \"object\": {\n        \"id\": \"SCRUBBED_ID\",\n        \"type\": \"Delete\",\n        \"actor\": \"https://kbin.test/u/user\",\n        \"object\": \"SCRUBBED_ID\",\n        \"to\": [\n            \"https://www.w3.org/ns/activitystreams#Public\"\n        ],\n        \"cc\": [\n            \"https://kbin.test/u/user/followers\"\n        ],\n        \"removeData\": true\n    },\n    \"to\": [\n        \"https://www.w3.org/ns/activitystreams#Public\"\n    ],\n    \"cc\": [\n        \"https://kbin.test/m/test/followers\",\n        \"https://kbin.test/u/user/followers\"\n    ],\n    \"published\": \"SCRUBBED_DATE\",\n    \"audience\": \"https://kbin.test/m/test\"\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/JsonSnapshots/AnnounceTest__testAnnounceLikeEntryComment__1.json",
    "content": "{\n    \"@context\": [\n        \"https://www.w3.org/ns/activitystreams\",\n        \"https://w3id.org/security/v1\",\n        \"https://kbin.test/contexts\"\n    ],\n    \"id\": \"SCRUBBED_ID\",\n    \"type\": \"Announce\",\n    \"actor\": \"https://kbin.test/m/test\",\n    \"object\": {\n        \"id\": \"SCRUBBED_ID\",\n        \"type\": \"Like\",\n        \"actor\": \"https://kbin.test/u/user2\",\n        \"to\": [\n            \"https://www.w3.org/ns/activitystreams#Public\"\n        ],\n        \"cc\": [\n            \"https://kbin.test/u/user2/followers\",\n            \"https://kbin.test/m/test\"\n        ],\n        \"object\": \"SCRUBBED_ID\",\n        \"audience\": \"https://kbin.test/m/test\"\n    },\n    \"to\": [\n        \"https://www.w3.org/ns/activitystreams#Public\"\n    ],\n    \"cc\": [\n        \"https://kbin.test/m/test/followers\",\n        \"https://kbin.test/u/user2/followers\"\n    ],\n    \"published\": \"SCRUBBED_DATE\",\n    \"audience\": \"https://kbin.test/m/test\"\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/JsonSnapshots/AnnounceTest__testAnnounceLikeEntry__1.json",
    "content": "{\n    \"@context\": [\n        \"https://www.w3.org/ns/activitystreams\",\n        \"https://w3id.org/security/v1\",\n        \"https://kbin.test/contexts\"\n    ],\n    \"id\": \"SCRUBBED_ID\",\n    \"type\": \"Announce\",\n    \"actor\": \"https://kbin.test/m/test\",\n    \"object\": {\n        \"id\": \"SCRUBBED_ID\",\n        \"type\": \"Like\",\n        \"actor\": \"https://kbin.test/u/user2\",\n        \"to\": [\n            \"https://www.w3.org/ns/activitystreams#Public\"\n        ],\n        \"cc\": [\n            \"https://kbin.test/u/user2/followers\",\n            \"https://kbin.test/m/test\"\n        ],\n        \"object\": \"SCRUBBED_ID\",\n        \"audience\": \"https://kbin.test/m/test\"\n    },\n    \"to\": [\n        \"https://www.w3.org/ns/activitystreams#Public\"\n    ],\n    \"cc\": [\n        \"https://kbin.test/m/test/followers\",\n        \"https://kbin.test/u/user2/followers\"\n    ],\n    \"published\": \"SCRUBBED_DATE\",\n    \"audience\": \"https://kbin.test/m/test\"\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/JsonSnapshots/AnnounceTest__testAnnounceLikeNestedEntryComment__1.json",
    "content": "{\n    \"@context\": [\n        \"https://www.w3.org/ns/activitystreams\",\n        \"https://w3id.org/security/v1\",\n        \"https://kbin.test/contexts\"\n    ],\n    \"id\": \"SCRUBBED_ID\",\n    \"type\": \"Announce\",\n    \"actor\": \"https://kbin.test/m/test\",\n    \"object\": {\n        \"id\": \"SCRUBBED_ID\",\n        \"type\": \"Like\",\n        \"actor\": \"https://kbin.test/u/user2\",\n        \"to\": [\n            \"https://www.w3.org/ns/activitystreams#Public\"\n        ],\n        \"cc\": [\n            \"https://kbin.test/u/user2/followers\",\n            \"https://kbin.test/m/test\"\n        ],\n        \"object\": \"SCRUBBED_ID\",\n        \"audience\": \"https://kbin.test/m/test\"\n    },\n    \"to\": [\n        \"https://www.w3.org/ns/activitystreams#Public\"\n    ],\n    \"cc\": [\n        \"https://kbin.test/m/test/followers\",\n        \"https://kbin.test/u/user2/followers\"\n    ],\n    \"published\": \"SCRUBBED_DATE\",\n    \"audience\": \"https://kbin.test/m/test\"\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/JsonSnapshots/AnnounceTest__testAnnounceLikeNestedPostComment__1.json",
    "content": "{\n    \"@context\": [\n        \"https://www.w3.org/ns/activitystreams\",\n        \"https://w3id.org/security/v1\",\n        \"https://kbin.test/contexts\"\n    ],\n    \"id\": \"SCRUBBED_ID\",\n    \"type\": \"Announce\",\n    \"actor\": \"https://kbin.test/m/test\",\n    \"object\": {\n        \"id\": \"SCRUBBED_ID\",\n        \"type\": \"Like\",\n        \"actor\": \"https://kbin.test/u/user2\",\n        \"to\": [\n            \"https://www.w3.org/ns/activitystreams#Public\"\n        ],\n        \"cc\": [\n            \"https://kbin.test/u/user2/followers\",\n            \"https://kbin.test/m/test\"\n        ],\n        \"object\": \"SCRUBBED_ID\",\n        \"audience\": \"https://kbin.test/m/test\"\n    },\n    \"to\": [\n        \"https://www.w3.org/ns/activitystreams#Public\"\n    ],\n    \"cc\": [\n        \"https://kbin.test/m/test/followers\",\n        \"https://kbin.test/u/user2/followers\"\n    ],\n    \"published\": \"SCRUBBED_DATE\",\n    \"audience\": \"https://kbin.test/m/test\"\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/JsonSnapshots/AnnounceTest__testAnnounceLikePostComment__1.json",
    "content": "{\n    \"@context\": [\n        \"https://www.w3.org/ns/activitystreams\",\n        \"https://w3id.org/security/v1\",\n        \"https://kbin.test/contexts\"\n    ],\n    \"id\": \"SCRUBBED_ID\",\n    \"type\": \"Announce\",\n    \"actor\": \"https://kbin.test/m/test\",\n    \"object\": {\n        \"id\": \"SCRUBBED_ID\",\n        \"type\": \"Like\",\n        \"actor\": \"https://kbin.test/u/user2\",\n        \"to\": [\n            \"https://www.w3.org/ns/activitystreams#Public\"\n        ],\n        \"cc\": [\n            \"https://kbin.test/u/user2/followers\",\n            \"https://kbin.test/m/test\"\n        ],\n        \"object\": \"SCRUBBED_ID\",\n        \"audience\": \"https://kbin.test/m/test\"\n    },\n    \"to\": [\n        \"https://www.w3.org/ns/activitystreams#Public\"\n    ],\n    \"cc\": [\n        \"https://kbin.test/m/test/followers\",\n        \"https://kbin.test/u/user2/followers\"\n    ],\n    \"published\": \"SCRUBBED_DATE\",\n    \"audience\": \"https://kbin.test/m/test\"\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/JsonSnapshots/AnnounceTest__testAnnounceLikePost__1.json",
    "content": "{\n    \"@context\": [\n        \"https://www.w3.org/ns/activitystreams\",\n        \"https://w3id.org/security/v1\",\n        \"https://kbin.test/contexts\"\n    ],\n    \"id\": \"SCRUBBED_ID\",\n    \"type\": \"Announce\",\n    \"actor\": \"https://kbin.test/m/test\",\n    \"object\": {\n        \"id\": \"SCRUBBED_ID\",\n        \"type\": \"Like\",\n        \"actor\": \"https://kbin.test/u/user2\",\n        \"to\": [\n            \"https://www.w3.org/ns/activitystreams#Public\"\n        ],\n        \"cc\": [\n            \"https://kbin.test/u/user2/followers\",\n            \"https://kbin.test/m/test\"\n        ],\n        \"object\": \"SCRUBBED_ID\",\n        \"audience\": \"https://kbin.test/m/test\"\n    },\n    \"to\": [\n        \"https://www.w3.org/ns/activitystreams#Public\"\n    ],\n    \"cc\": [\n        \"https://kbin.test/m/test/followers\",\n        \"https://kbin.test/u/user2/followers\"\n    ],\n    \"published\": \"SCRUBBED_DATE\",\n    \"audience\": \"https://kbin.test/m/test\"\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/JsonSnapshots/AnnounceTest__testAnnounceRemoveModerator__1.json",
    "content": "{\n    \"@context\": [\n        \"https://www.w3.org/ns/activitystreams\",\n        \"https://w3id.org/security/v1\",\n        \"https://kbin.test/contexts\"\n    ],\n    \"id\": \"SCRUBBED_ID\",\n    \"type\": \"Announce\",\n    \"actor\": \"https://kbin.test/m/test\",\n    \"object\": {\n        \"id\": \"SCRUBBED_ID\",\n        \"actor\": \"https://kbin.test/u/owner\",\n        \"to\": [\n            \"https://www.w3.org/ns/activitystreams#Public\"\n        ],\n        \"object\": \"SCRUBBED_ID\",\n        \"cc\": [\n            \"https://kbin.test/m/test\"\n        ],\n        \"type\": \"Remove\",\n        \"target\": \"https://kbin.test/m/test/moderators\",\n        \"audience\": \"https://kbin.test/m/test\"\n    },\n    \"to\": [\n        \"https://www.w3.org/ns/activitystreams#Public\"\n    ],\n    \"cc\": [\n        \"https://kbin.test/m/test/followers\"\n    ],\n    \"published\": \"SCRUBBED_DATE\",\n    \"audience\": \"https://kbin.test/m/test\"\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/JsonSnapshots/AnnounceTest__testAnnounceRemovePinnedPost__1.json",
    "content": "{\n    \"@context\": [\n        \"https://www.w3.org/ns/activitystreams\",\n        \"https://w3id.org/security/v1\",\n        \"https://kbin.test/contexts\"\n    ],\n    \"id\": \"SCRUBBED_ID\",\n    \"type\": \"Announce\",\n    \"actor\": \"https://kbin.test/m/test\",\n    \"object\": {\n        \"id\": \"SCRUBBED_ID\",\n        \"actor\": \"https://kbin.test/u/owner\",\n        \"to\": [\n            \"https://www.w3.org/ns/activitystreams#Public\"\n        ],\n        \"object\": \"SCRUBBED_ID\",\n        \"cc\": [\n            \"https://kbin.test/m/test\"\n        ],\n        \"type\": \"Remove\",\n        \"target\": \"https://kbin.test/m/test/pinned\",\n        \"audience\": \"https://kbin.test/m/test\"\n    },\n    \"to\": [\n        \"https://www.w3.org/ns/activitystreams#Public\"\n    ],\n    \"cc\": [\n        \"https://kbin.test/m/test/followers\"\n    ],\n    \"published\": \"SCRUBBED_DATE\",\n    \"audience\": \"https://kbin.test/m/test\"\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/JsonSnapshots/AnnounceTest__testAnnounceUndoBlockUser__1.json",
    "content": "{\n    \"@context\": [\n        \"https://www.w3.org/ns/activitystreams\",\n        \"https://w3id.org/security/v1\",\n        \"https://kbin.test/contexts\"\n    ],\n    \"id\": \"SCRUBBED_ID\",\n    \"type\": \"Announce\",\n    \"actor\": \"https://kbin.test/m/test\",\n    \"object\": {\n        \"id\": \"SCRUBBED_ID\",\n        \"type\": \"Undo\",\n        \"actor\": \"https://kbin.test/u/owner\",\n        \"object\": {\n            \"id\": \"SCRUBBED_ID\",\n            \"type\": \"Block\",\n            \"actor\": \"https://kbin.test/u/owner\",\n            \"object\": \"SCRUBBED_ID\",\n            \"target\": \"https://kbin.test/m/test\",\n            \"summary\": \"some test\",\n            \"audience\": \"https://kbin.test/m/test\",\n            \"expires\": null,\n            \"to\": [\n                \"https://www.w3.org/ns/activitystreams#Public\"\n            ],\n            \"cc\": [\n                \"https://kbin.test/m/test\"\n            ]\n        },\n        \"to\": [\n            \"https://www.w3.org/ns/activitystreams#Public\"\n        ],\n        \"cc\": [\n            \"https://kbin.test/m/test\"\n        ],\n        \"audience\": \"https://kbin.test/m/test\"\n    },\n    \"to\": [\n        \"https://www.w3.org/ns/activitystreams#Public\"\n    ],\n    \"cc\": [\n        \"https://kbin.test/m/test/followers\"\n    ],\n    \"published\": \"SCRUBBED_DATE\",\n    \"audience\": \"https://kbin.test/m/test\"\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/JsonSnapshots/AnnounceTest__testAnnounceUndoLikeEntryComment__1.json",
    "content": "{\n    \"@context\": [\n        \"https://www.w3.org/ns/activitystreams\",\n        \"https://w3id.org/security/v1\",\n        \"https://kbin.test/contexts\"\n    ],\n    \"id\": \"SCRUBBED_ID\",\n    \"type\": \"Announce\",\n    \"actor\": \"https://kbin.test/m/test\",\n    \"object\": {\n        \"id\": \"SCRUBBED_ID\",\n        \"type\": \"Undo\",\n        \"actor\": \"https://kbin.test/u/user2\",\n        \"object\": {\n            \"id\": \"SCRUBBED_ID\",\n            \"type\": \"Like\",\n            \"actor\": \"https://kbin.test/u/user2\",\n            \"to\": [\n                \"https://www.w3.org/ns/activitystreams#Public\"\n            ],\n            \"cc\": [\n                \"https://kbin.test/u/user2/followers\",\n                \"https://kbin.test/m/test\"\n            ],\n            \"object\": \"SCRUBBED_ID\",\n            \"audience\": \"https://kbin.test/m/test\"\n        },\n        \"to\": [\n            \"https://www.w3.org/ns/activitystreams#Public\"\n        ],\n        \"cc\": [\n            \"https://kbin.test/u/user2/followers\",\n            \"https://kbin.test/m/test\"\n        ],\n        \"audience\": \"https://kbin.test/m/test\"\n    },\n    \"to\": [\n        \"https://www.w3.org/ns/activitystreams#Public\"\n    ],\n    \"cc\": [\n        \"https://kbin.test/m/test/followers\",\n        \"https://kbin.test/u/user2/followers\"\n    ],\n    \"published\": \"SCRUBBED_DATE\",\n    \"audience\": \"https://kbin.test/m/test\"\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/JsonSnapshots/AnnounceTest__testAnnounceUndoLikeEntry__1.json",
    "content": "{\n    \"@context\": [\n        \"https://www.w3.org/ns/activitystreams\",\n        \"https://w3id.org/security/v1\",\n        \"https://kbin.test/contexts\"\n    ],\n    \"id\": \"SCRUBBED_ID\",\n    \"type\": \"Announce\",\n    \"actor\": \"https://kbin.test/m/test\",\n    \"object\": {\n        \"id\": \"SCRUBBED_ID\",\n        \"type\": \"Undo\",\n        \"actor\": \"https://kbin.test/u/user2\",\n        \"object\": {\n            \"id\": \"SCRUBBED_ID\",\n            \"type\": \"Like\",\n            \"actor\": \"https://kbin.test/u/user2\",\n            \"to\": [\n                \"https://www.w3.org/ns/activitystreams#Public\"\n            ],\n            \"cc\": [\n                \"https://kbin.test/u/user2/followers\",\n                \"https://kbin.test/m/test\"\n            ],\n            \"object\": \"SCRUBBED_ID\",\n            \"audience\": \"https://kbin.test/m/test\"\n        },\n        \"to\": [\n            \"https://www.w3.org/ns/activitystreams#Public\"\n        ],\n        \"cc\": [\n            \"https://kbin.test/u/user2/followers\",\n            \"https://kbin.test/m/test\"\n        ],\n        \"audience\": \"https://kbin.test/m/test\"\n    },\n    \"to\": [\n        \"https://www.w3.org/ns/activitystreams#Public\"\n    ],\n    \"cc\": [\n        \"https://kbin.test/m/test/followers\",\n        \"https://kbin.test/u/user2/followers\"\n    ],\n    \"published\": \"SCRUBBED_DATE\",\n    \"audience\": \"https://kbin.test/m/test\"\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/JsonSnapshots/AnnounceTest__testAnnounceUndoLikeNestedEntryComment__1.json",
    "content": "{\n    \"@context\": [\n        \"https://www.w3.org/ns/activitystreams\",\n        \"https://w3id.org/security/v1\",\n        \"https://kbin.test/contexts\"\n    ],\n    \"id\": \"SCRUBBED_ID\",\n    \"type\": \"Announce\",\n    \"actor\": \"https://kbin.test/m/test\",\n    \"object\": {\n        \"id\": \"SCRUBBED_ID\",\n        \"type\": \"Undo\",\n        \"actor\": \"https://kbin.test/u/user2\",\n        \"object\": {\n            \"id\": \"SCRUBBED_ID\",\n            \"type\": \"Like\",\n            \"actor\": \"https://kbin.test/u/user2\",\n            \"to\": [\n                \"https://www.w3.org/ns/activitystreams#Public\"\n            ],\n            \"cc\": [\n                \"https://kbin.test/u/user2/followers\",\n                \"https://kbin.test/m/test\"\n            ],\n            \"object\": \"SCRUBBED_ID\",\n            \"audience\": \"https://kbin.test/m/test\"\n        },\n        \"to\": [\n            \"https://www.w3.org/ns/activitystreams#Public\"\n        ],\n        \"cc\": [\n            \"https://kbin.test/u/user2/followers\",\n            \"https://kbin.test/m/test\"\n        ],\n        \"audience\": \"https://kbin.test/m/test\"\n    },\n    \"to\": [\n        \"https://www.w3.org/ns/activitystreams#Public\"\n    ],\n    \"cc\": [\n        \"https://kbin.test/m/test/followers\",\n        \"https://kbin.test/u/user2/followers\"\n    ],\n    \"published\": \"SCRUBBED_DATE\",\n    \"audience\": \"https://kbin.test/m/test\"\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/JsonSnapshots/AnnounceTest__testAnnounceUndoLikeNestedPostComment__1.json",
    "content": "{\n    \"@context\": [\n        \"https://www.w3.org/ns/activitystreams\",\n        \"https://w3id.org/security/v1\",\n        \"https://kbin.test/contexts\"\n    ],\n    \"id\": \"SCRUBBED_ID\",\n    \"type\": \"Announce\",\n    \"actor\": \"https://kbin.test/m/test\",\n    \"object\": {\n        \"id\": \"SCRUBBED_ID\",\n        \"type\": \"Undo\",\n        \"actor\": \"https://kbin.test/u/user2\",\n        \"object\": {\n            \"id\": \"SCRUBBED_ID\",\n            \"type\": \"Like\",\n            \"actor\": \"https://kbin.test/u/user2\",\n            \"to\": [\n                \"https://www.w3.org/ns/activitystreams#Public\"\n            ],\n            \"cc\": [\n                \"https://kbin.test/u/user2/followers\",\n                \"https://kbin.test/m/test\"\n            ],\n            \"object\": \"SCRUBBED_ID\",\n            \"audience\": \"https://kbin.test/m/test\"\n        },\n        \"to\": [\n            \"https://www.w3.org/ns/activitystreams#Public\"\n        ],\n        \"cc\": [\n            \"https://kbin.test/u/user2/followers\",\n            \"https://kbin.test/m/test\"\n        ],\n        \"audience\": \"https://kbin.test/m/test\"\n    },\n    \"to\": [\n        \"https://www.w3.org/ns/activitystreams#Public\"\n    ],\n    \"cc\": [\n        \"https://kbin.test/m/test/followers\",\n        \"https://kbin.test/u/user2/followers\"\n    ],\n    \"published\": \"SCRUBBED_DATE\",\n    \"audience\": \"https://kbin.test/m/test\"\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/JsonSnapshots/AnnounceTest__testAnnounceUndoLikePostComment__1.json",
    "content": "{\n    \"@context\": [\n        \"https://www.w3.org/ns/activitystreams\",\n        \"https://w3id.org/security/v1\",\n        \"https://kbin.test/contexts\"\n    ],\n    \"id\": \"SCRUBBED_ID\",\n    \"type\": \"Announce\",\n    \"actor\": \"https://kbin.test/m/test\",\n    \"object\": {\n        \"id\": \"SCRUBBED_ID\",\n        \"type\": \"Undo\",\n        \"actor\": \"https://kbin.test/u/user2\",\n        \"object\": {\n            \"id\": \"SCRUBBED_ID\",\n            \"type\": \"Like\",\n            \"actor\": \"https://kbin.test/u/user2\",\n            \"to\": [\n                \"https://www.w3.org/ns/activitystreams#Public\"\n            ],\n            \"cc\": [\n                \"https://kbin.test/u/user2/followers\",\n                \"https://kbin.test/m/test\"\n            ],\n            \"object\": \"SCRUBBED_ID\",\n            \"audience\": \"https://kbin.test/m/test\"\n        },\n        \"to\": [\n            \"https://www.w3.org/ns/activitystreams#Public\"\n        ],\n        \"cc\": [\n            \"https://kbin.test/u/user2/followers\",\n            \"https://kbin.test/m/test\"\n        ],\n        \"audience\": \"https://kbin.test/m/test\"\n    },\n    \"to\": [\n        \"https://www.w3.org/ns/activitystreams#Public\"\n    ],\n    \"cc\": [\n        \"https://kbin.test/m/test/followers\",\n        \"https://kbin.test/u/user2/followers\"\n    ],\n    \"published\": \"SCRUBBED_DATE\",\n    \"audience\": \"https://kbin.test/m/test\"\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/JsonSnapshots/AnnounceTest__testAnnounceUndoLikePost__1.json",
    "content": "{\n    \"@context\": [\n        \"https://www.w3.org/ns/activitystreams\",\n        \"https://w3id.org/security/v1\",\n        \"https://kbin.test/contexts\"\n    ],\n    \"id\": \"SCRUBBED_ID\",\n    \"type\": \"Announce\",\n    \"actor\": \"https://kbin.test/m/test\",\n    \"object\": {\n        \"id\": \"SCRUBBED_ID\",\n        \"type\": \"Undo\",\n        \"actor\": \"https://kbin.test/u/user2\",\n        \"object\": {\n            \"id\": \"SCRUBBED_ID\",\n            \"type\": \"Like\",\n            \"actor\": \"https://kbin.test/u/user2\",\n            \"to\": [\n                \"https://www.w3.org/ns/activitystreams#Public\"\n            ],\n            \"cc\": [\n                \"https://kbin.test/u/user2/followers\",\n                \"https://kbin.test/m/test\"\n            ],\n            \"object\": \"SCRUBBED_ID\",\n            \"audience\": \"https://kbin.test/m/test\"\n        },\n        \"to\": [\n            \"https://www.w3.org/ns/activitystreams#Public\"\n        ],\n        \"cc\": [\n            \"https://kbin.test/u/user2/followers\",\n            \"https://kbin.test/m/test\"\n        ],\n        \"audience\": \"https://kbin.test/m/test\"\n    },\n    \"to\": [\n        \"https://www.w3.org/ns/activitystreams#Public\"\n    ],\n    \"cc\": [\n        \"https://kbin.test/m/test/followers\",\n        \"https://kbin.test/u/user2/followers\"\n    ],\n    \"published\": \"SCRUBBED_DATE\",\n    \"audience\": \"https://kbin.test/m/test\"\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/JsonSnapshots/AnnounceTest__testAnnounceUpdateEntryComment__1.json",
    "content": "{\n    \"@context\": [\n        \"https://www.w3.org/ns/activitystreams\",\n        \"https://w3id.org/security/v1\",\n        \"https://kbin.test/contexts\"\n    ],\n    \"id\": \"SCRUBBED_ID\",\n    \"type\": \"Announce\",\n    \"actor\": \"https://kbin.test/m/test\",\n    \"object\": {\n        \"id\": \"SCRUBBED_ID\",\n        \"type\": \"Update\",\n        \"actor\": \"https://kbin.test/u/user\",\n        \"published\": \"SCRUBBED_DATE\",\n        \"to\": [\n            \"https://www.w3.org/ns/activitystreams#Public\",\n            \"https://kbin.test/u/user\"\n        ],\n        \"cc\": [\n            \"https://kbin.test/m/test\",\n            \"https://kbin.test/u/user/followers\"\n        ],\n        \"object\": {\n            \"id\": \"SCRUBBED_ID\",\n            \"type\": \"Note\",\n            \"attributedTo\": \"https://kbin.test/u/user\",\n            \"inReplyTo\": \"SCRUBBED_ID\",\n            \"to\": [\n                \"https://www.w3.org/ns/activitystreams#Public\",\n                \"https://kbin.test/u/user\"\n            ],\n            \"cc\": [\n                \"https://kbin.test/m/test\",\n                \"https://kbin.test/u/user/followers\"\n            ],\n            \"audience\": \"https://kbin.test/m/test\",\n            \"sensitive\": false,\n            \"content\": \"<p>test</p>\\n\",\n            \"mediaType\": \"text/html\",\n            \"source\": {\n                \"content\": \"test\",\n                \"mediaType\": \"text/markdown\"\n            },\n            \"url\": \"SCRUBBED_ID\",\n            \"tag\": [\n                {\n                    \"type\": \"Hashtag\",\n                    \"href\": \"https://kbin.test/tag/test\",\n                    \"name\": \"#test\"\n                }\n            ],\n            \"published\": \"SCRUBBED_DATE\",\n            \"contentMap\": {\n                \"en\": \"<p>test</p>\\n\"\n            },\n            \"object\": {\n                \"updated\": \"SCRUBBED_DATE\"\n            }\n        },\n        \"audience\": \"https://kbin.test/m/test\"\n    },\n    \"to\": [\n        \"https://www.w3.org/ns/activitystreams#Public\"\n    ],\n    \"cc\": [\n        \"https://kbin.test/m/test/followers\",\n        \"https://kbin.test/u/user/followers\"\n    ],\n    \"published\": \"SCRUBBED_DATE\",\n    \"audience\": \"https://kbin.test/m/test\"\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/JsonSnapshots/AnnounceTest__testAnnounceUpdateEntry__1.json",
    "content": "{\n    \"@context\": [\n        \"https://www.w3.org/ns/activitystreams\",\n        \"https://w3id.org/security/v1\",\n        \"https://kbin.test/contexts\"\n    ],\n    \"id\": \"SCRUBBED_ID\",\n    \"type\": \"Announce\",\n    \"actor\": \"https://kbin.test/m/test\",\n    \"object\": {\n        \"id\": \"SCRUBBED_ID\",\n        \"type\": \"Update\",\n        \"actor\": \"https://kbin.test/u/user\",\n        \"published\": \"SCRUBBED_DATE\",\n        \"to\": [\n            \"https://kbin.test/m/test\",\n            \"https://www.w3.org/ns/activitystreams#Public\"\n        ],\n        \"cc\": [\n            \"https://kbin.test/u/user/followers\"\n        ],\n        \"object\": {\n            \"id\": \"SCRUBBED_ID\",\n            \"type\": \"Page\",\n            \"attributedTo\": \"https://kbin.test/u/user\",\n            \"inReplyTo\": null,\n            \"to\": [\n                \"https://kbin.test/m/test\",\n                \"https://www.w3.org/ns/activitystreams#Public\"\n            ],\n            \"cc\": [\n                \"https://kbin.test/u/user/followers\"\n            ],\n            \"name\": \"test\",\n            \"audience\": \"https://kbin.test/m/test\",\n            \"content\": null,\n            \"summary\": \"test #test\",\n            \"mediaType\": \"text/html\",\n            \"source\": null,\n            \"tag\": [\n                {\n                    \"type\": \"Hashtag\",\n                    \"href\": \"https://kbin.test/tag/test\",\n                    \"name\": \"#test\"\n                }\n            ],\n            \"commentsEnabled\": true,\n            \"sensitive\": false,\n            \"stickied\": false,\n            \"published\": \"SCRUBBED_DATE\",\n            \"contentMap\": {\n                \"en\": null\n            },\n            \"object\": {\n                \"updated\": \"SCRUBBED_DATE\"\n            }\n        },\n        \"audience\": \"https://kbin.test/m/test\"\n    },\n    \"to\": [\n        \"https://www.w3.org/ns/activitystreams#Public\"\n    ],\n    \"cc\": [\n        \"https://kbin.test/m/test/followers\",\n        \"https://kbin.test/u/user/followers\"\n    ],\n    \"published\": \"SCRUBBED_DATE\",\n    \"audience\": \"https://kbin.test/m/test\"\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/JsonSnapshots/AnnounceTest__testAnnounceUpdateMagazine__1.json",
    "content": "{\n    \"@context\": [\n        \"https://www.w3.org/ns/activitystreams\",\n        \"https://w3id.org/security/v1\",\n        \"https://kbin.test/contexts\"\n    ],\n    \"id\": \"SCRUBBED_ID\",\n    \"type\": \"Announce\",\n    \"actor\": \"https://kbin.test/m/test\",\n    \"object\": {\n        \"id\": \"SCRUBBED_ID\",\n        \"type\": \"Update\",\n        \"actor\": \"https://kbin.test/u/owner\",\n        \"published\": \"SCRUBBED_DATE\",\n        \"to\": [\n            \"https://www.w3.org/ns/activitystreams#Public\"\n        ],\n        \"cc\": [\n            \"https://kbin.test/m/test/followers\"\n        ],\n        \"object\": {\n            \"type\": \"Group\",\n            \"id\": \"SCRUBBED_ID\",\n            \"name\": \"test\",\n            \"preferredUsername\": \"test\",\n            \"inbox\": \"https://kbin.test/m/test/inbox\",\n            \"outbox\": \"https://kbin.test/m/test/outbox\",\n            \"followers\": \"https://kbin.test/m/test/followers\",\n            \"featured\": \"https://kbin.test/m/test/pinned\",\n            \"url\": \"https://kbin.test/m/test\",\n            \"publicKey\": \"SCRUBBED_KEY\",\n            \"summary\": \"\",\n            \"source\": {\n                \"content\": \"\",\n                \"mediaType\": \"text/markdown\"\n            },\n            \"sensitive\": false,\n            \"attributedTo\": \"https://kbin.test/m/test/moderators\",\n            \"postingRestrictedToMods\": false,\n            \"discoverable\": true,\n            \"indexable\": true,\n            \"endpoints\": {\n                \"sharedInbox\": \"https://kbin.test/f/inbox\"\n            },\n            \"published\": \"SCRUBBED_DATE\",\n            \"updated\": \"SCRUBBED_DATE\"\n        }\n    },\n    \"to\": [\n        \"https://www.w3.org/ns/activitystreams#Public\"\n    ],\n    \"cc\": [\n        \"https://kbin.test/m/test/followers\",\n        \"https://kbin.test/m/test/followers\"\n    ],\n    \"published\": \"SCRUBBED_DATE\",\n    \"audience\": \"https://kbin.test/m/test\"\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/JsonSnapshots/AnnounceTest__testAnnounceUpdatePostComment__1.json",
    "content": "{\n    \"@context\": [\n        \"https://www.w3.org/ns/activitystreams\",\n        \"https://w3id.org/security/v1\",\n        \"https://kbin.test/contexts\"\n    ],\n    \"id\": \"SCRUBBED_ID\",\n    \"type\": \"Announce\",\n    \"actor\": \"https://kbin.test/m/test\",\n    \"object\": {\n        \"id\": \"SCRUBBED_ID\",\n        \"type\": \"Update\",\n        \"actor\": \"https://kbin.test/u/user\",\n        \"published\": \"SCRUBBED_DATE\",\n        \"to\": [\n            \"https://www.w3.org/ns/activitystreams#Public\",\n            \"https://kbin.test/u/user\"\n        ],\n        \"cc\": [\n            \"https://kbin.test/m/test\",\n            \"https://kbin.test/u/user/followers\"\n        ],\n        \"object\": {\n            \"id\": \"SCRUBBED_ID\",\n            \"type\": \"Note\",\n            \"attributedTo\": \"https://kbin.test/u/user\",\n            \"inReplyTo\": \"SCRUBBED_ID\",\n            \"to\": [\n                \"https://www.w3.org/ns/activitystreams#Public\",\n                \"https://kbin.test/u/user\"\n            ],\n            \"cc\": [\n                \"https://kbin.test/m/test\",\n                \"https://kbin.test/u/user/followers\"\n            ],\n            \"audience\": \"https://kbin.test/m/test\",\n            \"sensitive\": false,\n            \"content\": \"<p>test</p>\\n\",\n            \"mediaType\": \"text/html\",\n            \"source\": {\n                \"content\": \"test\",\n                \"mediaType\": \"text/markdown\"\n            },\n            \"url\": \"SCRUBBED_ID\",\n            \"tag\": [\n                {\n                    \"type\": \"Hashtag\",\n                    \"href\": \"https://kbin.test/tag/test\",\n                    \"name\": \"#test\"\n                }\n            ],\n            \"published\": \"SCRUBBED_DATE\",\n            \"contentMap\": {\n                \"en\": \"<p>test</p>\\n\"\n            },\n            \"object\": {\n                \"updated\": \"SCRUBBED_DATE\"\n            }\n        },\n        \"audience\": \"https://kbin.test/m/test\"\n    },\n    \"to\": [\n        \"https://www.w3.org/ns/activitystreams#Public\"\n    ],\n    \"cc\": [\n        \"https://kbin.test/m/test/followers\",\n        \"https://kbin.test/u/user/followers\"\n    ],\n    \"published\": \"SCRUBBED_DATE\",\n    \"audience\": \"https://kbin.test/m/test\"\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/JsonSnapshots/AnnounceTest__testAnnounceUpdatePost__1.json",
    "content": "{\n    \"@context\": [\n        \"https://www.w3.org/ns/activitystreams\",\n        \"https://w3id.org/security/v1\",\n        \"https://kbin.test/contexts\"\n    ],\n    \"id\": \"SCRUBBED_ID\",\n    \"type\": \"Announce\",\n    \"actor\": \"https://kbin.test/m/test\",\n    \"object\": {\n        \"id\": \"SCRUBBED_ID\",\n        \"type\": \"Update\",\n        \"actor\": \"https://kbin.test/u/user\",\n        \"published\": \"SCRUBBED_DATE\",\n        \"to\": [\n            \"https://kbin.test/m/test\",\n            \"https://www.w3.org/ns/activitystreams#Public\"\n        ],\n        \"cc\": [\n            \"https://kbin.test/u/user/followers\"\n        ],\n        \"object\": {\n            \"id\": \"SCRUBBED_ID\",\n            \"type\": \"Note\",\n            \"attributedTo\": \"https://kbin.test/u/user\",\n            \"inReplyTo\": null,\n            \"to\": [\n                \"https://kbin.test/m/test\",\n                \"https://www.w3.org/ns/activitystreams#Public\"\n            ],\n            \"cc\": [\n                \"https://kbin.test/u/user/followers\"\n            ],\n            \"audience\": \"https://kbin.test/m/test\",\n            \"sensitive\": false,\n            \"stickied\": false,\n            \"content\": \"<p>test</p>\\n<p><a href=\\\"https://kbin.test/tag/test\\\">#test</a></p>\\n\",\n            \"mediaType\": \"text/html\",\n            \"source\": {\n                \"content\": \"test\\n\\n #test\",\n                \"mediaType\": \"text/markdown\"\n            },\n            \"url\": \"SCRUBBED_ID\",\n            \"tag\": [\n                {\n                    \"type\": \"Hashtag\",\n                    \"href\": \"https://kbin.test/tag/test\",\n                    \"name\": \"#test\"\n                }\n            ],\n            \"commentsEnabled\": true,\n            \"published\": \"SCRUBBED_DATE\",\n            \"contentMap\": {\n                \"en\": \"<p>test</p>\\n<p><a href=\\\"https://kbin.test/tag/test\\\">#test</a></p>\\n\"\n            },\n            \"object\": {\n                \"updated\": \"SCRUBBED_DATE\"\n            }\n        },\n        \"audience\": \"https://kbin.test/m/test\"\n    },\n    \"to\": [\n        \"https://www.w3.org/ns/activitystreams#Public\"\n    ],\n    \"cc\": [\n        \"https://kbin.test/m/test/followers\",\n        \"https://kbin.test/u/user/followers\"\n    ],\n    \"published\": \"SCRUBBED_DATE\",\n    \"audience\": \"https://kbin.test/m/test\"\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/JsonSnapshots/AnnounceTest__testAnnounceUpdateUser__1.json",
    "content": "{\n    \"@context\": [\n        \"https://www.w3.org/ns/activitystreams\",\n        \"https://w3id.org/security/v1\",\n        \"https://kbin.test/contexts\"\n    ],\n    \"id\": \"SCRUBBED_ID\",\n    \"type\": \"Announce\",\n    \"actor\": \"https://kbin.test/m/test\",\n    \"object\": {\n        \"id\": \"SCRUBBED_ID\",\n        \"type\": \"Update\",\n        \"actor\": \"https://kbin.test/u/user\",\n        \"published\": \"SCRUBBED_DATE\",\n        \"to\": [\n            \"https://www.w3.org/ns/activitystreams#Public\"\n        ],\n        \"cc\": [\n            \"https://kbin.test/u/user/followers\"\n        ],\n        \"object\": {\n            \"id\": \"SCRUBBED_ID\",\n            \"type\": \"Person\",\n            \"name\": \"user\",\n            \"preferredUsername\": \"user\",\n            \"inbox\": \"https://kbin.test/u/user/inbox\",\n            \"outbox\": \"https://kbin.test/u/user/outbox\",\n            \"url\": \"https://kbin.test/u/user\",\n            \"manuallyApprovesFollowers\": false,\n            \"discoverable\": true,\n            \"indexable\": true,\n            \"published\": \"SCRUBBED_DATE\",\n            \"following\": \"https://kbin.test/u/user/following\",\n            \"followers\": \"https://kbin.test/u/user/followers\",\n            \"publicKey\": \"SCRUBBED_KEY\",\n            \"endpoints\": {\n                \"sharedInbox\": \"https://kbin.test/f/inbox\"\n            }\n        }\n    },\n    \"to\": [\n        \"https://www.w3.org/ns/activitystreams#Public\"\n    ],\n    \"cc\": [\n        \"https://kbin.test/m/test/followers\",\n        \"https://kbin.test/u/user/followers\"\n    ],\n    \"published\": \"SCRUBBED_DATE\",\n    \"audience\": \"https://kbin.test/m/test\"\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/JsonSnapshots/AnnounceTest__testMagazineBoostEntryComment__1.json",
    "content": "{\n    \"@context\": [\n        \"https://www.w3.org/ns/activitystreams\",\n        \"https://w3id.org/security/v1\",\n        \"https://kbin.test/contexts\"\n    ],\n    \"id\": \"SCRUBBED_ID\",\n    \"type\": \"Announce\",\n    \"actor\": \"https://kbin.test/m/test\",\n    \"object\": \"SCRUBBED_ID\",\n    \"to\": [\n        \"https://www.w3.org/ns/activitystreams#Public\"\n    ],\n    \"cc\": [\n        \"https://kbin.test/m/test/followers\"\n    ],\n    \"published\": \"SCRUBBED_DATE\",\n    \"audience\": \"https://kbin.test/m/test\"\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/JsonSnapshots/AnnounceTest__testMagazineBoostEntry__1.json",
    "content": "{\n    \"@context\": [\n        \"https://www.w3.org/ns/activitystreams\",\n        \"https://w3id.org/security/v1\",\n        \"https://kbin.test/contexts\"\n    ],\n    \"id\": \"SCRUBBED_ID\",\n    \"type\": \"Announce\",\n    \"actor\": \"https://kbin.test/m/test\",\n    \"object\": \"SCRUBBED_ID\",\n    \"to\": [\n        \"https://www.w3.org/ns/activitystreams#Public\"\n    ],\n    \"cc\": [\n        \"https://kbin.test/m/test/followers\"\n    ],\n    \"published\": \"SCRUBBED_DATE\",\n    \"audience\": \"https://kbin.test/m/test\"\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/JsonSnapshots/AnnounceTest__testMagazineBoostNestedEntryComment__1.json",
    "content": "{\n    \"@context\": [\n        \"https://www.w3.org/ns/activitystreams\",\n        \"https://w3id.org/security/v1\",\n        \"https://kbin.test/contexts\"\n    ],\n    \"id\": \"SCRUBBED_ID\",\n    \"type\": \"Announce\",\n    \"actor\": \"https://kbin.test/m/test\",\n    \"object\": \"SCRUBBED_ID\",\n    \"to\": [\n        \"https://www.w3.org/ns/activitystreams#Public\"\n    ],\n    \"cc\": [\n        \"https://kbin.test/m/test/followers\"\n    ],\n    \"published\": \"SCRUBBED_DATE\",\n    \"audience\": \"https://kbin.test/m/test\"\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/JsonSnapshots/AnnounceTest__testMagazineBoostNestedPostComment__1.json",
    "content": "{\n    \"@context\": [\n        \"https://www.w3.org/ns/activitystreams\",\n        \"https://w3id.org/security/v1\",\n        \"https://kbin.test/contexts\"\n    ],\n    \"id\": \"SCRUBBED_ID\",\n    \"type\": \"Announce\",\n    \"actor\": \"https://kbin.test/m/test\",\n    \"object\": \"SCRUBBED_ID\",\n    \"to\": [\n        \"https://www.w3.org/ns/activitystreams#Public\"\n    ],\n    \"cc\": [\n        \"https://kbin.test/m/test/followers\"\n    ],\n    \"published\": \"SCRUBBED_DATE\",\n    \"audience\": \"https://kbin.test/m/test\"\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/JsonSnapshots/AnnounceTest__testMagazineBoostPostComment__1.json",
    "content": "{\n    \"@context\": [\n        \"https://www.w3.org/ns/activitystreams\",\n        \"https://w3id.org/security/v1\",\n        \"https://kbin.test/contexts\"\n    ],\n    \"id\": \"SCRUBBED_ID\",\n    \"type\": \"Announce\",\n    \"actor\": \"https://kbin.test/m/test\",\n    \"object\": \"SCRUBBED_ID\",\n    \"to\": [\n        \"https://www.w3.org/ns/activitystreams#Public\"\n    ],\n    \"cc\": [\n        \"https://kbin.test/m/test/followers\"\n    ],\n    \"published\": \"SCRUBBED_DATE\",\n    \"audience\": \"https://kbin.test/m/test\"\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/JsonSnapshots/AnnounceTest__testMagazineBoostPost__1.json",
    "content": "{\n    \"@context\": [\n        \"https://www.w3.org/ns/activitystreams\",\n        \"https://w3id.org/security/v1\",\n        \"https://kbin.test/contexts\"\n    ],\n    \"id\": \"SCRUBBED_ID\",\n    \"type\": \"Announce\",\n    \"actor\": \"https://kbin.test/m/test\",\n    \"object\": \"SCRUBBED_ID\",\n    \"to\": [\n        \"https://www.w3.org/ns/activitystreams#Public\"\n    ],\n    \"cc\": [\n        \"https://kbin.test/m/test/followers\"\n    ],\n    \"published\": \"SCRUBBED_DATE\",\n    \"audience\": \"https://kbin.test/m/test\"\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/JsonSnapshots/AnnounceTest__testUserBoostEntryComment__1.json",
    "content": "{\n    \"@context\": [\n        \"https://www.w3.org/ns/activitystreams\",\n        \"https://w3id.org/security/v1\",\n        \"https://kbin.test/contexts\"\n    ],\n    \"id\": \"SCRUBBED_ID\",\n    \"type\": \"Announce\",\n    \"actor\": \"https://kbin.test/u/user\",\n    \"object\": \"SCRUBBED_ID\",\n    \"to\": [\n        \"https://www.w3.org/ns/activitystreams#Public\"\n    ],\n    \"cc\": [\n        \"https://kbin.test/u/user/followers\"\n    ],\n    \"published\": \"SCRUBBED_DATE\"\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/JsonSnapshots/AnnounceTest__testUserBoostEntry__1.json",
    "content": "{\n    \"@context\": [\n        \"https://www.w3.org/ns/activitystreams\",\n        \"https://w3id.org/security/v1\",\n        \"https://kbin.test/contexts\"\n    ],\n    \"id\": \"SCRUBBED_ID\",\n    \"type\": \"Announce\",\n    \"actor\": \"https://kbin.test/u/user\",\n    \"object\": \"SCRUBBED_ID\",\n    \"to\": [\n        \"https://www.w3.org/ns/activitystreams#Public\"\n    ],\n    \"cc\": [\n        \"https://kbin.test/u/user/followers\"\n    ],\n    \"published\": \"SCRUBBED_DATE\"\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/JsonSnapshots/AnnounceTest__testUserBoostNestedEntryComment__1.json",
    "content": "{\n    \"@context\": [\n        \"https://www.w3.org/ns/activitystreams\",\n        \"https://w3id.org/security/v1\",\n        \"https://kbin.test/contexts\"\n    ],\n    \"id\": \"SCRUBBED_ID\",\n    \"type\": \"Announce\",\n    \"actor\": \"https://kbin.test/u/user\",\n    \"object\": \"SCRUBBED_ID\",\n    \"to\": [\n        \"https://www.w3.org/ns/activitystreams#Public\"\n    ],\n    \"cc\": [\n        \"https://kbin.test/u/user/followers\"\n    ],\n    \"published\": \"SCRUBBED_DATE\"\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/JsonSnapshots/AnnounceTest__testUserBoostNestedPostComment__1.json",
    "content": "{\n    \"@context\": [\n        \"https://www.w3.org/ns/activitystreams\",\n        \"https://w3id.org/security/v1\",\n        \"https://kbin.test/contexts\"\n    ],\n    \"id\": \"SCRUBBED_ID\",\n    \"type\": \"Announce\",\n    \"actor\": \"https://kbin.test/u/user\",\n    \"object\": \"SCRUBBED_ID\",\n    \"to\": [\n        \"https://www.w3.org/ns/activitystreams#Public\"\n    ],\n    \"cc\": [\n        \"https://kbin.test/u/user/followers\"\n    ],\n    \"published\": \"SCRUBBED_DATE\"\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/JsonSnapshots/AnnounceTest__testUserBoostPostComment__1.json",
    "content": "{\n    \"@context\": [\n        \"https://www.w3.org/ns/activitystreams\",\n        \"https://w3id.org/security/v1\",\n        \"https://kbin.test/contexts\"\n    ],\n    \"id\": \"SCRUBBED_ID\",\n    \"type\": \"Announce\",\n    \"actor\": \"https://kbin.test/u/user\",\n    \"object\": \"SCRUBBED_ID\",\n    \"to\": [\n        \"https://www.w3.org/ns/activitystreams#Public\"\n    ],\n    \"cc\": [\n        \"https://kbin.test/u/user/followers\"\n    ],\n    \"published\": \"SCRUBBED_DATE\"\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/JsonSnapshots/AnnounceTest__testUserBoostPost__1.json",
    "content": "{\n    \"@context\": [\n        \"https://www.w3.org/ns/activitystreams\",\n        \"https://w3id.org/security/v1\",\n        \"https://kbin.test/contexts\"\n    ],\n    \"id\": \"SCRUBBED_ID\",\n    \"type\": \"Announce\",\n    \"actor\": \"https://kbin.test/u/user\",\n    \"object\": \"SCRUBBED_ID\",\n    \"to\": [\n        \"https://www.w3.org/ns/activitystreams#Public\"\n    ],\n    \"cc\": [\n        \"https://kbin.test/u/user/followers\"\n    ],\n    \"published\": \"SCRUBBED_DATE\"\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/JsonSnapshots/BlockTest__testBlockUser__1.json",
    "content": "{\n    \"@context\": [\n        \"https://www.w3.org/ns/activitystreams\",\n        \"https://w3id.org/security/v1\",\n        \"https://kbin.test/contexts\"\n    ],\n    \"id\": \"SCRUBBED_ID\",\n    \"type\": \"Block\",\n    \"actor\": \"https://kbin.test/u/owner\",\n    \"object\": \"SCRUBBED_ID\",\n    \"target\": \"https://kbin.test/m/test\",\n    \"summary\": \"some test\",\n    \"audience\": \"https://kbin.test/m/test\",\n    \"expires\": null,\n    \"to\": [\n        \"https://www.w3.org/ns/activitystreams#Public\"\n    ],\n    \"cc\": [\n        \"https://kbin.test/m/test\"\n    ]\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/JsonSnapshots/CreateTest__testCreateEntryComment__1.json",
    "content": "{\n    \"@context\": [\n        \"https://www.w3.org/ns/activitystreams\",\n        \"https://w3id.org/security/v1\",\n        \"https://kbin.test/contexts\"\n    ],\n    \"id\": \"SCRUBBED_ID\",\n    \"type\": \"Create\",\n    \"actor\": \"https://kbin.test/u/user\",\n    \"published\": \"SCRUBBED_DATE\",\n    \"to\": [\n        \"https://www.w3.org/ns/activitystreams#Public\",\n        \"https://kbin.test/u/user\"\n    ],\n    \"cc\": [\n        \"https://kbin.test/m/test\",\n        \"https://kbin.test/u/user/followers\"\n    ],\n    \"object\": {\n        \"id\": \"SCRUBBED_ID\",\n        \"type\": \"Note\",\n        \"attributedTo\": \"https://kbin.test/u/user\",\n        \"inReplyTo\": \"SCRUBBED_ID\",\n        \"to\": [\n            \"https://www.w3.org/ns/activitystreams#Public\",\n            \"https://kbin.test/u/user\"\n        ],\n        \"cc\": [\n            \"https://kbin.test/m/test\",\n            \"https://kbin.test/u/user/followers\"\n        ],\n        \"audience\": \"https://kbin.test/m/test\",\n        \"sensitive\": false,\n        \"content\": \"<p>test</p>\\n\",\n        \"mediaType\": \"text/html\",\n        \"source\": {\n            \"content\": \"test\",\n            \"mediaType\": \"text/markdown\"\n        },\n        \"url\": \"SCRUBBED_ID\",\n        \"tag\": [\n            {\n                \"type\": \"Hashtag\",\n                \"href\": \"https://kbin.test/tag/test\",\n                \"name\": \"#test\"\n            }\n        ],\n        \"published\": \"SCRUBBED_DATE\",\n        \"contentMap\": {\n            \"en\": \"<p>test</p>\\n\"\n        }\n    },\n    \"audience\": \"https://kbin.test/m/test\"\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/JsonSnapshots/CreateTest__testCreateEntryWithUrlAndImage__1.json",
    "content": "{\n    \"@context\": [\n        \"https://www.w3.org/ns/activitystreams\",\n        \"https://w3id.org/security/v1\",\n        \"https://kbin.test/contexts\"\n    ],\n    \"id\": \"SCRUBBED_ID\",\n    \"type\": \"Create\",\n    \"actor\": \"https://kbin.test/u/user\",\n    \"published\": \"SCRUBBED_DATE\",\n    \"to\": [\n        \"https://kbin.test/m/test\",\n        \"https://www.w3.org/ns/activitystreams#Public\"\n    ],\n    \"cc\": [\n        \"https://kbin.test/u/user/followers\"\n    ],\n    \"object\": {\n        \"id\": \"SCRUBBED_ID\",\n        \"type\": \"Page\",\n        \"attributedTo\": \"https://kbin.test/u/user\",\n        \"inReplyTo\": null,\n        \"to\": [\n            \"https://kbin.test/m/test\",\n            \"https://www.w3.org/ns/activitystreams#Public\"\n        ],\n        \"cc\": [\n            \"https://kbin.test/u/user/followers\"\n        ],\n        \"name\": \"test\",\n        \"audience\": \"https://kbin.test/m/test\",\n        \"content\": null,\n        \"summary\": \"test #test\",\n        \"mediaType\": \"text/html\",\n        \"source\": \"https://joinmbin.org\",\n        \"tag\": [\n            {\n                \"type\": \"Hashtag\",\n                \"href\": \"https://kbin.test/tag/test\",\n                \"name\": \"#test\"\n            }\n        ],\n        \"commentsEnabled\": true,\n        \"sensitive\": false,\n        \"stickied\": false,\n        \"published\": \"SCRUBBED_DATE\",\n        \"contentMap\": {\n            \"en\": null\n        },\n        \"attachment\": [\n            {\n                \"href\": \"https://joinmbin.org\",\n                \"type\": \"Link\"\n            },\n            {\n                \"type\": \"Image\",\n                \"mediaType\": \"image/png\",\n                \"url\": \"https://kbin.test/media/a8/1c/a81cc2fea35eeb232cd28fcb109b3eb5a4e52c71bce95af6650d71876c1bcbb7.png\",\n                \"name\": \"kibby\",\n                \"blurhash\": \"L$Pie*?^spxt%3W.oyn*r^W-tQjG\",\n                \"focalPoint\": [\n                    0,\n                    0\n                ],\n                \"width\": 96,\n                \"height\": 96\n            }\n        ],\n        \"image\": {\n            \"type\": \"Image\",\n            \"url\": \"https://kbin.test/media/a8/1c/a81cc2fea35eeb232cd28fcb109b3eb5a4e52c71bce95af6650d71876c1bcbb7.png\"\n        }\n    },\n    \"audience\": \"https://kbin.test/m/test\"\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/JsonSnapshots/CreateTest__testCreateEntry__1.json",
    "content": "{\n    \"@context\": [\n        \"https://www.w3.org/ns/activitystreams\",\n        \"https://w3id.org/security/v1\",\n        \"https://kbin.test/contexts\"\n    ],\n    \"id\": \"SCRUBBED_ID\",\n    \"type\": \"Create\",\n    \"actor\": \"https://kbin.test/u/user\",\n    \"published\": \"SCRUBBED_DATE\",\n    \"to\": [\n        \"https://kbin.test/m/test\",\n        \"https://www.w3.org/ns/activitystreams#Public\"\n    ],\n    \"cc\": [\n        \"https://kbin.test/u/user/followers\"\n    ],\n    \"object\": {\n        \"id\": \"SCRUBBED_ID\",\n        \"type\": \"Page\",\n        \"attributedTo\": \"https://kbin.test/u/user\",\n        \"inReplyTo\": null,\n        \"to\": [\n            \"https://kbin.test/m/test\",\n            \"https://www.w3.org/ns/activitystreams#Public\"\n        ],\n        \"cc\": [\n            \"https://kbin.test/u/user/followers\"\n        ],\n        \"name\": \"test\",\n        \"audience\": \"https://kbin.test/m/test\",\n        \"content\": null,\n        \"summary\": \"test #test\",\n        \"mediaType\": \"text/html\",\n        \"source\": null,\n        \"tag\": [\n            {\n                \"type\": \"Hashtag\",\n                \"href\": \"https://kbin.test/tag/test\",\n                \"name\": \"#test\"\n            }\n        ],\n        \"commentsEnabled\": true,\n        \"sensitive\": false,\n        \"stickied\": false,\n        \"published\": \"SCRUBBED_DATE\",\n        \"contentMap\": {\n            \"en\": null\n        }\n    },\n    \"audience\": \"https://kbin.test/m/test\"\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/JsonSnapshots/CreateTest__testCreateMessage__1.json",
    "content": "{\n    \"@context\": [\n        \"https://www.w3.org/ns/activitystreams\",\n        \"https://w3id.org/security/v1\",\n        \"https://kbin.test/contexts\"\n    ],\n    \"id\": \"SCRUBBED_ID\",\n    \"type\": \"Create\",\n    \"actor\": \"https://kbin.test/u/user\",\n    \"published\": \"SCRUBBED_DATE\",\n    \"to\": [\n        \"https://kbin.test/u/user2\"\n    ],\n    \"cc\": [],\n    \"object\": {\n        \"id\": \"SCRUBBED_ID\",\n        \"attributedTo\": \"https://kbin.test/u/user\",\n        \"to\": [\n            \"https://kbin.test/u/user2\"\n        ],\n        \"cc\": [],\n        \"type\": \"ChatMessage\",\n        \"published\": \"SCRUBBED_DATE\",\n        \"content\": \"<p>some test message</p>\\n\",\n        \"mediaType\": \"text/html\",\n        \"source\": {\n            \"mediaType\": \"text/markdown\",\n            \"content\": \"some test message\"\n        }\n    }\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/JsonSnapshots/CreateTest__testCreateNestedEntryComment__1.json",
    "content": "{\n    \"@context\": [\n        \"https://www.w3.org/ns/activitystreams\",\n        \"https://w3id.org/security/v1\",\n        \"https://kbin.test/contexts\"\n    ],\n    \"id\": \"SCRUBBED_ID\",\n    \"type\": \"Create\",\n    \"actor\": \"https://kbin.test/u/user\",\n    \"published\": \"SCRUBBED_DATE\",\n    \"to\": [\n        \"https://www.w3.org/ns/activitystreams#Public\",\n        \"https://kbin.test/u/user\"\n    ],\n    \"cc\": [\n        \"https://kbin.test/m/test\",\n        \"https://kbin.test/u/user/followers\"\n    ],\n    \"object\": {\n        \"id\": \"SCRUBBED_ID\",\n        \"type\": \"Note\",\n        \"attributedTo\": \"https://kbin.test/u/user\",\n        \"inReplyTo\": \"SCRUBBED_ID\",\n        \"to\": [\n            \"https://www.w3.org/ns/activitystreams#Public\",\n            \"https://kbin.test/u/user\"\n        ],\n        \"cc\": [\n            \"https://kbin.test/m/test\",\n            \"https://kbin.test/u/user/followers\"\n        ],\n        \"audience\": \"https://kbin.test/m/test\",\n        \"sensitive\": false,\n        \"content\": \"<p>test</p>\\n\",\n        \"mediaType\": \"text/html\",\n        \"source\": {\n            \"content\": \"test\",\n            \"mediaType\": \"text/markdown\"\n        },\n        \"url\": \"SCRUBBED_ID\",\n        \"tag\": [\n            {\n                \"type\": \"Hashtag\",\n                \"href\": \"https://kbin.test/tag/test\",\n                \"name\": \"#test\"\n            }\n        ],\n        \"published\": \"SCRUBBED_DATE\",\n        \"contentMap\": {\n            \"en\": \"<p>test</p>\\n\"\n        }\n    },\n    \"audience\": \"https://kbin.test/m/test\"\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/JsonSnapshots/CreateTest__testCreateNestedPostComment__1.json",
    "content": "{\n    \"@context\": [\n        \"https://www.w3.org/ns/activitystreams\",\n        \"https://w3id.org/security/v1\",\n        \"https://kbin.test/contexts\"\n    ],\n    \"id\": \"SCRUBBED_ID\",\n    \"type\": \"Create\",\n    \"actor\": \"https://kbin.test/u/user\",\n    \"published\": \"SCRUBBED_DATE\",\n    \"to\": [\n        \"https://www.w3.org/ns/activitystreams#Public\",\n        \"https://kbin.test/u/user\"\n    ],\n    \"cc\": [\n        \"https://kbin.test/m/test\",\n        \"https://kbin.test/u/user/followers\"\n    ],\n    \"object\": {\n        \"id\": \"SCRUBBED_ID\",\n        \"type\": \"Note\",\n        \"attributedTo\": \"https://kbin.test/u/user\",\n        \"inReplyTo\": \"SCRUBBED_ID\",\n        \"to\": [\n            \"https://www.w3.org/ns/activitystreams#Public\",\n            \"https://kbin.test/u/user\"\n        ],\n        \"cc\": [\n            \"https://kbin.test/m/test\",\n            \"https://kbin.test/u/user/followers\"\n        ],\n        \"audience\": \"https://kbin.test/m/test\",\n        \"sensitive\": false,\n        \"content\": \"<p>test</p>\\n\",\n        \"mediaType\": \"text/html\",\n        \"source\": {\n            \"content\": \"test\",\n            \"mediaType\": \"text/markdown\"\n        },\n        \"url\": \"SCRUBBED_ID\",\n        \"tag\": [\n            {\n                \"type\": \"Hashtag\",\n                \"href\": \"https://kbin.test/tag/test\",\n                \"name\": \"#test\"\n            }\n        ],\n        \"published\": \"SCRUBBED_DATE\",\n        \"contentMap\": {\n            \"en\": \"<p>test</p>\\n\"\n        }\n    },\n    \"audience\": \"https://kbin.test/m/test\"\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/JsonSnapshots/CreateTest__testCreatePostComment__1.json",
    "content": "{\n    \"@context\": [\n        \"https://www.w3.org/ns/activitystreams\",\n        \"https://w3id.org/security/v1\",\n        \"https://kbin.test/contexts\"\n    ],\n    \"id\": \"SCRUBBED_ID\",\n    \"type\": \"Create\",\n    \"actor\": \"https://kbin.test/u/user\",\n    \"published\": \"SCRUBBED_DATE\",\n    \"to\": [\n        \"https://www.w3.org/ns/activitystreams#Public\",\n        \"https://kbin.test/u/user\"\n    ],\n    \"cc\": [\n        \"https://kbin.test/m/test\",\n        \"https://kbin.test/u/user/followers\"\n    ],\n    \"object\": {\n        \"id\": \"SCRUBBED_ID\",\n        \"type\": \"Note\",\n        \"attributedTo\": \"https://kbin.test/u/user\",\n        \"inReplyTo\": \"SCRUBBED_ID\",\n        \"to\": [\n            \"https://www.w3.org/ns/activitystreams#Public\",\n            \"https://kbin.test/u/user\"\n        ],\n        \"cc\": [\n            \"https://kbin.test/m/test\",\n            \"https://kbin.test/u/user/followers\"\n        ],\n        \"audience\": \"https://kbin.test/m/test\",\n        \"sensitive\": false,\n        \"content\": \"<p>test</p>\\n\",\n        \"mediaType\": \"text/html\",\n        \"source\": {\n            \"content\": \"test\",\n            \"mediaType\": \"text/markdown\"\n        },\n        \"url\": \"SCRUBBED_ID\",\n        \"tag\": [\n            {\n                \"type\": \"Hashtag\",\n                \"href\": \"https://kbin.test/tag/test\",\n                \"name\": \"#test\"\n            }\n        ],\n        \"published\": \"SCRUBBED_DATE\",\n        \"contentMap\": {\n            \"en\": \"<p>test</p>\\n\"\n        }\n    },\n    \"audience\": \"https://kbin.test/m/test\"\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/JsonSnapshots/CreateTest__testCreatePost__1.json",
    "content": "{\n    \"@context\": [\n        \"https://www.w3.org/ns/activitystreams\",\n        \"https://w3id.org/security/v1\",\n        \"https://kbin.test/contexts\"\n    ],\n    \"id\": \"SCRUBBED_ID\",\n    \"type\": \"Create\",\n    \"actor\": \"https://kbin.test/u/user\",\n    \"published\": \"SCRUBBED_DATE\",\n    \"to\": [\n        \"https://kbin.test/m/test\",\n        \"https://www.w3.org/ns/activitystreams#Public\"\n    ],\n    \"cc\": [\n        \"https://kbin.test/u/user/followers\"\n    ],\n    \"object\": {\n        \"id\": \"SCRUBBED_ID\",\n        \"type\": \"Note\",\n        \"attributedTo\": \"https://kbin.test/u/user\",\n        \"inReplyTo\": null,\n        \"to\": [\n            \"https://kbin.test/m/test\",\n            \"https://www.w3.org/ns/activitystreams#Public\"\n        ],\n        \"cc\": [\n            \"https://kbin.test/u/user/followers\"\n        ],\n        \"audience\": \"https://kbin.test/m/test\",\n        \"sensitive\": false,\n        \"stickied\": false,\n        \"content\": \"<p>test</p>\\n<p><a href=\\\"https://kbin.test/tag/test\\\">#test</a></p>\\n\",\n        \"mediaType\": \"text/html\",\n        \"source\": {\n            \"content\": \"test\\n\\n #test\",\n            \"mediaType\": \"text/markdown\"\n        },\n        \"url\": \"SCRUBBED_ID\",\n        \"tag\": [\n            {\n                \"type\": \"Hashtag\",\n                \"href\": \"https://kbin.test/tag/test\",\n                \"name\": \"#test\"\n            }\n        ],\n        \"commentsEnabled\": true,\n        \"published\": \"SCRUBBED_DATE\",\n        \"contentMap\": {\n            \"en\": \"<p>test</p>\\n<p><a href=\\\"https://kbin.test/tag/test\\\">#test</a></p>\\n\"\n        }\n    },\n    \"audience\": \"https://kbin.test/m/test\"\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/JsonSnapshots/DeleteTest__testDeleteEntryByModerator__1.json",
    "content": "{\n    \"@context\": [\n        \"https://www.w3.org/ns/activitystreams\",\n        \"https://w3id.org/security/v1\",\n        \"https://kbin.test/contexts\"\n    ],\n    \"id\": \"SCRUBBED_ID\",\n    \"type\": \"Delete\",\n    \"actor\": \"https://kbin.test/u/owner\",\n    \"object\": {\n        \"id\": \"SCRUBBED_ID\",\n        \"type\": \"Tombstone\"\n    },\n    \"to\": [\n        \"https://kbin.test/m/test\",\n        \"https://www.w3.org/ns/activitystreams#Public\"\n    ],\n    \"cc\": [\n        \"https://kbin.test/u/user/followers\"\n    ],\n    \"summary\": \" \"\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/JsonSnapshots/DeleteTest__testDeleteEntryCommentByModerator__1.json",
    "content": "{\n    \"@context\": [\n        \"https://www.w3.org/ns/activitystreams\",\n        \"https://w3id.org/security/v1\",\n        \"https://kbin.test/contexts\"\n    ],\n    \"id\": \"SCRUBBED_ID\",\n    \"type\": \"Delete\",\n    \"actor\": \"https://kbin.test/u/owner\",\n    \"object\": {\n        \"id\": \"SCRUBBED_ID\",\n        \"type\": \"Tombstone\"\n    },\n    \"to\": [\n        \"https://www.w3.org/ns/activitystreams#Public\",\n        \"https://kbin.test/u/user\"\n    ],\n    \"cc\": [\n        \"https://kbin.test/m/test\",\n        \"https://kbin.test/u/user/followers\"\n    ],\n    \"summary\": \" \"\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/JsonSnapshots/DeleteTest__testDeleteEntryComment__1.json",
    "content": "{\n    \"@context\": [\n        \"https://www.w3.org/ns/activitystreams\",\n        \"https://w3id.org/security/v1\",\n        \"https://kbin.test/contexts\"\n    ],\n    \"id\": \"SCRUBBED_ID\",\n    \"type\": \"Delete\",\n    \"actor\": \"https://kbin.test/u/user\",\n    \"object\": {\n        \"id\": \"SCRUBBED_ID\",\n        \"type\": \"Tombstone\"\n    },\n    \"to\": [\n        \"https://www.w3.org/ns/activitystreams#Public\",\n        \"https://kbin.test/u/user\"\n    ],\n    \"cc\": [\n        \"https://kbin.test/m/test\",\n        \"https://kbin.test/u/user/followers\"\n    ]\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/JsonSnapshots/DeleteTest__testDeleteEntry__1.json",
    "content": "{\n    \"@context\": [\n        \"https://www.w3.org/ns/activitystreams\",\n        \"https://w3id.org/security/v1\",\n        \"https://kbin.test/contexts\"\n    ],\n    \"id\": \"SCRUBBED_ID\",\n    \"type\": \"Delete\",\n    \"actor\": \"https://kbin.test/u/user\",\n    \"object\": {\n        \"id\": \"SCRUBBED_ID\",\n        \"type\": \"Tombstone\"\n    },\n    \"to\": [\n        \"https://kbin.test/m/test\",\n        \"https://www.w3.org/ns/activitystreams#Public\"\n    ],\n    \"cc\": [\n        \"https://kbin.test/u/user/followers\"\n    ]\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/JsonSnapshots/DeleteTest__testDeletePostByModerator__1.json",
    "content": "{\n    \"@context\": [\n        \"https://www.w3.org/ns/activitystreams\",\n        \"https://w3id.org/security/v1\",\n        \"https://kbin.test/contexts\"\n    ],\n    \"id\": \"SCRUBBED_ID\",\n    \"type\": \"Delete\",\n    \"actor\": \"https://kbin.test/u/owner\",\n    \"object\": {\n        \"id\": \"SCRUBBED_ID\",\n        \"type\": \"Tombstone\"\n    },\n    \"to\": [\n        \"https://kbin.test/m/test\",\n        \"https://www.w3.org/ns/activitystreams#Public\"\n    ],\n    \"cc\": [\n        \"https://kbin.test/u/user/followers\"\n    ],\n    \"summary\": \" \"\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/JsonSnapshots/DeleteTest__testDeletePostCommentByModerator__1.json",
    "content": "{\n    \"@context\": [\n        \"https://www.w3.org/ns/activitystreams\",\n        \"https://w3id.org/security/v1\",\n        \"https://kbin.test/contexts\"\n    ],\n    \"id\": \"SCRUBBED_ID\",\n    \"type\": \"Delete\",\n    \"actor\": \"https://kbin.test/u/owner\",\n    \"object\": {\n        \"id\": \"SCRUBBED_ID\",\n        \"type\": \"Tombstone\"\n    },\n    \"to\": [\n        \"https://www.w3.org/ns/activitystreams#Public\",\n        \"https://kbin.test/u/user\"\n    ],\n    \"cc\": [\n        \"https://kbin.test/m/test\",\n        \"https://kbin.test/u/user/followers\"\n    ],\n    \"summary\": \" \"\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/JsonSnapshots/DeleteTest__testDeletePostComment__1.json",
    "content": "{\n    \"@context\": [\n        \"https://www.w3.org/ns/activitystreams\",\n        \"https://w3id.org/security/v1\",\n        \"https://kbin.test/contexts\"\n    ],\n    \"id\": \"SCRUBBED_ID\",\n    \"type\": \"Delete\",\n    \"actor\": \"https://kbin.test/u/user\",\n    \"object\": {\n        \"id\": \"SCRUBBED_ID\",\n        \"type\": \"Tombstone\"\n    },\n    \"to\": [\n        \"https://www.w3.org/ns/activitystreams#Public\",\n        \"https://kbin.test/u/user\"\n    ],\n    \"cc\": [\n        \"https://kbin.test/m/test\",\n        \"https://kbin.test/u/user/followers\"\n    ]\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/JsonSnapshots/DeleteTest__testDeletePost__1.json",
    "content": "{\n    \"@context\": [\n        \"https://www.w3.org/ns/activitystreams\",\n        \"https://w3id.org/security/v1\",\n        \"https://kbin.test/contexts\"\n    ],\n    \"id\": \"SCRUBBED_ID\",\n    \"type\": \"Delete\",\n    \"actor\": \"https://kbin.test/u/user\",\n    \"object\": {\n        \"id\": \"SCRUBBED_ID\",\n        \"type\": \"Tombstone\"\n    },\n    \"to\": [\n        \"https://kbin.test/m/test\",\n        \"https://www.w3.org/ns/activitystreams#Public\"\n    ],\n    \"cc\": [\n        \"https://kbin.test/u/user/followers\"\n    ]\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/JsonSnapshots/DeleteTest__testDeleteUser__1.json",
    "content": "{\n    \"id\": \"SCRUBBED_ID\",\n    \"type\": \"Delete\",\n    \"actor\": \"https://kbin.test/u/user\",\n    \"object\": \"SCRUBBED_ID\",\n    \"to\": [\n        \"https://www.w3.org/ns/activitystreams#Public\"\n    ],\n    \"cc\": [\n        \"https://kbin.test/u/user/followers\"\n    ],\n    \"removeData\": true\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/JsonSnapshots/FlagTest__testFlagEntryComment__1.json",
    "content": "{\n    \"@context\": \"https://www.w3.org/ns/activitystreams\",\n    \"id\": \"SCRUBBED_ID\",\n    \"type\": \"Flag\",\n    \"actor\": \"https://kbin.test/u/reportingUser\",\n    \"object\": \"SCRUBBED_ID\",\n    \"audience\": \"https://kbin.test/m/test\",\n    \"summary\": null,\n    \"content\": null,\n    \"to\": [\n        \"https://kbin.test/m/test\"\n    ]\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/JsonSnapshots/FlagTest__testFlagEntry__1.json",
    "content": "{\n    \"@context\": \"https://www.w3.org/ns/activitystreams\",\n    \"id\": \"SCRUBBED_ID\",\n    \"type\": \"Flag\",\n    \"actor\": \"https://kbin.test/u/reportingUser\",\n    \"object\": \"SCRUBBED_ID\",\n    \"audience\": \"https://kbin.test/m/test\",\n    \"summary\": null,\n    \"content\": null,\n    \"to\": [\n        \"https://kbin.test/m/test\"\n    ]\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/JsonSnapshots/FlagTest__testFlagNestedEntryComment__1.json",
    "content": "{\n    \"@context\": \"https://www.w3.org/ns/activitystreams\",\n    \"id\": \"SCRUBBED_ID\",\n    \"type\": \"Flag\",\n    \"actor\": \"https://kbin.test/u/reportingUser\",\n    \"object\": \"SCRUBBED_ID\",\n    \"audience\": \"https://kbin.test/m/test\",\n    \"summary\": null,\n    \"content\": null,\n    \"to\": [\n        \"https://kbin.test/m/test\"\n    ]\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/JsonSnapshots/FlagTest__testFlagNestedPostComment__1.json",
    "content": "{\n    \"@context\": \"https://www.w3.org/ns/activitystreams\",\n    \"id\": \"SCRUBBED_ID\",\n    \"type\": \"Flag\",\n    \"actor\": \"https://kbin.test/u/reportingUser\",\n    \"object\": \"SCRUBBED_ID\",\n    \"audience\": \"https://kbin.test/m/test\",\n    \"summary\": null,\n    \"content\": null,\n    \"to\": [\n        \"https://kbin.test/m/test\"\n    ]\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/JsonSnapshots/FlagTest__testFlagPostComment__1.json",
    "content": "{\n    \"@context\": \"https://www.w3.org/ns/activitystreams\",\n    \"id\": \"SCRUBBED_ID\",\n    \"type\": \"Flag\",\n    \"actor\": \"https://kbin.test/u/reportingUser\",\n    \"object\": \"SCRUBBED_ID\",\n    \"audience\": \"https://kbin.test/m/test\",\n    \"summary\": null,\n    \"content\": null,\n    \"to\": [\n        \"https://kbin.test/m/test\"\n    ]\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/JsonSnapshots/FlagTest__testFlagPost__1.json",
    "content": "{\n    \"@context\": \"https://www.w3.org/ns/activitystreams\",\n    \"id\": \"SCRUBBED_ID\",\n    \"type\": \"Flag\",\n    \"actor\": \"https://kbin.test/u/reportingUser\",\n    \"object\": \"SCRUBBED_ID\",\n    \"audience\": \"https://kbin.test/m/test\",\n    \"summary\": null,\n    \"content\": null,\n    \"to\": [\n        \"https://kbin.test/m/test\"\n    ]\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/JsonSnapshots/FollowTest__testAcceptFollowMagazine__1.json",
    "content": "{\n    \"@context\": [\n        \"https://www.w3.org/ns/activitystreams\",\n        \"https://w3id.org/security/v1\",\n        \"https://kbin.test/contexts\"\n    ],\n    \"id\": \"SCRUBBED_ID\",\n    \"type\": \"Accept\",\n    \"actor\": \"https://kbin.test/m/test\",\n    \"object\": {\n        \"id\": \"SCRUBBED_ID\",\n        \"type\": \"Follow\",\n        \"actor\": \"https://kbin.test/u/user2\",\n        \"object\": \"SCRUBBED_ID\",\n        \"to\": [\n            \"https://kbin.test/m/test\"\n        ]\n    },\n    \"to\": [\n        \"https://kbin.test/u/user2\"\n    ]\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/JsonSnapshots/FollowTest__testAcceptFollowUser__1.json",
    "content": "{\n    \"@context\": [\n        \"https://www.w3.org/ns/activitystreams\",\n        \"https://w3id.org/security/v1\",\n        \"https://kbin.test/contexts\"\n    ],\n    \"id\": \"SCRUBBED_ID\",\n    \"type\": \"Accept\",\n    \"actor\": \"https://kbin.test/u/user\",\n    \"object\": {\n        \"id\": \"SCRUBBED_ID\",\n        \"type\": \"Follow\",\n        \"actor\": \"https://kbin.test/u/user2\",\n        \"object\": \"SCRUBBED_ID\",\n        \"to\": [\n            \"https://kbin.test/u/user\"\n        ]\n    },\n    \"to\": [\n        \"https://kbin.test/u/user2\"\n    ]\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/JsonSnapshots/FollowTest__testFollowMagazine__1.json",
    "content": "{\n    \"@context\": [\n        \"https://www.w3.org/ns/activitystreams\",\n        \"https://kbin.test/contexts\"\n    ],\n    \"id\": \"SCRUBBED_ID\",\n    \"type\": \"Follow\",\n    \"actor\": \"https://kbin.test/u/user2\",\n    \"object\": \"SCRUBBED_ID\"\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/JsonSnapshots/FollowTest__testFollowUser__1.json",
    "content": "{\n    \"@context\": [\n        \"https://www.w3.org/ns/activitystreams\",\n        \"https://w3id.org/security/v1\",\n        \"https://kbin.test/contexts\"\n    ],\n    \"id\": \"SCRUBBED_ID\",\n    \"type\": \"Follow\",\n    \"actor\": \"https://kbin.test/u/user2\",\n    \"object\": \"SCRUBBED_ID\",\n    \"to\": [\n        \"https://kbin.test/u/user\"\n    ]\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/JsonSnapshots/FollowTest__testRejectFollowMagazine__1.json",
    "content": "{\n    \"@context\": [\n        \"https://www.w3.org/ns/activitystreams\",\n        \"https://w3id.org/security/v1\",\n        \"https://kbin.test/contexts\"\n    ],\n    \"id\": \"SCRUBBED_ID\",\n    \"type\": \"Reject\",\n    \"actor\": \"https://kbin.test/m/test\",\n    \"object\": {\n        \"id\": \"SCRUBBED_ID\",\n        \"type\": \"Follow\",\n        \"actor\": \"https://kbin.test/u/user2\",\n        \"object\": \"SCRUBBED_ID\",\n        \"to\": [\n            \"https://kbin.test/m/test\"\n        ]\n    },\n    \"to\": [\n        \"https://kbin.test/u/user2\"\n    ]\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/JsonSnapshots/FollowTest__testRejectFollowUser__1.json",
    "content": "{\n    \"@context\": [\n        \"https://www.w3.org/ns/activitystreams\",\n        \"https://w3id.org/security/v1\",\n        \"https://kbin.test/contexts\"\n    ],\n    \"id\": \"SCRUBBED_ID\",\n    \"type\": \"Reject\",\n    \"actor\": \"https://kbin.test/u/user\",\n    \"object\": {\n        \"id\": \"SCRUBBED_ID\",\n        \"type\": \"Follow\",\n        \"actor\": \"https://kbin.test/u/user2\",\n        \"object\": \"SCRUBBED_ID\",\n        \"to\": [\n            \"https://kbin.test/u/user\"\n        ]\n    },\n    \"to\": [\n        \"https://kbin.test/u/user2\"\n    ]\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/JsonSnapshots/LikeTest__testLikeEntryComment__1.json",
    "content": "{\n    \"@context\": [\n        \"https://www.w3.org/ns/activitystreams\",\n        \"https://w3id.org/security/v1\",\n        \"https://kbin.test/contexts\"\n    ],\n    \"id\": \"SCRUBBED_ID\",\n    \"type\": \"Like\",\n    \"actor\": \"https://kbin.test/u/user2\",\n    \"to\": [\n        \"https://www.w3.org/ns/activitystreams#Public\"\n    ],\n    \"cc\": [\n        \"https://kbin.test/u/user2/followers\",\n        \"https://kbin.test/m/test\"\n    ],\n    \"object\": \"SCRUBBED_ID\",\n    \"audience\": \"https://kbin.test/m/test\"\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/JsonSnapshots/LikeTest__testLikeEntry__1.json",
    "content": "{\n    \"@context\": [\n        \"https://www.w3.org/ns/activitystreams\",\n        \"https://w3id.org/security/v1\",\n        \"https://kbin.test/contexts\"\n    ],\n    \"id\": \"SCRUBBED_ID\",\n    \"type\": \"Like\",\n    \"actor\": \"https://kbin.test/u/user2\",\n    \"to\": [\n        \"https://www.w3.org/ns/activitystreams#Public\"\n    ],\n    \"cc\": [\n        \"https://kbin.test/u/user2/followers\",\n        \"https://kbin.test/m/test\"\n    ],\n    \"object\": \"SCRUBBED_ID\",\n    \"audience\": \"https://kbin.test/m/test\"\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/JsonSnapshots/LikeTest__testLikeNestedEntryComment__1.json",
    "content": "{\n    \"@context\": [\n        \"https://www.w3.org/ns/activitystreams\",\n        \"https://w3id.org/security/v1\",\n        \"https://kbin.test/contexts\"\n    ],\n    \"id\": \"SCRUBBED_ID\",\n    \"type\": \"Like\",\n    \"actor\": \"https://kbin.test/u/user2\",\n    \"to\": [\n        \"https://www.w3.org/ns/activitystreams#Public\"\n    ],\n    \"cc\": [\n        \"https://kbin.test/u/user2/followers\",\n        \"https://kbin.test/m/test\"\n    ],\n    \"object\": \"SCRUBBED_ID\",\n    \"audience\": \"https://kbin.test/m/test\"\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/JsonSnapshots/LikeTest__testLikeNestedPostComment__1.json",
    "content": "{\n    \"@context\": [\n        \"https://www.w3.org/ns/activitystreams\",\n        \"https://w3id.org/security/v1\",\n        \"https://kbin.test/contexts\"\n    ],\n    \"id\": \"SCRUBBED_ID\",\n    \"type\": \"Like\",\n    \"actor\": \"https://kbin.test/u/user2\",\n    \"to\": [\n        \"https://www.w3.org/ns/activitystreams#Public\"\n    ],\n    \"cc\": [\n        \"https://kbin.test/u/user2/followers\",\n        \"https://kbin.test/m/test\"\n    ],\n    \"object\": \"SCRUBBED_ID\",\n    \"audience\": \"https://kbin.test/m/test\"\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/JsonSnapshots/LikeTest__testLikePostComment__1.json",
    "content": "{\n    \"@context\": [\n        \"https://www.w3.org/ns/activitystreams\",\n        \"https://w3id.org/security/v1\",\n        \"https://kbin.test/contexts\"\n    ],\n    \"id\": \"SCRUBBED_ID\",\n    \"type\": \"Like\",\n    \"actor\": \"https://kbin.test/u/user2\",\n    \"to\": [\n        \"https://www.w3.org/ns/activitystreams#Public\"\n    ],\n    \"cc\": [\n        \"https://kbin.test/u/user2/followers\",\n        \"https://kbin.test/m/test\"\n    ],\n    \"object\": \"SCRUBBED_ID\",\n    \"audience\": \"https://kbin.test/m/test\"\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/JsonSnapshots/LikeTest__testLikePost__1.json",
    "content": "{\n    \"@context\": [\n        \"https://www.w3.org/ns/activitystreams\",\n        \"https://w3id.org/security/v1\",\n        \"https://kbin.test/contexts\"\n    ],\n    \"id\": \"SCRUBBED_ID\",\n    \"type\": \"Like\",\n    \"actor\": \"https://kbin.test/u/user2\",\n    \"to\": [\n        \"https://www.w3.org/ns/activitystreams#Public\"\n    ],\n    \"cc\": [\n        \"https://kbin.test/u/user2/followers\",\n        \"https://kbin.test/m/test\"\n    ],\n    \"object\": \"SCRUBBED_ID\",\n    \"audience\": \"https://kbin.test/m/test\"\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/JsonSnapshots/LockTest__testLockEntryByAuthor__1.json",
    "content": "{\n    \"@context\": [\n        \"https://www.w3.org/ns/activitystreams\",\n        \"https://w3id.org/security/v1\",\n        \"https://kbin.test/contexts\"\n    ],\n    \"id\": \"SCRUBBED_ID\",\n    \"type\": \"Lock\",\n    \"actor\": \"https://kbin.test/u/user\",\n    \"to\": [\n        \"https://www.w3.org/ns/activitystreams#Public\"\n    ],\n    \"cc\": [\n        \"https://kbin.test/m/test\",\n        \"https://kbin.test/u/user/followers\"\n    ],\n    \"object\": \"SCRUBBED_ID\"\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/JsonSnapshots/LockTest__testLockEntryByModerator__1.json",
    "content": "{\n    \"@context\": [\n        \"https://www.w3.org/ns/activitystreams\",\n        \"https://w3id.org/security/v1\",\n        \"https://kbin.test/contexts\"\n    ],\n    \"id\": \"SCRUBBED_ID\",\n    \"type\": \"Lock\",\n    \"actor\": \"https://kbin.test/u/owner\",\n    \"to\": [\n        \"https://www.w3.org/ns/activitystreams#Public\"\n    ],\n    \"cc\": [\n        \"https://kbin.test/m/test\",\n        \"https://kbin.test/u/owner/followers\"\n    ],\n    \"object\": \"SCRUBBED_ID\"\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/JsonSnapshots/LockTest__testLockPostByAuthor__1.json",
    "content": "{\n    \"@context\": [\n        \"https://www.w3.org/ns/activitystreams\",\n        \"https://w3id.org/security/v1\",\n        \"https://kbin.test/contexts\"\n    ],\n    \"id\": \"SCRUBBED_ID\",\n    \"type\": \"Lock\",\n    \"actor\": \"https://kbin.test/u/user\",\n    \"to\": [\n        \"https://www.w3.org/ns/activitystreams#Public\"\n    ],\n    \"cc\": [\n        \"https://kbin.test/m/test\",\n        \"https://kbin.test/u/user/followers\"\n    ],\n    \"object\": \"SCRUBBED_ID\"\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/JsonSnapshots/LockTest__testLockPostByModerator__1.json",
    "content": "{\n    \"@context\": [\n        \"https://www.w3.org/ns/activitystreams\",\n        \"https://w3id.org/security/v1\",\n        \"https://kbin.test/contexts\"\n    ],\n    \"id\": \"SCRUBBED_ID\",\n    \"type\": \"Lock\",\n    \"actor\": \"https://kbin.test/u/owner\",\n    \"to\": [\n        \"https://www.w3.org/ns/activitystreams#Public\"\n    ],\n    \"cc\": [\n        \"https://kbin.test/m/test\",\n        \"https://kbin.test/u/owner/followers\"\n    ],\n    \"object\": \"SCRUBBED_ID\"\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/JsonSnapshots/UndoTest__testUndoBlockUser__1.json",
    "content": "{\n    \"@context\": [\n        \"https://www.w3.org/ns/activitystreams\",\n        \"https://w3id.org/security/v1\",\n        \"https://kbin.test/contexts\"\n    ],\n    \"id\": \"SCRUBBED_ID\",\n    \"type\": \"Undo\",\n    \"actor\": \"https://kbin.test/u/owner\",\n    \"object\": {\n        \"id\": \"SCRUBBED_ID\",\n        \"type\": \"Block\",\n        \"actor\": \"https://kbin.test/u/owner\",\n        \"object\": \"SCRUBBED_ID\",\n        \"target\": \"https://kbin.test/m/test\",\n        \"summary\": \"some test\",\n        \"audience\": \"https://kbin.test/m/test\",\n        \"expires\": null,\n        \"to\": [\n            \"https://www.w3.org/ns/activitystreams#Public\"\n        ],\n        \"cc\": [\n            \"https://kbin.test/m/test\"\n        ]\n    },\n    \"to\": [\n        \"https://www.w3.org/ns/activitystreams#Public\"\n    ],\n    \"cc\": [\n        \"https://kbin.test/m/test\"\n    ],\n    \"audience\": \"https://kbin.test/m/test\"\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/JsonSnapshots/UndoTest__testUndoFollowMagazine__1.json",
    "content": "{\n    \"@context\": [\n        \"https://www.w3.org/ns/activitystreams\",\n        \"https://w3id.org/security/v1\",\n        \"https://kbin.test/contexts\"\n    ],\n    \"id\": \"SCRUBBED_ID\",\n    \"type\": \"Undo\",\n    \"actor\": \"https://kbin.test/u/user2\",\n    \"object\": {\n        \"id\": \"SCRUBBED_ID\",\n        \"type\": \"Follow\",\n        \"actor\": \"https://kbin.test/u/user2\",\n        \"object\": \"SCRUBBED_ID\",\n        \"to\": [\n            \"https://kbin.test/m/test\"\n        ]\n    },\n    \"to\": [\n        \"https://kbin.test/m/test\"\n    ],\n    \"cc\": []\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/JsonSnapshots/UndoTest__testUndoFollowUser__1.json",
    "content": "{\n    \"@context\": [\n        \"https://www.w3.org/ns/activitystreams\",\n        \"https://w3id.org/security/v1\",\n        \"https://kbin.test/contexts\"\n    ],\n    \"id\": \"SCRUBBED_ID\",\n    \"type\": \"Undo\",\n    \"actor\": \"https://kbin.test/u/user2\",\n    \"object\": {\n        \"id\": \"SCRUBBED_ID\",\n        \"type\": \"Follow\",\n        \"actor\": \"https://kbin.test/u/user2\",\n        \"object\": \"SCRUBBED_ID\",\n        \"to\": [\n            \"https://kbin.test/u/user\"\n        ]\n    },\n    \"to\": [\n        \"https://kbin.test/u/user\"\n    ],\n    \"cc\": []\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/JsonSnapshots/UndoTest__testUndoLikeEntryComment__1.json",
    "content": "{\n    \"@context\": [\n        \"https://www.w3.org/ns/activitystreams\",\n        \"https://w3id.org/security/v1\",\n        \"https://kbin.test/contexts\"\n    ],\n    \"id\": \"SCRUBBED_ID\",\n    \"type\": \"Undo\",\n    \"actor\": \"https://kbin.test/u/user2\",\n    \"object\": {\n        \"id\": \"SCRUBBED_ID\",\n        \"type\": \"Like\",\n        \"actor\": \"https://kbin.test/u/user2\",\n        \"to\": [\n            \"https://www.w3.org/ns/activitystreams#Public\"\n        ],\n        \"cc\": [\n            \"https://kbin.test/u/user2/followers\",\n            \"https://kbin.test/m/test\"\n        ],\n        \"object\": \"SCRUBBED_ID\",\n        \"audience\": \"https://kbin.test/m/test\"\n    },\n    \"to\": [\n        \"https://www.w3.org/ns/activitystreams#Public\"\n    ],\n    \"cc\": [\n        \"https://kbin.test/u/user2/followers\",\n        \"https://kbin.test/m/test\"\n    ],\n    \"audience\": \"https://kbin.test/m/test\"\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/JsonSnapshots/UndoTest__testUndoLikeEntry__1.json",
    "content": "{\n    \"@context\": [\n        \"https://www.w3.org/ns/activitystreams\",\n        \"https://w3id.org/security/v1\",\n        \"https://kbin.test/contexts\"\n    ],\n    \"id\": \"SCRUBBED_ID\",\n    \"type\": \"Undo\",\n    \"actor\": \"https://kbin.test/u/user2\",\n    \"object\": {\n        \"id\": \"SCRUBBED_ID\",\n        \"type\": \"Like\",\n        \"actor\": \"https://kbin.test/u/user2\",\n        \"to\": [\n            \"https://www.w3.org/ns/activitystreams#Public\"\n        ],\n        \"cc\": [\n            \"https://kbin.test/u/user2/followers\",\n            \"https://kbin.test/m/test\"\n        ],\n        \"object\": \"SCRUBBED_ID\",\n        \"audience\": \"https://kbin.test/m/test\"\n    },\n    \"to\": [\n        \"https://www.w3.org/ns/activitystreams#Public\"\n    ],\n    \"cc\": [\n        \"https://kbin.test/u/user2/followers\",\n        \"https://kbin.test/m/test\"\n    ],\n    \"audience\": \"https://kbin.test/m/test\"\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/JsonSnapshots/UndoTest__testUndoLikeNestedEntryComment__1.json",
    "content": "{\n    \"@context\": [\n        \"https://www.w3.org/ns/activitystreams\",\n        \"https://w3id.org/security/v1\",\n        \"https://kbin.test/contexts\"\n    ],\n    \"id\": \"SCRUBBED_ID\",\n    \"type\": \"Undo\",\n    \"actor\": \"https://kbin.test/u/user2\",\n    \"object\": {\n        \"id\": \"SCRUBBED_ID\",\n        \"type\": \"Like\",\n        \"actor\": \"https://kbin.test/u/user2\",\n        \"to\": [\n            \"https://www.w3.org/ns/activitystreams#Public\"\n        ],\n        \"cc\": [\n            \"https://kbin.test/u/user2/followers\",\n            \"https://kbin.test/m/test\"\n        ],\n        \"object\": \"SCRUBBED_ID\",\n        \"audience\": \"https://kbin.test/m/test\"\n    },\n    \"to\": [\n        \"https://www.w3.org/ns/activitystreams#Public\"\n    ],\n    \"cc\": [\n        \"https://kbin.test/u/user2/followers\",\n        \"https://kbin.test/m/test\"\n    ],\n    \"audience\": \"https://kbin.test/m/test\"\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/JsonSnapshots/UndoTest__testUndoLikeNestedPostComment__1.json",
    "content": "{\n    \"@context\": [\n        \"https://www.w3.org/ns/activitystreams\",\n        \"https://w3id.org/security/v1\",\n        \"https://kbin.test/contexts\"\n    ],\n    \"id\": \"SCRUBBED_ID\",\n    \"type\": \"Undo\",\n    \"actor\": \"https://kbin.test/u/user2\",\n    \"object\": {\n        \"id\": \"SCRUBBED_ID\",\n        \"type\": \"Like\",\n        \"actor\": \"https://kbin.test/u/user2\",\n        \"to\": [\n            \"https://www.w3.org/ns/activitystreams#Public\"\n        ],\n        \"cc\": [\n            \"https://kbin.test/u/user2/followers\",\n            \"https://kbin.test/m/test\"\n        ],\n        \"object\": \"SCRUBBED_ID\",\n        \"audience\": \"https://kbin.test/m/test\"\n    },\n    \"to\": [\n        \"https://www.w3.org/ns/activitystreams#Public\"\n    ],\n    \"cc\": [\n        \"https://kbin.test/u/user2/followers\",\n        \"https://kbin.test/m/test\"\n    ],\n    \"audience\": \"https://kbin.test/m/test\"\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/JsonSnapshots/UndoTest__testUndoLikePostComment__1.json",
    "content": "{\n    \"@context\": [\n        \"https://www.w3.org/ns/activitystreams\",\n        \"https://w3id.org/security/v1\",\n        \"https://kbin.test/contexts\"\n    ],\n    \"id\": \"SCRUBBED_ID\",\n    \"type\": \"Undo\",\n    \"actor\": \"https://kbin.test/u/user2\",\n    \"object\": {\n        \"id\": \"SCRUBBED_ID\",\n        \"type\": \"Like\",\n        \"actor\": \"https://kbin.test/u/user2\",\n        \"to\": [\n            \"https://www.w3.org/ns/activitystreams#Public\"\n        ],\n        \"cc\": [\n            \"https://kbin.test/u/user2/followers\",\n            \"https://kbin.test/m/test\"\n        ],\n        \"object\": \"SCRUBBED_ID\",\n        \"audience\": \"https://kbin.test/m/test\"\n    },\n    \"to\": [\n        \"https://www.w3.org/ns/activitystreams#Public\"\n    ],\n    \"cc\": [\n        \"https://kbin.test/u/user2/followers\",\n        \"https://kbin.test/m/test\"\n    ],\n    \"audience\": \"https://kbin.test/m/test\"\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/JsonSnapshots/UndoTest__testUndoLikePost__1.json",
    "content": "{\n    \"@context\": [\n        \"https://www.w3.org/ns/activitystreams\",\n        \"https://w3id.org/security/v1\",\n        \"https://kbin.test/contexts\"\n    ],\n    \"id\": \"SCRUBBED_ID\",\n    \"type\": \"Undo\",\n    \"actor\": \"https://kbin.test/u/user2\",\n    \"object\": {\n        \"id\": \"SCRUBBED_ID\",\n        \"type\": \"Like\",\n        \"actor\": \"https://kbin.test/u/user2\",\n        \"to\": [\n            \"https://www.w3.org/ns/activitystreams#Public\"\n        ],\n        \"cc\": [\n            \"https://kbin.test/u/user2/followers\",\n            \"https://kbin.test/m/test\"\n        ],\n        \"object\": \"SCRUBBED_ID\",\n        \"audience\": \"https://kbin.test/m/test\"\n    },\n    \"to\": [\n        \"https://www.w3.org/ns/activitystreams#Public\"\n    ],\n    \"cc\": [\n        \"https://kbin.test/u/user2/followers\",\n        \"https://kbin.test/m/test\"\n    ],\n    \"audience\": \"https://kbin.test/m/test\"\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/JsonSnapshots/UpdateTest__testUpdateEntryComment__1.json",
    "content": "{\n    \"@context\": [\n        \"https://www.w3.org/ns/activitystreams\",\n        \"https://w3id.org/security/v1\",\n        \"https://kbin.test/contexts\"\n    ],\n    \"id\": \"SCRUBBED_ID\",\n    \"type\": \"Update\",\n    \"actor\": \"https://kbin.test/u/user\",\n    \"published\": \"SCRUBBED_DATE\",\n    \"to\": [\n        \"https://www.w3.org/ns/activitystreams#Public\",\n        \"https://kbin.test/u/user\"\n    ],\n    \"cc\": [\n        \"https://kbin.test/m/test\",\n        \"https://kbin.test/u/user/followers\"\n    ],\n    \"object\": {\n        \"id\": \"SCRUBBED_ID\",\n        \"type\": \"Note\",\n        \"attributedTo\": \"https://kbin.test/u/user\",\n        \"inReplyTo\": \"SCRUBBED_ID\",\n        \"to\": [\n            \"https://www.w3.org/ns/activitystreams#Public\",\n            \"https://kbin.test/u/user\"\n        ],\n        \"cc\": [\n            \"https://kbin.test/m/test\",\n            \"https://kbin.test/u/user/followers\"\n        ],\n        \"audience\": \"https://kbin.test/m/test\",\n        \"sensitive\": false,\n        \"content\": \"<p>test</p>\\n\",\n        \"mediaType\": \"text/html\",\n        \"source\": {\n            \"content\": \"test\",\n            \"mediaType\": \"text/markdown\"\n        },\n        \"url\": \"SCRUBBED_ID\",\n        \"tag\": [\n            {\n                \"type\": \"Hashtag\",\n                \"href\": \"https://kbin.test/tag/test\",\n                \"name\": \"#test\"\n            }\n        ],\n        \"published\": \"SCRUBBED_DATE\",\n        \"contentMap\": {\n            \"en\": \"<p>test</p>\\n\"\n        },\n        \"object\": {\n            \"updated\": \"SCRUBBED_DATE\"\n        }\n    },\n    \"audience\": \"https://kbin.test/m/test\"\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/JsonSnapshots/UpdateTest__testUpdateEntry__1.json",
    "content": "{\n    \"@context\": [\n        \"https://www.w3.org/ns/activitystreams\",\n        \"https://w3id.org/security/v1\",\n        \"https://kbin.test/contexts\"\n    ],\n    \"id\": \"SCRUBBED_ID\",\n    \"type\": \"Update\",\n    \"actor\": \"https://kbin.test/u/user\",\n    \"published\": \"SCRUBBED_DATE\",\n    \"to\": [\n        \"https://kbin.test/m/test\",\n        \"https://www.w3.org/ns/activitystreams#Public\"\n    ],\n    \"cc\": [\n        \"https://kbin.test/u/user/followers\"\n    ],\n    \"object\": {\n        \"id\": \"SCRUBBED_ID\",\n        \"type\": \"Page\",\n        \"attributedTo\": \"https://kbin.test/u/user\",\n        \"inReplyTo\": null,\n        \"to\": [\n            \"https://kbin.test/m/test\",\n            \"https://www.w3.org/ns/activitystreams#Public\"\n        ],\n        \"cc\": [\n            \"https://kbin.test/u/user/followers\"\n        ],\n        \"name\": \"test\",\n        \"audience\": \"https://kbin.test/m/test\",\n        \"content\": null,\n        \"summary\": \"test #test\",\n        \"mediaType\": \"text/html\",\n        \"source\": null,\n        \"tag\": [\n            {\n                \"type\": \"Hashtag\",\n                \"href\": \"https://kbin.test/tag/test\",\n                \"name\": \"#test\"\n            }\n        ],\n        \"commentsEnabled\": true,\n        \"sensitive\": false,\n        \"stickied\": false,\n        \"published\": \"SCRUBBED_DATE\",\n        \"contentMap\": {\n            \"en\": null\n        },\n        \"object\": {\n            \"updated\": \"SCRUBBED_DATE\"\n        }\n    },\n    \"audience\": \"https://kbin.test/m/test\"\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/JsonSnapshots/UpdateTest__testUpdateMagazine__1.json",
    "content": "{\n    \"@context\": [\n        \"https://www.w3.org/ns/activitystreams\",\n        \"https://w3id.org/security/v1\",\n        \"https://kbin.test/contexts\"\n    ],\n    \"id\": \"SCRUBBED_ID\",\n    \"type\": \"Update\",\n    \"actor\": \"https://kbin.test/u/owner\",\n    \"published\": \"SCRUBBED_DATE\",\n    \"to\": [\n        \"https://www.w3.org/ns/activitystreams#Public\"\n    ],\n    \"cc\": [\n        \"https://kbin.test/m/test/followers\"\n    ],\n    \"object\": {\n        \"type\": \"Group\",\n        \"id\": \"SCRUBBED_ID\",\n        \"name\": \"test\",\n        \"preferredUsername\": \"test\",\n        \"inbox\": \"https://kbin.test/m/test/inbox\",\n        \"outbox\": \"https://kbin.test/m/test/outbox\",\n        \"followers\": \"https://kbin.test/m/test/followers\",\n        \"featured\": \"https://kbin.test/m/test/pinned\",\n        \"url\": \"https://kbin.test/m/test\",\n        \"publicKey\": \"SCRUBBED_KEY\",\n        \"summary\": \"\",\n        \"source\": {\n            \"content\": \"\",\n            \"mediaType\": \"text/markdown\"\n        },\n        \"sensitive\": false,\n        \"attributedTo\": \"https://kbin.test/m/test/moderators\",\n        \"postingRestrictedToMods\": false,\n        \"discoverable\": true,\n        \"indexable\": true,\n        \"endpoints\": {\n            \"sharedInbox\": \"https://kbin.test/f/inbox\"\n        },\n        \"published\": \"SCRUBBED_DATE\",\n        \"updated\": \"SCRUBBED_DATE\"\n    }\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/JsonSnapshots/UpdateTest__testUpdatePostComment__1.json",
    "content": "{\n    \"@context\": [\n        \"https://www.w3.org/ns/activitystreams\",\n        \"https://w3id.org/security/v1\",\n        \"https://kbin.test/contexts\"\n    ],\n    \"id\": \"SCRUBBED_ID\",\n    \"type\": \"Update\",\n    \"actor\": \"https://kbin.test/u/user\",\n    \"published\": \"SCRUBBED_DATE\",\n    \"to\": [\n        \"https://www.w3.org/ns/activitystreams#Public\",\n        \"https://kbin.test/u/user\"\n    ],\n    \"cc\": [\n        \"https://kbin.test/m/test\",\n        \"https://kbin.test/u/user/followers\"\n    ],\n    \"object\": {\n        \"id\": \"SCRUBBED_ID\",\n        \"type\": \"Note\",\n        \"attributedTo\": \"https://kbin.test/u/user\",\n        \"inReplyTo\": \"SCRUBBED_ID\",\n        \"to\": [\n            \"https://www.w3.org/ns/activitystreams#Public\",\n            \"https://kbin.test/u/user\"\n        ],\n        \"cc\": [\n            \"https://kbin.test/m/test\",\n            \"https://kbin.test/u/user/followers\"\n        ],\n        \"audience\": \"https://kbin.test/m/test\",\n        \"sensitive\": false,\n        \"content\": \"<p>test</p>\\n\",\n        \"mediaType\": \"text/html\",\n        \"source\": {\n            \"content\": \"test\",\n            \"mediaType\": \"text/markdown\"\n        },\n        \"url\": \"SCRUBBED_ID\",\n        \"tag\": [\n            {\n                \"type\": \"Hashtag\",\n                \"href\": \"https://kbin.test/tag/test\",\n                \"name\": \"#test\"\n            }\n        ],\n        \"published\": \"SCRUBBED_DATE\",\n        \"contentMap\": {\n            \"en\": \"<p>test</p>\\n\"\n        },\n        \"object\": {\n            \"updated\": \"SCRUBBED_DATE\"\n        }\n    },\n    \"audience\": \"https://kbin.test/m/test\"\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/JsonSnapshots/UpdateTest__testUpdatePost__1.json",
    "content": "{\n    \"@context\": [\n        \"https://www.w3.org/ns/activitystreams\",\n        \"https://w3id.org/security/v1\",\n        \"https://kbin.test/contexts\"\n    ],\n    \"id\": \"SCRUBBED_ID\",\n    \"type\": \"Update\",\n    \"actor\": \"https://kbin.test/u/user\",\n    \"published\": \"SCRUBBED_DATE\",\n    \"to\": [\n        \"https://kbin.test/m/test\",\n        \"https://www.w3.org/ns/activitystreams#Public\"\n    ],\n    \"cc\": [\n        \"https://kbin.test/u/user/followers\"\n    ],\n    \"object\": {\n        \"id\": \"SCRUBBED_ID\",\n        \"type\": \"Note\",\n        \"attributedTo\": \"https://kbin.test/u/user\",\n        \"inReplyTo\": null,\n        \"to\": [\n            \"https://kbin.test/m/test\",\n            \"https://www.w3.org/ns/activitystreams#Public\"\n        ],\n        \"cc\": [\n            \"https://kbin.test/u/user/followers\"\n        ],\n        \"audience\": \"https://kbin.test/m/test\",\n        \"sensitive\": false,\n        \"stickied\": false,\n        \"content\": \"<p>test</p>\\n<p><a href=\\\"https://kbin.test/tag/test\\\">#test</a></p>\\n\",\n        \"mediaType\": \"text/html\",\n        \"source\": {\n            \"content\": \"test\\n\\n #test\",\n            \"mediaType\": \"text/markdown\"\n        },\n        \"url\": \"SCRUBBED_ID\",\n        \"tag\": [\n            {\n                \"type\": \"Hashtag\",\n                \"href\": \"https://kbin.test/tag/test\",\n                \"name\": \"#test\"\n            }\n        ],\n        \"commentsEnabled\": true,\n        \"published\": \"SCRUBBED_DATE\",\n        \"contentMap\": {\n            \"en\": \"<p>test</p>\\n<p><a href=\\\"https://kbin.test/tag/test\\\">#test</a></p>\\n\"\n        },\n        \"object\": {\n            \"updated\": \"SCRUBBED_DATE\"\n        }\n    },\n    \"audience\": \"https://kbin.test/m/test\"\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/JsonSnapshots/UpdateTest__testUpdateUser__1.json",
    "content": "{\n    \"@context\": [\n        \"https://www.w3.org/ns/activitystreams\",\n        \"https://w3id.org/security/v1\",\n        \"https://kbin.test/contexts\"\n    ],\n    \"id\": \"SCRUBBED_ID\",\n    \"type\": \"Update\",\n    \"actor\": \"https://kbin.test/u/user\",\n    \"published\": \"SCRUBBED_DATE\",\n    \"to\": [\n        \"https://www.w3.org/ns/activitystreams#Public\"\n    ],\n    \"cc\": [\n        \"https://kbin.test/u/user/followers\"\n    ],\n    \"object\": {\n        \"id\": \"SCRUBBED_ID\",\n        \"type\": \"Person\",\n        \"name\": \"Test User\",\n        \"preferredUsername\": \"user\",\n        \"inbox\": \"https://kbin.test/u/user/inbox\",\n        \"outbox\": \"https://kbin.test/u/user/outbox\",\n        \"url\": \"https://kbin.test/u/user\",\n        \"manuallyApprovesFollowers\": false,\n        \"discoverable\": true,\n        \"indexable\": true,\n        \"published\": \"SCRUBBED_DATE\",\n        \"following\": \"https://kbin.test/u/user/following\",\n        \"followers\": \"https://kbin.test/u/user/followers\",\n        \"publicKey\": \"SCRUBBED_KEY\",\n        \"endpoints\": {\n            \"sharedInbox\": \"https://kbin.test/f/inbox\"\n        }\n    }\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/LikeTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Unit\\ActivityPub\\Outbox;\n\nuse App\\Tests\\ActivityPubJsonDriver;\nuse App\\Tests\\ActivityPubTestCase;\nuse App\\Tests\\Unit\\ActivityPub\\Traits\\LikeActivityGeneratorTrait;\n\nclass LikeTest extends ActivityPubTestCase\n{\n    use LikeActivityGeneratorTrait;\n\n    public function testLikeEntry(): void\n    {\n        $json = $this->activityJsonBuilder->buildActivityJson($this->getLikeEntryActivity());\n\n        $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver());\n    }\n\n    public function testLikeEntryComment(): void\n    {\n        $json = $this->activityJsonBuilder->buildActivityJson($this->getLikeEntryCommentActivity());\n\n        $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver());\n    }\n\n    public function testLikeNestedEntryComment(): void\n    {\n        $json = $this->activityJsonBuilder->buildActivityJson($this->getLikeNestedEntryCommentActivity());\n\n        $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver());\n    }\n\n    public function testLikePost(): void\n    {\n        $json = $this->activityJsonBuilder->buildActivityJson($this->getLikePostActivity());\n\n        $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver());\n    }\n\n    public function testLikePostComment(): void\n    {\n        $json = $this->activityJsonBuilder->buildActivityJson($this->getLikePostCommentActivity());\n\n        $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver());\n    }\n\n    public function testLikeNestedPostComment(): void\n    {\n        $json = $this->activityJsonBuilder->buildActivityJson($this->getLikeNestedPostCommentActivity());\n\n        $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver());\n    }\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/LockTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Unit\\ActivityPub\\Outbox;\n\nuse App\\Tests\\ActivityPubJsonDriver;\nuse App\\Tests\\ActivityPubTestCase;\nuse App\\Tests\\Unit\\ActivityPub\\Traits\\LockActivityGeneratorTrait;\n\nclass LockTest extends ActivityPubTestCase\n{\n    use LockActivityGeneratorTrait;\n\n    public function testLockEntryByAuthor(): void\n    {\n        $json = $this->activityJsonBuilder->buildActivityJson($this->getLockEntryActivityByAuthor());\n\n        $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver());\n    }\n\n    public function testLockEntryByModerator(): void\n    {\n        $json = $this->activityJsonBuilder->buildActivityJson($this->getLockEntryActivityByModerator());\n\n        $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver());\n    }\n\n    public function testLockPostByAuthor(): void\n    {\n        $json = $this->activityJsonBuilder->buildActivityJson($this->getLockPostActivityByAuthor());\n\n        $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver());\n    }\n\n    public function testLockPostByModerator(): void\n    {\n        $json = $this->activityJsonBuilder->buildActivityJson($this->getLockPostActivityByModerator());\n\n        $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver());\n    }\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/UndoTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Unit\\ActivityPub\\Outbox;\n\nuse App\\Tests\\ActivityPubJsonDriver;\nuse App\\Tests\\ActivityPubTestCase;\nuse App\\Tests\\Unit\\ActivityPub\\Traits\\BlockActivityGeneratorTrait;\nuse App\\Tests\\Unit\\ActivityPub\\Traits\\FollowActivityGeneratorTrait;\nuse App\\Tests\\Unit\\ActivityPub\\Traits\\LikeActivityGeneratorTrait;\nuse App\\Tests\\Unit\\ActivityPub\\Traits\\UndoActivityGeneratorTrait;\n\nclass UndoTest extends ActivityPubTestCase\n{\n    use FollowActivityGeneratorTrait;\n    use LikeActivityGeneratorTrait;\n    use UndoActivityGeneratorTrait;\n    use BlockActivityGeneratorTrait;\n\n    public function testUndoLikeEntry(): void\n    {\n        $json = $this->activityJsonBuilder->buildActivityJson($this->getUndoLikeEntryActivity());\n\n        $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver());\n    }\n\n    public function testUndoLikeEntryComment(): void\n    {\n        $json = $this->activityJsonBuilder->buildActivityJson($this->getUndoLikeEntryCommentActivity());\n\n        $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver());\n    }\n\n    public function testUndoLikeNestedEntryComment(): void\n    {\n        $json = $this->activityJsonBuilder->buildActivityJson($this->getUndoLikeNestedEntryCommentActivity());\n\n        $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver());\n    }\n\n    public function testUndoLikePost(): void\n    {\n        $json = $this->activityJsonBuilder->buildActivityJson($this->getUndoLikePostActivity());\n\n        $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver());\n    }\n\n    public function testUndoLikePostComment(): void\n    {\n        $json = $this->activityJsonBuilder->buildActivityJson($this->getUndoLikePostCommentActivity());\n\n        $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver());\n    }\n\n    public function testUndoLikeNestedPostComment(): void\n    {\n        $json = $this->activityJsonBuilder->buildActivityJson($this->getUndoLikeNestedPostCommentActivity());\n\n        $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver());\n    }\n\n    public function testUndoFollowUser(): void\n    {\n        $json = $this->activityJsonBuilder->buildActivityJson($this->getUndoFollowUserActivity());\n\n        $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver());\n    }\n\n    public function testUndoFollowMagazine(): void\n    {\n        $json = $this->activityJsonBuilder->buildActivityJson($this->getUndoFollowMagazineActivity());\n\n        $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver());\n    }\n\n    public function testUndoBlockUser(): void\n    {\n        $json = $this->activityJsonBuilder->buildActivityJson($this->getUndoBlockUserActivity());\n\n        $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver());\n    }\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Outbox/UpdateTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Unit\\ActivityPub\\Outbox;\n\nuse App\\Tests\\ActivityPubJsonDriver;\nuse App\\Tests\\ActivityPubTestCase;\nuse App\\Tests\\Unit\\ActivityPub\\Traits\\UpdateActivityGeneratorTrait;\n\nclass UpdateTest extends ActivityPubTestCase\n{\n    use UpdateActivityGeneratorTrait;\n\n    public function testUpdateUser(): void\n    {\n        $this->user->title = 'Test User';\n        $this->entityManager->persist($this->user);\n        $this->entityManager->flush();\n\n        $json = $this->activityJsonBuilder->buildActivityJson($this->getUpdateUserActivity());\n\n        $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver());\n    }\n\n    public function testUpdateMagazine(): void\n    {\n        $json = $this->activityJsonBuilder->buildActivityJson($this->getUpdateMagazineActivity());\n\n        $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver());\n    }\n\n    public function testUpdateEntry(): void\n    {\n        $json = $this->activityJsonBuilder->buildActivityJson($this->getUpdateEntryActivity());\n\n        $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver());\n    }\n\n    public function testUpdateEntryComment(): void\n    {\n        $json = $this->activityJsonBuilder->buildActivityJson($this->getUpdateEntryCommentActivity());\n\n        $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver());\n    }\n\n    public function testUpdatePost(): void\n    {\n        $json = $this->activityJsonBuilder->buildActivityJson($this->getUpdatePostActivity());\n\n        $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver());\n    }\n\n    public function testUpdatePostComment(): void\n    {\n        $json = $this->activityJsonBuilder->buildActivityJson($this->getUpdatePostCommentActivity());\n\n        $this->assertMatchesSnapshot($json, new ActivityPubJsonDriver());\n    }\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/TagMatchTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Unit\\ActivityPub;\n\nuse App\\ActivityPub\\JsonRd;\nuse App\\Entity\\Magazine;\nuse App\\Entity\\User;\nuse App\\Event\\ActivityPub\\WebfingerResponseEvent;\nuse App\\Message\\ActivityPub\\Inbox\\CreateMessage;\nuse App\\Message\\ActivityPub\\Inbox\\LikeMessage;\nuse App\\Service\\ActivityPub\\Webfinger\\WebFingerFactory;\nuse App\\Tests\\WebTestCase;\nuse Doctrine\\Common\\Collections\\ArrayCollection;\n\nclass TagMatchTest extends WebTestCase\n{\n    private array $domains = [\n        'mbin1.tld',\n        'mbin2.tld',\n        'mbin3.tld',\n        'mbin4.tld',\n        'mbin5.tld',\n        'mbin6.tld',\n        'mbin7.tld',\n        'mbin8.tld',\n        'mbin9.tld',\n        'mbin10.tld',\n    ];\n\n    /** @var User[] */\n    private array $remoteUsers = [];\n\n    /** @var Magazine[] */\n    private array $remoteMagazines = [];\n\n    /**\n     * Create 10 remote users ('user1'...'user10') and 10 remote magazines (all called mbin) with their webfingers\n     * and 1 entry in each of the magazines.\n     */\n    public function createMockedRemoteObjects(): void\n    {\n        $prevDomain = $this->settingsManager->get('KBIN_DOMAIN');\n\n        foreach ($this->domains as $domain) {\n            $this->settingsManager->set('KBIN_DOMAIN', $domain);\n            $context = $this->router->getContext();\n            $context->setHost($domain);\n\n            $username = 'user';\n            $user = $this->getUserByUsername($username);\n            $json = $this->personFactory->create($user);\n            $this->testingApHttpClient->actorObjects[$json['id']] = $json;\n\n            $userEvent = new WebfingerResponseEvent(new JsonRd(), \"acct:$username@$domain\", ['account' => $username]);\n            $this->eventDispatcher->dispatch($userEvent);\n            $realDomain = \\sprintf(WebFingerFactory::WEBFINGER_URL, 'https', $domain, '', \"$username@$domain\");\n            $this->testingApHttpClient->webfingerObjects[$realDomain] = $userEvent->jsonRd->toArray();\n\n            $magazineName = 'mbin';\n            $magazine = $this->getMagazineByName($magazineName, user: $user);\n            $json = $this->groupFactory->create($magazine);\n            $this->testingApHttpClient->actorObjects[$json['id']] = $json;\n\n            $magazineEvent = new WebfingerResponseEvent(new JsonRd(), \"acct:$magazineName@$domain\", ['account' => $magazineName]);\n            $this->eventDispatcher->dispatch($magazineEvent);\n            $realDomain = \\sprintf(WebFingerFactory::WEBFINGER_URL, 'https', $domain, '', \"$magazineName@$domain\");\n            $this->testingApHttpClient->webfingerObjects[$realDomain] = $magazineEvent->jsonRd->toArray();\n\n            $entry = $this->getEntryByTitle(\"test from $domain\", magazine: $magazine, user: $user);\n            $json = $this->pageFactory->create($entry, $this->tagLinkRepository->getTagsOfContent($entry));\n            $this->testingApHttpClient->activityObjects[$json['id']] = $json;\n\n            $activity = $this->createWrapper->build($entry);\n            $create = $this->activityJsonBuilder->buildActivityJson($activity);\n            $this->testingApHttpClient->activityObjects[$create['id']] = $create;\n\n            $this->entityManager->remove($activity);\n            $this->entityManager->remove($entry);\n            $this->entityManager->remove($magazine);\n            $this->entityManager->remove($user);\n            $this->entityManager->flush();\n            $this->entityManager->clear();\n\n            $this->entries = new ArrayCollection();\n            $this->magazines = new ArrayCollection();\n            $this->users = new ArrayCollection();\n        }\n\n        $this->settingsManager->set('KBIN_DOMAIN', $prevDomain);\n        $context = $this->router->getContext();\n        $context->setHost($prevDomain);\n\n        $this->testingApHttpClient->actorObjects[$this->mastodonUser['id']] = $this->mastodonUser;\n        $this->testingApHttpClient->activityObjects[$this->mastodonPost['id']] = $this->mastodonPost;\n        $this->testingApHttpClient->webfingerObjects[\\sprintf(WebFingerFactory::WEBFINGER_URL, 'https', 'masto.don', '', 'User@masto.don')] = $this->mastodonWebfinger;\n    }\n\n    public function setUp(): void\n    {\n        sort($this->domains);\n        parent::setUp();\n\n        $admin = $this->getUserByUsername('admin', isAdmin: true);\n        $this->getMagazineByName('random', user: $admin);\n\n        $this->createMockedRemoteObjects();\n        $user = $this->getUserByUsername('user');\n        $magazine = $this->getMagazineByName('matching_mbin', user: $user);\n        $magazine->title = 'Matching Mbin';\n        $magazine->tags = ['mbin'];\n        $this->entityManager->persist($magazine);\n        $this->entityManager->flush();\n\n        foreach ($this->domains as $domain) {\n            $this->remoteUsers[] = $this->activityPubManager->findActorOrCreate(\"user@$domain\");\n            $this->remoteMagazines[] = $this->activityPubManager->findActorOrCreate(\"mbin@$domain\");\n        }\n\n        foreach ($this->remoteUsers as $remoteUser) {\n            $this->magazineManager->subscribe($magazine, $remoteUser);\n        }\n\n        foreach ($this->remoteMagazines as $remoteMagazine) {\n            $this->magazineManager->subscribe($remoteMagazine, $user);\n        }\n    }\n\n    public function testMatching(): void\n    {\n        self::assertEquals(\\sizeof($this->domains), \\sizeof(array_filter($this->remoteMagazines)));\n        self::assertEquals(\\sizeof($this->domains), \\sizeof(array_filter($this->remoteUsers)));\n\n        $this->pullInRemoteEntries();\n        $this->pullInMastodonPost();\n\n        $postedObjects = $this->testingApHttpClient->getPostedObjects();\n        $postedAnnounces = array_filter($postedObjects, fn ($item) => 'Announce' === $item['payload']['type']);\n        $targetInboxes = array_map(fn ($item) => parse_url($item['inboxUrl'], PHP_URL_HOST), $postedAnnounces);\n        sort($targetInboxes);\n        self::assertArrayIsEqualToArrayIgnoringListOfKeys($this->domains, $targetInboxes, []);\n    }\n\n    public function testMatchingLikeAnnouncing(): void\n    {\n        self::assertEquals(\\sizeof($this->domains), \\sizeof(array_filter($this->remoteMagazines)));\n        self::assertEquals(\\sizeof($this->domains), \\sizeof(array_filter($this->remoteUsers)));\n\n        $this->pullInRemoteEntries();\n        $this->pullInMastodonPost();\n\n        $mastodonPost = $this->postRepository->findOneBy(['apId' => $this->mastodonPost['id']]);\n        $user = $this->getUserByUsername('user');\n        $this->favouriteManager->toggle($user, $mastodonPost);\n\n        $postedObjects = $this->testingApHttpClient->getPostedObjects();\n        $postedLikes = array_filter($postedObjects, fn ($item) => 'Like' === $item['payload']['type']);\n        $targetInboxes2 = array_map(fn ($item) => parse_url($item['inboxUrl'], PHP_URL_HOST), $postedLikes);\n        sort($targetInboxes2);\n\n        // the pure like activity is expected to be sent to the author of the post\n        $expectedInboxes = [...$this->domains, parse_url($mastodonPost->user->apInboxUrl, PHP_URL_HOST)];\n        sort($expectedInboxes);\n        self::assertArrayIsEqualToArrayIgnoringListOfKeys($expectedInboxes, $targetInboxes2, []);\n\n        // dispatch a remote like message, so we trigger the announcement of it\n        $activity = $this->likeWrapper->build($this->remoteUsers[0], $mastodonPost);\n        $json = $this->activityJsonBuilder->buildActivityJson($activity);\n        $this->testingApHttpClient->activityObjects[$json['id']] = $json;\n        $this->bus->dispatch(new LikeMessage($json));\n\n        $postedObjects = $this->testingApHttpClient->getPostedObjects();\n        $postedLikeAnnounces = array_filter($postedObjects, fn ($item) => 'Announce' === $item['payload']['type'] && 'Like' === $item['payload']['object']['type']);\n        $targetInboxes3 = array_map(fn ($item) => parse_url($item['inboxUrl'], PHP_URL_HOST), $postedLikeAnnounces);\n        sort($targetInboxes3);\n\n        // the announcement of the like is expected to be delivered only to the subscribers of the magazine,\n        // because we expect the pure like activity to already be sent to the author of the post by the remote server\n        self::assertArrayIsEqualToArrayIgnoringListOfKeys($this->domains, $targetInboxes3, []);\n    }\n\n    private function pullInRemoteEntries(): void\n    {\n        foreach (array_filter($this->testingApHttpClient->activityObjects, fn ($item) => 'Page' === $item['type']) as $apObject) {\n            $this->bus->dispatch(new CreateMessage($apObject));\n            $entry = $this->entryRepository->findOneBy(['apId' => $apObject['id']]);\n            self::assertNotNull($entry);\n        }\n    }\n\n    private function pullInMastodonPost(): void\n    {\n        $createActivity = $this->mastodonCreatePost;\n        $createActivity['object'] = $this->mastodonPost;\n        $this->bus->dispatch(new CreateMessage($this->mastodonPost, fullCreatePayload: $createActivity));\n    }\n\n    private array $mastodonUser = [\n        'id' => 'https://masto.don/users/User',\n        'type' => 'Person',\n        'following' => 'https://masto.don/users/User/following',\n        'followers' => 'https://masto.don/users/User/followers',\n        'inbox' => 'https://masto.don/users/User/inbox',\n        'outbox' => 'https://masto.don/users/User/outbox',\n        'featured' => 'https://masto.don/users/User/collections/featured',\n        'featuredTags' => 'https://masto.don/users/User/collections/tags',\n        'preferredUsername' => 'User',\n        'name' => 'User',\n        'summary' => '<p>Some summary</p>',\n        'url' => 'https://masto.don/@User',\n        'manuallyApprovesFollowers' => false,\n        'discoverable' => true,\n        'indexable' => true,\n        'published' => '2025-01-01T00:00:00Z',\n        'memorial' => false,\n        'publicKey' => [\n            'id' => 'https://masto.don/users/User#main-key',\n            'owner' => 'https://masto.don/users/User',\n            'publicKeyPem' => \"-----BEGIN PUBLIC KEY-----\\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAujdiYalTtr7R1CJIVBIy\\nP50V+/JX+P15o0Cz0LUOhKvJIVyeV6szQGHj6Idu74x9e3+xf9jzQRCH6eq8ASAH\\nHAKwdnHfhSmKbCQaTEI5V8497/4yU9z9Zn7uJ+C1rrKVIEoGGkpt8bK8fynfR/hb\\n17FctW6EnrVrvNHyW+WwbyEbyqAxwbcOYd78PhdftWEdP6D+t4+XUoF9N1XGpsGO\\nrixJDzMwNqkg9Gg9l/mnCmxV367xgh8qHC0SNmwaMbWv6AV/07dHWlr0N1pXmHqo\\n9YkOEy7XuH1hovBzHWEf++P1Ew4bstwdfyS/m5bcakmSe+dR3WDylW336nO88vAF\\nCQIDAQAB\\n-----END PUBLIC KEY-----\\n\",\n        ],\n        'tag' => [],\n        'attachment' => [],\n        'endpoints' => [\n            'sharedInbox' => 'https://masto.don/inbox',\n        ],\n    ];\n\n    private array $mastodonCreatePost = [\n        'id' => 'https://masto.don/users/User/activities/create/110226274955756643',\n        'type' => 'Create',\n        'actor' => 'https://masto.don/users/User',\n        'to' => [\n            'https://www.w3.org/ns/activitystreams#Public',\n        ],\n        'cc' => [\n            'https://masto.don/users/User/followers',\n        ],\n    ];\n\n    private array $mastodonPost = [\n        'id' => 'https://masto.don/users/User/statuses/110226274955756643',\n        'type' => 'Note',\n        'summary' => null,\n        'inReplyTo' => null,\n        'published' => '2025-01-01T15:51:18Z',\n        'url' => 'https://masto.don/@User/110226274955756643',\n        'attributedTo' => 'https://masto.don/users/User',\n        'to' => [\n            'https://www.w3.org/ns/activitystreams#Public',\n        ],\n        'cc' => [\n            'https://masto.don/users/User/followers',\n        ],\n        'sensitive' => false,\n        'atomUri' => 'https://masto.don/users/User/statuses/110226274955756643',\n        'inReplyToAtomUri' => null,\n        'conversation' => 'tag:masto.don,2025-01-01:objectId=399588:objectType=Conversation',\n        'content' => '<p>I am very excited about <a href=\"https://masto.don/tags/mbin\" class=\"mention hashtag\" rel=\"tag\">#<span>mbin</span></a></p>',\n        'contentMap' => [\n            'de' => '<p>I am very excited about <a href=\"https://masto.don/tags/mbin\" class=\"mention hashtag\" rel=\"tag\">#<span>mbin</span></a></p>',\n        ],\n        'attachment' => [],\n        'tag' => [\n            [\n                'type' => 'Hashtag',\n                'href' => 'https://masto.don/tags/mbin',\n                'name' => '#mbin',\n            ],\n        ],\n        'replies' => [\n            'id' => 'https://masto.don/users/User/statuses/110226274955756643/replies',\n            'type' => 'Collection',\n            'first' => [\n                'type' => 'CollectionPage',\n                'next' => 'https://masto.don/users/User/statuses/110226274955756643/replies?min_id=110226283102047096&page=true',\n                'partOf' => 'https://masto.don/users/User/statuses/110226274955756643/replies',\n                'items' => [\n                    'https://masto.don/users/User/statuses/110226283102047096',\n                ],\n            ],\n        ],\n        'likes' => [\n            'id' => 'https://masto.don/users/User/statuses/110226274955756643/likes',\n            'type' => 'Collection',\n            'totalItems' => 0,\n        ],\n        'shares' => [\n            'id' => 'https://masto.don/users/User/statuses/110226274955756643/shares',\n            'type' => 'Collection',\n            'totalItems' => 0,\n        ],\n    ];\n\n    private array $mastodonWebfinger = [\n        'subject' => 'acct:User@masto.don',\n        'aliases' => [\n            'https://masto.don/@User',\n            'https://masto.don/users/User',\n        ],\n        'links' => [\n            [\n                'rel' => 'http://webfinger.net/rel/profile-page',\n                'type' => 'text/html',\n                'href' => 'https://masto.don/@User',\n            ],\n            [\n                'rel' => 'self',\n                'type' => 'application/activity+json',\n                'href' => 'https://masto.don/users/User',\n            ],\n            [\n                'rel' => 'http://ostatus.org/schema/1.0/subscribe',\n                'template' => 'https://masto.don/authorize_interaction?uri=[uri]',\n            ],\n        ],\n    ];\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Traits/AddRemoveActivityGeneratorTrait.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Unit\\ActivityPub\\Traits;\n\nuse App\\Entity\\Activity;\n\ntrait AddRemoveActivityGeneratorTrait\n{\n    public function getAddModeratorActivity(): Activity\n    {\n        return $this->addRemoveFactory->buildAddModerator($this->owner, $this->user, $this->magazine);\n    }\n\n    public function getRemoveModeratorActivity(): Activity\n    {\n        return $this->addRemoveFactory->buildRemoveModerator($this->owner, $this->user, $this->magazine);\n    }\n\n    public function getAddPinnedPostActivity(): Activity\n    {\n        $entry = $this->getEntryByTitle('test', magazine: $this->magazine, user: $this->user);\n\n        return $this->addRemoveFactory->buildAddPinnedPost($this->owner, $entry);\n    }\n\n    public function getRemovePinnedPostActivity(): Activity\n    {\n        $entry = $this->getEntryByTitle('test', magazine: $this->magazine, user: $this->user);\n\n        return $this->addRemoveFactory->buildRemovePinnedPost($this->owner, $entry);\n    }\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Traits/AnnounceActivityGeneratorTrait.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Unit\\ActivityPub\\Traits;\n\nuse App\\Entity\\Activity;\n\ntrait AnnounceActivityGeneratorTrait\n{\n    public function getAnnounceAddModeratorActivity(): Activity\n    {\n        return $this->announceWrapper->build($this->magazine, $this->getAddModeratorActivity());\n    }\n\n    public function getAnnounceRemoveModeratorActivity(): Activity\n    {\n        return $this->announceWrapper->build($this->magazine, $this->getRemoveModeratorActivity());\n    }\n\n    public function getAnnounceAddPinnedPostActivity(): Activity\n    {\n        return $this->announceWrapper->build($this->magazine, $this->getAddPinnedPostActivity());\n    }\n\n    public function getAnnounceRemovePinnedPostActivity(): Activity\n    {\n        return $this->announceWrapper->build($this->magazine, $this->getRemovePinnedPostActivity());\n    }\n\n    public function getAnnounceCreateEntryActivity(): Activity\n    {\n        return $this->announceWrapper->build($this->magazine, $this->getCreateEntryActivity());\n    }\n\n    public function getAnnounceCreateEntryCommentActivity(): Activity\n    {\n        return $this->announceWrapper->build($this->magazine, $this->getCreateEntryCommentActivity());\n    }\n\n    public function getAnnounceCreateNestedEntryCommentActivity(): Activity\n    {\n        return $this->announceWrapper->build($this->magazine, $this->getCreateNestedEntryCommentActivity());\n    }\n\n    public function getAnnounceCreatePostActivity(): Activity\n    {\n        return $this->announceWrapper->build($this->magazine, $this->getCreatePostActivity());\n    }\n\n    public function getAnnounceCreatePostCommentActivity(): Activity\n    {\n        return $this->announceWrapper->build($this->magazine, $this->getCreatePostCommentActivity());\n    }\n\n    public function getAnnounceCreateNestedPostCommentActivity(): Activity\n    {\n        return $this->announceWrapper->build($this->magazine, $this->getCreateNestedPostCommentActivity());\n    }\n\n    public function getAnnounceCreateMessageActivity(): Activity\n    {\n        return $this->announceWrapper->build($this->magazine, $this->getCreateMessageActivity());\n    }\n\n    public function getAnnounceDeleteUserActivity(): Activity\n    {\n        return $this->announceWrapper->build($this->magazine, $this->getDeleteUserActivity());\n    }\n\n    public function getAnnounceDeleteEntryActivity(): Activity\n    {\n        return $this->announceWrapper->build($this->magazine, $this->getDeleteEntryActivity());\n    }\n\n    public function getAnnounceDeleteEntryByModeratorActivity(): Activity\n    {\n        return $this->announceWrapper->build($this->magazine, $this->getDeleteEntryByModeratorActivity());\n    }\n\n    public function getAnnounceDeleteEntryCommentActivity(): Activity\n    {\n        return $this->announceWrapper->build($this->magazine, $this->getDeleteEntryCommentActivity());\n    }\n\n    public function getAnnounceDeleteEntryCommentByModeratorActivity(): Activity\n    {\n        return $this->announceWrapper->build($this->magazine, $this->getDeleteEntryCommentByModeratorActivity());\n    }\n\n    public function getAnnounceDeletePostActivity(): Activity\n    {\n        return $this->announceWrapper->build($this->magazine, $this->getDeletePostActivity());\n    }\n\n    public function getAnnounceDeletePostByModeratorActivity(): Activity\n    {\n        return $this->announceWrapper->build($this->magazine, $this->getDeletePostByModeratorActivity());\n    }\n\n    public function getAnnounceDeletePostCommentActivity(): Activity\n    {\n        return $this->announceWrapper->build($this->magazine, $this->getDeletePostCommentActivity());\n    }\n\n    public function getAnnounceDeletePostCommentByModeratorActivity(): Activity\n    {\n        return $this->announceWrapper->build($this->magazine, $this->getDeletePostCommentByModeratorActivity());\n    }\n\n    public function getUserBoostEntryActivity(): Activity\n    {\n        $entry = $this->getEntryByTitle('test', magazine: $this->magazine, user: $this->user);\n\n        return $this->announceWrapper->build($this->user, $entry, true);\n    }\n\n    public function getMagazineBoostEntryActivity(): Activity\n    {\n        $entry = $this->getEntryByTitle('test', magazine: $this->magazine, user: $this->user);\n\n        return $this->announceWrapper->build($this->magazine, $entry, true);\n    }\n\n    public function getUserBoostEntryCommentActivity(): Activity\n    {\n        $entry = $this->getEntryByTitle('test', magazine: $this->magazine, user: $this->user);\n        $entryComment = $this->createEntryComment('test', entry: $entry, user: $this->user);\n\n        return $this->announceWrapper->build($this->user, $entryComment, true);\n    }\n\n    public function getMagazineBoostEntryCommentActivity(): Activity\n    {\n        $entry = $this->getEntryByTitle('test', magazine: $this->magazine, user: $this->user);\n        $entryComment = $this->createEntryComment('test', entry: $entry, user: $this->user);\n\n        return $this->announceWrapper->build($this->magazine, $entryComment, true);\n    }\n\n    public function getUserBoostNestedEntryCommentActivity(): Activity\n    {\n        $entry = $this->getEntryByTitle('test', magazine: $this->magazine, user: $this->user);\n        $entryComment = $this->createEntryComment('test', entry: $entry, user: $this->user);\n        $entryComment2 = $this->createEntryComment('test', entry: $entry, user: $this->user, parent: $entryComment);\n\n        return $this->announceWrapper->build($this->user, $entryComment2, true);\n    }\n\n    public function getMagazineBoostNestedEntryCommentActivity(): Activity\n    {\n        $entry = $this->getEntryByTitle('test', magazine: $this->magazine, user: $this->user);\n        $entryComment = $this->createEntryComment('test', entry: $entry, user: $this->user);\n        $entryComment2 = $this->createEntryComment('test', entry: $entry, user: $this->user, parent: $entryComment);\n\n        return $this->announceWrapper->build($this->magazine, $entryComment2, true);\n    }\n\n    public function getUserBoostPostActivity(): Activity\n    {\n        $post = $this->createPost('test', magazine: $this->magazine, user: $this->user);\n\n        return $this->announceWrapper->build($this->user, $post, true);\n    }\n\n    public function getMagazineBoostPostActivity(): Activity\n    {\n        $post = $this->createPost('test', magazine: $this->magazine, user: $this->user);\n\n        return $this->announceWrapper->build($this->magazine, $post, true);\n    }\n\n    public function getUserBoostPostCommentActivity(): Activity\n    {\n        $post = $this->createPost('test', magazine: $this->magazine, user: $this->user);\n        $postComment = $this->createPostComment('test', post: $post, user: $this->user);\n\n        return $this->announceWrapper->build($this->user, $postComment, true);\n    }\n\n    public function getMagazineBoostPostCommentActivity(): Activity\n    {\n        $post = $this->createPost('test', magazine: $this->magazine, user: $this->user);\n        $postComment = $this->createPostComment('test', post: $post, user: $this->user);\n\n        return $this->announceWrapper->build($this->magazine, $postComment, true);\n    }\n\n    public function getUserBoostNestedPostCommentActivity(): Activity\n    {\n        $post = $this->createPost('test', magazine: $this->magazine, user: $this->user);\n        $postComment = $this->createPostComment('test', post: $post, user: $this->user);\n        $postComment2 = $this->createPostComment('test', post: $post, user: $this->user, parent: $postComment);\n\n        return $this->announceWrapper->build($this->user, $postComment2, true);\n    }\n\n    public function getMagazineBoostNestedPostCommentActivity(): Activity\n    {\n        $post = $this->createPost('test', magazine: $this->magazine, user: $this->user);\n        $postComment = $this->createPostComment('test', post: $post, user: $this->user);\n        $postComment2 = $this->createPostComment('test', post: $post, user: $this->user, parent: $postComment);\n\n        return $this->announceWrapper->build($this->magazine, $postComment2, true);\n    }\n\n    public function getAnnounceLikeEntryActivity(): Activity\n    {\n        $like = $this->getLikeEntryActivity();\n\n        return $this->announceWrapper->build($this->magazine, $like, true);\n    }\n\n    public function getAnnounceLikeEntryCommentActivity(): Activity\n    {\n        $like = $this->getLikeEntryCommentActivity();\n\n        return $this->announceWrapper->build($this->magazine, $like, true);\n    }\n\n    public function getAnnounceLikeNestedEntryCommentActivity(): Activity\n    {\n        $like = $this->getLikeNestedEntryCommentActivity();\n\n        return $this->announceWrapper->build($this->magazine, $like, true);\n    }\n\n    public function getAnnounceLikePostActivity(): Activity\n    {\n        $like = $this->getLikePostActivity();\n\n        return $this->announceWrapper->build($this->magazine, $like, true);\n    }\n\n    public function getAnnounceLikePostCommentActivity(): Activity\n    {\n        $like = $this->getLikePostCommentActivity();\n\n        return $this->announceWrapper->build($this->magazine, $like, true);\n    }\n\n    public function getAnnounceLikeNestedPostCommentActivity(): Activity\n    {\n        $like = $this->getLikeNestedPostCommentActivity();\n\n        return $this->announceWrapper->build($this->magazine, $like, true);\n    }\n\n    public function getAnnounceUndoLikeEntryActivity(): Activity\n    {\n        return $this->announceWrapper->build($this->magazine, $this->getUndoLikeEntryActivity());\n    }\n\n    public function getAnnounceUndoLikeEntryCommentActivity(): Activity\n    {\n        return $this->announceWrapper->build($this->magazine, $this->getUndoLikeEntryCommentActivity());\n    }\n\n    public function getAnnounceUndoLikeNestedEntryCommentActivity(): Activity\n    {\n        return $this->announceWrapper->build($this->magazine, $this->getUndoLikeNestedEntryCommentActivity());\n    }\n\n    public function getAnnounceUndoLikePostActivity(): Activity\n    {\n        return $this->announceWrapper->build($this->magazine, $this->getUndoLikePostActivity());\n    }\n\n    public function getAnnounceUndoLikePostCommentActivity(): Activity\n    {\n        return $this->announceWrapper->build($this->magazine, $this->getUndoLikePostCommentActivity());\n    }\n\n    public function getAnnounceUndoLikeNestedPostCommentActivity(): Activity\n    {\n        return $this->announceWrapper->build($this->magazine, $this->getUndoLikeNestedPostCommentActivity());\n    }\n\n    public function getAnnounceUpdateUserActivity(): Activity\n    {\n        return $this->announceWrapper->build($this->magazine, $this->getUpdateUserActivity());\n    }\n\n    public function getAnnounceUpdateMagazineActivity(): Activity\n    {\n        return $this->announceWrapper->build($this->magazine, $this->getUpdateMagazineActivity());\n    }\n\n    public function getAnnounceUpdateEntryActivity(): Activity\n    {\n        return $this->announceWrapper->build($this->magazine, $this->getUpdateEntryActivity());\n    }\n\n    public function getAnnounceUpdateEntryCommentActivity(): Activity\n    {\n        return $this->announceWrapper->build($this->magazine, $this->getUpdateEntryCommentActivity());\n    }\n\n    public function getAnnounceUpdatePostActivity(): Activity\n    {\n        return $this->announceWrapper->build($this->magazine, $this->getUpdatePostActivity());\n    }\n\n    public function getAnnounceUpdatePostCommentActivity(): Activity\n    {\n        return $this->announceWrapper->build($this->magazine, $this->getUpdatePostCommentActivity());\n    }\n\n    public function getAnnounceBlockUserActivity(): Activity\n    {\n        return $this->announceWrapper->build($this->magazine, $this->getBlockUserActivity());\n    }\n\n    public function getAnnounceUndoBlockUserActivity(): Activity\n    {\n        return $this->announceWrapper->build($this->magazine, $this->getUndoBlockUserActivity());\n    }\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Traits/BlockActivityGeneratorTrait.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Unit\\ActivityPub\\Traits;\n\nuse App\\Entity\\Activity;\n\ntrait BlockActivityGeneratorTrait\n{\n    public function getBlockUserActivity(): Activity\n    {\n        $ban = $this->magazine->addBan($this->user, $this->owner, 'some test', null);\n\n        return $this->blockFactory->createActivityFromMagazineBan($ban);\n    }\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Traits/CreateActivityGeneratorTrait.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Unit\\ActivityPub\\Traits;\n\nuse App\\Entity\\Activity;\n\ntrait CreateActivityGeneratorTrait\n{\n    public function getCreateEntryActivity(): Activity\n    {\n        $entry = $this->getEntryByTitle('test', magazine: $this->magazine, user: $this->user);\n\n        return $this->createWrapper->build($entry);\n    }\n\n    public function getCreateEntryActivityWithImageAndUrl(): Activity\n    {\n        $entry = $this->getEntryByTitle('test', url: 'https://joinmbin.org', magazine: $this->magazine, user: $this->user, image: $this->getKibbyImageDto());\n\n        return $this->createWrapper->build($entry);\n    }\n\n    public function getCreateEntryCommentActivity(): Activity\n    {\n        $entry = $this->getEntryByTitle('test', magazine: $this->magazine, user: $this->user);\n        $entryComment = $this->createEntryComment('test', entry: $entry, user: $this->user);\n\n        return $this->createWrapper->build($entryComment);\n    }\n\n    public function getCreateNestedEntryCommentActivity(): Activity\n    {\n        $entry = $this->getEntryByTitle('test', magazine: $this->magazine, user: $this->user);\n        $entryComment = $this->createEntryComment('test', entry: $entry, user: $this->user);\n        $entryComment2 = $this->createEntryComment('test', entry: $entry, user: $this->user, parent: $entryComment);\n\n        return $this->createWrapper->build($entryComment2);\n    }\n\n    public function getCreatePostActivity(): Activity\n    {\n        $post = $this->createPost('test', magazine: $this->magazine, user: $this->user);\n\n        return $this->createWrapper->build($post);\n    }\n\n    public function getCreatePostCommentActivity(): Activity\n    {\n        $post = $this->createPost('test', magazine: $this->magazine, user: $this->user);\n        $postComment = $this->createPostComment('test', post: $post, user: $this->user);\n\n        return $this->createWrapper->build($postComment);\n    }\n\n    public function getCreateNestedPostCommentActivity(): Activity\n    {\n        $post = $this->createPost('test', magazine: $this->magazine, user: $this->user);\n        $postComment = $this->createPostComment('test', post: $post, user: $this->user);\n        $postComment2 = $this->createPostComment('test', post: $post, user: $this->user, parent: $postComment);\n\n        return $this->createWrapper->build($postComment2);\n    }\n\n    public function getCreateMessageActivity(): Activity\n    {\n        $user2 = $this->getUserByUsername('user2');\n        $message = $this->createMessage($user2, $this->user, 'some test message');\n\n        return $this->createWrapper->build($message);\n    }\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Traits/DeleteActivityGeneratorTrait.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Unit\\ActivityPub\\Traits;\n\nuse App\\Entity\\Activity;\n\ntrait DeleteActivityGeneratorTrait\n{\n    public function getDeleteUserActivity(): Activity\n    {\n        return $this->deleteWrapper->buildForUser($this->user);\n    }\n\n    public function getDeleteEntryActivity(): Activity\n    {\n        $entry = $this->getEntryByTitle('test', magazine: $this->magazine, user: $this->user);\n\n        return $this->deleteWrapper->adjustDeletePayload($this->user, $entry);\n    }\n\n    public function getDeleteEntryByModeratorActivity(): Activity\n    {\n        $entry = $this->getEntryByTitle('test', magazine: $this->magazine, user: $this->user);\n\n        return $this->deleteWrapper->adjustDeletePayload($this->owner, $entry);\n    }\n\n    public function getDeleteEntryCommentActivity(): Activity\n    {\n        $entry = $this->getEntryByTitle('test', magazine: $this->magazine, user: $this->user);\n        $entryComment = $this->createEntryComment('test', entry: $entry, user: $this->user);\n\n        return $this->deleteWrapper->adjustDeletePayload($this->user, $entryComment);\n    }\n\n    public function getDeleteEntryCommentByModeratorActivity(): Activity\n    {\n        $entry = $this->getEntryByTitle('test', magazine: $this->magazine, user: $this->user);\n        $entryComment = $this->createEntryComment('test', entry: $entry, user: $this->user);\n\n        return $this->deleteWrapper->adjustDeletePayload($this->owner, $entryComment);\n    }\n\n    public function getDeletePostActivity(): Activity\n    {\n        $post = $this->createPost('test', magazine: $this->magazine, user: $this->user);\n\n        return $this->deleteWrapper->adjustDeletePayload($this->user, $post);\n    }\n\n    public function getDeletePostByModeratorActivity(): Activity\n    {\n        $post = $this->createPost('test', magazine: $this->magazine, user: $this->user);\n\n        return $this->deleteWrapper->adjustDeletePayload($this->owner, $post);\n    }\n\n    public function getDeletePostCommentActivity(): Activity\n    {\n        $post = $this->createPost('test', magazine: $this->magazine, user: $this->user);\n        $postComment = $this->createPostComment('test', post: $post, user: $this->user);\n\n        return $this->deleteWrapper->adjustDeletePayload($this->user, $postComment);\n    }\n\n    public function getDeletePostCommentByModeratorActivity(): Activity\n    {\n        $post = $this->createPost('test', magazine: $this->magazine, user: $this->user);\n        $postComment = $this->createPostComment('test', post: $post, user: $this->user);\n\n        return $this->deleteWrapper->adjustDeletePayload($this->owner, $postComment);\n    }\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Traits/FlagActivityGeneratorTrait.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Unit\\ActivityPub\\Traits;\n\nuse App\\DTO\\ReportDto;\nuse App\\Entity\\Activity;\nuse App\\Entity\\User;\n\ntrait FlagActivityGeneratorTrait\n{\n    public function getFlagEntryActivity(User $reportingUser): Activity\n    {\n        $entry = $this->getEntryByTitle('test', magazine: $this->magazine, user: $this->user);\n\n        $report = $this->reportManager->report(ReportDto::create($entry), $reportingUser);\n\n        return $this->flagFactory->build($report);\n    }\n\n    public function getFlagEntryCommentActivity(User $reportingUser): Activity\n    {\n        $entry = $this->getEntryByTitle('test', magazine: $this->magazine, user: $this->user);\n        $entryComment = $this->createEntryComment('test', entry: $entry, user: $this->user);\n\n        $report = $this->reportManager->report(ReportDto::create($entryComment), $reportingUser);\n\n        return $this->flagFactory->build($report);\n    }\n\n    public function getFlagNestedEntryCommentActivity(User $reportingUser): Activity\n    {\n        $entry = $this->getEntryByTitle('test', magazine: $this->magazine, user: $this->user);\n        $entryComment = $this->createEntryComment('test', entry: $entry, user: $this->user);\n        $entryComment2 = $this->createEntryComment('test', entry: $entry, user: $this->user, parent: $entryComment);\n\n        $report = $this->reportManager->report(ReportDto::create($entryComment2), $reportingUser);\n\n        return $this->flagFactory->build($report);\n    }\n\n    public function getFlagPostActivity(User $reportingUser): Activity\n    {\n        $post = $this->createPost('test', magazine: $this->magazine, user: $this->user);\n\n        $report = $this->reportManager->report(ReportDto::create($post), $reportingUser);\n\n        return $this->flagFactory->build($report);\n    }\n\n    public function getFlagPostCommentActivity(User $reportingUser): Activity\n    {\n        $post = $this->createPost('test', magazine: $this->magazine, user: $this->user);\n        $postComment = $this->createPostComment('test', post: $post, user: $this->user);\n\n        $report = $this->reportManager->report(ReportDto::create($postComment), $reportingUser);\n\n        return $this->flagFactory->build($report);\n    }\n\n    public function getFlagNestedPostCommentActivity(User $reportingUser): Activity\n    {\n        $post = $this->createPost('test', magazine: $this->magazine, user: $this->user);\n        $postComment = $this->createPostComment('test', post: $post, user: $this->user);\n        $postComment2 = $this->createPostComment('test', post: $post, user: $this->user, parent: $postComment);\n\n        $report = $this->reportManager->report(ReportDto::create($postComment2), $reportingUser);\n\n        return $this->flagFactory->build($report);\n    }\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Traits/FollowActivityGeneratorTrait.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Unit\\ActivityPub\\Traits;\n\nuse App\\Entity\\Activity;\n\ntrait FollowActivityGeneratorTrait\n{\n    public function getFollowUserActivity(): Activity\n    {\n        $user2 = $this->getUserByUsername('user2');\n\n        return $this->followWrapper->build($user2, $this->user);\n    }\n\n    public function getAcceptFollowUserActivity(): Activity\n    {\n        return $this->followResponseWrapper->build($this->user, $this->getFollowUserActivity());\n    }\n\n    public function getRejectFollowUserActivity(): Activity\n    {\n        return $this->followResponseWrapper->build($this->user, $this->getFollowUserActivity(), isReject: true);\n    }\n\n    public function getFollowMagazineActivity(): Activity\n    {\n        $user2 = $this->getUserByUsername('user2');\n\n        return $this->followWrapper->build($user2, $this->magazine);\n    }\n\n    public function getAcceptFollowMagazineActivity(): Activity\n    {\n        return $this->followResponseWrapper->build($this->magazine, $this->getFollowMagazineActivity());\n    }\n\n    public function getRejectFollowMagazineActivity(): Activity\n    {\n        return $this->followResponseWrapper->build($this->magazine, $this->getFollowMagazineActivity(), isReject: true);\n    }\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Traits/LikeActivityGeneratorTrait.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Unit\\ActivityPub\\Traits;\n\nuse App\\Entity\\Activity;\n\ntrait LikeActivityGeneratorTrait\n{\n    public function getLikeEntryActivity(): Activity\n    {\n        $user2 = $this->getUserByUsername('user2');\n        $entry = $this->getEntryByTitle('test', magazine: $this->magazine, user: $this->user);\n\n        return $this->likeWrapper->build($user2, $entry);\n    }\n\n    public function getLikeEntryCommentActivity(): Activity\n    {\n        $user2 = $this->getUserByUsername('user2');\n        $entry = $this->getEntryByTitle('test', magazine: $this->magazine, user: $this->user);\n        $entryComment = $this->createEntryComment('test', entry: $entry, user: $this->user);\n\n        return $this->likeWrapper->build($user2, $entryComment);\n    }\n\n    public function getLikeNestedEntryCommentActivity(): Activity\n    {\n        $user2 = $this->getUserByUsername('user2');\n        $entry = $this->getEntryByTitle('test', magazine: $this->magazine, user: $this->user);\n        $entryComment = $this->createEntryComment('test', entry: $entry, user: $this->user);\n        $entryComment2 = $this->createEntryComment('test', entry: $entry, user: $this->user, parent: $entryComment);\n\n        return $this->likeWrapper->build($user2, $entryComment2);\n    }\n\n    public function getLikePostActivity(): Activity\n    {\n        $user2 = $this->getUserByUsername('user2');\n        $post = $this->createPost('test', magazine: $this->magazine, user: $this->user);\n\n        return $this->likeWrapper->build($user2, $post);\n    }\n\n    public function getLikePostCommentActivity(): Activity\n    {\n        $user2 = $this->getUserByUsername('user2');\n        $post = $this->createPost('test', magazine: $this->magazine, user: $this->user);\n        $postComment = $this->createPostComment('test', post: $post, user: $this->user);\n\n        return $this->likeWrapper->build($user2, $postComment);\n    }\n\n    public function getLikeNestedPostCommentActivity(): Activity\n    {\n        $user2 = $this->getUserByUsername('user2');\n        $post = $this->createPost('test', magazine: $this->magazine, user: $this->user);\n        $postComment = $this->createPostComment('test', post: $post, user: $this->user);\n        $postComment2 = $this->createPostComment('test', post: $post, user: $this->user, parent: $postComment);\n\n        return $this->likeWrapper->build($user2, $postComment2);\n    }\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Traits/LockActivityGeneratorTrait.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Unit\\ActivityPub\\Traits;\n\nuse App\\Entity\\Activity;\n\ntrait LockActivityGeneratorTrait\n{\n    public function getLockEntryActivityByAuthor(): Activity\n    {\n        $entry = $this->getEntryByTitle('test', magazine: $this->magazine, user: $this->user);\n\n        return $this->lockFactory->build($this->user, $entry);\n    }\n\n    public function getLockEntryActivityByModerator(): Activity\n    {\n        $entry = $this->getEntryByTitle('test', magazine: $this->magazine, user: $this->user);\n\n        return $this->lockFactory->build($this->owner, $entry);\n    }\n\n    public function getLockPostActivityByAuthor(): Activity\n    {\n        $post = $this->createPost('test', magazine: $this->magazine, user: $this->user);\n\n        return $this->lockFactory->build($this->user, $post);\n    }\n\n    public function getLockPostActivityByModerator(): Activity\n    {\n        $post = $this->createPost('test', magazine: $this->magazine, user: $this->user);\n\n        return $this->lockFactory->build($this->owner, $post);\n    }\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Traits/UndoActivityGeneratorTrait.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Unit\\ActivityPub\\Traits;\n\nuse App\\Entity\\Activity;\n\ntrait UndoActivityGeneratorTrait\n{\n    public function getUndoLikeEntryActivity(): Activity\n    {\n        return $this->undoWrapper->build($this->getLikeEntryActivity());\n    }\n\n    public function getUndoLikeEntryCommentActivity(): Activity\n    {\n        return $this->undoWrapper->build($this->getLikeEntryCommentActivity());\n    }\n\n    public function getUndoLikeNestedEntryCommentActivity(): Activity\n    {\n        return $this->undoWrapper->build($this->getLikeNestedEntryCommentActivity());\n    }\n\n    public function getUndoLikePostActivity(): Activity\n    {\n        return $this->undoWrapper->build($this->getLikePostActivity());\n    }\n\n    public function getUndoLikePostCommentActivity(): Activity\n    {\n        return $this->undoWrapper->build($this->getLikePostCommentActivity());\n    }\n\n    public function getUndoLikeNestedPostCommentActivity(): Activity\n    {\n        return $this->undoWrapper->build($this->getLikeNestedPostCommentActivity());\n    }\n\n    public function getUndoFollowUserActivity(): Activity\n    {\n        return $this->undoWrapper->build($this->getFollowUserActivity());\n    }\n\n    public function getUndoFollowMagazineActivity(): Activity\n    {\n        return $this->undoWrapper->build($this->getFollowMagazineActivity());\n    }\n\n    public function getUndoBlockUserActivity(): Activity\n    {\n        return $this->undoWrapper->build($this->getBlockUserActivity());\n    }\n}\n"
  },
  {
    "path": "tests/Unit/ActivityPub/Traits/UpdateActivityGeneratorTrait.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Unit\\ActivityPub\\Traits;\n\nuse App\\Entity\\Activity;\n\ntrait UpdateActivityGeneratorTrait\n{\n    public function getUpdateUserActivity(): Activity\n    {\n        return $this->updateWrapper->buildForActor($this->user);\n    }\n\n    public function getUpdateMagazineActivity(): Activity\n    {\n        return $this->updateWrapper->buildForActor($this->magazine, editedBy: $this->owner);\n    }\n\n    public function getUpdateEntryActivity(): Activity\n    {\n        $entry = $this->getEntryByTitle('test', magazine: $this->magazine, user: $this->user);\n\n        return $this->updateWrapper->buildForActivity($entry);\n    }\n\n    public function getUpdateEntryCommentActivity(): Activity\n    {\n        $entry = $this->getEntryByTitle('test', magazine: $this->magazine, user: $this->user);\n        $entryComment = $this->createEntryComment('test', entry: $entry, user: $this->user);\n\n        return $this->updateWrapper->buildForActivity($entryComment);\n    }\n\n    public function getUpdatePostActivity(): Activity\n    {\n        $post = $this->createPost('test', magazine: $this->magazine, user: $this->user);\n\n        return $this->updateWrapper->buildForActivity($post);\n    }\n\n    public function getUpdatePostCommentActivity(): Activity\n    {\n        $post = $this->createPost('test', magazine: $this->magazine, user: $this->user);\n        $postComment = $this->createPostComment('test', post: $post, user: $this->user);\n\n        return $this->updateWrapper->buildForActivity($postComment);\n    }\n}\n"
  },
  {
    "path": "tests/Unit/CursorPaginationTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Unit;\n\nuse App\\PageView\\ContentPageView;\nuse App\\Pagination\\Cursor\\CursorPagination;\nuse App\\Pagination\\Cursor\\CursorPaginationInterface;\nuse App\\Pagination\\Cursor\\NativeQueryCursorAdapter;\nuse App\\Repository\\Criteria;\nuse App\\Tests\\WebTestCase;\n\nclass CursorPaginationTest extends WebTestCase\n{\n    private CursorPaginationInterface $cursorPagination;\n\n    private array $createdEntries = [];\n\n    public function testCursorPaginationInteger(): void\n    {\n        $this->simpleSetUp();\n        $this->cursorPagination->setCurrentPage(-1);\n        $currentPage = $this->cursorPagination->getCurrentPageResults();\n\n        $i = 0;\n        foreach ($currentPage as $result) {\n            self::assertEquals($i, $result['value']);\n            ++$i;\n        }\n        self::assertEquals(3, $i);\n\n        self::assertTrue($this->cursorPagination->hasNextPage());\n        self::assertFalse($this->cursorPagination->hasPreviousPage());\n\n        $this->cursorPagination->setCurrentPage($this->cursorPagination->getNextPage()[0]);\n        $currentPage = $this->cursorPagination->getCurrentPageResults();\n\n        $i = 3;\n        foreach ($currentPage as $result) {\n            self::assertEquals($i, $result['value']);\n            ++$i;\n        }\n        self::assertEquals(6, $i);\n\n        self::assertTrue($this->cursorPagination->hasNextPage());\n        self::assertTrue($this->cursorPagination->hasPreviousPage());\n\n        $this->cursorPagination->setCurrentPage($this->cursorPagination->getNextPage()[0]);\n        $currentPage = $this->cursorPagination->getCurrentPageResults();\n\n        $i = 6;\n        foreach ($currentPage as $result) {\n            self::assertEquals($i, $result['value']);\n            ++$i;\n        }\n        self::assertEquals(9, $i);\n\n        self::assertTrue($this->cursorPagination->hasNextPage());\n        self::assertTrue($this->cursorPagination->hasPreviousPage());\n\n        $this->cursorPagination->setCurrentPage($this->cursorPagination->getNextPage()[0]);\n        $currentPage = $this->cursorPagination->getCurrentPageResults();\n\n        $i = 9;\n        foreach ($currentPage as $result) {\n            self::assertEquals($i, $result['value']);\n            ++$i;\n        }\n        self::assertEquals(10, $i);\n\n        self::assertFalse($this->cursorPagination->hasNextPage());\n        self::assertTrue($this->cursorPagination->hasPreviousPage());\n\n        $this->cursorPagination->setCurrentPage($this->cursorPagination->getPreviousPage()[0]);\n        $currentPage = $this->cursorPagination->getCurrentPageResults();\n\n        $i = 6;\n        foreach ($currentPage as $result) {\n            self::assertEquals($i, $result['value']);\n            ++$i;\n        }\n        self::assertEquals(9, $i);\n\n        self::assertTrue($this->cursorPagination->hasNextPage());\n        self::assertTrue($this->cursorPagination->hasPreviousPage());\n\n        $this->cursorPagination->setCurrentPage($this->cursorPagination->getPreviousPage()[0]);\n        $currentPage = $this->cursorPagination->getCurrentPageResults();\n\n        $i = 3;\n        foreach ($currentPage as $result) {\n            self::assertEquals($i, $result['value']);\n            ++$i;\n        }\n        self::assertEquals(6, $i);\n\n        self::assertTrue($this->cursorPagination->hasNextPage());\n        self::assertTrue($this->cursorPagination->hasPreviousPage());\n\n        $this->cursorPagination->setCurrentPage($this->cursorPagination->getPreviousPage()[0]);\n        $currentPage = $this->cursorPagination->getCurrentPageResults();\n\n        $i = 0;\n        foreach ($currentPage as $result) {\n            self::assertEquals($i, $result['value']);\n            ++$i;\n        }\n        self::assertEquals(3, $i);\n        self::assertTrue($this->cursorPagination->hasNextPage());\n        self::assertFalse($this->cursorPagination->hasPreviousPage());\n    }\n\n    public function testCursorPaginationEdgeCase(): void\n    {\n        $this->confusingSetUp();\n        $this->cursorPagination->setCurrentPage(-1);\n        $currentPage = $this->cursorPagination->getCurrentPageResults();\n\n        self::assertEquals(0, $currentPage[0]['value']);\n        self::assertEquals(0, $currentPage[0]['value2']);\n        self::assertEquals(0, $currentPage[1]['value']);\n        self::assertEquals(1, $currentPage[1]['value2']);\n        self::assertEquals(0, $currentPage[2]['value']);\n        self::assertEquals(2, $currentPage[2]['value2']);\n\n        self::assertTrue($this->cursorPagination->hasNextPage());\n        self::assertFalse($this->cursorPagination->hasPreviousPage());\n\n        $cursors = $this->cursorPagination->getNextPage();\n        self::assertEquals([0, 2], $cursors);\n        $this->cursorPagination->setCurrentPage($cursors[0], $cursors[1]);\n        $currentPage = $this->cursorPagination->getCurrentPageResults();\n\n        self::assertEquals(0, $currentPage[0]['value']);\n        self::assertEquals(3, $currentPage[0]['value2']);\n        self::assertEquals(0, $currentPage[1]['value']);\n        self::assertEquals(4, $currentPage[1]['value2']);\n        self::assertEquals(1, $currentPage[2]['value']);\n        self::assertEquals(5, $currentPage[2]['value2']);\n\n        self::assertTrue($this->cursorPagination->hasNextPage());\n        self::assertTrue($this->cursorPagination->hasPreviousPage());\n\n        $cursors = $this->cursorPagination->getNextPage();\n        self::assertEquals([1, 5], $cursors);\n        $this->cursorPagination->setCurrentPage($cursors[0], $cursors[1]);\n        $currentPage = $this->cursorPagination->getCurrentPageResults();\n\n        self::assertEquals(1, $currentPage[0]['value']);\n        self::assertEquals(6, $currentPage[0]['value2']);\n        self::assertEquals(1, $currentPage[1]['value']);\n        self::assertEquals(7, $currentPage[1]['value2']);\n        self::assertEquals(1, $currentPage[2]['value']);\n        self::assertEquals(8, $currentPage[2]['value2']);\n\n        self::assertTrue($this->cursorPagination->hasNextPage());\n        self::assertTrue($this->cursorPagination->hasPreviousPage());\n\n        $cursors = $this->cursorPagination->getPreviousPage();\n        self::assertEquals([0, 2], $cursors);\n        $this->cursorPagination->setCurrentPage($cursors[0], $cursors[1]);\n        $currentPage = $this->cursorPagination->getCurrentPageResults();\n\n        self::assertEquals(0, $currentPage[0]['value']);\n        self::assertEquals(3, $currentPage[0]['value2']);\n        self::assertEquals(0, $currentPage[1]['value']);\n        self::assertEquals(4, $currentPage[1]['value2']);\n        self::assertEquals(1, $currentPage[2]['value']);\n        self::assertEquals(5, $currentPage[2]['value2']);\n\n        self::assertTrue($this->cursorPagination->hasNextPage());\n        self::assertTrue($this->cursorPagination->hasPreviousPage());\n\n        $cursors = $this->cursorPagination->getPreviousPage();\n        self::assertEquals([0, -1], $cursors);\n        $this->cursorPagination->setCurrentPage($cursors[0], $cursors[1]);\n        $currentPage = $this->cursorPagination->getCurrentPageResults();\n\n        self::assertEquals(0, $currentPage[0]['value']);\n        self::assertEquals(0, $currentPage[0]['value2']);\n        self::assertEquals(0, $currentPage[1]['value']);\n        self::assertEquals(1, $currentPage[1]['value2']);\n        self::assertEquals(0, $currentPage[2]['value']);\n        self::assertEquals(2, $currentPage[2]['value2']);\n        self::assertTrue($this->cursorPagination->hasNextPage());\n        self::assertFalse($this->cursorPagination->hasPreviousPage());\n    }\n\n    public function simpleSetUp(): void\n    {\n        $tempTable = 'CREATE TEMPORARY TABLE cursorTest (value INT)';\n        $this->entityManager->getConnection()->executeQuery($tempTable);\n\n        for ($i = 0; $i < 10; ++$i) {\n            $this->entityManager->getConnection()->executeQuery(\"INSERT INTO cursorTest(value) VALUES($i)\");\n        }\n\n        $sql = 'SELECT * FROM cursorTest WHERE %cursor% ORDER BY %cursorSort%';\n\n        $this->cursorPagination = new CursorPagination(\n            new NativeQueryCursorAdapter(\n                $this->entityManager->getConnection(),\n                $sql,\n                'value > :cursor',\n                'value <= :cursor',\n                'value',\n                'value DESC',\n                [],\n            ),\n            'value',\n            3\n        );\n    }\n\n    public function confusingSetUp(): void\n    {\n        $tempTable = 'CREATE TEMPORARY TABLE cursorTest (value INT, value2 INT)';\n        $this->entityManager->getConnection()->executeQuery($tempTable);\n\n        $this->entityManager->getConnection()->executeQuery('INSERT INTO cursorTest(value, value2) VALUES (0, 0), (0, 1), (0, 2), (0, 3), (0, 4), (1, 5), (1, 6), (1, 7), (1, 8), (1, 9)');\n\n        $sql = 'SELECT * FROM cursorTest WHERE %cursor% OR (%cursor2%) ORDER BY %cursorSort%, %cursorSort2%';\n\n        $this->cursorPagination = new CursorPagination(\n            new NativeQueryCursorAdapter(\n                $this->entityManager->getConnection(),\n                $sql,\n                'value > :cursor',\n                'value < :cursor',\n                'value',\n                'value DESC',\n                [],\n                'value = :cursor AND value2 > :cursor2',\n                'value = :cursor AND value2 <= :cursor2',\n                'value2',\n                'value2 DESC',\n            ),\n            'value',\n            3,\n            'value2'\n        );\n    }\n\n    public function realSetUp(): void\n    {\n        for ($i = 0; $i < 10; ++$i) {\n            $entry = $this->getEntryByTitle(\"Entry $i\");\n            $ii = 10 - $i;\n            $entry->createdAt = new \\DateTimeImmutable(\"now - $ii minutes\");\n            // for debugging purposes\n            $this->createdEntries[$i] = \"$entry->title | {$entry->createdAt->format(DATE_ATOM)}\";\n        }\n        $this->entityManager->flush();\n    }\n\n    public function testRealPagination(): void\n    {\n        $this->realSetUp();\n\n        $criteria = new ContentPageView(1, $this->security);\n        $criteria->sortOption = Criteria::SORT_COMMENTED;\n        $criteria->perPage = 3;\n        $cursor = $this->contentRepository->guessInitialCursor($criteria->sortOption);\n        $cursor2 = $this->contentRepository->guessInitialCursor(Criteria::SORT_NEW);\n        $this->cursorPagination = $this->contentRepository->findByCriteriaCursored($criteria, $cursor, $cursor2);\n        $results = $this->cursorPagination->getCurrentPageResults();\n\n        self::assertTrue($this->cursorPagination->hasNextPage());\n        self::assertFalse($this->cursorPagination->hasPreviousPage());\n\n        self::assertEquals('Entry 9', $results[0]->title);\n        self::assertEquals('Entry 8', $results[1]->title);\n        self::assertEquals('Entry 7', $results[2]->title);\n\n        $cursors = $this->cursorPagination->getNextPage();\n        $this->cursorPagination->setCurrentPage($cursors[0], $cursors[1]);\n        $results = $this->cursorPagination->getCurrentPageResults();\n\n        self::assertTrue($this->cursorPagination->hasNextPage());\n        self::assertTrue($this->cursorPagination->hasPreviousPage());\n\n        self::assertEquals('Entry 6', $results[0]->title);\n        self::assertEquals('Entry 5', $results[1]->title);\n        self::assertEquals('Entry 4', $results[2]->title);\n\n        $cursors = $this->cursorPagination->getNextPage();\n        $this->cursorPagination->setCurrentPage($cursors[0], $cursors[1]);\n        $results = $this->cursorPagination->getCurrentPageResults();\n\n        self::assertTrue($this->cursorPagination->hasNextPage());\n        self::assertTrue($this->cursorPagination->hasPreviousPage());\n\n        self::assertEquals('Entry 3', $results[0]->title);\n        self::assertEquals('Entry 2', $results[1]->title);\n        self::assertEquals('Entry 1', $results[2]->title);\n\n        $cursors = $this->cursorPagination->getNextPage();\n        $this->cursorPagination->setCurrentPage($cursors[0], $cursors[1]);\n        $results = $this->cursorPagination->getCurrentPageResults();\n\n        self::assertFalse($this->cursorPagination->hasNextPage());\n        self::assertTrue($this->cursorPagination->hasPreviousPage());\n\n        self::assertEquals('Entry 0', $results[0]->title);\n\n        $cursors = $this->cursorPagination->getPreviousPage();\n        $this->cursorPagination->setCurrentPage($cursors[0], $cursors[1]);\n        $results = $this->cursorPagination->getCurrentPageResults();\n\n        self::assertTrue($this->cursorPagination->hasNextPage());\n        self::assertTrue($this->cursorPagination->hasPreviousPage());\n\n        self::assertEquals('Entry 3', $results[0]->title);\n        self::assertEquals('Entry 2', $results[1]->title);\n        self::assertEquals('Entry 1', $results[2]->title);\n\n        $cursors = $this->cursorPagination->getPreviousPage();\n        $this->cursorPagination->setCurrentPage($cursors[0], $cursors[1]);\n        $results = $this->cursorPagination->getCurrentPageResults();\n\n        self::assertTrue($this->cursorPagination->hasNextPage());\n        self::assertTrue($this->cursorPagination->hasPreviousPage());\n\n        self::assertEquals('Entry 6', $results[0]->title);\n        self::assertEquals('Entry 5', $results[1]->title);\n        self::assertEquals('Entry 4', $results[2]->title);\n\n        $cursors = $this->cursorPagination->getPreviousPage();\n        $this->cursorPagination->setCurrentPage($cursors[0], $cursors[1]);\n        $results = $this->cursorPagination->getCurrentPageResults();\n\n        self::assertTrue($this->cursorPagination->hasNextPage());\n        self::assertFalse($this->cursorPagination->hasPreviousPage());\n\n        self::assertEquals('Entry 9', $results[0]->title);\n        self::assertEquals('Entry 8', $results[1]->title);\n        self::assertEquals('Entry 7', $results[2]->title);\n    }\n}\n"
  },
  {
    "path": "tests/Unit/Service/ActivityPub/SignatureValidatorTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Unit\\Service\\ActivityPub;\n\nuse App\\Entity\\Magazine;\nuse App\\Exception\\InvalidApSignatureException;\nuse App\\Service\\ActivityPub\\ApHttpClientInterface;\nuse App\\Service\\ActivityPub\\SignatureValidator;\nuse App\\Service\\ActivityPubManager;\nuse PHPUnit\\Framework\\Attributes\\DoesNotPerformAssertions;\nuse PHPUnit\\Framework\\TestCase;\nuse Psr\\Log\\LoggerInterface;\n\nclass SignatureValidatorTest extends TestCase\n{\n    private static \\OpenSSLAsymmetricKey $privateKey;\n    private static string $publicKeyPem;\n\n    private array $body;\n    private array $headers;\n\n    /**\n     * Sets up an RSA keypair for use in the tests.\n     */\n    public static function setUpBeforeClass(): void\n    {\n        $res = openssl_pkey_new(\n            [\n                'private_key_bits' => 2048,\n                'private_key_type' => OPENSSL_KEYTYPE_RSA,\n            ]\n        );\n        if (false === $res) {\n            self::fail('Unable to generate suitable RSA key, ensure your testing environment has a correctly configured OpenSSL library');\n        }\n\n        $details = openssl_pkey_get_details($res);\n\n        self::$publicKeyPem = $details['key'];\n\n        openssl_pkey_export($res, $privateKey);\n        self::$privateKey = openssl_pkey_get_private($privateKey);\n    }\n\n    /**\n     * Sets up the test with a valid, hs2019 signed, http request body and headers.\n     *\n     * Includes the headers and signature that would be included in a request from\n     * a Lemmy (0.18.3) instance\n     */\n    private function createSignedRequest(string $inbox): void\n    {\n        $this->body = [\n            'actor' => 'https://kbin.localhost/m/group',\n            'id' => 'https://kbin.localhost/f/object/1',\n        ];\n        $headers = [\n            '(request-target)' => 'post '.$inbox,\n            'content-type' => 'application/activity+json',\n            'date' => (new \\DateTimeImmutable('now'))->format('D, d M Y H:i:s \\G\\M\\T'),\n            'digest' => 'SHA-256='.base64_encode(hash('sha256', json_encode($this->body), true)),\n            'host' => 'kbin.localhost',\n        ];\n\n        $signingString = implode(\n            \"\\n\",\n            array_map(function ($k, $v) {\n                return strtolower($k).': '.$v;\n            }, array_keys($headers), $headers)\n        );\n        $signedHeaders = implode(' ', array_map('strtolower', array_keys($headers)));\n\n        openssl_sign($signingString, $signature, self::$privateKey, OPENSSL_ALGO_SHA256);\n        $signature = base64_encode($signature);\n\n        unset($headers['(request-target)']);\n        $headers['signature'] = 'keyId=\"%s#main-key\",headers=\"'.$signedHeaders.'\",algorithm=\"hs2019\",signature=\"'.$signature.'\"';\n        array_walk($headers, function (string &$value) {\n            $value = [$value];\n        });\n        $this->headers = $headers;\n    }\n\n    #[DoesNotPerformAssertions]\n    public function testItValidatesACorrectlySignedRequest(): void\n    {\n        $this->createSignedRequest('/f/inbox');\n\n        $stubMagazine = $this->createStub(Magazine::class);\n        $stubMagazine->apProfileId = 'https://kbin.localhost/m/group';\n\n        $this->headers['signature'][0] = \\sprintf($this->headers['signature'][0], 'https://kbin.localhost/m/group');\n\n        $apManager = $this->createStub(ActivityPubManager::class);\n        $apManager->method('findActorOrCreate')\n            ->willReturn($stubMagazine);\n\n        $apHttpClient = $this->createStub(ApHttpClientInterface::class);\n        $apHttpClient->method('getActorObject')\n            ->willReturn(\n                [\n                    'publicKey' => [\n                        'publicKeyPem' => self::$publicKeyPem,\n                    ],\n                ],\n            );\n\n        $logger = $this->createStub(LoggerInterface::class);\n\n        $sut = new SignatureValidator($apManager, $apHttpClient, $logger);\n\n        $sut->validate(['uri' => '/f/inbox'], $this->headers, json_encode($this->body));\n    }\n\n    #[DoesNotPerformAssertions]\n    public function testItValidatesACorrectlySignedRequestToAPersonalInbox(): void\n    {\n        $this->createSignedRequest('/u/user/inbox');\n\n        $stubMagazine = $this->createStub(Magazine::class);\n        $stubMagazine->apProfileId = 'https://kbin.localhost/m/group';\n\n        $this->headers['signature'][0] = \\sprintf($this->headers['signature'][0], 'https://kbin.localhost/m/group');\n\n        $apManager = $this->createStub(ActivityPubManager::class);\n        $apManager->method('findActorOrCreate')\n            ->willReturn($stubMagazine);\n\n        $apHttpClient = $this->createStub(ApHttpClientInterface::class);\n        $apHttpClient->method('getActorObject')\n            ->willReturn(\n                [\n                    'publicKey' => [\n                        'publicKeyPem' => self::$publicKeyPem,\n                    ],\n                ],\n            );\n\n        $logger = $this->createStub(LoggerInterface::class);\n\n        $sut = new SignatureValidator($apManager, $apHttpClient, $logger);\n\n        $sut->validate(['uri' => '/u/user/inbox'], $this->headers, json_encode($this->body));\n    }\n\n    public function testItDoesNotValidateARequestWithDifferentBody(): void\n    {\n        $this->createSignedRequest('/f/inbox');\n\n        $stubMagazine = $this->createStub(Magazine::class);\n        $stubMagazine->apProfileId = 'https://kbin.localhost/m/group';\n\n        $this->headers['signature'][0] = \\sprintf($this->headers['signature'][0], 'https://kbin.localhost/m/group');\n\n        $apManager = $this->createStub(ActivityPubManager::class);\n        $apManager->method('findActorOrCreate')\n            ->willReturn($stubMagazine);\n\n        $apHttpClient = $this->createStub(ApHttpClientInterface::class);\n        $apHttpClient->method('getActorObject')\n            ->willReturn(\n                [\n                    'publicKey' => [\n                        'publicKeyPem' => self::$publicKeyPem,\n                    ],\n                ],\n            );\n\n        $logger = $this->createStub(LoggerInterface::class);\n\n        $sut = new SignatureValidator($apManager, $apHttpClient, $logger);\n\n        $badBody = [\n            'actor' => 'https://kbin.localhost/m/badgroup',\n            'id' => 'https://kbin.localhost/f/object/1',\n        ];\n\n        $this->expectException(InvalidApSignatureException::class);\n        $this->expectExceptionMessage('Signature of request could not be verified.');\n        $sut->validate(['uri' => '/f/inbox'], $this->headers, json_encode($badBody));\n    }\n\n    public function testItDoesNotValidateARequestWhenDomainsDoNotMatch(): void\n    {\n        $this->createSignedRequest('/f/inbox');\n\n        $stubMagazine = $this->createStub(Magazine::class);\n        $stubMagazine->apProfileId = 'https://kbin.localhost/m/group';\n\n        $this->headers['signature'][0] = \\sprintf($this->headers['signature'][0], 'https://kbin.localhost/m/group');\n\n        $apManager = $this->createStub(ActivityPubManager::class);\n        $apHttpClient = $this->createStub(ApHttpClientInterface::class);\n\n        $logger = $this->createStub(LoggerInterface::class);\n\n        $sut = new SignatureValidator($apManager, $apHttpClient, $logger);\n\n        $badBody = [\n            'actor' => 'https://kbin.localhost/m/group',\n            'id' => 'https://lemmy.localhost/activities/announce/1',\n        ];\n\n        $this->expectException(InvalidApSignatureException::class);\n        $this->expectExceptionMessage('Supplied key domain does not match domain of incoming activity.');\n        $sut->validate(['uri' => '/f/inbox'], $this->headers, json_encode($badBody));\n    }\n\n    public function testItDoesNotValidateARequestWhenUrlsAreNotHTTPS(): void\n    {\n        $this->createSignedRequest('/f/inbox');\n\n        $stubMagazine = $this->createStub(Magazine::class);\n        $stubMagazine->apProfileId = 'http://kbin.localhost/m/group';\n\n        $this->headers['signature'][0] = \\sprintf($this->headers['signature'][0], 'http://kbin.localhost/m/group');\n\n        $apManager = $this->createStub(ActivityPubManager::class);\n        $apHttpClient = $this->createStub(ApHttpClientInterface::class);\n\n        $logger = $this->createStub(LoggerInterface::class);\n\n        $sut = new SignatureValidator($apManager, $apHttpClient, $logger);\n\n        $badBody = [\n            'actor' => 'http://kbin.localhost/m/group',\n            'id' => 'http://kbin.localhost/f/object/1',\n        ];\n\n        $this->expectException(InvalidApSignatureException::class);\n        $this->expectExceptionMessage('Necessary supplied URL does not use HTTPS.');\n        $sut->validate(['uri' => '/f/inbox'], $this->headers, json_encode($badBody));\n    }\n}\n"
  },
  {
    "path": "tests/Unit/Service/MentionManagerTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Unit\\Service;\n\nuse App\\Service\\MentionManager;\nuse App\\Service\\SettingsManager;\nuse PHPUnit\\Framework\\Attributes\\DataProvider;\nuse Symfony\\Bundle\\FrameworkBundle\\Test\\WebTestCase;\n\nclass MentionManagerTest extends WebTestCase\n{\n    #[DataProvider('provider')]\n    public function testExtract(string $input, ?array $output): void\n    {\n        // Create a SettingsManager mock\n        $settingsManagerMock = $this->createMock(SettingsManager::class);\n\n        // Configure the stubs\n        $settingsManagerMock->method('get')\n            ->with('KBIN_DOMAIN')\n            ->willReturn('domain.tld');\n        $settingsManagerMock->method('getValue')\n            ->with('KBIN_DOMAIN')\n            ->willReturn('domain.tld');\n\n        // Replace the actual setting service with the mock in the container\n        $this->getContainer()->set(SettingsManager::class, $settingsManagerMock);\n        $manager = $this->getContainer()->get(MentionManager::class);\n        $this->assertEquals($output, $manager->extract($input));\n    }\n\n    public static function provider(): array\n    {\n        return [\n            ['Lorem @john ipsum', ['@john']],\n            ['@john lorem ipsum', ['@john']],\n            ['Lorem ipsum@john', null],\n            ['Lorem [@john](https://already.resolved.ap.url) ipsum', ['@john']],\n            ['Lorem @john@some.instance Ipsum', ['@john@some.instance']],\n            ['Lorem https://some.instance/@john/12345 ipsum', null], // post on another instance\n            ['Lorem https://some.instance/@john@other.instance/12345 ipsum', null], // AP post on another instance\n        ];\n    }\n}\n"
  },
  {
    "path": "tests/Unit/Service/MonitoringParameterEncodingTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Unit\\Service;\n\nuse App\\Entity\\MonitoringExecutionContext;\nuse App\\Entity\\MonitoringQuery;\nuse App\\Tests\\WebTestCase;\nuse PHPUnit\\Framework\\Attributes\\Depends;\n\nclass MonitoringParameterEncodingTest extends WebTestCase\n{\n    public function testThrowOnParameterEncoding(): void\n    {\n        $prepared = $this->prepareContextAndQuery();\n        $query = $prepared['query'];\n\n        $exception = null;\n        try {\n            $this->entityManager->persist($prepared['context']);\n            $this->entityManager->persist($query);\n            $this->entityManager->flush();\n        } catch (\\Exception $e) {\n            // this will throw an exception because the query parameters contain invalid characters\n            $exception = $e;\n        }\n\n        self::assertNotNull($exception);\n    }\n\n    #[Depends('testThrowOnParameterEncoding')]\n    public function testNotThrowOnEscape(): void\n    {\n        $prepared = $this->prepareContextAndQuery();\n        $query = $prepared['query'];\n        $query->cleanParameterArray();\n        $exception = null;\n        try {\n            $this->entityManager->persist($prepared['context']);\n            $this->entityManager->persist($query);\n            $this->entityManager->flush();\n        } catch (\\Exception $e) {\n            $exception = $e;\n        }\n        self::assertNull($exception);\n    }\n\n    private function prepareContextAndQuery(): array\n    {\n        $context = new MonitoringExecutionContext();\n        $context->executionType = 'test';\n        $context->path = 'test';\n        $context->handler = 'test';\n        $context->userType = 'anonymous';\n        $context->setStartedAt();\n\n        $query = new MonitoringQuery();\n        $query->query = 'INSERT SOME STUFF';\n        $query->parameters = [\n            // deliberately create a broken string\n            // see https://stackoverflow.com/questions/4663743/how-to-keep-json-encode-from-dropping-strings-with-invalid-characters\n            '1' => mb_convert_encoding('Düsseldorf', 'ISO-8859-1', 'UTF-8'),\n        ];\n        $query->context = $context;\n        $query->setStartedAt();\n        $query->setEndedAt();\n        $query->setDuration();\n\n        $context->setEndedAt();\n        $context->setDuration();\n        $context->queryDurationMilliseconds = $query->getDuration();\n        $context->twigRenderDurationMilliseconds = 0;\n        $context->curlRequestDurationMilliseconds = 0;\n\n        return ['query' => $query, 'context' => $context];\n    }\n}\n"
  },
  {
    "path": "tests/Unit/Service/SettingsManagerTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Unit\\Service;\n\nuse App\\Repository\\InstanceRepository;\nuse App\\Repository\\SettingsRepository;\nuse App\\Service\\SettingsManager;\nuse App\\Utils\\DownvotesMode;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse Psr\\Log\\LoggerInterface;\nuse Symfony\\Bundle\\FrameworkBundle\\Test\\WebTestCase;\nuse Symfony\\Component\\HttpFoundation\\RequestStack;\nuse Symfony\\Component\\HttpKernel\\KernelInterface;\n\nclass SettingsManagerTest extends WebTestCase\n{\n    protected function tearDown(): void\n    {\n        parent::tearDown();\n        // Reset static DTO to avoid leaking settings between tests\n        SettingsManager::resetDto();\n    }\n\n    public function testGetMaxImageByteStringDefault(): void\n    {\n        SettingsManager::resetDto();\n\n        // Set max images bytes (as if its coming from the .env)\n        $setMaxImagesBytes = 1500000;\n\n        $settingsRepository = $this->createStub(SettingsRepository::class);\n        $settingsRepository->method('findAll')->willReturn([]);\n        $entityManager = $this->createStub(EntityManagerInterface::class);\n        $requestStack = $this->createStub(RequestStack::class);\n        $kernel = $this->createStub(KernelInterface::class);\n        $kernel->method('getEnvironment')->willReturn('prod');\n        $instanceRepository = $this->createStub(InstanceRepository::class);\n        $logger = $this->createStub(LoggerInterface::class);\n\n        // SUT\n        $manager = new SettingsManager(\n            entityManager: $entityManager,\n            repository: $settingsRepository,\n            requestStack: $requestStack,\n            kernel: $kernel,\n            instanceRepository: $instanceRepository,\n            kbinDomain: 'domain.tld',\n            kbinTitle: 'title',\n            kbinMetaTitle: 'meta title',\n            kbinMetaDescription: 'meta description',\n            kbinMetaKeywords: 'meta keywords',\n            kbinDefaultLang: 'en',\n            kbinContactEmail: 'contact@domain.tld',\n            kbinSenderEmail: 'sender@domain.tld',\n            mbinDefaultTheme: 'light',\n            kbinJsEnabled: true,\n            kbinFederationEnabled: true,\n            kbinRegistrationsEnabled: true,\n            kbinHeaderLogo: true,\n            kbinCaptchaEnabled: true,\n            kbinFederationPageEnabled: true,\n            kbinAdminOnlyOauthClients: true,\n            mbinSsoOnlyMode: false,\n            mbinMaxImageBytes: $setMaxImagesBytes,\n            mbinDownvotesMode: DownvotesMode::Enabled,\n            mbinNewUsersNeedApproval: false,\n            logger: $logger,\n            mbinUseFederationAllowList: false\n        );\n\n        // Assert\n        $this->assertSame('1.5 MB', $manager->getMaxImageByteString());\n    }\n\n    public function testGetMaxImageByteStringOverridden(): void\n    {\n        SettingsManager::resetDto();\n\n        // Set max images bytes (as if its coming from the .env)\n        $setMaxImagesBytes = 1572864;\n\n        $settingsRepository = $this->createStub(SettingsRepository::class);\n        $settingsRepository->method('findAll')->willReturn([]);\n        $entityManager = $this->createStub(EntityManagerInterface::class);\n        $requestStack = $this->createStub(RequestStack::class);\n        $kernel = $this->createStub(KernelInterface::class);\n        $kernel->method('getEnvironment')->willReturn('prod');\n        $instanceRepository = $this->createStub(InstanceRepository::class);\n        $logger = $this->createStub(LoggerInterface::class);\n\n        // SUT\n        $manager = new SettingsManager(\n            entityManager: $entityManager,\n            repository: $settingsRepository,\n            requestStack: $requestStack,\n            kernel: $kernel,\n            instanceRepository: $instanceRepository,\n            kbinDomain: 'domain.tld',\n            kbinTitle: 'title',\n            kbinMetaTitle: 'meta title',\n            kbinMetaDescription: 'meta description',\n            kbinMetaKeywords: 'meta keywords',\n            kbinDefaultLang: 'en',\n            kbinContactEmail: 'contact@domain.tld',\n            kbinSenderEmail: 'sender@domain.tld',\n            mbinDefaultTheme: 'light',\n            kbinJsEnabled: true,\n            kbinFederationEnabled: true,\n            kbinRegistrationsEnabled: true,\n            kbinHeaderLogo: true,\n            kbinCaptchaEnabled: true,\n            kbinFederationPageEnabled: true,\n            kbinAdminOnlyOauthClients: true,\n            mbinSsoOnlyMode: false,\n            mbinMaxImageBytes: $setMaxImagesBytes,\n            mbinDownvotesMode: DownvotesMode::Enabled,\n            mbinNewUsersNeedApproval: false,\n            logger: $logger,\n            mbinUseFederationAllowList: false\n        );\n\n        // Assert\n        $this->assertSame('1.57 MB', $manager->getMaxImageByteString());\n    }\n}\n"
  },
  {
    "path": "tests/Unit/Service/TagExtractorTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Unit\\Service;\n\nuse App\\Service\\TagExtractor;\nuse PHPUnit\\Framework\\Attributes\\DataProvider;\nuse PHPUnit\\Framework\\TestCase;\n\nclass TagExtractorTest extends TestCase\n{\n    #[DataProvider('provider')]\n    public function testExtract(string $input, ?array $output): void\n    {\n        $this->assertEquals($output, (new TagExtractor())->extract($input, 'kbin'));\n    }\n\n    public static function provider(): array\n    {\n        return [\n            ['Lorem #acme ipsum', ['acme']],\n            ['#acme lorem ipsum', ['acme']],\n            ['Lorem #acme #kbin #acme2 ipsum', ['acme', 'acme2']],\n            ['Lorem ipsum#example', null],\n            ['Lorem #acme#example', ['acme']],\n            ['Lorem #Acme #acme ipsum', ['acme']],\n            ['Lorem ipsum', null],\n            ['#Test1_2_3', ['test1_2_3']],\n            ['#_123_ABC_', ['_123_abc_']],\n            ['Teraz #zażółć #gęślą #jaźń', ['zazolc', 'gesla', 'jazn']],\n            ['#Göbeklitepe #çarpıcı #eğlence #şarkı #ören', ['gobeklitepe', 'carpici', 'eglence', 'sarki', 'oren']],\n            ['#Viva #España #senõr', ['viva', 'espana', 'senor']],\n            ['#イラスト # #一次創作', ['イラスト', '一次創作']],\n            ['#ทำตัวไม่ถูกเลยเรา', ['ทำตัวไม่ถูกเลยเรา']],\n            ['#ไกด์ช้างม่วง #ทวิตล่ม', ['ไกด์ช้างม่วง', 'ทวิตล่ม']],\n            ['#Ｓｙｎｔｈｗａｖｅ', ['synthwave']],\n            ['#ｼｰｻｲﾄﾞﾗｲﾅｰ', ['シーサイドライナー']],\n            ['#ぼっち・ざ・ろっく', ['ぼっち・ざ・ろっく']],\n            ['https://www.site.tld/somepath/#heading', null],\n        ];\n    }\n}\n"
  },
  {
    "path": "tests/Unit/TwigRuntime/FormattingExtensionRuntimeTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Unit\\TwigRuntime;\n\nuse App\\Markdown\\MarkdownConverter;\nuse App\\Tests\\WebTestCase;\nuse App\\Twig\\Runtime\\FormattingExtensionRuntime;\nuse PHPUnit\\Framework\\Attributes\\DataProvider;\n\nclass FormattingExtensionRuntimeTest extends WebTestCase\n{\n    private FormattingExtensionRuntime $rt;\n\n    public function setUp(): void\n    {\n        parent::setUp();\n\n        $this->rt = new FormattingExtensionRuntime($this->createMock(MarkdownConverter::class));\n    }\n\n    public function testGetShortSentenceOnlyFirstParagraph()\n    {\n        $body = trim('\nThis is the first paragraph which is below the limit.\n\nAnd a second paragraph.\n        ');\n\n        $actual = $this->rt->getShortSentence($body, length: 60);\n        $expected = 'This is the first paragraph which is below the limit. ...';\n        self::assertSame($expected, $actual);\n    }\n\n    public function testGetShortSentenceOnlyFirstParagraphLimited()\n    {\n        $body = trim('\nThis is the first paragraph which is over the limit.\n\nAnd a second paragraph.\n        ');\n\n        $actual = $this->rt->getShortSentence($body, length: 10);\n        $expected = 'This is ...';\n        self::assertSame($expected, $actual);\n    }\n\n    public function testGetShortSentenceMultipleParagraphs()\n    {\n        $body = trim('\nThis is the first paragraph which is below the limit.\n\nAnd a second paragraph. With more than one sentence. And so on, and so on.\n        ');\n\n        $actual = $this->rt->getShortSentence($body, length: 89, onlyFirstParagraph: false);\n        $expected = \"This is the first paragraph which is below the limit.\\n\\nAnd a second paragraph. ...\";\n        self::assertSame($expected, $actual);\n    }\n\n    public function testGetShortSentenceMultipleParagraphsPreLimit()\n    {\n        $body = trim('\nThis is the first paragraph which is below the limit.\n\nAnd a second paragraph. With more than one sentence. And so on, and so on.\n        ');\n\n        $actual = $this->rt->getShortSentence($body, length: 90, onlyFirstParagraph: false);\n        $expected = \"This is the first paragraph which is below the limit.\\n\\nAnd a second paragraph. With more t...\";\n        self::assertSame($expected, $actual);\n    }\n\n    #[DataProvider('provideShortenNumberData')]\n    public function testShortenNumber(int $number, string $expected): void\n    {\n        self::assertEquals($expected, $this->rt->abbreviateNumber($number));\n    }\n\n    public static function provideShortenNumberData(): array\n    {\n        return [\n            [\n                'number' => 0,\n                'expected' => '0',\n            ],\n            [\n                'number' => 1234,\n                'expected' => '1.23K',\n            ],\n            [\n                'number' => 123456789,\n                'expected' => '123.46M',\n            ],\n            [\n                'number' => 1999,\n                'expected' => '2K',\n            ],\n            [\n                'number' => 1994,\n                'expected' => '1.99K',\n            ],\n            [\n                'number' => 3548,\n                'expected' => '3.55K',\n            ],\n            [\n                'number' => 1234567890,\n                'expected' => '1.23B',\n            ],\n            [\n                'number' => 12345678900000,\n                'expected' => '12345.68B',\n            ],\n        ];\n    }\n}\n"
  },
  {
    "path": "tests/Unit/Utils/ArrayUtilTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Unit\\Utils;\n\nuse App\\Utils\\ArrayUtils;\nuse PHPUnit\\Framework\\Attributes\\DataProvider;\nuse PHPUnit\\Framework\\TestCase;\n\nclass ArrayUtilTest extends TestCase\n{\n    #[DataProvider('provideSliceArrayIntoEqualPieces')]\n    public function testSliceArrayIntoEqualPieces(array $array, int $size, array $expected): void\n    {\n        $result = ArrayUtils::sliceArrayIntoEqualPieces($array, $size);\n        self::assertEquals($expected, $result);\n    }\n\n    public static function provideSliceArrayIntoEqualPieces(): array\n    {\n        return [\n            [[1, 2, 3, 4, 5, 6, 7, 8, 9], 3, [[1, 2, 3], [4, 5, 6], [7, 8, 9]]],\n            [[1, 2, 3, 4, 5, 6, 7, 8, 9, 10], 3, [[1, 2, 3], [4, 5, 6], [7, 8, 9], [10]]],\n            [[1, 2, 3, 4, 5, 6, 7, 8, 9, 10], 2, [[1, 2], [3, 4], [5, 6], [7, 8], [9, 10]]],\n        ];\n    }\n}\n"
  },
  {
    "path": "tests/Unit/Utils/GeneralUtilTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Unit\\Utils;\n\nuse App\\Utils\\GeneralUtil;\nuse PHPUnit\\Framework\\Attributes\\DataProvider;\nuse PHPUnit\\Framework\\TestCase;\n\nclass GeneralUtilTest extends TestCase\n{\n    #[DataProvider('providePaths')]\n    public function testPathIgnoring(array $ignoredPaths, string $path, bool $shouldBeIgnored): void\n    {\n        self::assertEquals($shouldBeIgnored, GeneralUtil::shouldPathBeIgnored($ignoredPaths, $path));\n    }\n\n    public static function providePaths(): array\n    {\n        // our paths never start with a '/'\n        return [\n            [['/cache'], 'ca/fe/asdoihsd.png', false],\n            [['/cache'], 'cache/ca/fe/asdoihsd.png', true],\n            [['cache'], 'cache/ca/fe/asdoihsd.png', true],\n            [['cache/'], 'cache/ca/fe/asdoihsd.png', true],\n            [['/fe'], 'ca/fe/asdoihsd.png', false],\n            [['fe'], 'ca/fe/asdoihsd.png', false],\n            [['fe/'], 'ca/fe/asdoihsd.png', false],\n            [['/fe', 'ca'], 'ca/fe/asdoihsd.png', true],\n        ];\n    }\n}\n"
  },
  {
    "path": "tests/Unit/Utils/MarkdownTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Unit\\Utils;\n\nuse App\\Entity\\Magazine;\nuse App\\Markdown\\MarkdownConverter;\nuse App\\Markdown\\RenderTarget;\nuse App\\Tests\\WebTestCase;\n\nuse function PHPUnit\\Framework\\assertStringContainsString;\n\nclass MarkdownTest extends WebTestCase\n{\n    public function testMagazineLinks(): void\n    {\n        $text = 'This should belong to !magazine@kbin.test2';\n        $markdown = $this->markdownConverter->convertToHtml($text, context: [MarkdownConverter::RENDER_TARGET => RenderTarget::Page]);\n        // assert that this community does not exist, and we get a search link for it that does not link to the external instance\n        self::assertStringContainsString('search', $markdown);\n        self::assertStringNotContainsString('(', $markdown);\n        self::assertStringNotContainsString(')', $markdown);\n        self::assertStringNotContainsString('[', $markdown);\n        self::assertStringNotContainsString(']', $markdown);\n        self::assertStringNotContainsString('https://kbin.test2', $markdown);\n    }\n\n    public function testMagazineLinks2(): void\n    {\n        $text = 'Lots of activity on [!fedibridge@lemmy.dbzer0.com](https://lemmy.dbzer0.com/c/fedibridge) following Reddit paywall announcements';\n        $markdown = $this->markdownConverter->convertToHtml($text, context: [MarkdownConverter::RENDER_TARGET => RenderTarget::Page]);\n        // assert that this community does not exist, and we get a search link for it that does not link to the external instance\n        self::assertStringContainsString('search', $markdown);\n        self::assertStringNotContainsString('(', $markdown);\n        self::assertStringNotContainsString(')', $markdown);\n        self::assertStringNotContainsString('[', $markdown);\n        self::assertStringNotContainsString(']', $markdown);\n        self::assertStringNotContainsString('https://lemmy.dbzer0.com', $markdown);\n    }\n\n    public function testLemmyMagazineLinks(): void\n    {\n        $text = 'This should belong to [!magazine@kbin.test2](https://kbin.test2/m/magazine)';\n        $markdown = $this->markdownConverter->convertToHtml($text, context: [MarkdownConverter::RENDER_TARGET => RenderTarget::Page]);\n        // assert that this community does not exist, and we get a search link for it that does not link to the external instance\n        self::assertStringContainsString('search', $markdown);\n        self::assertStringNotContainsString('(', $markdown);\n        self::assertStringNotContainsString(')', $markdown);\n        self::assertStringNotContainsString('[', $markdown);\n        self::assertStringNotContainsString(']', $markdown);\n        self::assertStringNotContainsString('https://kbin.test2', $markdown);\n    }\n\n    public function testExternalMagazineLinks(): void\n    {\n        $text = 'This should belong to [this magazine](https://kbin.test2/m/magazine)';\n        $markdown = $this->markdownConverter->convertToHtml($text, context: [MarkdownConverter::RENDER_TARGET => RenderTarget::Page]);\n        self::assertStringContainsString('https://kbin.test2', $markdown);\n    }\n\n    public function testMentionLink(): void\n    {\n        $text = 'Hi @admin@kbin.test2';\n        $markdown = $this->markdownConverter->convertToHtml($text, context: [MarkdownConverter::RENDER_TARGET => RenderTarget::Page]);\n        // assert that this community does not exist, and we get a search link for it that does not link to the external instance\n        self::assertStringContainsString('search', $markdown);\n        self::assertStringNotContainsString('(', $markdown);\n        self::assertStringNotContainsString(')', $markdown);\n        self::assertStringNotContainsString('[', $markdown);\n        self::assertStringNotContainsString(']', $markdown);\n        self::assertStringNotContainsString('https://kbin.test2', $markdown);\n    }\n\n    public function testNestedMentionLink(): void\n    {\n        $text = 'Hi [@admin@kbin.test2](https://kbin.test2/u/admin)';\n        $markdown = $this->markdownConverter->convertToHtml($text, context: [MarkdownConverter::RENDER_TARGET => RenderTarget::Page]);\n        // assert that this community does not exist, and we get a search link for it that does not link to the external instance\n        self::assertStringContainsString('search', $markdown);\n        self::assertStringNotContainsString('(', $markdown);\n        self::assertStringNotContainsString(')', $markdown);\n        self::assertStringNotContainsString('[', $markdown);\n        self::assertStringNotContainsString(']', $markdown);\n        self::assertStringNotContainsString('https://kbin.test2', $markdown);\n    }\n\n    public function testExternalMentionLink(): void\n    {\n        $text = 'You should really talk to your [instance admin](https://kbin.test2/u/admin)';\n        $markdown = $this->markdownConverter->convertToHtml($text, context: [MarkdownConverter::RENDER_TARGET => RenderTarget::Page]);\n        // assert that this community does not exist, and we get a search link for it that does not link to the external instance\n        self::assertStringContainsString('https://kbin.test2', $markdown);\n    }\n\n    public function testExternalMagazineLocalEntryLink(): void\n    {\n        $m = new Magazine('test@kbin.test2', 'test', null, null, null, false, false, null);\n        $m->apId = 'test@kbin.test2';\n        $m->apInboxUrl = 'https://kbin.test2/inbox';\n        $m->apPublicUrl = 'https://kbin.test2/m/test';\n        $m->apProfileId = 'https://kbin.test2/m/test';\n        $this->entityManager->persist($m);\n        $entry = $this->getEntryByTitle('test', magazine: $m);\n        $this->entityManager->flush();\n        $text = \"Look at my post at https://kbin.test/m/test@kbin.test2/t/{$entry->getId()}/some-slug\";\n        $markdown = $this->markdownConverter->convertToHtml($text, context: [MarkdownConverter::RENDER_TARGET => RenderTarget::Page]);\n        assertStringContainsString('entry-inline', $markdown);\n    }\n\n    public function testExternalMagazineLocalPostLink(): void\n    {\n        $m = new Magazine('test@kbin.test2', 'test', null, null, null, false, false, null);\n        $m->apId = 'test@kbin.test2';\n        $m->apInboxUrl = 'https://kbin.test2/inbox';\n        $m->apPublicUrl = 'https://kbin.test2/m/test';\n        $m->apProfileId = 'https://kbin.test2/m/test';\n        $this->entityManager->persist($m);\n        $post = $this->createPost('test', magazine: $m);\n        $this->entityManager->flush();\n        $text = \"Look at my post at https://kbin.test/m/test@kbin.test2/p/{$post->getId()}/some-slug\";\n        $markdown = $this->markdownConverter->convertToHtml($text, context: [MarkdownConverter::RENDER_TARGET => RenderTarget::Page]);\n        assertStringContainsString('post-inline', $markdown);\n    }\n\n    public function testLocalNotMatchingUrl(): void\n    {\n        $m = new Magazine('test@kbin.test2', 'test', null, null, null, false, false, null);\n        $m->apId = 'test@kbin.test2';\n        $m->apInboxUrl = 'https://kbin.test2/inbox';\n        $m->apPublicUrl = 'https://kbin.test2/m/test';\n        $m->apProfileId = 'https://kbin.test2/m/test';\n        $this->entityManager->persist($m);\n        $entry = $this->getEntryByTitle('test', magazine: $m);\n        $this->entityManager->flush();\n        $text = \"Look at my post at https://kbin.test/m/test@kbin.test2/t/{$entry->getId()}/some-slug/votes\";\n        $markdown = $this->markdownConverter->convertToHtml($text, context: [MarkdownConverter::RENDER_TARGET => RenderTarget::Page]);\n        assertStringContainsString(\"https://kbin.test/m/test@kbin.test2/t/{$entry->getId()}/some-slug/votes\", $markdown);\n    }\n\n    public function testBracketsInLinkTitle(): void\n    {\n        $m = new Magazine('test@kbin.test2', 'test', null, null, null, false, false, null);\n        $m->apId = 'test@kbin.test2';\n        $m->apInboxUrl = 'https://kbin.test2/inbox';\n        $m->apPublicUrl = 'https://kbin.test2/m/test';\n        $m->apProfileId = 'https://kbin.test2/m/test';\n        $this->entityManager->persist($m);\n        $entry = $this->getEntryByTitle('test', magazine: $m);\n        $this->entityManager->flush();\n        $text = \"[Look at my post (or not, your choice)](https://kbin.test/m/test@kbin.test2/t/{$entry->getId()}/some-slug/favourites)\";\n        $markdown = $this->markdownConverter->convertToHtml($text, context: [MarkdownConverter::RENDER_TARGET => RenderTarget::Page]);\n        assertStringContainsString(\"https://kbin.test/m/test@kbin.test2/t/{$entry->getId()}/some-slug/favourites\", $markdown);\n    }\n}\n"
  },
  {
    "path": "tests/Unit/Utils/SluggerTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests\\Unit\\Utils;\n\nuse App\\Utils\\Slugger;\nuse PHPUnit\\Framework\\Attributes\\DataProvider;\nuse PHPUnit\\Framework\\TestCase;\n\nclass SluggerTest extends TestCase\n{\n    #[DataProvider('provider')]\n    public function testCamelCase(string $input, string $output): void\n    {\n        $this->assertEquals($output, Slugger::camelCase($input));\n    }\n\n    public static function provider(): array\n    {\n        return [\n            ['Lorem ipsum', 'loremIpsum'],\n            ['LOremIpsum', 'lOremIpsum'],\n            ['LORemIpsum', 'lORemIpsum'],\n        ];\n    }\n}\n"
  },
  {
    "path": "tests/ValidationTrait.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests;\n\nuse App\\DTO\\MagazineLogResponseDto;\nuse App\\Entity\\Magazine;\nuse App\\Entity\\User;\n\ntrait ValidationTrait\n{\n    public function validateModlog(array $jsonData, Magazine $magazine, User $moderator): void\n    {\n        foreach ($jsonData['items'] as $item) {\n            self::assertArrayKeysMatch(WebTestCase::LOG_ENTRY_KEYS, $item);\n            self::assertIsArray($item['magazine']);\n            self::assertArrayKeysMatch(WebTestCase::MAGAZINE_SMALL_RESPONSE_KEYS, $item['magazine']);\n            self::assertSame($magazine->getId(), $item['magazine']['magazineId']);\n            self::assertArrayKeysMatch(WebTestCase::USER_SMALL_RESPONSE_KEYS, $item['moderator']);\n            self::assertSame($moderator->getId(), $item['moderator']['userId']);\n            self::assertStringMatchesFormat('%d-%d-%dT%d:%d:%d%i:00', $item['createdAt'], 'createdAt date format invalid');\n            self::assertContains($item['type'], MagazineLogResponseDto::LOG_TYPES, 'Log type invalid!');\n            switch ($item['type']) {\n                case 'log_entry_deleted':\n                case 'log_entry_restored':\n                    self::assertArrayKeysMatch(WebTestCase::ENTRY_RESPONSE_KEYS, $item['subject']);\n                    break;\n                case 'log_entry_comment_deleted':\n                case 'log_entry_comment_restored':\n                    self::assertArrayKeysMatch(WebTestCase::ENTRY_COMMENT_RESPONSE_KEYS, $item['subject']);\n                    break;\n                case 'log_post_deleted':\n                case 'log_post_restored':\n                    self::assertArrayKeysMatch(WebTestCase::POST_RESPONSE_KEYS, $item['subject']);\n                    break;\n                case 'log_post_comment_deleted':\n                case 'log_post_comment_restored':\n                    self::assertArrayKeysMatch(WebTestCase::POST_COMMENT_RESPONSE_KEYS, $item['subject']);\n                    break;\n                case 'log_ban':\n                case 'log_unban':\n                    self::assertArrayKeysMatch(WebTestCase::BAN_RESPONSE_KEYS, $item['subject']);\n                    break;\n                default:\n                    self::assertTrue(false, 'This should not be reached');\n                    break;\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "tests/WebTestCase.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Tests;\n\nuse App\\Factory\\ActivityPub\\EntryPageFactory;\nuse App\\Factory\\ActivityPub\\GroupFactory;\nuse App\\Factory\\ActivityPub\\PersonFactory;\nuse App\\Factory\\ActivityPub\\TombstoneFactory;\nuse App\\Factory\\ImageFactory;\nuse App\\Factory\\MagazineFactory;\nuse App\\Markdown\\MarkdownConverter;\nuse App\\MessageHandler\\ActivityPub\\Outbox\\DeliverHandler;\nuse App\\Repository\\ActivityRepository;\nuse App\\Repository\\BookmarkListRepository;\nuse App\\Repository\\BookmarkRepository;\nuse App\\Repository\\ContentRepository;\nuse App\\Repository\\EntryCommentRepository;\nuse App\\Repository\\EntryRepository;\nuse App\\Repository\\ImageRepository;\nuse App\\Repository\\InstanceRepository;\nuse App\\Repository\\MagazineBanRepository;\nuse App\\Repository\\MagazineRepository;\nuse App\\Repository\\MagazineSubscriptionRepository;\nuse App\\Repository\\MessageRepository;\nuse App\\Repository\\NotificationRepository;\nuse App\\Repository\\PostCommentRepository;\nuse App\\Repository\\PostRepository;\nuse App\\Repository\\ReportRepository;\nuse App\\Repository\\SettingsRepository;\nuse App\\Repository\\SiteRepository;\nuse App\\Repository\\TagLinkRepository;\nuse App\\Repository\\UserFollowRepository;\nuse App\\Repository\\UserRepository;\nuse App\\Service\\ActivityPub\\ActivityJsonBuilder;\nuse App\\Service\\ActivityPub\\ApHttpClientInterface;\nuse App\\Service\\ActivityPub\\Wrapper\\CreateWrapper;\nuse App\\Service\\ActivityPub\\Wrapper\\LikeWrapper;\nuse App\\Service\\ActivityPubManager;\nuse App\\Service\\BadgeManager;\nuse App\\Service\\BookmarkManager;\nuse App\\Service\\DomainManager;\nuse App\\Service\\EntryCommentManager;\nuse App\\Service\\EntryManager;\nuse App\\Service\\FavouriteManager;\nuse App\\Service\\ImageManagerInterface;\nuse App\\Service\\InstanceManager;\nuse App\\Service\\MagazineManager;\nuse App\\Service\\MentionManager;\nuse App\\Service\\MessageManager;\nuse App\\Service\\NotificationManager;\nuse App\\Service\\PostCommentManager;\nuse App\\Service\\PostManager;\nuse App\\Service\\ProjectInfoService;\nuse App\\Service\\ReportManager;\nuse App\\Service\\SettingsManager;\nuse App\\Service\\UserManager;\nuse App\\Service\\VoteManager;\nuse App\\Tests\\Service\\TestingApHttpClient;\nuse App\\Tests\\Service\\TestingImageManager;\nuse App\\Twig\\Runtime\\FormattingExtensionRuntime;\nuse Doctrine\\Common\\Collections\\ArrayCollection;\nuse Doctrine\\ORM\\EntityManagerInterface;\nuse League\\Flysystem\\Filesystem;\nuse Liip\\ImagineBundle\\Imagine\\Cache\\CacheManager;\nuse Psr\\EventDispatcher\\EventDispatcherInterface;\nuse Psr\\Log\\LoggerInterface;\nuse Symfony\\Bundle\\FrameworkBundle\\Console\\Application;\nuse Symfony\\Bundle\\FrameworkBundle\\KernelBrowser;\nuse Symfony\\Bundle\\FrameworkBundle\\Test\\WebTestCase as BaseWebTestCase;\nuse Symfony\\Bundle\\SecurityBundle\\Security;\nuse Symfony\\Component\\Console\\Tester\\CommandTester;\nuse Symfony\\Component\\HttpFoundation\\RequestStack;\nuse Symfony\\Component\\Messenger\\MessageBusInterface;\nuse Symfony\\Component\\Mime\\MimeTypesInterface;\nuse Symfony\\Component\\Routing\\Generator\\UrlGeneratorInterface;\nuse Symfony\\Component\\Routing\\RouterInterface;\nuse Symfony\\Component\\Validator\\Validator\\ValidatorInterface;\nuse Symfony\\Contracts\\HttpClient\\HttpClientInterface;\nuse Symfony\\Contracts\\Translation\\TranslatorInterface;\n\nabstract class WebTestCase extends BaseWebTestCase\n{\n    use FactoryTrait;\n    use OAuth2FlowTrait;\n    use ValidationTrait;\n\n    protected const PAGINATED_KEYS = ['items', 'pagination'];\n    protected const PAGINATION_KEYS = ['count', 'currentPage', 'maxPage', 'perPage'];\n    protected const CURSOR_PAGINATION_KEYS = ['currentCursor', 'currentCursor2', 'nextCursor', 'nextCursor2', 'previousCursor', 'previousCursor2', 'perPage'];\n    protected const IMAGE_KEYS = ['filePath', 'sourceUrl', 'storageUrl', 'altText', 'width', 'height', 'blurHash'];\n    protected const MESSAGE_RESPONSE_KEYS = ['messageId', 'threadId', 'sender', 'body', 'status', 'createdAt'];\n    protected const USER_RESPONSE_KEYS = ['userId', 'username', 'about', 'avatar', 'cover', 'createdAt', 'followersCount', 'apId', 'apProfileId', 'isBot', 'isFollowedByUser', 'isFollowerOfUser', 'isBlockedByUser', 'isAdmin', 'isGlobalModerator', 'serverSoftware', 'serverSoftwareVersion', 'notificationStatus', 'reputationPoints', 'discoverable', 'indexable', 'title'];\n    protected const USER_SMALL_RESPONSE_KEYS = ['userId', 'username', 'isBot', 'isFollowedByUser', 'isFollowerOfUser', 'isBlockedByUser', 'avatar', 'apId', 'apProfileId', 'createdAt', 'isAdmin', 'isGlobalModerator', 'discoverable', 'indexable', 'title'];\n    protected const ENTRY_RESPONSE_KEYS = ['entryId', 'magazine', 'user', 'domain', 'title', 'url', 'image', 'body', 'lang', 'tags', 'badges', 'numComments', 'uv', 'dv', 'favourites', 'isFavourited', 'userVote', 'isOc', 'isAdult', 'isPinned', 'isLocked', 'createdAt', 'editedAt', 'lastActive', 'visibility', 'type', 'slug', 'apId', 'canAuthUserModerate', 'notificationStatus', 'bookmarks', 'crosspostedEntries', 'isAuthorModeratorInMagazine'];\n    protected const ENTRY_COMMENT_RESPONSE_KEYS = ['commentId', 'magazine', 'user', 'entryId', 'parentId', 'rootId', 'image', 'body', 'lang', 'isAdult', 'uv', 'dv', 'favourites', 'isFavourited', 'userVote', 'visibility', 'apId', 'mentions', 'tags', 'createdAt', 'editedAt', 'lastActive', 'childCount', 'children', 'canAuthUserModerate', 'bookmarks', 'isAuthorModeratorInMagazine'];\n    protected const POST_RESPONSE_KEYS = ['postId', 'user', 'magazine', 'image', 'body', 'lang', 'isAdult', 'isPinned', 'isLocked', 'comments', 'uv', 'dv', 'favourites', 'isFavourited', 'userVote', 'visibility', 'apId', 'tags', 'mentions', 'createdAt', 'editedAt', 'lastActive', 'slug', 'canAuthUserModerate', 'notificationStatus', 'bookmarks', 'isAuthorModeratorInMagazine'];\n    protected const POST_COMMENT_RESPONSE_KEYS = ['commentId', 'user', 'magazine', 'postId', 'parentId', 'rootId', 'image', 'body', 'lang', 'isAdult', 'uv', 'dv', 'favourites', 'isFavourited', 'userVote', 'visibility', 'apId', 'mentions', 'tags', 'createdAt', 'editedAt', 'lastActive', 'childCount', 'children', 'canAuthUserModerate', 'bookmarks', 'isAuthorModeratorInMagazine'];\n    protected const BAN_RESPONSE_KEYS = ['banId', 'reason', 'expired', 'expiredAt', 'bannedUser', 'bannedBy', 'magazine'];\n    protected const LOG_ENTRY_KEYS = ['type', 'createdAt', 'magazine', 'moderator', 'subject'];\n    protected const MAGAZINE_RESPONSE_KEYS = ['magazineId', 'owner', 'icon', 'banner', 'name', 'title', 'description', 'rules', 'subscriptionsCount', 'entryCount', 'entryCommentCount', 'postCount', 'postCommentCount', 'isAdult', 'isUserSubscribed', 'isBlockedByUser', 'tags', 'badges', 'moderators', 'apId', 'apProfileId', 'serverSoftware', 'serverSoftwareVersion', 'isPostingRestrictedToMods', 'localSubscribers', 'notificationStatus', 'discoverable', 'indexable'];\n    protected const MAGAZINE_SMALL_RESPONSE_KEYS = ['magazineId', 'name', 'icon', 'banner', 'isUserSubscribed', 'isBlockedByUser', 'apId', 'apProfileId', 'discoverable', 'indexable'];\n    protected const DOMAIN_RESPONSE_KEYS = ['domainId', 'name', 'entryCount', 'subscriptionsCount', 'isUserSubscribed', 'isBlockedByUser'];\n\n    protected const KIBBY_PNG_URL_RESULT = 'a8/1c/a81cc2fea35eeb232cd28fcb109b3eb5a4e52c71bce95af6650d71876c1bcbb7.png';\n\n    protected ArrayCollection $users;\n    protected ArrayCollection $magazines;\n    protected ArrayCollection $entries;\n\n    protected EntityManagerInterface $entityManager;\n    protected KernelBrowser $client;\n\n    protected MagazineManager $magazineManager;\n    protected UserManager $userManager;\n    protected EntryManager $entryManager;\n    protected EntryCommentManager $entryCommentManager;\n    protected PostManager $postManager;\n    protected PostCommentManager $postCommentManager;\n    protected MessageManager $messageManager;\n    protected FavouriteManager $favouriteManager;\n    protected VoteManager $voteManager;\n    protected SettingsManager $settingsManager;\n    protected DomainManager $domainManager;\n    protected ReportManager $reportManager;\n    protected BadgeManager $badgeManager;\n    protected NotificationManager $notificationManager;\n    protected MentionManager $mentionManager;\n    protected ActivityPubManager $activityPubManager;\n    protected BookmarkManager $bookmarkManager;\n    protected MarkdownConverter $markdownConverter;\n    protected InstanceManager $instanceManager;\n\n    protected MagazineRepository $magazineRepository;\n    protected EntryRepository $entryRepository;\n    protected EntryCommentRepository $entryCommentRepository;\n    protected PostRepository $postRepository;\n    protected PostCommentRepository $postCommentRepository;\n    protected ImageRepository $imageRepository;\n    protected MessageRepository $messageRepository;\n    protected SiteRepository $siteRepository;\n    protected NotificationRepository $notificationRepository;\n    protected ReportRepository $reportRepository;\n    protected SettingsRepository $settingsRepository;\n    protected UserRepository $userRepository;\n    protected TagLinkRepository $tagLinkRepository;\n    protected BookmarkRepository $bookmarkRepository;\n    protected BookmarkListRepository $bookmarkListRepository;\n    protected UserFollowRepository $userFollowRepository;\n    protected MagazineSubscriptionRepository $magazineSubscriptionRepository;\n    protected ActivityRepository $activityRepository;\n    protected InstanceRepository $instanceRepository;\n    protected MagazineBanRepository $magazineBanRepository;\n    protected ContentRepository $contentRepository;\n\n    protected ImageFactory $imageFactory;\n    protected MagazineFactory $magazineFactory;\n    protected TombstoneFactory $tombstoneFactory;\n    protected PersonFactory $personFactory;\n    protected GroupFactory $groupFactory;\n    protected EntryPageFactory $pageFactory;\n    protected TestingApHttpClient $testingApHttpClient;\n    protected TestingImageManager $imageManager;\n\n    protected CreateWrapper $createWrapper;\n    protected LikeWrapper $likeWrapper;\n\n    protected UrlGeneratorInterface $urlGenerator;\n    protected TranslatorInterface $translator;\n    protected EventDispatcherInterface $eventDispatcher;\n    protected RequestStack $requestStack;\n    protected LoggerInterface $logger;\n    protected ProjectInfoService $projectInfoService;\n    protected RouterInterface $router;\n    protected MessageBusInterface $bus;\n    protected ActivityJsonBuilder $activityJsonBuilder;\n    protected Security $security;\n\n    protected DeliverHandler $deliverHandler;\n\n    protected string $kibbyPath;\n\n    public function setUp(): void\n    {\n        $this->users = new ArrayCollection();\n        $this->magazines = new ArrayCollection();\n        $this->entries = new ArrayCollection();\n        $this->kibbyPath = \\dirname(__FILE__).'/assets/kibby_emoji.png';\n        $this->client = static::createClient();\n\n        $this->testingApHttpClient = new TestingApHttpClient();\n        self::getContainer()->set(ApHttpClientInterface::class, $this->testingApHttpClient);\n\n        $this->imageManager = new TestingImageManager(\n            $this->getContainer()->getParameter('kbin_storage_url'),\n            $this->getService(Filesystem::class),\n            $this->getService(HttpClientInterface::class),\n            $this->getService(MimeTypesInterface::class),\n            $this->getService(ValidatorInterface::class),\n            $this->getService(LoggerInterface::class),\n            $this->getService(SettingsManager::class),\n            $this->getService(FormattingExtensionRuntime::class),\n            self::getContainer()->getParameter('mbin_image_compression_quality'),\n            $this->getService(CacheManager::class),\n            $this->getService(EntityManagerInterface::class),\n        );\n        $this->imageManager->setKibbyPath($this->kibbyPath);\n        self::getContainer()->set(ImageManagerInterface::class, $this->imageManager);\n\n        $this->entityManager = $this->getService(EntityManagerInterface::class);\n        $this->magazineManager = $this->getService(MagazineManager::class);\n        $this->userManager = $this->getService(UserManager::class);\n        $this->entryManager = $this->getService(EntryManager::class);\n        $this->entryCommentManager = $this->getService(EntryCommentManager::class);\n        $this->postManager = $this->getService(PostManager::class);\n        $this->postCommentManager = $this->getService(PostCommentManager::class);\n        $this->messageManager = $this->getService(MessageManager::class);\n        $this->favouriteManager = $this->getService(FavouriteManager::class);\n        $this->voteManager = $this->getService(VoteManager::class);\n        $this->settingsManager = $this->getService(SettingsManager::class);\n        $this->domainManager = $this->getService(DomainManager::class);\n        $this->reportManager = $this->getService(ReportManager::class);\n        $this->badgeManager = $this->getService(BadgeManager::class);\n        $this->notificationManager = $this->getService(NotificationManager::class);\n        $this->activityPubManager = $this->getService(ActivityPubManager::class);\n        $this->bookmarkManager = $this->getService(BookmarkManager::class);\n        $this->markdownConverter = $this->getService(MarkdownConverter::class);\n        $this->instanceManager = $this->getService(InstanceManager::class);\n        $this->activityJsonBuilder = $this->getService(ActivityJsonBuilder::class);\n        $this->mentionManager = $this->getService(MentionManager::class);\n        $this->security = $this->getService(Security::class);\n\n        $this->magazineRepository = $this->getService(MagazineRepository::class);\n        $this->entryRepository = $this->getService(EntryRepository::class);\n        $this->entryCommentRepository = $this->getService(EntryCommentRepository::class);\n        $this->postRepository = $this->getService(PostRepository::class);\n        $this->postCommentRepository = $this->getService(PostCommentRepository::class);\n        $this->imageRepository = $this->getService(ImageRepository::class);\n        $this->messageRepository = $this->getService(MessageRepository::class);\n        $this->siteRepository = $this->getService(SiteRepository::class);\n        $this->notificationRepository = $this->getService(NotificationRepository::class);\n        $this->reportRepository = $this->getService(ReportRepository::class);\n        $this->settingsRepository = $this->getService(SettingsRepository::class);\n        $this->userRepository = $this->getService(UserRepository::class);\n        $this->tagLinkRepository = $this->getService(TagLinkRepository::class);\n        $this->bookmarkRepository = $this->getService(BookmarkRepository::class);\n        $this->bookmarkListRepository = $this->getService(BookmarkListRepository::class);\n        $this->userFollowRepository = $this->getService(UserFollowRepository::class);\n        $this->magazineSubscriptionRepository = $this->getService(MagazineSubscriptionRepository::class);\n        $this->activityRepository = $this->getService(ActivityRepository::class);\n        $this->instanceRepository = $this->getService(InstanceRepository::class);\n        $this->magazineBanRepository = $this->getService(MagazineBanRepository::class);\n        $this->contentRepository = $this->getService(ContentRepository::class);\n\n        $this->imageFactory = $this->getService(ImageFactory::class);\n        $this->personFactory = $this->getService(PersonFactory::class);\n        $this->magazineFactory = $this->getService(MagazineFactory::class);\n        $this->groupFactory = $this->getService(GroupFactory::class);\n        $this->pageFactory = $this->getService(EntryPageFactory::class);\n        $this->tombstoneFactory = $this->getService(TombstoneFactory::class);\n\n        $this->createWrapper = $this->getService(CreateWrapper::class);\n        $this->likeWrapper = $this->getService(LikeWrapper::class);\n\n        $this->urlGenerator = $this->getService(UrlGeneratorInterface::class);\n        $this->translator = $this->getService(TranslatorInterface::class);\n        $this->eventDispatcher = $this->getService(EventDispatcherInterface::class);\n        $this->requestStack = $this->getService(RequestStack::class);\n        $this->router = $this->getService(RouterInterface::class);\n        $this->bus = $this->getService(MessageBusInterface::class);\n        $this->projectInfoService = $this->getService(ProjectInfoService::class);\n        $this->logger = $this->getService(LoggerInterface::class);\n\n        // clear all cache before every test\n        $app = new Application($this->client->getKernel());\n        $command = $app->get('cache:pool:clear');\n        $tester = new CommandTester($command);\n        $tester->execute(['--all' => '1']);\n    }\n\n    /**\n     * @template T\n     *\n     * @param class-string<T> $className\n     *\n     * @return T\n     */\n    private function getService(string $className)\n    {\n        return $this->getContainer()->get($className);\n    }\n\n    public static function getJsonResponse(KernelBrowser $client): array\n    {\n        $response = $client->getResponse();\n        self::assertJson($response->getContent());\n\n        return json_decode($response->getContent(), associative: true);\n    }\n\n    /**\n     * Checks that all values in array $keys are present as keys in array $value, and that no additional keys are included.\n     */\n    public static function assertArrayKeysMatch(array $keys, array $value, string $message = ''): void\n    {\n        $flipped = array_flip($keys);\n        $difference = array_diff_key($value, $flipped);\n        $diffString = json_encode(array_keys($difference));\n        self::assertEmpty($difference, $message ? $message : \"Extra keys were found in the provided array: $diffString\");\n        $intersect = array_intersect_key($value, $flipped);\n        self::assertCount(\\count($flipped), $intersect, $message);\n    }\n\n    public static function assertNotReached(string $message = 'This branch should never happen'): void\n    {\n        self::assertFalse(true, $message);\n    }\n\n    public static function removeTimeElements(string $content): string\n    {\n        $pattern = '/<time[ \\w=\"\\n-:]*>[\\w \\n]+<\\/time>/m';\n\n        return preg_replace($pattern, '', $content);\n    }\n\n    protected function tearDown(): void\n    {\n        parent::tearDown();\n\n        $entityManager = $this->entityManager;\n        if ($entityManager->isOpen()) {\n            $entityManager->close();\n        }\n    }\n}\n"
  },
  {
    "path": "tests/bootstrap.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nuse App\\Kernel;\nuse Symfony\\Bundle\\FrameworkBundle\\Console\\Application;\nuse Symfony\\Component\\Console\\Input\\ArrayInput;\nuse Symfony\\Component\\Dotenv\\Dotenv;\n\nrequire \\dirname(__DIR__).'/vendor/autoload.php';\n\nif (method_exists(Dotenv::class, 'bootEnv')) {\n    (new Dotenv())->bootEnv(\\dirname(__DIR__).'/.env');\n}\n\nif ($_SERVER['APP_DEBUG']) {\n    umask(0000);\n}\n\nfunction bootstrapDatabase(): void\n{\n    $kernel = new Kernel('test', true);\n    $kernel->boot();\n\n    $application = new Application($kernel);\n    $application->setAutoExit(false);\n\n    $application->run(new ArrayInput([\n        'command' => 'cache:pool:clear',\n        '--all' => '1',\n        '--no-interaction' => true,\n    ]));\n\n    $application->run(new ArrayInput([\n        'command' => 'doctrine:database:drop',\n        '--if-exists' => '1',\n        '--force' => '1',\n    ]));\n\n    $application->run(new ArrayInput([\n        'command' => 'doctrine:database:create',\n    ]));\n\n    $application->run(new ArrayInput([\n        'command' => 'doctrine:migrations:migrate',\n        '--no-interaction' => true,\n    ]));\n\n    $application->run(new ArrayInput([\n        'command' => 'mbin:ap:keys:update',\n        '--no-interaction' => true,\n    ]));\n\n    $application->run(new ArrayInput([\n        'command' => 'mbin:push:keys:update',\n        '--no-interaction' => true,\n    ]));\n\n    $conn = $kernel->getContainer()->get('doctrine.orm.entity_manager')->getConnection();\n    if ($conn->isTransactionActive()) {\n        $conn->commit();\n    }\n    if ($conn->isConnected()) {\n        $conn->close();\n    }\n\n    $kernel->shutdown();\n}\n\nif (!empty($_SERVER['BOOTSTRAP_DB'])) {\n    bootstrapDatabase();\n}\n"
  },
  {
    "path": "tools/composer.json",
    "content": "{\n    \"require\": {\n        \"friendsofphp/php-cs-fixer\": \"^3.75.0\"\n    }\n}\n"
  },
  {
    "path": "translations/.gitignore",
    "content": ""
  },
  {
    "path": "translations/messages.an.yaml",
    "content": "{}\n"
  },
  {
    "path": "translations/messages.ast.yaml",
    "content": "{}\n"
  },
  {
    "path": "translations/messages.bg.yaml",
    "content": "comment: Коментар\nsize: Размер\nuser_badge_moderator: Мод\nmonth: Месец\nreply: Отговор\nsave: Запазване\nweeks: Седмици\nsubscribe: Абониране\nFAQ: Често задавани въпроси\ntitle: Заглавие\nmoderators: Модератори\nthread: Нишка\nuser: Потребител\ntrash: Кошче\n1w: 1с\ndefault_theme: Тема по подразбиране\nfediverse: Федивселена\ntype.link: Връзка\nrss: RSS\nmicroblog: Микроблог\nuser_badge_admin: Админ\nno: Не\nunpin: Откачване\nvideos: Видеа\nicon: Иконка\ntype.photo: Снимка\nonline: На линия\nemail: Ел. поща\ntheme: Тема\nyear: Година\nname: Име\nyes: Да\nfilter.fields.names_and_descriptions: Имена и описания\nusername: Потребителско име\nlight: Светла\nterms: Условия за ползване\ntype.article: Нишка\npeople: Хора\n12h: 12ч\ndelete: Изтриване\n1m: 1м\narticle: Нишка\nurl: URL адрес\nfaq: Често задавани въпроси\n6h: 6ч\nbody: Тяло\nuser_badge_bot: Бот\nthreads: Нишки\nabout: Относно\nowner: Притежател\nprivacy_policy: Политика за поверителност\ntoolbar.link: Връзка\nstats: Статистика\nkeywords: Ключови думи\ndark: Тъмна\ncancel: Отказ\nno_comments: Няма коментари\ncontent: Съдържание\nusers: Потребители\nsearch: Търсене\nerror: Грешка\nlogout: Излизане\nreplies: Отговори\nphotos: Снимки\n1y: 1г\nlogin: Влизане\nweek: Седмица\n1d: 1д\nsettings: Настройки\nadd_media: Добавяне на мултимедия\nadd_comment: Добавяне на коментар\nmonths: Месеца\ntype.video: Видео\narticles: Нишки\naccept: Приемане\nshare: Споделяне\nadd: Добавяне\nabout_instance: Относно\ncomments: Коментари\nunsubscribe: Отписване\npin: Закачване\nprofile: Профил\nuser_badge_op: ОП\ndone: Готово\n3h: 3ч\nfilter_by_type: Филтриране по тип\nedit_my_profile: Редактиране на моя профил\ndeleted: Изтрито от автора\nreputation_points: Репутационни точки\nshare_on_fediverse: Споделяне във Федивселената\nfederated_magazine_info: Тази общност е от федеративен сървър и може да е \n  непълна.\ndown_vote: Редуциране\nfollowing: Последвани\ntop: Топ\nreports: Доклади\nwriting: Писане\nchange_view: Промяна на изгледа\ncreated_at: Създадено\ntoolbar.bold: Удебеляване\nfederation: Федериране\ntoolbar.header: Заглавка\ncards: Карти\ncomments_count: '{0}Коментара|{1}Коментар|]1,Inf[ Коментара'\nyou_cant_login: Забравена парола?\npassword: Парола\nrandom_posts: Случайни публикации\nadd_new: Добавяне на нова\noldest: Най-стари\ncards_view: Картиен изглед\nchange_password: Промяна на паролата\nemail_verify: Потвърди адреса на ел. поща\ncopy_url_to_fediverse: Копиране на оригиналния адрес\nfavourites: Гласове нагоре\nblocked: Блокирани\ndelete_account: Изтрий акаунта\nall: Всички\nchange: Промяна\nnew_password: Нова парола\nnewest: Най-нови\nselect_channel: Избери канал\ntree_view: Дървовиден изглед\nfollowers: Последователи\nactivity: Дейност\nadd_post: Добавяне на публикация\nhot: Популярни\non: Вкл.\nunfollow: Отследване\ncover: Корица\nsubscribers: Абонирани\ntype.magazine: Общност\nshow_more: Покажи повече\ndown_votes: Редуцирания\nlogin_or_email: Потребителско име или ел. поща\nfrom_url: От url адрес\nadd_new_link: Добавяне на нова връзка\nadded: Добавено\nnew_password_repeat: Потвърди новата парола\nrelated_magazines: Подобни общности\nappearance: Изглед\nremember_me: Запомни ме\ndescription: Описание\ncurrent_password: Текуща парола\nadd_moderator: Добавяне на модератор\noff: Изкл.\ncompact_view: Компактен изглед\nblock: Блокиране\ntable_view: Табличен изглед\nhelp: Помощ\ncreate_new_magazine: Създаване на нова общност\ntoolbar.quote: Цитат\nenter_your_post: Въведи своята публикация\nadd_new_photo: Добавяне на нова снимка\nchange_my_cover: Промяна на моята корица\nregister: Регистриране\nadd_new_article: Добавяне на нова нишка\nsend: Изпращане\nactive_users: Активни хора\nadd_new_post: Добавяне на нова публикация\ncontact: За контакт\nopen_url_to_fediverse: Отваряне на оригиналния адрес\nnotifications: Известия\nchange_theme: Промяна на темата\ninstance: Инстанция\nsubject_reported: Съдържанието бе докладвано.\nadd_new_video: Добавяне на ново видео\nrules: Правила\ncolumns: Колони\nalphabetically: По азбучен ред\nup_votes: Подсилвания\nreport: Докладване\nactive: Активни\nstatus: Състояние\nnew_email: Нов адрес на ел. поща\nmod_log: Дневник на модерирането\nevents: Събития\nmore: Още\nup_vote: Подсилване\nrelated_entries: Подобни нишки\ntry_again: Повторен опит\nresend_account_activation_email_question: Неактивен акаунт?\nrandom_magazines: Случайни общности\nlinks: Връзки\nupload_file: Качване на файл\nchange_email: Промяна на адреса на ел. поща\ninstances: Инстанции\nrandom_entries: Случайни нишки\nmarkdown_howto: Как работи редакторът?\nreputation: Репутация\nreset_password: Нулиране на паролата\ncommented: Коментирани\ntoolbar.code: Код\nsubscriptions: Абонаменти\nfilter_by_time: Филтриране по време\nedit_post: Редактиране на публикацията\njoined: Присъединяване\nremoved: Премахнато от модератор\ndomain: Домейн\ngo_to_original_instance: Разгледай на отдалечената инстанция\nchange_my_avatar: Промяна на моя лик\ndomains: Домейни\noverview: Обзор\nclassic_view: Класически изглед\nmessages: Съобщения\nboost: Подсилване\nempty: Празно\nchange_language: Промяна на езика\nfederated_user_info: Този профил е от федеративен сървър и може да е непълен.\npages: Страници\nmagazines: Общности\nuseful: Полезно\nchat_view: Чат изглед\nall_magazines: Всички общности\nenter_your_comment: Въведи своя коментар\nfavourite: Любимо\noauth2.grant.user.notification.delete: Изчистване на твоите известия.\nshow_users_avatars: Показване на ликовете на потребителите\nmagazine: Общност\nprivacy: Поверителност\nrepeat_password: Повтори паролата\nold_email: Текущ адрес на ел. поща\nposts: Публикации\nrelated_posts: Подобни публикации\nboosts: Подсилвания\napprove: Одобряване\ntoolbar.strikethrough: Зачертано\nchange_magazine: Промяна на общността\nhomepage: Начална страница\navatar: Лик\nfollow: Последване\ntoolbar.italic: Курсив\nmore_from_domain: Още от домейна\noauth2.grant.user.profile.edit: Редактиране на твоя профил.\napproved: Одобрени\ncheck_email: Провери своята ел. поща\nalready_have_account: Вече имате акаунт?\nvotes: Гласувания\nsend_message: Изпращане на лично съобщение\nmessage: Съобщение\nfilter.fields.only_names: Само имена\nlocal_and_federated: Местни и федеративни\nfirstname: Собствено име\ntags: Етикети\nreason: Причина\nedit: Редактиране\noc: ОС\nposition_bottom: Отдолу\npeople_local: Тукашни\nin: в\nrejected: Отхвърлени\nfilters: Филтри\nshow_avatars_on_comments: Показване на ликовете в коментарите\nselect_magazine: Избери общност\nto: до\ndont_have_account: Нямате акаунт?\nfederated: Федеративни\nmoderated: Модерирани\ncollapse: Свиване\nlocal: Местни\ngo_to_search: Към търсене\ngo_to_filters: Към филтрите\ngeneral: Общи\nexpand: Разтваряне\ngo_to_content: Към съдържанието\npeople_federated: Федеративни\npending: В очакване\nreject: Отхвърляне\nis_adult: 18+ / Деликатно\nagree_terms: Съгласие с %terms_link_start%Общите условия%terms_link_end% и \n  %policy_link_start%Политиката за поверителност%policy_link_end%\nnote: Бележка\ncomment_reply_position: Позиция на отговор на коментари\nbadges: Значки\nposition_top: Отгоре\npreview: Преглед\ndashboard: Табло\nclose: Затваряне\nshow_thumbnails: Показване на миниатюри\nflash_post_new_success: Публикацията е създадена успешно.\ncopy_url: Копиране на Mbin адреса\nflash_comment_edit_error: Неуспешно редактиране на коментара. Нещо се обърка.\nflash_comment_new_error: Неуспешно създаване на коментар. Нещо се обърка.\nsubscribed: Абонаменти\nflash_post_new_error: Публикацията не можа да бъде създадена. Нещо се обърка.\npage_width_fixed: Фикс\nremoved_thread_by: премахна нишка от\nflash_thread_edit_success: Нишката е редактирана успешно.\nrestored_post_by: възстанови публикация от\nflash_post_edit_error: Неуспешно редактиране на публикацията. Нещо се обърка.\npage_width_auto: Авто\nflash_post_edit_success: Публикацията е редактирана успешно.\nmeta: Мета\nright: Отдясно\nflash_user_edit_password_error: Неуспешна промяна на паролата.\noauth.consent.allow: Позволяване\ncustom_css: Персонализиран CSS\nignore_magazines_custom_css: Игнориране на персонализирания CSS на общностите\nflash_thread_edit_error: Неуспешно редактиране на нишката. Нещо се обърка.\noauth.consent.deny: Отказване\nflash_user_edit_email_error: Неуспешна промяна на адреса на е-поща.\npage_width_max: Макс\npage_width: Ширина на страницата\nflash_comment_new_success: Коментарът е създаден успешно.\nflash_user_settings_general_error: Неуспешно запазване на потребителските \n  настройки.\nremoved_comment_by: премахна коментар от\nleft: Отляво\nrestored_comment_by: възстанови коментар от\nflash_user_edit_profile_success: Настройките на профила са запазени успешно.\nsubscription_sort: Подреждане\nflash_user_settings_general_success: Потребителските настройки са запазени \n  успешно.\nflash_thread_delete_success: Нишката е изтрита успешно.\nrestored_thread_by: възстанови нишка от\ncontact_email: Ел. поща за контакт\nremoved_post_by: премахна публикация от\nshow_magazines_icons: Показване на иконките на общностите\nflash_user_edit_profile_error: Неуспешно запазване на настройките на профила.\nkbin_bot: Mbin Агент\nadded_new_reply: Добави нов отговор\nfeatured_magazines: Представени общности\nmod_remove_your_thread: Модератор премахна твоя нишка\nfont_size: Размер на шрифта\nfilter.adult.hide: Скриване на деликатно съдържание\nare_you_sure: Сигурен ли си?\nYour account is not active: Вашият акаунт не е активен.\n2fa.disable: Изключване на двуфакторното удостоверяване\nadded_new_comment: Добави нов коментар\ntoolbar.ordered_list: Подреден списък\nemail_confirm_content: 'Готов ли си да активираш своя Mbin акаунт? Щракни върху връзката\n  по-долу:'\nsidebar_position: Позиция на страничната лента\nimage_alt: Алтернативен текст на изображението\nfilter.adult.show: Показване на деликатно съдържание\nfilter.adult.only: Само деликатно съдържание\nemail_confirm_header: Здравей! Потвърди своя адрес на ел. поща.\nimage: Изображение\nsidebar: Странична лента\ntoolbar.unordered_list: Неподреден списък\nrounded_edges: Заоблени ръбове\nedited_thread: Редактира нишка\npreferred_languages: Филтриране по език на нишките и публикациите\n2fa.enable: Настройване на двуфакторно удостоверяване\nauto_preview: Автоматичен преглед на мултимедията\nwrote_message: Написа съобщение\nmod_deleted_your_comment: Модератор изтри твой коментар\ntoolbar.mention: Споменаване\nemail_confirm_title: Потвърди своя адрес на ел. поща.\nadded_new_thread: Добави нова нишка\ntwo_factor_backup: Резервни кодове за двуфакторно удостоверяване\nedit_comment: Запазване на промените\nedited_comment: Редактира коментар\nmod_remove_your_post: Модератор премахна твоя публикация\npassword_and_2fa: Парола & 2FA\ninfinite_scroll: Безкрайно превъртане\nemail_confirm_expire: Моля, имай предвид, че връзката ще изтече след час.\n2fa.backup: Твоите резервни кодове за двуфакторно удостоверяване\nall_time: Цялото време\nPassword is invalid: Паролата е невалидна.\nedited_post: Редактира публикация\nadded_new_post: Добави нова публикация\ntwo_factor_authentication: Двуфакторно удостоверяване\nreplied_to_your_comment: Отговори на твой коментар\n2fa.verify_authentication_code.label: Въведи двуфакторен код, за да потвърдиш \n  настройката\nflash_magazine_edit_success: Общността е редактирана успешно.\ntoolbar.image: Изображение\nnew_email_repeat: Потвърди новия адрес на ел. поща\nhide_adult: Скриване на деликатното съдържание\nmenu: Меню\ndynamic_lists: Динамични списъци\nreport_issue: Докладване на проблем\n2fa.authentication_code.label: Код за удостоверяване\n2fa.code_invalid: Кодът за удостоверяване не е валиден\nuser_badge_global_moderator: Глобален Мод\nsensitive_show: Щракни, за показване\nrestore: Възстановяване\nsensitive_hide: Щракни, за скриване\nannouncement: Оповестяване\nhide: Скриване\nshow: Показване\ndetails: Подробности\nspoiler: Спойлер\nsubscribers_count: '{0}Абонирани|{1}Абониран|]1,Inf[ Абонирани'\nfollowers_count: '{0}Последователи|{1}Последовател|]1,Inf[ Последователи'\npurge: Изчистване\nerrors.server429.title: 429 Твърде много заявки\nlast_active: Последно активни\nupdate_comment: Обновяване на коментара\n2fa.verify: Потвърждаване\nsubscription_panel_large: Голям панел\ndelete_magazine: Изтриване на общността\nmagazine_deletion: Изтриване на общност\nrestore_magazine: Възстановяване на общността\napply_for_moderator: Кандидатстване за модератор\nsensitive_warning: Деликатно съдържание\ntype.smart_contract: Смарт контракт\npost: Публикация\nerrors.server403.title: 403 Забранено\nerrors.server404.title: 404 Не е намерено\nsubscriptions_in_own_sidebar: В отделна странична лента\nexpires: Изтича\nkbin_intro_title: Разгледай Федивселената\ntype_search_term: Въведи термин за търсене\ndeletion: Изтриване\nerrors.server500.title: 500 Вътрешна грешка на сървъра\nshow_subscriptions: Показване на абонаментите\nabandoned: Изоставени\nshow_profile_followings: Показване на следваните потребители\nnotify_on_new_entry_reply: Коментари на всички нива в нишки, които съм създал\nnotify_on_new_post_comment_reply: Отговори на мои коментари във всички \n  публикации\nnotify_on_new_post_reply: Отговори на всички нива в публикации, които съм създал\nsubscription_header: Абонаменти за общности\naction: Действие\nrelated_tags: Подобни етикети\nshow_profile_subscriptions: Показване на абонаментите за общности\nnotify_on_new_posts: Нови публикации във всяка общност, за която съм абониран\nadd_mentions_entries: Добавяне на етикети за споменаване в нишките\nadd_mentions_posts: Добавяне на етикети за споменаване в публикациите\nnotify_on_new_entry_comment_reply: Отговори на мои коментари във всички нишки\nnotify_on_new_entry: Нови нишки (връзки или статии) във всяка общност, за която \n  съм абониран\nunblock: Отблокиране\nmarked_for_deletion: Отбелязано за изтриване\nmarked_for_deletion_at: Отбелязано за изтриване на %date%\nsingle_settings: Отделни\naccount_deletion_title: Изтриване на акаунта\nedited: редактирано\nkbin_promo_title: Създай своя собствена инстанция\nkbin_promo_desc: '%link_start%Клонирай хранилището%link_end% и разработвай федивселената'\nremove_user_cover: Премахване на корицата\nremove_user_avatar: Премахване на лика\nhidden: Скрито\nedit_entry: Редактиране на нишката\nadd_badge: Добавяне на значка\nmoderation.report.approve_report_title: Одобряване на доклада\nmoderate: Модериране\nviewing_one_signup_request: Разглеждаш само една заявка за регистрация от \n  %username%\nfederated_search_only_loggedin: Федеративното търсене е ограничено, ако не сте \n  влезли\nfederation_page_dead_description: Инстанции, до които не можахме да доставим \n  поне 10 дейности подред и където последната успешна доставка и получаване са \n  били преди повече от седмица\naccount_deletion_button: Изтриване на акаунта\nemail.delete.description: Следният потребител е поискал акаунтът му да бъде \n  изтрит\nresend_account_activation_email: Изпращане отново на имейл за активиране на \n  акаунта\nemail_confirm_button_text: Потвърдете заявката си за промяна на паролата\nemail_confirm_link_help: Алтернативно, можеш да копираш и поставиш следното в \n  браузъра си\nemail.delete.title: Заявка за изтриване на потребителски акаунт\nresend_account_activation_email_error: Възникна проблем при изпращането на тази \n  заявка. Може да няма акаунт, свързан с този имейл, или може би вече е \n  активиран.\nshow_avatars_on_comments_help: Показване/скриване на потребителски ликове при \n  преглед на коментари към единична нишка или публикация.\nmagazine_theme_appearance_background_image: Персонализирано фоново изображение, \n  което ще се прилага при преглед на съдържание в твоята общност.\nmagazine_theme_appearance_icon: Персонализирана иконка за общността.\nfilter_by_federation: Филтриране по статут на федерирането\nflash_thread_new_success: Нишката е създадена успешно и вече е видима за другите\n  потребители.\nmod_log_alert: ПРЕДУПРЕЖДЕНИЕ - Дневникът на модерирането може да съдържа \n  неприятно или стресиращо съдържание, което е било премахнато от модераторите. \n  Моля, бъди внимателен.\nfrom: от\nreset_check_email_desc: Ако вече съществува акаунт, свързан с твоя адрес на ел. \n  поща, скоро трябва да получиш писмо, съдържащо връзка, която можеш да \n  използваш за нулиране на паролата си. Тази връзка ще изтече след %expire%.\nenabled: Включено\ndisabled: Изключено\noauth.consent.title: OAuth2 формуляр за съгласие\nerrors.server500.description: Съжаляваме, нещо се обърка от наша страна. Ако \n  продължаваш да виждаш тази грешка, опитай да се свържеш с притежателя на \n  инстанцията. Ако тази инстанция изобщо не работи, разгледай %link_start%други \n  Mbin инстанции%link_end% междувременно, докато проблемът бъде разрешен.\noauth.consent.app_has_permissions: вече може да извършва следните действия\ncomment_reply_position_help: Показване на формата за отговор на коментар в \n  горната или долната част на страницата. Когато е включено „безкрайно \n  превъртане“, позицията винаги ще се показва в горната част.\nflash_post_pin_success: Публикацията е закачена успешно.\nmoderation.report.approve_report_confirmation: Сигурни ли сте, че искате да \n  одобрите този доклад?\nsubject_reported_exists: Това съдържание вече е докладвано.\nmoderation.report.reject_report_confirmation: Сигурни ли сте, че искате да \n  отхвърлите този доклад?\npurge_content: Изчистване на съдържанието\nreset_check_email_desc2: Ако не получиш писмо, провери папката си за спам.\nflash_thread_unpin_success: Нишката е откачена успешно.\nflash_thread_pin_success: Нишката е закачена успешно.\ntoo_many_requests: Ограничението е превишено, моля, опитай отново по-късно.\nbanned: Забрани те\nkbin_intro_desc: е децентрализирана платформа за агрегиране на съдържание и \n  микроблогинг, която работи в мрежата на Федивселената.\nbrowsing_one_thread: Разглеждаш само една нишка в дискусията! Всички коментари \n  са достъпни на страницата на публикацията.\ninfinite_scroll_help: Автоматично зареждане на още съдържание при достигане на \n  дъното на страницата.\nreload_to_apply: Презареди страницата, за да приложиш промените\npassword_confirm_header: Потвърдете заявката си за промяна на паролата.\noauth.consent.grant_permissions: Предоставяне на разрешения\nmagazine_theme_appearance_custom_css: Персонализиран CSS, който ще се прилага \n  при преглед на съдържание в твоята общност.\ndelete_content: Изтриване на съдържанието\nremove_media: Премахване на мултимедията\naccount_deletion_description: Акаунтът ти ще бъде изтрит след 30 дни, освен ако \n  не избереш да го изтриеш незабавно. За да възстановиш акаунта си в рамките на \n  30 дни, влез със същите потребителски данни или се свържи с администратор.\nnotify_on_user_signup: Нови регистрации\nsolarized_dark: Слънчево тъмна\nsolarized_light: Слънчево светла\ndefault_theme_auto: Светла/Тъмна (Автоматично)\nsolarized_auto: Слънчева (Автоматично)\nshow_all: Покажи всичко\nflash_register_success: Добре дошъл! Твоят акаунт вече е регистриран. Още една \n  стъпка - провери входящата си кутия за връзка за активиране, която ще оживи \n  акаунта ти.\nflash_magazine_new_success: Общността е създадена успешно. Вече можеш да добавяш\n  ново съдържание или да разгледаш административния панел на общността.\nflash_mark_as_adult_success: Публикацията е отбелязана успешно като деликатна.\nflash_unmark_as_adult_success: Публикацията е демаркирана успешно като \n  деликатна.\nset_magazines_bar: Лента с общности\nmentioned_you: Спомена те\nfilter.fields.label: Избери кои полета да се търсят\nyour_account_is_not_yet_approved: Твоят акаунт все още не е одобрен. Ще ти \n  изпратим имейл веднага щом администраторите обработят заявката ти за \n  регистрация.\nyour_account_is_not_active: Твоят акаунт не е активиран. Моля, провери имейла си\n  за инструкции за активиране на акаунта или <a href=\"%link_target%\">поискай нов\n  имейл за активиране на акаунта.</a>\ntoolbar.spoiler: Спойлер\nfederation_page_allowed_description: Известни инстанции, с които се федерираме\nfederation_page_disallowed_description: Инстанции, с които не се федерираме\nresend_account_activation_email_success: Ако съществува акаунт, свързан с този \n  имейл, ще изпратим нов имейл за активиране.\nresend_account_activation_email_description: Въведете адреса на ел. поща свързан\n  с вашия акаунт. Ще ви изпратим друг имейл за активиране.\noauth.consent.to_allow_access: За да разрешиш този достъп, щракни върху бутона \n  'Позволяване' по-долу\nflash_post_unpin_success: Публикацията е откачена успешно.\nfederation_page_dead_title: Мъртви инстанции\nfilter.adult.label: Избери дали да се показва деликатно съдържание\naccount_deletion_immediate: Незабавно изтриване\nread_all: Прочети всичко\noauth.consent.app_requesting_permissions: иска да извърши следните действия от \n  твое име\nsort_by: Подреждане по\nfilter_by_subscription: Филтриране по абонамент\ndisconnected_magazine_info: Тази общност не получава обновления (последна \n  активност преди %days% дни).\nalways_disconnected_magazine_info: Тази общност не получава обновления.\nsubscribe_for_updates: Абонирай се, за да започнеш да получаваш обновления.\nmoderation.report.reject_report_title: Отхвърляне на доклада\naccount_settings_changed: Настройките на акаунта ти са променени успешно. Ще \n  трябва да влезеш отново.\nshow_related_magazines: Показване на случайни общности\nshow_related_posts: Показване на случайни публикации\nadmin_users_inactive: Неактивни\ntable_of_contents: Съдържание\nsearch_type_all: Всичко\nshow_new_icons: Показване на нови иконки\nflash_email_was_sent: Имейлът е изпратен успешно.\nflash_email_failed_to_sent: Имейлът не можа да бъде изпратен.\nmagazine_is_deleted: Общността е изтрита. Можеш да я <a \n  href=\"%link_target%\">възстановиш</a> в рамките на 30 дни.\nmagazine_log_mod_added: добави модератор\nmagazine_log_mod_removed: премахна модератор\nflash_thread_tag_banned_error: Нишката не можа да бъде създадена. Съдържанието \n  не е позволено.\nshow_new_icons_help: Показване на иконка за нова общност/потребител (на 30 дни \n  или по-нова)\nnotification_title_removed_thread: Нишка беше премахната\nnotification_title_mention: Беше споменат\nnotification_title_new_thread: Нова нишка\nflash_image_download_too_large_error: Изображението не можа да бъде създадено, \n  твърде голямо е (макс. размер %bytes%)\ndirect_message: Лично съобщение\nnotification_title_new_post: Нова публикация\n2fa.setup_error: Грешка при включване на двуфакторното удостоверяване за акаунта\nflash_account_settings_changed: Настройките на акаунта ти бяха променени \n  успешно. Ще трябва да влезеш отново.\nflash_comment_edit_success: Коментарът е обновен успешно.\nreported_user: Докладван потребител\ncake_day: Тортен ден\nnotification_title_message: Ново лично съобщение\nmagazine_posting_restricted_to_mods_warning: Само модераторите могат да създават\n  нишки в тази общност\n2fa.backup_codes.recommendation: Препоръчително е да запазиш копие от тях на \n  сигурно място.\nsensitive_toggle: Превключване на видимостта на деликатно съдържание\ncontinue_with: Продължаване със\nown_content_reported_accepted: Доклад за твое съдържание беше приет.\nnotification_title_new_comment: Нов коментар\nnotification_title_removed_comment: Коментар беше премахнат\nnotification_title_edited_comment: Коментар беше редактиран\nnotification_title_edited_thread: Нишка беше редактирана\nnotification_title_removed_post: Публикация беше премахната\nnotification_title_edited_post: Публикация беше редактирана\nnotification_title_new_signup: Нов потребител се регистрира\nnotification_body_new_signup: Потребителят %u% се регистрира.\nnotification_body2_new_signup_approval: Трябва да одобрите заявката, преди да \n  могат да влязат\nshow_related_entries: Показване на случайни нишки\nlast_failed_contact: Последен неуспешен контакт\nflash_posting_restricted_error: Създаването на нишки е ограничено до модератори \n  в тази общност, а ти не си такъв\nadmin_users_active: Активни\nmax_image_size: Максимален размер на файла\nbookmark_add_to_list: Добавяне на отметка към %list%\nbookmark_add_to_default_list: Добавяне на отметка към списъка по подразбиране\nbookmark_remove_from_list: Премахване на отметка от %list%\nbookmark_lists: Списъци с отметки\nbookmark_remove_all: Премахване на всички отметки\nbookmarks: Отметки\nbookmarks_list: Отметки в %list%\nbookmark_list_create: Създаване\nbookmark_list_create_placeholder: въведи име...\nbookmark_list_create_label: Име на списъка\nbookmarks_list_edit: Редактиране на списъка с отметки\nbookmark_list_edit: Редактиране\nnew_users_need_approval: Новите потребители трябва да бъдат одобрени от \n  администратор, преди да могат да влязат.\nsearch_type_post: Микроблогове\nsearch_type_entry: Нишки\nselect_user: Избери потребител\nsignup_requests: Заявки за регистрация\napplication_text: Обяснете защо искате да се присъедините\nsignup_requests_header: Заявки за регистрация\nsignup_requests_paragraph: Тези потребители биха искали да се присъединят към \n  вашия сървър. Те не могат да влязат, докато не одобрите заявката им за \n  регистрация.\ncomment_not_found: Коментарът не е намерен\nnotification_title_new_reply: Нов отговор\nflash_thread_new_error: Нишката не можа да бъде създадена. Нещо се обърка.\ndeleted_by_moderator: Нишката, публикацията или коментарът е изтрит от модератор\nshow_active_users: Показване на активни потребители\nnotification_title_new_report: Създаден е нов доклад\nserver_software: Сървърен софтуер\nlast_successful_receive: Последно успешно получаване\nversion: Версия\nlast_successful_deliver: Последна успешна доставка\ndeleted_by_author: Нишката, публикацията или коментарът е изтрит от автора\nauto: Авто.\nlast_updated: Последно обновено\nback: Назад\nreporting_user: Докладващ потребител\nown_report_rejected: Твоят доклад беше отхвърлен\nown_report_accepted: Твоят доклад беше приет\nsomeone: Някой\nand: и\ntest_push_message: Здравей, свят!\ncomment_default_sort: Подреждане по подразбиране на коментарите\nmagazine_log_entry_pinned: закачи запис\nmagazine_log_entry_unpinned: премахна закачен запис\ncompact_view_help: Компактен изглед с по-малки полета, където мултимедията е \n  преместена отдясно.\nshow_thumbnails_help: Показване на миниатюрните изображения.\nshow_users_avatars_help: Показване на изображението на потребителския лик.\nimage_lightbox_in_list_help: Когато е отметнато, щракването върху миниатюрата \n  показва модален прозорец с изображение. Когато не е отметнато, щракването \n  върху миниатюрата ще отвори нишката.\nshow_magazines_icons_help: Показване на иконката на общността.\nfront_default_sort: Подреждане по подразбиране на началната страница\n2fa.backup-create.help: Можеш да създадеш нови резервни кодове за \n  удостоверяване; това ще направи съществуващите кодове невалидни.\nimage_lightbox_in_list: Миниатюрите на нишките се отварят на цял екран\n2fa.backup-create.label: Създаване на нови резервни кодове за удостоверяване\ntest_push_notifications_button: Тестване на известията\nregister_push_notifications_button: Регистриране за известия\nbookmark_list_make_default: Задаване по подразбиране\nownership_requests: Заявки за притежание\nrequest_magazine_ownership: Заявете притежание на общността\nunregister_push_notifications_button: Премахване на насочени известия\nmanually_approves_followers: Ръчно одобрява последователи\ncreated: Създаден\nmark_as_adult: Отбелязване като деликатно\nunmark_as_adult: Демаркиране като деликатно\nshow_magazine_domains: Показване на домейните на общностите\nshow_user_domains: Показване на домейните на потребителите\nshow_top_bar: Показване на горната лента\nsticky_navbar: Залепена навигационна лента\nmagazine_panel: Панел на общността\nadmin_panel: Админ панел\nregistrations_enabled: Регистрирането е включено\nregistration_disabled: Регистрирането е изключено\npinned: Закачено\nfederation_enabled: Федерирането е включено\nflash_magazine_theme_changed_success: Изгледът на общността е обновен успешно.\nflash_magazine_theme_changed_error: Неуспешно обновяване на изгледа на \n  общността.\nnew_user_description: Този потребител е нов (активен от по-малко от %days% дни)\nnew_magazine_description: Тази общност е нова (активна от по-малко от %days% \n  дни)\nmagazine_posting_restricted_to_mods: Ограничаване на създаването на нишки до \n  модератори\nsidebars_same_side: Страничните ленти от една и съща страна\nremove_following: Премахване на следваните\nmoderator_requests: Заявки за модератор\nbanned_instances: Забранени инстанции\nfilter_labels: Етикети на филтрите\npurge_magazine: Изчистване на общността\nremove_subscriptions: Премахване на абонаментите\npurge_account: Изчистване на акаунта\ncancel_request: Отказване на заявката\nchange_downvotes_mode: Промяна на режима на редуциране\ntag: Етикет\neng: АНГЛ\noauth2.grant.moderate.magazine_admin.delete: Изтриване на някоя от притежаваните\n  от теб общности.\noauth2.grant.moderate.magazine_admin.moderators: Добавяне или премахване на \n  модератори на някоя от притежаваните от теб общности.\noauth2.grant.moderate.magazine_admin.stats: Преглед на съдържанието, гласуването\n  и статистиката на притежаваните от теб общности.\noauth2.grant.entry.edit: Редактиране на твоите съществуващи нишки.\noauth2.grant.entry.report: Докладване на всяка нишка.\noauth2.grant.entry_comment.create: Създаване на нови коментари в нишки.\noauth2.grant.entry_comment.vote: Гласуване нагоре, подсилване или гласуване \n  надолу за всеки коментар в нишка.\noauth2.grant.magazine.block: Блокиране или отблокиране на общности и преглед на \n  общностите, които си блокирал.\noauth2.grant.user.bookmark.add: Добавяне на отметки\noauth2.grant.user.bookmark.remove: Премахване на отметки\noauth2.grant.user.bookmark_list.read: Четене на твоите списъци с отметки\noauth2.grant.user.bookmark_list.edit: Редактиране на твоите списъци с отметки\noauth2.grant.domain.subscribe: Абониране или отписване от домейни и преглед на \n  домейните, за които си абониран.\noauth2.grant.moderate.entry_comment.trash: Изтриване или възстановяване на \n  коментари в нишки в модерираните от теб общности.\noauth2.grant.admin.entry_comment.purge: Пълно изтриване на всеки коментар в \n  нишка от твоята инстанция.\noauth2.grant.admin.magazine.move_entry: Преместване на нишки между общности в \n  твоята инстанция.\noauth2.grant.admin.user.delete: Изтриване на потребители от твоята инстанция.\noauth.client_not_granted_message_read_permission: Това приложение не е получило \n  разрешение да чете твоите съобщения.\nauto_preview_help: Показване на прегледите на мултимедията (снимка, видео) в \n  по-голям размер под съдържанието.\nyour_account_has_been_banned: Вашият акаунт е забранен\noauth2.grant.report.general: Докладване на нишки, публикации или коментари.\noauth2.grant.post.edit: Редактиране на твоите съществуващи публикации.\nschedule_delete_account: Планиране на изтриване\noauth2.grant.domain.all: Абониране за или блокиране на домейни и преглед на \n  домейните, за които си абониран или блокирал.\noauth2.grant.moderate.entry_comment.set_adult: Отбелязване на коментари в нишки \n  като деликатни в модерираните от теб общности.\noauth2.grant.moderate.post.all: Модериране на публикации в модерираните от теб \n  общности.\noauth2.grant.moderate.magazine.all: Управление на забрани, доклади и преглед на \n  изтрити елементи в модерираните от теб общности.\noauth2.grant.admin.user.all: Забраняване, потвърждаване или пълно изтриване на \n  потребители в твоята инстанция.\noauth2.grant.admin.magazine.all: Преместване на нишки между или пълно изтриване \n  на общности в твоята инстанция.\noauth2.grant.admin.magazine.purge: Пълно изтриване на общности в твоята \n  инстанция.\noauth.client_identifier.invalid: Невалиден OAuth клиентски идентификатор!\nmoderation.report.ban_user_description: Искаш ли да забраниш потребителя \n  (%username%), който е създал това съдържание, от тази общност?\nreport_subject: Предмет\nbans: Забрани\noauth2.grant.moderate.magazine.reports.all: Управление на докладите в \n  модерираните от теб общности.\noauth2.grant.moderate.magazine.reports.read: Четене на докладите в модерираните \n  от теб общности.\noauth2.grant.admin.all: Извършване на всяко административно действие върху \n  твоята инстанция.\noauth2.grant.delete.general: Изтриване на твоите нишки, публикации или \n  коментари.\noauth2.grant.admin.entry.purge: Пълно изтриване на всяка нишка от твоята \n  инстанция.\noauth2.grant.block.general: Блокиране или отблокиране на всяка общност, домейн \n  или потребител и преглед на общностите, домейните и потребителите, които си \n  блокирал.\noauth2.grant.entry.delete: Изтриване на твоите съществуващи нишки.\noauth2.grant.entry_comment.all: Създаване, редактиране или изтриване на твоите \n  коментари в нишки и гласуване, подсилване или докладване на всеки коментар в \n  нишка.\noauth2.grant.magazine.all: Абониране за или блокиране на общности и преглед на \n  общностите, за които си абониран или блокирал.\noauth2.grant.post_comment.all: Създаване, редактиране или изтриване на твоите \n  коментари към публикации и гласуване, подсилване или докладване на всеки \n  коментар към публикация.\noauth2.grant.user.bookmark_list: Четене, редактиране и изтриване на твоите \n  списъци с отметки\nmagazine_panel_tags_info: Попълни само ако искаш съдържание от федивселената да \n  бъде включено в тази общност въз основа на етикети\nreturn: Връщане\nbot_body_content: \"Добре дошъл в Mbin Агента! Този агент играе ключова роля във включването\n  на ActivityPub функционалността в Mbin. Той гарантира, че Mbin може да комуникира\n  и да се федерира с други инстанции във федивселената.\\n\\nActivityPub е отворен стандартен\n  протокол, който позволява на децентрализираните социални мрежови платформи да комуникират\n  и да си взаимодействат. Той позволява на потребителите на различни инстанции (сървъри)\n  да следват, взаимодействат и споделят съдържание във федеративната социална мрежа,\n  известна като федивселената. Той предоставя стандартизиран начин за потребителите\n  да публикуват съдържание, да следват други потребители и да участват в социални\n  взаимодействия като харесване, споделяне и коментиране на нишки или публикации.\"\nsuspend_account: Спиране на акаунта\noauth2.grant.user.bookmark: Добавяне и премахване на отметки\noauth2.grant.user.bookmark_list.delete: Изтриване на твоите списъци с отметки\noauth2.grant.user.profile.all: Четене и редактиране на твоя профил.\noauth2.grant.user.message.all: Четене на твоите съобщения и изпращане на \n  съобщения до други потребители.\noauth2.grant.admin.instance.settings.all: Преглед или обновяване на настройките \n  на твоята инстанция.\noauth2.grant.admin.user.purge: Пълно изтриване на потребители от твоята \n  инстанция.\nuser_suspend_desc: Спирането на акаунта ти скрива съдържанието ти в инстанцията,\n  но не го премахва за постоянно и можеш да го възстановиш по всяко време.\nsso_registrations_enabled: SSO регистрациите са включени\nrestrict_magazine_creation: Ограничаване на създаването на местни общности до \n  администратори и глобални модератори\nby: от\nanswered: отговорено\nopen_signup_request: Отваряне на заявката за регистрация\ndownvotes_mode: Режим на редуцирането\ndelete_account_desc: Изтриване на акаунта, включително отговорите на други \n  потребители в създадените нишки, публикации и коментари.\n2fa.user_active_tfa.title: Потребителят има активно 2FA\naccount_suspended: Акаунтът е спрян.\n2fa.add: Добавяне към моя акаунт\n2fa.qr_code_link.title: Посещаването на тази връзка може да позволи на твоята \n  платформа да регистрира това двуфакторно удостоверяване\nban_expired: Забраната изтече\nunban: Разрешаване\nban_hashtag_btn: Забраняване на хаштага\nban: Забраняване\nadd_ban: Добавяне на забрана\nexpired_at: Изтекла на\nunban_account: Разрешаване на акаунта\ncaptcha_enabled: Captcha е включено\nheader_logo: Лого на заглавката\nmercure_enabled: Mercure е включено\noauth2.grant.moderate.magazine.ban.delete: Разрешаване на потребители в \n  модерираните от теб общности.\noauth2.grant.moderate.magazine.list: Четене на списъка с модерираните от теб \n  общности.\nprivate_instance: Принуждаване на потребителите да влязат, преди да имат достъп \n  до каквото и да е съдържание\noauth2.grant.moderate.magazine.trash.read: Преглед на изтритото съдържание в \n  модерираните от теб общности.\noauth2.grant.moderate.magazine_admin.all: Създаване, редактиране или изтриване \n  на притежаваните от теб общности.\noauth2.grant.moderate.magazine.reports.action: Приемане или отхвърляне на \n  доклади в модерираните от теб общности.\noauth2.grant.moderate.magazine_admin.update: Редактиране на правила, описание, \n  състояние на деликатността или иконката на някоя от притежаваните от теб \n  общности.\noauth2.grant.write.general: Създаване или редактиране на твоите нишки, \n  публикации или коментари.\noauth2.grant.entry.create: Създаване на нови нишки.\noauth2.grant.entry.vote: Гласуване нагоре, подсилване или гласуване надолу за \n  всяка нишка.\noauth2.grant.post.create: Създаване на нови публикации.\noauth2.grant.user.profile.read: Четене на твоя профил.\noauth2.grant.user.all: Четене и редактиране на твоя профил, съобщения или \n  известия; Четене и редактиране на разрешенията, които си предоставил на други \n  приложения; следване или блокиране на други потребители; преглед на списъци с \n  потребители, които следваш или блокираш.\noauth2.grant.user.notification.all: Четене и изчистване на твоите известия.\noauth2.grant.user.notification.read: Четене на твоите известия, включително \n  известия за съобщения.\noauth2.grant.user.oauth_clients.edit: Редактиране на разрешенията, които си \n  предоставил на други OAuth2 приложения.\noauth2.grant.user.follow: Последване или отследване на потребители и четене на \n  списъка с потребители, които следваш.\noauth2.grant.user.block: Блокиране или отблокиране на потребители и четене на \n  списъка с потребители, които блокираш.\noauth2.grant.moderate.entry_comment.all: Модериране на коментари в нишки в \n  модерираните от теб общности.\noauth2.grant.moderate.entry.set_adult: Отбелязване на нишки като деликатни в \n  модерираните от теб общности.\noauth2.grant.moderate.entry.trash: Изтриване или възстановяване на нишки в \n  модерираните от теб общности.\noauth2.grant.admin.federation.read: Преглед на списъка с дефедерирани инстанции.\noauth2.grant.admin.federation.all: Преглед и обновяване на текущо дефедерираните\n  инстанции.\noauth2.grant.admin.instance.information.edit: Обновяване на страниците \n  „Относно“, „Често задавани въпроси“, „За контакт“, „Условия за ползване“ и \n  „Политика за поверителност“ на твоята инстанция.\nremove_schedule_delete_account: Премахване на планираното изтриване\nschedule_delete_account_desc: Планиране на изтриването на този акаунт след 30 \n  дни. Това ще скрие потребителя и неговото съдържание, както и ще попречи на \n  потребителя да влезе.\nremove_schedule_delete_account_desc: Премахване на планираното изтриване. Цялото\n  съдържание ще бъде отново достъпно и потребителят ще може да влезе.\n2fa.qr_code_img.alt: QR код, който позволява настройката на двуфакторно \n  удостоверяване за твоя акаунт\nperm: За постоянно\noauth2.grant.admin.instance.stats: Преглед на статистиката на твоята инстанция.\nnotification_title_ban: Получихте забрана\noauth2.grant.moderate.entry.change_language: Промяна на езика на нишките в \n  модерираните от теб общности.\nhe_banned: забрани\nhe_unbanned: разреши\nset_magazines_bar_desc: добави имената на общностите след запетаята\nset_magazines_bar_empty_desc: ако полето е празно, активните общности се \n  показват на лентата.\nban_hashtag_description: Забраната на хаштаг ще спре създаването на публикации с\n  този хаштаг, както и ще скрие съществуващите публикации с този хаштаг.\nunban_hashtag_btn: Разрешаване на хаштага\nunban_hashtag_description: Разрешаването на хаштаг ще позволи отново създаването\n  на публикации с този хаштаг. Съществуващите публикации с този хаштаг вече няма\n  да са скрити.\nban_account: Забраняване на акаунта\ntokyo_night: Нощ в Токио\nfilter.origin.label: Избери произход\nsticky_navbar_help: Навигационната лента ще залепне за горната част на \n  страницата, когато превърташ надолу.\nfederation_page_enabled: Страницата за федерирането е включена\noauth2.grant.moderate.magazine_admin.create: Създаване на нови общности.\nrestrict_oauth_clients: Ограничаване на създаването на OAuth2 клиенти до \n  администратори\noauth2.grant.moderate.magazine_admin.edit_theme: Редактиране на персонализирания\n  CSS на някоя от притежаваните от теб общности.\noauth2.grant.entry_comment.edit: Редактиране на твоите съществуващи коментари в \n  нишки.\noauth2.grant.entry_comment.report: Докладване на всеки коментар в нишка.\noauth2.grant.domain.block: Блокиране или отблокиране на домейни и преглед на \n  домейните, които си блокирал.\noauth2.grant.post.vote: Гласуване нагоре, подсилване или гласуване надолу за \n  всяка публикация.\noauth2.grant.post.report: Докладване на всяка публикация.\noauth2.grant.post_comment.create: Създаване на нови коментари към публикации.\noauth2.grant.post_comment.edit: Редактиране на твоите съществуващи коментари към\n  публикации.\noauth2.grant.post_comment.vote: Гласуване нагоре, подсилване или гласуване \n  надолу за всеки коментар към публикация.\noauth2.grant.post_comment.delete: Изтриване на твоите съществуващи коментари към\n  публикации.\noauth2.grant.user.message.read: Четене на твоите съобщения.\noauth2.grant.user.oauth_clients.read: Четене на разрешенията, които си \n  предоставил на други OAuth2 приложения.\noauth2.grant.moderate.all: Извършване на всяко действие по модериране, което \n  имаш разрешение да извършиш в модерираните от теб общности.\noauth2.grant.moderate.entry.all: Модериране на нишки в модерираните от теб \n  общности.\noauth2.grant.user.message.create: Изпращане на съобщения до други потребители.\noauth2.grant.moderate.entry.pin: Закачане на нишки в горната част на \n  модерираните от теб общности.\noauth2.grant.moderate.entry_comment.change_language: Промяна на езика на \n  коментарите в нишки в модерираните от теб общности.\noauth2.grant.moderate.post.change_language: Промяна на езика на публикациите в \n  модерираните от теб общности.\noauth2.grant.moderate.post_comment.change_language: Промяна на езика на \n  коментарите към публикации в модерираните от теб общности.\noauth2.grant.moderate.post_comment.trash: Изтриване или възстановяване на \n  коментари към публикации в модерираните от теб общности.\noauth2.grant.admin.user.ban: Забраняване или разрешаване на потребители от \n  твоята инстанция.\noauth2.grant.admin.user.verify: Потвърждаване на потребители в твоята инстанция.\noauth2.grant.admin.instance.all: Преглед и обновяване на настройките или \n  информацията за инстанцията.\noauth2.grant.admin.instance.settings.edit: Обновяване на настройките на твоята \n  инстанция.\noauth2.grant.admin.oauth_clients.all: Преглед или анулиране на OAuth2 клиенти, \n  които съществуват в твоята инстанция.\noauth2.grant.admin.federation.update: Добавяне или премахване на инстанции към \n  или от списъка с дефедерирани инстанции.\noauth2.grant.admin.instance.settings.read: Преглед на настройките на твоята \n  инстанция.\noauth2.grant.admin.oauth_clients.revoke: Анулиране на достъпа до OAuth2 клиенти \n  в твоята инстанция.\noauth2.grant.admin.oauth_clients.read: Преглед на OAuth2 клиентите, които \n  съществуват в твоята инстанция, и тяхната статистика за използване.\nmoderation.report.ban_user_title: Забраняване на потребителя\noauth2.grant.moderate.post.pin: Закачане на публикации в горната част на \n  модерираните от теб общности.\npurge_content_desc: Пълно изчистване на съдържанието на потребителя, включително\n  изтриване на отговорите на други потребители в създадените нишки, публикации и\n  коментари.\ndelete_content_desc: Изтриване на съдържанието на потребителя, оставяйки \n  отговорите на други потребители в създадените нишки, публикации и коментари.\n2fa.available_apps: Използвай приложение за двуфакторно удостоверяване като \n  %google_authenticator%, %aegis% (Android) или %raivo% (iOS), за да сканираш QR\n  кода.\nsubscription_sidebar_pop_out_right: Преместване в отделна странична лента \n  отдясно\nsso_show_first: Показване първо на SSO на страниците за вход и регистрация\nreport_accepted: Доклад беше приет\nopen_report: Отваряне на доклада\nadmin_users_suspended: Спрени\nadmin_users_banned: Забранени\nuser_verify: Активиране на акаунта\ncount: Брой\nbookmark_list_is_default: Списък по подразбиране\nis_default: По подразбиране\nbookmark_list_selected_list: Избран списък\nemail_application_rejected_body: Благодарим ти за интереса, но със съжаление те \n  информираме, че заявката ти за регистрация е отхвърлена.\nemail_verification_pending: Трябва да потвърдиш своя адрес на ел. поща, преди да\n  можеш да влезеш.\nemail_application_pending: Акаунтът ти изисква одобрение от администратор, преди\n  да можеш да влезеш.\nemail_application_approved_title: Заявката ти за регистрация е одобрена\nemail_application_approved_body: Твоята заявка за регистрация беше одобрена от \n  администратора на сървъра. Вече можеш да влезеш в сървъра на <a \n  href=\"%link%\">%siteName%</a>.\nemail_application_rejected_title: Заявката ти за регистрация е отхвърлена\nflash_application_info: Администратор трябва да одобри акаунта ти, преди да \n  можеш да влезеш. Ще получиш имейл, след като заявката ти за регистрация бъде \n  обработена.\noauth2.grant.moderate.post.set_adult: Отбелязване на публикации като деликатни в\n  модерираните от теб общности.\noauth2.grant.moderate.post.trash: Изтриване или възстановяване на публикации в \n  модерираните от теб общности.\noauth2.grant.moderate.post_comment.all: Модериране на коментари към публикации в\n  модерираните от теб общности.\noauth2.grant.moderate.post_comment.set_adult: Отбелязване на коментари към \n  публикации като деликатни в модерираните от теб общности.\naccount_is_suspended: Потребителският акаунт е спрян.\naccount_unbanned: Акаунтът е разрешен.\nsso_registrations_enabled.error: Новите регистрации на акаунти с мениджъри на \n  самоличност на трети страни в момента са изключени.\nreported: докладва\nsso_only_mode: Ограничаване на влизането и регистрацията само до SSO методи\nYour account has been banned: Вашият акаунт е забранен.\noauth2.grant.moderate.magazine_admin.badges: Създаване или премахване на значки \n  от притежаваните от теб общности.\noauth2.grant.moderate.magazine_admin.tags: Създаване или премахване на етикети \n  от притежаваните от теб общности.\noauth2.grant.entry.all: Създаване, редактиране или изтриване на твоите нишки и \n  гласуване, подсилване или докладване на всяка нишка.\noauth2.grant.magazine.subscribe: Абониране или отписване от общности и преглед \n  на общностите, за които си абониран.\noauth2.grant.post.all: Създаване, редактиране или изтриване на твоите \n  микроблогове и гласуване, подсилване или докладване на всеки микроблог.\noauth2.grant.post.delete: Изтриване на твоите съществуващи публикации.\noauth2.grant.post_comment.report: Докладване на всеки коментар към публикация.\noauth2.grant.user.oauth_clients.all: Четене и редактиране на разрешенията, които\n  си предоставил на други OAuth2 приложения.\nsubscription_sidebar_pop_out_left: Преместване в отделна странична лента отляво\nunsuspend_account: Възстановяване на акаунта\naccount_unsuspended: Акаунтът е възстановен.\naccount_banned: Акаунтът е забранен.\nrelated_entry: Подобно\n2fa.remove: Премахване на 2FA\noauth2.grant.entry_comment.delete: Изтриване на твоите съществуващи коментари в \n  нишки.\noauth2.grant.read.general: Четене на цялото съдържание, до което имаш достъп.\noauth2.grant.vote.general: Гласуване нагоре, гласуване надолу или подсилване на \n  нишки, публикации или коментари.\noauth2.grant.subscribe.general: Абониране или следване на всяка общност, домейн \n  или потребител и преглед на общностите, домейните и потребителите, за които си\n  абониран.\noauth2.grant.moderate.magazine.ban.create: Забраняване на потребители в \n  модерираните от теб общности.\noauth2.grant.admin.post.purge: Пълно изтриване на всяка публикация от твоята \n  инстанция.\noauth2.grant.admin.post_comment.purge: Пълно изтриване на всеки коментар към \n  публикация от твоята инстанция.\noauth2.grant.moderate.magazine.ban.all: Управление на забранените потребители в \n  модерираните от теб общности.\noauth2.grant.moderate.magazine.ban.read: Преглед на забранените потребители в \n  модерираните от теб общности.\n2fa.backup_codes.help: Можеш да използваш тези кодове, когато нямаш своето \n  устройство или приложение за двуфакторно удостоверяване. <strong>Те няма да ти\n  бъдат показани отново</strong> и ще можеш да използваш всеки от тях \n  <strong>само веднъж</strong>.\nsubscription_sidebar_pop_in: Преместване на абонаментите във вградения панел\n2fa.manual_code_hint: Ако не можете да сканирате QR кода, въведете тайната ръчно\ntoolbar.emoji: Емоджи\ntype_search_term_url_handle: Въведи термин за търсене, уеб адрес или профил\nsearch_type_magazine: Общности\nsearch_type_user: Потребители\nsearch_type_actors: Общности + Потребители\nsearch_type_content: Нишки + Микроблогове\nuser_instance_defederated_info: Инстанцията на този потребител е дефедерирана.\nmagazine_instance_defederated_info: Инстанцията на тази общност е дефедерирана. \n  Следователно общността няма да получава обновления.\nflash_thread_instance_banned: Инстанцията на тази общност е забранена.\nshow_rich_mention: Разширени споменавания\nshow_rich_mention_magazine: Разширени споменавания на общностите\ntype_search_magazine: Ограничаване на търсенето до общност...\ntype_search_user: Ограничаване на търсенето до автор...\nbtn_allow: Позволяване\nallow_instance: Позволяване на инстанцията\nallowed_instances: Позволени инстанции\nnobody: Никой\nmodlog_type_entry_deleted: Нишка е изтрита\nmodlog_type_entry_restored: Нишка е възстановена\nmodlog_type_entry_comment_deleted: Нишков коментар е изтрит\nmodlog_type_entry_comment_restored: Нишков коментар е възстановен\nmodlog_type_entry_pinned: Нишка е закачена\nmodlog_type_entry_unpinned: Нишка е откачена\ncrosspost: Препубликуване\nbanner: Банер\nmagazine_theme_appearance_banner: Персонализиран банер за общността. Показва се \n  над всички нишки и трябва да е в широк формат (5:1 или 1500px * 300px).\nflash_thread_ref_image_not_found: Изображението, посочено чрез „imageHash“, не \n  може да бъде намерено.\nshow_rich_mention_help: Изобразяване на потребителски компонент при споменаване \n  на потребител. Това ще включва показваното им име и профилна снимка.\nshow_rich_mention_magazine_help: Изобразяване на компонент на общността при \n  споменаване. Това ще включва показваното име и иконка.\nshow_rich_ap_link: Богати AP връзки\nattitude: Отношение\nmodlog_type_post_deleted: Микроблог е изтрит\nmodlog_type_post_restored: Микроблог е възстановен\nmodlog_type_post_comment_deleted: Микроблогов отговор е изтрит\nmodlog_type_post_comment_restored: Микроблогов отговор е възстановен\nmodlog_type_ban: Потребител получи забрана от общност\nmodlog_type_moderator_add: Добавен е модератор на общност\nmodlog_type_moderator_remove: Премахнат е модератор на общност\nshow_rich_ap_link_help: Изобразяване на вграден компонент, когато е свързано \n  друго съдържание на ActivityPub.\neveryone: Всеки\nfollowers_only: Само последователи\ndelete_magazine_icon: Изтриване на иконката на общността\nflash_magazine_theme_icon_detached_success: Иконката на общността е изтрита \n  успешно\ndelete_magazine_banner: Изтриване на банера на общността\nflash_magazine_theme_banner_detached_success: Банерът на общността е изтрит \n  успешно\ntheir_user_follows: Брой потребители от тяхната инстанция, следващи потребители \n  от нашата инстанция\nour_user_follows: Брой потребители от нашата инстанция, следващи потребители от \n  тяхната инстанция\ntheir_magazine_subscriptions: Брой потребители от тяхната инстанция, абонирани \n  за общности от нашата инстанция\nour_magazine_subscriptions: Брой потребители от нашата инстанция, абонирани за \n  общности от тяхната инстанция\nbtn_deny: Отказване\nban_instance: Забраняване на инстанцията\ndefault_content_threads: Нишки\ndefault_content_microblog: Микроблог\ncombined: Обединено\ndirect_message_setting_label: Кой може да ви изпраща лично съобщение\ndefault_content_default: По подразбиране (Нишки)\nfront_default_content: Изглед по подразбиране на началната страница\nfederation_uses_allowlist: Използване на списък с разрешени инстанции за \n  федериране\ndefederating_instance: Дефедериране от инстанция %i\nconfirm_defederation: Потвърдете дефедериране\nflash_error_defederation_must_confirm: Трябва да потвърдите дефедерирането\nfederation_page_use_allowlist_help: Ако се използва списък с разрешени \n  инстанции, тази инстанция ще се федерира само с изрично разрешените инстанции.\n  В противен случай тази инстанция ще се федерира с всички инстанции, с \n  изключение на тези, които са забранени.\ndefault_content_combined: Нишки + Микроблог\nsidebar_sections_random_local_only: Ограничаване на секциите в страничната лента\n  „Случайни нишки/публикации“ само до местни\nsidebar_sections_users_local_only: Ограничаване на секцията в страничната лента \n  „Активни хора“ само до местни\nrandom_local_only_performance_warning: Включването на „Случайни (само местни)“ \n  може да повлияе на производителността на SQL.\n"
  },
  {
    "path": "translations/messages.ca.yaml",
    "content": "type.link: Enllaç\ntype.article: Fil\ntype.photo: Foto\ntype.video: Vídeo\ntype.smart_contract: Contracte intel·ligent\ntype.magazine: Revista\nthread: Fil\nthreads: Fils\nmicroblog: Microblog\npeople: Gent\nevents: Esdeveniments\nmagazine: Revista\nmagazines: Revistes\nsearch: Cercar\nadd: Afegir\nselect_channel: Trieu un canal\nsort_by: Ordenar per\nhot: Popular\nnewest: Més nou\noldest: Més vell\ncommented: Comentat\nchange_view: Canviar vista\nfilter_by_time: Filtrar per temps\nfilter_by_type: Filtrar per tipus\nfilter_by_subscription: Filtrar per subscripció\nfilter_by_federation: Filtrar per estat de federació\ncomments_count: '{0}Comentaris|{1}Comentari|]1,Inf[ Comentaris'\nsubscribers_count: '{0}Subscriptores|{1}Subscriptora|]1,Inf[ Subscriptores'\nfollowers_count: '{0}Seguidores|{1}Seguidora|]1,Inf[ Seguidores'\nmarked_for_deletion: Marcat per a supressió\nmarked_for_deletion_at: Marcat per suprimir-se el %date%\nfavourites: Vots a favor\nfavourite: Preferit\navatar: Avatar\nadded: Afegit\ndown_votes: Vots en contra\nup_votes: Impulsos\nno_comments: Sense comentaris\ncreated_at: Creat\nowner: Propietari(a)\ntop: Destacat\nactive: Actiu(va)\nmore: Més\nlogin: Iniciar sessió\nsubscribers: Subscritors(es)\nonline: En línia\ncomments: Comentaris\nposts: Publicacions\nreplies: Respostes\nmoderators: Moderació\nadd_comment: Afegir comentari\nadd_post: Afegir publicació\nadd_media: Afegir mitjà\nremove_user_avatar: Eliminar avatar\nmod_log: Registre de moderació\nremove_media: Eliminar mitjà\ndisconnected_magazine_info: 'Aquesta revista no està rebent actualitzacions: darrera\n  activitat fa %days% dia(es).'\nactivity: Activitat\nmarkdown_howto: Com funciona l'editor?\nenter_your_comment: Escriviu el comentari\nenter_your_post: Escriviu la publicació\ncover: Portada\nremove_user_cover: Eliminar portada\nrelated_posts: Publicacions relacionades\nrandom_posts: Publicacions aleatòries\nfederated_magazine_info: Aquesta revista és d'un servidor federat i pot estar \n  incompleta.\nalways_disconnected_magazine_info: Aquesta revista no està rebent \n  actualitzacions.\nfederated_user_info: Aquest perfil és d'un servidor federat i pot ser incomplet.\nsubscribe_for_updates: Subscriviu-vos per començar a rebre actualitzacions.\ncontact: Contacte\nalready_have_account: Ja teniu un compte?\nfrom: des de\nlinks: Enllaços\nyou_cant_login: Heu oblidat la contrasenya?\nenabled: Habilitat\nfollowers: Seguidor(e)s\nreset_check_email_desc: Si ja hi ha un compte associat a la vostra adreça \n  electrònica, rebreu un correu electrònic en breu amb un enllaç que podeu \n  utilitzar per restablir la vostra contrasenya. Aquest enllaç caducarà en \n  %expire%.\nemail_confirm_expire: Tingueu en compte que l'enllaç caducarà d'aquí a una hora.\nmoderated: Moderat(da)\ntag: Etiqueta\neng: ENG\ncolumns: Columnes\nchat_view: Vista de xat\nphotos: Fotos\nare_you_sure: Ho confirmeu?\ngo_to_search: Anar a la cerca\nmenu: Menú\nblocked: Blocats\ndomain: Domini\nsubscriptions: Subscripcions\noverview: Vista general\npeople_local: Local\npeople_federated: Federat\nsubscribed: Subscrit(a)\nreason: Motiu\nhomepage: Pàgina d'inici\ncopy_url_to_fediverse: Copiar URL original\nedit_entry: Editar fil\ndelete: Eliminar\nedit_post: Editar publicació\nshow_profile_followings: Mostrar els comptes seguits\nappearance: Aparença\n6h: 6h\n3h: 3h\ngo_to_filters: Anar a filtres\ntree_view: Vista d'arbre\n12h: 12h\narticles: Fils\nreport: Denunciar\nall: Tot\n1d: 1 dia\nshow_profile_subscriptions: Mostrar subscripcions a revistes\nnotify_on_new_entry: Fils nous (enllaços o articles) a qualsevol revista a què \n  estic subscrit\ntable_view: Vista de taula\ngeneral: General\nprofile: Perfil\ncards_view: Vista de targetes\nemail_verify: Confirmeu l'adreça electrònica\nfollowing: Seguint\nrss: RSS\n1y: 1 any\nvideos: Vídeos\nshare_on_fediverse: Compartir al Fedivers\nedit_comment: Desar canvis\nmessages: Missatges\nedit: Editar\nmoderate: Moderar\n1m: 1 mes\nnotify_on_new_post_reply: Qualsevol nivell de respostes a les publicacions que \n  he escrit\ncopy_url: Copiar URL de Mbin\nsettings: Configuració\nnotify_on_new_posts: Noves publicacions a qualsevol revista a què estic subscrit\nreports: Denúncies\nnotifications: Notificacions\nagree_terms: Accepteu els %terms_link_start%Termes i condicions%terms_link_end% \n  i la %policy_link_start%Política de privadesa%policy_link_end%\ngo_to_original_instance: Veure en instància remota\nempty: Buit\nsubscribe: Subscriure's\nunsubscribe: Cancel·lar subscripció\nfollow: Seguir\nunfollow: Deixar de seguir\nreply: Resposta\nlogin_or_email: Identificador o adreça electrònica\npassword: Contrasenya\ndont_have_account: No teniu un compte?\nremember_me: Recordar-me\nregister: Crear compte\nreset_password: Restablir contrasenya\nshow_more: Mostrar més\nto: a\nin: en\nusername: Identificador\nemail: Adreça electrònica\nrepeat_password: Repetiu la contrasenya\nterms: Condicions del servei\nprivacy_policy: Política de privadesa\nabout_instance: Quant a\nall_magazines: Totes les revistes\nstats: Estadístiques\nfediverse: Fedivers\ncreate_new_magazine: Crear revista nova\nadd_new_article: Afegir fil nou\nadd_new_link: Afegir enllaç nou\nadd_new_photo: Afegir foto nova\nadd_new_post: Afegir publicació nova\nadd_new_video: Afegir vídeo nou\nchange_theme: Canviar tema\ndownvotes_mode: Mode de vots negatius\nchange_downvotes_mode: Canviar el mode de vots en contra\ndisabled: Deshabilitat\nhidden: Amagat\nfaq: Preguntes més freqüents (PMF)\nuseful: Útil\nhelp: Ajuda\ncheck_email: Comproveu la vostra bústia electrònica\nreset_check_email_desc2: Si no rebeu cap correu electrònic, comproveu la vostra \n  carpeta de correu brossa.\ntry_again: Torneu-ho a provar\nup_vote: Impulsar\ndown_vote: Votar en contra\nemail_confirm_header: Hola! Confirmeu la vostra adreça electrònica.\nemail_confirm_content: \"Per activar el compte de Mbin feu clic a l'enllaç següent:\"\nemail_confirm_title: Confirmeu la vostra adreça electrònica.\nselect_magazine: Trieu una revista\nadd_new: Afegir nou\nurl: URL\ntitle: Títol\nbody: Cos\ntags: Etiquetes\nbadges: Insígnies\nis_adult: +18 / Explícit\noc: Cont. Orig.\nimage: Imatge\nimage_alt: Text alternatiu de la imatge\nname: Nom\ndescription: Descripció\nrules: Normes\ncards: Targetes\nuser: Usuari(a)\njoined: Inscrit(a)\nreputation_points: Punts de reputació\nrelated_tags: Etiquetes relacionades\ngo_to_content: Anar al contingut\nlogout: Tancar sessió\nclassic_view: Vista clàssica\ncompact_view: Vista compacta\n1w: 1 setm.\nshare: Compartir\nhide_adult: Amagar contingut explícit\nfeatured_magazines: Revistes destacades\nprivacy: Privadesa\nnotify_on_new_entry_reply: Qualsevol nivell de comentaris als fils que he escrit\nnotify_on_new_entry_comment_reply: Respostes als meus comentaris en qualsevol \n  fil\nnotify_on_new_post_comment_reply: Respostes als meus comentaris a qualsevol \n  publicació\nnotify_on_user_signup: Nous registres\nsave: Desar\nabout: Quant a\nold_email: Adreça electrònica actual\nnew_email: Nova adreça electrònica\nnew_email_repeat: Confirmar l'adreça electrònica nova\ncurrent_password: Contrasenya actual\nnew_password: Contrasenya nova\nnew_password_repeat: Confirmar la nova contrasenya\nchange_email: Canviar l'adreça electrònica\nchange_password: Canviar la contrasenya\nexpand: Desplegar\ndomains: Dominis\nvotes: Vots\ntheme: Tema\ndark: Fosc\nlight: Clar\nsolarized_light: Clar solaritzat\nsolarized_dark: Fosc solaritzat\ndefault_theme: Tema predeterminat\ndefault_theme_auto: Clar/fosc (detecció automàtica)\nsolarized_auto: Solaritzat (detecció automàtica)\nfont_size: Mida de la lletra\nboosts: Impulsos\nshow_users_avatars: Mostrar avatars d'usuaris(es)\nshow_thumbnails: Mostrar miniatures\nshow_magazines_icons: Mostrar icones de les revistes\nrounded_edges: Vores arrodonides\nremoved_thread_by: ha eliminat un fil de\nrestored_thread_by: ha restaurat un fil de\nrestored_comment_by: ha restaurat el comentari de\nremoved_post_by: ha eliminat una publicació de\nrestored_post_by: ha restaurat una publicació de\nhe_banned: bandejat(da)\nhe_unbanned: desbandejat(da)\nread_all: Marcar-ho tot com a llegit\nshow_all: Mostrar-ho tot\nflash_thread_edit_success: El fil s'ha editat correctament.\nflash_magazine_edit_success: La revista ha estat editada amb èxit.\nflash_unmark_as_adult_success: La publicació s'ha desmarcat correctament com a \n  explícita.\nflash_mark_as_adult_success: La publicació s'ha marcat correctament com a \n  explícita.\ntoo_many_requests: S'ha superat el límit; torneu-ho a provar més tard.\nset_magazines_bar: Barra de revistes\nset_magazines_bar_desc: afegiu els noms de les revistes després de la coma\nset_magazines_bar_empty_desc: si el camp està buit, les revistes actives es \n  mostren a la barra.\nadded_new_thread: S'ha afegit un fil nou\nedited_thread: Ha editat un fil\nmod_log_alert: 'ADVERTÈNCIA: En el registre de moderació podreu trobar contingut desagradable\n  o ofensiu eliminat per la moderació. Assegureu-vos de saber el que esteu fent.'\nreplied_to_your_comment: Ha respost al vostre comentari\nadded_new_post: Ha afegit una publicació nova\nedited_post: Ha editat una publicació\nedited_comment: Ha editat un comentari\nadded_new_reply: Ha afegit una nova resposta\nmod_deleted_your_comment: La moderació ha suprimit el vostre comentari\nmod_remove_your_post: La moderació ha eliminat la vostra publicació\nno: No\nerror: Error\ncollapse: Plegar\nflash_register_success: Benvinguda a bord! El vostre compte ja està registrat. \n  Un últim pas - consulteu la vostra safata d'entrada per a rebre un enllaç \n  d'activació que donarà vida al vostre compte.\nflash_thread_new_success: El fil s'ha creat correctament i ara és visible per a \n  altres usuaris(es).\nflash_thread_unpin_success: El fil s'ha desfixat correctament.\nyes: Sí\nsize: Mida\nremoved_comment_by: ha eliminat un comentari de\nflash_thread_delete_success: El fil s'ha suprimit correctament.\nflash_thread_pin_success: El fil s'ha fixat correctament.\nflash_magazine_new_success: La revista ha estat creada amb èxit. Ara podeu \n  afegir contingut nou o explorar el tauler d'administració de la revista.\nmod_remove_your_thread: La moderació ha eliminat el vostre fil\nadded_new_comment: Ha afegit un comentari nou\nwrote_message: Ha escrit un missatge\nbanned: Us ha bandejat\nremoved: Eliminat per la moderació\ndeleted: Esborrat per l'autor\nmentioned_you: Us ha esmentat\ncomment: Comentari\npost: Publicació\nban_expired: El bandeig ha expirat\npurge: Buidar la llista\nsend_message: Enviar missatge directe\nsticky_navbar: Barra de navegació fixa\nsubject_reported: S'ha denunciat el contingut.\nsidebar_position: Posició de la barra lateral\nleft: Esquerra\nright: Dreta\nfederation: Federació\nstatus: Estat\non: Encès\noff: Apagat\ninstances: Instàncies\nfrom_url: Des de l'URL\nmagazine_panel: Panell de la revista\nreject: Rebutjar\napprove: Aprovar\nban: Bandejar\nunban: Desbandejar\nunban_hashtag_btn: Desbandejar hashtag\nban_hashtag_btn: Bandejar hashtag\nfilters: Filtres\napproved: Aprovat\nrejected: Rebutjat\nadd_moderator: Afegir moderador(a)\nadd_badge: Afegir insígnia\nbans: Bandejos\ncreated: Creat\nexpires: Caduca\nperm: Permanent\nexpired_at: Caducà el\nadd_ban: Afegir bandeig\ntrash: Paperera\nicon: Icona\ndone: Fet\npin: Fixar\nunpin: Desfixar\nchange_language: Canviar idioma\nmark_as_adult: Marcar com a explícit\nunmark_as_adult: Desmarcar com a explícit\nchange: Canviar\npinned: Fixat\npreview: Previsualitzar\narticle: Fil\nreputation: Reputació\nnote: Nota\nwriting: Escriptura\nusers: Usuaris(es)\ncontent: Contingut\nweek: Setmana\nweeks: Setmanes\nmonth: Mes\nmonths: Mesos\nfederated: Federat\nlocal: Local\nadmin_panel: Tauler d'administració\ndashboard: Tauler de control\ncontact_email: Adreça electrònica de contacte\ninstance: Instància\npages: Pàgines\nFAQ: Preguntes més freqüents (PMF)\ntype_search_term: Escriviu el terme de cerca\nregistration_disabled: Registre desactivat\nrestore: Restaurar\nadd_mentions_entries: Afegir etiquetes de menció als fils\nadd_mentions_posts: Afegir etiquetes de menció a les publicacions\nPassword is invalid: La contrasenya no és vàlida.\nYour account is not active: El vostre compte no està actiu.\nYour account has been banned: El vostre compte ha estat bandejat.\nfirstname: Nom\nsend: Enviar\nactive_users: Persones actives\nrelated_entries: Fils relacionats\npurge_account: Purgar el compte\nban_account: Bandejar el compte\nunban_account: Desbandejar el compte\nrelated_magazines: Revistes relacionades\nrandom_magazines: Revistes aleatòries\nsidebar: Barra lateral\nauto_preview: Vista prèvia automàtica dels mitjans\ndynamic_lists: Llistes dinàmiques\nbanned_instances: Instàncies bandejades\nkbin_intro_title: Explorar el fedivers\nkbin_promo_title: Creeu la vostra pròpia instància\nkbin_promo_desc: '%link_start%Cloneu el repositori%link_end% i desenvolupeu fedivers'\ncaptcha_enabled: Captcha activat\nheader_logo: Logotip de la capçalera\nbrowsing_one_thread: Només esteu navegant per un fil de la discussió! Tots els \n  comentaris estan disponibles a la pàgina de publicació.\nviewing_one_signup_request: Només esteu veient una sol·licitud de registre de \n  %username%\nreturn: Tornar\nboost: Impulsar\nmercure_enabled: Mercure activat\nreport_issue: Denunciar problema\ntokyo_night: Nit de Tòquio\npreferred_languages: Filtrar els idiomes de fils i publicacions\ninfinite_scroll_help: Carregar automàticament més contingut en arribar a la part\n  inferior de la pàgina.\nauto_preview_help: Mostrar les previsualitzacions multimèdia (foto, vídeo) en \n  una mida més gran a sota del contingut.\nreload_to_apply: Torneu a carregar la pàgina per aplicar els canvis\nfilter.origin.label: Trieu l'origen\nfilter.fields.label: Trieu quins camps voleu cercar\nfilter.adult.label: Trieu si voleu mostrar contingut explícit\nfilter.adult.hide: Amagar contingut explícit\nfilter.adult.only: Només el contingut explícit\nlocal_and_federated: Local i federat\nfilter.fields.only_names: Només noms\nfilter.fields.names_and_descriptions: Noms i descripcions\nkbin_bot: Agent Mbin\npassword_confirm_header: Confirmeu la vostra sol·licitud de canvi de \n  contrasenya.\nyour_account_is_not_active: El vostre compte no s'ha activat. Comproveu la \n  vostra bústia electrònica per obtenir instruccions d'activació del compte o <a\n  href=\"%link_target%\">sol·liciteu un correu electrònic d'activació del compte \n  nou.</a>\nyour_account_has_been_banned: El vostre compte ha estat bandejat\nyour_account_is_not_yet_approved: El vostre compte encara no s'ha aprovat. \n  Enviarem un correu electrònic tan bon punt l'administració hagi processat la \n  vostra sol·licitud de registre.\ntoolbar.bold: Negreta\ntoolbar.italic: Itàlica\ntoolbar.strikethrough: Ratllat\ntoolbar.header: Capçalera\ntoolbar.quote: Cita\ntoolbar.code: Codi\ntoolbar.link: Enllaç\ntoolbar.image: Imatge\ntoolbar.ordered_list: Llista ordenada\ntoolbar.mention: Esment\ntoolbar.spoiler: Spoiler\nyear: Any\nupload_file: Pujar arxiu\nmagazine_panel_tags_info: Indiqueu-ho només si voleu que el contingut del \n  fedivers s'inclogui en aquesta revista segons les etiquetes\nregistrations_enabled: Registre activat\nmessage: Missatge\ntoolbar.unordered_list: Llista no ordenada\ninfinite_scroll: Desplaçament infinit\nfilter.adult.show: Mostrar el contingut explícit\nshow_top_bar: Mostrar barra superior\nban_hashtag_description: Bandejar un hashtag impedirà que es creïn publicacions \n  amb aquest hashtag, a més d'amagar les publicacions existents amb aquest \n  hashtag.\nunban_hashtag_description: Desbandejar un hashtag permetrà tornar a crear \n  publicacions amb aquest hashtag. Les publicacions existents amb aquest hashtag\n  ja no s'amaguen.\nchange_magazine: Canviar revista\nfederation_enabled: Federació activada\nsticky_navbar_help: La barra de navegació es fixarà a la part superior de la \n  pàgina quan us desplaceu cap avall.\nmeta: Meta\nrandom_entries: Fils aleatoris\nkbin_intro_desc: és una plataforma descentralitzada per a l'agregació de \n  continguts i microblogging que opera dins de la xarxa fedivers.\nbot_body_content: \"Benvinguda a l'agent Mbin! Aquest agent té un paper crucial per\n  habilitar la funcionalitat d'ActivityPub dins de Mbin. Assegura que Mbin es pugui\n  comunicar i federar amb altres instàncies del fedivers.\\n\\nActivityPub és un protocol\n  estàndard obert que permet que les plataformes de xarxes socials descentralitzades\n  es comuniquin i interactuïn entre elles. Permet a usuari(e)s de diferents instàncies\n  (servidors) seguir, interactuar i compartir contingut a través de la xarxa social\n  federada coneguda com a fedivers. Proporciona una manera estandarditzada per publicar\n  contingut, seguir altres usuaris(es) i participar en interaccions socials, com ara\n  fer m'agrada, compartir i comentar fils o publicacions.\"\ndelete_account: Suprimir el compte\nfederation_page_enabled: Pàgina de federació activada\nfederation_page_allowed_description: Instàncies conegudes amb què ens federem\nfederation_page_disallowed_description: Instàncies amb què no ens federem\nfederation_page_dead_title: Instàncies mortes\nfederated_search_only_loggedin: Cerca federada limitada si no s'ha iniciat \n  sessió\naccount_deletion_title: Supressió del compte\naccount_deletion_button: Suprimir el compte\naccount_deletion_immediate: Suprimir immediatament\nerrors.server500.title: 500 Error intern del servidor\nerrors.server429.title: 429 Massa sol·licituds\nerrors.server404.title: 404 No trobat\nemail.delete.description: L'usuari(a) següent ha sol·licitat que s'elimini el \n  seu compte\nresend_account_activation_email_question: Compte inactiu?\nresend_account_activation_email_success: Si existeix un compte associat amb \n  l'adreça electrònica, hi enviarem un nou correu d'activació.\nresend_account_activation_email_description: Introduïu l'adreça electrònica \n  associada al vostre compte. Us hi enviarem un altre correu d'activació.\ncustom_css: CSS personalitzat\nignore_magazines_custom_css: Ignorar el CSS personalitzat de les revistes\noauth.consent.title: Formulari de consentiment OAuth2\noauth.consent.grant_permissions: Concedir permisos\noauth.consent.app_requesting_permissions: voldria realitzar les accions següents\n  en nom vostre\noauth.consent.app_has_permissions: ja pot realitzar les accions següents\noauth.consent.allow: Permetre\noauth.consent.deny: Denegar\noauth.client_identifier.invalid: Identificador de client OAuth no vàlid!\noauth.client_not_granted_message_read_permission: Aquesta aplicació no ha rebut \n  permís per llegir els vostres missatges.\nrestrict_oauth_clients: Restringir la creació de clients OAuth2 a \n  l'administració\nblock: Blocar\nunblock: Desblocar\noauth2.grant.moderate.magazine.list: Mostrar la llista de les revistes que \n  modereu.\noauth2.grant.moderate.magazine.reports.read: Mostrar les denúncies a les \n  revistes que modereu.\noauth2.grant.moderate.magazine.reports.action: Acceptar o rebutjar denúncies a \n  les revistes que modereu.\noauth2.grant.moderate.magazine.trash.read: Veure el contingut a la paperera de \n  les revistes que modereu.\noauth2.grant.moderate.magazine_admin.create: Crear noves revistes.\noauth2.grant.moderate.magazine_admin.delete: Suprimir qualsevol de les vostres \n  revistes.\noauth2.grant.moderate.magazine_admin.update: Editar les regles, la descripció, \n  el mode explícit o la icona de les vostres revistes.\noauth2.grant.moderate.magazine_admin.moderators: Afegir o eliminar moderador(e)s\n  de qualsevol de les vostres revistes.\noauth2.grant.moderate.magazine_admin.badges: Crear o eliminar insígnies de les \n  vostres revistes.\noauth2.grant.moderate.magazine_admin.tags: Crear o eliminar etiquetes de les \n  vostres revistes.\noauth2.grant.moderate.magazine_admin.stats: Veure el contingut, votar i \n  consultar les estadístiques de les vostres revistes.\noauth2.grant.admin.all: Realitzar qualsevol acció administrativa sobre la vostra\n  instància.\noauth2.grant.read.general: Llegir tot el contingut a què tingueu accés.\noauth2.grant.write.general: Crear o editar qualsevol dels vostres fils, \n  publicacions o comentaris.\noauth2.grant.delete.general: Suprimir qualsevol dels vostres fils, publicacions \n  o comentaris.\noauth2.grant.report.general: Denunciar fils, publicacions o comentaris.\noauth2.grant.block.general: Blocar o desblocar qualsevol revista, domini o \n  compte i veure les revistes, dominis i comptes que heu blocat.\noauth2.grant.domain.subscribe: Subscriure-vos o cancel·lar la subscripció als \n  dominis i veure els dominis a què us subscriviu.\noauth2.grant.domain.block: Blocar o desblocar dominis i veure els dominis que \n  heu blocat.\noauth2.grant.entry.all: Crear, editar o suprimir els vostres fils i votar, \n  impulsar o denunciar qualsevol fil.\noauth2.grant.entry.create: Crear fils nous.\noauth2.grant.entry.edit: Editar els vostres fils existents.\noauth2.grant.entry.delete: Suprimir els vostres fils existents.\noauth2.grant.entry.vote: Votar a favor, impulsar o votar en contra de qualsevol \n  fil.\noauth2.grant.entry.report: Denunciar qualsevol fil.\noauth2.grant.entry_comment.create: Crear comentaris nous en fils.\noauth2.grant.entry_comment.edit: Editar els vostres comentaris existents als \n  fils.\noauth2.grant.entry_comment.delete: Suprimir els vostres comentaris existents als\n  fils.\noauth2.grant.entry_comment.report: Denunciar qualsevol comentari en un fil.\noauth2.grant.magazine.subscribe: Subscriure-vos o cancel·lar la subscripció a \n  revistes i veure les revistes a què us subscriviu.\noauth2.grant.magazine.block: Blocar o desblocar revistes i veure les revistes \n  que heu blocat.\noauth2.grant.post.all: Crear, editar o suprimir els vostres microblogs i votar, \n  impulsar o denunciar qualsevol microblog.\noauth2.grant.post.edit: Editar les vostres publicacions existents.\noauth2.grant.post.delete: Suprimir les vostres publicacions existents.\noauth2.grant.post.vote: Votar a favor, impulsar o votar en contra de qualsevol \n  publicació.\noauth2.grant.post_comment.all: Crear, editar o suprimir els vostres comentaris a\n  les publicacions i votar, impulsar o denunciar qualsevol comentari en una \n  publicació.\noauth2.grant.post_comment.edit: Editar els vostres comentaris existents a les \n  publicacions.\noauth2.grant.post_comment.delete: Suprimir els vostres comentaris existents a \n  les publicacions.\noauth2.grant.post_comment.report: Denunciar qualsevol comentari en una \n  publicació.\noauth2.grant.user.bookmark: Afegir i eliminar marcadors\noauth2.grant.user.bookmark.add: Afegir marcadors\noauth2.grant.user.bookmark.remove: Eliminar marcadors\noauth2.grant.user.bookmark_list: Veure, editar i suprimir les vostres llistes de\n  marcadors\noauth2.grant.user.bookmark_list.read: Veure les vostres llistes de marcadors\noauth2.grant.user.bookmark_list.edit: Editar les vostres llistes de marcadors\noauth2.grant.user.bookmark_list.delete: Esborrar les vostres llistes de \n  marcadors\noauth2.grant.user.profile.all: Llegir i editar el vostre perfil.\noauth2.grant.user.message.all: Llegir els vostres missatges i enviar missatges a\n  altres usuaris(es).\noauth2.grant.user.message.read: Llegir els vostres missatges.\noauth2.grant.user.message.create: Enviar missatges a altres usuaris(es).\noauth2.grant.user.notification.read: Llegir les vostres notificacions, incloses \n  les notificacions de missatges.\noauth2.grant.user.oauth_clients.read: Veure els permisos que heu concedit a \n  altres aplicacions OAuth2.\noauth2.grant.user.oauth_clients.edit: Editar els permisos que heu concedit a \n  altres aplicacions OAuth2.\noauth2.grant.user.block: Blocar o desblocar comptes i veure una llista de \n  comptes que bloqueu.\noauth2.grant.moderate.all: Realitzar qualsevol acció de moderació que tingueu \n  permís per dur a terme a les revistes que modereu.\noauth2.grant.moderate.entry.pin: Fixar els fils a la part superior de les \n  revistes que modereu.\noauth2.grant.moderate.entry.set_adult: Marcar els fils com a explícits a les \n  revistes que modereu.\noauth2.grant.moderate.entry.trash: Ficar a la paperera o restaurar fils a les \n  revistes que modereu.\noauth2.grant.moderate.entry_comment.all: Moderar els comentaris als fils de les \n  revistes que modereu.\noauth2.grant.moderate.entry_comment.change_language: Canviar l'idioma dels \n  comentaris als fils de les revistes que modereu.\noauth2.grant.moderate.entry_comment.set_adult: Marcar els comentaris als fils \n  com a explícits a les revistes que modereu.\noauth2.grant.moderate.entry_comment.trash: Ficar a la paperera o restaurar els \n  comentaris dels fils de les revistes que modereu.\noauth2.grant.moderate.post.change_language: Canviar l'idioma de les publicacions\n  a les revistes que modereu.\noauth2.grant.moderate.post.trash: Ficar a la paperera o restaurar les \n  publicacions de les revistes que modereu.\noauth2.grant.moderate.post_comment.all: Moderar els comentaris a les \n  publicacions de les revistes que modereu.\noauth2.grant.moderate.post_comment.set_adult: Marcar com a explícits els \n  comentaris de les revistes que modereu.\noauth2.grant.moderate.post_comment.trash: Ficar a la paperera o restaurar els \n  comentaris a les publicacions de les revistes que modereu.\noauth2.grant.moderate.magazine.all: Gestionar els bandejos, les denúncies i \n  veure els articles a la paperera a les revistes que modereu.\noauth2.grant.moderate.magazine.ban.read: Veure els comptes bandejats a les \n  revistes que modereu.\noauth2.grant.moderate.magazine.ban.create: Bandejar comptes a les revistes que \n  modereu.\noauth2.grant.admin.entry_comment.purge: Suprimir completament qualsevol \n  comentari d'un fil de la vostra instància.\noauth2.grant.admin.post_comment.purge: Suprimir completament qualsevol comentari\n  d'una publicació de la vostra instància.\noauth2.grant.admin.magazine.move_entry: Moure fils entre revistes a la vostra \n  instància.\noauth2.grant.admin.magazine.purge: Suprimir completament les revistes de la \n  vostra instància.\noauth2.grant.admin.user.verify: Verificar usuaris(es) a la vostra instància.\noauth2.grant.admin.user.delete: Eliminar comptes de la vostra instància.\noauth2.grant.admin.user.purge: Eliminar completament comptes de la vostra \n  instància.\noauth2.grant.admin.instance.all: Veure i actualitzar la configuració o la \n  informació de la instància.\noauth2.grant.admin.instance.stats: Veure les estadístiques de la vostra \n  instància.\noauth2.grant.admin.instance.settings.read: Veure la configuració de la vostra \n  instància.\noauth2.grant.admin.instance.settings.edit: Actualitzar la configuració de la \n  vostra instància.\noauth2.grant.admin.federation.all: Veure i actualitzar les instàncies \n  desfederades actualment.\noauth2.grant.admin.federation.read: Veure la llista d'instàncies desfederades.\noauth2.grant.admin.federation.update: Afegir o eliminar instàncies de la llista \n  d'instàncies desfederades.\noauth2.grant.admin.oauth_clients.all: Veure o revocar els clients OAuth2 que \n  existeixen a la vostra instància.\noauth2.grant.admin.oauth_clients.read: Veure els clients OAuth2 que existeixen a\n  la vostra instància i les seves estadístiques d'ús.\noauth2.grant.admin.oauth_clients.revoke: Revocar l'accés als clients OAuth2 a la\n  vostra instància.\nlast_active: Última activitat\nflash_post_pin_success: La publicació s'ha fixat correctament.\nflash_post_unpin_success: La publicació s'ha desfixat correctament.\nshow_avatars_on_comments: Mostrar avatars als comentaris\nsingle_settings: Únic\nupdate_comment: Actualitzar comentari\nshow_avatars_on_comments_help: Mostrar/amagar els avatars quan es veuen \n  comentaris en un sol fil o publicació.\ncomment_reply_position: Posició del comentari de resposta\nmagazine_theme_appearance_icon: Icona personalitzada per a la revista.\nmagazine_theme_appearance_background_image: Imatge de fons personalitzada que \n  s'aplicarà quan visualitzeu contingut a la vostra revista.\nmoderation.report.approve_report_title: Aprovar la denúncia\nmoderation.report.reject_report_title: Rebutjar la denúncia\nsubject_reported_exists: Aquest contingut ja s'ha denunciat.\nmoderation.report.ban_user_title: Bandejar compte\nmoderation.report.reject_report_confirmation: Confirmeu que voleu rebutjar \n  aquesta denúncia?\ndelete_content: Suprimir contingut\npurge_content: Purgar contingut\ndelete_content_desc: Suprimir el contingut de l'usuari(a) deixant les respostes \n  d'altres usuaris(es) als fils, publicacions i comentaris creats.\nschedule_delete_account: Programar eliminació\nschedule_delete_account_desc: Programar la supressió d'aquest compte en 30 dies.\n  Això amagarà l'usuari(a) i el seu contingut, i també impedirà que iniciï \n  sessió.\nremove_schedule_delete_account: Cancel·lar la supressió programada\nremove_schedule_delete_account_desc: Cancel·lar la programació de l'eliminació. \n  Tot el contingut tornarà a estar disponible i el compte podrà iniciar sessió.\ntwo_factor_backup: Codis de recolzament d'autenticació de dos factors\n2fa.authentication_code.label: Codi d'autenticació\n2fa.verify: Verificar\n2fa.code_invalid: El codi d'autenticació no és vàlid\n2fa.enable: Configurar l'autenticació de dos factors\n2fa.backup-create.label: Crear codis d'autenticació de recolzament nous\n2fa.remove: Eliminar l'autenticació de dos factors\n2fa.add: Afegir al meu compte\n2fa.verify_authentication_code.label: Introduïu un codi de dos factors per \n  verificar la configuració\n2fa.qr_code_img.alt: Un codi QR que permet configurar l'autenticació de dos \n  factors per al vostre compte\n2fa.qr_code_link.title: En visitar aquest enllaç permetreu a la vostra \n  plataforma registrar aquesta autenticació de dos factors\n2fa.user_active_tfa.title: L'usuari(a) té actiu el doble factor d'autenticació\ncancel: Cancel·lar\n2fa.backup_codes.recommendation: Guardeu-ne una còpia en un lloc segur.\npassword_and_2fa: Contrasenya i A2F\nshow_subscriptions: Mostrar subscripcions\nsubscription_sort: Ordenar\nalphabetically: Alfabèticament\nsubscriptions_in_own_sidebar: A una barra lateral separada\nsidebars_same_side: Barres laterals al mateix costat\nsubscription_sidebar_pop_out_right: Moure a la barra lateral separada de la \n  dreta\nsubscription_sidebar_pop_out_left: Moure a la barra lateral separada de \n  l'esquerra\nsubscription_panel_large: Panell gran\nclose: Tancar\nposition_bottom: Inferior\nposition_top: Superior\npending: Pendent\nflash_thread_new_error: No s'ha pogut crear el fil. Alguna cosa ha fallat.\nflash_thread_tag_banned_error: No s'ha pogut crear el fil. El contingut no està \n  permès.\nflash_image_download_too_large_error: La imatge no s'ha pogut crear, és massa \n  gran (mida màxima %bytes%)\nflash_email_was_sent: El correu electrònic s'ha enviat correctament.\nflash_email_failed_to_sent: No s'ha pogut enviar el correu electrònic.\nflash_post_new_success: La publicació s'ha creat correctament.\nflash_magazine_theme_changed_success: S'ha actualitzat correctament l'aparença \n  de la revista.\nflash_magazine_theme_changed_error: No s'ha pogut actualitzar l'aparença de la \n  revista.\nflash_comment_edit_success: El comentari s'ha actualitzat correctament.\nflash_comment_edit_error: No s'ha pogut editar el comentari. Alguna cosa ha \n  fallat.\nflash_user_settings_general_error: No s'ha pogut desar la configuració \n  d'usuari(a).\nflash_user_edit_profile_error: No s'ha pogut desar la configuració del perfil.\nflash_user_edit_password_error: No s'ha pogut canviar la contrasenya.\nflash_thread_edit_error: No s'ha pogut editar el fil. Alguna cosa ha fallat.\nflash_post_edit_error: No s'ha pogut editar la publicació. Alguna cosa ha \n  fallat.\nflash_post_edit_success: La publicació s'ha editat correctament.\npage_width: Amplada de pàgina\npage_width_max: Màxim\npage_width_auto: Automàtic\npage_width_fixed: Fix\nfilter_labels: Filtrar etiquetes\nauto: Automàtic\nchange_my_avatar: Canviar el meu avatar\nchange_my_cover: Canviar la meva portada\nedit_my_profile: Editar el meu perfil\naccount_settings_changed: La configuració del vostre compte s'ha canviat \n  correctament. Haureu de tornar a iniciar sessió.\nmagazine_deletion: Eliminació de la revista\ndelete_magazine: Suprimir revista\nrestore_magazine: Recuperar revista\npurge_magazine: Purgar revista\nmagazine_is_deleted: S'ha suprimit la revista. Podeu <a \n  href=\"%link_target%\">recuperar-la</a> en un termini de 30 dies.\nuser_suspend_desc: En suspendre el compte s'amaga el contingut a la instància, \n  però no l'elimina permanentment i el podeu restaurar en qualsevol moment.\naccount_banned: El compte ha estat bandejat.\nremove_subscriptions: Eliminar subscripcions\napply_for_moderator: Sol·licitar ser moderador(a)\nrequest_magazine_ownership: Demanar la propietat de la revista\ncancel_request: Cancel·lar sol·licitud\nabandoned: Abandonat\nownership_requests: Sol·licituds de propietat\naccept: Acceptar\nmoderator_requests: Sol·licituds de moderació\naction: Acció\nuser_badge_op: OP\nuser_badge_admin: Administrador(a)\nuser_badge_global_moderator: Moderador(a) global\nuser_badge_moderator: Moderador(a)\nuser_badge_bot: Bot\nannouncement: Anunci\nkeywords: Paraules clau\ndeleted_by_author: L'autor(a) ha eliminat el fil, la publicació o el comentari\nsensitive_warning: Contingut sensible\nsensitive_toggle: Commutar la visibilitat del contingut sensible\nsensitive_show: Feu clic per mostrar\nsensitive_hide: Feu clic per amagar\ndetails: Detalls\nspoiler: Spoiler\nall_time: Tot el temps\nshow: Mostrar\nhide: Amagar\nedited: editat\nsso_registrations_enabled: Registres SSO activats\ncontinue_with: Continuar amb\nown_report_accepted: La vostra denúncia ha estat acceptada\nreport_accepted: S'ha acceptat una denúncia\nmagazine_log_mod_added: ha afegit un(a) moderador(a)\nmagazine_log_mod_removed: ha llevat un(a) moderador(a)\nmagazine_log_entry_pinned: entrada fixada\nmagazine_log_entry_unpinned: s'ha eliminat l'entrada fixada\nlast_updated: Última actualització\nand: i\ndirect_message: Missatge directe\nmanually_approves_followers: Aprova seguidors(es) manualment\nregister_push_notifications_button: Registreu-vos per a les notificacions «push»\nunregister_push_notifications_button: Eliminar el registre de «push»\ntest_push_notifications_button: Provar les notificacions «push»\ntest_push_message: Hola món!\nnotification_title_new_comment: Nou comentari\nnotification_title_removed_comment: S'ha eliminat un comentari\nnotification_title_edited_comment: S'ha editat un comentari\nnotification_title_new_reply: Nova resposta\nnotification_title_new_thread: Nou fil\nnotification_title_removed_thread: S'ha eliminat un fil\nnotification_title_edited_thread: S'ha editat un fil\nnotification_title_ban: Us han bandejat\nnotification_title_message: Nou missatge directe\nnotification_title_new_post: Publicació nova\nnotification_title_removed_post: S'ha eliminat una publicació\nnotification_title_edited_post: S'ha editat una publicació\nnotification_title_new_signup: S'ha registrat un nou compte\nnotification_body_new_signup: S'ha registrat el compte %u%.\nnotification_body2_new_signup_approval: Heu d'aprovar la sol·licitud abans que \n  puguin iniciar sessió\nshow_related_magazines: Mostrar revistes aleatòries\nshow_related_entries: Mostrar fils aleatoris\nshow_related_posts: Mostrar publicacions aleatòries\nshow_active_users: Mostrar comptes actius\nnotification_title_new_report: S'ha creat una nova denúncia\nmagazine_posting_restricted_to_mods_warning: Només la moderació pot crear fils \n  en aquesta revista\nflash_posting_restricted_error: La creació de fils està restringida a la \n  moderació d'aquesta revista i no en sou part\nserver_software: Programari del servidor\nversion: Versió\nlast_successful_deliver: Darrera entrega correcta\nlast_successful_receive: Darrera rebuda correcta\nlast_failed_contact: Últim contacte fallit\nnew_user_description: Aquest compte és nou (actiu durant menys de %days% dies)\nnew_magazine_description: Aquesta revista és nova (activa durant menys de %days%\n  dies)\nadmin_users_suspended: Suspesos(es)\nadmin_users_active: Actius(ves)\nadmin_users_banned: Bandejats(des)\nuser_verify: Activar compte\nmax_image_size: Mida màxima del fitxer\ncomment_not_found: No s'ha trobat el comentari\nbookmark_remove_from_list: Eliminar el marcador de %list%\nbookmark_add_to_list: Afegir marcador a %list%\nbookmark_remove_all: Eliminar tots els marcadors\nbookmark_add_to_default_list: Afegir marcador a la llista predeterminada\nbookmark_lists: Llistes de marcadors\nbookmarks: Marcadors\nbookmarks_list: Marcadors en %list%\ncount: Recompte\nis_default: És predeterminada\nbookmark_list_make_default: Fer predeterminada\nbookmark_list_is_default: És la llista predeterminada\nbookmark_list_create: Crear\nbookmark_list_create_placeholder: escriviu el nom…\nbookmark_list_create_label: Nom de la llista\nbookmarks_list_edit: Editar la llista de marcadors\nerrors.server403.title: 403 Prohibit\nresend_account_activation_email: Tornar a enviar el correu electrònic \n  d'activació del compte\nfederation_page_dead_description: Instàncies en què no vam poder lliurar almenys\n  10 activitats seguides i on l'última entrega i recepció amb èxit van ser fa \n  més d'una setmana\noauth2.grant.moderate.magazine.ban.delete: Desblocar usuaris(es) de les revistes\n  que modereu.\noauth2.grant.domain.all: Subscriure-vos o blocar dominis i veure els dominis a \n  què us subscriviu o que bloqueu.\naccount_deletion_description: El vostre compte se suprimirà d'aquí a 30 dies \n  tret que decidiu suprimir-lo immediatament. Per restaurar el vostre compte en \n  un termini de 30 dies, inicieu sessió amb les mateixes credencials o poseu-vos\n  en contacte amb l'equip d'administració.\noauth2.grant.moderate.magazine_admin.edit_theme: Editar el CSS personalitzat de \n  qualsevol de les vostres revistes.\nmore_from_domain: Més del domini\nemail_confirm_button_text: Confirmeu la vostra sol·licitud de canvi de \n  contrasenya\nerrors.server500.description: Ho sentim, hi ha hagut un error al nostre costat. \n  Si continueu veient aquest error, proveu de contactar amb l'administració de \n  la instància. Si aquesta instància no funciona en absolut, aneu a \n  %link_start%altres instàncies de Mbin%link_end% mentrestant fins que es \n  resolgui el problema.\nresend_account_activation_email_error: Hi ha hagut un problema en enviar la \n  sol·licitud. Potser no hi ha cap compte associat amb l'adreça electrònica o \n  potser ja està activat.\nemail_confirm_link_help: Alternativament, podeu copiar i enganxar el següent al \n  vostre navegador\nemail.delete.title: Sol·licitud d'eliminació del compte\noauth2.grant.moderate.magazine.reports.all: Gestionar les denúncies a les \n  revistes que modereu.\noauth.consent.to_allow_access: Per permetre aquest accés feu clic al botó \n  «Permetre” a continuació\nprivate_instance: Forçar a iniciar sessió abans de poder accedir a qualsevol \n  contingut\noauth2.grant.moderate.magazine_admin.all: Crear, editar o suprimir les vostres \n  revistes.\noauth2.grant.subscribe.general: Subscriure-vos o seguir qualsevol revista, \n  domini o compte, i veure les revistes, dominis i comptes a què esteu \n  subscrit(e)s.\noauth2.grant.admin.entry.purge: Suprimir completament qualsevol fil de la vostra\n  instància.\noauth2.grant.vote.general: Votar a favor, en contra o impulsar els fils, \n  publicacions o comentaris.\noauth2.grant.entry_comment.all: Crear, editar o suprimir els vostres comentaris \n  en fils i votar, millorar o denunciar qualsevol comentari d'un fil.\noauth2.grant.entry_comment.vote: Votar a favor, impulsar o votar en contra de \n  qualsevol comentari d'un fil.\noauth2.grant.user.profile.edit: Editar el vostre perfil.\noauth2.grant.magazine.all: Subscriure-vos o bloquejar les revistes i veure les \n  revistes a què us subscriviu o heu blocat.\noauth2.grant.user.profile.read: Veure el vostre perfil.\noauth2.grant.post.report: Denunciar qualsevol publicació.\noauth2.grant.post_comment.create: Crear comentaris nous a les publicacions.\noauth2.grant.post.create: Crear publicacions noves.\noauth2.grant.moderate.entry.change_language: Canviar l'idioma dels fils a les \n  revistes que modereu.\noauth2.grant.user.notification.all: Llegir i eliminar les vostres notificacions.\noauth2.grant.user.oauth_clients.all: Veure i editar els permisos que heu \n  concedit a altres aplicacions OAuth2.\noauth2.grant.user.follow: Seguir o deixar de seguir comptes i veure una llista \n  de comptes que seguiu.\noauth2.grant.moderate.entry.all: Moderar els fils a les revistes que modereu.\noauth2.grant.post_comment.vote: Votar a favor, impulsar o votar en contra de \n  qualsevol comentari d'una publicació.\noauth2.grant.user.notification.delete: Esborrar les vostres notificacions.\noauth2.grant.user.all: Veure i editar el vostre perfil, missatges o \n  notificacions; veure i editar els permisos que heu concedit a altres \n  aplicacions; seguir o blocar altres comptes; veure les llistes de comptes que \n  seguiu o bloqueu.\noauth2.grant.admin.user.ban: Bandejar o desbandejar comptes de la vostra \n  instància.\noauth2.grant.moderate.post_comment.change_language: Canviar l'idioma dels \n  comentaris a les publicacions de les revistes que modereu.\noauth2.grant.admin.user.all: Bandejar, verificar o suprimir completament comptes\n  de la vostra instància.\noauth2.grant.moderate.post.all: Moderar les publicacions a les revistes que \n  modereu.\noauth2.grant.moderate.post.set_adult: Marcar com a explícites les publicacions a\n  les revistes que modereu.\noauth2.grant.moderate.magazine.ban.all: Gestionar els comptes bandejats a les \n  revistes que modereu.\noauth2.grant.admin.post.purge: Suprimir completament qualsevol publicació de la \n  vostra instància.\noauth2.grant.admin.instance.settings.all: Veure o actualitzar la configuració de\n  la vostra instància.\noauth2.grant.admin.instance.information.edit: Actualitzar les pàgines Quant a, \n  Preguntes freqüents, Contacte, Condicions del servei i Política de privadesa a\n  la vostra instància.\noauth2.grant.admin.magazine.all: Moure fils entre les revistes o suprimir-les \n  completament a la vostra instància.\n2fa.backup: Els vostres codis de recolzament de dos factors\nmoderation.report.ban_user_description: Voleu bandejar el compte (%username%) \n  que ha creat aquest contingut d'aquesta revista?\nmoderation.report.approve_report_confirmation: Confirmeu l'aprovació d'aquest \n  informe?\npurge_content_desc: Purgar completament el contingut de l'usuari(a), incloent la\n  supressió de les respostes d'altres usuaris(es) en fils, publicacions i \n  comentaris creats.\n2fa.backup-create.help: Podeu crear nous codis d'autenticació de recolzament; \n  fer-ho invalidarà els codis existents.\nsubscription_header: Revistes subscrites\ncomment_reply_position_help: Mostrar el formulari de resposta de comentaris a la\n  part superior o inferior de la pàgina. Quan el «desplaçament infinit» està \n  habilitat, la posició sempre apareixerà a la part superior.\ndelete_account_desc: Suprimir el compte, incloses les respostes d'altres \n  usuaris(es) en fils, publicacions i comentaris creats.\noauth2.grant.moderate.post.pin: Fixar les publicacions a la part superior de les\n  revistes que modereu.\nmagazine_theme_appearance_custom_css: CSS personalitzat que s'aplicarà quan \n  visualitzeu contingut a la vostra revista.\ntwo_factor_authentication: Autenticació de dos factors\n2fa.disable: Desactivar l'autenticació de dos factors\n2fa.setup_error: Error en activar A2F per al compte\nflash_post_new_error: No s'ha pogut crear la publicació. Alguna cosa ha fallat.\naccount_suspended: El compte s'ha suspès.\nflash_account_settings_changed: La configuració del vostre compte s'ha canviat \n  correctament. Haureu de tornar a iniciar sessió.\n2fa.backup_codes.help: Podeu utilitzar aquests codis quan no teniu el vostre \n  dispositiu o aplicació d'autenticació de dos factors. <strong>No se us \n  tornaran a mostrar</strong> i els podreu fer servir <strong>només una \n  vegada</strong>.\n2fa.available_apps: Utilitzar una aplicació d'autenticació de dos factors com \n  ara %google_authenticator%, %aegis% (Android) o %raivo% (iOS) per escanejar el\n  codi QR.\naccount_unsuspended: El compte s'ha reactivat.\ndeletion: Eliminació\nsubscription_sidebar_pop_in: Moure subscripcions al panell emergent\nflash_user_edit_profile_success: La configuració del perfil s'ha desat \n  correctament.\nflash_comment_new_success: El comentari s'ha creat correctament.\nsuspend_account: Suspendre el compte\nflash_user_edit_email_error: No s'ha pogut canviar l'adreça electrònica.\nflash_comment_new_error: No s'ha pogut crear el comentari. Alguna cosa ha \n  fallat.\nflash_user_settings_general_success: La configuració d'usuari(a) s'ha desat \n  correctament.\naccount_is_suspended: El compte està suspès.\nopen_url_to_fediverse: Obrir URL original\nremove_following: Eliminar el seguiment\nunsuspend_account: Reactivar el compte\naccount_unbanned: S'ha desbandejat el compte.\ndeleted_by_moderator: El fil, la publicació o el comentari ha estat suprimit per\n  l'equip de moderació\ncake_day: Des del dia\nreported: denunciat(da)\nnotification_title_mention: Us han esmentat\nsomeone: Algú\nsso_only_mode: Restringir l'inici de sessió i el registre només als mètodes SSO\nreporting_user: Denunciant\nback: Tornar\nadmin_users_inactive: Inactius(ves)\nrestrict_magazine_creation: Restringir la creació de revistes locals a \n  l'administració i moderació global\nreport_subject: Assumpte\nsso_show_first: Mostrar primer SSO a les pàgines d'inici de sessió i de registre\nmagazine_posting_restricted_to_mods: Restringir la creació de fils a la \n  moderació\nsso_registrations_enabled.error: Els registres de comptes nous amb gestors \n  d'identitats de tercers estan actualment desactivats.\nreported_user: Compte denunciat\nrelated_entry: Relacionat\nopen_report: Obrir denúncia\nown_report_rejected: La vostra denúncia ha estat rebutjada\nown_content_reported_accepted: S'ha acceptat una denúncia del vostre contingut.\nbookmark_list_edit: Editar\nbookmark_list_selected_list: Llista seleccionada\ntable_of_contents: Taula de continguts\nsearch_type_all: Tot\nsearch_type_entry: Fils\nsearch_type_post: Microblogs\nselect_user: Trieu un(a) usuari(a)\nnew_users_need_approval: Els comptes nous han de ser aprovats per \n  l'administració abans que puguin iniciar sessió.\napplication_text: Expliqueu per què voleu unir-vos\nsignup_requests: Sol·licituds de registre\nsignup_requests_header: Sol·licituds de registre\nemail_application_approved_title: La vostra sol·licitud de registre s'ha aprovat\nemail_application_approved_body: L'administració del servidor ha aprovat la \n  vostra sol·licitud de registre. Ara podeu iniciar sessió al servidor a <a \n  href=\"%link%\">%siteName%</a>.\nemail_application_rejected_title: La vostra sol·licitud de registre ha estat \n  rebutjada\nemail_application_pending: El vostre compte requereix l'aprovació de \n  l'administració abans de poder iniciar sessió.\nemail_verification_pending: Heu de verificar la vostra adreça electrònica abans \n  de poder iniciar sessió.\nsignup_requests_paragraph: A aquests(es) usuaris(es) els agradaria unir-se al \n  vostre servidor. No poden iniciar sessió fins que no hàgiu aprovat llurs \n  sol·licituds de registre.\nshow_magazine_domains: Mostrar els dominis de les revistes\nshow_user_domains: Mostrar els dominis dels comptes\nanswered: respost\nby: per\nfront_default_sort: Ordenació predeterminada de la portada\ncomment_default_sort: Ordenació predeterminada dels comentaris\nopen_signup_request: Obrir la sol·licitud de registre\nimage_lightbox_in_list: Les miniatures dels fils obren pantalla completa\nshow_users_avatars_help: Mostrar la imatge de l'avatar de l'usuari(a).\ncompact_view_help: Una vista compacta amb marges menors, on la miniatura passa \n  al costat dret.\nshow_magazines_icons_help: Mostrar la icona de la revista.\nshow_thumbnails_help: Mostrar les miniatures de les imatges.\nshow_new_icons: Mostrar noves icones\nshow_new_icons_help: Mostrar la icona per a la revista o el compte nou (30 dies \n  d'antiguitat o més recent)\nflash_application_info: L'administració ha d'aprovar el vostre compte abans de \n  poder iniciar sessió. Rebreu un correu electrònic un cop s'hagi processat la \n  vostra sol·licitud de registre.\nemail_application_rejected_body: Gràcies pel vostre interès, però lamentem \n  informar-vos que la vostra sol·licitud de registre ha estat rebutjada.\nimage_lightbox_in_list_help: Quan està marcat, en fer clic a la miniatura es \n  mostra una finestra modal amb la imatge. Quan no estigui marcat, fer clic a la\n  miniatura obrirà el fil.\n2fa.manual_code_hint: Si no podeu escanejar el codi QR, introduïu el secret \n  manualment\ntoolbar.emoji: Emoji\nmagazine_instance_defederated_info: La instància d'aquesta revista està \n  desfederada. Per tant, la revista no rebrà actualitzacions.\nuser_instance_defederated_info: La instància d'aquest compte està defederada.\nflash_thread_instance_banned: La instància d'aquesta revista està banida.\nshow_rich_mention: Mencions enriquides\nshow_rich_mention_help: Mostrar un component de compte quan es menciona un \n  compte. Això n'inclourà el nom de visualització i la foto de perfil.\nshow_rich_mention_magazine: Mencions enriquides de revistes\nshow_rich_mention_magazine_help: Mostrar un component de revista quan es \n  menciona una revista. Això in'nclourà el nom de visualització i la icona.\nshow_rich_ap_link: Enllaços AP enriquits\nshow_rich_ap_link_help: Mostrar un component en línia quan s'hi enllaça un altre\n  contingut d'ActivityPub.\nattitude: Actitud\ntype_search_term_url_handle: Escriviu el terme de cerca, l'URL o l'identificador\nsearch_type_magazine: Revistes\nsearch_type_user: Comptes\nsearch_type_actors: Revistes i comptes\nsearch_type_content: Temes i microblogs\ntype_search_magazine: Limitar la cerca a la revista...\ntype_search_user: Limitar la cerca a l'autoria...\nmodlog_type_entry_deleted: Fil suprimit\nmodlog_type_entry_restored: Fil restaurat\nmodlog_type_entry_comment_deleted: Comentari del fil suprimit\nmodlog_type_entry_comment_restored: Comentari del fil restaurat\nmodlog_type_entry_pinned: Fil fixat\nmodlog_type_entry_unpinned: Fil deixat de fixar\nmodlog_type_post_deleted: Microblog suprimit\nmodlog_type_post_restored: Microblog restaurat\nmodlog_type_post_comment_deleted: Resposta del microblog suprimida\nmodlog_type_post_comment_restored: Resposta del microblog restaurada\nmodlog_type_ban: Compte expulsat de la revista\nmodlog_type_moderator_add: Moderador(a) de la revista afegit(da)\nmodlog_type_moderator_remove: Moderador(a) de la revista destituït(da)\neveryone: Tothom\nnobody: Ningú\nfollowers_only: Només seguidor(e)s\ndirect_message_setting_label: Qui pot enviar-vos un missatge directe\nbanner: Bàner\nmagazine_theme_appearance_banner: Bàner personalitzat per a la revista. Es \n  mostra a sobre de tots els fils de discussió i ha de tenir una relació \n  d'aspecte ampla (5:1 o 1500 px * 300 px).\ndelete_magazine_icon: Suprimeix icona de la revista\nflash_magazine_theme_icon_detached_success: La icona de la revista s'ha suprimit\n  correctament\ndelete_magazine_banner: Suprimeix el bàner de la revista\nflash_magazine_theme_banner_detached_success: El bàner de la revista s'ha \n  suprimit correctament\nfederation_page_use_allowlist_help: Si s'utilitza una llista de permesos, \n  aquesta instància només es federarà amb les instàncies explícitament permeses.\n  En cas contrari, aquesta instància es federarà amb totes les instàncies, \n  excepte les que estiguin prohibides.\ncrosspost: Publicació creuada\nflash_thread_ref_image_not_found: No s'ha pogut trobar la imatge a què fa \n  referència 'imageHash'.\nfederation_uses_allowlist: Utilitzar la llista de permesos per a la federació\ndefederating_instance: S'està defederant la instància %i\ntheir_user_follows: Quantitat de comptes de la seva instància que segueixen \n  comptes de la nostra\nour_user_follows: Quantitat de comptes de la nostra instància que segueixen \n  comptes de la seva\ntheir_magazine_subscriptions: Quantitat de comptes de la seva instància \n  subscrits a revistes de la nostra\nour_magazine_subscriptions: Quantitat de comptes de la nostra instància \n  subscrits a revistes des de la seva\nconfirm_defederation: Confirmar la desfederació\nflash_error_defederation_must_confirm: Heu de confirmar la desfederació\nallowed_instances: Instàncies permeses\nbtn_deny: Denegar\nbtn_allow: Permetre\nban_instance: Banir instància\nallow_instance: Permetre instància\nfront_default_content: Vista per defecte de portada\ndefault_content_default: Valor per defecte del servidor (Fils)\ndefault_content_threads: Fils\ndefault_content_microblog: Microblog\ncombined: Combinat\nsidebar_sections_random_local_only: Restringir les seccions de la barra lateral \n  «Publicacions/Fils aleatoris» només a locals\nsidebar_sections_users_local_only: Restringir la secció de la barra lateral \n  «Persones actives» només a locals\nrandom_local_only_performance_warning: Habilitar «Només aleatoris locals» pot \n  afectar el rendiment de l'SQL.\ndefault_content_combined: Fils + Microblog\nban_expires: La prohibició caduca\nyou_have_been_banned_from_magazine: Us han prohibit l'accés a la revista %m.\nyou_have_been_banned_from_magazine_permanently: Us han prohibit permanentment \n  l'accés a la revista %m.\nyou_are_no_longer_banned_from_magazine: Ja no teniu prohibit l'accés a la \n  revista %m.\noauth2.grant.moderate.entry.lock: Bloca els fils de les revistes moderades \n  perquè ningú no hi pugui fer comentaris\noauth2.grant.moderate.post.lock: Bloca els microblogs a les revistes moderades, \n  perquè ningú no hi pugui fer comentaris\ndiscoverable: Descobrible\nuser_discoverable_help: Si aquesta opció està habilitada, el vostre perfil, fils\n  de discussió, microblogs i comentaris es poden trobar mitjançant la cerca i \n  els panells aleatoris. El vostre perfil també pot aparèixer al panell \n  d'usuari(a) actiu(va) i a la pàgina de persones. Si aquesta opció està \n  desactivada, les vostres publicacions continuaran sent visibles per a altres \n  usuari(e)s, però no apareixeran al canal complet.\nmagazine_discoverable_help: Si això està habilitat, aquesta revista i els fils, \n  microblogs i comentaris d'aquesta revista es poden trobar mitjançant la cerca \n  i els panells aleatoris. Si això està desactivat, la revista encara apareixerà\n  a la llista de revistes, però els fils i microblogs no apareixeran al canal \n  complet.\nflash_thread_lock_success: Fil blocat correctament\nflash_thread_unlock_success: Fil desblocat correctament\nflash_post_lock_success: Microblog blocat correctament\nflash_post_unlock_success: Microblog desblocat correctament\nlock: Blocar\nunlock: Desblocar\ncomments_locked: Els comentaris estan blocats.\nmagazine_log_entry_locked: ha blocat els comentaris de\nmagazine_log_entry_unlocked: ha desblocat els comentaris de\nmodlog_type_entry_lock: Fil blocat\nmodlog_type_entry_unlock: Fil desblocat\nmodlog_type_post_lock: Microblog blocat\nmodlog_type_post_unlock: Microblog desblocat\ncontentnotification.muted: Silenciós | no rebre notificacions\ncontentnotification.default: Predeterminat | rebre notificacions segons la \n  configuració predeterminada\ncontentnotification.loud: Sorollós | rebre totes les notificacions\nindexable_by_search_engines: Indexable pels motors de cerca\nuser_indexable_by_search_engines_help: Si aquesta configuració es desactiva, es \n  recomana als motors de cerca que no indexin cap dels vostres fils i \n  microblogs, però els vostres comentaris no es veuen afectats per això i els \n  malfactors podrien ignorar-la. Aquesta configuració també està federada a \n  altres servidors.\nmagazine_indexable_by_search_engines_help: Si aquesta configuració es desactiva,\n  es recomana als motors de cerca que no indexin cap dels fils i microblogs \n  d'aquestes revistes. Això inclou la pàgina de destinació i totes les pàgines \n  de comentaris. Aquesta configuració també està federada a altres servidors.\nmagazine_name_as_tag: Utilitza el nom de la revista com a etiqueta\nmagazine_name_as_tag_help: Les etiquetes d'una revista s'utilitzen per fer \n  coincidir les entrades de microblog amb aquesta revista. Per exemple, si el \n  nom és \"fediverse\" i les etiquetes de la revista contenen \"fediverse\", totes \n  les entrades de microblog que continguin \"#fediverse\" es posaran en aquesta \n  revista.\nmagazine_rules_deprecated: el camp de regles està obsolet i s'eliminarà en el \n  futur. Si us plau, poseu les vostres regles al quadre de descripció.\ncreated_since: Creat des de\n"
  },
  {
    "path": "translations/messages.ca@valencia.yaml",
    "content": "type.link: Enllaç\ntype.article: Fil\ntype.video: Vídeo\ntype.smart_contract: Contracte intel·ligent\ntype.magazine: Revista\nthread: Fil\nthreads: Fils\nmicroblog: Microblog\npeople: Gent\nevents: Esdeveniments\nmagazine: Revista\nsearch: Buscar\nadd: Afegir\nselect_channel: Trieu un canal\nlogin: Iniciar sessió\nsort_by: Ordenar per\ntop: Destacat\nhot: Popular\nactive: Actiu(va)\nnewest: Més nou\noldest: Més vell\ncommented: Comentat\nmagazines: Revistes\ntype.photo: Foto\nchange_view: Canviar vista\nfilter_by_time: Filtrar per temps\nfilter_by_subscription: Filtrar per subscripció\nfilter_by_federation: Filtrar per estat de federació\ncomments_count: '{0}Comentaris|{1}Comentari|]1,Inf[ Comentaris'\nsubscribers_count: '{0}Subscriptores|{1}Subscriptora|]1,Inf[ Subscriptores'\nmarked_for_deletion: Marcat per a supressió\nmarked_for_deletion_at: Marcat per suprimir-se el %date%\nfavourites: Vots a favor\nfavourite: Preferit\nmore: Més\navatar: Avatar\nadded: Afegit\nup_votes: Impulsos\ndown_votes: Vots en contra\nno_comments: Sense comentaris\ncreated_at: Creat\nowner: Propietari(a)\nsubscribers: Subscritors(es)\nonline: En línia\ncomments: Comentaris\nposts: Publicacions\nreplies: Respostes\nmoderators: Moderació\nmod_log: Registre de moderació\nadd_comment: Afegir comentari\nadd_post: Afegir publicació\nadd_media: Afegir mitjà\nremove_media: Eliminar mitjà\nremove_user_avatar: Eliminar avatar\nremove_user_cover: Eliminar portada\nfollowers_count: '{0}Seguidores|{1}Seguidora|]1,Inf[ Seguidores'\nfilter_by_type: Filtrar per tipus\nfollow: Seguir\nshow_profile_followings: Mostrar els comptes seguits\nblocked: Bloquejats\nreports: Denúncies\noverview: Visió general\nedit_comment: Guardar canvis\ngo_to_filters: Anar a filtres\nhide_adult: Amagar contingut explícit\nfeatured_magazines: Revistes destacades\nnotify_on_new_entry_comment_reply: Respostes als meus comentaris en qualsevol \n  fil\nnotify_on_new_post_reply: Qualsevol nivell de respostes a les publicacions que \n  he escrit\nnotify_on_new_posts: Noves publicacions a qualsevol revista a què estic subscrit\nvideos: Vídeos\nmessages: Missatges\ntry_again: Torneu-ho a provar\nhomepage: Pàgina d'inici\nbadges: Insígnies\nsettings: Configuració\nnotifications: Notificacions\nnotify_on_new_post_comment_reply: Respostes als meus comentaris a qualsevol \n  publicació\nappearance: Aparença\nprivacy: Privadesa\nare_you_sure: Ho confirmeu?\nrepeat_password: Repetiu la contrasenya\nadd_new: Afegir nou\nagree_terms: Accepteu els %terms_link_start%Termes i condicions%terms_link_end% \n  i la %policy_link_start%Política de privadesa%policy_link_end%\nshare: Compartir\nselect_magazine: Trieu una revista\nreset_check_email_desc2: Si no rebeu cap correu electrònic, comproveu la vostra \n  carpeta de correu brossa.\ntags: Etiquetes\nis_adult: +18 / Explícit\ndomain: Domini\nimage_alt: Text alternatiu de la imatge\nfollowing: Seguint\nsubscriptions: Subscripcions\ncompact_view: Vista compacta\nshare_on_fediverse: Compartir al Fedivers\nchat_view: Vista de xat\nprofile: Perfil\nphotos: Fotos\nreport: Denunciar\ncopy_url: Copiar URL de Mbin\ncopy_url_to_fediverse: Copiar URL original\nnotify_on_new_entry: Fils nous (enllaços o articles) a qualsevol revista a què \n  estic subscrit\nedit: Editar\nmoderate: Moderar\nreason: Motiu\nedit_entry: Editar fil\nshow_profile_subscriptions: Mostrar subscripcions a revistes\ndelete: Eliminar\nedit_post: Editar publicació\nmenu: Menú\ngeneral: General\nnotify_on_new_entry_reply: Qualsevol nivell de comentaris als fils que he escrit\nmarkdown_howto: Com funciona l'editor?\nenter_your_comment: Escriviu el comentari\nenter_your_post: Escriviu la publicació\nactivity: Activitat\ncover: Portada\nrelated_posts: Publicacions relacionades\nrandom_posts: Publicacions aleatòries\nfederated_magazine_info: Esta revista és d'un servidor federat i pot estar \n  incompleta.\ndisconnected_magazine_info: 'Esta revista no està rebent actualitzacions: última activitat\n  fa %days% dia(es).'\nalways_disconnected_magazine_info: Esta revista no està rebent actualitzacions.\nsubscribe_for_updates: Subscriviu-vos per començar a rebre actualitzacions.\nfederated_user_info: Este perfil és d'un servidor federat i pot ser incomplet.\ngo_to_original_instance: Vore en instància remota\nempty: Buit\nsubscribe: Subscriure's\nunsubscribe: Cancel·lar subscripció\nunfollow: Deixar de seguir\nreply: Resposta\nlogin_or_email: Identificador o adreça electrònica\npassword: Contrasenya\nremember_me: Recordar-me\ndont_have_account: No teniu un compte?\nyou_cant_login: Heu oblidat la contrasenya?\nalready_have_account: Ja teniu un compte?\nregister: Crear compte\nreset_password: Restablir contrasenya\nshow_more: Mostrar més\nto: a\nin: en\nfrom: des de\nusername: Identificador\nemail: Adreça electrònica\nterms: Condicions del servici\nprivacy_policy: Política de privadesa\nabout_instance: Quant a\nall_magazines: Totes les revistes\nstats: Estadístiques\nfediverse: Fedivers\ncreate_new_magazine: Crear revista nova\nadd_new_article: Afegir fil nou\nadd_new_link: Afegir enllaç nou\nadd_new_photo: Afegir foto nova\nadd_new_post: Afegir publicació nova\nadd_new_video: Afegir vídeo nou\ncontact: Contacte\nfaq: Preguntes més freqüents (PMF)\nrss: RSS\nchange_theme: Canviar tema\ndownvotes_mode: Mode de vots negatius\nchange_downvotes_mode: Canviar el mode de vots negatius\ndisabled: Deshabilitat\nhidden: Amagat\nenabled: Habilitat\nuseful: Útil\nhelp: Ajuda\ncheck_email: Comproveu la vostra bústia electrònica\nreset_check_email_desc: Si ja hi ha un compte associat a la vostra adreça \n  electrònica, rebreu un correu electrònic en breu amb un enllaç que podeu \n  utilitzar per restablir la vostra contrasenya. Este enllaç caducarà en \n  %expire%.\nup_vote: Impulsar\ndown_vote: Votar en contra\nemail_confirm_header: Hola! Confirmeu la vostra adreça electrònica.\nemail_confirm_content: \"Per activar el compte de Mbin feu clic a l'enllaç següent:\"\nemail_verify: Confirmeu l'adreça electrònica\nemail_confirm_expire: Tingueu en compte que l'enllaç caducarà en una hora.\nemail_confirm_title: Confirmeu la vostra adreça electrònica.\nurl: URL\ntitle: Títol\nbody: Cos\ntag: Etiqueta\neng: ENG\noc: Cont. Orig.\nimage: Imatge\nname: Nom\ndescription: Descripció\nrules: Normes\nfollowers: Seguidor(e)s\ncards: Targetes\ncolumns: Columnes\nuser: Usuari(a)\njoined: Inscrit(a)\nmoderated: Moderat(da)\npeople_local: Local\npeople_federated: Federat\nreputation_points: Punts de reputació\nrelated_tags: Etiquetes relacionades\ngo_to_content: Anar al contingut\ngo_to_search: Anar a la cerca\nsubscribed: Subscrit(a)\nall: Tot\nlogout: Tancar sessió\nclassic_view: Vista clàssica\ntree_view: Vista d'arbre\ntable_view: Vista de taula\ncards_view: Vista de targetes\n3h: 3h\n6h: 6h\n12h: 12h\n1w: 1 setm.\n1m: 1 mes\n1d: 1 dia\n1y: 1 any\nlinks: Enllaços\narticles: Fils\nnotify_on_user_signup: Nous registres\nsave: Guardar\nabout: Quant a\nrestored_post_by: ha restaurat una publicació de\nsize: Grandària\nflash_magazine_new_success: La revista s'ha creat correctament. Ara podeu afegir\n  contingut nou o explorar el tauler d'administració de la revista.\nedited_post: Ha editat una publicació\nshow_magazines_icons: Mostrar icones de les revistes\nflash_thread_pin_success: El fil s'ha fixat correctament.\nmod_remove_your_post: La moderació ha eliminat la vostra publicació\nold_email: Adreça electrònica actual\nnew_email: Nova adreça electrònica\nnew_email_repeat: Confirmar l'adreça electrònica nova\ncurrent_password: Contrasenya actual\nnew_password: Contrasenya nova\nnew_password_repeat: Confirmar la nova contrasenya\nchange_email: Canviar l'adreça electrònica\nchange_password: Canviar la contrasenya\nexpand: Desplegar\ncollapse: Plegar\ndomains: Dominis\nerror: Error\nvotes: Vots\ntheme: Tema\ndark: Fosc\nlight: Clar\nsolarized_light: Clar solaritzat\nsolarized_dark: Fosc solaritzat\ndefault_theme: Tema predeterminat\ndefault_theme_auto: Clar/fosc (detecció automàtica)\nsolarized_auto: Solaritzat (detecció automàtica)\nfont_size: Grandària de la lletra\nboosts: Impulsos\nshow_users_avatars: Mostrar avatars d'usuaris(es)\nyes: Sí\nno: No\nshow_thumbnails: Mostrar miniatures\nrounded_edges: Vores arredonides\nremoved_thread_by: ha eliminat un fil de\nrestored_thread_by: ha restaurat un fil de\nremoved_comment_by: ha eliminat un comentari de\nrestored_comment_by: ha restaurat el comentari de\nremoved_post_by: ha eliminat una publicació de\nhe_banned: vetat(da)\nhe_unbanned: vet retirat\nread_all: Marcar-ho tot com a llegit\nshow_all: Mostrar-ho tot\nflash_register_success: Benvinguda a bord! El vostre compte ja està registrat. \n  Un últim pas - consulteu la vostra safata d'entrada per a rebre un enllaç \n  d'activació que donarà vida al vostre compte.\nflash_thread_new_success: El fil s'ha creat correctament i ara és visible per a \n  altres usuaris(es).\nflash_thread_edit_success: El fil s'ha editat correctament.\nflash_thread_delete_success: El fil s'ha suprimit correctament.\nflash_thread_unpin_success: El fil s'ha desfixat correctament.\nflash_magazine_edit_success: La revista s'ha editat correctament.\nflash_mark_as_adult_success: La publicació s'ha marcat correctament com a \n  explícita.\nflash_unmark_as_adult_success: La publicació s'ha desmarcat correctament com a \n  explícita.\ntoo_many_requests: S'ha superat el límit; torneu-ho a provar més tard.\nset_magazines_bar: Barra de revistes\nset_magazines_bar_desc: afegiu els noms de les revistes després de la coma\nset_magazines_bar_empty_desc: si el camp està buit, les revistes actives es \n  mostren a la barra.\nmod_log_alert: 'ADVERTÈNCIA: En el registre de moderació podreu trobar contingut desagradable\n  o ofensiu eliminat per la moderació. Assegureu-vos de saber el que esteu fent.'\nadded_new_thread: S'ha afegit un fil nou\nedited_thread: Ha editat un fil\nmod_remove_your_thread: La moderació ha eliminat el vostre fil\nadded_new_comment: Ha afegit un comentari nou\nedited_comment: Ha editat un comentari\nreplied_to_your_comment: Ha respost al vostre comentari\nmod_deleted_your_comment: La moderació ha suprimit el vostre comentari\nadded_new_post: Ha afegit una publicació nova\nadded_new_reply: Ha afegit una nova resposta\npost: Publicació\ncomment: Comentari\nmentioned_you: Vos ha esmentat\nban_expired: El vet ha expirat\nwrote_message: Ha escrit un missatge\nbanned: Vos ha vetat\nremoved: Eliminat per la moderació\ndeleted: Esborrat per l'autor\nmessage: Missatge\ninfinite_scroll: Desplaçament infinit\nshow_top_bar: Mostrar barra superior\nsubject_reported: S'ha denunciat el contingut.\nleft: Esquerra\nright: Dreta\nfederation: Federació\nstatus: Estat\nupload_file: Pujar arxiu\napprove: Aprovar\napproved: Aprovat\nrejected: Rebutjat\nadd_moderator: Afegir moderador(a)\nadd_badge: Afegir insígnia\ncreated: Creat\nexpires: Caduca\nperm: Permanent\ntrash: Paperera\nicon: Icona\ndone: Fet\npin: Fixar\nunpin: Desfixar\nunban: Retirar vet\nunban_hashtag_btn: Retirar vet al hashtag\nunban_hashtag_description: Retirar el vet a un hashtag permetrà tornar a crear \n  publicacions amb este hashtag. Les publicacions existents amb el hashtag ja no\n  s'amaguen.\nadd_ban: Afegir vet\nban: Vetar\nban_hashtag_btn: Vetar hashtag\nbans: Vets\nchange_magazine: Canviar revista\nchange_language: Canviar idioma\nmark_as_adult: Marcar com a explícit\nchange: Canviar\nwriting: Escriptura\nusers: Usuaris(es)\nrestore: Restaurar\nadd_mentions_entries: Afegir etiquetes de menció als fils\nadd_mentions_posts: Afegir etiquetes de menció a les publicacions\nPassword is invalid: La contrasenya no és vàlida.\nYour account is not active: El vostre compte no està actiu.\nfirstname: Nom\nsend: Enviar\nactive_users: Persones actives\nrandom_entries: Fils aleatoris\nrelated_entries: Fils relacionats\ndelete_account: Suprimir el compte\nYour account has been banned: El vostre compte ha sigut vetat.\nrelated_magazines: Revistes relacionades\nrandom_magazines: Revistes aleatòries\nunban_account: Retirar vet al compte\nban_account: Vetar el compte\nbanned_instances: Instàncies prohibides\nkbin_intro_title: Explorar el fedivers\nkbin_promo_title: Creeu la vostra pròpia instància\ncaptcha_enabled: Captcha activat\nreturn: Tornar\nboost: Impulsar\nmercure_enabled: Mercure activat\ntokyo_night: Nit de Tòquio\npreferred_languages: Filtrar els idiomes de fils i publicacions\ninfinite_scroll_help: Carregar automàticament més contingut en arribar a la part\n  inferior de la pàgina.\nsticky_navbar_help: La barra de navegació es fixarà a la part superior de la \n  pàgina quan vos desplaceu cap avall.\nauto_preview_help: Mostrar les previsualitzacions multimèdia (foto, vídeo) en \n  una grandària major baix del contingut.\nreload_to_apply: Torneu a carregar la pàgina per aplicar els canvis\nfilter.fields.label: Trieu quins camps voleu buscar\nfilter.adult.label: Trieu si voleu mostrar contingut explícit\nfilter.adult.hide: Amagar contingut explícit\nfilter.adult.only: Només el contingut explícit\nlocal_and_federated: Local i federat\nkbin_bot: Agent Mbin\nyour_account_has_been_banned: El vostre compte ha sigut vetat\ntoolbar.bold: Negreta\ntoolbar.image: Imatge\ntoolbar.unordered_list: Llista no ordenada\ntoolbar.ordered_list: Llista ordenada\ntoolbar.mention: Esment\ntoolbar.spoiler: Spoiler\nfederation_page_enabled: Pàgina de federació activada\nfederation_page_allowed_description: Instàncies conegudes amb què ens federem\nfederated_search_only_loggedin: Cerca federada limitada si no s'ha iniciat \n  sessió\naccount_deletion_title: Supressió del compte\naccount_deletion_button: Suprimir el compte\naccount_deletion_immediate: Suprimir immediatament\nmore_from_domain: Més del domini\nerrors.server500.title: 500 Error intern del servidor\nerrors.server500.description: Ho sentim, hi ha hagut un error al nostre costat. \n  Si continueu veient l'error, proveu de contactar amb l'administració de la \n  instància. Si la instància no funciona en absolut, aneu a %link_start%altres \n  instàncies de Mbin%link_end% mentrestant fins que es resolga el problema.\nerrors.server429.title: 429 Massa sol·licituds\nerrors.server403.title: 403 Prohibit\nemail_confirm_button_text: Confirmeu la vostra sol·licitud de canvi de \n  contrasenya\nemail_confirm_link_help: Alternativament, podeu copiar i pegar el següent al \n  vostre navegador\nemail.delete.title: Sol·licitud d'eliminació del compte\nemail.delete.description: L'usuari(a) següent ha sol·licitat que s'elimine el \n  seu compte\nresend_account_activation_email_question: Compte inactiu?\nresend_account_activation_email: Tornar a enviar el correu electrònic \n  d'activació del compte\nresend_account_activation_email_success: Si existix un compte associat amb \n  l'adreça electrònica, hi enviarem un nou correu d'activació.\nresend_account_activation_email_description: Introduïu l'adreça electrònica \n  associada al vostre compte. Vos hi enviarem un altre correu d'activació.\noauth.consent.title: Formulari de consentiment OAuth2\noauth.consent.grant_permissions: Concedir permisos\noauth.consent.app_requesting_permissions: voldria realitzar les accions següents\n  en nom vostre\noauth.consent.to_allow_access: Per permetre este accés feu clic al botó \n  «Permetre” a continuació\noauth.consent.allow: Permetre\noauth.consent.deny: Denegar\noauth.client_identifier.invalid: Identificador de client OAuth no vàlid!\noauth.client_not_granted_message_read_permission: Esta aplicació no ha rebut \n  permís per llegir els vostres missatges.\nrestrict_oauth_clients: Restringir la creació de clients OAuth2 a \n  l'administració\nblock: Bloquejar\nunblock: Desbloquejar\noauth2.grant.moderate.magazine.reports.action: Acceptar o rebutjar denúncies a \n  les revistes que modereu.\noauth2.grant.moderate.magazine.trash.read: Veure el contingut a la paperera de \n  les revistes que modereu.\noauth2.grant.moderate.magazine_admin.all: Crear, editar o suprimir les vostres \n  revistes.\noauth2.grant.moderate.magazine_admin.create: Crear noves revistes.\noauth2.grant.moderate.magazine_admin.delete: Suprimir qualsevol de les vostres \n  revistes.\noauth2.grant.moderate.magazine_admin.update: Editar les regles, la descripció, \n  el mode explícit o la icona de les vostres revistes.\noauth2.grant.moderate.magazine_admin.moderators: Afegir o eliminar moderador(e)s\n  de qualsevol de les vostres revistes.\noauth2.grant.moderate.magazine_admin.badges: Crear o eliminar insígnies de les \n  vostres revistes.\noauth2.grant.moderate.magazine_admin.tags: Crear o eliminar etiquetes de les \n  vostres revistes.\noauth2.grant.admin.entry.purge: Suprimir completament qualsevol fil de la vostra\n  instància.\noauth2.grant.report.general: Denunciar fils, publicacions o comentaris.\noauth2.grant.vote.general: Votar a favor, en contra o impulsar els fils, \n  publicacions o comentaris.\noauth2.grant.subscribe.general: Subscriure-vos o seguir qualsevol revista, \n  domini o compte, i veure les revistes, dominis i comptes a què esteu \n  subscrit(e)s.\noauth2.grant.block.general: Bloquejar o desbloquejar qualsevol revista, domini o\n  compte i veure les revistes, dominis i comptes que heu bloquejat.\noauth2.grant.domain.all: Subscriure-vos o bloquejar dominis i veure els dominis \n  a què us subscriviu o que bloquegeu.\noauth2.grant.magazine.all: Subscriure-vos o bloquejar les revistes i veure les \n  revistes a què vos subscriviu o heu bloquejat.\noauth2.grant.magazine.subscribe: Subscriure-vos o cancel·lar la subscripció a \n  revistes i veure les revistes a què vos subscriviu.\noauth2.grant.magazine.block: Bloquejar o desbloquejar revistes i veure les \n  revistes que heu bloquejat.\noauth2.grant.post.all: Crear, editar o suprimir els vostres microblogs i votar, \n  impulsar o denunciar qualsevol microblog.\noauth2.grant.post.create: Crear publicacions noves.\noauth2.grant.post.edit: Editar les vostres publicacions existents.\noauth2.grant.post.delete: Suprimir les vostres publicacions existents.\noauth2.grant.post.vote: Votar a favor, impulsar o votar en contra de qualsevol \n  publicació.\noauth2.grant.post.report: Denunciar qualsevol publicació.\noauth2.grant.user.bookmark: Afegir i eliminar marcadors\noauth2.grant.user.bookmark.add: Afegir marcadors\noauth2.grant.user.bookmark.remove: Eliminar marcadors\noauth2.grant.user.bookmark_list: Veure, editar i suprimir les vostres llistes de\n  marcadors\noauth2.grant.user.bookmark_list.read: Veure les vostres llistes de marcadors\noauth2.grant.user.bookmark_list.edit: Editar les vostres llistes de marcadors\noauth2.grant.user.bookmark_list.delete: Esborrar les vostres llistes de \n  marcadors\noauth2.grant.user.profile.all: Llegir i editar el vostre perfil.\noauth2.grant.user.oauth_clients.edit: Editar els permisos que heu concedit a \n  altres aplicacions OAuth2.\noauth2.grant.user.follow: Seguir o deixar de seguir comptes i veure una llista \n  de comptes que seguiu.\noauth2.grant.moderate.entry_comment.set_adult: Marcar els comentaris als fils \n  com a explícits a les revistes que modereu.\noauth2.grant.moderate.entry_comment.trash: Ficar a la paperera o restaurar els \n  comentaris dels fils de les revistes que modereu.\noauth2.grant.moderate.post.set_adult: Marcar com a explícites les publicacions a\n  les revistes que modereu.\noauth2.grant.moderate.magazine.all: Gestionar els vets, les denúncies i veure \n  els articles a la paperera a les revistes que modereu.\noauth2.grant.moderate.magazine.ban.read: Veure els comptes vetats a les revistes\n  que modereu.\noauth2.grant.admin.user.ban: Vetar o retirar vet a comptes de la vostra \n  instància.\noauth2.grant.moderate.magazine.ban.create: Vetar comptes a les revistes que \n  modereu.\noauth2.grant.admin.entry_comment.purge: Suprimir completament qualsevol \n  comentari d'un fil de la vostra instància.\noauth2.grant.admin.magazine.purge: Suprimir completament les revistes de la \n  vostra instància.\noauth2.grant.admin.user.delete: Eliminar comptes de la vostra instància.\noauth2.grant.admin.user.purge: Eliminar completament comptes de la vostra \n  instància.\noauth2.grant.admin.instance.all: Veure i actualitzar la configuració o la \n  informació de la instància.\noauth2.grant.admin.user.verify: Verificar usuaris(es) a la vostra instància.\noauth2.grant.admin.instance.stats: Veure les estadístiques de la vostra \n  instància.\noauth2.grant.admin.instance.settings.read: Veure la configuració de la vostra \n  instància.\noauth2.grant.admin.instance.settings.edit: Actualitzar la configuració de la \n  vostra instància.\nflash_post_pin_success: La publicació s'ha fixat correctament.\nflash_post_unpin_success: La publicació s'ha desfixat correctament.\ncomment_reply_position_help: Mostrar el formulari de resposta de comentaris a la\n  part superior o inferior de la pàgina. Quan el «desplaçament infinit» està \n  habilitat, la posició sempre apareixerà a la part superior.\nshow_avatars_on_comments: Mostrar avatars als comentaris\nsingle_settings: Únic\nupdate_comment: Actualitzar comentari\nshow_avatars_on_comments_help: Mostrar/amagar els avatars quan es veuen \n  comentaris en un sol fil o publicació.\ncomment_reply_position: Posició del comentari de resposta\nmagazine_theme_appearance_custom_css: CSS personalitzat que s'aplicarà quan \n  visualitzeu contingut a la vostra revista.\nmagazine_theme_appearance_icon: Icona personalitzada per a la revista.\nmoderation.report.ban_user_title: Vetar compte\nmoderation.report.approve_report_confirmation: Confirmeu l'aprovació d'aquest \n  informe?\nsubject_reported_exists: Aquest contingut ja s'ha denunciat.\nmoderation.report.reject_report_confirmation: Confirmeu que voleu rebutjar \n  aquesta denúncia?\ndelete_content: Suprimir contingut\npurge_content: Purgar contingut\nschedule_delete_account_desc: Programar la supressió d'aquest compte en 30 dies.\n  Açò amagarà l'usuari(a) i el seu contingut, i també impedirà que inicie \n  sessió.\nremove_schedule_delete_account: Cancel·lar la supressió programada\ntwo_factor_authentication: Autenticació de dos factors\ntwo_factor_backup: Codis de recolzament d'autenticació de dos factors\n2fa.verify: Verificar\n2fa.code_invalid: El codi d'autenticació no és vàlid\n2fa.enable: Configurar l'autenticació de dos factors\n2fa.disable: Desactivar l'autenticació de dos factors\n2fa.backup: Els vostres codis de recolzament de dos factors\ndelete_account_desc: Suprimir el compte, incloses les respostes d'altres \n  usuaris(es) en fils, publicacions i comentaris creats.\n2fa.verify_authentication_code.label: Introduïu un codi de dos factors per \n  verificar la configuració\n2fa.qr_code_link.title: En visitar aquest enllaç permetreu a la vostra \n  plataforma registrar aquesta autenticació de dos factors\n2fa.user_active_tfa.title: L'usuari(a) té actiu el doble factor d'autenticació\n2fa.backup_codes.help: Podeu emprar estos codis quan no teniu el vostre \n  dispositiu o aplicació d'autenticació de dos factors. <strong>No se vos \n  tornaran a mostrar</strong> i els podreu fer servir <strong>només una \n  vegada</strong>.\n2fa.backup_codes.recommendation: Guardeu-ne una còpia en un lloc segur.\nflash_account_settings_changed: La configuració del vostre compte s'ha canviat \n  correctament. Haureu de tornar a iniciar sessió.\nsubscriptions_in_own_sidebar: A una barra lateral separada\nsidebars_same_side: Barres laterals al mateix costat\nsubscription_sidebar_pop_out_right: Moure a la barra lateral separada de la \n  dreta\nsubscription_sidebar_pop_out_left: Moure a la barra lateral separada de \n  l'esquerra\nsubscription_sidebar_pop_in: Moure subscripcions al panell emergent\nsubscription_panel_large: Panell gran\nsubscription_header: Revistes subscrites\nclose: Tancar\nposition_top: Superior\npending: Pendent\nflash_thread_new_error: No s'ha pogut crear el fil. Alguna cosa ha fallat.\nflash_thread_tag_banned_error: No s'ha pogut crear el fil. El contingut no està \n  permès.\nflash_image_download_too_large_error: La imatge no s'ha pogut crear, és massa \n  gran (grandària màxima %bytes%)\nflash_email_was_sent: El correu electrònic s'ha enviat correctament.\nflash_post_new_error: No s'ha pogut crear la publicació. Alguna cosa ha fallat.\nflash_magazine_theme_changed_success: S'ha actualitzat correctament l'aparença \n  de la revista.\nflash_magazine_theme_changed_error: No s'ha pogut actualitzar l'aparença de la \n  revista.\nflash_comment_new_success: El comentari s'ha creat correctament.\nflash_comment_edit_success: El comentari s'ha actualitzat correctament.\nflash_comment_new_error: No s'ha pogut crear el comentari. Alguna cosa ha \n  fallat.\nflash_user_settings_general_error: No s'ha pogut guardar la configuració \n  d'usuari(a).\nflash_user_edit_profile_error: No s'ha pogut guardar la configuració del perfil.\nflash_user_edit_profile_success: La configuració del perfil s'ha guardat \n  correctament.\nflash_user_edit_email_error: No s'ha pogut canviar l'adreça electrònica.\nflash_user_edit_password_error: No s'ha pogut canviar la contrasenya.\nflash_thread_edit_error: No s'ha pogut editar el fil. Alguna cosa ha fallat.\nflash_post_edit_success: La publicació s'ha editat correctament.\npage_width: Amplària de pàgina\npage_width_max: Màxim\npage_width_auto: Automàtic\npage_width_fixed: Fix\nfilter_labels: Filtrar etiquetes\nauto: Automàtic\nopen_url_to_fediverse: Obrir URL original\nchange_my_avatar: Canviar el meu avatar\nchange_my_cover: Canviar la meua portada\nedit_my_profile: Editar el meu perfil\naccount_settings_changed: La configuració del vostre compte s'ha canviat \n  correctament. Haureu de tornar a iniciar sessió.\nmagazine_deletion: Eliminació de la revista\ndelete_magazine: Suprimir revista\nrestore_magazine: Recuperar revista\npurge_magazine: Purgar revista\nmagazine_is_deleted: S'ha suprimit la revista. Podeu <a \n  href=\"%link_target%\">recuperar-la</a> en un termini de 30 dies.\nsuspend_account: Suspendre el compte\nunsuspend_account: Reactivar el compte\naccount_suspended: El compte s'ha suspès.\ndeletion: Eliminació\naccount_unbanned: S'ha retirat el vet al compte.\naccount_is_suspended: El compte està suspès.\nremove_subscriptions: Eliminar subscripcions\napply_for_moderator: Sol·licitar ser moderador(a)\ncancel_request: Cancel·lar sol·licitud\naction: Acció\nuser_badge_op: OP\nuser_badge_admin: Administrador(a)\nuser_badge_global_moderator: Moderador(a) global\nuser_badge_moderator: Moderador(a)\nuser_badge_bot: Bot\nannouncement: Anunci\ndeleted_by_author: L'autor(a) ha eliminat el fil, la publicació o el comentari\nsensitive_toggle: Commutar la visibilitat del contingut sensible\nsensitive_hide: Feu clic per amagar\ndetails: Detalls\nedited: editat\nsso_registrations_enabled: Registres SSO activats\nsso_only_mode: Restringir l'inici de sessió i el registre només als mètodes SSO\nrelated_entry: Relacionat\nsso_show_first: Mostrar primer SSO a les pàgines d'inici de sessió i de registre\nreporting_user: Denunciant\nreported: denunciat(da)\nown_content_reported_accepted: S'ha acceptat una denúncia del vostre contingut.\nopen_report: Obrir denúncia\ncake_day: Des del dia\nsomeone: Algú\nmagazine_log_mod_added: ha afegit un(a) moderador(a)\nmagazine_log_entry_unpinned: s'ha eliminat l'entrada fixada\nunregister_push_notifications_button: Eliminar el registre de «push»\ntest_push_notifications_button: Provar les notificacions «push»\nnotification_title_removed_comment: S'ha eliminat un comentari\nnotification_title_edited_comment: S'ha editat un comentari\nnotification_title_mention: Vos han esmentat\nnotification_title_new_reply: Nova resposta\nnotification_title_new_thread: Nou fil\nnotification_title_removed_thread: S'ha eliminat un fil\nnotification_title_ban: Vos han vetat\nnotification_title_edited_thread: S'ha editat un fil\nnotification_body2_new_signup_approval: Heu d'aprovar la sol·licitud abans que \n  puguen iniciar sessió\nshow_related_magazines: Mostrar revistes aleatòries\nshow_related_entries: Mostrar fils aleatoris\nshow_related_posts: Mostrar publicacions aleatòries\nshow_active_users: Mostrar comptes actius\nflash_posting_restricted_error: La creació de fils està restringida a la \n  moderació d'aquesta revista i no en sou part\nserver_software: Programari del servidor\nversion: Versió\nlast_successful_deliver: Última entrega correcta\nlast_successful_receive: Última rebuda correcta\nlast_failed_contact: Últim contacte fallit\nmagazine_posting_restricted_to_mods: Restringir la creació de fils a la \n  moderació\nadmin_users_banned: Vetats(des)\nadmin_users_active: Actius(ves)\nadmin_users_suspended: Suspesos(es)\nuser_verify: Activar compte\nmax_image_size: Grandària màxima del fitxer\ncomment_not_found: No s'ha trobat el comentari\nbookmark_remove_all: Eliminar tots els marcadors\ncount: Recompte\nis_default: És predeterminada\nbookmark_list_is_default: És la llista predeterminada\nbookmark_list_create: Crear\nbookmark_list_create_placeholder: escriviu el nom…\nbookmarks_list_edit: Editar la llista de marcadors\nsearch_type_entry: Fils\nsearch_type_post: Microblogs\nselect_user: Trieu un(a) usuari(a)\nsignup_requests: Sol·licituds de registre\napplication_text: Expliqueu per què voleu unir-vos\nsignup_requests_header: Sol·licituds de registre\nsignup_requests_paragraph: A estos(es) usuaris(es) els agradaria unir-se al \n  vostre servidor. No poden iniciar sessió fins que no hàgeu aprovat llurs \n  sol·licituds de registre.\nemail_application_approved_title: La vostra sol·licitud de registre s'ha aprovat\nemail_application_approved_body: L'administració del servidor ha aprovat la \n  vostra sol·licitud de registre. Ara podeu iniciar sessió al servidor a <a \n  href=\"%link%\">%siteName%</a>.\nemail_application_rejected_title: La vostra sol·licitud de registre ha sigut \n  rebutjada\nshow_magazine_domains: Mostrar els dominis de les revistes\nshow_user_domains: Mostrar els dominis dels comptes\nimage_lightbox_in_list: Les miniatures dels fils obren pantalla completa\nshow_magazines_icons_help: Mostrar la icona de la revista.\nshow_thumbnails_help: Mostrar les miniatures de les imatges.\nimage_lightbox_in_list_help: Quan està marcat, en fer clic a la miniatura es \n  mostra una finestra modal amb la imatge. Quan no estiga marcat, fer clic a la \n  miniatura obrirà el fil.\nshow_new_icons: Mostrar noves icones\nshow_new_icons_help: Mostrar la icona per a la revista o el compte nou (30 dies \n  d'antiguitat o més recent)\noff: Apagat\nnote: Nota\nheader_logo: Logotip de la capçalera\nunmark_as_adult: Desmarcar com a explícit\nlocal: Local\narticle: Fil\nmonth: Mes\nreputation: Reputació\nyear: Any\nregistrations_enabled: Registre activat\nFAQ: Preguntes més freqüents (PMF)\nmonths: Mesos\ndashboard: Tauler de control\nfederated: Federat\ncontact_email: Adreça electrònica de contacte\ninstance: Instància\nyour_account_is_not_yet_approved: El vostre compte encara no s'ha aprovat. \n  Enviarem un correu electrònic quan l'administració haja processat la vostra \n  sol·licitud de registre.\ntoolbar.link: Enllaç\non: Encés\npurge: Buidar la llista\nreject: Rebutjar\ninstances: Instàncies\nfrom_url: Des de l'URL\ntype_search_term: Escriviu el terme de cerca\nbrowsing_one_thread: Només esteu navegant per un fil de la discussió! Tots els \n  comentaris estan disponibles a la pàgina de publicació.\ntoolbar.strikethrough: Ratllat\nsend_message: Enviar missatge directe\nadmin_panel: Tauler d'administració\nfilter.origin.label: Trieu l'origen\nmeta: Meta\nkbin_promo_desc: '%link_start%Cloneu el repositori%link_end% i desenvolupeu fedivers'\nsidebar_position: Posició de la barra lateral\npreview: Previsualitzar\nregistration_disabled: Registre desactivat\npurge_account: Purgar el compte\nfilters: Filtres\nweeks: Setmanes\npinned: Fixat\nban_hashtag_description: Vetar un hashtag impedirà que es creen publicacions amb\n  este hashtag i amagarà les publicacions existents que el tinguen.\nweek: Setmana\nsticky_navbar: Barra de navegació fixa\nfederation_enabled: Federació activada\ncontent: Contingut\nmagazine_panel_tags_info: Indiqueu-ho només si voleu que el contingut del \n  fedivers s'incloga en esta revista segons les etiquetes\nmagazine_panel: Panell de la revista\nexpired_at: Caducà el\npassword_confirm_header: Confirmeu la vostra sol·licitud de canvi de \n  contrasenya.\ntoolbar.header: Capçalera\ntoolbar.quote: Cita\nfilter.fields.names_and_descriptions: Noms i descripcions\ndynamic_lists: Llistes dinàmiques\npages: Pàgines\nsidebar: Barra lateral\nreport_issue: Denunciar problema\nfilter.fields.only_names: Només noms\nauto_preview: Vista prèvia automàtica dels mitjans\nfilter.adult.show: Mostrar el contingut explícit\nkbin_intro_desc: és una plataforma descentralitzada per a l'agregació de \n  continguts i microblogging que opera dins de la xarxa fedivers.\nviewing_one_signup_request: Només esteu veient una sol·licitud de registre de \n  %username%\nyour_account_is_not_active: El vostre compte no s'ha activat. Comproveu la \n  vostra bústia electrònica per obtenir instruccions d'activació del compte o <a\n  href=\"%link_target%\">sol·liciteu un correu electrònic d'activació del compte \n  nou.</a>\ntoolbar.italic: Itàlica\ntoolbar.code: Codi\noauth2.grant.moderate.magazine.ban.all: Gestionar els comptes vetats a les \n  revistes que modereu.\naccount_deletion_description: El vostre compte se suprimirà d'aquí a 30 dies \n  tret que decidiu suprimir-lo immediatament. Per restaurar el vostre compte en \n  un termini de 30 dies, inicieu sessió amb les mateixes credencials o poseu-vos\n  en contacte amb l'equip d'administració.\noauth2.grant.moderate.magazine_admin.edit_theme: Editar el CSS personalitzat de \n  qualsevol de les vostres revistes.\noauth2.grant.admin.all: Realitzar qualsevol acció administrativa sobre la vostra\n  instància.\noauth2.grant.moderate.entry.all: Moderar els fils a les revistes que modereu.\noauth2.grant.entry_comment.all: Crear, editar o suprimir els vostres comentaris \n  en fils i votar, millorar o denunciar qualsevol comentari d'un fil.\noauth2.grant.user.notification.read: Llegir les vostres notificacions, incloses \n  les notificacions de missatges.\noauth2.grant.delete.general: Suprimir qualsevol dels vostres fils, publicacions \n  o comentaris.\nfederation_page_dead_title: Instàncies mortes\noauth2.grant.entry.delete: Suprimir els vostres fils existents.\nfederation_page_dead_description: Instàncies en què no vam poder entregar \n  almenys 10 activitats seguides i on l'última entrega i recepció amb èxit van \n  ser fa més d'una setmana\ncustom_css: CSS personalitzat\noauth2.grant.domain.block: Bloquejar o desbloquejar dominis i veure els dominis \n  que heu bloquejat.\noauth2.grant.moderate.entry_comment.all: Moderar els comentaris als fils de les \n  revistes que modereu.\noauth2.grant.domain.subscribe: Subscriure-vos o cancel·lar la subscripció als \n  dominis i veure els dominis a què vos subscriviu.\noauth2.grant.post_comment.vote: Votar a favor, impulsar o votar en contra de \n  qualsevol comentari d'una publicació.\noauth2.grant.moderate.magazine_admin.stats: Veure el contingut, votar i \n  consultar les estadístiques de les vostres revistes.\noauth2.grant.admin.magazine.all: Moure fils entre les revistes o suprimir-les \n  completament a la vostra instància.\nshow_subscriptions: Mostrar subscripcions\noauth2.grant.entry.all: Crear, editar o suprimir els vostres fils i votar, \n  impulsar o denunciar qualsevol fil.\nfederation_page_disallowed_description: Instàncies amb què no ens federem\nresend_account_activation_email_error: Hi ha hagut un problema en enviar la \n  sol·licitud. Potser no hi ha cap compte associat amb l'adreça electrònica o \n  potser ja està activat.\nignore_magazines_custom_css: Ignorar el CSS personalitzat de les revistes\nerrors.server404.title: 404 No trobat\nprivate_instance: Forçar a iniciar sessió abans de poder accedir a qualsevol \n  contingut\noauth2.grant.user.notification.all: Llegir i eliminar les vostres notificacions.\noauth2.grant.write.general: Crear o editar qualsevol dels vostres fils, \n  publicacions o comentaris.\noauth2.grant.admin.user.all: Vetar, verificar o suprimir completament comptes de\n  la vostra instància.\nalphabetically: Alfabèticament\noauth2.grant.moderate.magazine.ban.delete: Retirar vet a usuaris(es) de les \n  revistes que modereu.\noauth.consent.app_has_permissions: ja pot realitzar les accions següents\noauth2.grant.moderate.magazine.list: Mostrar la llista de les revistes que \n  modereu.\noauth2.grant.user.all: Veure i editar el vostre perfil, missatges o \n  notificacions; veure i editar els permisos que heu concedit a altres \n  aplicacions; seguir o bloquejar altres comptes; veure les llistes de comptes \n  que seguiu o bloquegeu.\nposition_bottom: Inferior\noauth2.grant.moderate.magazine.reports.all: Gestionar les denúncies a les \n  revistes que modereu.\nflash_post_edit_error: No s'ha pogut editar la publicació. Alguna cosa ha \n  fallat.\ncontinue_with: Continuar amb\noauth2.grant.moderate.magazine.reports.read: Mostrar les denúncies a les \n  revistes que modereu.\noauth2.grant.entry_comment.edit: Editar els vostres comentaris existents als \n  fils.\noauth2.grant.read.general: Llegir tot el contingut a què tingueu accés.\noauth2.grant.entry.create: Crear fils nous.\noauth2.grant.entry_comment.create: Crear comentaris nous en fils.\noauth2.grant.entry_comment.vote: Votar a favor, impulsar o votar en contra de \n  qualsevol comentari d'un fil.\noauth2.grant.entry.edit: Editar els vostres fils existents.\noauth2.grant.entry_comment.delete: Suprimir els vostres comentaris existents als\n  fils.\noauth2.grant.entry_comment.report: Denunciar qualsevol comentari en un fil.\noauth2.grant.moderate.post.change_language: Canviar l'idioma de les publicacions\n  a les revistes que modereu.\nmoderation.report.reject_report_title: Rebutjar la denúncia\nand: i\noauth2.grant.entry.vote: Votar a favor, impulsar o votar en contra de qualsevol \n  fil.\noauth2.grant.moderate.entry.set_adult: Marcar els fils com a explícits a les \n  revistes que modereu.\noauth2.grant.entry.report: Denunciar qualsevol fil.\noauth2.grant.post_comment.edit: Editar els vostres comentaris existents a les \n  publicacions.\nreport_subject: Assumpte\noauth2.grant.post_comment.delete: Suprimir els vostres comentaris existents a \n  les publicacions.\noauth2.grant.post_comment.report: Denunciar qualsevol comentari en una \n  publicació.\noauth2.grant.user.message.create: Enviar missatges a altres usuaris(es).\noauth2.grant.user.message.read: Llegir els vostres missatges.\noauth2.grant.user.oauth_clients.all: Veure i editar els permisos que heu \n  concedit a altres aplicacions OAuth2.\noauth2.grant.moderate.entry_comment.change_language: Canviar l'idioma dels \n  comentaris als fils de les revistes que modereu.\noauth2.grant.moderate.post_comment.trash: Ficar a la paperera o restaurar els \n  comentaris a les publicacions de les revistes que modereu.\noauth2.grant.post_comment.all: Crear, editar o suprimir els vostres comentaris a\n  les publicacions i votar, impulsar o denunciar qualsevol comentari en una \n  publicació.\noauth2.grant.post_comment.create: Crear comentaris nous a les publicacions.\noauth2.grant.user.profile.read: Veure el vostre perfil.\noauth2.grant.moderate.post_comment.change_language: Canviar l'idioma dels \n  comentaris a les publicacions de les revistes que modereu.\noauth2.grant.user.profile.edit: Editar el vostre perfil.\noauth2.grant.user.block: Bloquejar o desbloquejar comptes i veure una llista de \n  comptes que bloquegeu.\noauth2.grant.moderate.post.all: Moderar les publicacions a les revistes que \n  modereu.\noauth2.grant.moderate.post.trash: Ficar a la paperera o restaurar les \n  publicacions de les revistes que modereu.\noauth2.grant.admin.post_comment.purge: Suprimir completament qualsevol comentari\n  d'una publicació de la vostra instància.\nsensitive_show: Feu clic per mostrar\noauth2.grant.user.message.all: Llegir els vostres missatges i enviar missatges a\n  altres usuaris(es).\noauth2.grant.user.notification.delete: Esborrar les vostres notificacions.\noauth2.grant.admin.federation.all: Veure i actualitzar les instàncies \n  desfederades actualment.\n2fa.backup-create.label: Crear codis d'autenticació de recolzament nous\n2fa.remove: Eliminar l'autenticació de dos factors\noauth2.grant.user.oauth_clients.read: Veure els permisos que heu concedit a \n  altres aplicacions OAuth2.\nremove_schedule_delete_account_desc: Cancel·lar la programació de l'eliminació. \n  Tot el contingut tornarà a estar disponible i el compte podrà iniciar sessió.\n2fa.qr_code_img.alt: Un codi QR que permet configurar l'autenticació de dos \n  factors per al vostre compte\nnotification_title_new_post: Publicació nova\noauth2.grant.moderate.all: Realitzar qualsevol acció de moderació que tingueu \n  permís per dur a terme a les revistes que modereu.\noauth2.grant.moderate.entry.change_language: Canviar l'idioma dels fils a les \n  revistes que modereu.\noauth2.grant.moderate.entry.pin: Fixar els fils a la part superior de les \n  revistes que modereu.\nrequest_magazine_ownership: Demanar la propietat de la revista\nreported_user: Compte denunciat\nlast_updated: Última actualització\noauth2.grant.moderate.entry.trash: Ficar a la paperera o restaurar fils a les \n  revistes que modereu.\noauth2.grant.admin.federation.update: Afegir o eliminar instàncies de la llista \n  d'instàncies desfederades.\noauth2.grant.moderate.post_comment.all: Moderar els comentaris a les \n  publicacions de les revistes que modereu.\noauth2.grant.moderate.post_comment.set_adult: Marcar com a explícits els \n  comentaris de les publicacions a les revistes que modereu.\noauth2.grant.admin.federation.read: Veure la llista d'instàncies desfederades.\nall_time: Tot el temps\nemail_application_rejected_body: Gràcies pel vostre interés, però lamentem \n  informar-vos que la vostra sol·licitud de registre ha sigut rebutjada.\nmoderation.report.ban_user_description: Voleu vetar el compte (%username%) que \n  ha creat aquest contingut d'aquesta revista?\nabandoned: Abandonat\noauth2.grant.admin.magazine.move_entry: Moure fils entre revistes a la vostra \n  instància.\nlast_active: Última activitat\nmoderation.report.approve_report_title: Aprovar la denúncia\n2fa.add: Afegir al meu compte\noauth2.grant.admin.oauth_clients.all: Veure o revocar els clients OAuth2 que \n  existeixen a la vostra instància.\nbookmark_add_to_list: Afegir marcador a %list%\noauth2.grant.admin.post.purge: Suprimir completament qualsevol publicació de la \n  vostra instància.\nuser_suspend_desc: En suspendre el compte s'amaga el contingut a la instància, \n  però no l'elimina permanentment i el podeu restaurar en qualsevol moment.\nmagazine_theme_appearance_background_image: Imatge de fons personalitzada que \n  s'aplicarà quan visualitzeu contingut a la vostra revista.\naccount_unsuspended: El compte s'ha reactivat.\nshow: Mostrar\noauth2.grant.admin.instance.settings.all: Veure o actualitzar la configuració de\n  la vostra instància.\nshow_users_avatars_help: Mostrar la imatge de l'avatar de l'usuari(a).\noauth2.grant.admin.instance.information.edit: Actualitzar les pàgines Quant a, \n  Preguntes freqüents, Contacte, Condicions del servei i Política de privadesa a\n  la vostra instància.\nkeywords: Paraules clau\noauth2.grant.admin.oauth_clients.read: Veure els clients OAuth2 que existeixen a\n  la vostra instància i les seves estadístiques d'ús.\nback: Tornar\nbookmark_add_to_default_list: Afegir marcador a la llista predeterminada\noauth2.grant.admin.oauth_clients.revoke: Revocar l'accés als clients OAuth2 a la\n  vostra instància.\noauth2.grant.moderate.post.pin: Fixar les publicacions a la part superior de les\n  revistes que modereu.\n2fa.available_apps: Utilitzar una aplicació d'autenticació de dos factors com \n  ara %google_authenticator%, %aegis% (Android) o %raivo% (iOS) per escanejar el\n  codi QR.\nfront_default_sort: Ordenació predeterminada de la portada\npurge_content_desc: Purgar completament el contingut de l'usuari(a), incloent la\n  supressió de les respostes d'altres usuaris(es) en fils, publicacions i \n  comentaris creats.\nflash_post_new_success: La publicació s'ha creat correctament.\nschedule_delete_account: Programar eliminació\n2fa.authentication_code.label: Codi d'autenticació\ncomment_default_sort: Ordenació predeterminada dels comentaris\n2fa.setup_error: Error en activar A2F per al compte\n2fa.backup-create.help: Podeu crear nous codis d'autenticació de recolzament; \n  fer-ho invalidarà els codis existents.\nsubscription_sort: Ordenar\nemail_application_pending: El vostre compte requerix l'aprovació de \n  l'administració abans de poder iniciar sessió.\ncancel: Cancel·lar\nnotification_title_removed_post: S'ha eliminat una publicació\npassword_and_2fa: Contrasenya i A2F\nflash_email_failed_to_sent: No s'ha pogut enviar el correu electrònic.\nbookmark_remove_from_list: Eliminar el marcador de %list%\nflash_comment_edit_error: No s'ha pogut editar el comentari. Alguna cosa ha \n  fallat.\nflash_user_settings_general_success: La configuració d'usuari(a) s'ha desat \n  correctament.\naccept: Acceptar\nremove_following: Eliminar el seguiment\nsensitive_warning: Contingut sensible\nownership_requests: Sol·licituds de propietat\nmoderator_requests: Sol·licituds de moderació\nnotification_title_edited_post: S'ha editat una publicació\ndeleted_by_moderator: El fil, la publicació o el comentari ha sigut suprimit per\n  l'equip de moderació\nspoiler: Spoiler\nhide: Amagar\nsso_registrations_enabled.error: Els registres de comptes nous amb gestors \n  d'identitats de tercers estan actualment desactivats.\ncompact_view_help: Una vista compacta amb marges menors, on la miniatura passa \n  al costat dret.\nrestrict_magazine_creation: Restringir la creació de revistes locals a \n  l'administració i moderació global\nopen_signup_request: Obrir la sol·licitud de registre\nown_report_accepted: La vostra denúncia ha sigut acceptada\nreport_accepted: S'ha acceptat una denúncia\nmagazine_log_mod_removed: ha llevat un(a) moderador(a)\nnotification_title_new_comment: Nou comentari\nmagazine_log_entry_pinned: entrada fixada\nby: per\ndirect_message: Missatge directe\nmanually_approves_followers: Aprova seguidors(es) manualment\nregister_push_notifications_button: Registreu-vos per a les notificacions «push»\ntest_push_message: Hola món!\nnotification_title_message: Nou missatge directe\nbookmark_list_create_label: Nom de la llista\nnotification_title_new_signup: S'ha registrat un nou compte\nnotification_title_new_report: S'ha creat una nova denúncia\nnotification_body_new_signup: S'ha registrat el compte %u%.\nbookmark_list_make_default: Fer predeterminada\nbookmark_lists: Llistes de marcadors\nbookmarks: Marcadors\nbookmark_list_edit: Editar\nbookmark_list_selected_list: Llista seleccionada\nbot_body_content: \"Benvinguda a l'agent Mbin! Aquest agent té un paper crucial per\n  habilitar la funcionalitat d'ActivityPub dins de Mbin. Assegura que Mbin es puga\n  comunicar i federar amb altres instàncies del fedivers.\\n\\nActivityPub és un protocol\n  estàndard obert que permet que les plataformes de xarxes socials descentralitzades\n  es comuniquen i interactuen entre elles. Permet a usuari(e)s de diferents instàncies\n  (servidors) seguir, interactuar i compartir contingut a través de la xarxa social\n  federada coneguda com a fedivers. Proporciona una manera estandarditzada per publicar\n  contingut, seguir altres usuaris(es) i participar en interaccions socials, com ara\n  fer m'agrada, compartir i comentar fils o publicacions.\"\ndelete_content_desc: Suprimir el contingut de l'usuari(a) deixant les respostes \n  d'altres usuaris(es) als fils, publicacions i comentaris creats.\nmagazine_posting_restricted_to_mods_warning: Només la moderació pot crear fils \n  en aquesta revista\nnew_user_description: Aquest compte és nou (actiu durant menys de %days% dies)\nnew_users_need_approval: Els comptes nous han de ser aprovats per \n  l'administració abans que puguen iniciar sessió.\nnew_magazine_description: Aquesta revista és nova (activa durant menys de %days%\n  dies)\nadmin_users_inactive: Inactius(ves)\nbookmarks_list: Marcadors en %list%\ntable_of_contents: Taula de continguts\nemail_verification_pending: Heu de verificar la vostra adreça electrònica abans \n  de poder iniciar sessió.\nsearch_type_all: Tot\nflash_application_info: L'administració ha d'aprovar el vostre compte abans de \n  poder iniciar sessió. Rebreu un correu electrònic quan s'haja processat la \n  vostra sol·licitud de registre.\naccount_banned: El compte ha sigut vetat.\nanswered: respost\nown_report_rejected: La vostra denúncia ha sigut rebutjada\n2fa.manual_code_hint: Si no podeu escanejar el codi QR, introduïu el secret \n  manualment\ntoolbar.emoji: Emoji\nmagazine_instance_defederated_info: La instància d'esta revista està \n  desfederada. Per tant, la revista no rebrà actualitzacions.\nuser_instance_defederated_info: La instància d'este compte està defederada.\nflash_thread_instance_banned: La instància d'esta revista està banejada.\nshow_rich_mention: Mencions enriquides\nshow_rich_mention_help: Mostrar un component de compte quan es menciona un \n  compte. Això n'inclourà el nom de visualització i la foto de perfil.\nshow_rich_mention_magazine: Mencions enriquides de revistes\nshow_rich_mention_magazine_help: Mostrar un component de revista quan es \n  menciona una revista. Això in'nclourà el nom de visualització i la icona.\nshow_rich_ap_link: Enllaços AP enriquits\nshow_rich_ap_link_help: Mostrar un component en línia quan s'hi enllaça un altre\n  contingut d'ActivityPub.\nattitude: Actitud\ntype_search_term_url_handle: Escriviu el terme de cerca, l'URL o l'identificador\nsearch_type_magazine: Revistes\nsearch_type_user: Comptes\nsearch_type_actors: Revistes i comptes\nsearch_type_content: Temes i microblogs\ntype_search_magazine: Limitar la cerca a la revista...\ntype_search_user: Limitar la cerca a l'autoria...\nmodlog_type_entry_deleted: Fil suprimit\nmodlog_type_entry_restored: Fil restaurat\nmodlog_type_entry_comment_deleted: Comentari del fil suprimit\nmodlog_type_entry_comment_restored: Comentari del fil restaurat\nmodlog_type_entry_pinned: Fil fixat\nmodlog_type_entry_unpinned: Fil deixat de fixar\nmodlog_type_post_deleted: Microblog suprimit\nmodlog_type_post_restored: Microblog restaurat\nmodlog_type_post_comment_deleted: Resposta del microblog suprimida\nmodlog_type_post_comment_restored: Resposta del microblog restaurada\nmodlog_type_ban: Compte expulsat de la revista\nmodlog_type_moderator_add: Moderador(a) de la revista afegit(da)\nmodlog_type_moderator_remove: Moderador(a) de la revista destituït(da)\neveryone: Tot el món\nnobody: Ningú\nfollowers_only: Només seguidor(e)s\ndirect_message_setting_label: Qui pot enviar-vos un missatge directe\nbanner: Bàner\nmagazine_theme_appearance_banner: Bàner personalitzat per a la revista. Es \n  mostra damunt de tots els fils de discussió i ha de tindre una relació \n  d'aspecte ampla (5:1 ó 1500 px * 300 px).\ndelete_magazine_icon: Suprimix icona de la revista\nflash_magazine_theme_icon_detached_success: La icona de la revista s'ha suprimit\n  correctament\ndelete_magazine_banner: Suprimix el bàner de la revista\nflash_magazine_theme_banner_detached_success: El bàner de la revista s'ha \n  suprimit correctament\ncrosspost: Publicació creuada\nflash_thread_ref_image_not_found: No s'ha pogut trobar la imatge a què fa \n  referència 'imageHash'.\nfederation_uses_allowlist: Utilitzar la llista de permesos per a la federació\ndefederating_instance: S'està defederant la instància %i\ntheir_user_follows: Quantitat de comptes de la seua instància que seguixen \n  comptes de la nostra\nour_user_follows: Quantitat de comptes de la nostra instància que seguixen \n  comptes de la seua\ntheir_magazine_subscriptions: Quantitat de comptes de la seua instància \n  subscrits a revistes de la nostra\nour_magazine_subscriptions: Quantitat de comptes de la nostra instància \n  subscrits a revistes des de la seua\nconfirm_defederation: Confirmar la desfederació\nflash_error_defederation_must_confirm: Heu de confirmar la desfederació\nallowed_instances: Instàncies permeses\nbtn_deny: Denegar\nbtn_allow: Permetre\nban_instance: Prohibir instància\nallow_instance: Permetre instància\nfederation_page_use_allowlist_help: Si s'utilitza una llista de permesos, esta \n  instància només es federarà amb les instàncies explícitament permeses. En cas \n  contrari, esta instància es federarà amb totes les instàncies, excepte les que\n  estiguen prohibides.\nfront_default_content: Vista per defecte de portada\ndefault_content_default: Valor per defecte del servidor (Fils)\ndefault_content_threads: Fils\ndefault_content_microblog: Microblog\ncombined: Combinat\nsidebar_sections_random_local_only: Restringir les seccions de la barra lateral \n  «Publicacions/Fils aleatoris» només a locals\nsidebar_sections_users_local_only: Restringir la secció de la barra lateral \n  «Persones actives» només a locals\nrandom_local_only_performance_warning: Habilitar «Només aleatoris locals» pot \n  afectar el rendiment de l'SQL.\ndefault_content_combined: Fils + Microblog\nban_expires: La prohibició caduca\nyou_have_been_banned_from_magazine: Vos han prohibit l'accés a la revista %m.\nyou_have_been_banned_from_magazine_permanently: Vos han prohibit permanentment \n  l'accés a la revista %m.\nyou_are_no_longer_banned_from_magazine: Ja no teniu prohibit l'accés a la \n  revista %m.\noauth2.grant.moderate.entry.lock: Bloqueja els fils de les revistes moderades \n  perquè ningú no hi puga fer comentaris\noauth2.grant.moderate.post.lock: Bloqueja els microblogs a les revistes \n  moderades, perquè ningú no hi puga fer comentaris\ndiscoverable: Descobrible\nuser_discoverable_help: Si esta opció està habilitada, el vostre perfil, fils de\n  discussió, microblogs i comentaris es poden trobar mitjançant la cerca i els \n  panells aleatoris. El vostre perfil també pot aparèixer al panell d'usuari(a) \n  actiu(va) i a la pàgina de persones. Si esta opció està desactivada, les \n  vostres publicacions continuaran sent visibles per a altres usuari(e)s, però \n  no apareixeran al canal complet.\nmagazine_discoverable_help: Si això està habilitat, esta revista i els fils, \n  microblogs i comentaris d'esta revista es poden trobar mitjançant la busca i \n  els panells aleatoris. Si això està desactivat, la revista encara apareixerà a\n  la llista de revistes, però els fils i microblogs no apareixeran al canal \n  complet.\nflash_thread_lock_success: Fil bloquejat correctament\nflash_thread_unlock_success: Fil desbloquejat correctament\nflash_post_lock_success: Microblog bloquejat correctament\nflash_post_unlock_success: Microblog desbloquejat correctament\nlock: Bloquejar\nunlock: Desbloquejar\ncomments_locked: Els comentaris estan bloquejats.\nmagazine_log_entry_locked: ha bloquejat els comentaris de\nmagazine_log_entry_unlocked: ha desbloquejat els comentaris de\nmodlog_type_entry_lock: Fil bloquejat\nmodlog_type_entry_unlock: Fil desbloquejat\nmodlog_type_post_lock: Microblog bloquejat\nmodlog_type_post_unlock: Microblog desbloquejat\ncontentnotification.muted: Silenciós | no rebre notificacions\ncontentnotification.default: Predeterminat | rebre notificacions segons la \n  configuració predeterminada\ncontentnotification.loud: Sorollós | rebre totes les notificacions\nindexable_by_search_engines: Indexable pels motors de cerca\nuser_indexable_by_search_engines_help: Si esta configuració es desactiva, es \n  recomana als motors de cerca que no indexen cap dels vostres fils i \n  microblogs, però els vostres comentaris no es veuen afectats per això i els \n  malfactors podrien ignorar-la. Esta configuració també està federada a altres \n  servidors.\nmagazine_indexable_by_search_engines_help: Si esta configuració es desactiva, es\n  recomana als motors de cerca que no indexen cap dels fils i microblogs d'estes\n  revistes. Açò inclou la pàgina de destinació i totes les pàgines de \n  comentaris. Esta configuració també està federada a d'altres servidors.\nmagazine_name_as_tag: Empra el nom de la revista com a etiqueta\nmagazine_name_as_tag_help: Les etiquetes d'una revista s'utilitzen per fer \n  coincidir les entrades de microblog amb esta revista. Per exemple, si el nom \n  és \"fediverse\" i les etiquetes de la revista contenen \"fediverse\", totes les \n  entrades de microblog que continguen \"#fediverse\" es posaran en esta revista.\nmagazine_rules_deprecated: el camp de regles està obsolet i s'eliminarà en el \n  futur. Per favor, poseu les vostres regles al quadre de descripció.\ncreated_since: Creat des de\n"
  },
  {
    "path": "translations/messages.da.yaml",
    "content": "type.link: Link\ntype.article: Tråd\ntype.photo: Foto\ntype.video: Video\ntype.smart_contract: intelligent kontrakt\ntype.magazine: Magasin\nthread: Tråd\npeople: Folk\nevents: Begivenheder\nmagazine: Magasin\nmagazines: Magasiner\nsearch: Søg\nselect_channel: Vælge en kanal\nlogin: Log ind\nhot: Heftig\nactive: Aktiv\nnewest: Nyeste\noldest: Ældste\ncommented: Kommenterede\nfilter_by_time: Filtrer efter tid\nfilter_by_type: Filtrer efter type\ncomments_count: '{0}Kommentarer|{1}Kommentar|]1,Inf[ Kommentarer'\nsubscribers_count: '{0}Abonnenter|{1}Abonnent|]1,Inf[Abonnenter'\nfollowers_count: '{0}Følgere|{1}Følger|]1,Inf[ Følgere'\nmarked_for_deletion: Markeret til sletning\nmarked_for_deletion_at: markeret til sletning d. %date%\nfavourites: Favoritter\nfavourite: Favorit\nmore: Mere\nadded: Tilføjet\nup_votes: Booster\nthreads: Tråde\nmicroblog: Mikroblog\nadd: Tilføj\ntop: Top\nchange_view: Ændre visning\nno_comments: Ingen kommentarer\ncreated_at: Oprettet\navatar: Avatar\n"
  },
  {
    "path": "translations/messages.de.yaml",
    "content": "type.article: Thema\ntype.photo: Foto\ntype.video: Video\ntype.magazine: Magazin\npeople: Personen\nmagazine: Magazin\nmagazines: Magazine\nsearch: Suchen\nadd: Hinzufügen\nchange_view: Ansicht wechseln\ncreated_at: Erstellt\nfilter_by_type: Filtern nach Typ\nactive: Aktiv\nselect_channel: Kanal auswählen\nevents: Ereignisse\nlogin: Anmelden\nnewest: Neu\noldest: Alt\ncommented: kommentiert\nfavourites: Upvotes\nfavourite: Favorit\nmore: Mehr\navatar: Avatar\nadded: Hinzugefügt\nsubscribers: 'Abonnenten'\ncomments: Kommentare\ncards_view: Karten-Ansicht\n12h: 12h\n3h: 3h\n6h: 6h\n1m: 1m\n1w: 1w\nrules: Regeln\nedited_post: Beitrag wurde bearbeitet\nPassword is invalid: Passwort ist ungültig.\nYour account has been banned: Dein Benutzerkonto ist gesperrt.\nfirstname: Vorname\nfilter_by_time: Filtern nach Zeit\ncomments_count: '{0}Kommentare|{1}Kommentar|]1,Inf[ Kommentare'\ncomment: Kommentar\ntable_view: Tabellen-Ansicht\nterms: AGB\nprofile: Profil\narticle: Thema\ntype.link: Link\nthread: Thema\nno_comments: Keine Kommentare\nowner: Eigentümer\nthreads: Themen\nmicroblog: Mikroblog\nenter_your_comment: Gib deinen Kommentar ein\nenter_your_post: Gib deinen Beitrag ein\ncover: Banner\nremember_me: An mich erinnern\ndont_have_account: Noch kein Konto?\nyou_cant_login: Passwort vergessen?\nadd_new_photo: Neues Foto erstellen\nadd_new_post: Neuen Beitrag erstellen\nadd_new_video: Neues Video erstellen\nprivacy_policy: Datenschutzbestimmungen\nuseful: Nützlich\nedit: Bearbeiten\ndelete: Löschen\nedit_post: Beitrag bearbeiten\nedit_comment: Änderungen speichern\nnew_password_repeat: Neues Passwort bestätigen\nfont_size: Schriftgröße\nno: Nein\nshow_all: Alle anzeigen\nalready_have_account: Hast du bereits ein Konto?\nnew_password: Neues Passwort\nyes: Ja\nonline: Online\nreplies: Antworten\nmoderators: Moderatoren\nadd_comment: Kommentar hinzufügen\nadd_media: Medien hinzufügen\nactivity: Aktivität\nfederated_magazine_info: Dieses Magazin ist von einem föderierten Server und \n  möglicherweise unvollständig.\nfederated_user_info: Dieses Profil ist von einem föderierten Server und \n  möglicherweise unvollständig.\nempty: Leer\nsubscribe: Abonnieren\nfollow: Folgen\npassword: Passwort\nrelated_posts: Ähnliche Beiträge\nmarkdown_howto: Wie funktioniert der Editor?\nunsubscribe: Abo beenden\nregister: Registrieren\nreset_password: Passwort zurücksetzen\nusername: Benutzername\nemail: E-Mail\nrepeat_password: Passwort wiederholen\nposts: Beiträge\ntop: Top\nmod_log: Moderations-Log\nadd_post: Beitrag hinzufügen\nrandom_posts: Zufällige Beiträge\nlogin_or_email: Login oder E-Mail\nreply: Antworten\nshow_more: Mehr anzeigen\nto: an\nin: in\nall_magazines: Alle Magazine\nstats: Statistiken\nfediverse: Fediverse\nadd_new_link: Neuen Link erstellen\ncreate_new_magazine: Neues Magazin erstellen\ncontact: Kontakt\nfaq: FAQ\nrss: RSS\nhelp: Hilfe\ncheck_email: Prüfe deine E-Mails\nreset_check_email_desc2: Bitte prüfe deinen Spam-Ordner, wenn du keine E-Mail \n  bekommen hast.\ntry_again: Nochmal versuchen\nemail_confirm_header: Hallo! Bitte bestätige deine E-Mail-Adresse.\nemail_verify: E-Mail-Adresse bestätigen\nemail_confirm_expire: Achtung, dieser Link wird in einer Stunde ablaufen.\nemail_confirm_title: Bestätige deine E-Mail-Adresse.\nselect_magazine: Wähle ein Magazin\nurl: URL\ntitle: Titel\neng: ENG\nimage: Bild\nimage_alt: Alternativtext zum Bild\nname: Name\ndescription: Beschreibung\ndomain: Domäne\noverview: Übersicht\ncards: Karten\ncolumns: Spalten\nuser: Nutzer\nmoderated: Moderiert\npeople_local: Lokal\nreputation_points: Reputationspunkte\ngo_to_content: Zum Inhalt springen\ngo_to_filters: Zu den Filtern springen\ngo_to_search: Zur Suche springen\nall: Alle\nlogout: Abmelden\nclassic_view: Klassische Ansicht\ncompact_view: Kompakte Ansicht\ngeneral: Allgemein\ndynamic_lists: Dynamische Listen\nkbin_intro_title: Das Fediverse erkunden\nkbin_promo_title: Eigene Instanz erstellen\ncaptcha_enabled: Captcha aktiviert\nreturn: Zurück\nabout_instance: Über\nadd_new_article: Neues Thema erstellen\nreset_check_email_desc: Wenn zu dieser Email-Addresse bereits ein Konto \n  registriert ist, dann wird in Kürze eine E-Mail mit einem Link um das Passwort\n  zurückzusetzen gesendet. Dieser Link läuft in %expire% ab.\nemail_confirm_content: 'Bereit dein Mbin-Konto zu aktivieren? Dann klicke den folgenden\n  Link:'\nagree_terms: Stimme den %terms_link_start%Allgemeine \n  Geschäftsbedingungen%terms_link_end% und der \n  %policy_link_start%Datenschutzerklärung%policy_link_end% zu\nup_votes: Boosts\ndown_votes: Reduziert\nusers: Benutzer\nnote: Notiz\nreputation: Ruf\npreview: Vorschau\npinned: Angeheftet\nchange: Ändern\nchange_magazine: Magazin ändern\nunpin: Lösen\npin: Anheften\ndone: Fertig\ntrash: Papierkorb\ncreated: Erstellt\nrejected: Abgelehnt\nfilters: Filter\nreject: Ablehnen\npages: Seiten\ninstance: Instanz\nmeta: Meta\ndashboard: Dashboard\nlocal: Lokal\nfederated: Föderiert\nyear: Jahr\nmonths: Monate\nmonth: Monat\nweeks: Wochen\nweek: Woche\ncontent: Inhalt\nchange_language: Sprache ändern\ntype.smart_contract: Smart Contract\nhot: Heiß\nunfollow: Entfolgen\nsubscribed: Abonniert\nsubscriptions: Abonnements\nchat_view: Chat-Ansicht\nare_you_sure: Bist du dir sicher?\nmoderate: Moderieren\nreason: Grund\nsettings: Einstellungen\nnotifications: Benachrichtigungen\nmessages: Nachrichten\nprivacy: Datenschutz\nnotify_on_new_entry_reply: Alle Kommentare in meinen erstellten Themen\nnotify_on_new_entry_comment_reply: Antworten zu meinen Kommentaren in allen \n  Themen\nnotify_on_new_post_comment_reply: Antworten zu meinen Kommentaren in Beiträgen\nnotify_on_new_entry: Neue Themen (Links und Artikel) in sämtlichen Magazinen die\n  ich abonniert habe\nsave: Speichern\nnotify_on_new_post_reply: Antworten aller Ebenen zu Beiträgen die ich verfasst \n  habe\nnotify_on_new_posts: Neue Beiträge in sämtlichen Magazinen die ich abonniert \n  habe\nchange_theme: Design ändern\nadd_new: Erstellen\nbadges: Abzeichen\nbody: Inhalt\nis_adult: 18+ / NSFW\njoined: Beigetreten\nfollowing: Folgende\n1d: 1t\n1y: 1j\nlinks: Links\narticles: Themen\nphotos: Bilder\nvideos: Videos\nreport: Melden\nshare: Teilen\ncopy_url: Mbin URL kopieren\nshare_on_fediverse: Im Fediverse teilen\ngo_to_original_instance: Auf der Original-Instanz anzeigen\nup_vote: Boost\ndown_vote: Reduzieren\ntags: Tags\nfollowers: Follower\ncopy_url_to_fediverse: Original-URL kopieren\nblocked: Geblockt\nreports: Meldungen\noc: OC\npeople_federated: Föderiert\ntree_view: Strukturansicht\nrelated_tags: Verwandte Tags\nappearance: Design\nhomepage: Startseite\nhide_adult: 18+ Inhalte verbergen\nfeatured_magazines: Vorgestellte Magazine\nshow_profile_subscriptions: Magazinabos anzeigen\nabout: Über\nold_email: Derzeitige E-Mail\nnew_email: Neue E-Mail\nnew_email_repeat: Neue E-Mail bestätigen\ncurrent_password: Aktuelles Passwort\nshow_profile_followings: Folgende Nutzer anzeigen\nchange_email: E-Mail ändern\nchange_password: Passwort ändern\nexpand: Erweitern\ncollapse: Einklappen\nerror: Fehler\nvotes: Stimmen\ntheme: Design\nsolarized_light: Sonniges Hell\nsolarized_dark: Sonniges Dunkel\nboosts: Boosts\nshow_users_avatars: Nutzeravatare anzeigen\nshow_magazines_icons: Magazin-Icons anzeigen\nrounded_edges: Abgerundete Ecken\nremoved_post_by: hat den Beitrag entfernt, erstellt von\nrestored_post_by: hat den Beitrag wiederhergestellt, erstellt von\nhe_banned: sperrte\nhe_unbanned: entsperrte\ndomains: Domänen\ndark: Dunkel\nlight: Hell\nsize: Größe\nshow_thumbnails: Vorschaubilder anzeigen\nread_all: Alle als gelesen markieren\nflash_register_success: Willkommen an Bord! Dein Konto wurde registriert. Ein \n  letzter Schritt — überprüfe deinen Posteingang nach einem Aktivierungslink der\n  dein Konto zum Leben erwecken wird.\nflash_thread_new_success: Das Thema wurde erfolgreich erstellt und ist jetzt für\n  andere Benutzer sichtbar.\nrelated_magazines: Verwandte Magazine\nactive_users: Aktive Personen\nsidebar: Seitenleiste\nbanned_instances: Gesperrte Instanzen\nheader_logo: Header Logo\nflash_thread_edit_success: Das Thema wurde erfolgreich bearbeitet.\nflash_thread_delete_success: Das Thema wurde erfolgreich gelöscht.\nflash_thread_pin_success: Das Thema wurde erfolgreich angeheftet.\nflash_thread_unpin_success: Das Thema wurde erfolgreich losgeheftet.\nflash_magazine_edit_success: Das Magazin wurde erfolgreich bearbeitet.\ntoo_many_requests: Grenzwert überschritten, bitte versuche es später nochmal.\nset_magazines_bar_desc: Füge die Namen der Magazine hinter dem Komma ein\nset_magazines_bar_empty_desc: Wenn das Feld leer ist werden aktive Magazine auf \n  der Magazin-Leiste angezeigt.\nset_magazines_bar: Magazin-Leiste\nadded_new_thread: Ein neues Thema wurde hinzugefügt\nmod_deleted_your_comment: Ein Moderator hat deinen Kommentar entfernt\nmod_remove_your_post: Ein Moderator hat deinen Beitrag entfernt\nremoved: Entfernt von einem Moderator\ndeleted: Vom Author gelöscht\npost: Beitrag\nsend_message: Direktnachricht senden\nmessage: Nachricht\nleft: Links\nright: Rechts\nstatus: Status\non: An\noff: Aus\ninstances: Instanzen\nupload_file: Datei hochladen\nfrom_url: Von Url\nadd_moderator: Moderator hinzufügen\nadd_badge: 'Abzeichen hinzufügen'\nexpires: Läuft ab\nFAQ: FAQ\ntype_search_term: Suchbegriff eingeben\nregistrations_enabled: 'Registrierung aktiviert'\nrestore: Wiederherstellen\nsend: Senden\nYour account is not active: Dein Benutzerkonto ist nicht aktiv.\nrandom_magazines: Zufällige Magazine\nrandom_entries: Zufällige Themen\nrelated_entries: Verwandte Themen\ndelete_account: Konto löschen\nauto_preview: Automatische Medienvorschau\nmod_remove_your_thread: Ein Moderator hat dein Thema entfernt\nmod_log_alert: WARNUNG - Der Modlog kann unangenehme oder verstörende Inhalte \n  enthalten, welche von Moderatoren entfernt wurden. Bitte bedenke was du tust.\nremoved_thread_by: hat ein Thema entfernt, erstellt von\nrestored_thread_by: hat ein Thema wiederhergestellt, erstellt von\nremoved_comment_by: hat einen Kommentar entfernt, erstellt von\nrestored_comment_by: hat den Kommentar wiederhergestellt, erstellt von\nflash_magazine_new_success: Das Magazin wurde erfolgreich erstellt. Du kannst \n  nun neue Inhalte hinzufügen oder die Administrationsansicht des Magazins \n  öffnen.\nedited_thread: Thema wurde bearbeitet\nadded_new_comment: Neuer Kommentar hinzugefügt\nedited_comment: Kommentar bearbeitet\nadded_new_post: Neuen Beitrag hinzugefügt\nadded_new_reply: Antwort wurde hinzugefügt\nwrote_message: Schrieb eine Nachricht\nbanned: Hat dich gesperrt\nmentioned_you: Hat dich erwähnt\nsubject_reported: Beitrag wurde gemeldet.\nsidebar_position: Position der Seitenleiste\nfederation: Föderation\napprove: Genehmigen\napproved: Genehmigt\nperm: Dauerhaft\ncontact_email: Kontakt-Mailadresse\nreplied_to_your_comment: Antwortete auf deinen Kommentar\npurge: Aufräumen\ninfinite_scroll: Unendliches Scrollen\nshow_top_bar: Zeige obere Statusleiste an\nexpired_at: Abgelaufen am\nregistration_disabled: Registrierung geschlossen\nfederation_enabled: Föderation aktiviert\npurge_account: Konto endgültig löschen\nunban_account: Konto entsperren\nkbin_intro_desc: ist eine dezentralisierte Plattform zur Inhaltssammlung und für\n  Mikroblogs im Fediverse.\nmagazine_panel_tags_info: Nur benutzen wenn du möchtest, dass Inhalte aus dem \n  Fediverse, basierend auf Tags, in dieses Magazin eingefügt werden\nsticky_navbar: Statische Navigationsleiste\nkbin_promo_desc: '%link_start%Klone das Repo%link_end% und erweitere das Fediverse'\nbrowsing_one_thread: Du siehst nur Inhalte aus einem Thema in dieser Diskussion.\n  Alle Kommentare sind auf der Beitragsseite verfügbar.\nboost: Boost\nicon: Icon\nmagazine_panel: Magazin Panel\nban: Sperre\nbans: Sperren\nadd_ban: Sperre hinzufügen\nwriting: Verfassen\nadmin_panel: Admin Panel\nadd_mentions_posts: Erwähnungen in Beiträgen hinzufügen\nban_account: Konto sperren\nadd_mentions_entries: Erwähnungen in Themen hinzufügen\nban_expired: Sperre abgelaufen\nmercure_enabled: Mercure aktiviert\nreport_issue: Fehler melden\ntokyo_night: Tokyo Night\npreferred_languages: Sprachen von Themen und Beiträgen filtern\ninfinite_scroll_help: Automatisch mehr Inhalte laden, wenn du das Ende der Seite\n  erreichst.\nsticky_navbar_help: Die Navigationsleiste wird oben an der Seite angeheftet \n  bleiben während du nach unten scrollst.\nauto_preview_help: Zeige die Medien-Vorschau (Bild, Video) vergrößert unter dem \n  Inhalt.\nreload_to_apply: Seite neu laden, um Änderungen anzuwenden\nfilter.origin.label: Ursprung auswählen\nfilter.adult.label: Wähle ob NSFW soll gezeigt werden\nfilter.adult.hide: NSFW verbergen\nfilter.adult.show: NSFW anzeigen\nfilter.adult.only: Nur NSFW\nlocal_and_federated: Lokal und föderiert\nfilter.fields.only_names: Nur Namen\nfilter.fields.names_and_descriptions: Namen und Beschreibungen\nkbin_bot: Mbin Agent\nfilter.fields.label: Wähle Felder für die Suche\nbot_body_content: \"Willkommen beim Mbin Agenten! Der Agent spielt eine entscheidende\n  Rolle um die ActivityPub-Funktionalität für Mbin bereitzustellen. Er stellt sicher,\n  dass Mbin mit anderen Instanzen des Fediverse kommunizieren und föderieren kann.\\n\\\n  \\ \\nActivityPub ist ein offenes Protokoll dass dezentralen sozialen Netzwerken ermöglicht\n  miteinander zu kommunizieren und zu interagieren. Es ermöglicht Nutzern auf unterschiedlichen\n  Instanzen (Servern) anderen Nutzern zu folgen, mit ihnen zu interagieren und Inhalte\n  innerhalb des föderierten sozialen Netzwerk, bekannt als das Fediverse zu teilen.\n  Es stellt eine standardisierte Schnittstelle bereit über die Nutzer Inhalte veröffentlichen,\n  anderen Nutzern folgen, und mit ihnen interagieren können indem sie Inhalte favorisieren,\n  teilen oder kommentieren.\"\npassword_confirm_header: Bestätige deine Passwortänderungsanfrage.\ntoolbar.bold: Fett\ntoolbar.italic: Kursiv\ntoolbar.strikethrough: Durchgestrichen\ntoolbar.header: Überschrift\ntoolbar.quote: Zitat\ntoolbar.code: Code\ntoolbar.link: Link\ntoolbar.image: Bild\ntoolbar.unordered_list: Liste\ntoolbar.mention: Erwähnen\ntoolbar.ordered_list: Geordnete Liste\nyour_account_is_not_active: Dein Profil wurde noch nicht aktiviert. Bitte prüfe \n  deine E-Mails und klicke den Aktivierungslink um fortzufahren. Falls du keine \n  Mail erhalten hast frage <a href=\"%link_target%\">eine neue \n  Aktivierungsmail</a> an.\nyour_account_has_been_banned: Dein Konto wurde gesperrt\nfederation_page_enabled: Föderationsseite aktiviert\nfederation_page_disallowed_description: Instanzen mit denen wir nicht föderieren\nfederation_page_allowed_description: Bekannte Instanzen mit denen wir föderieren\nfederated_search_only_loggedin: Föderierte Suche ist eingeschränkt wenn nicht \n  angemeldet\nmore_from_domain: Mehr von der Domäne\nresend_account_activation_email_error: Es gab ein Problem bei der Bearbeitung \n  der Anfrage. Eventuell gibt es keinen Account der mit diese E-Mail assoziiert \n  wird oder vielleicht ist der Account bereits aktiviert.\nemail_confirm_button_text: Bestätige deine Passwortänderungsanfrage\nerrors.server429.title: 429 Zu viele Anfragen\noauth.consent.to_allow_access: Um den Zugriff zu gestatten, klicken Sie den \n  \"Erlauben\" Button weiter unten\nemail.delete.description: Der folgende Nutzer hat die Löschung seines Kontos \n  angefragt\noauth.consent.app_requesting_permissions: möchte die folgenden Aktionen in \n  deinem Namen ausführen\noauth.consent.allow: Erlauben\ncustom_css: Eigenes CSS\nblock: Blockieren\noauth2.grant.moderate.magazine.list: Lese eine Liste deine moderierten Magazine.\nerrors.server404.title: 404 Nicht Gefunden\nresend_account_activation_email_success: Wenn es einen Account mit dieser E-Mail\n  gibt, werden wir eine neue Aktivierungsmail senden.\nerrors.server403.title: 403 Zugriff Verboten\nignore_magazines_custom_css: Ignoriere das eigene CSS eines Magazins\nsidebars_same_side: Seitenleisten auf der selben Seite\noauth.consent.deny: Verbieten\noauth.consent.title: OAUTH2 Zustimmungsformular\nresend_account_activation_email: Konto Aktivierungsmail erneut senden\nerrors.server500.title: 500 Interner Serverfehler\nalphabetically: Alphabetisch\nshow_subscriptions: Zeige Abonnements\nresend_account_activation_email_question: Inaktiver Account?\nresend_account_activation_email_description: Gib die E-Mail-Adresse die mit \n  deinem Account assoziiert ist ein. Wir werden erneut eine Aktivierungsmail für\n  Sie senden.\nerrors.server500.description: Es tut uns leid, etwas ist schiefgelaufen. Sollte \n  dieser Fehler bestehen kontaktieren Sie bitte den Instanz Besitzer. Wenn diese\n  Instanz überhaupt nicht funktioniert kann eine %link_start%andere Mbin \n  Instanz%link_end% verwendet werden bis das Problem gelöst ist.\noauth.client_not_granted_message_read_permission: Diese App hat keine \n  Berechtigung erhalten deine Nachrichten zu lesen.\nrestrict_oauth_clients: Beschränke OAuth2 Client Erstellung auf Admins\nsubscription_sort: Sortierung\nunblock: Entblockieren\noauth.consent.grant_permissions: Gebe Berechtigungen\noauth2.grant.moderate.magazine.ban.delete: Nutzer in deinen moderierten \n  Magazinen entsperren.\nsubscriptions_in_own_sidebar: In eigener Seitenleiste\nsubscription_sidebar_pop_out_left: Nach links in eigene Seitenleiste verschieben\nsubscription_sidebar_pop_out_right: Nach rechts in eigene Seitenleiste \n  verschieben\nsubscription_sidebar_pop_in: In andere Seitenleiste verschieben\nsubscription_panel_large: Großes Panel\nsubscription_header: Abonnierte Magazine\noauth.client_identifier.invalid: Ungültige OAuth Client ID!\noauth.consent.app_has_permissions: kann bereits die folgenden Aktionen ausführen\nemail.delete.title: Benutzerkonto Löschanfrage\nemail_confirm_link_help: Alternativ können Sie das Folgende in ihren Browser \n  kopieren\n2fa.authentication_code.label: Authentisierungscode\noauth2.grant.post.edit: Bearbeite existierende Beiträge.\noauth2.grant.moderate.post.trash: Lösche oder stelle Beiträge in deinen \n  moderierten Magazinen wieder her.\nmoderation.report.approve_report_title: Meldung Zustimmen\nmoderation.report.reject_report_title: Meldung Ablehnen\noauth2.grant.moderate.magazine.reports.all: Bearbeite Meldungen in deinen \n  moderierten Magazinen.\noauth2.grant.admin.federation.update: Instanzen der liste der deföderierten \n  Instanzen hinzufügen oder davon entfernen.\noauth2.grant.user.message.all: Lese deine Nachrichten und sende Nachrichten an \n  andere Nutzer.\noauth2.grant.moderate.magazine.trash.read: Gelöschte Inhalte in Ihren \n  moderierten Magazinen sehen.\noauth2.grant.moderate.magazine_admin.create: Erstelle neue Magazine.\noauth2.grant.post.vote: Favorisiere, booste oder reduziere jeden Beitrag.\n2fa.remove: 2FA Entfernen\ndelete_content_desc: Lösche den Inhalt des Nutzers, aber behalte die Antworten \n  anderer Nutzer in den erstellten Themen, Beiträgen und Kommentaren.\nmagazine_theme_appearance_custom_css: Eigenes CSS das angewendet wird wenn \n  Inhalte innerhalb deines Magazins angeschaut werden.\n2fa.backup_codes.help: Du kannst diese Code nutzen wenn du keinen Zugriff auf \n  dein Zwei-Faktor-Authentifizierung Gerät oder deine App hast. Die Codes werden\n  dir <strong>nicht noch einmal gezeigt werden</strong> und du kannst jeden Code\n  <strong>nur ein einziges Mal</strong> nutzen.\noauth2.grant.user.oauth_clients.edit: Bearbeite die Berechtigungen die du \n  anderen OAuth2 Apps gegeben hast.\noauth2.grant.user.all: Lese und bearbeite dein Profil, deine Nachrichten und \n  Benachrichtigungen; Lese und bearbeite Berechtigungen die du anderen Apps \n  gegeben hast; Folge oder blockiere andere Nutzer; Zeige die Nutzer denen du \n  folgst oder die du blockiert hast.\noauth2.grant.moderate.post.set_adult: Kennzeichne Beiträge in deinen moderierten\n  Magazinen als NSFW.\noauth2.grant.moderate.magazine_admin.edit_theme: Bearbeite das Custom CSS deiner\n  eigenen Magazine.\noauth2.grant.moderate.magazine_admin.tags: Erstelle oder Entferne Tags von \n  deinen eigenen Magazinen.\nmoderation.report.ban_user_description: Willst du den Nutzer (%username%) \n  sperren der diesen Inhalt von dem Magazin erstellt hat?\noauth2.grant.moderate.entry.pin: Pinne Themen an den Anfang deiner moderierten \n  Magazine.\noauth2.grant.user.message.read: Lese deine Nachrichten.\noauth2.grant.admin.entry.purge: Lösche jedes Thema endgültig von deiner Instanz.\n2fa.verify: Verifizieren\n2fa.add: Zu meinem Konto hinzufügen\noauth2.grant.admin.magazine.all: Verschiebe Themen zwischen Magazinen oder \n  lösche Magazine endgültig von deiner Instanz.\noauth2.grant.admin.instance.settings.read: Zeige die Einstellungen deiner \n  Instanz.\noauth2.grant.entry.report: Melde jedes Thema.\noauth2.grant.moderate.post_comment.all: Moderiere Kommentare unter Beiträgen in \n  deinen moderierten Magazinen.\n2fa.disable: Zwei-Faktor-Authentifizierung Deaktivieren\nlast_active: Zuletzt Aktiv\npurge_content: Lösche Inhalt endgültig\noauth2.grant.domain.subscribe: Abonniere oder Deabonniere Domains und zeige die \n  Domains die du abonniert hast.\nmagazine_theme_appearance_icon: Benutzerdefiniertes Icon für das Magazin.\noauth2.grant.moderate.magazine.ban.create: Sperre Nutzer in deinen moderierten \n  Magazinen.\noauth2.grant.admin.user.delete: Lösche Nutzer von deiner Instanz.\noauth2.grant.moderate.post.change_language: Ändere die Sprache von Beiträgen in \n  deinen moderierten Magazinen.\noauth2.grant.moderate.magazine_admin.delete: Lösche alle deine eigenen Magazine.\noauth2.grant.moderate.entry_comment.all: Moderiere Kommentare in Themen in \n  deinen moderierten Magazinen.\noauth2.grant.admin.magazine.move_entry: Verschiebe Themen zwischen Magazinen auf\n  deine Instanz.\noauth2.grant.post_comment.edit: Bearbeite deine existierenden Kommentare unter \n  Beiträgen.\noauth2.grant.moderate.post.pin: Hefte Beiträge oben an deine moderierten \n  Magazine an.\noauth2.grant.entry.create: Erstelle neue Themen.\noauth2.grant.moderate.magazine.reports.action: Akzeptiere oder Verwerfe \n  Meldungen in deinen moderierten Magazinen.\noauth2.grant.admin.magazine.purge: Lösche Magazine endgültig von deiner Instanz.\noauth2.grant.user.notification.read: Lese deine Benachrichtigungen, inklusive \n  Benachrichtigungen zu Nachrichten.\noauth2.grant.magazine.block: Blockiere oder entblockiere Magazine und zeige \n  Magazine die du blockiert hast.\noauth2.grant.magazine.subscribe: Abonniere oder deabonniere Magazine und zeige \n  die Magazine die du abonniert hast.\noauth2.grant.admin.user.all: Sperre, verifiziere oder lösche Nutzer endgültig \n  von deiner Instanz.\n2fa.backup_codes.recommendation: Es wird empfohlen dass du eine Kopie davon an \n  einem sicheren Ort aufbewahrst.\noauth2.grant.post_comment.report: Melde jeden Kommentar unter einem Beitrag.\noauth2.grant.magazine.all: Abonniere oder blockiere Magazine und zeige die \n  Magazine die du abonniert oder blockiert hast.\noauth2.grant.vote.general: Favorisiere, Reduziere oder Booste jedes Thema, \n  Betrag oder Kommentar.\noauth2.grant.entry.vote: Favorisiere, booste oder reduziere jedes Thema.\noauth2.grant.moderate.magazine_admin.all: Erstelle, Bearbeite oder Lösche deine \n  eigenen Magazine.\ncomment_reply_position_help: Zeige das Kommentar-Formular entweder oben oder \n  unten auf der Seite. Falls \"unendliches scrollen\" aktiviert ist wird das \n  Formular immer oben auf der Seite angezeigt werden.\noauth2.grant.post_comment.vote: Favorisiere, booste oder reduziere jeden \n  Kommentar unter einem Beitrag.\noauth2.grant.user.oauth_clients.read: Lese die Berechtigungen die du anderen \n  OAuth2 Apps gegeben hast.\noauth2.grant.moderate.entry.change_language: Ändere die Sprache von Themen in \n  deinen moderierten Magazinen.\noauth2.grant.moderate.all: Führe jede Moderationshandlung aus zu der du die \n  Berechtigung in deinen moderierten Magazinen hast.\noauth2.grant.moderate.magazine.ban.all: Verwalte gesperrte Nutzer in deinen \n  moderierten Magazinen.\noauth2.grant.moderate.magazine.all: Verwalte Sperren, Meldungen und zeige \n  gelöschte Items in deinen moderierten Magazinen.\noauth2.grant.admin.federation.all: Zeige und aktualisiere die aktuell \n  deföderierten Instanzen.\noauth2.grant.user.notification.all: Lese und lösche deine Benachrichtigungen.\noauth2.grant.report.general: Melde Themen, Beiträge oder Kommentare.\noauth2.grant.admin.post.purge: Lösche jeden Beitrag endgültig von deiner \n  Instanz.\noauth2.grant.moderate.magazine.ban.read: Zeige gesperrte Nutzer in deinen \n  moderierten Magazinen.\noauth2.grant.user.profile.all: Lese und bearbeite dein Profil.\noauth2.grant.admin.user.ban: Sperre oder entsperre Nutzer von deiner Instanz.\nshow_avatars_on_comments: Zeige Kommentar Avatare\noauth2.grant.admin.all: Führe jede administrative Aktion auf deiner Instanz aus.\noauth2.grant.post.report: Melde jeden Beitrag.\noauth2.grant.moderate.magazine.reports.read: Lese Meldungen in deinen \n  moderierten Magazinen.\noauth2.grant.entry_comment.create: Erstelle neue Kommentare in Themen.\n2fa.qr_code_img.alt: Ein QR-Code der die Einrichtung der \n  Zwei-Faktor-Authentifizierung für dein Konto ermöglicht\noauth2.grant.user.follow: Folge oder entfolge Nutzern und zeige die Nutzer denen\n  du folgst.\nflash_post_pin_success: Der Beitrag wurde erfolgreich angeheftet.\noauth2.grant.entry_comment.report: Melde jeden Kommentar in einem Thema.\nmoderation.report.approve_report_confirmation: Bist du dir sicher dass du dieser\n  Meldung zustimmen willst?\noauth2.grant.moderate.entry.all: Moderiere Themen in deinen moderierten \n  Magazinen.\noauth2.grant.entry_comment.all: Erstelle, Bearbeite oder Lösche deine Kommentare\n  in Themen und favorisiere, booste oder melde jeden Kommentar in einem Thema.\noauth2.grant.entry_comment.delete: Lösche deine existierenden Kommentare in \n  Themen.\ndelete_account_desc: Lösche das Konto, inklusive Antworten anderer Nutzer in den\n  erstellten Themen, Beiträgen und Kommentaren.\nsubject_reported_exists: Dieser Inhalt wurde bereits gemeldet.\noauth2.grant.moderate.entry_comment.set_adult: Kennzeichne Kommentare in Themen \n  in deinen moderierten Magazinen als NSFW.\noauth2.grant.entry.edit: Bearbeite deine existierenden Themen.\noauth2.grant.moderate.entry_comment.trash: Lösche oder stelle Kommentare in \n  Themen deiner moderierten Magazine wieder her.\noauth2.grant.moderate.post.all: Moderiere Beiträge in deinen moderierten \n  Magazinen.\n2fa.enable: Zwei-Faktor-Authentifizierung Einrichten\noauth2.grant.entry_comment.edit: Bearbeite deine existierenden Kommentare in \n  Themen.\n2fa.user_active_tfa.title: Nutzer hat aktive 2FA\noauth2.grant.moderate.magazine_admin.update: Bearbeite Regeln, Beschreibungen, \n  NSFW Status und Icons deiner eigenen Magazine.\noauth2.grant.moderate.entry.trash: Lösche oder stelle Themen in deinen \n  moderierten Magazinen wieder her.\noauth2.grant.user.oauth_clients.all: Lese und Bearbeite die Berechtigungen die \n  du anderen OAuth2 Apps gegeben hast.\n2fa.code_invalid: Der Authentisierungscode ist ungültig\noauth2.grant.user.profile.read: Lese dein Profil.\noauth2.grant.admin.oauth_clients.read: Zeige die OAuth2 Clients die auf deiner \n  Instanz existieren und deren Nutzungsstatistik.\noauth2.grant.write.general: Erstelle oder Beatbeite jedes deiner Themen, \n  Beiträge oder Kommentare.\nsingle_settings: Einzeln\noauth2.grant.moderate.post_comment.set_adult: Kennzeichne Kommentare unter \n  Beiträgen in deinen moderierten Magazinen als NSFW.\noauth2.grant.moderate.post_comment.change_language: Ändere die Sprache von \n  Kommentaren unter Beiträgen in deinen moderierten Magazinen.\nmoderation.report.ban_user_title: Nutzer Sperren\n2fa.available_apps: Nutze eine Zwei-Faktor-Authentifizierung-App wie \n  %google_authenticator%, %aegis% (Android) oder %raivo% (iOS) um den QR-Code zu\n  scannen.\noauth2.grant.admin.user.verify: Verifiziere Nutzer auf deiner Instanz.\ncancel: Abbrechen\noauth2.grant.entry.delete: Lösche deine existierenden Themen.\noauth2.grant.admin.instance.information.edit: Aktualisiere die Über, FAQ, \n  Kontakt, Nutzungsbedingungen und Datenschutzerklärung Seiten deiner Instanz.\noauth2.grant.read.general: Lese alle Inhalte zu denen du Zugang hast.\noauth2.grant.domain.all: Abonniere oder Blockiere Domains und zeige die Domains \n  die du abonniert oder blockiert hast.\npurge_content_desc: Lösche den Inhalt des Nutzers endgültig, inklusive aller \n  Antworten anderer Nutzer in den erstellten Themen, Beiträgen und Kommentaren.\nflash_post_unpin_success: Der Beitrag wurde erfolgreich gelöst.\noauth2.grant.post_comment.delete: Lösche deine existierenden Kommentare unter \n  Beiträgen.\noauth2.grant.admin.oauth_clients.revoke: Widerrufe Zugang zu OAuth2 Clients auf \n  deiner Instanz.\noauth2.grant.admin.instance.settings.edit: Aktualisiere die Einstellungen deiner\n  Instanz.\noauth2.grant.moderate.entry.set_adult: Kennzeichne Themen als NSFW in deinen \n  moderierten Magazinen.\noauth2.grant.delete.general: Lösche jedes deiner Themen, Beiträge oder \n  Kommentare.\noauth2.grant.entry_comment.vote: Favorisiere, booste oder reduziere jeden \n  Kommentar in einem Thema.\noauth2.grant.admin.instance.stats: Zeige die Statistiken deiner Instanz.\noauth2.grant.admin.instance.settings.all: Zeige oder aktualisiere die \n  Einstellungen deiner Instanz.\noauth2.grant.entry.all: Erstelle, Bearbeite oder Lösche deine Themen und stimme \n  ab, booste oder melde jedes Thema.\ndelete_content: Lösche Inhalt\ntwo_factor_backup: Zwei-Faktor-Authentifizierung Backup Codes\nmagazine_theme_appearance_background_image: Eigenes Hintergrundbild das \n  angezeigt wird wenn Inhalte innerhalb deines Magazins angezeigt werden.\noauth2.grant.admin.federation.read: Zeige die deföderierten Instanzen.\noauth2.grant.moderate.entry_comment.change_language: Ändere die Sprache von \n  Kommentaren in Themen deiner moderierten Magazine.\noauth2.grant.moderate.magazine_admin.stats: Zeige den Inhalt, Abstimmung, und \n  Ansichtstatus deiner eigenen Magazine.\npassword_and_2fa: Passwort & 2FA\noauth2.grant.user.message.create: Sende Nachrichten an andere Nutzer.\noauth2.grant.admin.oauth_clients.all: Zeige oder widerrufe OAuth2 Clients die \n  auf deiner Instanz existieren.\n2fa.backup-create.label: Erstelle neue Backup Codes\n2fa.backup: Deine Zwei-Faktor Backup Codes\noauth2.grant.post_comment.all: Erstelle, Bearbeite oder Lösche deine Kommentare \n  zu Beiträgen und favorisiere, booste oder melde jeden Kommentar unter einem \n  Beitrag.\noauth2.grant.admin.user.purge: Lösche Nutzer endgültig von deiner Instanz.\nupdate_comment: Aktualisiere Kommentar\n2fa.qr_code_link.title: Diesen Link aufzurufen kann deiner Plattform erlauben \n  diese Zwei-Faktor-Authentifizierung zu registrieren\n2fa.backup-create.help: Du Kannst neue Backup Codes erstellen; diese Aktion wird\n  existierende Codes ungültig machen.\noauth2.grant.user.notification.delete: Lösche deine Benachrichtigungen.\ntwo_factor_authentication: Zwei-Faktor-Authentifizierung\nshow_avatars_on_comments_help: Zeige/Verstecke Nutzeravatare wenn Kommentare in \n  einem einzelnen Thema oder Beitrag angeschaut werden.\n2fa.verify_authentication_code.label: Gebe einen Zwei-Faktor Code ein um die \n  Einrichtung zu verifizieren\noauth2.grant.post.all: Erstelle, Bearbeite oder Lösche Beiträge und stimme ab, \n  booste oder melde jeden Beitrag.\noauth2.grant.block.general: Blockiere oder Entblockiere jedes Magazin, Domain, \n  oder Nutzer und zeige die Magazine, Domains und Nutzer die du blockiert hast.\nmoderation.report.reject_report_confirmation: Bist du dir sicher dass du diese \n  Meldung Ablehnen willst?\noauth2.grant.post_comment.create: Erstelle neue Kommentare unter Beiträgen.\noauth2.grant.user.block: Sperre oder entsperre Nutzer und zeige die Nutzer die \n  blockiert hast.\noauth2.grant.post.delete: Lösche deine existierenden Beiträge.\noauth2.grant.subscribe.general: Abonniere oder Folge jedem Magazin, Domain, oder\n  Nutzer und zeige die Magazine, Domains und Nutzer an die du abonniert hast.\noauth2.grant.moderate.post_comment.trash: Lösche oder stelle Kommentare unter \n  Beiträgen in deinen moderierten Magazinen wieder her.\noauth2.grant.moderate.magazine_admin.moderators: Hinzufügen oder Entfernen von \n  Moderatoren in deinen eigenen Magazinen.\noauth2.grant.admin.entry_comment.purge: Lösche jeden Kommentar in einem Thema \n  endgültig von deiner Instanz.\ncomment_reply_position: Kommentarantwortposition\noauth2.grant.post.create: Erstelle neue Beiträge.\noauth2.grant.domain.block: Blockiere oder Entblockiere Domains und zeige die \n  Domains die du blockiert hast.\noauth2.grant.admin.instance.all: Zeige und aktualisiere Instanzeinstellungen und\n  -informationen.\noauth2.grant.user.profile.edit: Bearbeite dein Profil.\noauth2.grant.admin.post_comment.purge: Lösche jeden Kommentar unter einem \n  Beitrag endgültig von deiner Instanz.\noauth2.grant.moderate.magazine_admin.badges: Erstelle oder Entferne Abzeichen in\n  deinen eigenen Magazinen.\nedit_my_profile: Mein Profil bearbeiten\nuser_badge_moderator: Mod\nflash_account_settings_changed: Deine Konto Einstellungen wurden erfolgreich \n  geändert. Du musst dich erneut anmelden.\nclose: Schließen\nrestore_magazine: Magazin wiederherstellen\nflash_post_new_success: Beitrag wurde erfolgreich erstellt.\nflash_comment_edit_error: Kommentar konnte nicht bearbeitet werden. Etwas ist \n  schief gegangen.\nflash_comment_new_error: Kommentar konnte nicht erstellt werden. Etwas ist \n  schief gegangen.\naccount_settings_changed: Die Kontoeinstellungen wurden erfolgreich geändert. Du\n  musst dich erneut anmelden.\nsuspend_account: Konto suspendieren\nflash_post_new_error: Beitrag konnte nicht erstellt werden. Etwas ist schief \n  gegangen.\ndefault_theme: Standard Design\npage_width_fixed: Festgelegt\nflash_thread_new_error: Thema konnte nicht erstellt werden. Etwas ist schief \n  gelaufen.\nremove_following: Folgen entfernen\naccount_suspended: Das Konto wurde suspendiert.\nmark_as_adult: Als NSFW markieren\nuser_badge_admin: Admin\nflash_magazine_theme_changed_error: Die Magazin Darstellung konnte nicht \n  aktualisiert werden.\nflash_post_edit_error: Beitrag konnte nicht bearbeitet werden. Etwas ist schief \n  gegangen.\n2fa.setup_error: Fehler bei der 2FA Aktivierung für das Konto\npage_width_auto: Automatisch\nflash_post_edit_success: Beitrag erfolgreich bearbeitet.\nflash_user_edit_password_error: Passwort konnte nicht geändert werden.\nuser_badge_global_moderator: Globaler Mod\napply_for_moderator: Als Moderator bewerben\nunmark_as_adult: NSFW Markierung entfernen\nposition_bottom: Unten\ndeletion: Löschung\ndeleted_by_author: Thema, Beitrag oder Kommentar wurde durch den Author gelöscht\naccount_unbanned: Dieses Konto wurde entsperrt.\nsolarized_auto: Sonnig (Auto Erkennung)\nflash_magazine_theme_changed_success: Die Magazin Darstellung wurde erfolgreich \n  aktualisiert.\nchange_my_cover: Mein Banner ändern\nflash_thread_edit_error: Thema konnte nicht bearbeitet werden. Etwas ist schief \n  gegangen.\nannouncement: Ankündigung\nflash_user_edit_email_error: E-Mail konnte nicht geändert werden.\npage_width_max: Maximal\nsensitive_toggle: Sichtbarkeit für sensible Inhalte umschalten\nmagazine_is_deleted: Magazin ist gelöscht. Du kannst es innerhalb von 30 Tagen \n  <a href=\"%link_target%\">wiederherstellen</a>.\nsensitive_show: Klicke zum anzeigen\nopen_url_to_fediverse: Original-URL öffnen\npage_width: Seiten Breite\nuser_badge_bot: Bot\nflash_comment_new_success: Kommentar wurde erfolgreich erstellt.\nflash_user_settings_general_error: Nutzer-Einstellungen konnten nicht \n  gespeichert werden.\naccount_is_suspended: Dieses Nutzerkonto ist suspendiert.\nuser_suspend_desc: Dein Konto suspendieren versteckt deine Inhalte auf der \n  Instanz, löscht diese aber nicht permanent, du kannst die Inhalte jederzeit \n  wiederherstellen.\nownership_requests: Eigentümer Anfragen\nflash_email_was_sent: E-Mail wurde erfolgreich versandt.\naccount_unsuspended: Die Kontosuspendierung wurde aufgehoben.\nsensitive_warning: Sensibler Inhalt\nkeywords: Schlüsselwörter\nmoderator_requests: Moderator Anfragen\ndefault_theme_auto: Hell/Dunkel (Auto Erkennung)\nflash_comment_edit_success: Kommentar wurde erfolgreich aktualisiert.\naction: Aktion\nflash_mark_as_adult_success: Die NSFW Markierung wurde dem Post erfolgreich \n  hinzugefügt.\ncancel_request: Anfrage abbrechen\nunsuspend_account: Konto Suspendierung aufheben\ndeleted_by_moderator: Thema, Beitrag oder Kommentar wurde durch den Moderator \n  gelöscht\nflash_unmark_as_adult_success: Die NSFW Markierung wurde vom Post erfolgreich \n  entfernt.\nabandoned: Verlassen\naccount_banned: Dieses Konto wurde gesperrt.\nchange_my_avatar: Meinen Avatar ändern\nflash_user_edit_profile_success: Profil-Einstellungen erfolgreich gespeichert.\nflash_user_settings_general_success: Nutzer Einstellungen erfolgreich \n  gespeichert.\nremove_subscriptions: Abonnements entfernen\ndelete_magazine: Magazin löschen\nsensitive_hide: Klicke zum verstecken\nrequest_magazine_ownership: Magazin Eigentümerschaft beantragen\npending: Unerledigt\naccept: Akzeptieren\nflash_email_failed_to_sent: E-Mail konnte nicht gesendet werden.\nflash_user_edit_profile_error: Profil-Einstellungen konnten nicht gespeichert \n  werden.\nuser_badge_op: OP\npurge_magazine: Magazin permanent löschen\nmagazine_deletion: Magazin Löschung\nposition_top: Oben\nsubscribers_count: '{0}Abonnenten|{1}Abonennt|]1,Inf[ Abonnenten'\nfollowers_count: '{0}Follower|{1}Follower|]1,Inf[ Follower'\nremove_media: Medien entfernen\nmenu: Menü\nall_time: Gesamt\ndetails: Details\nspoiler: Spoiler\nsso_registrations_enabled: SSO Registrierung aktiviert\nmarked_for_deletion: für Löschung markiert\nmarked_for_deletion_at: für Löschung am %date% markiert\nsubscribe_for_updates: Abonniere um Updates zu erhalten.\naccount_deletion_title: Kontolöschung\naccount_deletion_description: Dein Konto wird in 30 Tagen gelöscht außer du \n  entscheidest dich dein Konto sofort zu löschen. Um das Konto \n  wiederherzustellen, logge dich in den nächsten 30 Tagen mit deinen \n  Zugangsdaten ein oder kontaktiere einen Administrator.\naccount_deletion_button: Lösche Konto\naccount_deletion_immediate: Sofort Löschen\nremove_schedule_delete_account: Entferne geplante Löschung\nremove_schedule_delete_account_desc: Entfernt die geplante Löschung. Aller \n  Inhalt wird wieder verfügbar sein und der Nutzer wird sich wieder anmelden \n  können.\ndisconnected_magazine_info: Dieses Magazin erhält keine Updates (letzte \n  Aktivität vor %days% Tage(n)).\nalways_disconnected_magazine_info: Dieses Magazin erhält keine Updates.\nschedule_delete_account: Plane Löschung\nschedule_delete_account_desc: Plane die Löschung des Kontos in 30 Tagen. Dies \n  wird den Nutzer und deren Inhalt verstecken und die Anmeldung verhindern.\nsso_registrations_enabled.error: Neue Konto Registrierung mit Identitätsmanager \n  von Drittanbietern sind momentan deaktiviert.\nshow: Zeige\nhide: Verstecke\nedited: Bearbeitet\nfilter_by_federation: 'Filtern nach Föderierungsstatus'\nauto: 'Automatisch'\nfilter_labels: 'Filter Beschreibung'\nrestrict_magazine_creation: 'Erstellung lokaler Magazine auf Admins und globale Mods\n  beschränken'\nsort_by: 'Sortieren nach'\nfilter_by_subscription: 'Filtern nach Abonnements'\nrelated_entry: Zugehörig\ntag: Hashtag\nunban: Sperre aufheben\nban_hashtag_btn: Hashtag Sperren\nunban_hashtag_btn: Hashtag Sperre Aufheben\nprivate_instance: Nutzer zur Anmeldung zwingen um auf Inhalte zugreifen zu \n  können\nflash_thread_tag_banned_error: Thema konnte nicht erstellt werden. Der Inhalt \n  ist nicht erlaubt.\nsso_only_mode: Anmeldung und Registrierung auf SSO Methoden beschränken\nmagazine_log_mod_added: hat einen Moderator hinzugefügt\nmagazine_log_mod_removed: hat einen Moderator entfernt\nban_hashtag_description: Durch das Sperren eines Hashtags wird verhindert, dass \n  Beiträge mit diesem Hashtag erstellt werden. Außerdem werden vorhandene \n  Beiträge mit diesem Hashtag ausgeblendet.\nunban_hashtag_description: Wenn Sie eine Hashtag Sperre aufheben, können wieder \n  Beiträge mit diesem Hashtag erstellt werden. Vorhandene Beiträge mit diesem \n  Hashtag werden nicht mehr ausgeblendet.\nsso_show_first: SSO als erstes auf der Anmeldungs- und Registrierungsseite \n  anzeigen\ncontinue_with: Weiter mit\nfrom: von\nsomeone: Jemand\ncake_day: Kuchen-Tag\nlast_updated: Letzte Aktualisierung\nmagazine_log_entry_unpinned: hat Eintrag gelöst\nand: und\nback: Zurück\nmagazine_log_entry_pinned: hat Eintrag angeheftet\ndirect_message: Direktnachricht\nmanually_approves_followers: Genehmigt Follower manuell\nregister_push_notifications_button: Push-Benachrichigungen aktivieren\nunregister_push_notifications_button: Push-Benachrichigungen deaktivieren\ntest_push_notifications_button: Push-Benachrichtigung Testen\ntest_push_message: Hallo Welt!\nnotification_title_new_comment: Neuer Kommentar\nnotification_title_removed_comment: Ein Kommentar wurde entfernt\nnotification_title_edited_comment: Ein Kommentar wurde bearbeitet\nnotification_title_mention: Du wurdest erwähnt\nnotification_title_new_reply: Neue Antwort\nnotification_title_new_thread: Neues Thema\nnotification_title_removed_thread: Ein Thema wurde entfernt\nnotification_title_edited_thread: Ein Thema wurde bearbeitet\nnotification_title_ban: Du wurdest gesperrt\nnotification_title_message: Neue Direktnachricht\nnotification_title_new_post: Neuer Beitrag\nnotification_title_removed_post: Ein Beitrag wurde entfernt\nnotification_title_edited_post: Ein Beitrag wurde bearbeitet\nreported_user: Gemeldeter Nutzer\nreporting_user: Meldender Nutzer\nreport_subject: Inhalt\nown_report_rejected: Deine Meldung wurde abgelehnt\nopen_report: Meldung öffnen\nreported: gemeldet\nown_report_accepted: Deine Meldung wurde angenommen\nown_content_reported_accepted: Eine Meldung deines Inhalts wurde angenommen.\nreport_accepted: Eine Meldung wurde angenommen\nshow_active_users: Zeige aktive Nutzer\nshow_related_magazines: Zeige zufällige Magazine\nshow_related_entries: Zeige zufällige Themen\nshow_related_posts: Zeige zufällige Beiträge\nnotification_title_new_report: Eine neue Meldung wurde erstellt\nfederation_page_dead_title: Tote Instanzen\nfederation_page_dead_description: Instanzen zu denen wir mindestens 10 \n  Aktivitäten in folge nicht zustellen konnten und bei denen die letzte \n  erfolgreiche Zustellung und der letzte erfolgreiche Empfang mehr als eine \n  Woche zurückliegt\nserver_software: Server Software\nversion: Version\nmagazine_posting_restricted_to_mods_warning: Nur Mods können Themen in diesem \n  Magazin erstellen\nflash_posting_restricted_error: Die Erstellung von Themen ist in diesem Magazin \n  auf Moderatoren eingeschränkt und du bist keiner\nlast_successful_deliver: Letzte erfolgreiche Zustellung\nlast_successful_receive: Letzter erfolgreicher Empfang\nlast_failed_contact: Letzter misslungener Kontakt\nmagazine_posting_restricted_to_mods: Erstellung von Themen auf Moderatoren \n  beschränken\nnew_user_description: Dieser Nutzer ist neu (seit weniger als %days% Tagen \n  aktiv)\nnew_magazine_description: Dieses Magazin ist neu (seit weniger als %days% Tagen \n  aktiv)\nedit_entry: Thema bearbeiten\nflash_image_download_too_large_error: 'Bild konnte nicht erstellt werden, da es zu\n  groß ist (maximale Größe: %bytes%)'\nadmin_users_active: Aktiv\nadmin_users_inactive: Inaktiv\nadmin_users_suspended: Suspendiert\nadmin_users_banned: Gesperrt\nuser_verify: Konto aktivieren\nmax_image_size: Maximale Dateigröße\nchange_downvotes_mode: Reduzieren Modus ändern\ndisabled: Deaktiviert\ndownvotes_mode: Reduzieren Modus\nhidden: Versteckt\nenabled: Aktiviert\ntoolbar.spoiler: Spoiler\ncomment_not_found: Kommentar nicht gefunden\ntable_of_contents: Inhaltsverzeichnis\nnotification_body_new_signup: Der Nutzer %u% hat sich registriert.\nnotify_on_user_signup: Registrierungen\nnotification_title_new_signup: Ein Nutzer hat sich registriert\nyour_account_is_not_yet_approved: Dein Konto wurde noch nicht freigegeben. Wir \n  werden dir eine E-Mail schicken, sobald die Administratoren deine Anfrage \n  bearbeitet haben.\nbookmark_list_edit: Bearbeiten\nbookmarks: Lesezeichen\nbookmarks_list: Lesezeichen in %list%\ncount: Anzahl\nbookmark_list_make_default: Zum Standard machen\nbookmark_list_create_placeholder: Namen eingeben...\nbookmark_list_create_label: Listenname\nselect_user: Wähle einen Nutzer\nsignup_requests: Registrierungsanfragen\napplication_text: Erkläre warum du beitreten möchtest\nemail_application_approved_title: Deine Registrierungsanfrage wurde freigegeben\nsignup_requests_header: Registrierungsanfragen\nsignup_requests_paragraph: Diese Nutzer möchten deinem Server beitreten. Sie \n  können sich nicht anmelden bevor du die Anfragen freigegeben hast.\nbookmark_list_is_default: Ist Standardliste\nbookmark_list_selected_list: Liste auswählen\nnew_users_need_approval: Neue Nutzer müssen von einem Admin freigegeben werden \n  bevor sie sich anmelden können.\nbookmark_add_to_default_list: Lesezeichen zur Standardliste hinzufügen\nbookmark_lists: Lesezeichenlisten\nflash_application_info: Ein Admin muss deinen Account freigeben bevor du dich \n  anmelden kannst. Wir werden dir eine E-Mail schicken, sobald deine Anfrage \n  bearbeitet wurde.\nis_default: Ist Standard\nbookmark_list_create: Erstellen\nbookmarks_list_edit: Lesezeichenliste bearbeiten\nemail_application_rejected_body: Vielen Dank für dein Interesse, aber wir müssen\n  dir leider mitteilen, dass deine Registrierungsanfrage abgelehnt wurde.\nemail_application_approved_body: 'Deine Registrierungsanfrage wurde von den Server-Administratoren\n  freigegeben. Du kannst dich jetzt unter <a href=\"%link%\">%siteName%</a> auf dem\n  Server anmelden.'\nemail_application_rejected_title: Deine Registrierungsanfrage wurde abgelehnt\nemail_application_pending: Dein Konto muss von einem Admin freigegeben werden, \n  bevor du dich anmelden kannst.\nemail_verification_pending: Du musst deine E-Mail-Adresse bestätigen, bevor du \n  dich anmelden kannst.\nbookmark_add_to_list: Lesezeichen zu %list% hinzufügen\nsearch_type_entry: Themen\nnotification_body2_new_signup_approval: Du musst die Anfrage freigeben bevor \n  sich der Nutzer anmelden kann\nbookmark_remove_all: All Lesezeichen entfernen\nsearch_type_post: Beiträge\nbookmark_remove_from_list: Lesezeichen von %list% entfernen\nsearch_type_all: Alles\nviewing_one_signup_request: Du schaust dir eine einzelne Registrierungsanfrage \n  von %username% an\nshow_magazine_domains: Zeige Magazin-Domains\noauth2.grant.user.bookmark: Hinzufügen und Löschen von Lesezeichen\noauth2.grant.user.bookmark.add: Lesezeichen hinzufügen\noauth2.grant.user.bookmark.remove: Lesezeichen löschen\noauth2.grant.user.bookmark_list: Deine Lesezeichenlisten lesen, bearbeiten und \n  löschen\noauth2.grant.user.bookmark_list.read: Deine Lesezeichenlisten lesen\noauth2.grant.user.bookmark_list.edit: Deine Lesezeichenlisten bearbeiten\noauth2.grant.user.bookmark_list.delete: Deine Lesezeichenlisten löschen\nshow_user_domains: Zeige Nutzer-Domains\nanswered: antwortete\nby: von\nfront_default_sort: Standardsortierung Startseite\ncomment_default_sort: Standardsortierung Kommentare\nopen_signup_request: Registrierungsanfrage öffnen\nimage_lightbox_in_list: Themen-Vorschaubild öffnet Vollbild\ncompact_view_help: Eine kompakte Ansicht mit geringeren Abständen, bei der die \n  Bilder auf der rechten Seite angezeigt werden.\nremove_user_avatar: Avatar entfernen\nimage_lightbox_in_list_help: Wenn aktiviert werden Bilder in einem Pop-Up Dialog\n  vergrößert dargestellt nachdem man auf ein Vorschaubild geklickt hat. \n  Andernfalls wird das Thema geöffnet.\nremove_user_cover: Banner entfernen\nshow_thumbnails_help: Zeige Vorschaubilder.\nshow_new_icons: Zeige das Neu-Icon\nshow_new_icons_help: Zeige ein Icon neben neuen Magazinen/Nutzern an (30 Tage \n  alt oder jünger)\nshow_users_avatars_help: Zeige Avatare von Nutzern.\nshow_magazines_icons_help: Zeige Icons von Magazinen.\ntoolbar.emoji: Emoji\n2fa.manual_code_hint: Falls du den QR-Code nicht einlesen kannst, gib den \n  Schlüssel manuell ein\nmagazine_instance_defederated_info: Die Instanz dieses Magazins ist deföderiert.\n  Das Magazine wird deshalb keine Aktualisierungen erhalten.\nuser_instance_defederated_info: Die Instanz dieses Nutzers ist deföderiert.\nflash_thread_instance_banned: Die Instanz dieses Magazines ist gesperrt.\nshow_rich_mention_help: Zeige eine Nutzer-Komponente an wenn ein Nutzer erwähnt \n  wird. Dies beinhaltet deren Anzeigename und Avatar.\nshow_rich_mention_magazine_help: Zeige eine Magazin-Komponente an wenn ein \n  Magazine erwähnt wird. Dies beinhaltet dessen Anzeigename und Icon.\nshow_rich_ap_link_help: Zeige eine Komponente wenn anderer ActivityPub-Inhalt \n  verlinkt wird.\nattitude: Einstellung\nshow_rich_mention: Schöne Erwähnungen\nshow_rich_mention_magazine: Schöne Magazin-Erwähnungen\nshow_rich_ap_link: Schöne AP Links\ntype_search_term_url_handle: Suchbegriff, URL oder Nutzername eingeben\nsearch_type_magazine: Magazine\nsearch_type_user: Nutzer\nsearch_type_actors: Magazine + Nutzer\nsearch_type_content: Themen + Mikroblogs\ntype_search_magazine: Suche auf Magazin eingrenzen...\ntype_search_user: Suche auf Author eingrenzen...\nmodlog_type_entry_deleted: Thema gelöscht\nmodlog_type_entry_restored: Thema wiederhergestellt\nmodlog_type_entry_comment_deleted: Themen-Kommentar gelöscht\nmodlog_type_entry_comment_restored: Themen-Kommentar wiederhergestellt\nmodlog_type_entry_pinned: Thema angeheftet\nmodlog_type_entry_unpinned: Thema gelöst\nmodlog_type_post_deleted: Mikroblog gelöscht\nmodlog_type_post_restored: Mikroblog wiederhergestellt\nmodlog_type_post_comment_deleted: Mikroblog-Kommentar gelöscht\nmodlog_type_post_comment_restored: Mikroblog-Kommentar wiederhergestellt\nmodlog_type_ban: Nutzer vom Magazine gebannt\nmodlog_type_moderator_add: Magazin-Moderator hinzugefügt\nmodlog_type_moderator_remove: Magazin-Moderator entfernt\nbanner: Banner\nmagazine_theme_appearance_banner: Nutzerdefiniertes Banner für das Magazin. Es \n  wird über allen Themen angezeigt und sollte in einem breiten Format sein (5:1 \n  oder 1500px * 300px).\neveryone: Jeder\nnobody: Niemand\nfollowers_only: Nur Follower\ndirect_message_setting_label: Wer kann dir eine Direktnachricht senden\ndelete_magazine_icon: Magazin Icon löschen\nflash_magazine_theme_icon_detached_success: Magazin Icon erfolgreich gelöscht\ndelete_magazine_banner: Magazin Banner löschen\nflash_magazine_theme_banner_detached_success: Magazin Banner erfolgreich \n  gelöscht\ncrosspost: Cross-Post\nflash_thread_ref_image_not_found: Das Bild, welches durch 'imageHash' \n  referenziert wird, konnte nicht gefunden werden.\nfederation_uses_allowlist: Nutze eine Erlaubnisliste für Föderation\ndefederating_instance: Deföderation von Instanz %i\ntheir_user_follows: Anzahl an Nutzern von deren Instanz die Nutzern auf unserer \n  Instanz folgen\nour_user_follows: Anzahl an Nutzern von unserer Instanz die Nutzern auf deren \n  Instanz folgen\ntheir_magazine_subscriptions: Anzahl an Nutzern von deren Instanz die ein \n  Magazin auf unserer Instanz abonniert haben\nour_magazine_subscriptions: Anzahl an Nutzern von unserer Instanz die ein \n  Magazin auf deren Instanz abonniert haben\nconfirm_defederation: Deföderation bestätigen\nflash_error_defederation_must_confirm: Du musst die Deföderation bestätigen\nallowed_instances: Erlaubte Instanzen\nbtn_deny: Verweigern\nbtn_allow: Erlauben\nban_instance: Instanz sperren\nallow_instance: Instanz erlauben\nfederation_page_use_allowlist_help: Wenn eine Erlaubnisliste genutzt wird, wird \n  diese Instanz nur mit explizit erlaubten Instanzen föderieren. Andernfalls \n  wird diese Instanz mit allen Instanzen föderieren, mit Ausnahme derer die \n  gesperrt sind.\nban_expires: Sperre läuft ab\nyou_have_been_banned_from_magazine: Du wurdest in Magazine %m gesperrt.\nyou_have_been_banned_from_magazine_permanently: Du wurdest dauerhaft in Magazin \n  %m gesperrt.\nyou_are_no_longer_banned_from_magazine: Du ist nicht mehr in Magazin %m \n  gesperrt.\nfront_default_content: Startseiten Standard-Ansicht\ndefault_content_default: Server Standard (Themen)\ndefault_content_combined: Themen + Mikroblog\ndefault_content_threads: Themen\ndefault_content_microblog: Mikroblog\ncombined: Kombiniert\nsidebar_sections_random_local_only: '\"Zufällige Themen/Posts\" in der Seitenleiste\n  auf lokale Themen beschränken'\nsidebar_sections_users_local_only: '\"Aktive Nutzer\" in der Seitenleiste auf lokale\n  beschränken'\nrandom_local_only_performance_warning: Aktivierung von \"Zufällige nur lokal\" \n  kann zu SQL Performance Problemen führen.\noauth2.grant.moderate.entry.lock: Themen in deinen moderierten Magazinen \n  sperren, damit niemand darunter Kommentieren kann\noauth2.grant.moderate.post.lock: Mikroblogs in deinen moderierten Magazinen \n  sperren, damit niemand darunter kommentieren kann\ndiscoverable: Auffindbar\nuser_discoverable_help: Wenn dies aktiviert ist können dein Profil, deine \n  Themen, Mikroblogs und Kommentare über die Suche und die zufälligen Panele \n  gefunden werden. Dein Profil könnte außerdem in dem aktive Nutzer Panel und \n  auf der Personen Seite angezeigt werden. Wenn dies deaktiviert ist sind deine \n  Posts nach wie vor sichtbar für andere Nutzer, sie werden aber nicht in dem \n  Alle Feed angezeigt.\nmagazine_discoverable_help: Wenn dies aktiviert ist kann dieses Magazin und alle\n  Themen, Mikroblogs und Kommentar in diesem Magazin über die Suche und die \n  zufälligen Panele gefunden werden. Wenn dies deaktiviert ist wird dieses \n  Magazin nach wie vor in der Magazinliste angezeigt, aber Themen und Mikroblogs\n  werden nicht in dem Alle Feed angezeigt.\nflash_thread_lock_success: Thema erfolgreich gesperrt\nflash_thread_unlock_success: Thema erfolgreich entsperrt\nflash_post_lock_success: Mikroblog erfolgreich gesperrt\nflash_post_unlock_success: Mikroblog erfolgreich entsperrt\nlock: Sperren\nunlock: Entsperren\ncomments_locked: Die Kommentare sind gesperrt.\nmagazine_log_entry_locked: hat die Kommentar gesperrt von\nmagazine_log_entry_unlocked: hat die Kommentare entsperrt von\nmodlog_type_entry_lock: Thema gesperrt\nmodlog_type_entry_unlock: Thema entsperrt\nmodlog_type_post_lock: Mikroblog gesperrt\nmodlog_type_post_unlock: Mirkoblog entsperrt\ncontentnotification.muted: Stummschalten | keine Benachrichtigungen erhalten\ncontentnotification.default: Standard | erhalte Benachrichtigungen anhand deiner\n  Standard-Einstellungen\ncontentnotification.loud: Laut | erhalte alle Benachrichtigungen\nindexable_by_search_engines: Indizierbar von Suchmaschinen\nuser_indexable_by_search_engines_help: Wenn diese Einstellung aus ist, werden \n  Suchmaschinen angewiesen deine Themen und Mikroblogs nicht zu indizieren, \n  allerdings werden deine Kommentare davon nicht beeinflusst und böswillige \n  Akteure könnten es ignorieren. Diese Einstellung wird auch an andere Server \n  föderiert.\nmagazine_indexable_by_search_engines_help: Wenn diese Einstellung aus ist, \n  werden Suchmaschinen angewiesen keins der Themen und Mikroblogs dieses \n  Magazins zu indizieren. Das beinhaltet die Front Seite und alle \n  Kommentar-Seiten. Diese Einstellung wird auch an andere Server föderiert.\nmagazine_name_as_tag: Nutze den Magazinnamen als Hashtag\nmagazine_name_as_tag_help: Die Hashtags eines Magazine werden genutzt um \n  Mikroblogs einem Magazin zuzuweise. Wenn ein Magazin den Namen \"fediverse\" hat\n  und dieses Magazine den Hashtag \"fediverse\" enthält, wird jeder Mikroblog mit \n  dem Hashtag \"#fediverse\" diesem Magazine zugewiesen.\nmagazine_rules_deprecated: das Regel-Feld ist veraltet und wird in der Zukunft \n  entfernt werden. Bitte schreib die Regeln in das Beschreibungs-Feld.\ncreated_since: Erstellt seit\ndisplayname: Anzeigename\nshow_boost_following_label: Zeige geboostete Inhalte in den Mikroblog und \n  Kombiniert-Feeds\nmonitoring: Überwachung\nmonitoring_queries: '{0}SQL Abfragen|{1}SQL Abfrage|]1,Inf[ SQL Abfragen'\nmonitoring_duration_min: minimum\nmonitoring_duration_mean: durchschnitt\nmonitoring_duration_max: maximum\nmonitoring_query_count: anzahl\nmonitoring_query_total: gesamt\nmonitoring_duration: Dauer\nmonitoring_dont_group_similar: ähnliche Abfragen nicht zusammenfassen\nmonitoring_group_similar: ähnliche Abfragen zusammenfassen\nmonitoring_http_method: HTTP Methode\nmonitoring_url: URL\nmonitoring_request_successful: Erfolgreich\nmonitoring_user_type: Nutzer-Typ\nmonitoring_path: Route/Nachrichten Klasse\nmonitoring_handler: Controller/Transport\nmonitoring_started: Gestartet\nmonitoring_twig_renders: Twig Render\nmonitoring_curl_requests: Curl Anfragen\nmonitoring_route_overview: Prozesslaufzeit summiert pro Route\nmonitoring_route_overview_description: Dieser Graph zeigt die summierte \n  Prozesslaufzeit in Millisekunden pro Route/Nachricht\nmonitoring_duration_overall: Andere\nmonitoring_duration_query: Abfrage\nmonitoring_duration_twig_render: Twig Render\nmonitoring_duration_curl_request: Curl Anfrage\nmonitoring_duration_sending_response: Antwort senden\nmonitoring_dont_format_query: Abfrage nicht formatieren\nmonitoring_format_query: Abfrage formatieren\nmonitoring_dont_show_parameters: Parameter nicht anzeigen\nmonitoring_show_parameters: Parameter anzeigen\nmonitoring_execution_type: Verarbeitungstyp\nmonitoring_request: HTTP Anfrage\nmonitoring_messenger: Messenger\nmonitoring_anonymous: Anonym\nmonitoring_user: Nutzer\nmonitoring_activity_pub: ActivityPub\nmonitoring_ajax: AJAX\nmonitoring_created_from: Gestartet nach\nmonitoring_created_to: Gestartet vor\nmonitoring_duration_minimum: Minimale Dauer\nmonitoring_submit: Filtern\nmonitoring_has_exception: Fehlerhaft\nmonitoring_chart_ordering: Diagramm Sortierung\nmonitoring_total_duration: Gesamtdauer\nmonitoring_mean_duration: Durchschnittsdauer\nmonitoring_twig_compare_to_total: Vergleiche mit Gesamtdauer\nmonitoring_twig_compare_to_parent: Vergleiche mit übergeordneter Dauer\nmonitoring_disabled: Überwachung ist deaktiviert.\nmonitoring_queries_enabled_persisted: Abfragenüberwachung ist aktiviert.\nmonitoring_queries_enabled_not_persisted: Abfragenüberwachung ist aktiviert, \n  aber nur für die Prozessdauer.\nmonitoring_queries_disabled: Abfragenüberwachung ist deaktiviert.\nmonitoring_twig_renders_enabled_persisted: Twig-Render-Überwachung ist \n  aktiviert.\nmonitoring_twig_renders_enabled_not_persisted: Twig-Render-Überwachung, aber nur\n  für die Prozessdauer.\nmonitoring_twig_renders_disabled: Twig-Render-Überwachung ist deaktiviert.\nmonitoring_curl_requests_enabled_persisted: Curl-Anfragen-Überwachung ist \n  aktiviert.\nmonitoring_curl_requests_enabled_not_persisted: Curl-Anfragen-Überwachung ist \n  aktiviert, aber nur für die Prozessdauer.\nmonitoring_curl_requests_disabled: Curl-Anfragen-Überwachung ist deaktiviert.\nreached_end: Du hast das Ende erreicht\nfirst_page: Erste Seite\nnext_page: Nächste Seite\nprevious_page: Vorherige Seite\nfilter_list_create: Filter-Liste erstellen\nfilter_lists: Filterlisten\nfilter_lists_where_to_filter: Wo soll der Filter angewandt werden\nfilter_lists_filter_words: Gefilterte Wörter\nexpiration_date: Ablaufdatum\nfilter_lists_filter_location: Aktiv in\nfilter_lists_word_exact_match: Exakte Übereinstimmung\nfilter_lists_word_exact_match_help: Wenn exakte Übereinstimmung aktiviert ist, \n  wird die Suche die Groß-/Kleinschreibung beachten\nfeeds: Feeds\nfilter_lists_feeds_help: Filtere die Wörter in Themen, Mikroblogs und \n  Kommentaren in Feeds, wie zum Beispiel /all, /sub, Magazin-Feeds, etc.\nfilter_lists_comments_help: Filtere die Wörter während du Themen oder Mikroblogs\n  anschaust in den Kommentaren.\nfilter_lists_profile_help: Filtere die Wörter während du dir das Profil eines \n  Nutzers und seine Inhalte ansiehst.\nexpired: Abgelaufen\n"
  },
  {
    "path": "translations/messages.el.yaml",
    "content": "type.link: Σύνδεσμος\ntype.article: Νήμα\ntype.photo: Φωτογραφία\ntype.video: Βίντεο\ntype.magazine: Περιοδικό\nthread: Νήμα\nthreads: Νήματα\nmicroblog: Μικροϊστολόγιο\nevents: Γεγονότα\nmagazine: Περιοδικό\nmagazines: Περιοδικά\nsearch: Αναζήτηση\nadd: Προσθήκη\nselect_channel: Επίλεξε ένα κανάλι\nlogin: Σύνδεση\nactive: Ενεργά\nnewest: Νεότερα\noldest: Παλαιότερα\ncommented: Με σχόλια\nfilter_by_time: Φιλτράρισμα βάσει χρόνου\nfilter_by_type: φιλτράρισμα βάσει τύπου\nfavourites: Θετικοί ψήφοι\nfavourite: Αγαπημένο\navatar: Άβαταρ\nadded: Προστέθηκε\nno_comments: Χωρίς σχόλια\ncreated_at: Δημιουργήθηκε\nsubscribers: Συνδρομητές\nonline: Σε σύνδεση\ncomments: Σχόλια\nposts: Αναρτήσεις\nreplies: Απαντήσεις\nmoderators: Διαχειριστές\nadd_post: Προσθήκη ανάρτησης\nadd_media: Προσθήκη πολυμέσων\nmarkdown_howto: Πώς λειτουργεί ο συντάκτης;\nactivity: Δραστηριότητα\ncover: Εξώφυλλο\nrelated_posts: Σχετικές αναρτήσεις\nunsubscribe: Απεγγραφή\nsubscribe: Εγγραφή\nfollow: Ακολούθησε\nreply: Απάντησε\npassword: Κωδικός\nremember_me: Να με θυμάσαι\ntop: Κορυφαία\nyou_cant_login: Ξέχασες τον κωδικό σου;\nalready_have_account: Έχεις ήδη λογαριασμό;\nregister: Εγγραφή\nreset_password: Επαναφορά κωδικού\nshow_more: Δείξε περισσότερα\nemail: Email\nrepeat_password: Επανάληψη κωδικού\nterms: Όροι χρήσης\nprivacy_policy: Πολιτική απορρήτου\nabout_instance: Σχετικά\nall_magazines: Όλα τα περιοδικά\nstats: Στατιστικά\nfediverse: Fediverse\nadd_new_article: Προσθήκη νέου νήματος\nadd_new_link: Προσθήκη νέου συνδέσμου\nadd_new_photo: Προσθήκη νέας φωτογραφίας\nadd_new_post: Προσθήκη νέας ανάρτησης\nadd_new_video: Προσθήκη νέου βίντεο\ncontact: Επικοινωνία\nrss: RSS\nchange_theme: Αλλαγή θέματος\nuseful: Χρήσιμα\nhelp: Βοήθεια\ncheck_email: \"'Ελεγξε το email σου\"\nreset_check_email_desc2: Αν δεν έλαβες το email παρακαλώ κοίτα το φάκελο με τα \n  ανεπιθύμητα.\ntry_again: Προσπάθησε ξανά\nemail_confirm_header: Γεια! Επιβεβαίωσε την διεύθυνση email σου.\nemail_verify: Επιβεβαίωσε την διεύθυνση email\nemail_confirm_title: Επιβεβαίωσε την διεύθυνση email.\nselect_magazine: Επίλεξε ένα περιοδικό\nadd_new: Προσθήκη νέου\nurl: URL\ntitle: Τίτλος\ntags: Ετικέτες\nbadges: Σήματα\nis_adult: 18+ / NSFW\neng: ENG\noc: OC\nimage: Εικόνα\nname: Όνομα\nrules: Κανόνες\nfollowers: Ακόλουθοι\nsubscriptions: Εγγραφές\ncards: Κάρτες\ncolumns: Στήλες\nuser: Χρήστης\njoined: Έγινε μέλος\npeople_local: Τοπικά\nhot: Σε τάση\ndont_have_account: Δεν έχεις λογαριασμό;\nchange_view: Αλλαγή όψης\nusername: Όνομα χρήστη\nmore: Περισσότερα\nagree_terms: Δέχομαι τους %terms_link_start%Όρους και \n  Προϋποθέσεις%terms_link_end% και την %policy_link_start%Πολιτική \n  Απορρήτου%policy_link_end%\nowner: Ιδιοκτήτης\ncreate_new_magazine: Δημιουργία νέου περιοδικού\nmod_log: Καταγραφές συντονισμού\nfaq: Συχνές ερωτήσεις (FAQ)\nadd_comment: Προσθήκη σχολίου\nreset_check_email_desc: Αν υπάρχει ήδη λογαριασμός με το email σου, σύντομα θα \n  λάβεις ένα email που θα περιέχει σύνδεσμο ώστε να επαναφέρεις τον κωδικό σου. \n  Ο σύνδεσμος θα λήξει σε %expire%.\nrandom_posts: Τυχαίες αναρτήσεις\nemail_confirm_content: 'Θες να ενεργοποιήσεις τον λογαριασμό σου στο Μbin; Πάτα στον\n  παρακάτω σύνδεσμο:'\nfederated_user_info: Αυτό το προφίλ είναι από έναν συνενωμένο διακομιστή και \n  μπορεί να είναι ελλιπές.\nemail_confirm_expire: Λάβετε υπόψη ότι ο σύνδεσμος θα λήξει σε μία ώρα.\ndescription: Περιγραφή\npeople: Άτομα\nimage_alt: Εναλλακτικό κείμενο εικόνας\ndomain: Τομέας\noverview: Επισκόπηση\nrelated_tags: Σχετικές ετικέτες\ngo_to_content: Μετάβαση στο περιεχόμενο\ngo_to_filters: Μετάβαση στα φίλτρα\ngo_to_search: Μετάβαση στην αναζήτηση\nsubscribed: Εγγεγραμμένος\nall: Όλα\nlogout: Αποσύνδεση\nclassic_view: Κλασική προβολή\ncompact_view: Συμπαγής προβολή\nchat_view: Προβολή συνομιλίας\ntree_view: Προβολή δέντρου\ntable_view: Προβολή πίνακα\ncards_view: Προβολή καρτών\n3h: 3ώ\n6h: 6ώ\n12h: 12ώ\n1w: 1εβδ\n1m: 1μ\nlinks: Σύνδεσμοι\narticles: Νήματα\nphotos: Φωτογραφίες\nvideos: Βίντεο\nreport: Αναφορά\ncopy_url: Αντιγραφή Mbin URL\ncopy_url_to_fediverse: Αντιγραφή πρωτότυπου URL\nshare_on_fediverse: Κοινοποίηση στο Fediverse\nare_you_sure: Σίγουρα;\nreason: Αιτιολογία\ndelete: Διαγραφή\nedit_post: Επεξεργασία ανάρτησης\nedit_comment: Αποθήκευση αλλαγών\nsettings: Ρυθμίσεις\nprofile: Προφίλ\nblocked: Μπλοκαρισμένοι\nreports: Αναφορές\nnotifications: Ειδοποιήσεις\nmessages: Μηνύματα\nappearance: Εμφάνιση\nhide_adult: Απόκρυψη περιεχομένου NSFW\nprivacy: Ιδιωτικότητα\nshow_profile_subscriptions: Εμφάνιση εγγραφών σε περιοδικά\nnotify_on_new_entry_comment_reply: Απαντήσεις στα σχόλια μου σε οποιοδήποτε νήμα\nnotify_on_new_post_reply: Απαντήσεις οποιοδήποτε επιπέδου σε αναρτήσεις που \n  εξουσιοδότησα\nnotify_on_new_entry: Νέα νήματα (σύνδεσμοι ή άρθρα) σε οποιοδήποτε περιοδικό στο\n  οποίο έχω εγγραφεί\nsave: Αποθήκευση\nabout: Σχετικά\nold_email: Τρέχον email\nnew_email: Νέο email\nnew_email_repeat: Επιβεβαίωση νέου email\ncurrent_password: Τρέχων κωδικός πρόσβασης\nnew_password: Νέος κωδικός πρόσβασης\nchange_email: Αλλαγή email\nchange_password: Αλλαγή κωδικού πρόσβασης\nexpand: Ανάπτυξη\ncollapse: Σύμπτυξη\ndomains: Τομείς\nerror: Σφάλμα\nvotes: Ψήφοι\ntheme: Θέμα\ndark: Σκοτεινό\nlight: Φωτεινό\nsolarized_light: Solarized Φωτεινό\nfont_size: Μέγεθος γραμματοσειράς\nsize: Μέγεθος\nboosts: Ενισχύσεις\nshow_users_avatars: Εμφάνιση άβαταρ των χρηστών\nyes: Ναι\nno: Όχι\nshow_thumbnails: Εμφάνιση μικρογραφιών\nrounded_edges: Στρογγυλεμένες άκρες\nremoved_thread_by: έχει αφαιρέσει ένα νήμα από\nremoved_comment_by: έχει αφαιρέσει ένα σχόλιο από\nrestored_comment_by: έχει επαναφέρει το σχόλιο από\nrestored_post_by: έχει επαναφέρει την ανάρτηση από\nhe_banned: αποκλεισμός\nhe_unbanned: άρση αποκλεισμού\nread_all: Ανάγνωση όλων\nshow_all: Εμφάνιση όλων\nflash_thread_edit_success: Το νήμα επεξεργάστηκε επιτυχώς.\nflash_thread_delete_success: Το νήμα διαγράφηκε επιτυχώς.\nflash_thread_pin_success: Το νήμα καρφιτσώθηκε επιτυχώς.\nflash_thread_unpin_success: Το νήμα ξεκαρφιτσώθηκε επιτυχώς.\nflash_magazine_edit_success: Το περιοδικό έχει επεξεργαστεί επιτυχώς.\ntoo_many_requests: Υπέρβαση του ορίου, δοκιμάστε ξανά αργότερα.\nset_magazines_bar_desc: πρόσθεσε τα ονόματα των περιοδικών μετά το κόμμα\nadded_new_thread: Πρόσθεσε νέο νήμα\nedited_thread: Επεξεργάστηκε ένα νήμα\nmod_remove_your_thread: Ένας συντονιστής αφαίρεσε το νήμα σου\nadded_new_comment: Πρόσθεσε νέο σχόλιο\nedited_comment: Επεξεργάστηκε ένα σχόλιο\nmod_deleted_your_comment: Ένας συντονιστής διέγραψε το σχόλιο σου\nadded_new_post: Πρόσθεσε μια νέα ανάρτηση\nmod_remove_your_post: Ένας συντονιστής αφαίρεσε την ανάρτησή σου\nadded_new_reply: Πρόσθεσε μια νέα απάντηση\nwrote_message: Έγραψε ένα μήνυμα\nmentioned_you: Σε ανέφερε\ncomment: Σχόλιο\npost: Ανάρτηση\nsend_message: Αποστολή άμεσου μηνύματος\nmessage: Μήνυμα\ninfinite_scroll: Άπειρη κύλιση\nsticky_navbar: Καρφιτσωμένη μπάρα πλοήγησης\nsubject_reported: Το περιεχόμενο έχει αναφερθεί.\nleft: Αριστερά\nright: Δεξιά\nstatus: Κατάσταση\non: Ενεργό\noff: Ανενεργό\nupload_file: Ανέβασμα αρχείου\nfrom_url: Από url\nmagazine_panel: Πίνακας περιοδικών\nreject: Απόρριψη\nfilters: Φίλτρα\napproved: Εγκρίθηκε\nrejected: Απορρίφθηκε\nadd_moderator: Προσθήκη συντονιστή\ncreated: Δημιουργήθηκε\nexpires: Λήγει\nperm: Μόνιμο\nexpired_at: Έληξε στις\nicon: Εικονίδιο\ndone: Ολοκληρώθηκε\npin: Καρφίτσωμα\nunpin: Ξεκαρφίτσωμα\nchange_language: Αλλαγή γλώσσας\nchange: Αλλαγή\npinned: Καρφιτσωμένο\npreview: Προεπισκόπηση\narticle: Νήμα\nreputation: Φήμη\nwriting: Γραφή\nusers: Χρήστες\ncontent: Περιεχόμενο\nweek: Εβδομάδα\nweeks: Εβδομάδες\nmonth: Μήνας\nmonths: Μήνες\nyear: Έτος\nadmin_panel: Πίνακας Διαχειριστή\ndashboard: Ταμπλό\ncontact_email: Email επικοινωνίας\npages: Σελίδες\ntype_search_term: Πληκτρολόγησε τον όρο αναζήτησης\nregistrations_enabled: Εγγραφή ενεργή\nregistration_disabled: Εγγραφή ανενεργή\nrestore: Επαναφορά\nadd_mentions_posts: Προσθήκη ετικετών αναφοράς σε αναρτήσεις\nPassword is invalid: Ο κωδικός είναι μη έγκυρος.\nYour account has been banned: Ο λογαριασμός σου έχει αποκλειστεί.\nfirstname: Όνομα\nsend: Αποστολή\nactive_users: Ενεργοί χρήστες\nrandom_entries: Τυχαία νήματα\ndelete_account: Διαγραφή λογαριασμού\npurge_account: Εκκαθάριση λογαριασμού\nban_account: Αποκλεισμός λογαριασμού\nrelated_magazines: Σχετικά περιοδικά\nsidebar: Πλαϊνή μπάρα\nauto_preview: Αυτόματη προεπισκόπηση πολυμέσων\ndynamic_lists: Δυναμικές λίστες\ncaptcha_enabled: Το Captcha ενεργοποιήθηκε\nreturn: Επιστροφή\nboost: Ενίσχυση\n1d: 1η\nedit: Επεξεργασία\nnotify_on_new_entry_reply: Σχόλια οποιουδήποτε επιπέδου σε νήματα που \n  εξουσιοδότησα\n1y: 1χρ\ngeneral: Γενικά\nsolarized_dark: Solarized Σκοτεινό\nshare: Κοινοποίηση\nhomepage: Αρχική\nnotify_on_new_post_comment_reply: Απαντήσεις στα σχόλια μου σε οποιεσδήποτε \n  αναρτήσεις\nfeatured_magazines: Παρεχόμενα περιοδικά\nnotify_on_new_posts: Νέες αναρτήσεις σε οποιοδήποτε περιοδικό στο οποίο έχω \n  εγγραφεί\nshow_magazines_icons: Εμφάνιση εικονιδίων περιοδικών\nrestored_thread_by: έχει επαναφέρει ένα νήμα από\nnew_password_repeat: Επιβεβαίωση νέου κωδικού πρόσβασης\nremoved_post_by: έχει αφαιρέσει την ανάρτηση από\nflash_thread_new_success: Το νήμα δημιουργήθηκε επιτυχημένα και είναι ορατό σε \n  άλλους χρήστες.\nflash_magazine_new_success: Το περιοδικό δημιουργήθηκε επιτυχώς. Τώρα μπορείς να\n  προσθέσεις νέο περιεχόμενο ή να εξερευνήσεις τον πίνακα διαχείρισης του \n  περιοδικού.\nreplied_to_your_comment: Απάντησε στο σχόλιό σου\npurge: Eκκαθάριση\nshow_top_bar: Εμφάνιση μπάρας κορυφής\napprove: Αποδοχή\ntrash: Διαγραμμένα\nchange_magazine: Αλλαγή περιοδικού\nnote: Σημείωση\nlocal: Τοπικά\nFAQ: Συχνές ερωτήσεις (FAQ)\nrelated_entries: Σχετικά νήματα\nheader_logo: Λογότυπο κεφαλίδας\nset_magazines_bar_empty_desc: εάν το πεδίο είναι κενό, τα ενεργά περιοδικά \n  εμφανίζονται στη γραμμή.\nedited_post: Επεξεργάστηκε μια ανάρτηση\nsidebar_position: Θέση πλευρικής γραμμής\nadd_badge: Προσθήκη σήματος\nadd_mentions_entries: Προσθήκη ετικετών αναφοράς σε νήματα\nunban_account: Άρση αποκλεισμού λογαριασμού\nbrowsing_one_thread: Περιηγήσαι σε μόνο ένα νήμα στη συζήτηση! Όλα τα σχόλια \n  είναι διαθέσιμα στη σελίδα ανάρτησης.\nYour account is not active: Ο λογαριασμός σου δεν είναι ενεργός.\nrandom_magazines: Τυχαία περιοδικά\nremoved: Αφαιρέθηκε από συντονιστή\ndeleted: Διαγράφηκε από τον συντάκτη\nmod_log_alert: ΠΡΟΕΙΔΟΠΟΙΗΣΗ - Το αρχείο καταγραφής του συντονιστή μπορεί να \n  περιέχει δυσάρεστο ή ενοχλητικό περιεχόμενο που έχει αφαιρεθεί από τους \n  συντονιστές. Παρακαλούμε δώσε προσοχή.\ntype.smart_contract: Έξυπνο συμβόλαιο\nup_votes: Ενισχύσεις\nenter_your_comment: Εισήγαγε το σχόλιό σου\nenter_your_post: Εισήγαγε την ανάρτησή σου\ncomments_count: '{0}Σχόλια|{1}Σχόλιο|]1,Inf[ Σχόλια'\nempty: Κενό\nunfollow: Κατάργηση ακολούθησης\ndown_votes: Μειώσεις\nfederated_magazine_info: Αυτό το περιοδικό είναι από έναν συνενωμένο διακομιστή \n  και ενδέχεται να είναι ελλιπές.\ngo_to_original_instance: Προβολή σε απομακρυσμένη οντότητα\nlogin_or_email: Σύνδεση ή email\nin: στο\nup_vote: Ενίσχυση\ndown_vote: Μείωση\nmoderated: Συντονισμένα\nreputation_points: Πόντοι φήμης\nbody: Κείμενο\nfollowing: Ακολουθεί\npeople_federated: Federated\nmoderate: Συντόνισε\nshow_profile_followings: Εμφάνιση χρηστών που ακολουθούνται\nflash_register_success: Καλώς ήρθες, ο λογαριασμός σου είναι πλέον \n  εγγεγραμμένος. Ένα τελευταίο βήμα! - Στα εισερχόμενά σου θα βρεις έναν \n  σύνδεσμο ενεργοποίησης ώστε να δώσεις ζωή στον λογαριασμό σου.\nset_magazines_bar: Μπάρα περιοδικών\nbanned: Σε απέκλεισε\nban_expired: Ο αποκλεισμός έληξε\nban: Αποκλεισμός\nbans: Αποκλεισμοί\nadd_ban: Προσθήκη αποκλεισμού\nfederation: Ομοσπονδία\ninstances: Οντότητες\nmeta: Meta\nfederated: Ομοσπονδιακός\ninstance: Οντότητα\nfederation_enabled: Ενεργοποιήθηκε η ομοσπονδία\nmagazine_panel_tags_info: Παροχή μόνο εάν θες περιεχόμενο από το fediverse να \n  περιλαμβάνεται σε αυτό το περιοδικό με βάση ετικέτες\nbanned_instances: Οντότητες σε αποκλεισμό\nkbin_intro_title: Εξερεύνησε το Fediverse\nkbin_intro_desc: είναι μια αποκεντρωμένη πλατφόρμα για συγκέντρωση περιεχομένου \n  και μικροϊστολόγια που λειτουργεί εντός του δικτύου Fediverse.\nkbin_promo_title: Δημιούργησε τη δική σου οντότητα\nkbin_promo_desc: '%link_start%Κλωνοποίηση αποθετηρίου%link_end% και ανάπτυξη fediverse'\nto: σε\nreport_issue: Αναφορά προβλήματος\ntokyo_night: Τόκυο Νύχτα\nmercure_enabled: Το Mercure είναι ενεργό\npreferred_languages: Φιλτράρισμα γλωσσών των νημάτων και των αναρτήσεων\ninfinite_scroll_help: Φόρτωσε αυτόματα περισσότερο περιεχόμενο όταν φτάσεις το \n  τέλος της σελίδας.\nsticky_navbar_help: Η γραμμή πλοήγησης θα καρφιτσωθεί στην κορυφή της σελίδας \n  όταν κάνεις κύλιση προς τα κάτω.\nauto_preview_help: Εμφάνιση των προεπισκοπήσεων πολυμέσων (φωτό, βίντεο) σε \n  μεγαλύτερο μέγεθος κάτω απ' το περιεχόμενο.\nreload_to_apply: Ανανέωση σελίδας για να εφαρμοστούν οι αλλαγές\nfilter.fields.label: Επιλογή πεδίων για αναζήτηση\nfilter.fields.only_names: Μόνο ονόματα\nfilter.fields.names_and_descriptions: Ονόματα και περιγραφές\nfilter.adult.hide: Απόκρυψη NSFW\nfilter.adult.show: Εμφάνιση NSFW\nfilter.adult.label: Επιλογή εμφάνισης περιεχομένου NSFW\nfilter.adult.only: Μόνο NSFW\ntoolbar.bold: Έντονα\nyour_account_is_not_active: Ο λογαριασμός σου δεν έχει ενεργοποιηθεί. Παρακαλώ \n  έλεγξε το email σου για οδηγίες περί ενεργοποίησης λογαριασμού ή <a \n  href=\"%link_target%\"> αιτήσου ένα νέο email ενεργοποιήσης λογαριασμού.</a>\nyour_account_has_been_banned: Ο λογαριασμός σου έχει αποκλειστεί\nfilter.origin.label: Επιλογή προέλευσης\nkbin_bot: Πράκτορας Mbin\ntoolbar.header: Κεφαλίδα\ntoolbar.quote: Παράθεση\ntoolbar.code: Κώδικας\ntoolbar.link: Σύνδεσμος\ntoolbar.image: Εικόνα\ntoolbar.mention: Αναφορά\npassword_confirm_header: Επιβεβαίωσε το αίτημα αλλαγής κωδικού πρόσβασης.\nfilter_by_subscription: Φιλτράρισμα βάσει εγγραφής\nfilter_by_federation: Φιλτράρισμα βάσει κατάστασης ομοσπονδίας\nsort_by: Ταξινόμηση βάσει\nsubscribers_count: '{0}Εγγεγραμμένοι|{1}Εγγεγραμμένος|]1,Inf[ Εγγεγραμμένοι'\nfollowers_count: '{0}Ακόλουθοι|{1}Ακόλουθος|]1,Inf[ Ακόλουθοι'\nmarked_for_deletion: Επισημάνθηκε για διαγραφή\nmarked_for_deletion_at: Επισημάνθηκε για διαγραφή στις %date%\nremove_media: Αφαίρεση πολυμέσων\nedit_entry: Επεξεργασία νήματος\ndefault_theme_auto: Ανοιχτό/Σκοτεινό (Αυτόματος Εντοπισμός)\nunban: Άρση αποκλεισμού\nban_hashtag_btn: Αποκλεισμός Ετικέτας\nban_hashtag_description: Αποκλείοντας μία ετικέτα θα αποτρέπει τη δημιουργία \n  αναρτήσεων μ' αυτή την ετικέτα, αλλά και θα αποκρύπτει υπάρχουσες μ' αυτή την \n  ετικέτα.\nunban_hashtag_description: Αφαιρόντας τον αποκλεισμό μιας ετικέτας θα \n  επιτρέπεται η δημιουργία αναρτήσεων μ' αυτή την ετικέτα ξανά. Υπάρχουσες \n  αναρτήσεις μ' αυτή δεν θα κρύβονται.\nmark_as_adult: Επισήμανση ως NSFW\nunmark_as_adult: Άρση επισήμανσης ως NSFW\ntag: Ετικέτα\nfrom: από\ndefault_theme: Προεπιλεγμένο θέμα\nsolarized_auto: Solarized (Αυτόματος Εντοπισμός)\nlocal_and_federated: Τοπική και σε ομοσπονδία\nmenu: Μενού\nflash_mark_as_adult_success: Αυτή η ανάρτηση έχει επισημανθεί επιτυχώς ως NSFW.\nflash_unmark_as_adult_success: Αυτή η ανάρτηση δεν είναι πλέον επισημασμένη ως \n  NSFW.\ndisconnected_magazine_info: Αυτό το περιοδικό δεν λαμβάνει ενημερώσεις \n  (τελευταία δραστηριότητα %days% ημέρα/-ες πριν).\nalways_disconnected_magazine_info: Αυτό το περιοδικό δεν λαμβάνει ενημερώσεις.\nsubscribe_for_updates: Κάνε εγγραφή για να λαμβάνεις ενημερώσεις.\nunban_hashtag_btn: Άρση αποκλεισμού Ετικέτας\nresend_account_activation_email_question: Ανενεργός λογαριασμός;\nfederated_search_only_loggedin: Η αναζήτηση σε οποσπονδία είναι περιορισμένη αν \n  δεν έχεις συνδεθεί\noauth.consent.to_allow_access: Για να επιτρεπεί αυτή η πρόσβαση, κάνε κλικ στο \n  κουμπί \"Επιτρέπεται\" παρακάτω\ntoolbar.unordered_list: Μη τακτοποιημένη λίστα\nfederation_page_allowed_description: Γνωστές οντότητες με τις οποίες έχουμε \n  συνενωθεί\nfederation_page_disallowed_description: Οντότητες με τις οποίες δε συνενωνόμαστε\naccount_deletion_title: Διαγραφή λογαριασμού\naccount_deletion_button: Διαγραφή Λογαριασμού\nerrors.server404.title: 404 Δε βρέθηκε\nerrors.server500.title: 500 Εσωτερικό Σφάλμα Διακομιστή\nemail_confirm_link_help: Εναλλακτικά μπορείς να κάνεις αντιγραφή επικόλληση στον\n  περιηγητή σου το ακόλουθο\nbot_body_content: \"Καλώς ήρθατε στον Πράκτορα του Mbin! Αυτός ο πράπτορας έχει καθοριστικό\n  ρόλο στην διατήρηση της λειτουργικότητας του ActivityPub εντός του Mbin. Εξασφαλίζει\n  ότι το Mbin μπορεί να επικοινωνεί και να συνενώνεται με άλλες οντότητες στο fediverse.\\n\\\n  \\ \\nΤο ActivityPub είναι ένα ανοιχτό πρωτόκολλο που επιτρέπει στις αποκεντρωμένες\n  πλατφόρμες κοινωνικής δικτύωσης να επικοινωνούν και να αλληλεπιδρούν μεταξύ τους.\n  Επιτρέπει στους χρήστες σε διαφορετικές οντότητες (διακομιστές) να ακολουθούν, να\n  αλληλεπιδρούν και να μοιράζονται περιεχόμενο μέσω του ομοσπονδιακού κοινωνικού δικτύου\n  που είναι γνωστό ως fediverse. Παρέχει έναν τυποποιημένο τρόπο στους χρήστες να\n  δημοσιεύουν περιεχόμενο, να ακολουθούν άλλους χρήστες και να συμμετέχουν σε κοινωνικές\n  αλληλεπιδράσεις όπως να τους αρέσει, να μοιράζονται και να σχολιάζουν νήματα ή αναρτήσεις.\"\ntoolbar.strikethrough: Γραμμή διαγραφής\ntoolbar.ordered_list: Τακτοποιημένη Λίστα\nfederation_page_enabled: Σελίδα ομοσπονδίας ενεργή\nresend_account_activation_email_error: Υπήρξε ένα ζήτημα κατοχύρωσης αυτού του \n  αιτήματος. Μπορεί να μην υπάρχει λογαριασμός που να σχετίζεται μ' αυτό το \n  email ή ίσως έχει ήδη ενεργοποιηθεί.\nblock: Αποκλεισμός\ntoolbar.italic: Πλάγια\nfederation_page_dead_title: Νεκρές οντότητες\nerrors.server429.title: 429 Υπερβολικά Πολλές Αιτήσεις\naccount_deletion_description: Ο λογαριασμός σου θα διαγραφεί σε 30 ημέρες εκτός \n  αν επιλέξεις να τον διαγράψεις αμέσως. Για να τον επαναφέρεις εντός 30 ημέρων,\n  συνδέσου με τα ίδια στοιχεία σύνδεσης χρήστη ή επικοινώνησε με έναν \n  διαχειριστή.\naccount_deletion_immediate: Άμεση διαγραφή\nmore_from_domain: Περισσότερα απ' τον τομέα\noauth.consent.app_requesting_permissions: θα 'θελε να πραγματοποιήσει τις \n  ακόλουθες ενέργειες εκ μέρους σου\noauth.consent.allow: Επιτρέπεται\nresend_account_activation_email_description: Εισήγαγε τη διεύθυνση email που \n  σχετίζεται με τον λογαριασμό σου. Θα σου στείλουμε άλλο ένα email \n  ενεργοποίησης.\noauth.consent.app_has_permissions: μπορεί ήδη να πραγματοποιήσει τις ακόλουθες \n  ενέργειες\ncustom_css: Προσαρμοσμένη CSS\nfederation_page_dead_description: Οντότητες στις οποίες δε μπορέσαμε να \n  παραδώσουμε 10 δραστηριότητες στη σειρά και όπου η τελευταία παράδοση και \n  παραλαβή ήταν μια εβδομάδα πριν\nerrors.server500.description: Συγγνώμη, κάτι πήγε στραβά από τη πλευρά μας. Αν \n  συνεχίζεις να βλέπεις αυτό το σφάλμα, δοκίμασε να επικοινωνήσεις με τον \n  ιδιοκτήτη της οντότητας. Αν η οντότητα δε δουλεύει καθόλου, ρίξε μια ματιά \n  εντωμεταξύ, σε %link_start%άλλες οντότητες του Mbin%link_end% μέχρι να \n  επιλυθεί το πρόβλημα.\nerrors.server403.title: 403 Απαγορευμένο\nemail_confirm_button_text: Επιβεβαίωσε την αίτηση για αλλαγή κωδικού\nemail.delete.title: Αίτημα διαγραφής λογαριασμού χρήστη\nemail.delete.description: Ο ακόλουθος χρήστης αιτήθηκε διαγραφής του λογαριασμού\n  του\nresend_account_activation_email: Επαναποστολή email ενεργοποίησης λογαριασμού\nresend_account_activation_email_success: Αν υπάρχει λογαριασμός που συσχετίζεται\n  μ' αυτό το email, θα στείλουμε νέο email ενεργοποίησης.\nignore_magazines_custom_css: Αγνόηση προσαρμοσμένης CSS περιοδικών\noauth.consent.title: Φόρμα Συναίνεσης OAuth2\noauth.consent.grant_permissions: Αποδοχή Δικαιωμάτων\noauth.consent.deny: Απόρριψη\noauth.client_identifier.invalid: Μη Έγκυρο ID Πελάτη OAuth!\noauth.client_not_granted_message_read_permission: Αυτή η εφαρμογή δεν έχει το \n  δικαίωμα να διαβάζει τα μηνύματά σου.\nrestrict_oauth_clients: Περιορισμός δημιουργίας Πελάτη OAuth2 για τους \n  Διαχειριστές\nprivate_instance: Υποχρέωσε τους χρήστες να συνδεθούν πριν μπορούν να έχουν \n  πρόσβαση σε περιεχόμενο\noauth2.grant.entry.vote: Ψήφισε θετικά ή αρνητικά, ενίσχυσε οποιοδήποτε νήμα.\noauth2.grant.entry.report: Ανέφερε οποιοδήποτε νήμα.\noauth2.grant.entry_comment.create: Δημιούργησε νέα σχόλια σε νήματα.\noauth2.grant.domain.block: Απέκλεισε τομείς ή αίρε τον αποκλεισμό και δες τους \n  τομείς που έχεις αποκλείσει.\noauth2.grant.subscribe.general: Κάνε εγγραφή ή ακολούθησε οποιοδήποτε περιοδικό,\n  τομέα ή χρήστη και δες τα περιοδικά, τους τομείς και τους χρήστες που \n  εγγράφεσαι.\noauth2.grant.moderate.magazine.reports.all: Διαχειρίσου αναφορές στα περιοδικά \n  που συντονίζεις.\noauth2.grant.domain.all: Εγγράψου σε τομείς ή απέκλεισέ τους και δες τους τομείς\n  στους οποίους έχεις εγγραφεί ή αποκλείσει.\noauth2.grant.domain.subscribe: Εγγράψου ή κάνε απεγγραφή σε τομείς και δες τους \n  τομείς στους οποίους έχεις εγγραφεί.\noauth2.grant.entry.edit: Επεξεργάσου τα υπάρχοντα σου νήματα.\noauth2.grant.moderate.magazine_admin.stats: Δες το περιεχόμενο, τις ψήφους και \n  τα στατιστικά προβολής των ιδιόκτητων περιοδικών σου.\noauth2.grant.entry_comment.edit: Επεξεργάσου τα υπάρχοντα σχόλιά σου σε νήματα.\noauth2.grant.moderate.magazine_admin.delete: Διέγραψε κάποιο απ' τα ιδιότητα \n  περιοδικά σου.\noauth2.grant.moderate.magazine.trash.read: Προβολή περιεχομένου απορριμάτων στα \n  περιοδικά που συντονίζεις.\noauth2.grant.moderate.magazine_admin.edit_theme: Επεξεργάσου την προσαρμοσμένη \n  CSS οποιωνδήποτε από τα ιδιόκτητα περιοδικά σου.\noauth2.grant.moderate.magazine_admin.moderators: Πρόσθεσε ή αφαίρεσε συντονιστές\n  από οποιοδήποτε από τα ιδιόκτητα περιοδικά σου.\noauth2.grant.entry_comment.delete: Διέγραψε τα υπάρχοντα σχόλιά σου σε νήματα.\noauth2.grant.entry.create: Δημιούργησε νέα νήματα.\nunblock: Άρση αποκλεισμού\noauth2.grant.moderate.magazine.ban.delete: Άρση αποκλεισμού χρηστών σε περιοδικά\n  που συντονίζεις.\noauth2.grant.moderate.magazine.list: Διάβασε μια λίστα με τα περιοδικά που \n  συντονίζεις.\noauth2.grant.moderate.magazine.reports.read: Διάβασε αναφορές στα περιοδικά που \n  συντονίζεις.\noauth2.grant.moderate.magazine.reports.action: Αποδέξου ή απέρριψε αναφορές στα \n  περιοδικά που συντονίζεις.\noauth2.grant.moderate.magazine_admin.create: Δημιούργησε νέα περιοδικά.\noauth2.grant.moderate.magazine_admin.update: Επεξεργάσου οποιονδήποτε από τους \n  κανόνες, την περιγραφή, τη κατάσταση NSFW ή το εικονίδιο των ιδιόκτητων \n  περιοδικών σου.\noauth2.grant.moderate.magazine_admin.all: Δημιούργησε, επεξεργάσου ή διέγραψε τα\n  ιδιόκτητα περιοδικά σου.\noauth2.grant.moderate.magazine_admin.badges: Δημιούργησε ή αφαίρεσε τα σήματα \n  από τα ιδιόκτητα περιοδικά σου.\noauth2.grant.moderate.magazine_admin.tags: Δημιούργησε ή αφαίρεσε ετικέτες από \n  τα ιδιόκτητα περιοδικά σου.\noauth2.grant.admin.all: Εκτέλεσε οποιαδήποτε ενέργεια διαχειριστή στην οντότητά \n  σου.\noauth2.grant.admin.entry.purge: Διέγραψε πλήρως οποιοδήποτε νήμα από την \n  οντότητά σου.\noauth2.grant.read.general: Διάβασε όλο το περιεχόμενο στο οποίο έχεις πρόσβαση.\noauth2.grant.write.general: Δημιούργησε ή επεξεργάσου οποιοδήποτε από τα νήματα,\n  αναρτήσεις ή σχόλια σου.\noauth2.grant.delete.general: Διάγραψε οποιοδήποτε από τα νήματα, αναρτήσεις ή \n  σχόλια σου.\noauth2.grant.report.general: Ανάφερε νήματα, αναρτήσεις ή σχόλια.\noauth2.grant.vote.general: Δώσε θετική, αρνητική ψήφο ή ενίσχυσε νήματα, \n  αναρτήσεις ή σχόλια.\noauth2.grant.block.general: Απέκλεισε ή αίρε τον αποκλεισμό οποιουδήποτε \n  περιοδικού, τομέα ή χρήστη και δες τα περιοδικά, τους τομείς και τους χρήστες \n  που έχεις αποκλείσει.\noauth2.grant.entry.all: Δημιούργησε, επεξεργάσου ή διάγραψε τα νήματα σου και \n  ψήφισε, ενίσχυσε ή ανέφερε οποιοδήποτε νήμα.\noauth2.grant.entry.delete: Διάγραψε τα υπάρχοντα σου νήματα.\noauth2.grant.entry_comment.all: Δημιούργησε, επεξεργάσου ή διάγραψε τα σχόλιά \n  σου σε νήματα και ψήφισε, ενίσχυσε ή ανέφερε οποιοδήποτε σχόλιο σε ένα νήμα.\ndownvotes_mode: Λειτουργία αρνητικών ψήφων\nchange_downvotes_mode: Αλλαγή λειτουργίας αρνητικών ψήφων\nhidden: Κρυφό\nenabled: Ενεργό\ndisabled: Ανενεργό\noauth2.grant.magazine.block: Απέκλεισε ή κατάργησε τον αποκλεισμό περιοδικών και\n  προβολή των περιοδικών που έχεις αποκλείσει.\noauth2.grant.post.edit: Επεξεργάσου υπάρχουσες αναρτήσεις.\noauth2.grant.post.delete: Διέγραψε τις υπάρχουσες αναρτήσεις σου.\noauth2.grant.magazine.subscribe: Εγγράψου ή κατάργησε την εγγραφή σου σε \n  περιοδικά και δες τα περιοδικά στα οποία έχεις εγγραφεί.\noauth2.grant.entry_comment.vote: Δώσε θετική ή αρνητική ψήφο ή ενίσχυσε \n  οποιοδήποτε σχόλιο σ' ένα νήμα.\noauth2.grant.entry_comment.report: Ανέφερε οποιοδήποτε σχόλιο σ' ένα νήμα.\noauth2.grant.magazine.all: Κάνε εγγραφή ή απέκλεισε περιοδικά και δες τα \n  περιοδικά στα οποία έχεις εγγραφεί ή απέκλεισέ τα.\noauth2.grant.post.create: Δημιούργησε νέες αναρτήσεις.\noauth2.grant.post.all: Δημιούργησε, επεξεργάσου ή διέγραψε τα μικροϊστολόγιά σου\n  και ψήφισε, ενίσχυσε ή ανέφερε οποιοδήποτε μικροϊστολόγιο.\noauth2.grant.post.vote: Ψήφισε θετικά ή αρνητικά, ενίσχυσε οποιαδήποτε ανάρτηση.\noauth2.grant.post.report: Ανέφερε οποιαδήποτε ανάρτηση.\noauth2.grant.post_comment.edit: Επεξεργάσου τα υπάρχοντα σχόλιά σου σε \n  αναρτήσεις.\noauth2.grant.post_comment.delete: Διέγραψε τα υπάρχοντα σχόλιά σου σε \n  αναρτήσεις.\noauth2.grant.post_comment.all: Δημιούργησε, επεξεργάσου ή διέγραψε τα σχόλιά σου\n  σε αναρτήσεις και ψήφισε, ενίσχυσε ή να ανέφερε οποιοδήποτε σχόλιο σε με μια \n  ανάρτηση.\noauth2.grant.post_comment.create: Δημιούργησε νέα σχόλια σε αναρτήσεις.\noauth2.grant.user.message.create: Στείλε μηνύματα σε άλλους χρήστες.\noauth2.grant.user.notification.all: Διάβασε και καθάρισε τις ειδοποιήσεις σου.\noauth2.grant.user.notification.read: Διάβασε τις ειδοποιήσεις σου, \n  συμπεριλαμβανομένων αυτών για μηνύματα.\noauth2.grant.user.notification.delete: Καθάρισε τις ειδοποιήσεις σου.\ntoolbar.spoiler: Σπόιλερ\noauth2.grant.user.profile.all: Διάβασε και επεξεργάσου το προφίλ σου.\noauth2.grant.user.message.all: Διάβασε τα μηνύματά σου και στείλε μηνύματα σε \n  άλλους χρήστες.\noauth2.grant.user.profile.read: Διάβασε το προφίλ σου.\noauth2.grant.post_comment.vote: Ψήφισε θετικά ή αρητικά, ενίσχυσε οποιοδήποτε \n  σχόλιο σε μια ανάρτηση.\noauth2.grant.post_comment.report: Ανέφερε οποιοδήποτε σχόλιο σε μια ανάρτηση.\noauth2.grant.user.profile.edit: Επεξεργάσου το προφίλ σου.\noauth2.grant.user.message.read: Διάβασε τα μηνύματά σου.\noauth2.grant.user.oauth_clients.all: Διάβασε και επεξεργάσου τα δικαιώματα που \n  έχεις χορηγήσει σε άλλες εφαρμογές OAuth2.\noauth2.grant.user.oauth_clients.read: Διάβασε τα δικαιώματα που έχεις χορηγήσει \n  σε άλλες εφαρμογές OAuth2.\noauth2.grant.user.all: Διάβασε και επεξεργάσου το προφίλ, τα μηνύματα ή τις \n  ειδοποιήσεις σου· διάβασε και επεξεργάσου δικαιώματα που έχεις δώσει σε άλλες \n  εφαρμογές, ακολούθησε ή μπλόκαρε άλλους χρήστες· δες λίστες των χρηστών που \n  ακολουθείς ή έχεις αποκλείσει.\noauth2.grant.user.oauth_clients.edit: Επεξεργάσου τα δικαιώματα που έχεις \n  χορηγήσει σε άλλες εφαρμογές OAuth2.\noauth2.grant.user.follow: Ακολούθησε ή αφαίρεσε από ακόλουθο χρήστες και διάβασε\n  μια λίστα χρηστών που ακολουθείς.\noauth2.grant.moderate.entry.all: Συντόνισε τα νήματα στα περιοδικά που \n  συντονίζεις.\noauth2.grant.moderate.entry.change_language: Άλλαξε τη γλώσσα των νημάτων στα \n  περιοδικά που συντονίζεις.\noauth2.grant.moderate.all: Εκτέλεσε οποιαδήποτε ενέργεια συντονισμού που έχετε \n  την άδεια να εκτελέσετε στα περιοδικά που συντονίζεις.\noauth2.grant.user.block: Αποκλεισμός ή άρση αποκλεισμού χρήστη και δες την λίστα\n  χρηστών που έχεις αποκλείσει.\noauth2.grant.moderate.entry.pin: Καρφίτσωσε νήματα στην κορυφή των περιοδικών \n  που συντονίζεις.\noauth2.grant.moderate.entry.set_adult: Επισήμανση νημάτων ως NSFW στα περιοδικά \n  που συντονίζεις.\noauth2.grant.moderate.entry.trash: Απόρριψε ή ανάκτησε νήματα στα περιοδικά που \n  συντονίζεις.\noauth2.grant.moderate.entry_comment.all: Συντόνισε τα σχόλια σε νήματα στα \n  περιοδικά που συντονίζεις.\noauth2.grant.moderate.entry_comment.change_language: Άλλαξε τη γλώσσα των \n  σχολίων σε νήματα στα περιοδικά που συντονίζεις.\noauth2.grant.moderate.entry_comment.set_adult: Επισημάνετε τα σχόλια στα νήματα \n  ως NSFW στα περιοδικά που συντονίζετε.\noauth2.grant.admin.post_comment.purge: Διέγραψε εντελώς οποιοδήποτε σχόλιο σε \n  μια ανάρτηση από την οντότητα σου.\noauth2.grant.admin.entry_comment.purge: Διέγραψε εντελώς οποιοδήποτε σχόλιο σε \n  ένα νήμα από την οντότητα σου.\noauth2.grant.admin.post.purge: Διέγραψε εντελώς οποιαδήποτε ανάρτηση από την \n  οντότητα σου.\noauth2.grant.moderate.entry_comment.trash: Πέταξε ή επανέφερε σχολίων σε νήματα \n  στα περιοδικά που διαχειρίζεσαι.\noauth2.grant.moderate.post.set_adult: Επισήμανε τις αναρτήσεις ως NSFW στα \n  περιοδικά που συντονίζεις.\noauth2.grant.moderate.post_comment.set_adult: Επισήμανε τα σχόλια σε αναρτήσεις \n  ως NSFW στα περιοδικά που συντονίζεις.\noauth2.grant.moderate.post_comment.all: Συντόνισε σχόλια σε αναρτήσεις στα \n  περιοδικά που συντονίζεις.\noauth2.grant.moderate.post_comment.change_language: Άλλαξε τη γλώσσα των σχολίων\n  σε αναρτήσεις στα περιοδικά που συντονίζεις.\noauth2.grant.moderate.magazine.ban.all: Διαχειρίσου τους αποκλεισμένους χρήστες \n  στα περιοδικά που συντονίζεις.\noauth2.grant.moderate.post.all: Συντόνισε αναρτήσεις στα περιοδικά που \n  συντονίζεις.\noauth2.grant.moderate.post.change_language: Άλλαξε τη γλώσσα των αναρτήσεων στα \n  περιοδικά που συντονίζεις.\noauth2.grant.moderate.post.trash: Πέταξε ή επανέφερε τις αναρτήσεις στα \n  περιοδικά που συντονίζεις.\noauth2.grant.moderate.post_comment.trash: Πέταξε ή επανέφερε τα σχόλια σε \n  αναρτήσεις στα περιοδικά που συντονίζεις.\noauth2.grant.moderate.magazine.all: Διαχειρίσου τους αποκλεισμούς, τις αναφορές \n  και την προβολή αντικειμένων στον κάδο απορριμμάτων στα περιοδικά που \n  συντονίζεις.\noauth2.grant.moderate.magazine.ban.read: Δες τους αποκλεισμένους χρήστες στα \n  περιοδικά που συντονίζεις.\noauth2.grant.moderate.magazine.ban.create: Απέκλεισε τους χρήστες στα περιοδικά \n  που συντονίζεις.\ncomment_reply_position_help: Εμφανίζει τη φόρμα απάντησης σχολίων είτε στην \n  κορυφή ή στο κάτω μέρος της σελίδας. Όταν η \"Άπειρη κύλιση\" είναι ενεργή η \n  θέση θα είναι πάντα στην κορυφή.\nflash_post_pin_success: Η ανάρτηση έχει καρφιτσωθεί επιτυχώς.\noauth2.grant.admin.magazine.purge: Πλήρης διαγραφή περιοδικών στην οντότητα σου.\noauth2.grant.admin.user.ban: Αποκλεισμός ή άρση αποκλεισμού χρηστών από την \n  οντότητά σου.\noauth2.grant.admin.user.verify: Επαλήθευσε τους χρήστες στην οντότητά σου.\noauth2.grant.admin.federation.update: Πρόσθεσε ή αφαίρεσε οντότητες στον ή από \n  τον κατάλογο των οντοτήτων εκτός ομοσπονδίας.\noauth2.grant.admin.oauth_clients.all: Δες ή ανακάλεσε τους πελάτες OAuth2 που \n  υπάρχουν στην οντότητα σου.\noauth2.grant.admin.user.all: Αποκλεισμός, επαλήθευση ή πλήρης διαγραφή των \n  χρηστών στην οντότητά σου.\nflash_post_unpin_success: Η ανάρτηση έχει ξεκαρφιτσωθεί επιτυχώς.\nshow_avatars_on_comments: Εμφάνιση Άβαταρ Σχολίων\noauth2.grant.admin.magazine.move_entry: Μετακίνησε τα νήματα μεταξύ των \n  περιοδικών της οντότητας σου.\noauth2.grant.admin.instance.settings.read: Δες τις ρυθμίσεις στην οντότητά σου.\noauth2.grant.admin.oauth_clients.read: Δες τους πελάτες OAuth2 που υπάρχουν στην\n  οντότητα σου και τα στατιστικά χρήσης τους.\nsingle_settings: Απλό\nupdate_comment: Ενημέρωση σχολίου\noauth2.grant.admin.oauth_clients.revoke: Ανακάλεσε την πρόσβαση στους πελάτες \n  OAuth2 στην οντότητα σου.\nlast_active: Τελευταία Ενεργό\noauth2.grant.admin.instance.settings.edit: Ενημέρωσε τις ρυθμίσεις στην οντότητα\n  σου.\noauth2.grant.admin.instance.information.edit: Ενημέρωσε τα Σχετικά, FAQ, \n  Συμβόλαιο, Όρους Της Υπηρεσίας και τις σελίδες Πολιτικής Απορρήτου στην \n  οντότητά σου.\noauth2.grant.admin.magazine.all: Μετακίνησε νήματα μεταξύ ή διέγραψε εντελώς τα \n  περιοδικά στην οντότητα σου.\noauth2.grant.admin.user.delete: Διέγραψε χρήστες από την οντότητά σου.\noauth2.grant.admin.user.purge: Διέγραψε πλήρως τους χρήστες από την οντότητά \n  σου.\noauth2.grant.admin.instance.all: Δες και ενημέρωσε τις ρυθμίσεις οντότητας ή τις\n  πληροφορίες.\noauth2.grant.admin.instance.stats: Δες τα στατιστικά στοιχεία της οντότητας σου.\noauth2.grant.admin.instance.settings.all: Δες ή ενημέρωσε τις ρυθμίσεις στην \n  οντότητά σου.\noauth2.grant.admin.federation.all: Δες και ενημέρωσε τις τρέχουσες - εκτός \n  ομοσπονδίας, οντότητες.\noauth2.grant.admin.federation.read: Δες τη λίστα των οντοτήτων εκτός \n  ομοσπονδίας.\nmoderation.report.ban_user_description: Θες να αποκλείσεις τον χρήστη \n  (%username%) που δημιούργησε αυτό το περιεχόμενο από αυτό το περιοδικό;\nsubject_reported_exists: Το περιεχόμενο αυτό έχει ήδη αναφερθεί.\nmoderation.report.ban_user_title: Αποκλεισμός Χρήστη\nmagazine_theme_appearance_custom_css: Το Custom CSS που θα ισχύει κατά την \n  προβολή περιεχομένου εντός του περιοδικού σου.\nmoderation.report.approve_report_title: Έγκριση Αναφοράς\nmoderation.report.reject_report_title: Απόρριψη Αναφοράς\npurge_content: Εκκαθάριση περιεχομένου\ndelete_content: Διαγραφή περιεχομένου\nshow_avatars_on_comments_help: Εμφάνιση/απόκρυψη άβαταρ χρήστη κατά την προβολή \n  σχολίων σε ένα ενιαίο νήμα ή ανάρτηση.\ncomment_reply_position: Θέση απάντησης σχολίου\nmagazine_theme_appearance_icon: Προσαρμοσμένο εικονίδιο για το περιοδικό. Αν δεν\n  επιλεχθεί κανένα, θα χρησιμοποιηθεί το προεπιλεγμένο.\nmagazine_theme_appearance_background_image: Προσαρμοσμένη εικόνα παρασκηνίου που\n  θα εφαρμοστεί κατά την προβολή περιεχομένου εντός του περιοδικού σου.\nmoderation.report.approve_report_confirmation: Σίγουρα θες να εγκρίνεις αυτή την\n  αναφορά;\nmoderation.report.reject_report_confirmation: Σίγουρα θες να απορρίψεις αυτή την\n  αναφορά;\noauth2.grant.moderate.post.pin: Καρφίτσωσε στην κορυφή των περιοδικών που \n  συντονίζεις.\ncancel: Ακύρωση\nsubscription_sort: Ταξινόμηση\n2fa.qr_code_img.alt: Ένας κωδικός QR που επιτρέπει τη ρύθμιση ταυτότητας δύο \n  παραγόντων για τον λογαριασμό σου\ntwo_factor_backup: Εφεδρικό κωδικοί ταυτοποίησης δύο παραγόντων\n2fa.authentication_code.label: Κωδικός Ταυτοποίησης\n2fa.remove: Αφαίρεση 2FA\n2fa.disable: Απενεργοποίηση ταυτοποίησης δύο παραγόντων\n2fa.backup: Οι εφεδρικό κωδικοί ταυτοποίησης δύο παραγόντων σου\n2fa.backup_codes.recommendation: Συνιστάται να κρατήσεις ένα αντίγραφο αυτών σε \n  ασφαλή θέση.\nflash_account_settings_changed: Οι ρυθμίσεις λογαριασμού σου έχουν αλλάξει με \n  επιτυχία. Θα χρειαστεί να συνδεθείς ξανά.\nshow_subscriptions: Εμφάνιση εγγραφών\nsubscription_panel_large: Μεγάλο πάνελ\ndelete_account_desc: Διέγραψε το λογαριασμό, συμπεριλαμβανομένων των απαντήσεων \n  άλλων χρηστών σε δημιουργημένα νήματα, αναρτήσεις και σχόλια.\n2fa.code_invalid: Ο κωδικός ταυτοποίησης δεν είναι έγκυρος\n2fa.enable: Ρύθμιση ταυτοποίησης δύο παραγόντων\nsubscription_sidebar_pop_out_left: Μετακίνηση σε ξεχωριστή sidebar στα αριστερά\n2fa.setup_error: Σφάλμα ενεργοποίησης 2FA για λογαριασμό\npurge_content_desc: Πλήρης εκκαθάριση του περιεχομένου του χρήστη, \n  συμπεριλαμβανομένης της διαγραφής των απαντήσεων άλλων χρηστών σε \n  δημιουργημένα νήματα, αναρτήσεις και σχόλια.\ndelete_content_desc: Διέγραψε το περιεχόμενο του χρήστη, αφήνοντας τις \n  απαντήσεις άλλων χρηστών στα δημιουργημένα νήματα, αναρτήσεις και σχόλια.\nschedule_delete_account: Προγραμματισμός Διαγραφής\nschedule_delete_account_desc: Προγραμμάτισε τη διαγραφή αυτού του λογαριασμού σε\n  30 ημέρες. Αυτό θα αποκρύψει τον χρήστη και το περιεχόμενό του, καθώς και θα \n  εμποδίσει τον χρήστη να συνδεθεί.\nremove_schedule_delete_account: Αφαίρεση Προγραμματισμένης Διαγραφής\nremove_schedule_delete_account_desc: Αφαίρεσε την προγραμματισμένη διαγραφή. Όλο\n  το περιεχόμενο θα είναι διαθέσιμο ξανά και ο χρήστης θα είναι σε θέση να \n  συνδεθεί.\ntwo_factor_authentication: Έλεγχος ταυτότητας δύο παραγόντων\n2fa.verify: Επαλήθευση\n2fa.backup-create.help: Μπορείτε να δημιουργήσεις νέους εφεδρικούς κωδικούς \n  αυθεντικοποίησης· αυτό θα ακυρώσει τους υφιστάμενους κωδικούς.\n2fa.add: Προσθήκη στον λογαριασμό μου\n2fa.backup-create.label: Δημιουργία νέων εφεδρικών κωδικών ταυτοποίησης\n2fa.qr_code_link.title: Η επίσκεψη σε αυτόν τον σύνδεσμο μπορεί να επιτρέψει \n  στην πλατφόρμα σου να καταχωρήσει αυτή την επαλήθευση δύο παραγόντων\n2fa.user_active_tfa.title: Ο χρήστης έχει ενεργό 2FA\n2fa.backup_codes.help: Μπορείς να χρησιμοποιήσεις αυτούς τους κωδικούς όταν δεν \n  έχεις τη συσκευή επαλήθευσης δύο παραγόντων ή εφαρμογή σου. <strong>Δεν θα \n  τους δείξεις ξανά </strong> και θα μπορείς να χρησιμοποιήσεις τον καθένα τους \n  <strong> μόνο μία φορά </strong>.\npassword_and_2fa: Κωδικός & 2FA\nalphabetically: Αλφαβητικά\nsubscriptions_in_own_sidebar: Σε ξεχωριστή sidebar\nsidebars_same_side: Sidebars στην ίδια πλευρά\nsubscription_sidebar_pop_out_right: Μετακίνηση σε ξεχωριστή sidebar στα δεξιά\nsubscription_sidebar_pop_in: Μετακίνηση συνδρομών στο inline πάνελ\n2fa.available_apps: Χρησιμοποίησε μια εφαρμογή επαλήθευσης δύο παραγόντων, όπως \n  το %google_authenticator%, %aegis% (Android) ή το %raivo% (iOS) για να \n  σαρώσεις τον κωδικό QR.\nyour_account_is_not_yet_approved: Ο λογαριασμός σου δεν έχει εγκριθεί ακόμα. Θα \n  σου στείλουμε ένα email μόλις οι διαχειριστές έχουν επεξεργαστεί το αίτημα \n  εγγραφής σου.\nsubscription_header: Εγγεγραμμένα Περιοδικά\nclose: Κλείσιμο\nnotify_on_user_signup: Νέες εγγραφές\n2fa.verify_authentication_code.label: Εισήγαγε έναν κωδικό δύο παραγόντων για να\n  επαληθεύσεις τη ρύθμιση\nposition_bottom: Κάτω μέρος\nposition_top: Επάνω\npending: Σε αναμονή\nflash_thread_new_error: Δεν μπόρεσε να δημιουργηθεί νήμα. Κάτι πήγε στραβά.\nremove_user_avatar: Αφαίρεση άβαταρ\nremove_user_cover: Αφαίρεση εξωφύλλου\noauth2.grant.user.bookmark_list: Διάβασε, επεξεργάσου και διέγραψε τις λίστες \n  σελιδοδεικτών σου\noauth2.grant.user.bookmark_list.read: Διάβασε τις λίστες σελιδοδεικτών σου\noauth2.grant.user.bookmark_list.delete: Διέγραψε τις λίστες σελιδοδεικτών σου\nflash_image_download_too_large_error: Η εικόνα δεν ήταν δυνατό να δημιουργηθεί, \n  είναι πολύ μεγάλη (μέγιστο μέγεθος %bytes%)\nflash_email_was_sent: Το email εστάλη με επιτυχία.\nflash_email_failed_to_sent: Το email δεν μπόρεσε να αποσταλεί.\nviewing_one_signup_request: Βλέπεις μόνο ένα αίτημα εγγραφής από %username%\nflash_post_new_success: Η ανάρτηση δημιουργήθηκε με επιτυχία.\noauth2.grant.user.bookmark: Προσθήκη και αφαίρεση σελιδοδεικτών\noauth2.grant.user.bookmark.remove: Αφαίρεση σελιδοδεικτών\noauth2.grant.user.bookmark.add: Προσθήκη σελιδοδεικτών\noauth2.grant.user.bookmark_list.edit: Επεξεργάσου τις λίστες σελιδοδεικτών σου\nflash_magazine_theme_changed_success: Η εμφάνιση του περιοδικού ενημερώθηκε με \n  επιτυχία.\nflash_post_new_error: Η ανάρτηση δεν μπόρεσε να δημιουργηθεί. Κάτι πήγε στραβά.\nflash_thread_tag_banned_error: Δε μπόρεσε να δημιουργηθεί νήμα. Το περιεχόμενο \n  δεν επιτρέπεται.\npage_width_auto: Αυτόματο\nfilter_labels: Φιλτράρισμα Ετικετών\n2fa.manual_code_hint: Εάν δεν μπορείς να σαρώσεις τον κωδικό QR, εισήγαγε το \n  μυστικό χειροκίνητα\nflash_magazine_theme_changed_error: Αποτυχία ενημέρωσης εμφάνισης του \n  περιοδικού.\nflash_comment_new_success: Το σχόλιο δημιουργήθηκε επιτυχώς.\nflash_user_settings_general_success: Οι ρυθμίσεις χρήστη αποθηκεύτηκαν με \n  επιτυχία.\nflash_user_settings_general_error: Αποτυχία αποθήκευσης ρυθμίσεων χρήστη.\nflash_user_edit_profile_success: Οι ρυθμίσεις προφίλ χρήστη αποθηκεύτηκαν με \n  επιτυχία.\nflash_comment_edit_success: Το σχόλιο ενημερώθηκε επιτυχώς.\nflash_comment_new_error: Αποτυχία δημιουργίας σχολίου. Κάτι πήγε στραβά.\nflash_user_edit_email_error: Αποτυχία αλλαγής email.\nflash_user_edit_password_error: Αποτυχία αλλαγής κωδικού πρόσβασης.\nflash_thread_edit_error: Αποτυχία επεξεργασίας νήματος. Κάτι πήγε στραβά.\nflash_post_edit_error: Αποτυχία επεξεργασίας ανάρτησης. Κάτι πήγε στραβά.\nflash_post_edit_success: Η ανάρτηση επεξεργάστηκε με επιτυχία.\npage_width: Πλάτος σελίδας\npage_width_fixed: Σταθερό\nauto: Αυτόματο\nopen_url_to_fediverse: Άνοιγμα πρωτότυπου URL\npage_width_max: Μεγ\nflash_comment_edit_error: Αποτυχία επεξεργασίας σχολίου. Κάτι πήγε στραβά.\nflash_user_edit_profile_error: Αποτυχία αποθήκευσης ρυθμίσεων προφίλ.\nchange_my_cover: Άλλαξε το εξώφυλλό μου\nchange_my_avatar: Άλλαξε το άβατάρ μου\naccount_settings_changed: Οι ρυθμίσεις λογαριασμού σου έχουν αλλάξει με \n  επιτυχία. Θα χρειαστεί να συνδεθείς ξανά.\nmagazine_deletion: Διαγραφή περιοδικού\ntoolbar.emoji: Εμότζι\nedit_my_profile: Επεξεργασία του προφίλ μου\ntype_search_term_url_handle: Τύπος αναζήτησης, διεύθυνση ή handle\ndelete_magazine: Διαγραφή περιοδικού\nrestore_magazine: Επαναφορά περιοδικού\npurge_magazine: Εκκαθάριση περιοδικού\nmagazine_is_deleted: Το περιοδικό διαγράφεται. Μπορείς να κάνεις <a \n  href=\"%link_target%\">επαναφορά</a> μέσα σε 30 ημέρες.\nsuspend_account: Αναστολή λογαριασμού\nunsuspend_account: Άρση αναστολής λογαριασμού\naccount_suspended: Ο λογαριασμός έχει ανασταλεί.\naccount_unsuspended: Ο λογαριασμός δεν είναι πλέον σε αναστολή.\ndeletion: Διαγραφή\nuser_suspend_desc: Η αναστολή του λογαριασμού σου κρύβει το περιεχόμενό σου από \n  την οντότητα, αλλά δεν τον αφαιρεί μόνιμα και μπορείς να το επαναφέρεις ανά \n  πάσα στιγμή.\naccount_banned: Ο λογαριασμός έχει αποκλειστεί.\n"
  },
  {
    "path": "translations/messages.en.yaml",
    "content": "type.link: Link\ntype.article: Thread\ntype.photo: Photo\ntype.video: Video\ntype.smart_contract: Smart contract\ntype.magazine: Magazine\nthread: Thread\nthreads: Threads\nmicroblog: Microblog\npeople: People\nevents: Events\nmagazine: Magazine\nmagazines: Magazines\nsearch: Search\nadd: Add\nselect_channel: Select a channel\nlogin: Log in\nsort_by: Sort by\ntop: Top\nhot: Hot\nactive: Active\nnewest: Newest\noldest: Oldest\ncommented: Commented\nchange_view: Change view\nfilter_by_time: Filter by time\nfilter_by_type: Filter by type\nfilter_by_subscription: Filter by subscription\nfilter_by_federation: Filter by federation status\ncomments_count: '{0}Comments|{1}Comment|]1,Inf[ Comments'\nsubscribers_count: '{0}Subscribers|{1}Subscriber|]1,Inf[ Subscribers'\nfollowers_count: '{0}Followers|{1}Follower|]1,Inf[ Followers'\nmarked_for_deletion: Marked for deletion\nmarked_for_deletion_at: Marked for deletion at %date%\nfavourites: Upvotes\nfavourite: Favorite\nmore: More\navatar: Avatar\nadded: Added\nup_votes: Boosts\ndown_votes: Reduces\nno_comments: No comments\ncreated_at: Created\ncreated_since: Created since\nowner: Owner\nsubscribers: Subscribers\nonline: Online\ncomments: Comments\nposts: Posts\nreplies: Replies\nmoderators: Moderators\nmod_log: Moderation log\nadd_comment: Add comment\nadd_post: Add post\nadd_media: Add media\nremove_media: Remove media\nremove_user_avatar: Remove avatar\nremove_user_cover: Remove cover\nmarkdown_howto: How does the editor work?\nenter_your_comment: Enter your comment\nenter_your_post: Enter your post\nactivity: Activity\ncover: Cover\nrelated_posts: Related posts\nrandom_posts: Random posts\nfederated_magazine_info: This magazine is from a federated server and may be\n  incomplete.\ndisconnected_magazine_info: This magazine is not receiving updates (last\n  activity %days% day(s) ago).\nalways_disconnected_magazine_info: This magazine is not receiving updates.\nsubscribe_for_updates: Subscribe to start receiving updates.\nfederated_user_info: This profile is from a federated server and may be\n  incomplete.\ngo_to_original_instance: View on remote instance\nempty: Empty\nsubscribe: Subscribe\nunsubscribe: Unsubscribe\nfollow: Follow\nunfollow: Unfollow\nreply: Reply\nlogin_or_email: Login or email\npassword: Password\nremember_me: Remember me\ndont_have_account: Don't have an account?\nyou_cant_login: Forgot your password?\nalready_have_account: Already have an account?\nregister: Register\nreset_password: Reset password\nshow_more: Show more\nto: to\nin: in\nfrom: from\nusername: Username\ndisplayname: Display name\nemail: Email\nrepeat_password: Repeat password\nagree_terms: Consent to %terms_link_start%Terms and Conditions%terms_link_end%\n  and %policy_link_start%Privacy Policy%policy_link_end%\nterms: Terms of service\nprivacy_policy: Privacy policy\nabout_instance: About\nall_magazines: All magazines\nstats: Statistics\nfediverse: Fediverse\ncreate_new_magazine: Create new magazine\nadd_new_article: Add new thread\nadd_new_link: Add new link\nadd_new_photo: Add new photo\nadd_new_post: Add new post\nadd_new_video: Add new video\ncontact: Contact\nfaq: FAQ\nrss: RSS\nchange_theme: Change theme\ndownvotes_mode: Downvotes mode\nchange_downvotes_mode: Change downvotes mode\ndisabled: Disabled\nhidden: Hidden\nenabled: Enabled\nuseful: Useful\nhelp: Help\ncheck_email: Check your email\nreset_check_email_desc: If there is already an account associated with your\n  email address, you should receive an email shortly containing a link that you\n  can use to reset your password. This link will expire in %expire%.\nreset_check_email_desc2: If you don't receive an email please check your spam\n  folder.\ntry_again: Try again\nup_vote: Boost\ndown_vote: Reduce\nemail_confirm_header: Hello! Confirm your email address.\nemail_confirm_content: 'Ready to activate your Mbin account? Click on the link below:'\nemail_verify: Confirm email address\nemail_confirm_expire: Please note that the link will expire in an hour.\nemail_confirm_title: Confirm your email address.\nselect_magazine: Select a magazine\nadd_new: Add new\nurl: URL\ntitle: Title\nbody: Body\ntags: Tags\ntag: Tag\nbadges: Badges\nis_adult: 18+ / NSFW\neng: ENG\noc: OC\nimage: Image\nimage_alt: Image alternative text\nname: Name\ndescription: Description\nrules: Rules\ndomain: Domain\nfollowers: Followers\nfollowing: Following\nsubscriptions: Subscriptions\noverview: Overview\ncards: Cards\ncolumns: Columns\nuser: User\njoined: Joined\nmoderated: Moderated\npeople_local: Local\npeople_federated: Federated\nreputation_points: Reputation points\nrelated_tags: Related tags\ngo_to_content: Go to content\ngo_to_filters: Go to filters\ngo_to_search: Go to search\nsubscribed: Subscribed\nall: All\nlogout: Log out\nclassic_view: Classic view\ncompact_view: Compact view\nchat_view: Chat view\ntree_view: Tree view\ntable_view: Table view\ncards_view: Cards view\n3h: 3h\n6h: 6h\n12h: 12h\n1d: 1d\n1w: 1w\n1m: 1m\n1y: 1y\nlinks: Links\narticles: Threads\nphotos: Photos\nvideos: Videos\nreport: Report\nshare: Share\ncopy_url: Copy Mbin URL\ncopy_url_to_fediverse: Copy original URL\nshare_on_fediverse: Share on Fediverse\ncrosspost: Crosspost\nedit: Edit\nare_you_sure: Are you sure?\nmoderate: Moderate\nreason: Reason\nedit_entry: Edit thread\ndelete: Delete\nedit_post: Edit post\nedit_comment: Save changes\nmenu: Menu\nsettings: Settings\ngeneral: General\nprofile: Profile\nblocked: Blocked\nreports: Reports\nnotifications: Notifications\nmessages: Messages\nappearance: Appearance\nhomepage: Homepage\nhide_adult: Hide NSFW content\nfeatured_magazines: Featured magazines\nprivacy: Privacy\nshow_profile_subscriptions: Show magazine subscriptions\nshow_profile_followings: Show following users\nnotify_on_new_entry_reply: Any level comments in threads I authored\nnotify_on_new_entry_comment_reply: Replies to my comments in any threads\nnotify_on_new_post_reply: Any level replies to posts I authored\nnotify_on_new_post_comment_reply: Replies to my comments on any posts\nnotify_on_new_entry: New threads (links or articles) in any magazine to which\n  I'm subscribed\nnotify_on_new_posts: New posts in any magazine to which I'm subscribed\nnotify_on_user_signup: New signups\nsave: Save\nabout: About\nold_email: Current email\nnew_email: New email\nnew_email_repeat: Confirm new email\ncurrent_password: Current password\nnew_password: New password\nnew_password_repeat: Confirm new password\nchange_email: Change email\nchange_password: Change password\nexpand: Expand\ncollapse: Collapse\ndomains: Domains\nerror: Error\nvotes: Votes\ntheme: Theme\ndark: Dark\nlight: Light\nsolarized_light: Solarized Light\nsolarized_dark: Solarized Dark\ndefault_theme: Default theme\ndefault_theme_auto: Light/Dark (Auto Detect)\nsolarized_auto: Solarized (Auto Detect)\nfont_size: Font size\nsize: Size\nboosts: Boosts\nshow_users_avatars: 'Show users’ avatars'\nyes: Yes\nno: No\nshow_magazines_icons: 'Show magazines’ icons'\nshow_thumbnails: Show thumbnails\nrounded_edges: Rounded edges\nremoved_thread_by: has removed a thread by\nrestored_thread_by: has restored a thread by\nremoved_comment_by: has removed a comment by\nrestored_comment_by: has restored comment by\nremoved_post_by: has removed a post by\nrestored_post_by: has restored a post by\nhe_banned: banned\nhe_unbanned: unbanned\nread_all: Read all\nshow_all: Show all\nflash_register_success: Welcome aboard! Your account is now registered. One last\n  step - check your inbox for an activation link that will bring your account to\n  life.\nflash_thread_new_success: The thread has been created successfully and is now\n  visible to other users.\nflash_thread_edit_success: The thread has been successfully edited.\nflash_thread_delete_success: The thread has been successfully deleted.\nflash_thread_pin_success: The thread has been successfully pinned.\nflash_thread_unpin_success: The thread has been successfully unpinned.\nflash_magazine_new_success: The magazine has been created successfully. You can\n  now add new content or explore the magazine's administration panel.\nflash_magazine_edit_success: The magazine has been successfully edited.\nflash_mark_as_adult_success: The post has been successfully marked as NSFW.\nflash_unmark_as_adult_success: The post has been successfully unmarked as NSFW.\ntoo_many_requests: Limit exceeded, please try again later.\nset_magazines_bar: Magazines bar\nset_magazines_bar_desc: add the magazine names after the comma\nset_magazines_bar_empty_desc: if the field is empty, active magazines are\n  displayed on the bar.\nmod_log_alert: WARNING - The Modlog may contain unpleasant or distressing\n  content that has been removed by moderators. Please exercise caution.\nadded_new_thread: Added a new thread\nedited_thread: Edited a thread\nmod_remove_your_thread: A moderator removed your thread\nadded_new_comment: Added a new comment\nedited_comment: Edited a comment\nreplied_to_your_comment: Replied to your comment\nmod_deleted_your_comment: A moderator deleted your comment\nadded_new_post: Added a new post\nedited_post: Edited a post\nmod_remove_your_post: A moderator removed your post\nadded_new_reply: Added a new reply\nwrote_message: Wrote a message\nbanned: Banned you\nremoved: Removed by mod\ndeleted: Deleted by author\nmentioned_you: Mentioned you\ncomment: Comment\npost: Post\nparent_post: Parent Post\nban_expired: Ban expired\nban_expires: Ban expires\npurge: Purge\nsend_message: Send direct message\nmessage: Message\ninfinite_scroll: Infinite scrolling\nshow_top_bar: Show top bar\nsticky_navbar: Sticky navbar\nsubject_reported: Content has been reported.\nsidebar_position: Sidebar position\nleft: Left\nright: Right\nfederation: Federation\nstatus: Status\non: On\noff: Off\ninstances: Instances\nupload_file: Upload file\nfrom_url: From url\nmagazine_panel: Magazine panel\nreject: Reject\napprove: Approve\nban: Ban\nunban: Unban\nban_hashtag_btn: Ban Hashtag\nban_hashtag_description: Banning a hashtag will stop posts with this hashtag\n  from being created, as well as hiding existing posts with this hashtag.\nunban_hashtag_btn: Unban Hashtag\nunban_hashtag_description: Unbanning a hashtag will allow creating posts with\n  this hashtag again. Existing posts with this hashtag are no longer hidden.\nfilters: Filters\napproved: Approved\nrejected: Rejected\nadd_moderator: Add moderator\nadd_badge: Add badge\nbans: Bans\ncreated: Created\nexpires: Expires\nperm: Permanent\nexpired_at: Expired at\nadd_ban: Add ban\ntrash: Trash\nicon: Icon\nbanner: Banner\ndone: Done\npin: Pin\nunpin: Unpin\nchange_magazine: Change magazine\nchange_language: Change language\nmark_as_adult: Mark NSFW\nunmark_as_adult: Unmark NSFW\nchange: Change\npinned: Pinned\npreview: Preview\narticle: Thread\nreputation: Reputation\nnote: Note\nwriting: Writing\nusers: Users\ncontent: Content\nweek: Week\nweeks: Weeks\nmonth: Month\nmonths: Months\nyear: Year\nfederated: Federated\nlocal: Local\nadmin_panel: Admin panel\ndashboard: Dashboard\ncontact_email: Contact email\nmeta: Meta\ninstance: Instance\npages: Pages\nFAQ: FAQ\ntype_search_term: Type search term\ntype_search_term_url_handle: Type search term, url or handle\nfederation_enabled: Federation enabled\nregistrations_enabled: Registration enabled\nregistration_disabled: Registration disabled\nrestore: Restore\nadd_mentions_entries: Add mention tags in threads\nadd_mentions_posts: Add mention tags in posts\nPassword is invalid: Password is invalid.\nYour account is not active: Your account is not active.\nYour account has been banned: Your account has been banned.\nfirstname: First name\nsend: Send\nactive_users: Active people\nrandom_entries: Random threads\nrelated_entries: Related threads\ndelete_account: Delete account\npurge_account: Purge account\nban_account: Ban account\nunban_account: Unban account\nrelated_magazines: Related magazines\nrandom_magazines: Random magazines\nmagazine_panel_tags_info: Provide only if you want content from the fediverse to\n  be included in this magazine based on tags\nsidebar: Sidebar\nauto_preview: Auto media preview\ndynamic_lists: Dynamic lists\nbanned_instances: Banned instances\nkbin_intro_title: Explore the Fediverse\nkbin_intro_desc: is a decentralized platform for content aggregation and\n  microblogging that operates within the Fediverse network.\nkbin_promo_title: Create your own instance\nkbin_promo_desc: '%link_start%Clone repo%link_end% and develop fediverse'\ncaptcha_enabled: Captcha enabled\nheader_logo: Header logo\nbrowsing_one_thread: You are only browsing one thread in the discussion! All\n  comments are available on the post page.\nviewing_one_signup_request: You are only viewing one signup request by\n  %username%\nreturn: Return\nboost: Boost\nmercure_enabled: Mercure enabled\nreport_issue: Report issue\ntokyo_night: Tokyo Night\npreferred_languages: Filter languages of threads and posts\ninfinite_scroll_help: Automatically load more content when you reach the bottom\n  of the page.\nsticky_navbar_help: The navbar will stick to the top of the page when you scroll\n  down.\nauto_preview_help: Show the media (photo, video) previews in a larger size below\n  the content.\nreload_to_apply: Reload page to apply changes\nfilter.origin.label: Choose origin\nfilter.fields.label: Choose which fields to search\nfilter.adult.label: Choose whether to display NSFW\nfilter.adult.hide: Hide NSFW\nfilter.adult.show: Show NSFW\nfilter.adult.only: Only NSFW\nlocal_and_federated: Local and federated\nfilter.fields.only_names: Only names\nfilter.fields.names_and_descriptions: Names and descriptions\nkbin_bot: Mbin Agent\nbot_body_content: \"Welcome to the Mbin Agent! This agent plays a crucial role in enabling\n  ActivityPub functionality within Mbin. It ensures that Mbin can communicate and\n  federate with other instances in the fediverse.\\n\\nActivityPub is an open standard\n  protocol that allows decentralized social networking platforms to communicate and\n  interact with each other. It enables users on different instances (servers) to follow,\n  interact with, and share content across the federated social network known as the\n  fediverse. It provides a standardized way for users to publish content, follow other\n  users, and engage in social interactions such as liking, sharing, and commenting\n  on threads or posts.\"\npassword_confirm_header: Confirm your password change request.\nyour_account_is_not_active: Your account has not been activated. Please check\n  your email for account activation instructions or <a\n  href=\"%link_target%\">request a new account activation email.</a>\nyour_account_has_been_banned: Your account has been banned\nyour_account_is_not_yet_approved: Your account has not been approved yet. We\n  will send you an email as soon as the admins have processed your signup\n  request.\ntoolbar.bold: Bold\ntoolbar.italic: Italic\ntoolbar.strikethrough: Strikethrough\ntoolbar.header: Header\ntoolbar.quote: Quote\ntoolbar.code: Code\ntoolbar.link: Link\ntoolbar.image: Image\ntoolbar.unordered_list: Unordered List\ntoolbar.ordered_list: Ordered List\ntoolbar.mention: Mention\ntoolbar.spoiler: Spoiler\ntoolbar.emoji: Emoji\nfederation_page_enabled: Federation page enabled\nfederation_page_allowed_description: Known instances we federate with\nfederation_page_disallowed_description: Instances we do not federate with\nfederation_page_dead_title: Dead instances\nfederation_page_dead_description: Instances that we could not deliver at least\n  10 activities in a row and where the last successful deliver and -receive were\n  more than a week ago\nfederated_search_only_loggedin: Federated search limited if not logged in\naccount_deletion_title: Account deletion\naccount_deletion_description: Your account will be deleted in 30 days unless you\n  choose to delete the account immediately. To restore your account within 30\n  days, login with the same user credentials or contact an administrator.\naccount_deletion_button: Delete Account\naccount_deletion_immediate: Delete immediately\nmore_from_domain: More from domain\nerrors.server500.title: 500 Internal Server Error\nerrors.server500.description: Sorry, something went wrong on our end. If you\n  continue to see this error, try contacting the instance owner. If this\n  instance is not working at all, check out %link_start%other Mbin\n  instances%link_end% in the meanwhile until the problem is resolved.\nerrors.server429.title: 429 Too Many Requests\nerrors.server404.title: 404 Not found\nerrors.server403.title: 403 Forbidden\nemail_confirm_button_text: Confirm your password change request\nemail_confirm_link_help: Alternatively you can copy and paste the following into\n  your browser\nemail.delete.title: User account deletion request\nemail.delete.description: The following user has requested that their account be\n  deleted\nresend_account_activation_email_question: Inactive account?\nresend_account_activation_email: Re-send account activation email\nresend_account_activation_email_error: There was an issue submitting this\n  request. There may be no account associated with that email or perhaps it is\n  already activated.\nresend_account_activation_email_success: If an account associated with that\n  email exists, we will send out a new activation email.\nresend_account_activation_email_description: Enter the email address associated\n  with your account. We will send out another activation email for you.\ncustom_css: Custom CSS\nignore_magazines_custom_css: Ignore magazines custom CSS\noauth.consent.title: OAuth2 Consent Form\noauth.consent.grant_permissions: Grant Permissions\noauth.consent.app_requesting_permissions: would like to perform the following\n  actions on your behalf\noauth.consent.app_has_permissions: can already perform the following actions\noauth.consent.to_allow_access: To allow this access, click the 'Allow' button\n  below\noauth.consent.allow: Allow\noauth.consent.deny: Deny\noauth.client_identifier.invalid: Invalid OAuth Client ID!\noauth.client_not_granted_message_read_permission: This app has not received\n  permission to read your messages.\nrestrict_oauth_clients: Restrict OAuth2 Client creation to Admins\nprivate_instance: Force users to login before they can access any content\nblock: Block\nunblock: Unblock\noauth2.grant.moderate.magazine.ban.delete: Unban users in your moderated\n  magazines.\noauth2.grant.moderate.magazine.list: Read a list of your moderated magazines.\noauth2.grant.moderate.magazine.reports.all: Manage reports in your moderated\n  magazines.\noauth2.grant.moderate.magazine.reports.read: Read reports in your moderated\n  magazines.\noauth2.grant.moderate.magazine.reports.action: Accept or reject reports in your\n  moderated magazines.\noauth2.grant.moderate.magazine.trash.read: View trashed content in your\n  moderated magazines.\noauth2.grant.moderate.magazine_admin.all: Create, edit, or delete your owned\n  magazines.\noauth2.grant.moderate.magazine_admin.create: Create new magazines.\noauth2.grant.moderate.magazine_admin.delete: Delete any of your owned magazines.\noauth2.grant.moderate.magazine_admin.update: Edit any of your owned magazines'\n  rules, description, NSFW status, or icon.\noauth2.grant.moderate.magazine_admin.edit_theme: Edit the custom CSS of any of\n  your owned magazines.\noauth2.grant.moderate.magazine_admin.moderators: Add or remove moderators of any\n  of your owned magazines.\noauth2.grant.moderate.magazine_admin.badges: Create or remove badges from your\n  owned magazines.\noauth2.grant.moderate.magazine_admin.tags: Create or remove tags from your owned\n  magazines.\noauth2.grant.moderate.magazine_admin.stats: View the content, vote, and view\n  stats of your owned magazines.\noauth2.grant.admin.all: Perform any administrative action on your instance.\noauth2.grant.admin.entry.purge: Completely delete any thread from your instance.\noauth2.grant.read.general: Read all content you have access to.\noauth2.grant.write.general: Create or edit any of your threads, posts, or\n  comments.\noauth2.grant.delete.general: Delete any of your threads, posts, or comments.\noauth2.grant.report.general: Report threads, posts, or comments.\noauth2.grant.vote.general: Upvote, downvote, or boost threads, posts, or\n  comments.\noauth2.grant.subscribe.general: Subscribe or follow any magazine, domain, or\n  user, and view the magazines, domains, and users you subscribe to.\noauth2.grant.block.general: Block or unblock any magazine, domain, or user, and\n  view the magazines, domains, and users you have blocked.\noauth2.grant.domain.all: Subscribe to or block domains, and view the domains you\n  subscribe to or block.\noauth2.grant.domain.subscribe: Subscribe or unsubscribe to domains and view the\n  domains you subscribe to.\noauth2.grant.domain.block: Block or unblock domains and view the domains you\n  have blocked.\noauth2.grant.entry.all: Create, edit, or delete your threads, and vote, boost,\n  or report any thread.\noauth2.grant.entry.create: Create new threads.\noauth2.grant.entry.edit: Edit your existing threads.\noauth2.grant.entry.delete: Delete your existing threads.\noauth2.grant.entry.vote: Upvote, boost, or downvote any thread.\noauth2.grant.entry.report: Report any thread.\noauth2.grant.entry_comment.all: Create, edit, or delete your comments in\n  threads, and vote, boost, or report any comment in a thread.\noauth2.grant.entry_comment.create: Create new comments in threads.\noauth2.grant.entry_comment.edit: Edit your existing comments in threads.\noauth2.grant.entry_comment.delete: Delete your existing comments in threads.\noauth2.grant.entry_comment.vote: Upvote, boost, or downvote any comment in a\n  thread.\noauth2.grant.entry_comment.report: Report any comment in a thread.\noauth2.grant.magazine.all: Subscribe to or block magazines, and view the\n  magazines you subscribe to or block.\noauth2.grant.magazine.subscribe: Subscribe or unsubscribe to magazines and view\n  the magazines you subscribe to.\noauth2.grant.magazine.block: Block or unblock magazines and view the magazines\n  you have blocked.\noauth2.grant.post.all: Create, edit, or delete your microblogs, and vote, boost,\n  or report any microblog.\noauth2.grant.post.create: Create new posts.\noauth2.grant.post.edit: Edit your existing posts.\noauth2.grant.post.delete: Delete your existing posts.\noauth2.grant.post.vote: Upvote, boost, or downvote any post.\noauth2.grant.post.report: Report any post.\noauth2.grant.post_comment.all: Create, edit, or delete your comments on posts,\n  and vote, boost, or report any comment on a post.\noauth2.grant.post_comment.create: Create new comments on posts.\noauth2.grant.post_comment.edit: Edit your existing comments on posts.\noauth2.grant.post_comment.delete: Delete your existing comments on posts.\noauth2.grant.post_comment.vote: Upvote, boost, or downvote any comment on a\n  post.\noauth2.grant.post_comment.report: Report any comment on a post.\noauth2.grant.user.all: Read and edit your profile, messages, or notifications;\n  Read and edit permissions you've granted other apps; follow or block other\n  users; view lists of users you follow or block.\noauth2.grant.user.bookmark: Add and remove bookmarks\noauth2.grant.user.bookmark.add: Add bookmarks\noauth2.grant.user.bookmark.remove: Remove bookmarks\noauth2.grant.user.bookmark_list: Read, edit and delete your bookmark lists\noauth2.grant.user.bookmark_list.read: Read your bookmark lists\noauth2.grant.user.bookmark_list.edit: Edit your bookmark lists\noauth2.grant.user.bookmark_list.delete: Delete your bookmark lists\noauth2.grant.user.profile.all: Read and edit your profile.\noauth2.grant.user.profile.read: Read your profile.\noauth2.grant.user.profile.edit: Edit your profile.\noauth2.grant.user.message.all: Read your messages and send messages to other\n  users.\noauth2.grant.user.message.read: Read your messages.\noauth2.grant.user.message.create: Send messages to other users.\noauth2.grant.user.notification.all: Read and clear your notifications.\noauth2.grant.user.notification.read: Read your notifications, including message\n  notifications.\noauth2.grant.user.notification.delete: Clear your notifications.\noauth2.grant.user.oauth_clients.all: Read and edit the permissions you have\n  granted to other OAuth2 applications.\noauth2.grant.user.oauth_clients.read: Read the permissions you have granted to\n  other OAuth2 applications.\noauth2.grant.user.oauth_clients.edit: Edit the permissions you have granted to\n  other OAuth2 applications.\noauth2.grant.user.follow: Follow or unfollow users, and read a list of users you\n  follow.\noauth2.grant.user.block: Block or unblock users, and read a list of users you\n  block.\noauth2.grant.moderate.all: Perform any moderation action you have permission to\n  perform in your moderated magazines.\noauth2.grant.moderate.entry.all: Moderate threads in your moderated magazines.\noauth2.grant.moderate.entry.change_language: Change the language of threads in\n  your moderated magazines.\noauth2.grant.moderate.entry.pin: Pin threads to the top of your moderated\n  magazines.\noauth2.grant.moderate.entry.lock: Lock threads in your moderated magazines,\n  so no one can comment on it\noauth2.grant.moderate.entry.set_adult: Mark threads as NSFW in your moderated\n  magazines.\noauth2.grant.moderate.entry.trash: Trash or restore threads in your moderated\n  magazines.\noauth2.grant.moderate.entry_comment.all: Moderate comments in threads in your\n  moderated magazines.\noauth2.grant.moderate.entry_comment.change_language: Change the language of\n  comments in threads in your moderated magazines.\noauth2.grant.moderate.entry_comment.set_adult: Mark comments in threads as NSFW\n  in your moderated magazines.\noauth2.grant.moderate.entry_comment.trash: Trash or restore comments in threads\n  in your moderated magazines.\noauth2.grant.moderate.post.all: Moderate posts in your moderated magazines.\noauth2.grant.moderate.post.change_language: Change the language of posts in your\n  moderated magazines.\noauth2.grant.moderate.post.lock: Lock microblogs in your moderated magazines,\n  so no one can comment on it\noauth2.grant.moderate.post.set_adult: Mark posts as NSFW in your moderated\n  magazines.\noauth2.grant.moderate.post.trash: Trash or restore posts in your moderated\n  magazines.\noauth2.grant.moderate.post_comment.all: Moderate comments on posts in your\n  moderated magazines.\noauth2.grant.moderate.post_comment.change_language: Change the language of\n  comments on posts in your moderated magazines.\noauth2.grant.moderate.post_comment.set_adult: Mark comments on posts as NSFW in\n  your moderated magazines.\noauth2.grant.moderate.post_comment.trash: Trash or restore comments on posts in\n  your moderated magazines.\noauth2.grant.moderate.magazine.all: Manage bans, reports, and view trashed items\n  in your moderated magazines.\noauth2.grant.moderate.magazine.ban.all: Manage banned users in your moderated\n  magazines.\noauth2.grant.moderate.magazine.ban.read: View banned users in your moderated\n  magazines.\noauth2.grant.moderate.magazine.ban.create: Ban users in your moderated\n  magazines.\noauth2.grant.admin.entry_comment.purge: Completely delete any comment in a\n  thread from your instance.\noauth2.grant.admin.post.purge: Completely delete any post from your instance.\noauth2.grant.admin.post_comment.purge: Completely delete any comment on a post\n  from your instance.\noauth2.grant.admin.magazine.all: Move threads between or completely delete\n  magazines on your instance.\noauth2.grant.admin.magazine.move_entry: Move threads between magazines on your\n  instance.\noauth2.grant.admin.magazine.purge: Completely delete magazines on your instance.\noauth2.grant.admin.user.all: Ban, verify, or completely delete users on your\n  instance.\noauth2.grant.admin.user.ban: Ban or unban users from your instance.\noauth2.grant.admin.user.verify: Verify users on your instance.\noauth2.grant.admin.user.delete: Delete users from your instance.\noauth2.grant.admin.user.purge: Completely delete users from your instance.\noauth2.grant.admin.instance.all: View and update instance settings or\n  information.\noauth2.grant.admin.instance.stats: View your instance's stats.\noauth2.grant.admin.instance.settings.all: View or update settings on your\n  instance.\noauth2.grant.admin.instance.settings.read: View settings on your instance.\noauth2.grant.admin.instance.settings.edit: Update settings on your instance.\noauth2.grant.admin.instance.information.edit: Update the About, FAQ, Contact,\n  Terms of Service, and Privacy Policy pages on your instance.\noauth2.grant.admin.federation.all: View and update currently defederated\n  instances.\noauth2.grant.admin.federation.read: View the list of defederated instances.\noauth2.grant.admin.federation.update: Add or remove instances to or from the\n  list of defederated instances.\noauth2.grant.admin.oauth_clients.all: View or revoke OAuth2 clients that exist\n  on your instance.\noauth2.grant.admin.oauth_clients.read: View the OAuth2 clients that exist on\n  your instance, and their usage stats.\noauth2.grant.admin.oauth_clients.revoke: Revoke access to OAuth2 clients on your\n  instance.\nlast_active: Last Active\nflash_post_pin_success: The post has been successfully pinned.\nflash_post_unpin_success: The post has been successfully unpinned.\ncomment_reply_position_help: Display the comment reply form either at the top or\n  bottom of the page. When 'infinite scroll' is enabled the position will always\n  appear at the top.\nshow_avatars_on_comments: Show Comment Avatars\nsingle_settings: Single\nupdate_comment: Update comment\nshow_avatars_on_comments_help: Display/hide user avatars when viewing comments\n  on a single thread or post.\ncomment_reply_position: Comment reply position\nmagazine_theme_appearance_custom_css: Custom CSS that will apply when viewing\n  content within your magazine.\nmagazine_theme_appearance_icon: Custom icon for the magazine.\nmagazine_theme_appearance_banner: Custom banner for the magazine. It is\n  displayed above all threads and should be in a wide aspect ratio (5:1, or\n  1500px * 300px).\nmagazine_theme_appearance_background_image: Custom background image that will be\n  applied when viewing content within your magazine.\nmoderation.report.approve_report_title: Approve Report\nmoderation.report.reject_report_title: Reject Report\nmoderation.report.ban_user_description: Do you want to ban the user (%username%)\n  who created this content from this magazine?\nmoderation.report.approve_report_confirmation: Are you sure that you want to\n  approve this report?\nsubject_reported_exists: This content has already been reported.\nmoderation.report.ban_user_title: Ban User\nmoderation.report.reject_report_confirmation: Are you sure that you want to\n  reject this report?\noauth2.grant.moderate.post.pin: Pin posts to the top of your moderated\n  magazines.\ndelete_content: Delete content\npurge_content: Purge content\ndelete_content_desc: Delete the user's content while leaving the responses of\n  other users in the created threads, posts and comments.\npurge_content_desc: Completely purge the user's content, including deleting the\n  responses of other users in created threads, posts and comments.\ndelete_account_desc: Delete the account, including the responses of other users\n  in created threads, posts and comments.\nschedule_delete_account: Schedule Deletion\nschedule_delete_account_desc: Schedule the deletion of this account in 30 days.\n  This will hide the user and their content as well as prevent the user from\n  logging in.\nremove_schedule_delete_account: Remove Scheduled Deletion\nremove_schedule_delete_account_desc: Remove the scheduled deletion. All the\n  content will be available again and the user will be able to login.\ntwo_factor_authentication: Two-factor authentication\ntwo_factor_backup: Two-factor authentication backup codes\n2fa.authentication_code.label: Authentication Code\n2fa.verify: Verify\n2fa.code_invalid: The authentication code is not valid\n2fa.setup_error: Error enabling 2FA for account\n2fa.enable: Setup two-factor authentication\n2fa.disable: Disable two-factor authentication\n2fa.backup: Your two-factor backup codes\n2fa.backup-create.help: You can create new backup authentication codes; doing so\n  will invalidate existing codes.\n2fa.backup-create.label: Create new backup authentication codes\n2fa.remove: Remove 2FA\n2fa.add: Add to my account\n2fa.verify_authentication_code.label: Enter a two-factor code to verify setup\n2fa.qr_code_img.alt: A QR code that allows the setup of two-factor\n  authentication for your account\n2fa.qr_code_link.title: Visiting this link may allow your platform to register\n  this two-factor authentication\n2fa.user_active_tfa.title: User has active 2FA\n2fa.available_apps: Use a two-factor authentication app such as\n  %google_authenticator%, %aegis% (Android) or %raivo% (iOS) to scan the\n  QR-code.\n2fa.backup_codes.help: You can use these codes when you don't have your\n  two-factor authentication device or app. You will <strong>not be shown them\n  again</strong> and will be able to use each of them <strong>only\n  once</strong>.\n2fa.backup_codes.recommendation: It is recommended that you keep a copy of them\n  in a safe place.\n2fa.manual_code_hint: If you cannot scan the QR code, enter the secret manually\ncancel: Cancel\npassword_and_2fa: Password & 2FA\nflash_account_settings_changed: Your account settings have been successfully\n  changed. You will need to login again.\nshow_subscriptions: Show subscriptions\nsubscription_sort: Sort\nalphabetically: Alphabetically\nsubscriptions_in_own_sidebar: In separate sidebar\nsidebars_same_side: Sidebars on the same side\nsubscription_sidebar_pop_out_right: Move to separate sidebar on the right\nsubscription_sidebar_pop_out_left: Move to separate sidebar on the left\nsubscription_sidebar_pop_in: Move subscriptions to the inline panel\nsubscription_panel_large: Large panel\nsubscription_header: Subscribed Magazines\nclose: Close\nposition_bottom: Bottom\nposition_top: Top\npending: Pending\nflash_thread_new_error: Thread could not be created. Something went wrong.\nflash_thread_tag_banned_error: Thread could not be created. The content is not\n  allowed.\nflash_thread_ref_image_not_found: The image referenced by 'imageHash' could not\n  be found.\nflash_image_download_too_large_error: Image could not be created, it is too big\n  (max size %bytes%)\nflash_email_was_sent: Email has been successfully sent.\nflash_email_failed_to_sent: Email could not be sent.\nflash_post_new_success: Post has been successfully created.\nflash_post_new_error: Post could not be created. Something went wrong.\nflash_magazine_theme_changed_success: Successfully updated the magazine\n  appearance.\nflash_magazine_theme_changed_error: Failed to update the magazine appearance.\nflash_comment_new_success: Comment has been successfully created.\nflash_comment_edit_success: Comment has been successfully updated.\nflash_comment_new_error: Failed to create comment. Something went wrong.\nflash_comment_edit_error: Failed to edit comment. Something went wrong.\nflash_user_settings_general_success: User settings successfully saved.\nflash_user_settings_general_error: Failed to save user settings.\nflash_user_edit_profile_error: Failed to save profile settings.\nflash_user_edit_profile_success: User profile settings successfully saved.\nflash_user_edit_email_error: Failed to change email.\nflash_user_edit_password_error: Failed to change password.\nflash_thread_edit_error: Failed to edit thread. Something went wrong.\nflash_post_edit_error: Failed to edit post. Something went wrong.\nflash_post_edit_success: Post has been successfully edited.\npage_width: Page width\npage_width_max: Max\npage_width_auto: Auto\npage_width_fixed: Fixed\nfilter_labels: Filter Labels\nauto: Auto\nopen_url_to_fediverse: Open original URL\nchange_my_avatar: Change my avatar\nchange_my_cover: Change my cover\nedit_my_profile: Edit my profile\naccount_settings_changed: Your account settings have been successfully changed.\n  You will need to login again.\nmagazine_deletion: Magazine deletion\ndelete_magazine: Delete magazine\nrestore_magazine: Restore magazine\npurge_magazine: Purge magazine\nmagazine_is_deleted: Magazine is deleted. You can <a\n  href=\"%link_target%\">restore</a> it within 30 days.\nsuspend_account: Suspend account\nunsuspend_account: Unsuspend account\naccount_suspended: The account has been suspended.\naccount_unsuspended: The account has been unsuspended.\ndeletion: Deletion\nuser_suspend_desc: Suspending your account hides your content on the instance,\n  but doesn't permanently remove it, and you can restore it at any time.\naccount_banned: The account has been banned.\naccount_unbanned: The account has been unbanned.\naccount_is_suspended: User account is suspended.\nremove_following: Remove following\nremove_subscriptions: Remove subscriptions\napply_for_moderator: Apply for moderator\nrequest_magazine_ownership: Request magazine ownership\ncancel_request: Cancel request\nabandoned: Abandoned\nownership_requests: Ownership requests\naccept: Accept\nmoderator_requests: Mod requests\naction: Action\nuser_badge_op: OP\nuser_badge_admin: Admin\nuser_badge_global_moderator: Global Mod\nuser_badge_moderator: Mod\nuser_badge_bot: Bot\nannouncement: Announcement\nkeywords: Keywords\ndeleted_by_moderator: Thread, post or comment was deleted by the moderator\ndeleted_by_author: Thread, post or comment was deleted by the author\nsensitive_warning: Sensitive content\nsensitive_toggle: Toggle visibility of sensitive content\nsensitive_show: Click to show\nsensitive_hide: Click to hide\ndetails: Details\nspoiler: Spoiler\nall_time: All time\nshow: Show\nhide: Hide\nedited: edited\nsso_registrations_enabled: SSO registrations enabled\nsso_registrations_enabled.error: New account registrations with third-party\n  identity managers are currently disabled.\nsso_only_mode: Restrict login and registration to SSO methods only\nrelated_entry: Related\nrestrict_magazine_creation: Restrict local magazine creation to admins and\n  global mods\nsso_show_first: Show SSO first on login and registration pages\ncontinue_with: Continue with\nreported_user: Reported user\nreporting_user: Reporting user\nreported: reported\nreport_subject: Subject\nown_report_rejected: Your report was rejected\nown_report_accepted: Your report was accepted\nown_content_reported_accepted: A report of your content was accepted.\nreport_accepted: A report was accepted\nopen_report: Open report\ncake_day: Cake day\nsomeone: Someone\nback: Back\nmagazine_log_mod_added: has added a moderator\nmagazine_log_mod_removed: has removed a moderator\nmagazine_log_entry_pinned: pinned entry\nmagazine_log_entry_unpinned: removed pinned entry\nlast_updated: Last updated\nand: and\ndirect_message: Direct message\nmanually_approves_followers: Manually approves followers\nregister_push_notifications_button: Register For Push Notifications\nunregister_push_notifications_button: Remove Push Registration\ntest_push_notifications_button: Test Push Notifications\ntest_push_message: Hello World!\nnotification_title_new_comment: New comment\nnotification_title_removed_comment: A comment was removed\nnotification_title_edited_comment: A comment was edited\nnotification_title_mention: You were mentioned\nnotification_title_new_reply: New Reply\nnotification_title_new_thread: New thread\nnotification_title_removed_thread: A thread was removed\nnotification_title_edited_thread: A thread was edited\nnotification_title_ban: You were banned\nnotification_title_message: New direct message\nnotification_title_new_post: New Post\nnotification_title_removed_post: A post was removed\nnotification_title_edited_post: A post was edited\nnotification_title_new_signup: A new user registered\nnotification_body_new_signup: The user %u% registered.\nnotification_body2_new_signup_approval: You need to approve the request before\n  they can log in\nshow_related_magazines: Show random magazines\nshow_related_entries: Show random threads\nshow_related_posts: Show random posts\nshow_active_users: Show active users\nnotification_title_new_report: A new report was created\nmagazine_posting_restricted_to_mods_warning: Only mods can create threads in\n  this magazine\nflash_posting_restricted_error: Creating threads is restricted to mods in this\n  magazine and you are not one\nserver_software: Server software\nversion: Version\nlast_successful_deliver: Last successful delivery\nlast_successful_receive: Last successful receive\nlast_failed_contact: Last failed contact\nmagazine_posting_restricted_to_mods: Restrict thread creation to moderators\nnew_user_description: This user is new (active for less than %days% days)\nnew_magazine_description: This magazine is new (active for less than %days%\n  days)\nadmin_users_active: Active\nadmin_users_inactive: Inactive\nadmin_users_suspended: Suspended\nadmin_users_banned: Banned\nuser_verify: Activate account\nmax_image_size: Maximum file size\ncomment_not_found: Comment not found\nbookmark_add_to_list: Add bookmark to %list%\nbookmark_remove_from_list: Remove bookmark from %list%\nbookmark_remove_all: Remove all bookmarks\nbookmark_add_to_default_list: Add bookmark to default list\nbookmark_lists: Bookmark Lists\nbookmarks: Bookmarks\nbookmarks_list: Bookmarks in %list%\ncount: Count\nis_default: Is Default\nbookmark_list_is_default: Is default list\nbookmark_list_make_default: Make Default\nbookmark_list_create: Create\nbookmark_list_create_placeholder: type name...\nbookmark_list_create_label: List name\nbookmarks_list_edit: Edit bookmark list\nbookmark_list_edit: Edit\nbookmark_list_selected_list: Selected list\ntable_of_contents: Table of contents\nsearch_type_all: Everything\nsearch_type_entry: Threads\nsearch_type_post: Microblogs\nsearch_type_magazine: Magazines\nsearch_type_user: Users\nsearch_type_actors: Magazines + Users\nsearch_type_content: Threads + Microblogs\nselect_user: Choose a user\nnew_users_need_approval: New users have to be approved by an admin before they\n  can log in.\nsignup_requests: Signup requests\napplication_text: Explain why you want to join\nsignup_requests_header: Signup Requests\nsignup_requests_paragraph: These users would like to join your server. They\n  cannot log in until you've approved their signup request.\nflash_application_info: An admin needs to approve your account before you can\n  log in. You will receive an email once your signup request has been processed.\nemail_application_approved_title: Your signup request has been approved\nemail_application_approved_body: Your signup request was approved by the server\n  admin. You can now log into the server at <a href=\"%link%\">%siteName%</a>.\nemail_application_rejected_title: Your signup request has been rejected\nemail_application_rejected_body: Thank you for your interest, but we regret to\n  inform you that your signup request has been declined.\nemail_application_pending: Your account requires admin approval before you can\n  log in.\nemail_verification_pending: You have to verify your email address before you can\n  log in.\nshow_magazine_domains: Show magazine domains\nshow_user_domains: Show user domains\nanswered: answered\nby: by\nfront_default_sort: Frontpage default sort\ncomment_default_sort: Comment default sort\nopen_signup_request: Open signup request\nimage_lightbox_in_list: Thread thumbnails opens full screen\ncompact_view_help: A compact view with less margins, where the media is moved to\n  the right side.\nshow_users_avatars_help: Display the user avatar image.\nshow_magazines_icons_help: Display the magazine icon.\nshow_thumbnails_help: Show the thumbnail images.\nimage_lightbox_in_list_help: When checked, clicking the thumbnail shows a modal\n  image box window. When unchecked, clicking the thumbnail will open the thread.\nshow_new_icons: Show new icons\nshow_new_icons_help: Show icon for new magazine/user (30 days old or newer)\nmagazine_instance_defederated_info: The instance of this magazine is\n  defederated. The magazine will therefore not receive updates.\nuser_instance_defederated_info: The instance of this user is defederated.\nflash_thread_instance_banned: The instance of this magazine is banned.\nshow_rich_mention: Rich mentions\nshow_rich_mention_help: Render a user component when a user is mentioned. This\n  will include their display name and profile picture.\nshow_rich_mention_magazine: Rich magazine mentions\nshow_rich_mention_magazine_help: Render a magazine component when a magazine is\n  mentioned. This will include their display name and icon.\nshow_rich_ap_link: Rich AP links\nshow_rich_ap_link_help: Render an inline component when other ActivityPub\n  content is linked to.\nattitude: Attitude\ntype_search_magazine: Limit search to magazine...\ntype_search_user: Limit search to author...\nmodlog_type_entry_deleted: Thread deleted\nmodlog_type_entry_restored: Thread restored\nmodlog_type_entry_comment_deleted: Thread comment deleted\nmodlog_type_entry_comment_restored: Thread comment restored\nmodlog_type_entry_pinned: Thread pinned\nmodlog_type_entry_unpinned: Thread unpinned\nmodlog_type_post_deleted: Microblog deleted\nmodlog_type_post_restored: Microblog restored\nmodlog_type_post_comment_deleted: Microblog reply deleted\nmodlog_type_post_comment_restored: Microblog reply restored\nmodlog_type_ban: User banned from magazine\nmodlog_type_moderator_add: Magazine moderator added\nmodlog_type_moderator_remove: Magazine moderator removed\neveryone: Everyone\nnobody: Nobody\nfollowers_only: Followers only\ndirect_message_setting_label: Who can send you a direct message\nshow_boost_following_label: Show boosted content in Microblog and Combined view\nshow_boost_following_help: If this is enabled, threads, posts and comments boosted by you or users you follow\n  will show up in the Combined view of your subscriptions and Microblog view.\ndelete_magazine_icon: Delete magazine icon\nflash_magazine_theme_icon_detached_success: Magazine icon deleted successfully\ndelete_magazine_banner: Delete magazine banner\nflash_magazine_theme_banner_detached_success: Magazine banner deleted\n  successfully\nfederation_uses_allowlist: Use allowlist for federation\ndefederating_instance: Defederating instance %i\ntheir_user_follows: Amount of users from their instance following users on our\n  instance\nour_user_follows: Amount of users from our instance following users on their\n  instance\ntheir_magazine_subscriptions: Amount of users from their instance subscribed to\n  magazines on our instance\nour_magazine_subscriptions: Amount of users on our instance subscribed to\n  magazines from their instance\nconfirm_defederation: Confirm defederation\nflash_error_defederation_must_confirm: You have to confirm the defederation\nallowed_instances: Allowed instances\nbtn_deny: Deny\nbtn_allow: Allow\nban_instance: Ban instance\nallow_instance: Allow instance\nfederation_page_use_allowlist_help: If an allow list is used, this instance will\n  only federate with the explicitly allowed instances. Otherwise this instance\n  will federate with every instance, except those that are banned.\nyou_have_been_banned_from_magazine: You have been banned from magazine %m.\nyou_have_been_banned_from_magazine_permanently: You have been permanently banned from magazine %m.\nyou_are_no_longer_banned_from_magazine: You are no longer banned from magazine %m.\nfront_default_content: Frontpage default view\ndefault_content_default: Server default (Threads)\ndefault_content_combined: Threads + Microblog\ndefault_content_threads: Threads\ndefault_content_microblog: Microblog\ncombined: Combined\nsidebar_sections_random_local_only: Restrict \"Random Threads/Posts\" sidebar sections to local only\nsidebar_sections_users_local_only: Restrict \"Active people\" sidebar section to local only\nrandom_local_only_performance_warning: Enabling \"Random local only\" may cause SQL performance impact.\ndiscoverable: Discoverable\nuser_discoverable_help: If this is enabled, your profile, threads, microblogs and comments can be found\n  through search and the random panels. Your profile might also appear in the active user panel and on the people page.\n  If this is disabled, your posts will still be visible to other users, but they will not show up in the all feed.\nmagazine_discoverable_help: If this is enabled, this magazine and threads, microblogs and comments of this magazine\n  can be found through search and the random panels.\n  If this is disabled the magazine will still appear in the magazine list, but the threads and microblogs will not\n  appear in the all feed.\nflash_thread_lock_success: Thread locked successfully\nflash_thread_unlock_success: Thread unlocked successfully\nflash_post_lock_success: Microblog locked successfully\nflash_post_unlock_success: Microblog unlocked successfully\nlock: Lock\nunlock: Unlock\ncomments_locked: The comments are locked.\nmagazine_log_entry_locked: locked the comments of\nmagazine_log_entry_unlocked: unlocked the comments of\nmodlog_type_entry_lock: Thread locked\nmodlog_type_entry_unlock: Thread unlocked\nmodlog_type_post_lock: Microblog locked\nmodlog_type_post_unlock: Microblog unlocked\ncontentnotification.muted: Mute | get no notifications\ncontentnotification.default: Default | get notifications according to your default settings\ncontentnotification.loud: Loud | get all notifications\nindexable_by_search_engines: Indexable by search engines\nuser_indexable_by_search_engines_help: If this setting is false, search engines are advised to not index any of your threads\n  and microblogs, however your comments are not affected by this and bad actors might ignore it. This setting is also\n  federated to other servers.\nmagazine_indexable_by_search_engines_help: If this setting is false, search engines are advised to not index any of the\n  threads and microblogs in this magazines. That includes the landing page and all comment pages. This setting is also\n  federated to other servers.\nmagazine_name_as_tag: Use the magazine name as a tag\nmagazine_name_as_tag_help: The tags of a magazine are used to match microblog posts to this magazine.\n  For example if the name is \"fediverse\" and the magazine tags contain \"fediverse\", then every microblog post\n  containing \"#fediverse\" will be put in this magazine.\nmagazine_rules_deprecated: the rules field is deprecated and will be removed in the future.\n  Please put your rules in the description box.\nmonitoring: Monitoring\nmonitoring_queries: '{0}SQL Queries|{1}SQL Query|]1,Inf[ SQL Queries'\nmonitoring_duration_min: min\nmonitoring_duration_mean: mean\nmonitoring_duration_max: max\nmonitoring_query_count: count\nmonitoring_query_total: total\nmonitoring_duration: Duration\nmonitoring_dont_group_similar: don't group similar queries\nmonitoring_group_similar: group similar queries\nmonitoring_http_method: HTTP Method\nmonitoring_url: URL\nmonitoring_request_successful: Successful\nmonitoring_user_type: User Type\nmonitoring_path: Route/Message class\nmonitoring_handler: Controller/Transport\nmonitoring_started: Started\nmonitoring_twig_renders: Twig Renders\nmonitoring_curl_requests: Curl Requests\nmonitoring_route_overview: Spend execution time summed per route\nmonitoring_route_overview_description: This graph shows the summed milliseconds spent grouped by the route/message\n  being computed\nmonitoring_duration_overall: Other\nmonitoring_duration_query: Query\nmonitoring_duration_twig_render: Twit Render\nmonitoring_duration_curl_request: Curl Request\nmonitoring_duration_sending_response: Sending Response\nmonitoring_dont_format_query: don't format query\nmonitoring_format_query: format query\nmonitoring_dont_show_parameters: don't show parameters\nmonitoring_show_parameters: show parameters\nmonitoring_execution_type: Execution Type\nmonitoring_request: HTTP Request\nmonitoring_messenger: Messenger\nmonitoring_anonymous: Anonymous\nmonitoring_user: User\nmonitoring_activity_pub: ActivityPub\nmonitoring_ajax: AJAX\nmonitoring_created_from: Started after\nmonitoring_created_to: Started before\nmonitoring_duration_minimum: Minimum duration\nmonitoring_submit: Filter\nmonitoring_has_exception: Has exception\nmonitoring_chart_ordering: Chart ordering\nmonitoring_total_duration: Total duration\nmonitoring_mean_duration: Mean duration\nmonitoring_twig_compare_to_total: Compare to total duration\nmonitoring_twig_compare_to_parent: Compare to parent duration\nmonitoring_disabled: Monitoring is disabled.\nmonitoring_queries_enabled_persisted: Query monitoring is enabled.\nmonitoring_queries_enabled_not_persisted: Query monitoring is enabled, but only the execution time.\nmonitoring_queries_disabled: Query monitoring is disabled.\nmonitoring_twig_renders_enabled_persisted: Twig render monitoring is enabled.\nmonitoring_twig_renders_enabled_not_persisted: Twig rendering is enabled, but only the execution time.\nmonitoring_twig_renders_disabled: Twig render monitoring is disabled.\nmonitoring_curl_requests_enabled_persisted: Curl request monitoring is enabled.\nmonitoring_curl_requests_enabled_not_persisted: Curl request monitoring is enabled, but only the execution time.\nmonitoring_curl_requests_disabled: Curl request monitoring is disabled.\nreached_end: You've reached the end\nfirst_page: First page\nnext_page: Next page\nprevious_page: Previous page\nfilter_list_create: Create Filter List\nfilter_lists: Filter lists\nfilter_lists_where_to_filter: Where to apply the filter\nfilter_lists_filter_words: Filtered words\nexpiration_date: Expiration date\nfilter_lists_filter_location: Active in\nfilter_lists_word_exact_match: Exact match\nfilter_lists_word_exact_match_help: If exact match is true, then the search will be case sensitive\nfeeds: Feeds\nfilter_lists_feeds_help: Filter words in threads, microblogs and comments in feeds, like /all, /sub, magazine feeds, etc.\nfilter_lists_comments_help: Filter words while viewing a thread or microblog in the comment tree.\nfilter_lists_profile_help: Filter words while viewing a users' profile in their content.\nexpired: Expired\n"
  },
  {
    "path": "translations/messages.eo.yaml",
    "content": "type.article: Fadeno\ntype.photo: Foto\ntype.video: Filmeto\ntype.smart_contract: Inteligenta kontrakto\ntype.magazine: Revuo\npeople: Homoj\nevents: Eventoj\nmagazine: Revuo\nmagazines: Revuoj\nsearch: Serĉi\nadd: Aldoni\nselect_channel: Elektu kanalon\nlogin: Ensaluti\ntop: Supro\nhot: Furora\nactive: Aktiva\nnewest: Plej nova\ncommented: Komentis\nchange_view: Ŝanĝi aspekton\nfilter_by_time: Filtri laŭ tempo\nfilter_by_type: Filtri laŭ tipo\nfavourites: Porvoĉdonoj\nfavourite: Igi plej ŝatata\nadded: Aldonis\nup_votes: Akceloj\ndown_votes: Reduktoj\ncreated_at: Kreita\nowner: Proprulo\nsubscribers: Abonantoj\nreplies: Respondoj\nempty: Malplena\nunsubscribe: Malaboni\nremember_me: Memoru min\nunfollow: Malsekvi\nterms: Servadokondiĉoj\nprivacy_policy: Privateca politiko\nadd_new_article: Aldoni novan fadenon\nchange_theme: Ŝanĝi etoson\nuseful: Utila\nhelp: Helpo\ncards_view: Kartoj aspekto\n3h: 3h\n6h: 6h\n12h: 12h\n1d: 1t\n1w: 1s\n1m: 1m\narticles: Fadenoj\nphotos: Fotoj\nvideos: Filmetoj\nshare: Kunhavigi\ncopy_url: Kopii Mbin URL\ncopy_url_to_fediverse: Kopii originala URL\nshare_on_fediverse: Kunhavigi en Fediverso\nare_you_sure: Ĉu vi certas?\ntype.link: Ligilo\nthread: Fadeno\nthreads: Fadenoj\nmicroblog: Mikroblogo\noldest: Plej malnova\nmore: Pli\navatar: Avataro\nno_comments: Sen komentoj\nonline: Reta\ncomments: Komentoj\nposts: Afiŝoj\nmoderators: Moderigantoj\nfederated_magazine_info: Ĉi tiu revuo estas el federaciita servilo kaj povas \n  esti nekompleta.\nfederated_user_info: Ĉi tiu profilo estas el federaciita servilo kaj povas esti \n  nekompleta.\ngo_to_original_instance: Rigardi ĝin sur la fora nodo\nreset_check_email_desc: Se jam estas konto asociita kun via retpoŝtadreso, vi \n  baldaŭ ricevu retmesaĝon enhavantan ligilon, kiun vi povas uzi por reagordi \n  vian pasvorton. Ĉi tiu ligilo eksvalidiĝos en %expire%.\nlogin_or_email: Uzantnomo aŭ retpoŝtadreso\nagree_terms: Konsentu al %terms_link_start%Kondiĉoj%terms_link_end% kaj \n  %policy_link_start%Privateca Politiko%policy_link_end%\nreset_check_email_desc2: Se vi ne ricevas retmesaĝon, bonvolu kontroli vian \n  spam-dosierujon.\ncheck_email: Kontrolu vian retpoŝton\ntable_view: Tablo aspekto\ntheme: Etoso\n1y: 1j\ncomment: Komento\npost: Afiŝo\nlinks: Ligiloj\nall: Ĉiuj\nreport: Raporti\ninfinite_scroll: Senfina rulumado\nedit: Redakti\nadd_post: Aldoni afiŝon\nadd_media: Aldoni aŭdvidaĵon\nenter_your_post: Enigu vian afiŝon\nactivity: Aktiveco\npassword: Pasvorto\nalready_have_account: Ĉu vi jam havas konton?\nreset_password: Reagordi pasvorton\nto: al\nusername: Uzantnomo\nemail: Retpoŝtadreso\nrepeat_password: Ripetu pasvorton\nstats: Statistikoj\nfediverse: Fediverso\nadd_new_link: Aldoni novan ligilon\nadd_new_photo: Aldoni novan foton\nadd_new_video: Aldoni novan filmeton\ncontact: Kontakto\nfaq: Oftaj demandoj\nrss: RSS\nrandom_magazines: Hazardaj revuoj\nkbin_promo_title: Krei vian propran nodon\ncomments_count: '{0}Komentoj|{1}Komento|]1,Inf[ Komentoj'\nadd_comment: Aldoni komenton\nmarkdown_howto: Kiel la redaktilo funkcias?\nenter_your_comment: Enigu vian komenton\ncover: Kovrilo\nrelated_posts: Rilataj afiŝoj\nrandom_posts: Hazardaj afiŝoj\nsubscribe: Aboni\nfollow: Sekvi\nreply: Respondi\ndont_have_account: Ĉu vi ne havas konton?\nyou_cant_login: Ĉu vi forgesis vian pasvorton?\nregister: Registriĝi\nshow_more: Montri pli\nin: en\nabout_instance: Pri\nall_magazines: Ĉiuj revuoj\ncreate_new_magazine: Krei novan revuon\nadd_new_post: Aldoni novan afiŝon\ntokyo_night: Tokia Nokto\ncaptcha_enabled: «Captcha» ebligita\nup_vote: Akceli\ndown_vote: Redukti\nemail_confirm_content: 'Ĉu vi pretas aktivigi vian Mbin-konton? Alklaku la suban ligilon:'\nemail_verify: Konfirmu la retpoŝtadreson\nselect_magazine: Elektu revuon\nadd_new: Aldoni novan\nurl: URL\nimage: Bildo\ntitle: Titolo\nbody: Korpo\ntags: Etikedoj\nbadges: Insignoj\nis_adult: 18+ / NSPL\ndomain: Domajno\nname: Nomo\ndescription: Priskribo\nfollowing: Sekvado\nsubscriptions: Abonoj\noverview: Superrigardo\ncards: Kartoj\ncolumns: Kolumnoj\nuser: Uzanto\njoined: Aliĝis\npeople_federated: Federaciita\nrelated_tags: Rilataj etikedoj\ngo_to_content: Iri al enhavo\ngo_to_search: Iri al serĉi\nsubscribed: Abonita\nlogout: Elsaluti\nclassic_view: Klasika aspekto\ncompact_view: Kompacta aspekto\nchat_view: Babilo aspekto\ntree_view: Arba aspekto\nmoderate: Kontroli\nreason: Kialo\ndelete: Forigi\nedit_post: Redakti afiŝon\nsettings: Agordoj\ngeneral: Ĝenerala\nreports: Raportoj\nmessages: Mesaĝojn\nappearance: Aspekto\nhomepage: Hejmpaĝo\nhide_adult: Kaŝi NSPL-enhavon\nprivacy: Privateco\nshow_profile_subscriptions: Montri revuon abonojn\nshow_profile_followings: Montri sekvajn uzantojn\nnotify_on_new_entry_comment_reply: Respondoj al miaj komentoj en iuj fadenoj\nnotify_on_new_post_reply: Respondoj je ajna nivelo al afiŝoj, kiujn mi verkis\nnotify_on_new_post_comment_reply: Respondoj al miaj komentoj pri iuj afiŝoj\nnotify_on_new_entry: Novaj fadenoj (ligiloj aŭ artikoloj) en iu ajn revuo, al \n  kiu mi estas abonita\nsave: Konservi\nabout: Pri\nold_email: Nuna retpoŝtadreso\nnew_email: Nova retpoŝtadreso\nnew_email_repeat: Konfirmu novan retpoŝtadreson\ncurrent_password: Nuna pasvorto\nnew_password: Nova pasvorto\nnew_password_repeat: Konfirmu novan pasvorton\nexpand: Vastigi\ncollapse: Kolapsi\ndomains: Domajnoj\nerror: Eraro\nvotes: Voĉdonoj\ndark: Malhela\nfont_size: Tipara grando\nsize: Grando\nboosts: Akceloj\nshow_users_avatars: Montri avatarojn de uzantoj\nyes: Jes\nno: Ne\nshow_magazines_icons: Montri ikonojn de revuoj\nshow_thumbnails: Montri bildetojn\nrounded_edges: Rondigitaj randoj\nrestored_thread_by: restaŭris fadenon de\nremoved_post_by: forigis afiŝon de\nrestored_comment_by: restaŭris komenton de\nrestored_post_by: restaŭris afiŝon de\nread_all: Legi ĉion\nshow_all: Montri ĉion\nset_magazines_bar: Revuoj breto\nset_magazines_bar_desc: aldonu la revuonomojn post la komo\nflash_thread_new_success: La fadeno estis kreita sukcese kaj nun videblas por \n  aliaj uzantoj.\nflash_thread_edit_success: La fadeno estas sukcese redaktita.\nflash_thread_delete_success: La fadeno estas sukcese forigita.\nflash_thread_pin_success: La fadeno estas sukcese alpinglita.\nflash_thread_unpin_success: La fadeno estas sukcese depinglita.\nflash_magazine_edit_success: La revuo estas sukcese redaktita.\nrules: Reguloj\nfollowers: Sekvantoj\noc: OE\nimage_alt: Bildo alternativa teksto\nemail_confirm_title: Konfirmu vian retpoŝtadreson.\nemail_confirm_header: Saluton! Konfirmu vian retpoŝtadreson.\nemail_confirm_expire: Bonvolu noti, ke la ligilo eksvalidiĝos post horo.\nremoved_comment_by: forigis komenton de\nremoved_thread_by: forigis fadenon de\ntoo_many_requests: Limo superita, bonvolu provi denove poste.\nset_magazines_bar_empty_desc: se la kampo estas malplena, aktivaj revuoj estas \n  montrataj sur la breto.\nedit_comment: Konservi ŝanĝojn\nprofile: Profilo\nmod_log: Kontrol-protokolo\nflash_register_success: Bonvenon surŝipe! Via konto nun estas registrita. Unu \n  fina paŝo - kontrolu vian enirkeston por aktiviga ligilo, kiu vivigos vian \n  konton.\npeople_local: Loka\nreputation_points: Reputaciopoentoj\nflash_magazine_new_success: La revuo estas sukcese kreita. Vi nun povas aldoni \n  novan enhavon aŭ esplori la administran panelon de la revuo.\ngo_to_filters: Iri al filtriloj\nnotifications: Sciigoj\nfeatured_magazines: Elstaraj revuoj\nnotify_on_new_entry_reply: Komentoj je ajna nivelo en fadenoj kiujn mi verkis\nnotify_on_new_posts: Novaj afiŝoj en iu ajn revuo, al kiu mi estas abonita\nchange_email: Ŝanĝi retpoŝtadreson\nchange_password: Ŝanĝi pasvorton\nlight: Hela\ntry_again: Provu denove\nblocked: Blokita\nhe_banned: forbari\nhe_unbanned: malforbari\nbanned: Forbaris vin\ndeleted: Forigita de la aŭtoro\nmentioned_you: Menciis vin\nsend_message: Sendi rektan mesaĝon\nmessage: Mesaĝo\nfrom_url: El URL\nusers: Uzantoj\ncontent: Enhavo\nweek: Semajno\nweeks: Semajnoj\nmonths: Monatoj\nyear: Jaro\npages: Paĝoj\nFAQ: Oftaj demandoj\nYour account is not active: Via konto ne estas aktiva.\nrelated_entries: Rilataj fadenoj\nrelated_magazines: Rilataj revuoj\nbanned_instances: Forbaritaj nodoj\nwrote_message: Skribis mesaĝon\ninstances: Nodoj\nupload_file: Alŝuti dosieron\nban: Forbari\nchange_language: 'Ŝanĝi lingvon'\narticle: Fadeno\nreputation: Reputacio\nmonth: Monato\ninstance: Nodo\nPassword is invalid: Pasvorto nevalidas.\nYour account has been banned: Via konto estis forbarita.\nsend: Sendi\nrandom_entries: Hazardaj fadenoj\ndelete_account: Forigi konton\nban_account: Forbari konton\nunban_account: Malforbari konton\nmoderated: Moderigita\nsolarized_light: Hele sunlumigita\nsolarized_dark: Malhele Sunlumigita\nmod_log_alert: AVERTO - La kontrol-protokolo povus enhavi malagrablan aŭ \n  afliktan enhavon, kiu estis forigita de moderigantoj. Bonvolu esti singarda.\nadded_new_thread: Aldonis novan fadenon\nboost: Diskonigi\nedited_thread: Redaktis fadenon\nadded_new_comment: Aldonis novan komenton\nedited_comment: Redaktis komenton\nmod_deleted_your_comment: Moderiganto forigis vian komenton\nedited_post: Redaktis afiŝon\nmod_remove_your_post: Moderiganto forigis vian afiŝon\nadded_new_reply: Aldonis novan respondon\nmod_remove_your_thread: Moderiganto forigis vian fadenon\nreplied_to_your_comment: Respondis al via komento\nadded_new_post: Aldonis novan afiŝon\nremoved: Forigita de moderiganto\nban_expired: Forbaro eksvalidiĝis\npurge: Viŝi\nadd_moderator: Aldoni moderiganton\nsticky_navbar: Gluita naviga breto\nshow_top_bar: Montri supran breton\neng: ENG\nsubject_reported: La enhavo estis raportita.\nleft: Maldekstre\nright: Dekstre\napprove: Aprobi\napproved: Aprobita\ntrash: Rubujo\nfederation: Federacio\nfilters: Filtriloj\nsidebar_position: Flanka kolumno pozicio\nstatus: Stato\noff: Malŝalta\nmagazine_panel: Revua panelo\nreject: Malakcepti\nrejected: Malakceptita\nadd_badge: Aldoni insignon\nperm: Permanenta\nexpired_at: Eksvalidiĝis je\nadd_ban: Aldoni forbaron\nicon: Ikono\ndone: Farita\npin: Alpingli\nunpin: Depingli\nchange_magazine: Ŝanĝi revuon\nchange: Ŝanĝi\npinned: Alpinglita\npreview: Antaŭrigardi\nnote: Noton\nwriting: Skribado\nfederated: Federaciita\nlocal: Loka\nadmin_panel: Administra panelo\ndashboard: Panelo\ncontact_email: Kontakta retpoŝtadreso\nmeta: Meta\nfederation_enabled: Federado ebligita\nregistration_disabled: Registrado malebligita\nrestore: Restarigi\nadd_mentions_posts: Aldoni mencioetikedojn en afiŝoj\nfirstname: Persona nomo\nactive_users: Aktivaj homoj\nsidebar: Flanka kolumno\nauto_preview: Aŭtomata aŭdvidaĵa antaŭvido\ndynamic_lists: Dinamikaj listoj\nkbin_intro_title: Esplori la Fediverso\nkbin_promo_desc: '%link_start%Kloni la deponejon%link_end% kaj programi la fediverson'\nheader_logo: Kapa emblemo\nreturn: Reveni\nbrowsing_one_thread: Vi nur foliumas unu fadenon en la diskuto! Ĉiuj komentoj \n  disponeblas en la afiŝa paĝo.\nreport_issue: Raporti problemon\nmercure_enabled: Mercure ebligita\npreferred_languages: Filtri lingvoj de fadenoj kaj afiŝoj\ntype_search_term: Tajpu serĉterminon\nregistrations_enabled: Registrado ebligita\nadd_mentions_entries: Aldoni mencioetikedojn en fadenoj\npurge_account: Viŝi konton\nmagazine_panel_tags_info: Provizu nur se vi volas, ke enhavo de la fediverso \n  estu inkluzivita en ĉi tiu revuo bazita sur etikedoj\non: Ŝalta\nbans: Forbaroj\ncreated: Kreita\nexpires: Eksvalidiĝas\nkbin_intro_desc: estas malcentra platformo por enhavo-kolektado kaj \n  mikroblogado, kiu funkcias ene de la Fediversa reto.\ninfinite_scroll_help: Aŭtomate ŝarĝi pli da enhavo kiam vi atingas la malsupron \n  de la paĝo.\nsticky_navbar_help: La navigadbreto algluiĝos al la supro de la paĝo kiam vi \n  rulumas malsupren.\nauto_preview_help: Montri la antaŭrigardojn de la aŭdvidaĵaj (fotaj, filmetaj) \n  en pli granda grandeco sub la enhavo.\nreload_to_apply: Reŝargi paĝon por apliki ŝanĝojn\nfilter.origin.label: Elekti originon\nfilter.fields.label: Elekti kiujn kampojn por serĉi\nfilter.adult.label: Elekti ĉu montri NSPL\nfilter.adult.hide: Kaŝi NSPL\nfilter.adult.only: Nur NSPL\nfilter.fields.only_names: Nur nomoj\nfilter.fields.names_and_descriptions: Nomoj kaj priskriboj\nkbin_bot: Mbin Agento\nfilter.adult.show: Montri NSPL\nlocal_and_federated: Loka kaj federaciita\nbot_body_content: \"Bonvenon al la Mbin Agento! Ĉi tiu agento ludas decidan rolon en\n  ebligado de ActivityPub-funkcio ene de Mbin. Ĝi certigas, ke Mbin povas komuniki\n  kaj federacii kun aliaj nodoj en la fediverso.\\n\\nActivityPub estas malferma norma\n  protokolo, kiu permesas al malcentraj sociaj retaj platformoj komuniki kaj interagi\n  unu kun la alia. Ĝi ebligas al uzantoj en malsamaj nodoj (serviloj) sekvi, interagi\n  kun, kaj kundividi enhavon tra la federacia socia reto konata kiel la fediverso.\n  Ĝi provizas normigitan manieron por uzantoj publikigi enhavon, sekvi aliajn uzantojn,\n  kaj okupiĝi pri sociaj interagoj kiel ŝatado, kundivido kaj komentado pri fadenoj\n  aŭ afiŝoj.\"\npassword_confirm_header: Konfirmu vian peton de ŝanĝo de pasvorto.\noauth2.grant.moderate.magazine.reports.all: Administri raportojn en la revuoj, \n  kiujn vi moderigas.\nresend_account_activation_email_error: Estis problemo dum ĉi tiu peto. Eble ne \n  ekzistas konto asociita kun tiu retpoŝtadreso aŭ eble ĝi jam estas aktivigita.\nfederation_page_enabled: Federada paĝo ebligita\nemail_confirm_button_text: Konfirmu vian peton de ŝanĝo de pasvorto\ntoolbar.bold: Grasa\nerrors.server429.title: 429 Tro da Petoj\ntoolbar.header: Kapo\noauth.consent.to_allow_access: Por permesi ĉi tiun aliron, alklaku la butonon \n  'Permesi' sube\nemail.delete.description: La sekva uzanto petis, ke ilia konto estu forigita\ntoolbar.ordered_list: Ordigita Listo\noauth.consent.app_requesting_permissions: ŝatus plenumi la sekvajn agojn en via \n  nomo\nfederated_search_only_loggedin: Federacia serĉo limigita se ne ensalutinta\noauth2.grant.moderate.magazine.reports.action: Akcepti aŭ malakcepti raportojn \n  en la revuoj, kiujn vi moderigas.\nyour_account_is_not_active: Via konto ne estas aktivigita. Bonvolu kontroli vian\n  retpoŝton por instrukcioj pri aktivigo de konto aŭ <a \n  href=\"%link_target%\">peti novan retmesaĝon pri aktivigo de konto.</a>\noauth.consent.allow: Permesi\ncustom_css: Adaptita CSS\nblock: Bloki\ntoolbar.quote: Citaĵo\noauth2.grant.moderate.magazine.list: Legi liston de la revuoj, kiujn vi \n  moderigas.\ntoolbar.unordered_list: Neordigita Listo\nerrors.server404.title: 404 Ne Trovita\nresend_account_activation_email_success: Se konto asociita kun tiu retpoŝtadreso\n  ekzistas, ni sendos novan aktivigan retmesaĝon.\nerrors.server403.title: 403 Malpermesita\noauth2.grant.moderate.magazine.reports.read: Legi raportojn en la revuoj, kiujn \n  vi moderigas.\nignore_magazines_custom_css: Ignori la adaptitan CSS-on de revuoj\noauth.consent.deny: Nei\noauth.consent.title: OAuth2 konsentformularo\nfederation_page_allowed_description: Konataj nodoj kun kiuj ni federacias\nresend_account_activation_email: Resendi retmesaĝon pri aktivigo de konto\nerrors.server500.title: 500 Interna Servila Eraro\ntoolbar.link: Ligilo\ntoolbar.mention: Mencio\nresend_account_activation_email_question: Neaktiva konto?\nresend_account_activation_email_description: Enigu la retpoŝtadreson asociitan \n  kun via konto. Ni sendos alian aktivigan retmesaĝon por vi.\nyour_account_has_been_banned: Via konto forbaris\ntoolbar.code: Kodo\nerrors.server500.description: Pardonu, io misfunkciis ĉe nia flanko. Se vi daŭre\n  vidas ĉi tiun eraron, provu kontakti la posedanton de la nodo. Se ĉi tiu nodo \n  tute ne funkcias, kontrolu %link_start%aliajn Mbin-nodojn%link_end% dume ĝis \n  la problemo estos solvita.\noauth.client_not_granted_message_read_permission: Ĉi tiu programo ne ricevis \n  permeson legi viajn mesaĝojn.\nrestrict_oauth_clients: Limigi kreadon de Kliento OAuth2 al Administrantoj\nfederation_page_disallowed_description: Nodoj kun kiuj ni ne federacias\nunblock: Malbloki\noauth.consent.grant_permissions: Doni Permesojn\noauth2.grant.moderate.magazine.ban.delete: Malforbari uzantojn en la revuoj, \n  kiujn vi moderigas.\noauth.client_identifier.invalid: Nevalida OAuth Kliento-ID!\noauth.consent.app_has_permissions: jam povas plenumi la jenajn agojn\nemail.delete.title: Peto pri forigo de uzantkonto\nemail_confirm_link_help: Alternative vi povas kopii kaj alglui la jenajn en vian\n  retumilon\ntoolbar.strikethrough: Trastreki\ntoolbar.image: Bildo\ntoolbar.italic: Kursiva\nmore_from_domain: Pli de domajno\noauth2.grant.moderate.magazine.trash.read: Rigardi rubujigatan enhavon en \n  revuoj, kiujn vi moderigas.\noauth2.grant.moderate.magazine_admin.create: Krei novajn revuojn.\noauth2.grant.moderate.magazine_admin.edit_theme: Redakti la adaptitan CSS-on de \n  iu ajn el viaj posedataj revuoj.\noauth2.grant.moderate.magazine_admin.tags: Krei aŭ forigi etikedojn de viaj \n  posedataj revuoj.\noauth2.grant.admin.entry.purge: Tute forigu ajnan fadenon de via nodo.\noauth2.grant.moderate.magazine_admin.delete: Forigi iujn ajn revuojn, kiujn vi \n  posedas.\noauth2.grant.moderate.magazine_admin.all: Krei, redakti aŭ forigi revuojn, kiujn\n  vi posedas.\noauth2.grant.report.general: Raporti fadenojn, afiŝojn aŭ komentojn.\noauth2.grant.admin.all: Fari ajnan administran agon sur via nodo.\noauth2.grant.moderate.magazine_admin.update: Redakti iun ajn el viaj posedataj \n  revuoj reguloj, priskribo, NSFL-statuso aŭ ikono.\noauth2.grant.write.general: Krei aŭ redakti iun ajn el viaj fadenoj, afiŝoj aŭ \n  komentoj.\noauth2.grant.read.general: Legi ĉiujn enhavojn, al kiuj vi havas aliron.\noauth2.grant.delete.general: Forigi iujn viajn fadenojn, afiŝojn aŭ komentojn.\noauth2.grant.moderate.magazine_admin.stats: Rigardi la statistikojn de enhavo, \n  voĉoj kaj vidoj de viaj posedataj revuoj.\noauth2.grant.moderate.magazine_admin.moderators: Aldoni aŭ forigi moderigantojn \n  de iu ajn el viaj posedataj revuoj.\noauth2.grant.moderate.magazine_admin.badges: Krei aŭ forigi insignojn de viaj \n  posedataj revuoj.\nflash_image_download_too_large_error: La bildo ne povis esti kreita, ĝi estas \n  tro granda (maksimuma grandeco %bytes%)\nshow_subscriptions: Montri abonojn\nflash_thread_new_error: La fadeno ne povis esti kreita. Io misfunkciis.\noauth2.grant.moderate.post_comment.change_language: Ŝanĝi la lingvon de komentoj\n  pri afiŝoj en la revuoj, kiujn vi moderigas.\ndownvotes_mode: Reĝimo kontraŭvoĉdonoj\ndisabled: Malebligita\nhidden: Kaŝita\nenabled: Ebligita\nchange_downvotes_mode: Ŝanĝi reĝimon de kontraŭvoĉdonoj\ntag: Etikedo\nedit_entry: Redakti fadenon\ndefault_theme: Defaŭlta etoso\ndefault_theme_auto: Hela/Malhela (Aŭtomatrekoni)\nflash_mark_as_adult_success: La afiŝo estis sukcese markita kiel NSPL.\nflash_unmark_as_adult_success: La afiŝo estis sukcese malmarkita kiel NSPL.\nunban: Malforbari\nban_hashtag_btn: Forbari haŝetikedon\naccount_deletion_title: Forigo de konto\noauth2.grant.subscribe.general: Aboni aŭ sekvi ajnan revuon, domajnon aŭ \n  uzanton, kaj rigardi la revuojn, domajnojn kaj uzantojn, al kiuj vi abonas.\noauth2.grant.moderate.post.all: Kontroli afiŝojn en la revuoj, kiujn vi \n  moderigas.\noauth2.grant.moderate.post_comment.all: Kontroli komentojn pri afiŝoj en la \n  revuoj, kiujn vi moderigas.\nsingle_settings: Unuopa\noauth2.grant.moderate.magazine.all: Administri forbarojn, raportojn, kaj rigardi\n  rubujigataj eroj en la revuoj, kiujn vi moderigas.\noauth2.grant.moderate.magazine.ban.all: Administri forbaritajn uzantojn en la \n  revuoj, kiujn vi moderigas.\naccount_deletion_immediate: Forigi tuj\nfrom: de\nsubscription_sidebar_pop_out_right: Movi al aparta flanka kolumno dekstre\nmenu: Menuo\nunban_hashtag_btn: Malforbari Haŝetikedon\nban_hashtag_description: Forbaro de haŝetikedo malhelpos kreadon de afiŝoj kun \n  ĉi tiu haŝetikedo, kaj ankaŭ kaŝos ekzistantajn afiŝojn kun ĉi tiu haŝetikedo.\nunmark_as_adult: Malmarki NSPL\naccount_deletion_button: Forigi Konton\noauth2.grant.domain.all: Aboni aŭ bloki domajnojn, kaj rigardi la domajnojn, \n  kiujn vi abonas aŭ blokas.\noauth2.grant.entry_comment.all: Krei, redakti aŭ forigi viajn komentojn en \n  fadenoj, kaj voĉdoni, akceli aŭ raporti ajnan komenton en fadeno.\noauth2.grant.entry.vote: Porvoĉdoni, akceli aŭ kontraŭvoĉdoni ajnan fadenon.\noauth2.grant.user.message.create: Sendi mesaĝojn al aliaj uzantoj.\noauth2.grant.user.notification.all: Legi kaj malplenigi viajn sciigojn.\noauth2.grant.user.message.read: Legi viajn mesaĝojn.\noauth2.grant.moderate.entry.all: Kontroli fadenojn en la revuoj, kiujn vi \n  moderigas.\noauth2.grant.user.oauth_clients.edit: Redakti la permesojn, kiujn vi donis al \n  aliaj OAuth2-aplikaĵoj.\noauth2.grant.user.block: Bloki aŭ malbloki uzantojn, kaj legi liston de uzantoj,\n  kiujn vi blokas.\noauth2.grant.moderate.entry.change_language: Ŝanĝi la lingvon de fadenoj en la \n  revuoj, kiujn vi moderigas.\noauth2.grant.moderate.entry.trash: Rubuji aŭ restarigi fadenojn en la revuoj, \n  kiujn vi moderigas.\noauth2.grant.moderate.entry_comment.trash: Rubuji aŭ restarigi komentojn en \n  fadenoj de la revuoj, kiujn vi moderigas.\noauth2.grant.moderate.entry_comment.change_language: Ŝanĝi la lingvon de \n  komentoj en fadenoj de la revuoj, kiujn vi moderigas.\noauth2.grant.admin.magazine.move_entry: Movi fadenojn inter revuojn sur via \n  nodo.\noauth2.grant.admin.user.verify: Konfirmi uzantojn sur via nodo.\noauth2.grant.admin.user.delete: Forigi uzantojn de via nodo.\noauth2.grant.admin.federation.read: Rigardi la liston de defederitaj nodoj.\noauth2.grant.admin.federation.update: Aldoni aŭ forigi nodojn al aŭ de la listo \n  de defederitaj nodoj.\noauth2.grant.admin.instance.settings.all: Rigardi aŭ ĝisdatigi agordojn sur via \n  nodo.\noauth2.grant.admin.oauth_clients.revoke: Revoki aliron al OAuth2-klientoj en via\n  nodo.\nshow_avatars_on_comments: Montri Komentajn Avatarojn\nmoderation.report.reject_report_title: Malakcepti La Raporton\nmoderation.report.approve_report_confirmation: Ĉu vi certas, ke vi volas aprobi \n  ĉi tiun raporton?\npurge_content_desc: Tute viŝi la enhavon de la uzanto, inkluzive de forigi la \n  respondoj de aliaj uzantoj en kreitaj fadenoj, afiŝoj kaj komentoj.\nschedule_delete_account: Plani Forigon\nremove_schedule_delete_account: Forigi Planitan Forigon\ntwo_factor_authentication: Dupaŝa aŭtentigo\n2fa.disable: Malebligi dupaŝan aŭtentigon\n2fa.backup: Viaj dupaŝaj rezervaj kodoj\n2fa.enable: Agordi dupaŝan aŭtentigon\n2fa.verify_authentication_code.label: Enigi dupaŝan kodon por konfirmi agordon\n2fa.qr_code_link.title: Vizitante ĉi tiun ligon povas permesi al via platformo \n  registri ĉi tiun dupaŝan aŭtentigon\n2fa.backup_codes.help: Vi povas uzi ĉi tiujn kodojn kiam vi ne havas vian \n  dupaŝan aŭtentikan aparaton aŭ aplikaĵon. <strong>Oni ne plu montros \n  ilin</strong> al vi kaj vi povos uzi ĉiun el ili <strong>nur unufoje</strong>.\nsubscriptions_in_own_sidebar: En aparta flanka kolumno\nsidebars_same_side: Flankaj kolumnoj al la sama flanko\nsubscription_sidebar_pop_in: Movi abonojn al la enlinia panelo\npending: Pritraktata\nposition_top: Supro\nsolarized_auto: Sunigita (Aŭtomatrekoni)\nunban_hashtag_description: Malforbaro de haŝetikedo permesos krei afiŝojn kun ĉi\n  tiu haŝetikedo denove. Ekzistantaj afiŝoj kun ĉi tiu hashetikedo ne plu estas \n  kaŝitaj.\noauth2.grant.admin.instance.all: Rigardi kaj ĝisdatigi agordojn aŭ informojn de \n  nodo.\noauth2.grant.admin.instance.settings.read: Rigradi agordojn sur via nodo.\noauth2.grant.moderate.post.trash: Rubuji aŭ restarigi afiŝojn en la revuoj, \n  kiujn vi moderigas.\noauth2.grant.admin.magazine.purge: Tute forigi revuojn sur via nodo.\nmoderation.report.reject_report_confirmation: Ĉu vi certas, ke vi volas \n  malakcepti ĉi tiun raporton?\npassword_and_2fa: Pasvorto & 2PA\nsubscription_sort: Ordigi\nsubscription_panel_large: Granda panelo\noauth2.grant.entry.create: Krei novajn fadenojn.\noauth2.grant.moderate.magazine.ban.create: Forbari uzantojn en la revuoj, kiujn \n  vi moderigas.\noauth2.grant.block.general: Bloki aŭ malbloki ajnan revuon, domajnon aŭ uzanton,\n  kaj rigardi la revuojn, domajnojn kaj uzantojn, kiujn vi blokis.\ntoolbar.spoiler: Malkaŝo de intrigo\naccount_deletion_description: Via konto estos forigita post 30 tagoj krom se vi \n  elektas forigi la konton tuj. Por restarigi vian konton ene de 30 tagoj, \n  ensalutu kun la samaj uzantkreditaĵoj aŭ kontaktu administranton.\noauth2.grant.post_comment.delete: Forigi viajn ekzistantajn komentojn pri \n  afiŝoj.\noauth2.grant.vote.general: Porvoĉdoni, kontraŭvoĉdoni, aŭ akceli fadenon, \n  afiŝojn aŭ komentojn.\noauth2.grant.post.edit: Redakti viajn ekzistantajn afiŝojn.\noauth2.grant.post_comment.all: Krei, redakti aŭ forigi viajn komentojn pri \n  afiŝoj, kaj voĉdoni, akceli aŭ raporti ajnan komenton pri afiŝo.\noauth2.grant.user.oauth_clients.all: Legi kaj redakti la permesojn, kiujn vi \n  donis al aliaj OAuth2-aplikaĵoj.\noauth2.grant.user.oauth_clients.read: Legi la permesojn, kiujn vi donis al aliaj\n  OAuth2-aplikaĵoj.\noauth2.grant.moderate.entry_comment.all: Kontroli komentojn en fadenoj en la \n  revuoj, kiujn vi moderigas.\noauth2.grant.moderate.entry.set_adult: Marki fadenojn kiel NSPL en la revuoj, \n  kiujn vi moderigas.\noauth2.grant.post.delete: Forigi viajn ekzistantajn afiŝojn.\noauth2.grant.moderate.entry.pin: Alpingli fadenojn al la supro de la revuoj, \n  kiujn vi moderigas.\noauth2.grant.post.report: Raporti ajnan afiŝon.\noauth2.grant.moderate.post_comment.set_adult: Marki komentojn pri afiŝoj kiel \n  NSPL en la revuoj, kiujn vi moderigas.\noauth2.grant.moderate.post_comment.trash: Rubuji aŭ restarigi komentojn pri \n  afiŝoj en la revuoj, kiujn vi moderigas.\nmoderation.report.approve_report_title: Aprobi La Raporton\noauth2.grant.admin.oauth_clients.all: Rigardi aŭ revoki OAuth2-klientojn, kiuj \n  ekzistas sur via nodo.\noauth2.grant.admin.oauth_clients.read: Rigardi OAuth2-klientojn, kiuj ekzistas \n  sur via nodo, kaj iliajn uzstatistikojn.\ncomment_reply_position_help: Montri la komentan respondformularon aŭ supre aŭ \n  malsupre de la paĝo. Kiam 'senfina movo' estas ebligita la pozicio ĉiam aperos\n  supre.\nupdate_comment: Ĝisdatigi komenton\nremove_schedule_delete_account_desc: Forigi la planitan forigon. La tuta enhavo \n  estos disponebla denove kaj la uzanto povos ensaluti.\noauth2.grant.domain.block: Bloki aŭ malbloki domajnojn kaj rigardi la domajnojn,\n  kiujn vi blokis.\noauth2.grant.entry.all: Krei, redakti aŭ forigi viajn fadenojn, kaj voĉdoni, \n  akceli aŭ raporti ajnan fadenon.\noauth2.grant.entry_comment.delete: Forigi viajn ekzistantajn komentojn en \n  fadenoj.\noauth2.grant.entry_comment.vote: Porvoĉdoni, akceli aŭ kontraŭvoĉdoni ajnan \n  komenton en fadeno.\noauth2.grant.entry_comment.report: Raporti ajnan komenton en fadeno.\noauth2.grant.post.create: Krei novajn afiŝojn.\noauth2.grant.magazine.block: Bloki aŭ malbloki revuojn kaj rigardi la revuojn, \n  kiujn vi blokis.\noauth2.grant.post.vote: Porvoĉdoni, akceli aŭ kontraŭvoĉdoni iun ajn afiŝon.\noauth2.grant.post_comment.vote: Porvoĉdoni, akceli aŭ kontraŭvoĉdoni ajnan \n  komenton pri afiŝo.\noauth2.grant.post_comment.report: Raporti ajnan komenton pri afiŝo.\noauth2.grant.user.notification.read: Legi viajn sciigojn, inkluzive de mesaĝaj \n  sciigoj.\noauth2.grant.moderate.all: Fari ajnan moderigan agon, kiun vi rajtas fari en la \n  revuoj, kiujn vi moderigas.\noauth2.grant.admin.post_comment.purge: Tute forigi ajnan komenton pri afiŝo de \n  via nodo.\noauth2.grant.admin.user.ban: Forbari aŭ malforbari uzantojn de via nodo.\noauth2.grant.admin.instance.settings.edit: Ĝisdatigi agordojn sur via nodo.\noauth2.grant.admin.instance.information.edit: Ĝisdatigi la paĝojn Pri, Oftaj \n  Demandoj, Kontakto, Servadokondiĉoj kaj Privateca Politiko sur via nodo.\nlast_active: Laste Aktiva\nflash_post_unpin_success: La afiŝo estis sukcese malpinglita.\nsubject_reported_exists: Ĉi tiu enhavo jam estis raportita.\nmoderation.report.ban_user_title: Forbari Uzanton\npurge_content: Viŝi enhavon\ndelete_content_desc: Forigi la enhavon de la uzanto lasante la respondojn de \n  aliaj uzantoj en la kreitaj fadenoj, afiŝoj kaj komentoj.\n2fa.authentication_code.label: Aŭtentikiga Kodo\n2fa.verify: Konfirmi\n2fa.qr_code_img.alt: QR-kodo, kiu permesas la agordon de dupaŝa aŭtentigo por \n  via konto\n2fa.backup_codes.recommendation: Oni rekomendas, ke vi konservu kopion de ili en\n  sekura loko.\ncancel: Nuligi\nschedule_delete_account_desc: Plani la forigon de ĉi tiu konto post 30 tagoj. Ĉi\n  tio kaŝos la uzanton kaj ilian enhavon kaj ankaŭ preventos la uzanton \n  ensaluti.\n2fa.code_invalid: La aŭtentikigkodo ne validas\nmoderation.report.ban_user_description: Ĉu vi volas forbari la uzanton \n  (%username%) kiu kreis ĉi tiun enhavon de ĉi tiu revuo?\nflash_thread_tag_banned_error: La fadeno ne povis esti kreita. La enhavo ne \n  estas permesita.\ndelete_account_desc: Forigi la konton, inkluzive de la respondoj de aliaj \n  uzantoj en kreitaj fadenoj, afiŝoj kaj komentoj.\nalphabetically: Alfabete\nclose: Fermi\nmark_as_adult: Marki NSPL\noauth2.grant.domain.subscribe: Aboni aŭ malaboni domajnojn kaj rigardi la \n  domajnojn al kiuj vi abonas.\noauth2.grant.entry.edit: Redakti viajn ekzistantajn fadenojn.\noauth2.grant.magazine.all: Aboni aŭ bloki revuojn, kaj rigardi la revuojn, kiujn\n  vi abonas aŭ blokas.\noauth2.grant.magazine.subscribe: Aboni aŭ malaboni revuojn kaj rigardi la \n  revuojn, kiujn vi abonas.\noauth2.grant.user.profile.all: Legi kaj redakti vian profilon.\noauth2.grant.admin.magazine.all: Movi fadenojn inter aŭ tute forigi revuojn sur \n  via nodo.\nflash_post_pin_success: La afiŝo estis sukcese alpinglita.\ncomment_reply_position: Komenta respondpozicio\nflash_account_settings_changed: Viaj kontaj agordoj estis sukcese ŝanĝitaj. Vi \n  devos denove ensaluti.\nsubscription_sidebar_pop_out_left: Movi al aparta flanka kolumno maldekstre\nflash_email_was_sent: Retmesaĝo estas sukcese sendita.\noauth2.grant.post.all: Krei, redakti aŭ forigi viajn mikroblogojn, kaj voĉdoni, \n  akceli aŭ raporti ajnan mikroblogon.\n2fa.remove: Forigi 2PA\noauth2.grant.user.follow: Sekvi aŭ malsekvi uzantojn, kaj legi liston de \n  uzantoj, kiujn vi sekvas.\nsubscribers_count: '{0}Abonantoj|{1}Abonanto|]1,Inf[ Abonantoj'\nfollowers_count: '{0}Sekvantoj|{1}Sekvanto|]1,Inf[ Sekvantoj'\nmarked_for_deletion: Markita por forigo\nmarked_for_deletion_at: Markita por forigo je %date%\nsort_by: Ordigi laŭ\nfilter_by_subscription: Filtri laŭ abono\nfilter_by_federation: Filtri laŭ federada stato\ntwo_factor_backup: Dupaŝaj aŭtentigaj rezervaj kodoj\nposition_bottom: Malsupro\nsubscribe_for_updates: Abonu por komenci ricevi ĝisdatigojn.\nremove_media: Forigi aŭdvidaĵon\nalways_disconnected_magazine_info: Ĉi tiu revuo ne ricevas ĝisdatigojn.\ndisconnected_magazine_info: Ĉi tiu revuo ne ricevas ĝisdatigojn (lasta aktiveco \n  antaŭ %days% tagoj).\noauth2.grant.post_comment.create: Krei novajn komentojn pri afiŝoj.\noauth2.grant.entry_comment.create: Krei novajn komentojn en fadenoj.\noauth2.grant.entry_comment.edit: Redakti viajn ekzistantajn komentojn en \n  fadenoj.\noauth2.grant.admin.instance.stats: Rigardi la statistikojn de via nodo.\n2fa.available_apps: Uzu dupaŝan aŭtentikigaplikaĵon kiel %google_authenticator%,\n  %aegis% (Android) aŭ %raivo% (iOS) por skani la QR-kodon.\n2fa.user_active_tfa.title: Uzanto havas aktivan 2PA-on\nfederation_page_dead_title: Mortintaj nodoj\nfederation_page_dead_description: Nodoj, kie ni ne povis liveri almenaŭ 10 \n  agadojn sinsekve kaj kie la lastaj sukcesaj liverado kaj ricevado estis antaŭ \n  pli ol unu semajno\nprivate_instance: Devigi uzantojn ensaluti antaŭ ol ili povas aliri ajnan \n  enhavon\noauth2.grant.entry.delete: Forigi viajn ekzistantajn fadenojn.\noauth2.grant.entry.report: Raporti ajnan fadenon.\noauth2.grant.post_comment.edit: Redakti viajn ekzistantajn komentojn pri afiŝoj.\noauth2.grant.user.all: Legi kaj redakti vian profilon, mesaĝojn aŭ sciigojn; \n  Legi kaj redakti permesojn, kiujn vi donis al aliaj aplikaĵoj; sekvi aŭ bloki \n  aliajn uzantojn; rigardi listojn de uzantoj, kiujn vi sekvas aŭ blokas.\noauth2.grant.user.profile.edit: Redakti vian profilon.\noauth2.grant.user.message.all: Legi viajn mesaĝojn kaj sendi mesaĝojn al aliaj \n  uzantoj.\noauth2.grant.user.notification.delete: Malplenigi viajn sciigojn.\noauth2.grant.user.profile.read: Legi vian profilon.\noauth2.grant.moderate.entry_comment.set_adult: Marki komentojn en fadenoj kiel \n  NSPL en la revuoj, kiujn vi moderigas.\noauth2.grant.moderate.post.change_language: Ŝanĝi la lingvon de afiŝoj en la \n  revuoj, kiujn vi moderigas.\noauth2.grant.moderate.post.set_adult: Marki afiŝojn kiel NSPL en la revuoj, \n  kiujn vi moderigas.\noauth2.grant.moderate.magazine.ban.read: Rigardi forbaritajn uzantojn en la \n  revuoj, kiujn vi moderigas.\noauth2.grant.admin.entry_comment.purge: Tute forigi ajnan komenton en fadeno de \n  via nodo.\noauth2.grant.admin.post.purge: Tute forigu ajnan afiŝon de via nodo.\noauth2.grant.admin.user.all: Forbari, konfirmi aŭ tute forigi uzantojn sur via \n  nodo.\noauth2.grant.admin.user.purge: Tute forigi uzantojn de via nodo.\noauth2.grant.admin.federation.all: Rigardi kaj ĝisdatigi nuntempe defederitajn \n  nodojn.\nshow_avatars_on_comments_help: Montri/kaŝi uzantajn avatarojn kiam rigardado \n  komentojn pri ununura fadeno aŭ afiŝo.\nmagazine_theme_appearance_custom_css: Adaptita CSS-o kiu aplikiĝos dum rigardado\n  de enhavo ene de via revuo.\nmagazine_theme_appearance_icon: Adaptita ikono por la revuo.\nmagazine_theme_appearance_background_image: Adaptita fonbildo kiu estos aplikata\n  dum rigardado de enhavo ene de via revuo.\noauth2.grant.moderate.post.pin: Alpingli afiŝojn al la supro de la revuoj, kiujn\n  vi moderigas.\ndelete_content: Forigi enhavon\n2fa.setup_error: Eraro ebligante 2PA por konto\n2fa.backup-create.label: Krei novajn rezervajn aŭtentigajn kodojn\n2fa.add: Aldoni al mia konto\nsubscription_header: Abonitaj Revuoj\n2fa.backup-create.help: Vi povas krei novajn rezervajn aŭtentigajn kodojn; fari \n  tion nevalidigos ekzistantajn kodojn.\nremove_user_avatar: Forigi avataron\nremove_user_cover: Forigi kovrilon\nnotify_on_user_signup: Novaj aliĝoj\ntype_search_term_url_handle: Tajpi serĉvorton, URL-on aŭ uzantnomon\nviewing_one_signup_request: Vi nur rigardas unu aliĝpeton fare de %username%\nyour_account_is_not_yet_approved: Via konto ankoraŭ ne estas aprobita. Ni sendos\n  al vi retmesaĝon tuj kiam la administrantoj prilaboros vian aliĝpeton.\ntoolbar.emoji: Emoĝio\noauth2.grant.user.bookmark: Aldoni kaj forigi legosignojn\noauth2.grant.user.bookmark.add: Aldoni legosignojn\noauth2.grant.user.bookmark.remove: Forigi legosignojn\noauth2.grant.user.bookmark_list: Legi, redakti kaj forigi viajn \n  legosigno-listojn\noauth2.grant.user.bookmark_list.read: Legi viajn legosigno-listojn\noauth2.grant.user.bookmark_list.edit: Redakti viajn legosigno-listojn\noauth2.grant.user.bookmark_list.delete: Forigi viajn legosigno-listojn\n2fa.manual_code_hint: Se vi ne povas skani la QR-kodon, enigu la sekreton \n  permane\nflash_email_failed_to_sent: Retmesaĝo ne eblis sendi.\nflash_post_new_success: Afiŝo sukcese kreita.\nflash_post_new_error: Afiŝo ne povis esti kreita. Io misfunkciis.\nflash_magazine_theme_changed_success: Sukcese ĝisdatigis la aspekton de la \n  revuo.\nflash_magazine_theme_changed_error: Malsukcesis ĝisdatigi la aspekton de la \n  revuo.\nflash_comment_new_success: Komento estis sukcese kreita.\nflash_comment_edit_success: Komento estis sukcese ĝisdatigita.\nflash_comment_new_error: Malsukcesis krei komenton. Io misfunkciis.\nflash_comment_edit_error: Malsukcesis redakti komenton. Io misfunkciis.\nflash_user_settings_general_success: Uzantagordoj sukcese konservitis.\nflash_user_settings_general_error: Malsukcesis konservi uzantagordojn.\nflash_user_edit_profile_error: Malsukcesis konservi profilagordojn.\nflash_user_edit_profile_success: Uzantprofilagordoj sukcese konservitis.\nflash_user_edit_email_error: Malsukcesis ŝanĝi retpoŝtadreson.\nflash_user_edit_password_error: Malsukcesis ŝanĝi pasvorton.\nflash_thread_edit_error: Malsukcesis redakti la fadenon. Io misfunkciis.\nflash_post_edit_error: Malsukcesis redakti la afiŝon. Io misfunkciis.\nflash_post_edit_success: La afiŝo estis sukcese redaktita.\npage_width: Paĝolarĝo\npage_width_max: Max\npage_width_auto: Aŭtomata\npage_width_fixed: Fiksita\nfilter_labels: Filtri Etikedojn\nauto: Aŭtomata\nopen_url_to_fediverse: Malfermi originalan URL-on\nchange_my_avatar: Ŝanĝi mian avataron\nchange_my_cover: Ŝanĝi mian kovrilon\nedit_my_profile: Redakti mian profilon\naccount_settings_changed: Viaj konto-agordoj estis sukcese ŝanĝitaj. Vi devos \n  ensaluti denove.\nmagazine_deletion: Forigo de revuo\ndelete_magazine: Forigi revuon\nrestore_magazine: Restaŭri revuon\npurge_magazine: Viŝi revuon\nmagazine_is_deleted: La revuo estas forigita. Vi povas <a \n  href=\"%link_target%\">restaŭri</a> ĝin ene de 30 tagoj.\nsuspend_account: Halteti konton\nunsuspend_account: Malhalteti konton\naccount_suspended: La konto estas haltetita.\naccount_unsuspended: La konto estas malhaltetita.\ndeletion: Forigo\nuser_suspend_desc: Halteti vian konton kaŝas vian enhavon en la nodo, sed ne \n  forigas ĝin porĉiame, kaj vi povas restarigi ĝin iam ajn.\naccount_banned: La konto estas forbarita.\naccount_unbanned: La konto estas malforbarita.\naccount_is_suspended: Uzantokonto haltetas.\nremove_following: Forigi sekvadon\nremove_subscriptions: Forigi abonojn\napply_for_moderator: Kandidatiĝi por moderiganto\nrequest_magazine_ownership: Peti proprieton de revuo\ncancel_request: Nuligi peton\nabandoned: Forlasita\nownership_requests: Petoj pri proprieto\naccept: Akcepti\nmoderator_requests: Petoj de moderiganto\naction: Ago\nuser_badge_op: OA\nuser_badge_admin: Admin\nuser_badge_global_moderator: Ĝenerala Moderiganto\nuser_badge_moderator: Moderiganto\nuser_badge_bot: Roboto\nannouncement: Anonco\nkeywords: Ŝlosilvortoj\ndeleted_by_moderator: Fadeno, afiŝo aŭ komento forigitis de la moderiganto\ndeleted_by_author: Fadeno, afiŝo aŭ komento forigitis de la aŭtoro\nsensitive_warning: Sentema enhavo\nsensitive_toggle: Baskuligi videblecon de sentema enhavo\nsensitive_show: Alklaki por montri\nsensitive_hide: Alklaki por kaŝi\ndetails: Detaloj\nspoiler: Malkaŝo de intrigo\nall_time: Ĉiuj tempoj\nshow: Montri\nhide: Kaŝi\nedited: redaktita\nsso_registrations_enabled: Unuensalutaj registradoj ebligitaj\nsso_registrations_enabled.error: Novaj konto-registradoj ĉe triapartaj \n  identec-administriloj estas nuntempe malebligitaj.\nsso_only_mode: Limigi ensaluton kaj registriĝon nur al unuensaluta-metodoj\nrelated_entry: Rilata\nrestrict_magazine_creation: Limigi lokan revuokreadon al administrantoj kaj \n  ĝeneralaj moderigantoj\nsso_show_first: Montri unuensaluto unue sur ensalutaj kaj registriĝaj paĝoj\ncontinue_with: Daŭrigu kun\nreported_user: Raportita uzanto\nreporting_user: Raportanta uzanto\nreported: raportita\nreport_subject: Temo\nown_report_rejected: Via raporto malakceptitis\nown_report_accepted: Via raporto akceptitis\nown_content_reported_accepted: Raporto pri via enhavo akceptitis.\nreport_accepted: Raporto akceptitis\nopen_report: Malfermi raporton\ncake_day: Kukotago\nsomeone: Iu\nback: Reen\nmagazine_log_mod_added: aldonis moderiganton\nmagazine_log_mod_removed: forigis moderiganton\nmagazine_log_entry_pinned: alpinglita eniro\nmagazine_log_entry_unpinned: forigita alpinglita eniro\nlast_updated: Laste ĝisdatigita\nand: kaj\ndirect_message: Rektmesaĝo\nmanually_approves_followers: Mane aprobas sekvantojn\nregister_push_notifications_button: Registriĝi por Puŝaj Sciigoj\nunregister_push_notifications_button: Forigi Puŝan Registriĝon\ntest_push_notifications_button: Testi Puŝajn Sciigojn\ntest_push_message: Saluton Mondo!\nnotification_title_new_comment: Nova komento\nnotification_title_removed_comment: Komento forigitis\nnotification_title_edited_comment: Komento redaktitis\nnotification_title_mention: Vi menciitis\nnotification_title_new_reply: Nova Respondo\nnotification_title_new_thread: Nova fadeno\nnotification_title_removed_thread: Fadeno forigitis\nnotification_title_edited_thread: Fadeno redaktitis\nnotification_title_ban: Vi estis forbarita\nnotification_title_message: Nova rektmesaĝo\nnotification_title_new_post: Nova Afiŝo\nnotification_title_removed_post: Afiŝo estis forigita\nnotification_title_edited_post: Afiŝo estis redaktita\nnotification_title_new_signup: Nova uzanto registriĝis\nnotification_body_new_signup: La uzanto %u% registriĝis.\nnotification_body2_new_signup_approval: Vi devas aprobi la peton antaŭ ol ili \n  povas ensaluti\nshow_related_magazines: Montri hazardajn revuojn\nshow_related_entries: Montri hazardajn fadenojn\nshow_related_posts: Montri hazardajn afiŝojn\nshow_active_users: Montri aktivajn uzantojn\nnotification_title_new_report: Nova raporto estis kreita\nmagazine_posting_restricted_to_mods_warning: Nur moderigantoj rajtas krei \n  fadenojn en ĉi tiu revuo\nflash_posting_restricted_error: Krei fadenojn estas limigita al moderigantoj en \n  ĉi tiu revuo kaj vi ne estas unu el ili\nserver_software: Servila programaro\nversion: Versio\nlast_successful_deliver: Lasta sukcesa liverado\nlast_successful_receive: Lasta sukcesa ricevo\nlast_failed_contact: Lasta malsukcesa kontakto\nmagazine_posting_restricted_to_mods: Limigi fadenkreadon al moderigantoj\nnew_user_description: Ĉi tiu uzanto estas nova (aktiva dum malpli ol %days% \n  tagoj)\nnew_magazine_description: Ĉi tiu revuo estas nova (aktiva dum malpli ol %days% \n  tagoj)\nadmin_users_active: Aktiva\nadmin_users_inactive: Neaktiva\nadmin_users_suspended: Haltetigita\nadmin_users_banned: Forbarita\nuser_verify: Aktivigi konton\nmax_image_size: Maksimuma dosiergrandeco\ncomment_not_found: Komento ne trovita\nbookmark_add_to_list: Aldoni legosignon al %list%\nbookmark_remove_from_list: Forigi legosignon el %list%\nbookmark_remove_all: Forigi ĉiujn legosignojn\nbookmark_add_to_default_list: Aldoni legosignon al defaŭlta listo\nbookmark_lists: Legosigno-Listoj\nbookmarks: Legosignoj\nbookmarks_list: Legosignoj en %list%\ncount: Nombro\nis_default: Estas Defaŭlta\nbookmark_list_is_default: Estas defaŭlta listo\nbookmark_list_make_default: Fari Defaŭlta\nbookmark_list_create: Krei\nbookmark_list_create_placeholder: tajpi nomon...\nbookmark_list_create_label: Listonomo\nbookmarks_list_edit: Redakti legosignoliston\nbookmark_list_edit: Redakti\nbookmark_list_selected_list: Elektita listo\ntable_of_contents: Enhavotabelo\nsearch_type_all: Ĉio\nsearch_type_entry: Fadenoj\nsearch_type_post: Mikroblogoj\nsearch_type_magazine: Revuoj\nsearch_type_user: Uzantoj\nsearch_type_actors: Revuoj + Uzantoj\nsearch_type_content: Fadenoj + Mikroblogoj\nselect_user: Elekti uzanton\nnew_users_need_approval: Novaj uzantoj devas esti aprobitaj de administranto \n  antaŭ ol ili povas ensaluti.\nsignup_requests: Aliĝpetoj\napplication_text: Klarigu kial vi volas aliĝi\nsignup_requests_header: Aliĝpetoj\nsignup_requests_paragraph: Ĉi tiuj uzantoj ŝatus aliĝi al via servilo. Ili ne \n  povas ensaluti ĝis vi aprobis ilian aliĝpeton.\nflash_application_info: Administranto bezonas aprobi vian konton antaŭ ol vi \n  povas ensaluti. Vi ricevos retmesaĝon post kiam via aliĝpeto estos \n  prilaborita.\nemail_application_approved_title: Via aliĝpeto estas aprobita\nemail_application_approved_body: Via aliĝpeto estis aprobita de la servila \n  administranto. Vi nun povas ensaluti en la servilon ĉe <a \n  href=\"%link%\">%siteName%</a>.\nemail_application_rejected_title: Via aliĝpeto estis malakceptita\nemail_application_rejected_body: Dankon pro via intereso, sed ni bedaŭras \n  informi vin, ke via aliĝpeto estis rifuzita.\nemail_application_pending: Via konto bezonas administran aprobon antaŭ ol vi \n  povas ensaluti.\nemail_verification_pending: Vi devas konfirmi vian retpoŝtadreson antaŭ ol vi \n  povas ensaluti.\nshow_magazine_domains: Montri revuajn domajnojn\nshow_user_domains: Montri uzantajn domajnojn\nanswered: respondita\nby: de\nfront_default_sort: Defaŭlta ordigo de la ĉefpaĝo\ncomment_default_sort: Defaŭlta ordigo de komentoj\nopen_signup_request: Malfermi aliĝpeton\nimage_lightbox_in_list: Fadenaj bildetoj malfermiĝas plenekrane\ncompact_view_help: Kompakta vido kun malpli da marĝenoj, kie la aŭdvidaĵo estas \n  movita dekstren.\nshow_users_avatars_help: Montri la bildon de la uzanto-avataro.\nshow_magazines_icons_help: Montri la revuo-bildsimbolo.\nshow_thumbnails_help: Montri la bildetojn.\nimage_lightbox_in_list_help: Kiam markita, alklako de la bildeto montras modalan\n  bildkestofenestron. Kiam nemarkita, alklako de la bildeto malfermos la \n  fadenon.\nshow_new_icons: Montri novajn bildsimbolojn\nshow_new_icons_help: Montri bildsimbolon por nova revuo/uzanto (30 tagojn aĝa aŭ\n  pli nova)\nmagazine_instance_defederated_info: La instanco de ĉi tiu revuo estas \n  defederita. La revuo tial ne ricevos ĝisdatigojn.\nuser_instance_defederated_info: La instanco de ĉi tiu uzanto estas defederita.\nflash_thread_instance_banned: La instanco de ĉi tiu revuo estas forbarita.\nshow_rich_mention: Riĉaj mencioj\nshow_rich_mention_help: Bildigi uzantan komponenton kiam uzanto estas menciita. \n  Tio inkluzivos ties montritan nomon kaj profilbildon.\nshow_rich_mention_magazine: Riĉaj revuaj mencioj\nshow_rich_mention_magazine_help: Bildigi revuan komponenton kiam revuo estas \n  menciita. Tio inkluzivos ĝian montritan nomon kaj bildsimbolon.\nshow_rich_ap_link: Riĉaj AP-ligiloj\nshow_rich_ap_link_help: Bildigi enlinian komponenton kiam alia \n  ActivityPub-enhavo estas ligita al.\nattitude: Sinteno\ntype_search_magazine: Limigi serĉon al revuo...\ntype_search_user: Limigi serĉon al aŭtoro...\nmodlog_type_entry_deleted: Fadeno forigita\nmodlog_type_entry_restored: Fadeno restarigita\nmodlog_type_entry_comment_deleted: Komento pri fadeno forigita\nmodlog_type_entry_comment_restored: Fadenkomento restarigita\nmodlog_type_entry_pinned: Fadeno alpinglita\nmodlog_type_entry_unpinned: Fadeno malfiksita\nmodlog_type_post_deleted: Mikroblogo forigita\nmodlog_type_post_restored: Mikroblogo restarigita\nmodlog_type_post_comment_deleted: Respondo al mikroblogo forigita\nmodlog_type_post_comment_restored: Respondo al mikroblogo restarigita\nmodlog_type_ban: Uzanto malpermesita de revuo\nmodlog_type_moderator_add: Revumoderigisto aldonita\nmodlog_type_moderator_remove: Revuomoderigisto forigita\ncrosspost: Disafiŝi\nban_expires: Forbaro eksvalidiĝas\nbanner: Standardo\nmagazine_theme_appearance_banner: Propra standardo por la revuo. Ĝi estas \n  montrata super ĉiuj fadenoj kaj devus esti en larĝa bildformato (5:1, aŭ \n  1500px * 300px).\nflash_thread_ref_image_not_found: La bildo referencia de 'imageHash' ne \n  trovitis.\neveryone: Ĉiuj\nnobody: Neniu\nfollowers_only: Nur sekvantoj\ndirect_message_setting_label: Kiu povas sendi al vi rektmesaĝon\ndelete_magazine_icon: Forigi revuikonon\nflash_magazine_theme_icon_detached_success: Revuikono sukcese forigitis\ndelete_magazine_banner: Forigi revustandardo\nflash_magazine_theme_banner_detached_success: Revustandardo sukcese forigitis\nfederation_uses_allowlist: Uzi permesliston por federacio\ndefederating_instance: Malfederanta nodo %i\ntheir_user_follows: Kvanto da uzantoj de ilia nodo sekvantaj uzantojn sur nia \n  nodo\nour_user_follows: Kvanto da uzantoj de nia nodo sekvantaj uzantojn sur ilia nodo\ntheir_magazine_subscriptions: Kvanto da uzantoj de ilia nodo abonis revuojn en \n  nia nodo\nour_magazine_subscriptions: Kvanto da uzantoj de nia nodo abonis revuojn en ilia\n  nodo\nconfirm_defederation: Konfirmi malfederacion\nban_instance: Forbari nodon\nallow_instance: Malforbari nodon\n"
  },
  {
    "path": "translations/messages.es.yaml",
    "content": "type.link: Enlace\ntype.article: Hilo\ntype.photo: Foto\ntype.video: Vídeo\ntype.smart_contract: Contrato inteligente\ntype.magazine: Revista\nthread: Hilo\nthreads: Hilos\nmicroblog: Microblog\npeople: Gente\nevents: Eventos\nmagazine: Revista\nmagazines: Revistas\nsearch: Buscar\nadd: Añadir\nselect_channel: Elige un canal\nlogin: Iniciar sesión\ntop: Destacado\nhot: Popular\nactive: Activo\nnewest: Más reciente\noldest: Más antiguo\ncommented: Comentado\nchange_view: Cambiar vista\nfilter_by_time: Ordenar por orden cronológico\nfilter_by_type: Ordenar por tipo\ncomments_count: '{0}Comentarios|{1}Comentario|]1,Inf[ Comentarios'\nfavourites: Votos a favor\nfavourite: Favorito\nmore: Más\navatar: Avatar\nadded: Añadido\ngeneral: General\ncreated_at: Creado\nowner: Propietario/a\nsubscribers: Suscriptores/as\ndown_votes: Votos en contra\nno_comments: No hay comentarios\nonline: En línea\ncomments: Comentarios\nposts: Publicaciones\nreplies: Respuestas\nmoderators: Moderadores/as\nmod_log: Registro de moderación\nadd_comment: Añadir comentario\nadd_post: Añadir publicación\nadd_media: Añadir medio\nmarkdown_howto: ¿Cómo se utiliza el editor?\nenter_your_comment: Escribe tu comentario\nenter_your_post: Introduce tu publicación\nactivity: Actividad\ncover: Portada\nrelated_posts: Publicaciones relacionadas\nrandom_posts: Publicaciones al azar\nfederated_magazine_info: Esta revista es de un servidor federado y podría estar \n  incompleta.\nfederated_user_info: Este perfil es de un servidor federado y podría estar \n  incompleto.\ngo_to_original_instance: Ver en instancia remota\nempty: Vacío\nsubscribe: Suscribirse\nunsubscribe: Cancelar suscripción\nfollow: Seguir\nunfollow: Dejar de seguir\nreply: Responder\nlogin_or_email: Identificador o correo electrónico\npassword: Contraseña\nremember_me: Recordarme\ndont_have_account: ¿No tienes una cuenta?\nyou_cant_login: ¿Has olvidado tu contraseña?\nregister: Registrarse\nreset_password: Restablecer la contraseña\nshow_more: Mostrar más\nto: a\nin: en\nusername: Nombre de usuarie\nemail: Correo electrónico\nrepeat_password: Repetir la contraseña\nterms: Condiciones de uso\nprivacy_policy: Política de privacidad\nabout_instance: Acerca de\nall_magazines: Todas las revistas\nstats: Estadísticas\nfediverse: Fediverso\ncreate_new_magazine: Crear una nueva revista\nadd_new_article: Agregar un nuevo hilo\nadd_new_link: Añadir un nuevo enlace\nadd_new_photo: Añadir una nueva foto\nadd_new_post: Añadir una nueva publicación\nadd_new_video: Añadir un nuevo vídeo\ncontact: Contacto\nfaq: Preguntas frecuentes\nrss: RSS\nchange_theme: Cambiar estilo\nuseful: Útil\nhelp: Ayuda\ncheck_email: Verifica tu correo electrónico\nreset_check_email_desc: Si ya existe una cuenta asociada con tu correo \n  electrónico, recibirás un mensaje a la brevedad que contiene un enlace que \n  puedes usar para restablecer tu contraseña. Este enlace expirará en %expire%.\nreset_check_email_desc2: Si no has recibido un correo electrónico, por favor \n  verifica tu carpeta de spam.\ntry_again: Intentar de nuevo\nemail_confirm_header: ¡Hola! Confirma tu dirección de correo electrónico.\nemail_confirm_content: '¿Listo para activar tu cuenta de Mbin? Haz clic en el siguiente\n  enlace:'\nemail_verify: Confirma la dirección de correo electrónico\nemail_confirm_expire: Ten en cuenta que el enlace caducará en una hora.\nselect_magazine: Selecciona una revista\nadd_new: Añadir nuevo\nurl: URL\ntitle: Título\nalready_have_account: ¿Ya tienes una cuenta?\nagree_terms: Consentimiento a los %terms_link_start%Términos y \n  Condiciones%terms_link_end% y a la %policy_link_start%Política de \n  Privacidad%policy_link_end%\nemail_confirm_title: Confirma tu dirección de correo electrónico.\n1w: 1 sem.\ndown_vote: Votar en contra\nbody: Cuerpo\ntags: Etiquetas\nbadges: Distintivos\nis_adult: 18+ / Explícito\neng: ENG\noc: Cont. Orig.\nimage: Imagen\nimage_alt: Texto alternativo para la imagen\nname: Nombre\ndescription: Descripción\nrules: Normas\ndomain: Dominio\nfollowers: Seguidores\nfollowing: Siguiendo\nsubscriptions: Suscripciones\noverview: Vista general\ncards: Tarjetas\ncolumns: Columnas\nuser: Usuarie\njoined: Inscrito/a\nmoderated: Moderado/a\npeople_local: Local\npeople_federated: Federado\nreputation_points: Puntos de reputación\nrelated_tags: Etiquetas relacionadas\ngo_to_content: Ir al contenido\ngo_to_filters: Ir a los filtros\ngo_to_search: Ir a la búsqueda\nsubscribed: Suscrito\nall: Todo\nlogout: Cerrar sesión\nclassic_view: Vista clásica\ncompact_view: Vista compacta\nchat_view: Vista chat\ntree_view: Vista de árbol\ntable_view: Vista en tabla\ncards_view: Vista en cartas\n3h: 3h\n6h: 6h\n12h: 12h\n1d: 1 día\n1y: 1 año\n1m: 1 mes\nlinks: Enlaces\narticles: Hilos\nphotos: Fotos\nvideos: Vídeos\nreport: Reportar\nshare: Compartir\ncopy_url: Copiar URL de Mbin\ncopy_url_to_fediverse: Copiar URL original\nshare_on_fediverse: Compartir en el Fediverso\nedit: Editar\nare_you_sure: ¿Estás seguro/a?\nmoderate: Moderar\nreason: Motivo\ndelete: Borrar\nedit_post: Editar publicación\nshow_users_avatars: Mostrar el avatar de usuarie\nyes: Sí\nno: No\nshow_magazines_icons: Mostrar iconos de las revistas\nshow_thumbnails: Mostrar las miniaturas\nrounded_edges: Bordes redondeados\nrestored_comment_by: ha restaurado el comentario de\nremoved_thread_by: ha eliminado un hilo de\nrestored_thread_by: ha restaurado el hilo de\nremoved_comment_by: ha borrado el comentario de\nremoved_post_by: ha borrado la publicación de\nrestored_post_by: ha restaurado la publicación de\nhe_banned: baneado/a\nhe_unbanned: desbaneado/a\nread_all: Leer todo\nshow_all: Mostrar todo\nflash_register_success: ¡Bienvenida/o a bordo! Tu cuenta ya está registrada. Un \n  último paso - consulta tu bandeja de entrada para recibir un enlace de \n  activación que dará vida a tu cuenta.\nflash_thread_new_success: El hilo ha sido creado con éxito y ahora es visible \n  para otras/os usuarias/os.\nflash_thread_edit_success: El hilo ha sido editado con éxito.\nflash_thread_delete_success: El hilo ha sido borrado con éxito.\nflash_thread_pin_success: El hilo ha sido anclado con éxito.\nflash_thread_unpin_success: El hilo ha sido desanclado con éxito.\nflash_magazine_new_success: La revista ha sido creado con éxito. Ahora puedes \n  añadir nuevo contenido o explorar el panel de administración.\nflash_magazine_edit_success: La revista ha sido editado con éxito.\ntoo_many_requests: Límite excedido, por favor, inténtalo de nuevo más tarde.\nset_magazines_bar: Barra de revistas\nset_magazines_bar_desc: añade el nombre de la revista tras la coma\nset_magazines_bar_empty_desc: si el campo está vacío, las revistas activas se \n  mostrarán en la barra.\nup_votes: Impulsos\nedit_comment: Guardar cambios\nsettings: Configuración\nprofile: Perfil\nblocked: Bloqueados\nreports: Reportes\nnotifications: Notificaciones\nmessages: Mensajes\nappearance: Apariencia\nhomepage: Página de inicio\nhide_adult: Ocultar contenido explícito\nfeatured_magazines: Revistas destacadas\nprivacy: Privacidad\nshow_profile_subscriptions: Mostrar suscripciones a revistas\nshow_profile_followings: Mostrar usuaries seguides\nnotify_on_new_entry_reply: Cualquier nivel de comentarios en los hilos que he \n  creado\nnotify_on_new_entry_comment_reply: Respuestas a mis comentarios en cualquier \n  hilo\nnotify_on_new_post_reply: Cualquier nivel de respuestas a las publicaciones que \n  he creado\nnotify_on_new_post_comment_reply: Respuestas a mis comentarios en cualquier \n  publicación\nnotify_on_new_entry: Nuevos hilos (enlaces o artículos) en cualquier revista a \n  la que me haya suscrito\nnotify_on_new_posts: Nuevas publicaciones en cualquier revista a la que me haya \n  suscrito\nsave: Guardar\nabout: Acerca de\nold_email: Correo electrónico actual\nnew_email: Nuevo correo electrónico\nnew_email_repeat: Confirmar nuevo correo electrónico\ncurrent_password: Contraseña actual\nnew_password: Nueva contraseña\nnew_password_repeat: Confirmar la nueva contraseña\nchange_email: Cambiar correo electrónico\nchange_password: Cambiar contraseña\nexpand: Expandir\ncollapse: Plegar\ndomains: Dominios\nerror: Error\nvotes: Votos\ntheme: Aspecto\ndark: Oscuro\nlight: Claro\nsolarized_light: Claro Solarizado\nsolarized_dark: Oscuro Solarizado\nfont_size: Tamaño de la fuente\nsize: Tamaño\nup_vote: Impulsar\nmod_log_alert: En el registro de moderación podrás encontrar contenido \n  desagradable u ofensivo eliminado por los/as moderadores/as. Asegúrate de \n  saber lo que estás haciendo.\nadded_new_thread: Agregado un nuevo hilo\nedited_thread: Editado un hilo\nmod_remove_your_thread: Un/a moderador/a ha borrado tu hilo\nadded_new_comment: Agregado nuevo comentario\nedited_comment: Editado un comentario\nreplied_to_your_comment: Ha respondido a tu comentario\nmod_deleted_your_comment: Un/a moderador/a ha borrado tu comentario\nadded_new_post: Agregada una nueva publicación\nedited_post: Editada una publicación\nmod_remove_your_post: Un/a moderador/a ha borrado tu publicación\nadded_new_reply: Agregada una nueva respuesta\nwrote_message: Ha escrito un mensaje\nbanned: Te ha baneado\nremoved: Borrado por un/a moderador/a\ndeleted: Borrado por el/la autor/a\nmentioned_you: Te ha mencionado\ncomment: Comentario\npost: Publicación\nban_expired: Baneo expirado\npurge: Purgar\nsend_message: Enviar un mensaje directo\nmessage: Mensaje\ninfinite_scroll: Desplazamiento infinito\nshow_top_bar: Mostrar la barra superior\nsticky_navbar: Barra de navegación fija\nsubject_reported: El contenido ha sido reportado.\nsidebar_position: Posición de la barra lateral\nleft: Izquierda\nright: Derecha\nfederation: Federación\nstatus: Estado\non: Encendido\noff: Apagado\ninstances: Instancias\nupload_file: Cargar archivo\nfrom_url: De una URL\nmagazine_panel: Panel de la revista\nreject: Rechazar\napprove: Aprobar\nban: Banear\nfilters: Filtros\napproved: Aprobado\nrejected: Rechazado\nadd_moderator: Añadir un/a moderador/a\nadd_badge: Añadir un distintivo\nbans: Baneos\ncreated: Creado\nexpires: Expira\nperm: Permanente\nexpired_at: Expiró el\nadd_ban: Añadir baneo\ntrash: Papelera\nicon: Icono\ndone: Hecho\nunpin: Desanclar\npin: Anclar\nchange_magazine: Cambiar de revista\nchange_language: Cambiar idioma\nchange: Cambio\npinned: Anclado\npreview: Vista previa\narticle: Hilo\nreputation: Reputación\nnote: Nota\nwriting: Escritura\nusers: Usuarias/os\ncontent: Contenido\nweek: Semana\nweeks: Semanas\nmonth: Mes\nmonths: Meses\nyear: Año\nfederated: Federado\nlocal: Local\nadmin_panel: Panel de administración\ndashboard: Panel de control\ncontact_email: e-mail de contacto\nmeta: Meta\ninstance: Instancia\nregistrations_enabled: Registro activado\npages: Páginas\nFAQ: FAQ\ntype_search_term: Escribir término de búsqueda\nfederation_enabled: Federación activada\nregistration_disabled: Inscripciones desactivadas\nrestore: Restaurar\nadd_mentions_entries: Añadir etiquetas de mención en los hilos\nadd_mentions_posts: Añadir etiquetas de mención en las publicaciones\nPassword is invalid: La contraseña no es válida.\nYour account is not active: Tu cuenta no es activa.\nYour account has been banned: Tu cuenta ha sido baneada.\nfirstname: Primer nombre\nsend: Enviar\nactive_users: Personas activas\nrandom_entries: Hilos al azar\nrelated_entries: Hilos relacionados\ndelete_account: Eliminar la cuenta\npurge_account: Purgar la cuenta\nban_account: Banear la cuenta\nunban_account: Desbanear la cuenta\nrelated_magazines: Revistas relacionadas\nrandom_magazines: Revistas al azar\nmagazine_panel_tags_info: Indícalo sólo si deseas que el contenido del fediverso\n  se incluya en esta revista en función de las etiquetas\nsidebar: Barra lateral\nauto_preview: Vista previa de medios\ndynamic_lists: Listas dinámicas\nbanned_instances: Instancias bloqueadas\nkbin_intro_title: Explorar el Fediverso\nkbin_intro_desc: es una plataforma descentralizada de agregación de contenidos y\n  microblogging que opera dentro de la red Fediverso.\nkbin_promo_title: Crea tu propia instancia\ncaptcha_enabled: Captcha activado\nheader_logo: Logo del encabezado\nbrowsing_one_thread: Estás viendo solo un hilo de la discusión! En la página de \n  la publicación puedes encontrar todos los comentarios.\nreturn: Volver\nkbin_promo_desc: '%link_start%Clona el repositorio%link_end% y desarrolla el Fediverso'\nboosts: Impulsos\nboost: Impulsar\nmercure_enabled: Mercure habilitado\nreport_issue: Informar de un problema\ntokyo_night: Noche de Tokio\npreferred_languages: Filtrar idiomas de hilos y publicaciones\ntoolbar.bold: Negrita\ntoolbar.italic: Cursiva\ntoolbar.strikethrough: Tachado\ntoolbar.header: Encabezado\ntoolbar.quote: Cita\ntoolbar.code: Código\ntoolbar.link: Enlace\ntoolbar.image: Imagen\ntoolbar.unordered_list: Lista sin ordenar\ntoolbar.ordered_list: Lista ordenada\ntoolbar.mention: Mención\nreload_to_apply: Recargar la página para aplicar los cambios\nlocal_and_federated: Locales y federados\nbot_body_content: \"¡Bienvenido al Agente Mbin! Este agente juega un papel crucial\n  al habilitar la funcionalidad ActivityPub dentro de Mbin. Garantiza que Mbin pueda\n  comunicarse y federarse con otras instancias del fediverso.\\n\\nActivityPub es un\n  protocolo estándar abierto que permite que las plataformas de redes sociales descentralizadas\n  se comuniquen e interactúen entre sí. Permite a les usuaries en diferentes instancias\n  (servidores) seguir, interactuar y compartir contenido a través de la red social\n  federada conocida como fediverse. Proporciona una forma estandarizada para que los\n  usuarios publiquen contenido, sigan a otres usuaries y participen en interacciones\n  sociales como dar me gusta, compartir y comentar en hilos o publicaciones.\"\nyour_account_is_not_active: Su cuenta no ha sido activada. Consulte su correo \n  electrónico para obtener instrucciones sobre la activación de la cuenta o <a \n  href=\"%link_target%\">solicite un nuevo correo electrónico de activación de la \n  cuenta.</a>\nmore_from_domain: Más del dominio\nyour_account_has_been_banned: Tu cuenta ha sido baneada\ninfinite_scroll_help: Cargar automáticamente más contenido cuando llegue al \n  final de la página.\nsticky_navbar_help: La barra de navegación se pegará a la parte superior de la \n  página cuando se desplace hacia abajo.\nauto_preview_help: Muestra la previsualización (foto, video) en tamaño grande \n  bajo el contenido.\nfilter.origin.label: Elegir origen\nfilter.fields.label: Elija los campos que desea buscar\nfilter.adult.label: Elija si desea mostrar NSFW\nfilter.adult.hide: Ocultar NSFW\nfilter.adult.show: Mostrar NSFW\nfilter.adult.only: Sólo NSFW\nfilter.fields.only_names: Sólo nombres\nfilter.fields.names_and_descriptions: Nombres y descripciones\nkbin_bot: Agente Mbin\npassword_confirm_header: Confirme su solicitud de cambio de contraseña.\nfederation_page_enabled: Página de la Federación activada\nfederation_page_allowed_description: Instancias conocidas con las que nos \n  federamos\nfederation_page_disallowed_description: Instancias con las que no nos federamos\nfederated_search_only_loggedin: Búsqueda federada limitada si no se ha iniciado \n  sesión\nemail_confirm_button_text: Confirma tu solicitud de cambio de la contraseña\nerrors.server429.title: 429 Demasiadas solicitudes\nerrors.server404.title: 404 No encontrado\nerrors.server403.title: 403 Prohibido\nerrors.server500.title: 500 Error interno de servidor\nerrors.server500.description: Algo salió mal de nuestra parte. Si continúas \n  viendo este error, intenta comunicarse con el/a propietario/a de la instancia.\n  Si esta instancia no funciona en absoluto, ve a %link_start%otras instancias \n  de Mbin%link_end% mientras tanto hasta que se resuelva el problema.\nemail_confirm_link_help: También puedes copiar y pegar lo siguiente en el \n  navegador\nresend_account_activation_email_error: Se ha producido un problema al enviar \n  esta solicitud. Es posible que no haya ninguna cuenta asociada a ese correo \n  electrónico o que ya esté activada.\nemail.delete.description: Este usuarie ha solicitado que se elimine su cuenta\ncustom_css: CSS personalizado\nresend_account_activation_email_success: Si existe una cuenta asociada a ese \n  correo electrónico, enviaremos un nuevo mensaje de activación.\nignore_magazines_custom_css: Ignorar CSS personalizado de las revistas\noauth.consent.title: Formulario de consentimiento OAuth2\nresend_account_activation_email: Volver a enviar el correo para la activación de\n  la cuenta\nresend_account_activation_email_question: ¿Cuenta inactiva?\nresend_account_activation_email_description: Introduce la dirección de correo \n  electrónico asociada a tu cuenta. Te enviaremos otro mensaje de activación.\noauth.consent.grant_permissions: Conceder los permisos\nemail.delete.title: Solicitud de eliminación de la cuenta\noauth2.grant.moderate.magazine.reports.all: Gestionar los informes en las \n  revistas bajo tu moderación.\noauth.consent.to_allow_access: Para permitir este acceso, haga clic en el botón \n  \"Permitir\"\noauth.consent.app_requesting_permissions: querría realizar las siguientes \n  acciones en tu nombre\noauth2.grant.moderate.magazine.reports.action: Aceptar o rechazar informes en \n  las revistas bajo tu moderación.\noauth.consent.allow: Permitir\nblock: Bloquear\noauth2.grant.moderate.magazine.list: Mostrar una lista de las revistas bajo tu \n  moderación.\noauth2.grant.moderate.magazine.reports.read: Mostrar informes en las revistas \n  bajo tu moderación.\noauth.consent.deny: Denegar\noauth.client_not_granted_message_read_permission: Esta aplicación no tiene \n  permiso para leer tus mensajes.\nrestrict_oauth_clients: Restringir la creación de clientes OAuth2 a \n  administradores/as\nunblock: Desbloquear\noauth2.grant.moderate.magazine.ban.delete: Desbanear usuaries en las revistas \n  que moderas.\noauth.client_identifier.invalid: ¡ID para el cliente de OAuth no válido!\noauth.consent.app_has_permissions: ya puede realizar las siguientes acciones\nmark_as_adult: Marcar como explícito\nsubscribers_count: '{0}Suscriptores|{1}Suscriptor|]1,Inf[ Suscriptores'\nfollowers_count: '{0}Seguidores|{1}Seguidor|]1,Inf[ Seguidores'\nremove_media: Eliminar medio\nmenu: Menú\nflash_mark_as_adult_success: La publicación se ha marcado correctamente como \n  explícita.\nunmark_as_adult: Desmarcar como explícito\nflash_unmark_as_adult_success: La publicación se ha desmarcado correctamente \n  como explícita.\ndefault_theme: Tema por defecto\noauth2.grant.moderate.magazine_admin.delete: Eliminar todas las revistas de tu \n  propiedad.\noauth2.grant.moderate.magazine_admin.all: Crear, editar o eliminar las revistas \n  de tu propiedad.\noauth2.grant.moderate.magazine_admin.create: Crear nuevas revistas.\noauth2.grant.moderate.magazine_admin.update: Editar las reglas, la descripción, \n  el modo explícito o el ícono de cualquiera de tus revistas.\noauth2.grant.admin.all: Realizar cualquier acción administrativas en tu \n  instancia.\noauth2.grant.moderate.magazine_admin.badges: Crear o eliminar distintivos de las\n  revistas de tu propiedad.\noauth2.grant.moderate.magazine_admin.tags: Crear o eliminar etiquetas en las \n  revistas de tu propiedad.\noauth2.grant.moderate.magazine_admin.stats: Mostrar el contenido, las votaciones\n  y el estado de visualización de las revistas de tu propiedad.\noauth2.grant.domain.all: Suscríbirse o bloquear dominios y ver los dominios a \n  los que se suscribe o bloquea.\noauth2.grant.block.general: Bloquear o desbloquear cualquier revista, dominio o \n  usuarie, y ver las revistas, dominios y usuaries que ha bloqueado.\noauth2.grant.domain.subscribe: Suscríbirse o cancelar la suscripción a dominios \n  y ver los dominios a los que está suscrito.\noauth2.grant.domain.block: Bloquear o desbloquear dominios y ver los dominios \n  que ha bloqueado.\noauth2.grant.subscribe.general: Suscribirse o seguir cualquier revista, dominio \n  o usuarie, y ver las revistas, dominios y usuaries a los que se suscribe.\ndisconnected_magazine_info: Esta revista no recibe actualizaciones (última \n  actividad hace %days% día(s)).\nalways_disconnected_magazine_info: Esta revista no recibe actualizaciones.\nsubscribe_for_updates: Suscríbete para comenzar a recibir actualizaciones.\ndefault_theme_auto: Claro/Oscuro (detección automática)\nsolarized_auto: Solarizado (Detección automática)\noauth2.grant.moderate.magazine.trash.read: Mostrar el contenido de la papelera \n  en tus revistas moderadas.\noauth2.grant.moderate.magazine_admin.edit_theme: Editar el CSS personalizado de \n  tus revistas.\noauth2.grant.moderate.magazine_admin.moderators: Añadir o eliminar \n  moderadores/as a las revistas de tu propiedad.\noauth2.grant.admin.entry.purge: Eliminar completamente cualquier hilo de tu \n  instancia.\noauth2.grant.read.general: Leer todo el contenido al que tienes acceso.\noauth2.grant.write.general: Crear o editar cualquiera de tus hilos, \n  publicaciones o comentarios.\noauth2.grant.delete.general: Eliminar cualquiera de tus hilos, publicaciones o \n  comentarios.\noauth2.grant.report.general: Reportar hilos, publicaciones o comentarios.\noauth2.grant.vote.general: Votar a favor, en contra o impulsar hilos, \n  publicaciones o comentarios.\nmarked_for_deletion: Marcado para eliminar\nfrom: desde\ntag: Etiqueta\nunban: Desbanear\nban_hashtag_btn: Prohibir hashtag\nmarked_for_deletion_at: Marcado para eliminar el %date%\nsort_by: Ordenar por\nfilter_by_subscription: Filtrar por suscripción\nfilter_by_federation: Filtrar por estado de federación\nhidden: Oculto\ndisabled: Desactivado\nedit_entry: Editar hilo\noauth2.grant.entry_comment.create: Crear nuevos comentarios en hilos.\noauth2.grant.entry_comment.delete: Eliminar todos tus comentarios existentes en \n  los hilos.\nenabled: Activado\ndownvotes_mode: Modo de votos negativos\nunban_hashtag_description: Si retiras la prohibición de un hashtag, se podrán \n  volver a crear publicaciones con este hashtag. Las publicaciones existentes \n  con este hashtag ya no estarán ocultas.\nchange_downvotes_mode: Cambiar el modo de votos negativos\noauth2.grant.magazine.all: Suscríbirse o bloquear revistas y visualizar las \n  revistas a las que estás suscrito o has bloqueado.\noauth2.grant.magazine.subscribe: Suscríbirse o cancelar tu suscripción a \n  revistas y consultar las revistas a las que te suscribes.\noauth2.grant.post.report: Reportar cualquier publicación.\nunban_hashtag_btn: Retirar la prohibición de hashtag\nban_hashtag_description: Prohibir un hashtag impedirá que se creen publicaciones\n  con ese hashtag, además de ocultar las publicaciones existentes con ese \n  hashtag.\naccount_deletion_immediate: Eliminar inmediatamente\naccount_deletion_button: Eliminar cuenta\nprivate_instance: Obligar a les usuaries a iniciar sesión antes de poder acceder\n  a cualquier contenido\noauth2.grant.entry.edit: Editar tus hilos existentes.\noauth2.grant.entry.report: Reportar cualquier hilo.\noauth2.grant.entry_comment.all: Crear, editar o eliminar tus comentarios en \n  hilos, y votar, impulsar o denunciar cualquier comentario en un hilo.\noauth2.grant.entry_comment.vote: Votar a favor, impulsar o rechazar cualquier \n  comentario en un hilo.\noauth2.grant.magazine.block: Bloquear o desbloquear revistas y visualizar las \n  revistas que has bloqueado.\noauth2.grant.post.all: Crear, editar o eliminar tus microblogs y votar, impulsar\n  o reportar cualquier microblog.\noauth2.grant.post.create: Crear nuevas publicaciones.\noauth2.grant.post_comment.create: Crear nuevos comentarios en las publicaciones.\noauth2.grant.post_comment.delete: Eliminar todos tus comentarios en las \n  publicaciones.\noauth2.grant.post_comment.edit: Editar todos tus comentarios en las \n  publicaciones.\noauth2.grant.entry.create: Crear nuevos hilos.\noauth2.grant.entry.vote: Votar a favor, impulsar o rechazar cualquier hilo.\noauth2.grant.entry.delete: Eliminar tus hilos.\noauth2.grant.entry_comment.edit: Editar tus comentarios existentes en los hilos.\noauth2.grant.post.vote: Votar a favor o en contra, o impulsar cualquier \n  publicación.\noauth2.grant.entry.all: Crear, editar o eliminar tus hilos y votar, impulsar o \n  reportar cualquier hilo.\naccount_deletion_title: Borrado de cuenta\noauth2.grant.post.edit: Editar todas tus publicaciones.\noauth2.grant.post.delete: Eliminar todas tus publicaciones.\noauth2.grant.post_comment.all: Crear, editar o eliminar tus comentarios en las \n  publicaciones y votar, impulsar o reportar cualquier comentario en una \n  publicación.\noauth2.grant.entry_comment.report: Reportar cualquier comentario en un hilo.\nfederation_page_dead_description: Casos en los que no pudimos realizar al menos \n  10 actividades seguidas y donde la última entrega y recepción exitosas fueron \n  hace más de una semana\naccount_deletion_description: Tu cuenta se eliminará en 30 días a menos que \n  elijas eliminarla inmediatamente. Para recuperar tu cuenta en un plazo de 30 \n  días, inicia sesión con las mismas credenciales o comunícate con \n  administración.\noauth2.grant.user.oauth_clients.read: Leer los permisos que ha concedido a otras\n  aplicaciones OAuth2.\noauth2.grant.moderate.post.trash: Eliminar o restaurar publicaciones en las \n  revistas que moderas.\nmoderation.report.ban_user_title: Banear cuenta\noauth2.grant.user.oauth_clients.all: Leer y editar los permisos que has otorgado\n  a otras aplicaciones OAuth2.\noauth2.grant.moderate.entry.change_language: Cambiar el idioma de los hilos en \n  las revistas que moderas.\noauth2.grant.moderate.entry.pin: Fijar hilos en la parte superior de las \n  revistas que moderas.\noauth2.grant.moderate.entry.set_adult: Marcar los hilos como explícitos en las \n  revistas que moderas.\noauth2.grant.admin.oauth_clients.read: Vernlos clientes OAuth2 que existen en tu\n  instancia y sus estadísticas de uso.\nlast_active: Última actividad\noauth2.grant.admin.federation.update: Añadir o eliminar instancias de la lista \n  de instancias desfederadas.\noauth2.grant.moderate.entry.all: Moderar los hilos en las revistas que moderas.\noauth2.grant.admin.magazine.purge: Borrar completamente las revistas de tu \n  instancia.\noauth2.grant.post_comment.vote: Votar a favor, en contra o impulsar cualquier \n  comentario de una publicación .\noauth2.grant.user.profile.all: Leer y editar tu perfil.\noauth2.grant.user.profile.read: Leer tu perfil.\noauth2.grant.moderate.post_comment.all: Moderar los comentarios en las \n  publicaciones de las revistas que moderas.\noauth2.grant.admin.magazine.move_entry: Mover los hilos entre las revistas de tu\n  instancia.\noauth2.grant.admin.user.ban: Banear o desbanear cuentas de tu instancia.\noauth2.grant.admin.user.delete: Eliminar cuentas de tu instancia.\noauth2.grant.admin.instance.settings.read: Ver la configuración de tu instancia.\noauth2.grant.admin.instance.settings.edit: Actualizar la configuración de tu \n  instancia.\nshow_avatars_on_comments_help: Mostrar u ocultar los avatares de los usuaries al\n  ver comentarios en un solo hilo o publicación .\ncomment_reply_position: Posición del comentario de respuesta\nmagazine_theme_appearance_icon: Icono personalizado para la revista.\nmoderation.report.approve_report_title: Aprobar informe\nmoderation.report.ban_user_description: ¿Quieres banear a (%username%) que creó \n  este contenido de esta revista?\nmoderation.report.approve_report_confirmation: ¿De verdad quieres aprobar este \n  informe?\nsubject_reported_exists: Este contenido ya ha sido reportado.\noauth2.grant.admin.federation.read: Ver la lista de instancias desfederadas.\noauth2.grant.user.block: Bloquear o desbloquear cuentas, y leer una lista de las\n  que bloqueas.\noauth2.grant.moderate.all: Realizar cualquier acción de moderación que tengas \n  permiso para hacer en las revistas que moderas.\noauth2.grant.admin.instance.all: Ver y actualizar la configuración o la \n  información de la instancia.\nsingle_settings: Único\nmoderation.report.reject_report_title: Rechazar informe\noauth2.grant.moderate.magazine.ban.create: Banear cuentas en las revistas que \n  moderas.\noauth2.grant.admin.user.purge: Eliminar completamente cuentas de tu instancia.\noauth2.grant.admin.oauth_clients.all: Ver o revocar clientes OAuth2 que existen \n  en tu instancia.\nflash_post_pin_success: La publicación se ha anclado correctamente.\nflash_post_unpin_success: La publicación se ha desanclado correctamente.\noauth2.grant.user.profile.edit: Editar tu perfil.\noauth2.grant.moderate.entry_comment.all: Moderar los comentarios en los hilos de\n  las revistas que moderas.\noauth2.grant.moderate.entry.trash: Eliminar o restaurar hilos en las revistas \n  que moderas.\noauth2.grant.admin.instance.information.edit: Actualizar las páginas Acerca de, \n  Preguntas frecuentes, Contacto, Condiciones del servicio y Política de \n  privacidad de tu instancia.\nupdate_comment: Actualizar comentario\noauth2.grant.admin.post.purge: Borrar completamente cualquier mensaje de tu \n  instancia.\noauth2.grant.admin.post_comment.purge: Eliminar completamente cualquier \n  comentario de una entrada de tu instancia.\noauth2.grant.user.all: Leer y editar tu perfil, mensajes o notificaciones; leer \n  y editar los permisos que has otorgado a otras aplicaciones; seguir o bloquear\n  a usuaries; ver listas de usuaries que sigues o bloqueas.\noauth2.grant.user.follow: Seguir o dejar de seguir cuentas, y leer la lista de \n  las que sigues.\noauth2.grant.moderate.post_comment.change_language: Cambiar el idioma de los \n  comentarios en las publicaciones de las revistas que moderas.\nmagazine_theme_appearance_custom_css: CSS personalizado que se aplicará al \n  visualizar el contenido dentro de su revista.\noauth2.grant.admin.entry_comment.purge: Eliminar por completo cualquier \n  comentario en un hilo de tu instancia.\noauth2.grant.moderate.magazine.ban.all: Gestionar cuentas baneadas en las \n  revistas que moderas.\noauth2.grant.moderate.magazine.ban.read: Ver cuentas baneadas en las revistas \n  que moderas.\noauth2.grant.admin.federation.all: Ver y actualizar las instancias actualmente \n  desfederadas.\noauth2.grant.admin.instance.settings.all: Ver o actualizar la configuración de \n  tu instancia.\noauth2.grant.moderate.post.change_language: Cambiar el idioma de las \n  publicaciones en las revistas que moderas.\noauth2.grant.admin.instance.stats: Consultar las estadísticas de tu instancia.\noauth2.grant.admin.user.all: Banear, verificar o eliminar completamente usuaries\n  de tu instancia.\noauth2.grant.post_comment.report: Reportar cualquier comentario en una \n  publicación.\nmagazine_theme_appearance_background_image: Imagen de fondo personalizada que se\n  aplicará al visualizar el contenido dentro de su revista.\noauth2.grant.moderate.post_comment.set_adult: Marcar como explícitos los \n  comentarios en las publicaciones en las revistas que moderas.\noauth2.grant.user.notification.delete: Eliminar tus notificaciones.\noauth2.grant.moderate.magazine.all: Administrar prohibiciones, informes y \n  visualizar elementos eliminados en las revistas que moderas.\noauth2.grant.admin.magazine.all: Mover hilos entre o eliminar completamente \n  revistas en tu instancia.\noauth2.grant.user.oauth_clients.edit: Editar los permisos que has concedido a \n  otras aplicaciones OAuth2.\noauth2.grant.moderate.post.set_adult: Marca como explícitas las publicaciones en\n  las revistas que moderas.\noauth2.grant.admin.oauth_clients.revoke: Revocar el acceso a clientes OAuth2 en \n  tu instancia.\ncomment_reply_position_help: Mostrar el formulario de respuesta a los \n  comentarios en la parte superior o inferior de la página. Cuando está \n  habilitado el \"desplazamiento infinito\", la posición siempre aparecerá en la \n  parte superior.\noauth2.grant.moderate.entry_comment.set_adult: Marcar como explícitos los \n  comentarios en los hilos en las revistas que moderas.\noauth2.grant.moderate.entry_comment.trash: Eliminar o restaurar comentarios en \n  hilos de las revistas que moderas.\noauth2.grant.moderate.post.all: Moderar las publicaciones en las revistas que \n  moderas.\noauth2.grant.moderate.entry_comment.change_language: Cambiar el idioma de los \n  comentarios en los hilos de las revistas que moderas.\noauth2.grant.moderate.post_comment.trash: Eliminar o restaurar comentarios en \n  publicaciones de las revistas que moderas.\noauth2.grant.admin.user.verify: Verificar usuaries en tu instancia.\noauth2.grant.user.notification.all: Leer y eliminar tus notificaciones.\noauth2.grant.user.notification.read: Leer tus notificaciones, incluidas las de \n  mensajes.\noauth2.grant.user.message.all: Leer tus mensajes y enviar mensajes a otres \n  usuaries.\noauth2.grant.user.message.read: Leer tus mensajes.\noauth2.grant.user.message.create: Enviar mensajes a usuaries.\nflash_image_download_too_large_error: No se ha podido crear la imagen, es \n  demasiado grande (tamaño máximo %bytes%)\npending: Pendiente\nannouncement: Anuncio\n2fa.backup_codes.recommendation: Guarda una copia de los mismos en un lugar \n  seguro.\n2fa.available_apps: Usar una aplicación para la autenticación de dos factores \n  como %google_authenticator%, %aegis% (Android) o %raivo% (iOS) para escanear \n  el código QR.\n2fa.backup_codes.help: Puedes usar estos códigos cuando no tengas tu dispositivo\n  o aplicación de autenticación de dos factores. No se <strong>volverán a \n  mostrar</strong> y podrás utilizar cada uno de ellos <strong>solo una \n  vez</strong>.\nshow_subscriptions: Mostrar suscripciones\nsubscription_sidebar_pop_out_right: Mover a una barra lateral separada a la \n  derecha\nsubscription_sidebar_pop_out_left: Mover a la barra lateral separada a la \n  izquierda\nsubscription_sidebar_pop_in: Mover suscripciones al panel emergente\nsubscriptions_in_own_sidebar: En una barra lateral separada\nflash_thread_tag_banned_error: No se pudo crear el hilo. El contenido no está \n  permitido.\nflash_email_failed_to_sent: No se ha podido enviar el correo electrónico.\nflash_post_new_success: La publicación se ha creado correctamente.\nflash_post_new_error: No se pudo crear la publicación. Algo salió mal.\nflash_magazine_theme_changed_success: Se ha actualizado la apariencia de la \n  revista.\nflash_user_edit_password_error: Error al cambiar la contraseña.\nownership_requests: Solicitudes de titularidad\nflash_email_was_sent: El correo electrónico se ha enviado correctamente.\npurge_content: Purgar contenido\npurge_content_desc: Purgar completamente el contenido de usuarie, incluidas las \n  respuestas de otres usuaries en conversaciones, publicaciones y comentarios \n  que has creado.\ndelete_account_desc: Eliminar la cuenta, incluidas las respuestas de otres \n  usuaries en hilos, publicaciones y comentarios que has creado.\nschedule_delete_account: Programar eliminación\nremove_schedule_delete_account: Cancelar la eliminación programada\nremove_schedule_delete_account_desc: Cancelar la eliminación programada. Todo el\n  contenido volverá a estar disponible y el usuarie podrá iniciar sesión.\ntwo_factor_authentication: Autenticación de dos factores\n2fa.setup_error: Error al habilitar la autenticación de dos factores para la \n  cuenta\n2fa.enable: Configurar la autenticación de dos factores\n2fa.code_invalid: El código de autenticación no es válido\n2fa.disable: Desactivar la autenticación de dos factores\n2fa.backup: Tus códigos de respaldo para la autenticación de dos factores\n2fa.backup-create.help: Puedes crear nuevos códigos de autenticación de \n  respaldo; al hacerlo, se invalidarán los códigos existentes.\n2fa.add: Añadir a mi cuenta\n2fa.qr_code_img.alt: Un código QR que permite configurar la autenticación de dos\n  factores para tu cuenta\nsidebars_same_side: Barras laterales en el mismo lado\nflash_user_settings_general_success: La configuración se guardó correctamente.\naccount_is_suspended: Cuenta suspendida.\naccount_unbanned: Se ha desbaneado la cuenta.\napply_for_moderator: Solicitar ser moderador/a\ncancel_request: Cancelar solicitud\nremove_subscriptions: Eliminar suscripciones\nabandoned: Abandonado\nmoderator_requests: Solicitudes de moderación\nuser_badge_global_moderator: Moderador global\nuser_badge_admin: Administrador/a\nuser_badge_moderator: Moderador/a\nuser_badge_bot: Bot\nkeywords: Palabras clave\nsensitive_warning: Contenido sensible\nsensitive_toggle: Alternar la visibilidad del contenido sensible\ndeleted_by_author: El hilo, mensaje o comentario ha sido eliminado por su \n  autor/a\nall_time: Todo el tiempo\ntoolbar.spoiler: Destripe\nalphabetically: Alfabéticamente\nsubscription_panel_large: Panel grande\nclose: Cerrar\nflash_magazine_theme_changed_error: No se pudo actualizar la apariencia de la \n  revista.\nflash_comment_new_success: El comentario ha sido creado correctamente.\nflash_comment_new_error: No se pudo crear el comentario. Algo salió mal.\nflash_user_settings_general_error: No se pudo guardar la configuración.\ndetails: Detalles\nflash_user_edit_profile_error: No se pudo guardar la configuración del perfil.\nflash_thread_edit_error: No se pudo editar el hilo. Algo ha salido mal.\n2fa.remove: Eliminar la autenticación de dos factores\n2fa.backup-create.label: Crear nuevos códigos de autenticación de respaldo\n2fa.verify_authentication_code.label: Ingresa un código del doble factor para \n  verificar la configuración\nsubscription_sort: Ordenar\nsubscription_header: Revistas suscritas\nsensitive_hide: Pulse para ocultar\n2fa.verify: Verificar\n2fa.user_active_tfa.title: La cuenta tiene activa la autenticación de dos \n  factores\ncancel: Cancelar\noauth2.grant.moderate.post.pin: Fijar publicaciones en la parte superior de las \n  revistas que moderas.\nmoderation.report.reject_report_confirmation: ¿De verdad quiere rechazar este \n  informe?\nshow_avatars_on_comments: Mostrar avatares en los comentarios\n2fa.qr_code_link.title: Vsitar este enlace permite a tu plataforma registrar \n  esta autenticación de dos factores\ndelete_content_desc: Eliminar el contenido de usuarie pero dejar las respuestas \n  de otres usuaries en las conversaciones, publicaciones y comentarios.\nschedule_delete_account_desc: Programar la eliminación de esta cuenta en 30 \n  días. Esto ocultará al usuarie y su contenido, e impedirá que inicie sesión.\npassword_and_2fa: Contraseña y A2F\nsensitive_show: Pulse para ver\naction: Acción\nuser_badge_op: OP\ndeleted_by_moderator: Tema, mensaje o comentario eliminado por la moderación\nspoiler: Spoiler\nshow: Mostrar\ndelete_content: Eliminar contenido\nflash_comment_edit_error: No se pudo editar el comentario. Algo salió mal.\nrequest_magazine_ownership: Pedir titularidad de revista\ntwo_factor_backup: Códigos de respaldo de la autenticación de dos factores\nflash_account_settings_changed: La configuración de tu cuenta se ha cambiado \n  correctamente. Tendrás que volver a iniciar sesión.\nposition_bottom: Inferior\nposition_top: Superior\nflash_thread_new_error: No se pudo crear el hilo. Algo salió mal.\n2fa.authentication_code.label: Código de autenticación\nflash_comment_edit_success: El comentario se ha actualizado correctamente.\nflash_user_edit_profile_success: La configuración del perfil se guardó \n  correctamente.\nflash_user_edit_email_error: Error al cambiar el correo electrónico.\nhide: Ocultar\nedited: editado\nsso_registrations_enabled: Registros SSO habilitados\nsso_registrations_enabled.error: Los registros de nuevas cuentas con \n  administradores de identidad de terceros están actualmente deshabilitados.\naccept: Aceptar\noauth2.grant.user.bookmark_list.delete: Borrar tus listas de marcadores\npage_width_auto: Automático\nauto: Automático\nopen_url_to_fediverse: Abrir URL original\nchange_my_avatar: Cambiar mi avatar\nchange_my_cover: Cambiar mi portada\naccount_settings_changed: Los ajustes de tu cuenta se han cambiado \n  correctamente. Necesitarás conectarte de nuevo.\nmagazine_deletion: Borrado de revista\nsuspend_account: Suspender cuenta\nviewing_one_signup_request: Sólo estás viendo una solicitud de registro de \n  %username%\naccount_suspended: La cuenta ha sido suspendida.\noauth2.grant.user.bookmark.add: Añadir marcadores\noauth2.grant.user.bookmark: Añadir y eliminar marcadores\noauth2.grant.user.bookmark_list: Leer, editar y borrar tus listas de marcadores\noauth2.grant.user.bookmark_list.edit: Editar tus listas de marcadores\nrestore_magazine: Recuperar revista\nunsuspend_account: Reactivar cuenta\ndeletion: Borrado\nuser_suspend_desc: Suspender tu cuenta oculta tu contenido en la instancia, pero\n  no lo borra permanentemente, y puedes restaurarlo en cualquier momento.\nnotify_on_user_signup: Nuevos registros\nyour_account_is_not_yet_approved: Tu cuenta no ha sido aprobada todavía. Te \n  enviaremos un email tan pronto como los administradores hayan procesado tu \n  solicitud de registro.\naccount_unsuspended: La cuenta ha sido reactivada.\nedit_my_profile: Editar mi perfil\npage_width_max: Máximo\npage_width: Ancho de página\npage_width_fixed: Fijo\nflash_post_edit_error: Error al editar el post. Algo ha salido mal.\noauth2.grant.user.bookmark.remove: Eliminar marcadores\noauth2.grant.user.bookmark_list.read: Leer tus listas de marcadores\ndelete_magazine: Borrar revista\nmagazine_is_deleted: La revista ha sido borrada. Puedes <a \n  href=\"%link_target%\">restaurarla</a> durante los próximos 30 días.\nfederation_page_dead_title: Instancias muertas\nflash_post_edit_success: El post se ha editado correctamente.\nfilter_labels: Filtrar etiquetas\nnotification_title_new_post: Nueva publicación\nnotification_title_new_report: Se creó un nuevo informe\nversion: Versión\nnew_user_description: Esta cuenta es nueva (activa durante menos de %days% días)\nnew_magazine_description: Esta revista es nueva (activa durante menos de %days% \n  días)\nadmin_users_inactive: Inactivos/as\nadmin_users_active: Activos/as\nremove_user_avatar: Eliminar avatar\naccount_banned: Se ha baneado la cuenta.\nsso_only_mode: Restringir el inicio de sesión y el registro únicamente a métodos\n  SSO\nrelated_entry: Relacionado\nreporting_user: Denunciante\nreported: denunciado\nown_content_reported_accepted: Se aceptó un informe de tu contenido.\nreport_accepted: Se ha aceptado un informe\ncake_day: Desde el día\nmagazine_log_entry_unpinned: se eliminó la entrada fijada\nunregister_push_notifications_button: Eliminar registro \"push\"\ntest_push_notifications_button: Probar notificaciones \"push\"\nnotification_title_new_comment: Nuevo comentario\nnotification_title_removed_comment: Se eliminó un comentario\nnotification_title_edited_comment: Se editó un comentario\nnotification_title_message: Nuevo mensaje directo\nnotification_title_edited_post: Se editó una publicación\nnotification_title_new_signup: Se ha registrado una nueva cuenta\nnotification_body_new_signup: Se ha registrado la cuenta %u%.\nlast_successful_receive: Última recepción correcta\nmagazine_posting_restricted_to_mods: Restringir la creación de hilos a la \n  moderación\nmax_image_size: Tamaño máximo del archivo\nbookmark_add_to_list: Añadir marcador a %list%\nbookmark_remove_from_list: Eliminar el marcador de %list%\nbookmark_list_create_placeholder: escribe el nombre...\ntable_of_contents: Tabla de contenido\nsearch_type_all: Todo\nsearch_type_entry: Hilos\napplication_text: Explica por qué quieres unirte\nsignup_requests_header: Solicitudes de registro\nsignup_requests_paragraph: Estes usuaries desean unirse a tu servidor. No podrán\n  iniciar sesión hasta que apruebes su solicitud de registro.\nemail_application_approved_title: Tu solicitud de registro ha sido aprobada\nemail_application_approved_body: La administración del servidor ha aprobado su \n  solicitud de registro. Ahora puede iniciar sesión en el servidor en <a \n  href=\"%link%\">%siteName%</a>.\nemail_application_rejected_body: Gracias por su interés, pero lamentamos \n  informarle que su solicitud de registro ha sido rechazada.\nshow_user_domains: Mostrar dominios de usuarie\nanswered: contestado/a\nby: por\nfront_default_sort: Orden predeterminado de la portada\ncomment_default_sort: Orden predeterminado de los comentarios\nopen_signup_request: Abrir solicitud de registro\nshow_thumbnails_help: Mostrar las miniaturas de las imágenes.\nimage_lightbox_in_list_help: Cuando está marcado, al hacer clic en la miniatura \n  se muestra una ventana modal con la imagen. Cuando no esté marcado, hacer clic\n  en la miniatura abrirá el hilo.\nshow_new_icons: Mostrar nuevos iconos\nshow_new_icons_help: Mostrar icono para nueva revista o cuenta (de 30 días de \n  antigüedad o más reciente)\nuser_verify: Activar cuenta\nshow_users_avatars_help: Mostrar la imagen del avatar del usuarie.\nshow_magazines_icons_help: Mostrar el icono de la revista.\ntest_push_message: ¡Hola mundo!\nshow_related_entries: Mostrar hilos al azar\nsearch_type_post: Microblogs\nselect_user: Elige un usuarie\nsignup_requests: Solicitudes de registro\ndirect_message: Mensaje directo\nremove_following: Eliminar el seguimiento\nrestrict_magazine_creation: Restringir la creación de revistas locales a \n  administradores y moderadores globales\nreported_user: Cuenta reportada\nown_report_rejected: Tu informe ha sido rechazado\nback: Atrás\nmagazine_log_mod_added: Ha añadido un/a moderador/a\nmagazine_log_mod_removed: ha eliminado un/a moderador/a\nmagazine_log_entry_pinned: entrada fijada\nsomeone: Alguien\nnotification_title_mention: Te mencionaron\nnotification_title_new_reply: Nueva respuesta\nnotification_title_new_thread: Nuevo hilo\nnotification_title_ban: Te banearon\nnotification_title_removed_thread: Se eliminó un hilo\nnotification_title_edited_thread: Se editó un hilo\nnotification_body2_new_signup_approval: Debes aprobar la solicitud antes de que \n  puedan iniciar sesión\nshow_related_posts: Mostrar publicaciones al azar\nshow_active_users: Mostrar cuentas activas\nserver_software: Software del servidor\nlast_successful_deliver: Última entrega correcta\nbookmark_add_to_default_list: Añadir marcador a la lista predeterminada\nbookmark_lists: Listas de marcadores\nbookmark_remove_all: Eliminar todos los marcadores\nbookmarks: Marcadores\nis_default: Es predeterminada\nbookmark_list_is_default: Es la lista predeterminada\nbookmark_list_make_default: Hacer predeterminada\nbookmark_list_create: Crear\nbookmark_list_edit: Editar\nbookmark_list_selected_list: Lista seleccionada\nshow_magazine_domains: Mostrar dominios de revistas\nflash_application_info: La administración debe aprobar su cuenta antes de poder \n  iniciar sesión. Recibirá un correo electrónico una vez procesada su solicitud \n  de registro.\nown_report_accepted: Tu informe ha sido aceptado\nand: Y\nlast_updated: Última actualización\nreport_subject: Asunto\ncount: Recuento\nbookmarks_list: Marcadores en %list%\nemail_application_rejected_title: Tu solicitud de registro ha sido rechazada\nemail_verification_pending: Debes verificar tu correo electrónico antes de poder\n  iniciar sesión.\ncomment_not_found: Comentario no encontrado\nflash_posting_restricted_error: La creación de hilos está restringida a la \n  moderación de esta revista y no es parte de ella\nlast_failed_contact: Último contacto fallido\nremove_user_cover: Eliminar portada\nbookmarks_list_edit: Editar la lista de marcadores\nmanually_approves_followers: Aprueba seguidores manualmente\nnew_users_need_approval: Las nuevas cuentas deben ser aprobadas antes de poder \n  iniciar sesión.\nregister_push_notifications_button: Regístrese para recibir notificaciones \n  \"push\"\nemail_application_pending: Su cuenta requiere la aprobación de la administración\n  antes de poder iniciar sesión.\nmagazine_posting_restricted_to_mods_warning: Sólo la moderación puede crear \n  hilos en esta revista\ncompact_view_help: Una vista compacta con márgenes menores, donde la miniatura \n  pasa al lado derecho.\nbookmark_list_create_label: Nombre de la lista\npurge_magazine: Purgar revista\nimage_lightbox_in_list: Las miniaturas de los hilos abren pantalla completa\nsso_show_first: Mostrar SSO primero en las páginas de inicio de sesión y \n  registro\ncontinue_with: Continuar con\nnotification_title_removed_post: Se eliminó una publicación\nshow_related_magazines: Mostrar revistas al azar\nadmin_users_suspended: Suspendidos/as\nadmin_users_banned: Baneados/as\nopen_report: Abrir informe\ntoolbar.emoji: Emoji\n2fa.manual_code_hint: Si no puedes escanear el código QR escribe el secreto \n  manualmente\nmagazine_instance_defederated_info: La instancia de esta revista está \n  desfederada. Por lo tanto, no recibirá actualizaciones.\nuser_instance_defederated_info: La instancia de esta cuenta está defederada.\nflash_thread_instance_banned: La instancia de esta revista está baneada.\nshow_rich_mention: Menciones enriquecidas\nshow_rich_mention_help: Mostrar un componente de cuenta al mencionar a una \n  cuenta. Este incluirá su nombre para mostrar y su foto de perfil.\nshow_rich_mention_magazine: Menciones enriquecidas de revistas\nshow_rich_mention_magazine_help: Mostrar un componente de revista al mencionar \n  una revista. Esto incluirá su nombre para mostrar y su icono.\nshow_rich_ap_link: Enlaces AP enriquecidos\nshow_rich_ap_link_help: Mostrar un componente en línea cuando otro contenido de \n  ActivityPub está vinculado a él.\ntype_search_term_url_handle: Escribe el término de búsqueda, URL o identificador\nsearch_type_magazine: Revistas\nsearch_type_user: Cuentas\nsearch_type_actors: Revistas + Cuentas\nsearch_type_content: Temas + Microblogs\nattitude: Actitud\ntype_search_magazine: Limitar la búsqueda a la revista...\ntype_search_user: Limitar búsqueda a la autoría...\nmodlog_type_entry_deleted: Hilo eliminado\nmodlog_type_entry_restored: Hilo restaurado\nmodlog_type_entry_comment_deleted: Comentario del hilo eliminado\nmodlog_type_entry_comment_restored: Comentario del hilo restaurado\nmodlog_type_entry_pinned: Hilo fijado\nmodlog_type_entry_unpinned: Hilo desfijado\nmodlog_type_post_deleted: Microblog eliminado\nmodlog_type_post_restored: Microblog restaurado\nmodlog_type_post_comment_deleted: Respuesta de microblog eliminada\nmodlog_type_post_comment_restored: Respuesta del microblog restaurada\nmodlog_type_ban: Cuenta baneada de la revista\nmodlog_type_moderator_add: Se agregó un/a moderador/a de la revista\nmodlog_type_moderator_remove: Moderador/a de la revista eliminado/a\ncreated_since: Creado desde\ncrosspost: Publicación cruzada\nban_expires: La prohibición caduca\nbanner: Báner\noauth2.grant.moderate.entry.lock: Bloquea los hilos en las revistas que moderas,\n  para que nadie pueda comentarlos\noauth2.grant.moderate.post.lock: Bloquea los microblogs en las revistas que \n  moderas, para que nadie pueda comentarlos\nmagazine_theme_appearance_banner: Báner personalizado para la revista. Se \n  muestra sobre todos los hilos y debe tener una relación de aspecto amplia (5:1\n  o 1500 px x 300 px).\nflash_thread_ref_image_not_found: No se pudo encontrar la imagen referenciada \n  por 'imageHash'.\neveryone: Todo el mundo\nnobody: Nadie\nfollowers_only: Solo seguidores/as\ndirect_message_setting_label: Quién puede enviarte un mensaje directo\ndelete_magazine_icon: Eliminar el icono de la revista\nflash_magazine_theme_icon_detached_success: El icono de la revista se eliminó \n  correctamente\ndelete_magazine_banner: Suprimir el báner de la revista\nflash_magazine_theme_banner_detached_success: El báner de la revista se eliminó \n  correctamente\nfederation_uses_allowlist: Utilizar lista de permitidos para la federación\ndefederating_instance: Desfederando la instancia %i\ntheir_user_follows: Cantidad de usuaries de su instancia que siguen a usuaries \n  de la nuestra\nour_user_follows: Cantidad de usuaries de nuestra instancia que siguen a \n  usuaries en la suya\ntheir_magazine_subscriptions: Cantidad de usuaries de su instancia suscrites a \n  revistas en la nuestra\nour_magazine_subscriptions: Cantidad de usuaries de nuestra instancia suscrites \n  a revistas de la suya\nconfirm_defederation: Confirmar la desfederación\nflash_error_defederation_must_confirm: Tienes que confirmar la desfederación\nallowed_instances: Instancias permitidas\nbtn_deny: Denegar\n"
  },
  {
    "path": "translations/messages.et.yaml",
    "content": "type.link: Link\ntype.article: Jutulõng\ntype.photo: Foto\ntype.video: Video\ntype.smart_contract: Nutileping\ntype.magazine: Ajakiri\nthread: Jutulõng\nthreads: Jutulõngad\nmicroblog: Mikroblogi\npeople: Inimesed\nevents: Sündmused\nmagazine: Ajakiri\nmagazines: Ajakirjad\nsearch: Otsi\nadd: Lisa\nselect_channel: Vali kanal\nlogin: Logi sisse\nsort_by: Järjestus\ntop: Parimad\nhot: Hetkel teemaks\nactive: Aktiivne\nnewest: Uusim\noldest: Vanim\ncommented: Kommenteeritud\nchange_view: Muuda vaadet\nfilter_by_time: Filtreeri aja alusel\nfilter_by_type: Filtreeri tüübi alusel\nfilter_by_subscription: Filtreeri tellimuse alusel\nfilter_by_federation: Filtreeri födereerimise oleku alusel\ncomments_count: '{0} kommentaari|{1} kommentaar|]1,Inf[ kommentaari'\nsubscribers_count: '{0} tellijat|{1} tellija|]1,Inf[ tellijat'\nfollowers_count: '{0} jälgijat|{1} jälgija|]1,Inf[ jälgijat'\nmarked_for_deletion: Märgitud kustutamiseks\nmarked_for_deletion_at: Märgitud kustutamiseks %date%\navatar: Tunnuspilt\nadded: Lisatud\nup_votes: Hoolisamised\ndown_votes: Mahahääletused\nno_comments: Kommentaare pole\ncreated_at: Loodud\nowner: Omanik\nsubscribers: Tellijad\nonline: Võrgus\ncomments: Kommentaarid\nposts: Postitused\nreplies: Vastused\nmoderators: Moderaatorid\nmod_log: Modereerimislogi\nadd_comment: Lisa kommentaar\nadd_post: Lisa postitus\nadd_media: Lisa meediat\nremove_media: Eemalda meedia\nremove_user_avatar: Eemalda tunnuspilt\nremove_user_cover: Eemalda kaanepilt\nlocal_and_federated: Kohalik ja födiversumis\nfilter.fields.only_names: Ainult nimed\nfilter.fields.names_and_descriptions: Nimed ja kirjedused\ntoolbar.bold: Paks kiri\ntoolbar.italic: Kaldkiri\ntoolbar.strikethrough: Läbikriipsutatud kiri\ntoolbar.header: Päis\ntoolbar.quote: Tsitaat\ntoolbar.code: Koodilõik\ntoolbar.link: Link\ntoolbar.image: Pilt\ntoolbar.unordered_list: Järjestamata loend\ntoolbar.ordered_list: Järjestatud loend\ntoolbar.mention: Mainimine\n"
  },
  {
    "path": "translations/messages.eu.yaml",
    "content": "thread: Haria\npeople: Jendea\nsearch: Bilatu\ntype.link: Esteka\ntype.article: Haria\ntype.photo: Argazkia\ntype.video: Bideoa\ntype.magazine: Aldizkaria\nthreads: Hariak\nmagazine: Aldizkaria\nmagazines: Aldizkariak\nadd: Gehitu\nlogin: Hasi saioa\noldest: Zaharrena\nmore: Gehiago\nadded: Gehituta\ninstances: Instantziak\nnewest: Berriena\nfavourites: Faboritoak\nmoderators: Moderatzaileak\nowner: Jabe\npassword: Pasahitza\nstats: Estatistikak\nfediverse: Fedibertsoa\nemail: Helbide elektronikoa\nreply: Erantzun\nfollow: Jarraitu\nunfollow: Ez jarraitu\nsubscribe: Harpidetu\nremember_me: Gogora nazazu\nprivacy_policy: Pribatutasun-politika\nhidden: Ezkututa\nhelp: Laguntza\ntry_again: Saiatu berriro\ntitle: Izenburua\ntag: Etiketa\ntags: Etiketak\nbadges: Intsigniak\nbody: Gorputza\nname: Izena\ncolumns: Zutabeak\nuser: Erabiltzailea\ndescription: Deskribapena\ndomain: Domeinua\nfollowers: Jarraitzaileak\nfollowing: Jarraitzen\nsubscriptions: Harpidetzak\nrules: Arauak\npeople_federated: Federatuta\noverview: ikuspegi orokorra\narticles: Hariak\nlinks: Estekak\nphotos: Argazkiak\nvideos: Bideoak\nshare: Partekatu\nreason: Arrazoia\nedit: Editatu\nnotifications: Jakinarazpenak\nprofile: Profila\nmessages: Mezuak\nsave: Gorde\ndomains: Domeinuak\nvotes: Botoak\nnew_password: Pasahitz berria\ndark: Iluna\nlight: Argia\nfont_size: Letra tamaina\nyes: Bai\nno: Ez\nleft: Ezkerra\nright: Eskuina\nfilters: Iragazkiak\nmessage: Mezua\napprove: Onartu\nmonth: Hilabetea\nmonths: Hilabeteak\nweek: Astea\nweeks: Asteak\npages: Orrialdeak\ntoolbar.bold: Lodia\ntoolbar.italic: Etzana\ntoolbar.header: Izenburua\ntoolbar.link: Esteka\nerrors.server404.title: 404 Ez aurkitua\nerrors.server403.title: 403 Debekatuta\nerrors.server429.title: 429 Eskaera gehiegi\ncustom_css: CSS egokituta\n2fa.verify: Egiaztatu\nalphabetically: Alfabetikoki\nannouncement: Iragarkia\nkeywords: Hitz gakoak\ndetails: Xehetasunak\nhide: Ezkutatu\nshow: Erakutsi\nand: eta\nlast_updated: Azken eguneraketa\nnotification_title_new_reply: Erantzun berria\nnotification_title_new_thread: Hari berria\nversion: Bertsioa\nshow_active_users: Erakutsi erabiltzaile aktiboak\nsearch_type_entry: Hariak\nbookmark_list_edit: Editatu\nbookmarks: Markatzaileak\nanswered: erantzuta\nshow_new_icons: Erakutsi ikono berriak\nshow_users_avatars_help: Erabiltzailearen abatarraren irudia erakutsi.\nshow_magazines_icons_help: Aldizkariaren ikonoa erakutsi.\ntype.smart_contract: Kontratu adimendun\ndelete_content: Ezabatu edukia\nsubject_reported_exists: Eduki honen berri eman da.\n2fa.remove: Kendu 2FA\nshow_subscriptions: Erakutsi harpidetzak\nchange_my_avatar: Aldatu nire abatarra\nedit_my_profile: Editatu nire profila\ndelete_magazine: Ezabatu aldizkaria\nrestore_magazine: Berrezarri aldizkaria\npage_width: Orrialdearen zabalera\naction: Ekintza\nabandoned: Abandonatuta\nspoiler: Spoilerra\nreport_subject: Gaia\nnotification_title_new_signup: Erabiltzaile berri bat erregistratuta\ntest_push_message: Kaixo mundua!\ntable_of_contents: Edukien taula\nbookmark_lists: Markatzaile zerrendak\nemail_verification_pending: Zure helbide elektronikoa egiaztatu behar duzu saioa\n  hasi aurretik.\nshow_thumbnails_help: Miniaturen irudiak erakutsi.\ncommented: Komentatua\nfollowers_count: '{0}Jarratzaileak|{1}Jarratzaile|]1,Inf[ Jarratzaileak'\navatar: Abatarra\nfavourite: Faborito\nsubscribers: Harpidedunak\nreplies: Erantzunak\nunsubscribe: Harpidetza kendu\nusername: Erabiltzaile-izena\ncreate_new_magazine: Aldizkari berria sortu\nall_magazines: Aldizkari guztiak\nreset_password: Pasahitza berridatzi\nempty: Hutsik\nshow_more: Erakutsi gehiago\nrepeat_password: Pasahitza berridatzi\nemail_confirm_title: Baieztatu zure helbide elektronikoa.\nemail_confirm_header: Kaixo! Baieztatu zure helbide elektronikoa.\nselect_magazine: Aukeratu aldizkari bat\nimage: Irudia\npeople_local: Lokala\nmoderated: Moderatua\nimage_alt: Irudi alternatiboaren testua\noauth2.grant.user.bookmark.add: Gehitu laster-markak\noauth2.grant.user.bookmark.remove: Kendu laster-markak\noauth2.grant.user.bookmark_list.edit: Editatu zure laster-marken zerrendak\noauth2.grant.user.bookmark_list.read: Irakurri zure laster-marken zerrendak\noauth2.grant.user.profile.edit: Editatu zure profila.\noauth2.grant.user.profile.read: Irakurri zure profila.\noauth2.grant.user.bookmark_list.delete: Ezabatu zure laster-marken zerrendak\noauth2.grant.user.profile.all: Irakurri eta editatu zure profila.\noauth2.grant.user.message.read: Irakurri zure mezuak.\n2fa.code_invalid: Autentifikazio-kodeak ez du balio\ntwo_factor_authentication: Autentifikazio-faktore bikoitza\ntwo_factor_backup: Autentifikazio-faktore bikoitzako babeskopien kodeak\n2fa.authentication_code.label: Autentifikazio-kodea\n2fa.disable: Autentifikazio-faktore bikoitza desaktibatu\n2fa.backup: Zure autentifikazio-faktore bikoitzako babeskopien kodeak\nclose: Itxi\npage_width_fixed: Finkatua\nuser_badge_bot: Bot\nsensitive_show: Klikatu erakusteko\nsensitive_warning: Eduki sentikorra\nsensitive_hide: Klikatu ezkutatzeko\nreported_user: Jakinarazitako erabiltzailea\nedited: editatua\nrelated_entry: Erlazionatua\nnotification_title_new_post: Post berria\nselect_user: Aukeratu erabiltzaile bat\nonline: Linean\nrelated_posts: Erlazionatutako postak\nadd_post: Gehitu posta\nposts: Postak\nadd_new_link: Esteka berria gehitu\nadd_new_photo: Argazki berria gehitu\nadd_new_post: Post berria gehitu\nadd_new_video: Bideo berria gehitu\nfaq: FAQ\nadd_new_article: Hari berria gehitu\nis_adult: 18+ / NSFW\nrelated_tags: Erlazionatutako etiketak\ntree_view: Arbola-bista\nclassic_view: Bista-klasikoa\ncompact_view: Bista-trinkoa\nsubscribed: Harpidetuta\nlogout: Saioa itxi\ntable_view: Taula-bista\nmoderate: Moderatu\nare_you_sure: Ziur zaude?\nmenu: Menua\nhide_adult: Ezkutatu NSFW edukia\nfilter_by_type: Iragazi motaren arabera\ncomments: Iruzkinak\nrandom_posts: Ausazko postak\nadd_comment: Gehitu iruzkina\nup_votes: Aldeko botoak\ndown_votes: Aurkako botoak\nmarked_for_deletion: Ezabatzeko markatuta\nmarked_for_deletion_at: '%date%an ezabatzeko markatuta'\nflash_thread_edit_success: Haria arrakastaz editatu da.\nrelated_entries: Hari erlazionatuak\ndelete_account: Ezabatu kontua\nrandom_entries: Ausazko hariak\nflash_thread_delete_success: Haria arrakastaz ezabatu da.\nflash_mark_as_adult_success: Posta arrakastaz NSFW bezala markatu da.\nnew_password_repeat: Baieztatu pasahitz berria\nchange_email: Helbide elektronikoa aldatu\nchange_password: Pasahitza aldatu\nread_all: Irakurri dena\nshow_all: Erakutsi dena\nflash_thread_pin_success: Haria arrakastaz finkatu da.\ntoo_many_requests: Muga gaindituta, mesedez, saiatu beranduago.\ndeleted: Egileak ezabatua\nsidebar_position: Albo-barraren posizioa\nstatus: Egoera\nadd_moderator: Gehitu moderatzailea\nupload_file: Artxiboa igo\nsend_message: Bidali mezu zuzena\napproved: Onartua\nrejected: Errefusatua\npin: Finkatu\ndone: Egina\nchange_language: Hizkuntza aldatu\nchange: Aldatu\npinned: Finkatuta\nusers: Erabiltzaileak\narticle: Haria\nyear: Urtea\nfederated: Federatua\nrestore: Berrezarri\nsend: Bidali\nsidebar: Albo-barra\ndynamic_lists: Zerrenda dinamikoak\ntoolbar.strikethrough: Marratua\nblock: Blokeatu\nunblock: Desblokeatu\noauth2.grant.moderate.magazine_admin.create: Aldizkari berriak sortu.\noauth2.grant.entry.create: Hari berriak sortu.\nsize: Tamaina\nshow_thumbnails: Miniaturak erakutsi\nflash_magazine_edit_success: Aldizkaria arrakastaz editatu da.\nban: Debekatu\nunban: Debekua kendu\nban_hashtag_btn: Hashtag debekatu\nbans: Debekuak\nicon: Ikono\npreview: Aurrebista\ncontact_email: Harremanetarako helbide elektronikoa\nadmin_panel: Administrazio-panela\ninstance: Instantzia\ntype_search_term: Bilaketa-termino bat idatzi\nactive_users: Pertsona aktiboak\nrelated_magazines: Aldizkari erlazionatuak\nbanned_instances: Instantzia debekatuak\nkbin_intro_title: Esploratu Fedibertsoa\nkbin_promo_title: Sortu zure instantzia\nrandom_magazines: Ausazko aldizkariak\nheader_logo: Goiburuko logotipoa\nfilter.adult.hide: Ezkutatu NSFW\nfilter.adult.show: Erakutsi NSFW\nfilter.adult.only: NSFW bakarrik\nlocal_and_federated: Bertakoa eta federatua\ntoolbar.image: Irudia\naccount_deletion_button: Ezabatu kontua\nmod_log: Moderazio-erregistroa\ncontact: Kontaktua\nemail_verify: Baieztatu helbide elektronikoa\nshare_on_fediverse: Partekatu Fedibertsoan\nedit_entry: Haria editatu\ncopy_url_to_fediverse: Kopiatu URL originala\nedit_comment: Aldaketak gorde\ndelete: Ezabatu\nedit_post: Posta editatu\nappearance: Itxura\nprivacy: Pribatutasuna\nnew_email_repeat: Baieztatu posta elektroniko berria\nerror: Errorea\nback: Atzera\nserver_software: Zerbitzariaren softwarea\nsearch_type_all: Hariak + Mikroblogak\nshow_new_icons_help: Erakutsi ikonoa aldizkari/erabiltzaile berriarentzat (30 \n  egun edo berriagoak)\nsettings: Doikuntzak\nblocked: Blokeatuta\nsubscribers_count: '{0}Harpidedunak|{1}Harpidedun|]1,Inf[ Harpidedunak'\nmicroblog: Mikrobloga\n"
  },
  {
    "path": "translations/messages.fi.yaml",
    "content": "people: Ihmiset\ntype.video: Video\nthread: Ketju\nmicroblog: Mikroblogi\nthreads: Ketjut\nadd: Lisää\nsearch: Haku\ncommented: Kommentoitu\nup_votes: Tehostukset\nmore: Lisää\nadded: Lisätty\ncomments: Kommentit\ncreated_at: Luotu\nmod_log: Moderointiloki\nposts: Viestit\nreplies: Vastaukset\nmoderators: Moderaattorit\nreply: Vastaa\nlogin_or_email: Käyttäjätunnus tai sähköpostiosoite\npassword: Salasana\ndont_have_account: Eikö sinulla ole tiliä?\nyou_cant_login: Unohditko salasanasi?\nalready_have_account: Onko sinulla jo tili?\nregister: Rekisteröidy\nreset_password: Palauta salasana\nusername: Käyttäjätunnus\nrepeat_password: Toista salasana\nabout_instance: Tietoja\nadd_new_article: Lisää uusi ketju\nadd_new_link: Lisää uusi linkki\nadd_new_photo: Lisää uusi kuva\nadd_new_post: Lisää uusi viesti\nadd_new_video: Lisää uusi video\ncontact: Yhteydenotto\nterms: Käyttöehdot\nprivacy_policy: Tietosuojakäytäntö\nfaq: UKK\nrss: RSS\nchange_theme: Vaihda teema\ncheck_email: Tarkista sähköpostisi\nup_vote: Tehosta\nemail_confirm_expire: Huomioi, että linkki vanhenee tunnissa.\nimage: Kuva\nimage_alt: Kuvan vaihtoehtoinen teksti\nname: Nimi\ndescription: Kuvaus\nrules: Säännöt\nsubscriptions: Tilaukset\nuser: Käyttäjä\npeople_local: Paikallinen\nsubscribed: Tilattu\npeople_federated: Federoitu\nall: Kaikki\nlogout: Kirjaudu ulos\nclassic_view: Klassinen näkymä\ncompact_view: Kompakti näkymä\n3h: 3 tuntia\n6h: 6 tuntia\n12h: 12 tuntia\n1w: 1 viikko\n1m: 1 kuukausi\n1y: 1 vuosi\nshare: Jaa\nedit: Muokkaa\nare_you_sure: Oletko varma?\nshare_on_fediverse: Jaa fediversessä\nedit_entry: Muokkaa ketjua\nedit_comment: Tallenna muutokset\nedit_post: Muokkaa viestiä\nnotifications: Ilmoitukset\nblocked: Estetty\nprivacy: Yksityisyys\nnew_password_repeat: Vahvista uusi salasana\nchange_email: Vaihda sähköpostiosoite\nchange_password: Vaihda salasana\nexpand: Laajenna\ntheme: Teema\ndark: Tumma\nlight: Vaalea\nerror: Virhe\ndefault_theme: Oletusteema\ndefault_theme_auto: Vaalea/tumma (havaitse automaattisesti)\nsolarized_auto: Solarized (havaitse automaattisesti)\nboosts: Tehostukset\nyes: Kyllä\nno: Ei\nrounded_edges: Pyöristetyt reunat\nshow_thumbnails: Näytä pikkukuvat\nshow_users_avatars: Näytä käyttäjien avatarit\nread_all: Lue kaikki\ninfinite_scroll: Loputon vieritys\nshow_top_bar: Näytä yläpalkki\noff: Pois\nupload_file: Lähetä tiedosto\ninstances: Instanssit\nfederation: Federointi\nfilters: Suodattimet\nicon: Kuvake\ndone: Valmis\npin: Kiinnitä\nunpin: Poista kiinnitys\nchange_language: Vaihda kieli\npinned: Kiinnitetty\nusers: Käyttäjät\ncontent: Sisältö\nyear: Vuosi\nlocal: Paikallinen\ninstance: Instanssi\npages: Sivut\nFAQ: UKK\nadmin_panel: Ylläpitäjän paneeli\nregistrations_enabled: Rekisteröinti käytössä\nrandom_entries: Satunnaiset ketjut\nactive_users: Aktiiviset ihmiset\nauto_preview: Median automaattinen esikatselu\nbanned_instances: Estetyt instanssit\ncaptcha_enabled: Captcha käytössä\nboost: Tehosta\nreturn: Palaa\nbrowsing_one_thread: Selaat vain yhtä ketjua keskustelussa! Kaikki kommentit ovat\n  näkyvillä viestisivulla.\ninfinite_scroll_help: Lataa lisää sisältöä automaattisesti, kun saavutat sivun alalaidan.\nreload_to_apply: Lataa sivu uudelleen, jotta muutokset tulevat voimaan\nauto_preview_help: Laajenna automaattisesti median esikatselut.\nfilter.fields.only_names: Vain nimet\nfilter.fields.names_and_descriptions: Nimet ja kuvaukset\nkbin_bot: Mbin-agentti\ntoolbar.strikethrough: Yliviivaus\ntoolbar.code: Koodi\ntoolbar.unordered_list: Järjestämätön luettelo\ntoolbar.ordered_list: Järjestetty luettelo\naccount_deletion_title: Tilin poistaminen\naccount_deletion_button: Poista tili\ncustom_css: Mukautettu CSS\nblock: Estä\nunblock: Poista esto\nschedule_delete_account: Ajasta poistaminen\ntwo_factor_backup: Kaksivaiheisen todennuksen varmistuskoodit\nshow_subscriptions: Näytä tilaukset\nclose: Sulje\nflash_email_failed_to_sent: Sähköpostia ei voitu lähettää.\nflash_user_edit_profile_success: Käyttäjäprofiilin asetukset tallennettu.\nflash_user_edit_email_error: Sähköpostiosoitteen vaihtaminen epäonnistui.\nflash_user_edit_password_error: Salasanan vaihtaminen epäonnistui.\nedit_my_profile: Muokkaa omaa profiilia\nall_time: Kaikelta ajalta\nshow: Näytä\nback: Takaisin\nlast_updated: Viimeksi päivitetty\nand: ja\nnotification_title_new_reply: Uusi vastaus\nnotification_title_new_thread: Uusi ketju\nserver_software: Palvelinohjelmisto\nversion: Versio\nuser_verify: Aktivoi tili\nmax_image_size: Tiedoston enimmäiskoko\ntype.link: Linkki\ntype.photo: Kuva\ntype.article: Ketju\nselect_channel: Valitse kanava\nevents: Tapahtumat\nlogin: Kirjaudu sisään\nfavourites: Suosikit\nsubscribers: Tilaajat\nhot: Kuumat\noldest: Vanhimmat\nsort_by: Järjestä\ntop: Parhaimmat\nactive: Aktiivinen\nnewest: Uusimmat\nno_comments: Ei kommentteja\nfavourite: Suosi\nadd_comment: Lisää kommentti\nremove_media: Poista media\nempty: Tyhjä\nunfollow: Lopeta seuraaminen\nowner: Omistaja\nadd_post: Lisää viesti\nenter_your_post: Kirjoita viestisi\nsubscribe: Tilaa\nfollow: Seuraa\nadd_media: Lisää media\nenter_your_comment: Kirjoita kommenttisi\nunsubscribe: Lopeta tilaus\nemail: Sähköpostiosoite\nremember_me: Muista minut\nshow_more: Näytä lisää\nstats: Tilastot\nphotos: Kuvat\nemail_verify: Vahvista sähköpostiosoite\ntry_again: Yritä uudelleen\nemail_confirm_content: 'Olethan valmis aktivoidaksesi Mbin-tilisi? Napsauta linkkiä\n  alla:'\nemail_confirm_header: Hei! Vahvista sähköpostiosoitteesi.\nemail_confirm_title: Vahvista sähköpostiosoitteesi.\nadd_new: Lisää uusi\nnew_email: Uusi sähköpostiosoite\nnew_password: Uusi salasana\ncollapse: Supista\nlinks: Linkit\narticles: Ketjut\nYour account is not active: Tilisi ei ole aktiivinen.\nvideos: Videot\nmessages: Viestit\nsave: Tallenna\nhomepage: Kotisivu\nhide_adult: Piilota NSFW-sisältö\ncurrent_password: Nykyinen salasana\ndelete: Poista\nprofile: Profiili\nold_email: Nykyinen sähköpostiosoite\nnew_email_repeat: Vahvista uusi sähköpostiosoite\nfont_size: Fontin koko\nmenu: Valikko\nsettings: Asetukset\nappearance: Ulkoasu\ngeneral: Yleinen\nsize: Koko\nshow_all: Näytä kaikki\nflash_register_success: Tervetuloa mukaan! Tilisi on nyt rekisteröity. Vielä yksi\n  asia - tarkista sähköpostisi ja napsauta vastaanottamaasi aktivointilinkkiä saattaaksesi\n  tilisi eloon.\ncreated: Luotu\nexpired_at: Vanheni\nfirstname: Etunimi\nsidebar_position: Sivupalkin sijainti\nleft: Vasen\nright: Oikea\non: Päällä\nstatus: Tila\nexpires: Vanhenee\nperm: Pysyvä\ndelete_account: Poista tili\ntype_search_term: Kirjoita hakuehto\nfederation_enabled: Federaatio käytössä\nPassword is invalid: Salasana on virheellinen.\nsend: Lähetä\nsidebar: Sivupalkki\nkbin_intro_title: Selaa fediverseä\nkbin_promo_title: Luo oma instanssi\nmercure_enabled: Mercure käytössä\nreport_issue: Ilmoita ongelmasta\nfilter.adult.show: Näytä NSFW\nkbin_promo_desc: '%link_start%Kloonaa tietovarasto%link_end% ja kehitä fediverseä'\ntoolbar.image: Kuva\nfilter.adult.hide: Piilota NSFW\nfilter.adult.only: Vain NSFW\ntoolbar.link: Linkki\naccount_deletion_immediate: Poista välittömästi\nlocal_and_federated: Paikallinen ja federoitu\nagree_terms: Hyväksy %terms_link_start%käyttöehdot%terms_link_end% ja %policy_link_start%tietosuojakäytäntö%policy_link_end%\nreason: Syy\npreview: Esikatselu\narticle: Ketju\nreputation: Maine\ntoolbar.bold: Lihavointi\ntoolbar.italic: Kursivointi\ntoolbar.quote: Lainaus\noauth.consent.allow: Salli\noauth.consent.deny: Estä\nflash_post_pin_success: Viesti on kiinnitetty.\ntwo_factor_authentication: Kaksivaiheinen todennus\n2fa.authentication_code.label: Todennuskoodi\n2fa.code_invalid: Todennuskoodi ei ole kelvollinen\n2fa.verify: Vahvista\ncancel: Peruuta\npassword_and_2fa: Salasana ja 2FA\nflash_email_was_sent: Sähköposti on lähetetty.\npage_width: Sivun leveys\nsensitive_show: Napsauta näyttääksesi\nflash_user_settings_general_success: Käyttäjäasetukset tallennettu.\nsensitive_hide: Napsauta piilottaaksesi\nhide: Piilota\ncake_day: Kakkupäivä\nshow_active_users: Näytä aktiiviset käyttäjät\nnotification_title_new_comment: Uusi kommentti\nrandom_posts: Satunnaiset viestit\n1d: 1 päivä\npage_width_fixed: Kiinteä\nkbin_intro_desc: on hajautettu alusta sisällön yhteenkokoamiseen sekä mikrobloggaukseen,\n  ja se toimii osana Fediverse-verkkoa.\nshow_profile_subscriptions: Näytä makasiinitilaukset\nshow_related_entries: Näytä satunnaisia ketjuja\nshow_related_posts: Näytä satunnaisia viestejä\nfilter_by_federation: Suodata federoinnin tilan mukaan\nfilter_by_time: Suodata ajan mukaan\nsubscribers_count: '{0}tilaajaa|{1}tilaaja|]1,Inf[ tilaajaa'\nmarked_for_deletion: Merkitty poistettavaksi\nmarked_for_deletion_at: Merkitty poistettavaksi %date%\nsubscriptions_in_own_sidebar: Erillisessä sivupalkissa\nsidebars_same_side: Sivupalkit samalla puolella\ntype.magazine: Makasiini\nmagazine: Makasiini\nmagazines: Makasiinit\nchange_view: Vaihda näkymää\ncomments_count: '{0}kommenttia|{1}kommentti|]1,Inf[ kommenttia'\nfollowers_count: '{0}seuraajaa|{1}seuraaja|]1,Inf[ seuraajaa'\nfilter_by_type: Suodata tyypin mukaan\nfilter_by_subscription: Suodata tilauksen mukaan\nmarkdown_howto: Kuinka muokkain toimii?\nrelated_posts: Asiaan liittyvät viestit\nalways_disconnected_magazine_info: Tämä makasiini ei vastaanota päivityksiä.\nsubscribe_for_updates: Tilaa vastaanottaaksesi päivityksiä.\nall_magazines: Kaikki makasiinit\ncreate_new_magazine: Luo uusi makasiini\nuseful: Hyödyllinen\ndown_vote: Vähennä\nselect_magazine: Valitse makasiini\nfollowers: Seuraajat\ngo_to_content: Siirry sisältöön\ngo_to_filters: Siirry suodattimiin\ngo_to_search: Siirry hakuun\ntree_view: Puunäkymä\nchat_view: Keskustelunäkymä\ntable_view: Taulukkonäkymä\ncards_view: Korttinäkymä\ncopy_url: Kopioi Mbin-osoite\ncopy_url_to_fediverse: Kopioi alkuperäinen osoite\nshow_magazines_icons: Näytä makasiinien kuvakkeet\nsubject_reported: Sisällöstä on ilmoitettu.\nchange_magazine: Vaihda makasiinia\nrelated_magazines: Aiheeseen liittyvät makasiinit\nrandom_magazines: Satunnaiset makasiinit\ncomment_reply_position: Kommentin vastauksen sijainti\nsubscription_header: TIlatut makasiinit\nposition_bottom: Alhaalla\nposition_top: Ylhäällä\nflash_account_settings_changed: Tilisi asetukset on muutettu onnistuneesti. Sinun\n  täytyy kirjautua uudelleen sisään.\nsubscription_sort: Järjestä\nalphabetically: Aakkosjärjestys\nsubscription_sidebar_pop_out_right: Siirrä erilliseen sivupalkkiin oikealle\nsubscription_sidebar_pop_out_left: Siirrä erilliseen sivupalkkiin vasemmalle\nflash_user_edit_profile_error: Profiilin asetusten tallentaminen epäonnistui.\nflash_user_settings_general_error: Käyttäjän asetusten tallentaminen epäonnistui.\nmagazine_deletion: Makasiinin poisto\ndelete_magazine: Poista makasiini\nrestore_magazine: Palauta makasiini\nopen_url_to_fediverse: Avaa alkuperäinen osoite\nmagazine_is_deleted: Makasiini on poistettu. Voit <a href=\"%link_target%\">palauttaa</a>\n  sen 30 päivän sisällä.\nrequest_magazine_ownership: Pyydä makasiinin omistajuutta\naccept: Hyväksy\nabandoned: Hylätty\ncancel_request: Peru pyyntö\nsso_registrations_enabled: Kertakirjautumisrekisteröinnit käytössä\nedited: muokattu\nkeywords: Avainsanat\nnotification_title_mention: Sinut mainittiin\nnotification_title_removed_comment: Kommentti poistettiin\nnotification_title_edited_comment: Kommenttia muokattiin\nnotification_title_removed_thread: Ketju poistettiin\nnotification_title_edited_thread: Ketjua muokattiin\nshow_related_magazines: Näytä satunnaisia makasiineja\nnew_user_description: Tämä käyttäjä on uusi (aktiivinen alle %days% päivää)\nnew_magazine_description: Tämä makasiini on uusi (aktiivinen alle %days% päivää)\nhidden: Piilotettu\nenabled: Käytössä\ntitle: Otsikko\nis_adult: 18+ / NSFW\noverview: Yleisnäkymä\ncolumns: Sarakkeet\njoined: Liittynyt\nrejected: Hylätty\nhelp: Tuki\nurl: URL-osoite\nbody: Sisältö\ntags: Tunnisteet\ntag: Tunniste\ncards: Kortit\nfeatured_magazines: Esittelyssä olevat makasiinit\nvotes: Äänet\nreject: Hylkää\napprove: Hyväksy\napproved: Hyväksytty\ndashboard: Kojelauta\nregistration_disabled: Rekisteröinti poistettu käytöstä\npassword_confirm_header: Vahvista salasanan vaihtopyyntö.\nnotification_title_new_report: Uusi raportti luotiin\ncomment_not_found: Kommenttia ei löydy\ntable_of_contents: Sisällysluettelo\n"
  },
  {
    "path": "translations/messages.fil.yaml",
    "content": "add: Magdagdag\npeople: Mga tao\noldest: Pinakaluma\nowner: May-ari\nreplies: Mga tugon\nsort_by: Isaayos ayon sa\nmore: Higit pa\nfavourites: Mga paborito\nnewest: Pinakabago\nsearch: Maghanap\nadded: Idinagdag\nshow_more: Ipakita ang higit pa\nin: sa\nrules: Mga patakaran\nfollowers: Mga tagasunod\nfollowing: Sinusundan\ngo_to_content: Pumunta sa nilalaman\nall: Lahat\n3h: 3o\nreport: Iulat\nreason: Dahilan\nreports: Mga pagulat\ndark: Madilim\nlight: Maliwanag\ntitle: Pamagat\ntry_again: Subukang muli\nfrom: mula\nabout_instance: Tungkol dito\nhelp: Tulong\nadd_new: Magdagdag ng bago\n6h: 6o\n1m: 1b\n1y: 1t\n12h: 12o\n1w: 1l\n1d: 1a\nsave: IImbak\nsubscription_sort: Isaayos\nup_votes: Mga pagpalakas\ndown_votes: Mga pagpahina\ncreated_at: Ginawa\ncomments: Mga Puna\nadd_comment: Magdagdag ng puna\nenter_your_comment: Ipasok ang iyong puna\nlogin: Mag-log in\nactive: Aktibo\nno_comments: Walang mga puna\nmoderators: Mga tagatimpi\nmod_log: Talaan ng pagtitimpi\nadd_post: Magdagdag ng post\nactivity: Aktibidad\ncover: Pangtakip\nempty: Walang laman\nfollow: Sundan\nunfollow: Huwag sundan\nreply: Tumugon\nregister: Mag-rehistro\nto: sa\nup_vote: Palakasin\ndown_vote: Pahinain\ncopy_url: Kopyahin ang URL sa Mbin\ncopy_url_to_fediverse: Kopyahin ang orihinal na URL\ndelete: Burahin\nabout: Tungkol dito\noff: Nakapatay\nreject: Tanggihan\ndone: Tapos na\nchange: Baguhin\npreview: Paunang tingin\ncontent: Nilalaman\nweek: Linggo\nweeks: (na) linggo\nmonth: Buwan\nmonths: (na) buwan\nyear: Taon\nsend: Ipadala\ndelete_account: Tanggalin ang account\npurge_account: Purgahin ang account\nreturn: Bumalik\nfollowers_count: '{0}Mga tagasunod|{1}Tagasunod|]1,Inf[Mga tagasunod'\nmarked_for_deletion: Naka-marka para sa pagbura\nmarked_for_deletion_at: Naka-marka para sa pagbura sa %date%\nenter_your_post: Ipasok ang iyong post\ncomments_count: '{0}Mga puna|{1}Puna|]1,Inf[ Mga puna'\nnotifications: Mga abiso\nblocked: Hinarangan\nshow_profile_followings: Ipakita ang mga sinusundan na tagagamit\nexpand: Palakihin\nsize: Laki\nleft: Kaliwa\nright: Kanan\non: Pinagana\nban: Bawalan\nrejected: Tinatanggihan\nbans: Mga pagbawal\ncreated: Ginawa\npages: Mga pahina\nYour account is not active: Hindi aktibo ang iyong account.\nban_account: Bawalan ang account\nYour account has been banned: Binabawalan na ang iyong account.\nmercure_enabled: Pinagana ang Mercure\nfilter.adult.show: Ipakita ang NSFW\nfilter.adult.only: NSFW lamang\nfilter.adult.hide: Itago ang NSFW\nfilter.fields.names_and_descriptions: Mga pamagat at paglalarawan\nfilter.fields.only_names: Mga pamagat lamang\nyour_account_has_been_banned: Binabawalan na ang iyong account\nerrors.server403.title: 403 Ipinagbabawal\nerrors.server500.title: 500 Pangloob na pagkamali sa Serbiro\nblock: Harangan\noauth.consent.allow: Payagan\noauth2.grant.moderate.magazine_admin.create: Gumawa ng mga bagong magasin.\noauth2.grant.moderate.magazine_admin.delete: Burahin ang ilan sa mga magasin na \n  pinag-aari mo.\noauth2.grant.moderate.magazine_admin.all: Gawin, baguhin, o burahin ang mga \n  magasin na pinag-aari mo.\nmoderation.report.reject_report_title: Tanggihan ang Ulat\nmoderation.report.ban_user_description: Nais mo bang bawalan ang tagagamit \n  (%username%) na gumagawa ng nilalaman na ito mula sa magasin na ito?\nsubject_reported_exists: Inuulat na ang nilalaman na ito.\npurge_content: Purgahin ang nilalaman\nmoderation.report.approve_report_confirmation: Sigorado ka bang nais mo na \n  aprubahin ang ulat na ito?\nban_hashtag_btn: Bawalan ang Hashtag\nerrors.server429.title: 429 Masyadong Maraming mga Hiling\nerrors.server404.title: 404 Hindi nakita\noauth.consent.to_allow_access: Upang payagan ang access, pindutin ang pindutang \n  'Payagan' sa ilalim\nmoderation.report.approve_report_title: Aprubahin ang Ulat\nmoderation.report.ban_user_title: Bawalan ang Tagagamit\ndelete_content: Tanggalin ang nilalaman\noauth.consent.deny: Tanggihan\ntype.magazine: Magasin\nmagazine: Magasin\nmagazines: Mga magasin\ndont_have_account: Wala bang account?\nyou_cant_login: Nakalimutan ang password?\nrepeat_password: Ulitin ang password\nall_magazines: Lahat ng mga magasin\ncreate_new_magazine: Gumawa ng bagong magasin\nchange_theme: Palitan ang tema\nselect_magazine: Pumili ng magasin\njoined: Sumali noong\nlogout: Mag log out\nshare_on_fediverse: Ibahagi sa Fediverse\nedit: Baguhin\nare_you_sure: Sigurado ka ba?\nshare: Ibahagi\nvotes: Mga boto\nyes: Oo\nno: Hindi\nsubject_reported: Iniulat na ang nilalaman na ito.\nonline: Nasa linya\nmarkdown_howto: Paano gumagana ang editor?\nrandom_posts: Pasadyang mga post\nrelated_posts: Kaugnay na mga post\nremember_me: Tandaan ako\nterms: Mga tuntunin ng serbisyo\nstats: Istatistika\nbody: Katawan\nname: Pamagat\ndescription: Paglalarawan\ngo_to_search: Pumunta sa paghahanap\nmessages: Mga mensahe\nfeatured_magazines: Itinatampok na mga magasin\nnotify_on_new_post_reply: Mga tugon ng anumang antas sa mga post na inaakda ko\nnotify_on_new_post_comment_reply: Mga tugon sa aking puna sa anumang mga post\ntheme: Tema\nboosts: Mga pagpalakas\nread_all: Basahin lahat\nflash_magazine_edit_success: Matagumpay na nabago ang magasin na ito.\nbanned: Binawalan ka\nsend_message: Magpadala ng direktang mensahe\nmessage: Mensahe\nstatus: Katayuan\nchange_language: Baguhin ang wika\nchange_magazine: baguhin ang magasin\nmark_as_adult: Markahin bilang NSFW\nregistrations_enabled: Pinagana ang pagrehistro\nregistration_disabled: Nakapatay ang pagrehistro\nreport_issue: Iulat ang isyu\nkbin_bot: Ahente sa Mbin\naccount_deletion_title: Pagbura ng account\naccount_deletion_button: Burahin ang account\nmore_from_domain: Higit pa mula sa domain\nsingle_settings: Pang-isahan\ncontinue_with: Magpatuloy gamit ang\npurge: Purgahin\nshow_all: Ipakita lahat\ndeleted: Tinanggal ng may-akda\nmentioned_you: Binabanggit sa iyo\nfilter_by_type: Salain ayon sa uri\nalphabetically: Paalpabetiko\ncancel: Kanselahin\nposition_top: Itaas\npending: Nakabinbin\nclose: Isara\ndirect_message: Direktang mensahe\ntop: Nangunguna\nedited_post: Binago ang post\nedited_comment: Binago ang puna\nlast_active: Huling Aktibo\ncomment_reply_position: Posisyon ng tugon sa puna\nposition_bottom: Ibaba\nedited: binago\nand: at\nback: Bumalik\nmod_remove_your_post: Binura ang iyong post ng isang tagatimpi\nmoderation.report.reject_report_confirmation: Sigurado ka bang tanggihan ang \n  ulat na ito?\nopen_url_to_fediverse: Buksan ang orihinal na URL\ndeletion: Pagbura\ndelete_magazine: Burahin ang magasin\ndetails: Mga detalye\ndeleted_by_author: Binura ng may-akda ang [thread], [post] o puna\nreport_accepted: Natanggap ang isang ulat\nown_content_reported_accepted: Natanggap ang isang ulat ng iyong nilalaman.\nown_report_accepted: Natanggap ang iyong ulat\nown_report_rejected: Naitanggi ang iyong ulat\nreported_user: Iniulat na tagagamit\nreporting_user: Paguulat sa tagagamit\nlast_successful_receive: Huling matagumpay na pagtanggap\nnotification_title_message: Bagong direktang mensahe\nhide: Itago\nshow: Ipakita\npurge_magazine: Purgahin ang magasin\nsensitive_hide: Pindutin upang itago\nsensitive_show: Pindutin upang ipakita\nsensitive_warning: Sensitibong nilalaman\nnotification_title_mention: Binabanggit ka\nnotification_title_edited_comment: Nabago ang puna\nnotification_title_new_reply: Bagong tugon\nnotification_title_removed_comment: Natanggal ang puna\nnotification_title_ban: Binabawalan ka\nmax_image_size: Pinakamataas na laki ng file\nhidden: Nakatago\nenabled: Pinagana\nnotification_title_new_report: Nagawa ang isang bagong ulat\nnotification_title_edited_post: Nabago ang isang post\nnotification_title_removed_post: Natanggal ang isang post\nmagazine_posting_restricted_to_mods_warning: Mga tagatimpi lamang ang gumagawa \n  ng mga thread sa magasin na ito\nopen_report: nakabukas na ulat\nalready_have_account: Mayroon ka na bang account?\neng: ENG\ncomment_not_found: Hindi nakita ang puna\nsubscription_header: Mga naka-subscribe na mga magasin\nflash_user_settings_general_success: Matagumpay na nakaimbak ang mga setting ng \n  tagagamit.\nsubscribed: Naka-subscribe\nold_email: Kasalukuyang email\nrestored_comment_by: binalik ang puna ni\nshow_related_magazines: Ipakita ang mga pasadyang magasin\nhot: Patok\nreset_check_email_desc2: Kapag hindi ka nakatanggap ng \"e-mail\" mangyaring \n  tingnan ang folder ng spam.\nsettings: Mga setting\nhide_adult: Itago ang nilalaman na NSFW\nnotify_on_new_posts: Bagong mga post sa anumang magasin sa saan ako \n  naka-subscribe\nwriting: Pagsusulat\nfirstname: Unang pangalan\nactive_users: Mga aktibong tao\nboost: Palakasin\nall_time: Lahat ng oras\nrandom_magazines: Mga pasadyang magasin\nrestore: Ibalik\nflash_user_settings_general_error: Nabigong iimbak ang mga setting ng tagagamit.\ndisconnected_magazine_info: Hindi tumatangap ng mga update ang magasin na ito \n  (huling aktibidad noong %days% (na) araw ang nakalipas).\nalways_disconnected_magazine_info: Hindi tumatangap ng mga update ang magasin na\n  ito.\nedit_comment: Iimbak ang nga pagbabago\nnotify_on_new_entry_reply: Mga puna sa anumang antas sa mga thread na \n  pinag-akdaan ko\ncollapse: Paliitin\nremoved_thread_by: tinanggal ang thread ni\nrestored_thread_by: ibinalik ang thread ni\nremoved_comment_by: tinanggal ang puna ni\nremoved_post_by: tinanggal ang post ni\nrestored_post_by: ibinalik ang post ni\nhe_banned: binawalan\nflash_register_success: Maligayang paglalakbay! Nakarehistro na ang iyong \n  account. Isang huling hakbang - tingnan ang iyong inbox para sa isang link ng \n  pag-activate na magbibigay-buhay sa iyong account.\nmod_deleted_your_comment: Tinanggal ng isang tagatimpi ang iyong puna\nadded_new_post: Idinagdag ang bagong post\nadded_new_reply: Idinagdag ang bagong tugon\nwrote_message: Isinulat ang isang mensahe\nremoved: Tinanggal ng tagatimpi\ncomment: Puna\nreplied_to_your_comment: Tinutugon sa iyong puna\nadded_new_comment: Idinagdag ang isang bagong puna\nmod_remove_your_thread: Tinanggal ng isang tagatimpi ang iyong thread\nedited_thread: Binago ang thread\nadded_new_thread: Idinagdag ang isang bagong puna\nmod_log_alert: BABALA - Maaaring naglalaman ang talaang ito ng hindi kasiya-siya\n  o nakakabagabag na nilalaman na tinanggal ng mga tagatimpi. Mangyaring \n  mag-ingat.\nkbin_promo_title: Gumawa ng iyong sariling instance\nfilter.origin.label: Piliin ang pinagmulan\nfilter_labels: Salain ang mga label\nauto_preview: Automatikong paunang tigin ng media\nbookmark_add_to_list: Idagdag ang bookmark sa %list%\nbookmark_add_to_default_list: Idagdag ang bookmark sa pangunahing talaan\nmagazine_posting_restricted_to_mods: Limitahin ang paggawa ng thread sa mga \n  tagatimpi\nnotification_title_new_signup: Nakarehisto ang isang bagong tagagamit\nnotification_body_new_signup: Nagrehisto ang tagagamit na si %u%.\nnotification_body2_new_signup_approval: Kailangan mong tanggapin ang hiling bago\n  sila maka-log-in\nnotification_title_edited_thread: Nabago ang isang thread\nlast_updated: Huling nabago\nsomeone: May\nedit_post: Baguhin ang post\noauth2.grant.post.report: Iulat ang anumang post.\noauth2.grant.post.create: Gumawa ng mga bagong post.\n"
  },
  {
    "path": "translations/messages.fr.yaml",
    "content": "type.article: Fil de discussion\ntype.photo: Photo\ntype.video: Vidéo\nthread: Fil\nthreads: Fils\nmicroblog: Microblogue\nevents: Événements\nmagazine: Magazine\nmagazines: Magazines\nsearch: Recherche\nadd: Ajouter\npeople: Personnes\nlogin: Se connecter\ntop: Top\nactive: Actif\nfavourites: Favoris\nfavourite: Favori\nmore: Plus\ntype.magazine: Magazine\nnewest: Plus récents\noldest: Plus anciens\ncommented: Commentés\nfilter_by_time: Filtrer par période\nfilter_by_type: Filtrer par type\ncomments_count: '{0}Commentaire|{1}Commentaire|]1,Inf[ Commentaires'\nadded: Ajouté\nno_comments: Aucun commentaire\ncreated_at: Créé\nowner: Propriétaire\nsubscribers: Abonné·e·s\nonline: En ligne\nposts: Publications\nreplies: Réponses\nmoderators: Modérateur·rice·s\nmod_log: Journal de modération\nadd_comment: Ajouter un commentaire\nadd_post: Ajouter une publication\nmarkdown_howto: Comment fonctionne l’éditeur ?\nenter_your_comment: Entrez votre commentaire\nenter_your_post: Entrez votre message\nactivity: Activité\nrelated_posts: Publications liées\ntype.link: Lien\nselect_channel: Sélectionnez une chaîne\navatar: Avatar\ncomments: Commentaires\nrandom_posts: Publications aléatoires\nchange_view: Changer la vue\nadd_media: Ajouter média\nup_votes: Partages\ndown_votes: Réductions\ncover: Couverture\nfederated_magazine_info: Les magazines provenant d’un serveur fédéré peuvent \n  être incomplets.\nfederated_user_info: Les profils provenant d’un serveur fédéré peuvent être \n  incomplets.\ngo_to_original_instance: Parcourez-en d’avantage sur l’instance d’origine.\nempty: Vide\nsubscribe: S’abonner\nunsubscribe: Se désabonner\nfollow: Suivre\nunfollow: Ne plus suivre\nreply: Répondre\nlogin_or_email: Nom d’utilisateur ou adresse e-mail\npassword: Mot de passe\nremember_me: Rester connecté·e\ndont_have_account: Vous n’avez pas de compte ?\nyou_cant_login: Mot de passe oublié ?\nalready_have_account: Vous avez déjà un compte ?\nregister: S’inscrire\nreset_password: Réinitialiser le mot de passe\nto: vers\nusername: Nom d’utilisateur\nemail: E-mail\nrepeat_password: Répéter le mot de passe\nterms: Conditions d’utilisation du service\nabout_instance: À propos\nall_magazines: Tous les magazines\nstats: Statistiques\nfediverse: Fédivers\ncreate_new_magazine: Créer un nouveau magazine\nadd_new_link: Ajouter un nouveau lien\nadd_new_photo: Ajouter une nouvelle photo\nadd_new_post: Ajouter une nouvelle publication\nadd_new_video: Ajouter une nouvelle vidéo\ncontact: Contact\nfaq: FAQ\nrss: RSS\nchange_theme: Changer le thème\nuseful: Utile\nhelp: Aide\ntry_again: Veuillez réessayer\nup_vote: Partager\ndown_vote: Réduire\nemail_verify: Confirmer l’adresse e-mail\nemail_confirm_expire: Veuillez noter que le lien expirera dans une heure.\nemail_confirm_title: Confirmez votre adresse e-mail.\nselect_magazine: Sélectionnez un magazine\nadd_new: Ajouter nouveau\nurl: URL\ntitle: Titre\nbody: Corps\ntags: Étiquettes\nbadges: Insignes\nis_adult: +18 / NSFW\nemail_confirm_content: 'Prêt pour activer votre compte Mbin ? Cliquez sur le lien\n  ci-dessous :'\noc: CO\nimage_alt: Texte alternatif de l’image\nname: Nom\ndescription: Description\nrules: Règles\ndomain: Domaine\nfollowers: Suiveur·se·s\nfollowing: Suivis\noverview: Vue d'ensemble\ncards: Cartes\ncolumns: Colonnes\nuser: Utilisateur\nmoderated: Modéré·e\npeople_local: Local\npeople_federated: Fédéré\nreputation_points: Points de réputation\nrelated_tags: Étiquettes liées\nall: Tous\nlogout: Se déconnecter\ncompact_view: Vue compacte\n3h: 3 h\n6h: 6 h\n12h: 12 h\n1d: 1 j\n1w: 1 sem\n1m: 1 m\n1y: 1 an\nlinks: Liens\narticles: Fils\nphotos: Photos\nreport: Signaler\nshare: Partager\ncopy_url: Copier l'URL de Mbin\ngo_to_content: Aller au contenu\ngo_to_filters: Aller aux filtres\nclassic_view: Vue classique\nchat_view: Vue de discussion\ntree_view: Vue arborescente\ntable_view: Vue en table\ncards_view: Vue en cartes\nmoderate: Modérer\nreason: Motif\ncopy_url_to_fediverse: Copier l'URL d'origine\nshare_on_fediverse: Partager sur le Fédivers\ndelete: Supprimer\nedit_post: Modifier le message\nedit_comment: Enregistrer les modifications\nsettings: Réglages\ngeneral: Général\nprofile: Profil\nblocked: Bloqué·e\nreports: Signalements\nnotifications: Notifications\nappearance: Apparence\nhomepage: Page d’accueil\nhide_adult: Masquer le contenu réservé aux adultes\nfeatured_magazines: Magazines en vedette\nprivacy: Confidentialité\nshow_more: Afficher plus\nadd_new_article: Ajouter un nouveau fil\ncheck_email: Vérifiez vos e-mails\neng: ENG\nsubscribed: Abonné·e\nvideos: Vidéos\nare_you_sure: Êtes-vous sûr·e ?\nreset_check_email_desc2: Si vous ne recevez pas d'e-mail, veuillez vérifier \n  votre dossier spam.\nmessages: Messages\nin: dans\nemail_confirm_header: Salut ! Confirmez votre adresse e-mail.\nimage: Image\nedit: Modifier\ngo_to_search: Aller à la recherche\nprivacy_policy: Politique de confidentialité\nsubscriptions: Abonnements\ntype.smart_contract: Contrat intelligent\nhot: Dernière minute\nagree_terms: Consentir aux %terms_link_start%conditions \n  d’utilisation%terms_link_end% et à la %policy_link_start%politique de \n  confidentialité%policy_link_end%\nreset_check_email_desc: Si un compte est déjà associé à votre adresse e-mail, \n  vous devriez recevoir sous peu un message contenant un lien que vous pourrez \n  utiliser pour réinitialiser votre mot de passe. Ce lien expirera en %expire%.\njoined: Inscrit·e\nshow_profile_subscriptions: Afficher les magazines souscrits\nshow_profile_followings: Afficher les utilisateurs abonnés\nnotify_on_new_entry_reply: Tous les commentaires dans les fils de discussion que\n  j'ai créés\nnotify_on_new_entry_comment_reply: Réponses à mes commentaires dans tous les \n  fils de discussion\nnotify_on_new_post_reply: Toutes les réponses aux messages que j'ai créés\nnotify_on_new_post_comment_reply: Réponses à mes commentaires sur les messages\nnotify_on_new_entry: Nouveaux fils de discussion (liens ou articles) dans \n  n'importe quel magazine auquel je suis abonné\nnotify_on_new_posts: Nouveaux articles dans n'importe quel magazine auquel je \n  suis abonné\nsave: Enregistrer\nabout: À propos\nold_email: E-mail actuel\nnew_email: Nouvelle adresse e-mail\nnew_email_repeat: Confirmer la nouvelle adresse e-mail\ncurrent_password: Mot de passe actuel\nnew_password: Nouveau mot de passe\nnew_password_repeat: Confirmer le nouveau mot de passe\nchange_email: Changer d'adresse e-mail\nchange_password: Changer le mot de passe\nexpand: Élargir\ncollapse: Réduire\ndomains: Domaines\nerror: Erreur\nvotes: Votes\ntheme: Thème\ndark: Sombre\nlight: Clair\nfont_size: Taille de police\nsize: Taille\nhe_unbanned: débloquer\nboosts: Partages\nyes: Oui\nno: Non\nshow_thumbnails: Afficher les vignettes\nshow_users_avatars: Afficher les avatars des utilisateurs\nban_expired: L'interdiction a expiré\nban: Bannir\nfirstname: Prénom\nbans: Interdictions\nadd_ban: Ajouter une interdiction\nreturn: Retour\nheader_logo: Logo d'entête\ncaptcha_enabled: Captcha activé\nkbin_promo_title: Créer votre propre instance\nkbin_intro_title: Explorer le Fédivers\ndynamic_lists: Listes dynamiques\nauto_preview: Aperçu automatique des médias\nsidebar: Barre latérale\nrandom_magazines: Magazines aléatoires\nrelated_magazines: Magazines reliés\nunban_account: Débloquer le compte\ndelete_account: Supprimer le compte\nrelated_entries: Fils reliés\nrandom_entries: Fils aléatoires\nactive_users: Personnes actives\nbanned_instances: Instances bannies\nban_account: Bannir le compte\nsend: Envoyer\nhe_banned: bannir\nYour account has been banned: Votre compte a été banni.\nbanned: Vous a banni\nshow_magazines_icons: Afficher les icônes des magazines\nsolarized_light: Clair solarisé\nsolarized_dark: Sombre solarisé\nrounded_edges: Bords arrondis\nremoved_thread_by: a supprimé le fil de\nrestored_thread_by: a rétabli le fil de\nremoved_comment_by: a supprimé le commentaire de\nrestored_comment_by: a rétabli le commentaire de\nremoved_post_by: a supprimé une publication de\nrestored_post_by: a rétabli la publication de\nread_all: Lire tout\nshow_all: Afficher tout\nflash_register_success: Bienvenue à bord ! Votre compte est maintenant \n  enregistré. Une dernière étape - vérifiez votre boîte de réception pour y \n  trouver un lien d'activation qui donnera vie à votre compte.\nflash_thread_new_success: Le fil a été créé et est maintenant visible par les \n  autres utilisateurs.\nflash_thread_edit_success: Le fil a été modifié.\nflash_thread_delete_success: Le fil a été supprimé.\nflash_thread_pin_success: Le fil a été épinglé.\nflash_thread_unpin_success: Le fil a été désépinglé.\nflash_magazine_edit_success: Le magazine a été modifié.\ntoo_many_requests: Limite dépassée, veuillez réessayer plus tard.\nset_magazines_bar: Barre des magazines\nset_magazines_bar_desc: ajoutez les noms de magazines après la virgule\nedited_thread: Fil modifié\nadded_new_thread: a ajouté un nouveau fil\nadded_new_comment: A ajouté un nouveau commentaire\nedited_comment: A modifié un commentaire\nreplied_to_your_comment: A répondu à un de vos commentaires\nmod_deleted_your_comment: Un(e) modérateur/trice a supprimé votre commentaire\nmod_remove_your_thread: Un(e) modérateur/trice a retiré votre fil\nadded_new_post: A ajouté un nouveau message\nedited_post: A modifié un message\nmod_remove_your_post: Un(e) modérateur/trice a retiré votre message\nadded_new_reply: A ajouté une nouvelle réponse\nremoved: Retiré par un(e) modérateur/trice\ndeleted: Supprimé par l'auteur\nmentioned_you: vous a mentionné\ncomment: Commentaire\npost: Message\npurge: Purger\nmessage: Message direct\ninfinite_scroll: Défilement infini\nshow_top_bar: Afficher la barre supérieure\nsticky_navbar: Barre de navigation collante\nsubject_reported: Le contenu a été signalé.\nleft: Gauche\nright: Droite\nfederation: Fédération\nstatus: Statut\non: On\noff: Inactif\ninstances: Instances\nupload_file: Envoyer un fichier\nfrom_url: Depuis l'URL\nmagazine_panel: Panneau de magazine\nreject: Refuser\napprove: Approuver\nfilters: Filtres\napproved: Approuvé\nrejected: Rejeté\nadd_moderator: Ajouter un(e) modérateur/trice\nadd_badge: Ajouter un badge\ncreated: Créé\nexpires: Expire\nperm: Permanent\nexpired_at: Expiré le\ntrash: Corbeille\nicon: Icône\ndone: Terminé\npin: Épingler\nflash_magazine_new_success: Le magazine a été créé. Vous pouvez désormais \n  ajouter du nouveau contenu ou explorer le panneau d’administration du \n  magazine.\nset_magazines_bar_empty_desc: si le champ est vide, les magazines actifs seront \n  affichés sur la barre.\nwrote_message: A écrit un message\nsend_message: Envoyer un message\nsidebar_position: Position de la barre latérale\nmod_log_alert: ATTENTION - Le journal de modération peut contenir des éléments \n  désagréables ou choquants qui ont été supprimés par les modérateurs. Soyez \n  prudents.\nunpin: Détacher\nchange_magazine: Changer de magazine\nchange_language: Changer de langue\nchange: Changer\npinned: Épinglé\npreview: Aperçu\narticle: Fil\nreputation: Réputation\nnote: Note\nwriting: Écriture\nusers: Utilisateurs/trices\ncontent: Contenu\nweek: Semaine\nweeks: Semaines\nmonth: Mois\nmonths: Mois\nyear: Année\nfederated: Fédéré\nlocal: Local\nadmin_panel: Panneau d'administration\ndashboard: Tableau de bord\ncontact_email: Email de contact\nmeta: Méta\ninstance: Instance\npages: Pages\nFAQ: FAQ\ntype_search_term: Tapez le terme de recherche\nfederation_enabled: Fédération active\nregistrations_enabled: Inscriptions ouvertes\nregistration_disabled: Inscriptions fermées\nrestore: Restaurer\nadd_mentions_entries: Ajouter des étiquettes dans le contenu\nadd_mentions_posts: Ajouter des étiquettes dans les messages\nPassword is invalid: Le mot de passe n'est pas valide.\nYour account is not active: Votre compte n'est pas actif.\npurge_account: Purger le compte\nmagazine_panel_tags_info: Fournissez seulement si vous voulez inclure du contenu\n  du Fédivers dans ce magazine, d'après ces étiquettes\nkbin_intro_desc: est une plate-forme décentralisée d'agrégation de contenu et de\n  microblogging qui fonctionne au sein du réseau Fédivers.\nkbin_promo_desc: '%link_start%Clonez le dépôt%link_end% et développez le Fédivers'\nbrowsing_one_thread: Vous ne parcourez qu'un seul fil de discussion ! Tous les \n  commentaires sont disponibles sur la page de publication.\nboost: Partager\nmercure_enabled: Mercure activé\nreport_issue: Signaler un problème\ntokyo_night: Nuit tokyoïte\noauth2.grant.post.edit: Editer de nouvelles publications.\noauth2.grant.moderate.post.trash: Supprimer ou restaurer des publications dans \n  vos magazines modérés.\nmoderation.report.approve_report_title: Approuver le rapport\nmoderation.report.reject_report_title: Rejeter le rapport\nkbin_bot: Robot Mbin\noauth2.grant.moderate.magazine.reports.all: Gérer les rapports dans vos \n  magazines modérés.\noauth2.grant.admin.federation.update: Ajouter ou supprimer des instances dans ou\n  à partir de la liste des instances défédérées.\nfilter.adult.label: Choisir d'afficher ou non du contenu NSFW\nresend_account_activation_email_error: Un problème est survenu lors de la \n  présentation de cette demande. Il se peut qu'aucun compte ne soit associé à \n  cet e-mail ou qu'il soit déjà activé.\noauth2.grant.user.message.all: Afficher vos messages et envoyer des messages à \n  d'autres utilisateurs.\noauth2.grant.moderate.magazine.trash.read: Afficher le contenu mis à la \n  corbeille dans vos magazines modérés.\nemail_confirm_button_text: Confirmez votre demande de modification de mot de \n  passe\noauth2.grant.moderate.magazine_admin.create: Créer de nouveaux magazines.\nfilter.adult.hide: Masquer le contenu NSFW\noauth2.grant.post.vote: Upvoter, booster, ou downvoter n'importe quelle \n  publication.\nmagazine_theme_appearance_custom_css: CSS personnalisé qui s'appliquera lors de \n  l'affichage du contenu dans votre magazine.\ntoolbar.bold: Gras\nerrors.server429.title: 429 Trop de requêtes\nauto_preview_help: Afficher automatiquement les aperçus multimédias.\nfilter.fields.label: Choisissez les champs à rechercher\ntoolbar.header: En-tête\noauth2.grant.user.oauth_clients.edit: Modifier les autorisations que vous avez \n  accordées à d'autres applications OAuth2.\noauth2.grant.user.all: Afficher et modifier votre profil, vos messages ou vos \n  notifications ; Afficher et modifier les autorisations que vous avez accordées\n  à d'autres applications ; suivre ou bloquer d'autres utilisateurs ; Afficher \n  les listes d'utilisateurs que vous suivez ou bloquez.\noauth2.grant.moderate.post.set_adult: Marquer les publications comme NSFW dans \n  vos magazines modérés.\noauth2.grant.moderate.magazine_admin.edit_theme: Modifier le CSS personnalisé de\n  l'un de vos magazines propriétaires.\noauth2.grant.moderate.magazine_admin.tags: Créer ou supprimer les tags des \n  magazines qui vous appartiennent.\nmoderation.report.ban_user_description: Voulez-vous bannir l'utilisateur \n  (%username%) qui a créé ce contenu à partir de ce magazine ?\noauth2.grant.moderate.entry.pin: Épingler des fils en haut de vos magazines \n  modérés.\noauth2.grant.user.message.read: Afficher vos messages.\noauth2.grant.admin.entry.purge: Supprimer complètement tout les fils de \n  discussion de votre instance.\noauth.consent.to_allow_access: Pour autoriser cet accès, cliquez sur le bouton «\n  Autoriser » ci-dessous\noauth2.grant.admin.magazine.all: Déplacer les fils de discussions entre les \n  magazines de votre instance ou les supprimer complètement.\nfilter.fields.only_names: Les noms seulement\noauth2.grant.admin.instance.settings.read: Afficher les paramètres de votre \n  instance.\noauth2.grant.entry.report: Signaler n'importe quel thread.\noauth2.grant.moderate.post_comment.all: Modérer les commentaires sur les \n  publications dans vos magazines modérés.\nlocal_and_federated: Local et fédéré\nemail.delete.description: L'utilisateur suivant a demandé que son compte soit \n  supprimé\nlast_active: Dernière activité\noauth2.grant.domain.subscribe: S'abonner ou se désabonner des domaines et \n  afficher les domaines auxquels vous êtes abonné.\nmagazine_theme_appearance_icon: Icône personnalisée pour le magazine. Si aucune \n  n'est sélectionnée, l'icône par défaut sera utilisée.\ntoolbar.ordered_list: Liste ordonnée\noauth2.grant.moderate.magazine.ban.create: Bannir des utilisateurs dans vos \n  magazines modérés.\noauth2.grant.admin.user.delete: Supprimer des utilisateurs sur votre instance.\noauth.consent.app_requesting_permissions: souhaite effectuer les actions \n  suivantes en votre nom\noauth2.grant.moderate.post.change_language: Changer la langue des publications \n  dans vos magazines modérés.\noauth2.grant.moderate.magazine_admin.delete: Supprimer tous les magazines qui \n  vous appartiennent.\noauth2.grant.moderate.entry_comment.all: Modérer les commentaires dans les fils \n  de discussion de vos magazines modérés.\noauth2.grant.admin.magazine.move_entry: Déplacer les fils de discussions entre \n  les magazines de votre instance.\noauth2.grant.post_comment.edit: Modifier vos commentaires existants sur les \n  publications.\noauth2.grant.entry.create: Créer de nouveaux threads.\nfederated_search_only_loggedin: Recherche fédérée limitée si vous n'êtes pas \n  connecté\noauth2.grant.moderate.magazine.reports.action: Accepter ou refuser les rapports \n  dans vos magazines modérés.\noauth2.grant.admin.magazine.purge: Supprimer complètement les magazines de votre\n  instance.\nyour_account_is_not_active: Votre compte n'a pas été activé. Veuillez consulter \n  votre adresse e-mail pour obtenir des instructions d'activation de compte ou \n  <a href=\"%link_target%\">demander un nouvel e-mail d'activation de compte.</a>\noauth2.grant.user.notification.read: Afficher vos notifications, y compris les \n  notifications de message.\nfilter.adult.show: Afficher le contenu NSFW\noauth.consent.allow: Autoriser\noauth2.grant.magazine.block: Bloquer ou débloquer les magazines et afficher les \n  magazines que vous avez bloqués.\noauth2.grant.magazine.subscribe: S'abonner ou se désabonner aux magazines et \n  afficher les magazines auxquels vous êtes abonné.\noauth2.grant.admin.user.all: Bannir, vérifier ou supprimer complètement les \n  utilisateurs de votre instance.\noauth2.grant.post_comment.report: Signaler tout commentaire sur une publication.\noauth2.grant.magazine.all: S'abonner à des magazines ou les bloquer, puis \n  afficher les magazines auxquels vous êtes abonné ou que vous bloquez.\noauth2.grant.vote.general: Upvoter, downvoter ou booster les fils de discussion,\n  les publications ou les commentaires.\ncustom_css: CSS personnalisé\noauth2.grant.entry.vote: Upvoter, booster ou downvoter pour n'importe quel fil.\noauth2.grant.moderate.magazine_admin.all: Créer, modifier ou supprimer les \n  magazines qui vous appartiennent.\ncomment_reply_position_help: Afficher le formulaire de réponse aux commentaires \n  en haut ou en bas de la page. Lorsque le « défilement infini » est activé, la \n  position apparaîtra toujours en haut.\nfilter.adult.only: Uniquement le contenu NSFW\noauth2.grant.post_comment.vote: Upvoter, booster, ou downvoter tout commentaire \n  sur une publication.\nfilter.fields.names_and_descriptions: Les noms et descriptions\noauth2.grant.user.oauth_clients.read: Afficher les autorisations que vous avez \n  accordées à d'autres applications OAuth2.\noauth2.grant.moderate.entry.change_language: Changer la langue des fils de \n  discussion dans vos magazines modérés.\npassword_confirm_header: Confirmez votre demande de modification de mot de \n  passe.\nblock: Bloquer\noauth2.grant.moderate.all: Effectuer toute action de modération que vous êtes \n  autorisé à effectuer dans vos magazines modérés.\noauth2.grant.moderate.magazine.ban.all: Gérer les utilisateurs bannis dans vos \n  magazines modérés.\noauth2.grant.moderate.magazine.all: Gérer les bannissements, les rapports et \n  afficher les articles mis à la corbeille dans vos magazines modérés.\noauth2.grant.admin.federation.all: Afficher et mettre à jour les instances \n  actuellement défédérées.\ntoolbar.quote: Citation\noauth2.grant.user.notification.all: Afficher et effacer vos notifications.\noauth2.grant.report.general: Signaler des fils de discussion, des publications \n  ou des commentaires.\noauth2.grant.moderate.magazine.list: Afficher la liste de vos magazines modérés.\noauth2.grant.admin.post.purge: Supprimer complètement de votre instance toute \n  les publications.\noauth2.grant.moderate.magazine.ban.read: Afficher les utilisateurs bannis dans \n  vos magazines modérés.\noauth2.grant.user.profile.all: Afficher et modifier votre profil.\noauth2.grant.admin.user.ban: Bannir ou annuler le bannissement des utilisateurs \n  de votre instance.\nshow_avatars_on_comments: Afficher les avatars de commentaires\noauth2.grant.admin.all: Effectuer des action administrative sur votre instance.\ntoolbar.unordered_list: Liste non ordonnée\nerrors.server404.title: 404 Page introuvable\nresend_account_activation_email_success: Si un compte associé à cet e-mail \n  existe, nous vous enverrons un nouvel e-mail d'activation.\nerrors.server403.title: 403 Accès réservé\noauth2.grant.post.report: Signaler n'importe quel message.\noauth2.grant.moderate.magazine.reports.read: Afficher les rapports dans vos \n  magazines modérés.\nignore_magazines_custom_css: Ignorer les CSS personnalisés des magazines\noauth2.grant.entry_comment.create: Créer de nouveaux commentaires dans les fils \n  de discussion.\noauth.consent.deny: Refuser\noauth2.grant.user.follow: Suivre ou ne plus suivre des utilisateurs, puis \n  afficher la liste des utilisateurs que vous suivez.\nflash_post_pin_success: La publication a été épinglée avec succès.\noauth2.grant.entry_comment.report: Signaler tout commentaire dans un fil de \n  discussion.\nmoderation.report.approve_report_confirmation: Êtes-vous sûr de vouloir \n  approuver ce rapport ?\noauth2.grant.moderate.entry.all: Modérer les discussions dans vos magazines \n  modérés.\noauth.consent.title: Formulaire de consentement OAuth2\nresend_account_activation_email: Renvoyer l'e-mail d'activation du compte\noauth2.grant.entry_comment.all: Créer, modifier ou supprimer vos commentaires \n  dans les fils de discussion, et voter, booster ou signaler tout commentaire \n  dans un fil de discussion.\noauth2.grant.entry_comment.delete: Supprimer vos commentaires existants dans les\n  fils de discussion.\nsubject_reported_exists: Ce contenu a déjà été signalé.\noauth2.grant.moderate.entry_comment.set_adult: Marquer les commentaires dans les\n  fils de discussion comme NSFW dans vos magazines modérés.\noauth2.grant.entry.edit: Modifier vos threads existants.\noauth2.grant.moderate.entry_comment.trash: Supprimer ou restaurer des \n  commentaires dans les fils de discussion de vos magazines modérés.\noauth2.grant.moderate.post.all: Modérer les publications dans vos magazines \n  modérés.\npreferred_languages: Filtrer les langues des fils de discussion et des \n  publications\nerrors.server500.title: 500 Erreur du serveur\noauth2.grant.entry_comment.edit: Modifier vos commentaires existants dans les \n  fils de discussion.\noauth2.grant.moderate.magazine_admin.update: Modifier les règles, la \n  description, le statut NSFW ou l'icône de vos magazines propriétaires.\noauth2.grant.moderate.entry.trash: Mettre à la corbeille ou restaurer les fils \n  de discussion dans vos magazines modérés.\noauth2.grant.user.oauth_clients.all: Afficher et modifier les autorisations que \n  vous avez accordées à d'autres applications OAuth2.\noauth2.grant.user.profile.read: Afficher votre profil.\ntoolbar.link: Lien\noauth2.grant.admin.oauth_clients.read: Afficher les clients OAuth2 qui existent \n  sur votre instance et leurs statistiques d'utilisation.\ntoolbar.mention: Mention\noauth2.grant.write.general: Créez ou modifiez l'un de vos fils de discussion, \n  publications ou commentaires.\nsingle_settings: Unique\noauth2.grant.moderate.post_comment.set_adult: Marquer les commentaires sur les \n  publications en tant que NSFW dans vos magazines modérés.\noauth2.grant.moderate.post_comment.change_language: Modifier la langue des \n  commentaires sur les publications dans vos magazines modérés.\nmoderation.report.ban_user_title: Bannir l'utilisateur\nfilter.origin.label: Choisissez l'origine\nresend_account_activation_email_question: Compte inactif ?\noauth2.grant.admin.user.verify: Vérifier des utilisateurs sur votre instance.\nresend_account_activation_email_description: Entrez l'adresse e-mail associée à \n  votre compte. Nous vous enverrons un autre e-mail d'activation.\nreload_to_apply: Recharger la page pour appliquer les modifications\noauth2.grant.entry.delete: Supprimer vos threads existants.\nyour_account_has_been_banned: Votre compte a été banni\noauth2.grant.admin.instance.information.edit: Mettre à jour les pages À propos, \n  FAQ, Contact, Conditions d'utilisation et Politique de confidentialité de \n  votre instance.\noauth2.grant.read.general: Lisez tout le contenu auquel vous avez accès.\noauth2.grant.domain.all: Abonnez-vous à des domaines ou bloquez-les, et affichez\n  les domaines auxquels vous vous abonnez ou que vous bloquez.\ntoolbar.code: Code\nerrors.server500.description: Désolé, quelque chose s'est mal passé de notre \n  côté. Nous travaillons à résoudre ce problème, veuillez revenir bientôt.\noauth.client_not_granted_message_read_permission: Cette application n'a pas reçu\n  l'autorisation de lire vos messages.\nrestrict_oauth_clients: Restreindre la création de clients OAuth2 aux \n  administrateurs\nflash_post_unpin_success: La publication a été désépinglée avec succès.\noauth2.grant.post_comment.delete: Supprimer vos commentaires existants sur les \n  publications.\nbot_body_content: \"Bienvenue dans le robot Mbin ! Ce robot joue un rôle crucial dans\n  l'activation de la fonctionnalité ActivityPub dans Mbin. Il garantit que Mbin peut\n  communiquer et fédérer avec d'autres instances dans le fediverse.\\n\\nActivityPub\n  est un protocole standard ouvert qui permet aux plateformes de réseaux sociaux décentralisées\n  de communiquer et d'interagir les unes avec les autres. Il permet aux utilisateurs\n  de différentes instances (serveurs) de se suivre, d'interagir et de partager du\n  contenu sur le réseau social fédéré connu sous le nom de fediverse. Il fournit aux\n  utilisateurs un moyen standardisé de publier du contenu, de suivre d'autres utilisateurs\n  et de participer à des interactions sociales telles que le fait d'aimer, de partager\n  et de commenter des fils de discussion ou des publications.\"\noauth2.grant.admin.oauth_clients.revoke: Révoquer l'accès aux clients OAuth2 sur\n  votre instance.\noauth2.grant.admin.instance.settings.edit: Mettre à jour les paramètres de votre\n  instance.\noauth2.grant.moderate.entry.set_adult: Marquer les fils de discussion comme NSFW\n  dans vos magazines modérés.\noauth2.grant.delete.general: Supprimez l'un de vos fils de discussion, \n  publications ou commentaires.\noauth2.grant.entry_comment.vote: Upvoter, booster, ou downvoter pour n'importe \n  quel commentaire dans un fil de discussion.\noauth2.grant.admin.instance.stats: Afficher les statistiques de votre instance.\noauth2.grant.admin.instance.settings.all: Afficher ou mettre à jour les \n  paramètres de votre instance.\noauth2.grant.entry.all: Créer, modifier ou supprimer vos fils de discussion, et \n  voter, booster ou signaler n'importe quel fil de discussion.\nmagazine_theme_appearance_background_image: Image d'arrière-plan personnalisée \n  qui sera appliquée lors de l'affichage du contenu de votre magazine.\nunblock: Débloquer\noauth2.grant.admin.federation.read: Consulter la liste des instances défédérées.\noauth2.grant.moderate.entry_comment.change_language: Changer la langue des \n  commentaires dans les fils de discussion de vos magazines modérés.\noauth2.grant.moderate.magazine_admin.stats: Afficher le contenu, les votes et \n  les statistiques d'affichage des magazines qui vous appartiennent.\noauth.consent.grant_permissions: Accorder les autorisations\noauth2.grant.user.message.create: Envoyer des messages à d'autres utilisateurs.\noauth2.grant.admin.oauth_clients.all: Afficher ou révoquer les clients OAuth2 \n  qui existent sur votre instance.\noauth2.grant.moderate.magazine.ban.delete: Annuler le bannissement \n  d'utilisateurs dans vos magazines modérés.\noauth.client_identifier.invalid: ID client OAuth non valide !\noauth2.grant.post_comment.all: Créer, modifier ou supprimer vos commentaires sur\n  les publications, et voter, booster ou signaler tout commentaire sur une \n  publication.\noauth2.grant.admin.user.purge: Supprimer complètement les utilisateurs de votre \n  instance.\nupdate_comment: Mettre à jour le commentaire\ninfinite_scroll_help: Chargez automatiquement plus de contenu lorsque vous \n  atteignez le bas de la page.\noauth2.grant.user.notification.delete: Effacer vos notifications.\nshow_avatars_on_comments_help: Afficher/masquer les avatars des utilisateurs \n  lorsque vous affichez des commentaires sur un seul fil de discussion ou \n  publication.\noauth2.grant.post.all: Créer, modifier ou supprimer vos microblogs, et voter, \n  booster ou signaler n'importe quel microblog.\noauth.consent.app_has_permissions: peut déjà effectuer les actions suivantes\nemail.delete.title: Demande de suppression de compte utilisateur\noauth2.grant.block.general: Bloquer ou débloquer un magazine, un domaine ou un \n  utilisateur, et afficher les magazines, les domaines et les utilisateurs que \n  vous avez bloqués.\nmoderation.report.reject_report_confirmation: Êtes-vous sûr de vouloir rejeter \n  ce rapport ?\noauth2.grant.post_comment.create: Créer de nouveaux commentaires sur les \n  publications.\noauth2.grant.user.block: Bloquer ou débloquer des utilisateurs et afficher la \n  liste des utilisateurs que vous bloquez.\noauth2.grant.post.delete: Supprimer de nouvelles publications.\noauth2.grant.subscribe.general: S'abonner ou suivre n'importe quel magazine, \n  domaine ou utilisateur, et afficher les magazines, domaines et utilisateurs \n  auxquels vous êtes abonné.\noauth2.grant.moderate.post_comment.trash: Supprimer ou restaurer des \n  commentaires sur les publications de vos magazines modérés.\noauth2.grant.moderate.magazine_admin.moderators: Ajouter ou supprimer les \n  modérateurs de l'un de vos magazines propriétaires.\nemail_confirm_link_help: Vous pouvez également copier et coller ce qui suit dans\n  votre navigateur\noauth2.grant.admin.entry_comment.purge: Supprimer complètement de votre instance\n  tout les commentaires d'un fil de discussion.\ntoolbar.strikethrough: Barré\ncomment_reply_position: Position de la réponse au commentaire\noauth2.grant.post.create: Créer de nouvelles publications.\ntoolbar.image: Image\noauth2.grant.domain.block: Bloquer ou débloquer des domaines et afficher les \n  domaines que vous avez bloqués.\noauth2.grant.admin.instance.all: Afficher et mettre à jour les paramètres ou les\n  informations de l'instance.\nsticky_navbar_help: La barre de navigation se collera en haut de la page lorsque\n  vous faites défiler vers le bas.\ntoolbar.italic: Italique\nmore_from_domain: Plus du domaine\noauth2.grant.user.profile.edit: Modifier votre profil.\noauth2.grant.admin.post_comment.purge: Supprimer complètement de votre instance \n  tout commentaire sur une publication.\noauth2.grant.moderate.magazine_admin.badges: Créer ou supprimer des badges des \n  magazines qui vous appartiennent.\noauth2.grant.moderate.post.pin: Épinglez des publications en haut de vos \n  magazines modérés.\nuser_badge_bot: Robot\nsuspend_account: Suspendre le compte\nuser_badge_op: OP\nuser_badge_admin: Admin\nannouncement: Annonce\nshow: Afficher\nhide: Masquer\nback: Retour\nversion: Version\nadmin_users_active: Actifs\n2fa.authentication_code.label: Code d'authentification\nrelated_entry: Connexes\nand: et\nedited: édité\nauto: Auto\ndelete_magazine: Supprimer le magazine\npurge_magazine: Purger le magazine\npurge_content: Purger le contenu\ndelete_content: Supprimer le contenu\nsubscription_panel_large: Grand panneau\npage_width: Largeur de la page\npage_width_max: Max\nrestore_magazine: Restaurer le magazine\ndeletion: Suppression\naccept: Accepter\naction: Action\nkeywords: Mots-clés\ndetails: Détails\nsensitive_warning: Contenu sensible\nfrom: de\ntag: Étiquette\nsomeone: Quelqu'un\ndefault_theme: Thème par défaut\n2fa.remove: Supprimer 2FA\nmenu: Menu\nabandoned: Abandonné\ncancel_request: Annuler la demande\nshow_subscriptions: Afficher les abonnements\nalphabetically: Alphabétiquement\npage_width_auto: Auto\npage_width_fixed: Fixe\nuser_badge_moderator: Mod\nreported: signalé\nreport_subject: Sujet\nunsuspend_account: Annuler la suspension du compte\nsort_by: Trier par\nfilter_by_subscription: Filtrer par abonnement\nfilter_by_federation: Filtrer par statut de fédération\nclose: Fermer\npending: En attente\nposition_bottom: En bas\nposition_top: En haut\ntwo_factor_authentication: Authentification à deux facteurs\nadmin_users_suspended: Suspendus\nadmin_users_banned: Bannis\nadmin_users_inactive: Inactifs\nenabled: Activé\ndisabled: Désactivé\nhidden: Masqué\nmagazine_deletion: Suppression de magazine\n"
  },
  {
    "path": "translations/messages.gl.yaml",
    "content": "subscribe_for_updates: Subscríbete para comezar a recibir actualizacións.\nban_hashtag_description: Ao vetar un cancelo non se crearán publicacións con \n  este cancelo, e as publicacións existentes que o conteñan serán agochadas.\nunban: Retirar veto\nban_hashtag_btn: Vetar Cancelo\nregistration_disabled: Non se permite crear novas contas\nrestore: Restablecer\nadd_mentions_entries: Engadir etiquetas de mención nos temas\nadd_mentions_posts: Engadir etiquetas de mención nas publicacións\nPassword is invalid: Contrasinal incorrecto.\nYour account is not active: A conta non está activa.\nYour account has been banned: A túa conta foi vetada.\nfirstname: Nome\nsend: Enviar\nactive_users: Persoas activas\nrandom_entries: Temas ao chou\nrelated_entries: Temas relacionados\npurge_account: Purgar conta\nban_account: Vetar conta\nunban_account: Retirar veto á conta\nrelated_magazines: Revistas relacionadas\nrandom_magazines: Revistas ao chou\nsidebar: Barra lateral\nauto_preview: Vista previa automática\ndynamic_lists: Listas dinámicas\nbanned_instances: Instancias vetadas\nkbin_intro_title: Explora o Fediverso\nkbin_intro_desc: é unha plataforma descentralizada para contidos agregados e \n  microblogs que actúa dentro da rede Fediverso.\nkbin_promo_title: Crea a túa propia instancia\nkbin_promo_desc: '%link_start%Clona o repositorio%link_end% e espalla o fediverso'\ncaptcha_enabled: Captcha activado\nheader_logo: Logo da cabeceira\nbrowsing_one_thread: Só estás a ver un dos fíos do tema! Os comentarios ao \n  completo están dispoñibles na páxina da publicación.\nmercure_enabled: Mercure activado\nreport_issue: Incidencias\ntokyo_night: Tokyo Night\nsticky_navbar_help: A barra de navegación estará fixa na parte superior da \n  páxina ao desprazarte.\nauto_preview_help: Mostra vista previa do multimedia (foto, vídeo) a tamaño \n  maior debaixo do contido.\nreload_to_apply: Recarga a páxina para aplicar os cambios\nfilter.origin.label: Elixe orixe\nfilter.fields.label: Elixe os campos nos que buscar\nfilter.adult.label: Elixe se queres mostrar contido NSFW\nfilter.adult.hide: Agochar NSFW\nfilter.adult.show: Mostrar NSFW\nfilter.adult.only: Só NSFW\nlocal_and_federated: Local e federado\nfilter.fields.only_names: Só nomes\nfilter.fields.names_and_descriptions: Nomes e descricións\npassword_confirm_header: Confirma a solicitude de cambio de contrasinal.\nyour_account_is_not_active: Non se activou a túa conta. Mira no correo para ver \n  as instruccións para activala ou <a href=\"%link_target%\">solicita un novo \n  correo para activala.</a>\ntoolbar.strikethrough: Riscada\ntoolbar.header: Cabeceira\ntoolbar.ordered_list: Lista con orde\ntoolbar.mention: Mención\nfederation_page_enabled: Páxina de federación activada\nyour_account_has_been_banned: Vetouse a túa conta\ntoolbar.bold: Grosa\ntoolbar.italic: Cursiva\nfederation_page_allowed_description: Instancias coñecidas coas que federamos\nfederation_page_disallowed_description: Instancias coas que non federamos\nfederated_search_only_loggedin: A busca federada está limitada se non inicias \n  sesión\naccount_deletion_title: Eliminación da Conta\nmore_from_domain: Máis desde o dominio\nerrors.server429.title: 429 Demasiadas Solicitudes\nerrors.server404.title: 404 Non se atopa\nerrors.server403.title: 403 Non autorizado\nemail_confirm_button_text: Confirma a solicitude de cambio de contrasinal\nemail_confirm_link_help: Ou tamén podes copiar e pegar o seguinte no teu \n  navegador\nemail.delete.title: Solicitude de eliminación da conta\nemail.delete.description: Esta usuaria solicitou que se elimine a súa conta\nresend_account_activation_email_question: Conta inactiva?\nresend_account_activation_email_error: Houbo un problema ao enviar esta \n  solicitude. Pode que non haxa unha conta asociada con este correo ou que xa \n  fose activada.\nresend_account_activation_email_success: Se existe unha conta asociada a este \n  correo, enviaremos un novo correo de activación.\nresend_account_activation_email_description: Escribe o enderezo de correo \n  asociado á conta. Enviaremosche outro correo de activación.\ncustom_css: CSS personalizado\nresend_account_activation_email: Reenviar correo de activación da conta\nignore_magazines_custom_css: Ignorar CSS personalizado das revistas\noauth.consent.title: Formulario de consentimento OAuth2\noauth.consent.grant_permissions: Conceder Permisos\noauth.consent.app_has_permissions: xa pode realizar as seguintes accións\noauth.consent.to_allow_access: Para permitir este acceso, preme no botón \n  'Permitir'\noauth.consent.allow: Permitir\noauth.consent.deny: Negar\noauth.client_identifier.invalid: ID de Cliente OAuth non válido!\noauth.client_not_granted_message_read_permission: Esta app non ten permiso para \n  ler as túas mensaxes.\nrestrict_oauth_clients: Restrinxir a creación de Clientes OAuth2 a Admins\nblock: Bloquear\nunblock: Desbloquear\noauth2.grant.moderate.magazine.ban.delete: Retirar veto a usuarias nas revistas \n  que moderas.\noauth2.grant.moderate.magazine.list: Ler a lista das revistas que moderas.\noauth2.grant.moderate.magazine.reports.all: Xestionar as denuncias nas revistas \n  que moderas.\noauth2.grant.moderate.magazine.reports.read: Ler as denuncias nas revistas que \n  moderas.\noauth2.grant.moderate.magazine.reports.action: Aceptar ou rexeitar denuncias nas\n  revistas que moderas.\noauth2.grant.moderate.magazine.trash.read: Ver contido eliminado nas revistas \n  que moderas.\noauth2.grant.moderate.magazine_admin.create: Crear novas revistas.\noauth2.grant.moderate.magazine_admin.delete: Eliminar calquera das túas propias \n  revistas.\noauth2.grant.moderate.magazine_admin.update: Editar calquera das regras das túas\n  revistas, descrición, estado NSFW ou icona.\noauth2.grant.moderate.magazine_admin.edit_theme: Editar o CSS personalizado de \n  calquera das túas revistas.\noauth2.grant.moderate.magazine_admin.moderators: Engadir ou eliminar moderadoras\n  de calquera das túas revistas.\noauth2.grant.moderate.magazine_admin.badges: Crear ou eliminar insignias das \n  túas revistas.\noauth2.grant.moderate.magazine_admin.tags: Crear ou eliminar etiquetas das túas \n  revistas.\noauth2.grant.moderate.magazine_admin.stats: Ver contido, votar, e ver \n  estatísticas das túas revistas.\noauth2.grant.admin.all: Realizar tarefas administrativas na túa instancia.\noauth2.grant.admin.entry.purge: Eliminar completamente calquera tema da túa \n  instancia.\noauth2.grant.read.general: Ler todo o contido ao que ti teñas acceso.\noauth2.grant.delete.general: Eliminar calquera dos teus temas, publicacións ou \n  comentarios.\noauth2.grant.report.general: Denunciar temas, publicacións ou comentarios.\noauth2.grant.vote.general: Voto positivo ou negativo, promover temas, \n  publicacións ou comentarios.\noauth2.grant.subscribe.general: Subscribirse ou seguir calquera revista, dominio\n  ou usuaria así como ver revistas, dominios e usuarias ás que te subscribiches.\noauth2.grant.block.general: Bloquear ou desbloquear calquera revista, dominio ou\n  usuaria, así como ver revistas, dominios e usuarias que bloqueaches.\noauth2.grant.domain.all: Subscribirse ou bloquear dominios, así como ver os \n  dominios aos que te subscribiches ou bloqueaches.\noauth2.grant.domain.subscribe: Subscribirse ou darse de baixa de dominios e ver \n  os dominios aos que te subscribiches.\noauth2.grant.domain.block: Bloquear ou desbloquear dominios e ver os dominios \n  que tes bloqueados.\noauth2.grant.entry.report: Denunciar calquera tema.\noauth2.grant.entry_comment.all: Crear, editar ou eliminar os teus comentarios en\n  temas, e votar, promover ou denunciar calquera comentario nun tema.\noauth2.grant.entry_comment.create: Crear novos comentarios en temas.\noauth2.grant.entry_comment.edit: Editar os teus comentarios existentes en temas.\noauth2.grant.entry_comment.delete: Eliminar os teus comentarios en temas.\noauth2.grant.entry_comment.vote: Voto positivo ou negativo, promoción de \n  calquera comentario nun tema.\noauth2.grant.entry_comment.report: Denunciar calquera comentario nun tema.\noauth2.grant.magazine.block: Bloquear e desbloquear revistas e ver as revistas \n  que tes bloqueadas.\noauth2.grant.post.all: Crear, editar ou eliminar microblogs, e votar, promover \n  ou denunciar calquera microblog.\noauth2.grant.post.create: Crear novas publicacións.\noauth2.grant.post.edit: Editar as túas publicacións.\noauth2.grant.magazine.subscribe: Subscribir ou dar de baixa dunha revista e ver \n  as revistas ás que te subscribiches.\noauth2.grant.post.delete: Eliminar as túas publicacións.\noauth2.grant.post.vote: Voto positivo ou negativo, ou promoción de calquera \n  publicación.\noauth2.grant.post_comment.delete: Eliminar os teus comentarios nas publicacións.\noauth2.grant.post_comment.vote: Voto positivo, promoción ou voto negativo en \n  calquera comentario nunha publicación.\noauth2.grant.user.all: Ler e editar o teu perfil, mensaxes ou notificacións; Ler\n  e editar os permisos concedidos a outras apps; seguir ou bloquear outras \n  usuarias; ver listas de usuarias que segues ou bloqueas.\noauth2.grant.user.profile.read: Ler o teu perfil.\noauth2.grant.user.profile.edit: Editar o teu perfil.\noauth2.grant.user.message.all: Ler as túas mensaxes e enviar mensaxes a outras \n  usuarias.\noauth2.grant.user.message.read: Ler as túas mensaxes.\noauth2.grant.user.message.create: Enviar mensaxes a outras usuarias.\noauth2.grant.post.report: Denunciar calquera publicación.\noauth2.grant.post_comment.all: Crear, editar ou eliminar os teus comentarios en \n  publicacións, e votar, promover ou denunciar calquera comentario nunha \n  publicación.\noauth2.grant.user.follow: Seguir e deixar de seguir usuarias, e ler a lista das \n  usuarias que segues.\noauth2.grant.user.block: Bloquear e desbloquear usuarias, e ler a lista de \n  usuarias que bloqueaches.\noauth2.grant.moderate.all: Realizar accións de moderación sobre os asuntos que \n  tes permiso nas revistas que moderas.\noauth2.grant.user.notification.all: Ler e limpar as notificacións.\noauth2.grant.moderate.entry.all: Moderar temas nas revistas que moderas.\noauth2.grant.user.notification.read: Ler as notificacións, incluíndo as \n  notificacións das mensaxes.\noauth2.grant.user.notification.delete: Limpar as notificacións.\noauth2.grant.user.oauth_clients.all: Ler e editar os permisos que concedeches a \n  outras aplicacións OAuth2.\noauth2.grant.user.oauth_clients.read: Ler os permisos que concedeches a outras \n  aplicacións OAuth2.\noauth2.grant.moderate.entry.set_adult: Marcar os temas como NSFW nas revistas \n  que moderas.\noauth2.grant.moderate.entry_comment.all: Moderar comentarios nos temas das \n  revistas que moderas.\noauth2.grant.moderate.entry.trash: Eliminar ou restablecer temas nas revistas \n  que moderas.\noauth2.grant.moderate.entry_comment.change_language: Cambiar o idioma dos \n  comentarios nos temas das revistas que moderas.\noauth2.grant.moderate.post.change_language: Cambiar o idioma das publicacións \n  nas revistas que moderas.\noauth2.grant.moderate.entry_comment.set_adult: Marcar comentarios en temas como \n  NSFW nas revistas que moderas.\noauth2.grant.moderate.post.set_adult: Marcar as publicacións como NSFW nas \n  revistas que moderas.\noauth2.grant.moderate.entry_comment.trash: Eliminar ou restablecer comentarios \n  en temas das revistas que moderas.\noauth2.grant.moderate.post.all: Moderar publicacións nas revistas que moderas.\noauth2.grant.moderate.post.trash: Eliminar ou restablecer as publicacións nas \n  revistas que moderas.\noauth2.grant.moderate.post_comment.all: Moderar comentarios nas publicacións das\n  revistas que moderas.\noauth2.grant.admin.entry_comment.purge: Eliminar completamente un comentario nun\n  tema da túa instancia.\noauth2.grant.moderate.post_comment.change_language: Cambiar os idioma dos \n  comentarios nas publicacións das revistas que moderas.\noauth2.grant.moderate.post_comment.set_adult: Marcar comentarios como NSFW nas \n  publicacións das revistas que moderas.\noauth2.grant.admin.post.purge: Eliminar completamente calquera publicación da \n  túa instancia.\noauth2.grant.admin.post_comment.purge: Eliminar completamente calquera \n  comentario nunha publicación da túa instancia.\noauth2.grant.admin.magazine.all: Mover de lugar os temas ou eliminar \n  completamente revistas da túa instancia.\noauth2.grant.moderate.post_comment.trash: Eliminar ou restablecer comentarios \n  nas publicacións das revistas que moderas.\noauth2.grant.moderate.magazine.all: Xestionar vetos, denuncias e ver elementos \n  eliminados nas revistas que moderas.\noauth2.grant.moderate.magazine.ban.all: Xestionar usuarias vetadas nas revistas \n  que moderas.\noauth2.grant.moderate.magazine.ban.read: Ver as usuarias vetadas nas revistas \n  que moderas.\noauth2.grant.moderate.magazine.ban.create: Vetar usuarias nas revistas que \n  moderas.\noauth2.grant.admin.magazine.move_entry: Mover temas entre revistas na túa \n  instancia.\noauth2.grant.admin.instance.settings.edit: Actualizar os axustes da túa \n  instancia.\noauth2.grant.admin.magazine.purge: Eliminar completamente revistas da túa \n  instancia.\noauth2.grant.admin.user.all: Vetar, verificar ou eliminar completamente usuarias\n  da túa instancia.\noauth2.grant.admin.instance.information.edit: Actualizar as PMF, Sobre, \n  Contacto, Termos do Servizo e Política de Privacidade da túa instancia.\noauth2.grant.admin.federation.all: Ver e actualizar as instancias actualmente \n  desfederadas.\noauth2.grant.admin.user.ban: Vetar ou restablecer usuarias da túa instancia.\noauth2.grant.admin.user.verify: Verificar usuarias da túa instancia.\noauth2.grant.admin.user.delete: Eliminar usuarias da túa instancia.\noauth2.grant.admin.user.purge: Eliminar completamente usuarias da túa instancia.\noauth2.grant.admin.instance.all: Ver e actualizar os axustes da instancia ou a \n  información.\noauth2.grant.admin.instance.stats: Ver estatísticas da túa instancia.\noauth2.grant.admin.instance.settings.all: Ver ou actualizar os axustes da túa \n  instancia.\noauth2.grant.admin.instance.settings.read: Ver os axustes da túa instancia.\noauth2.grant.admin.federation.read: Ver a lista das instancias desfederadas.\noauth2.grant.admin.federation.update: Engadir ou eliminar instancias da lista de\n  instancias desfederadas.\noauth2.grant.admin.oauth_clients.all: Ver ou revogar clientes OAuth2 que existan\n  na túa instancia.\noauth2.grant.admin.oauth_clients.read: Ver os clientes OAuth2 existentes na túa \n  instancia, e as súas estatísticas de uso.\noauth2.grant.admin.oauth_clients.revoke: Revogar o acceso a clientes OAuth2 na \n  túa instancia.\nlast_active: Última actividade\nflash_post_pin_success: Fixouse correctamente a publicación.\nflash_post_unpin_success: Soltouse correctamente a publicación.\ncomment_reply_position_help: Mostar a resposta ao comentario ou ben arriba ou \n  embaixo na páxina. Se activas o 'desprazamento infinito' a posición sempre \n  será arriba.\nshow_avatars_on_comments: Mostrar avatares nos comentarios\nsingle_settings: Único\ncomment_reply_position: Posición do comentario de resposta\nmagazine_theme_appearance_custom_css: CSS personalizado que se aplicará ao ver o\n  contido na túa revista.\nmagazine_theme_appearance_icon: Icona personalizada para a revista.\nmagazine_theme_appearance_background_image: Imaxe de fondo personalizada que se \n  aplicará ao ver o contido na túa revista.\ndelete_content_desc: Eliminar o contido da usuaria pero deixar os temas, \n  publicacións e comentarios de outras usuarias nos temas, publicacións e \n  comentarios creados.\npurge_content_desc: Purgar completamente o contido da usuaria, incluíndo as \n  respostas doutras usuarias nos temas, publicacións e comentarios creados.\ntwo_factor_authentication: Autenticación con dous factores\ntwo_factor_backup: Códigos de apoio do segundo factor de autenticación\n2fa.authentication_code.label: Código de Autenticación\n2fa.verify: Verificar\n2fa.code_invalid: O código de autenticación non é válido\nmoderation.report.approve_report_title: Aprobar Denuncia\nmoderation.report.reject_report_confirmation: Tes a certeza de querer rexeitar \n  esta denuncia?\noauth2.grant.moderate.post.pin: Fixar publicacións na parte superior das \n  revistas que moderas.\n2fa.enable: Configurar o segundo factor de autenticación\n2fa.disable: Desactivar o segundo factor de autenticación\n2fa.backup-create.label: Crear novos códigos de autenticación de apoio\n2fa.add: Engadir á conta\n2fa.verify_authentication_code.label: Escribe o código do segundo factor para \n  verificar\n2fa.backup: Códigos de apoio do segundo factor\n2fa.backup-create.help: Podes crear novos códigos de apoio para a autenticación;\n  ao facelo invalidarás os existentes.\n2fa.qr_code_img.alt: Un código QR que configura o segundo factor de \n  autenticación para a túa conta\n2fa.qr_code_link.title: Ao visitar esta ligazón permitirás á túa aplicación \n  rexistrar este segundo elemento de autenticación\n2fa.available_apps: Usar unha app tal que %google_authenticator%, %aegis% \n  (Android) ou %raivo% (iOS) como segundo factor para escanear o código QR.\n2fa.backup_codes.help: Podes usar estes códigs cando non tes a man a app ou \n  dispositivo de segundo factor. <strong>Non volverán a mostrarse</strong> e \n  ademáis só se pode <strong>usar unha única vez</strong> cada un.\n2fa.backup_codes.recommendation: Recomendamos que gardes unha copia dos códigos \n  nun lugar seguro.\ncancel: Cancelar\naccount_settings_changed: Cambiouse correctamente a configuración da conta. \n  Deberás iniciar sesión outra vez.\nmagazine_deletion: Eliminación da Revista\ndelete_magazine: Eliminar revista\nrestore_magazine: Restablecer revista\npurge_magazine: Purgar revista\nmagazine_is_deleted: Eliminouse a revista. Podes <a \n  href=\"%link_target%\">restablecela</a> durante os seguintes 30 días.\nuser_suspend_desc: Ao suspender a túa conta agochar o seu contido na instancia, \n  pero non a eliminar de xeito permanente, podes restablecela cando queiras.\ndeletion: Eliminación\nremove_subscriptions: Retirar as subscricións\napply_for_moderator: Solicita axudar coa moderación\nrequest_magazine_ownership: Solicita a propiedade da revista\ncancel_request: Retirar a solicitude\nownership_requests: Solicitudes de propiedade\naccept: Aceptar\nmoderator_requests: Solicitudes de Mod\nopen_url_to_fediverse: Abrir URL orixinal\nmarked_for_deletion: Marcado para eliminación\nmagazines: Revistas\nsearch: Buscar\nadd: Engadir\nlogin: Acceder\nsort_by: Orde por\nfilter_by_subscription: Filtrar por subscrición\nfilter_by_federation: Filtrar por estado da federación\nposts: Publicacións\nreplies: Respostas\nmoderators: Moderación\nmod_log: Rexistro da moderación\nadd_comment: Engadir comentario\nadd_post: Engadir publicación\nadd_media: Engadir multimedia\nremove_media: Retirar multimedia\nmarkdown_howto: Como funciona o editor?\nenter_your_comment: Escribe o comentario\nenter_your_post: Escribe a publicación\nactivity: Actividade\nalways_disconnected_magazine_info: Esta revista non recibe actualizacións.\ngo_to_original_instance: Ver nunha instancia remota\nfrom: desde\nchange_theme: Cambiar decorado\nuseful: Útil\nhelp: Axuda\ncheck_email: Comproba o correo\nreset_check_email_desc: Se xa existe unha conta asociada ao teu enderezo de \n  correo electrónico, axiña recibirás un correo cunha ligazón para restablecer o\n  contrasinal. A ligazón caducará en %expire%.\nreset_check_email_desc2: Se non recibes o correo electrónico, mira no cartafol \n  de spam.\ntry_again: Volve a intentalo\nup_vote: Promover\ndown_vote: Reducir\nemail_confirm_content: 'Activamos a túa conta Mbin? Preme na ligazón inferior:'\ntag: Etiqueta\ncolumns: Columnas\nuser: Usuaria\njoined: Alta\nmoderated: Moderada\npeople_local: Local\npeople_federated: Federada\ncopy_url: Copiar URL Mbin\nsettings: Axustes\ngeneral: Xeral\nprofile: Perfil\nmenu: Menú\nprivacy: Privacidade\ndefault_theme: Decorado por defecto\ndefault_theme_auto: Claro/Escuro (Auto)\nsolarized_auto: Solarizado (Auto)\nflash_magazine_edit_success: Editouse correctamente a revista.\nflash_mark_as_adult_success: Publicación marcada correctamente como NSFW.\nflash_unmark_as_adult_success: Retirouse correctamente a marca de NSFW.\ntoo_many_requests: Excedeches o límite, inténtao outra vez máis tarde.\nright: Dereita\nfederation: Federación\nstatus: Estado\non: On\noff: Off\ninstances: Instancias\nupload_file: Subir ficheiro\nfrom_url: Desde url\nreject: Rexeitar\nunban_hashtag_btn: Retirar veto ao Cancelo\nunban_hashtag_description: Ao retirarlle o veto ao cancelo permites a creación \n  de publicacións con ese cancelo. As publicacións existentes co cancelo \n  volverán ser visibles.\nfilters: Filtros\napproved: Aprobado\nrejected: Rexeitado\nadd_moderator: Engadir moderadora\nadd_badge: Engadir insignia\nbans: Vetos\ncreated: Creado\nicon: Icona\ndone: Feito\npin: Fixar\nunpin: Soltar\nchange: Cambiar\nmark_as_adult: Marcar como NSFW\nunmark_as_adult: Desmarcar como NSFW\npinned: Fixado\npreview: Vista previa\narticle: Tema\nreputation: Reputación\nnote: Nota\nwriting: Ao escribir\nusers: Usuarias\ncontent: Contido\ndashboard: Taboleiro\ncontact_email: Correo de contacto\nmeta: Meta\ninstance: Instancia\ndelete_account: Eliminar conta\nmagazine_panel_tags_info: Escribe algo só se queres que se inclúa nesta revista \n  contido do fediverso en función das etiquetas\nreturn: Volver\nboost: Promover\npreferred_languages: Filtrar os temas e publicacións por idioma\ninfinite_scroll_help: Cargar automáticamente máis contido cando acadas o fin da \n  páxina.\nkbin_bot: Mbin Agent\ntoolbar.quote: Cita\ntoolbar.code: Código\ntoolbar.link: Ligazón\ntoolbar.image: Imaxe\ntoolbar.unordered_list: Lista sen orde\noauth.consent.app_requesting_permissions: quere realizar as seguintes accións no\n  teu nome\noauth2.grant.moderate.magazine_admin.all: Crear, editar ou eliminar as túas \n  propias revistas.\noauth2.grant.write.general: Crear ou editar calquera dos teus temas, \n  publicacións ou comentarios.\noauth2.grant.entry.all: Crear, editar ou eliminar os teus temas, e votar, \n  promover ou denunciar calquera tema.\noauth2.grant.entry.create: Crear novos temas.\noauth2.grant.entry.edit: Editar os teus temas existentes.\noauth2.grant.entry.delete: Eliminar os túas temas existentes.\noauth2.grant.entry.vote: Voto positivo ou negativo e promocion de calquera tema.\noauth2.grant.magazine.all: Subscribirse ou bloquear revistas, así como ver as \n  revistas ás que te subscribiches ou bloqueaches.\noauth2.grant.post_comment.create: Crear novos comentarios en publicacións.\noauth2.grant.post_comment.edit: Editar os teus comentarios nas publicacións.\noauth2.grant.post_comment.report: Denunciar calquera comentario nunha \n  publicación.\noauth2.grant.user.profile.all: Ler e editar o teu perfil.\noauth2.grant.user.oauth_clients.edit: Editar os permisos que concedeches a \n  outras aplicacións OAuth2.\noauth2.grant.moderate.entry.change_language: Cambiar o idioma dos temas nas \n  revistas que moderas.\noauth2.grant.moderate.entry.pin: Fixar temas nas revistas que moderas.\nupdate_comment: Actualizar comentario\nshow_avatars_on_comments_help: Mostra/Oculta os avatares ao ver os comentarios \n  nun tema única ou publicación.\nmoderation.report.reject_report_title: Rexeitar Denuncia\nmoderation.report.ban_user_description: Queres vetar a usuaria (%username%) que \n  creou este contido nesta revista?\nmoderation.report.approve_report_confirmation: Tes a certeza de querer aprobar \n  esta denuncia?\nsubject_reported_exists: Este contido xa foi denunciado.\nmoderation.report.ban_user_title: Vetar Usuaria\ndelete_content: Eliminar contido\npurge_content: Purgar contido\n2fa.remove: Desbotar 2FA\npending: Pendente\nsuspend_account: Suspender conta\naccount_suspended: A conta foi suspendida.\nremove_following: Retirar o seguimento\nabandoned: Abandonado\ntop: votos\ntype.link: Ligazón\ntype.article: Tema\ntype.photo: Foto\ntype.video: Vídeo\ntype.smart_contract: Pregado intelixente\ntype.magazine: Revista\nthread: Tema\nthreads: Temas\nmicroblog: Microblog\npeople: Persoas\nevents: Eventos\nmagazine: Revista\nselect_channel: Escolle unha canle\nhot: En voga\nactive: Activo\nnewest: Máis novo\noldest: Máis antigo\ncommented: Comentado\nchange_view: Cambiar a vista\nfilter_by_time: Filtrar por data\navatar: Avatar\nadded: Engadido\nup_votes: Promocións\ndown_votes: Reprobar\nno_comments: Sen comentarios\ncreated_at: Creado\nfilter_by_type: Filtrar por tipo\nfavourites: Votos a favor\nfavourite: Favorecer\nmore: Máis\nowner: Creadora\nsubscribers: Subscritoras\nonline: Con conexión\ncomments: Comentarios\ncover: Portada\nrelated_posts: Publicacións relacionadas\nrandom_posts: Publicacións ao chou\nfederated_magazine_info: Esta revista procede dun servidor federado e podería \n  non estar completa.\nempty: Baleiro\nsubscribe: Subscríbete\nunsubscribe: Retira a subscrición\nfederated_user_info: Este perfil procede dun servidor federado e podería non \n  estar completo.\nfollow: Segue\nunfollow: Retira o seguimento\nreply: Responde\nlogin_or_email: Identificador ou correo\npassword: Contrasinal\nremember_me: Lémbrame\nregister: Crear conta\ndont_have_account: Non tes unha conta?\nyou_cant_login: Esqueceches o contrasinal?\nalready_have_account: Xa tes unha conta?\nreset_password: Restablecer contrasinal\nshow_more: Saber máis\nto: para\nin: en\nemail: Correo electrónico\nusername: Identificador\nrepeat_password: Repite o contrasinal\nagree_terms: Acepta os %terms_link_start%Termos e Condicións%terms_link_end% así\n  como a %policy_link_start%Política de Privacidade%policy_link_end%\nterms: Termos do servizo\nprivacy_policy: Cláusula de privacidade\nfediverse: Fediverso\ncreate_new_magazine: Crear unha nova revista\nadd_new_article: Engadir novo tema\nabout_instance: Sobre\nall_magazines: Todas as revistas\nstats: Estatísticas\nadd_new_link: Engadir nova ligazón\nadd_new_photo: Engadir nova foto\nadd_new_post: Engadir nova publicación\nadd_new_video: Engadir novo vídeo\ncontact: Contacto\nfaq: PMF\nrss: RSS\nemail_confirm_header: Ola! Confirma o teu enderezo de correo.\nemail_verify: Confirma o enderezo de correo\nemail_confirm_expire: Ten en conta que a ligazón caducará dentro dunha hora.\nemail_confirm_title: Confirma o teu enderezo de correo.\nselect_magazine: Escolle unha revista\nadd_new: Engadir nova\nurl: URL\ntitle: Título\ntags: Etiquetas\nbadges: Insignias\nis_adult: 18+ / NSFW\nbody: Corpo\neng: ENG\noc: OC\nimage: Imaxe\nimage_alt: Texto alternativo á imaxe\nname: Nome\ndescription: Descrición\nrules: Regras\ndomain: Dominio\nfollowers: Seguidoras\nfollowing: Seguimentos\nsubscriptions: Subscricións\noverview: Vista xeral\ncards: Tarxetas\nreputation_points: Puntos de reputación\nrelated_tags: Etiquetas relacionadas\ngo_to_content: Ir ao contido\ngo_to_filters: Ir aos filtros\ngo_to_search: Ir á busca\nsubscribed: Subscrita\nall: Todo\nlogout: Pechar sesión\nchat_view: Vista de conversa\ntree_view: Vista en árbore\ntable_view: Vista en táboa\ncards_view: Vista en tarxetas\nclassic_view: Vista clásica\ncompact_view: Vista compacta\n3h: 3h\n6h: 6h\n12h: 12h\n1d: 1d\n1w: 1s\n1m: 1m\n1y: 1a\nlinks: Ligazóns\narticles: Temas\nphotos: Fotos\nvideos: Vídeos\nreport: Denuncia\nshare: Comparte\ncopy_url_to_fediverse: Copiar URL orixinal\nshare_on_fediverse: Comparte no Fediverso\nedit: Editar\nare_you_sure: Tes certeza?\ndelete: Eliminar\nedit_post: Editar publicación\nedit_comment: Gardar cambios\nmoderate: Moderar\nreason: Razón\nblocked: Bloqueado\nreports: Denuncias\nnotifications: Notificacións\nmessages: Mensaxes\nappearance: Aparencia\nhomepage: Páxina de inicio\nhide_adult: Agochar contido NSFW\nfeatured_magazines: Revistas destacadas\nshow_profile_subscriptions: Mostrar subscricións a revistas\nshow_profile_followings: Mostrar usuarias seguidas\nnotify_on_new_entry_reply: Todos os niveis de comentarios en temas que iniciei\nnotify_on_new_entry_comment_reply: Respostas aos meus comentarios en calquera \n  tema\nnotify_on_new_post_reply: Respostas de todo nivel ás miñas publicacións\nnotify_on_new_post_comment_reply: Respostas aos meus comentarios en calquera \n  publicación\nnotify_on_new_entry: Novos temas (ligazóns ou artigos) nunha revista á que me \n  subscribín\nabout: Sobre\nold_email: Correo actual\nnew_email: Novo correo electrónico\nnotify_on_new_posts: Novas publicacións en calquera revista á que estou \n  subscrita\nnew_email_repeat: Confirmar o novo enderezo\nsave: Gardar\ncurrent_password: Contrasinal actual\nnew_password: Novo contrasinal\nnew_password_repeat: Confirmar o novo contrasinal\nchange_email: Cambiar correo electrónico\nchange_password: Cambiar contrasinal\nexpand: Despregar\ndomains: Dominios\ncollapse: Pregar\nerror: Erro\nvotes: Votos\ntheme: Decorado\ndark: Escuro\nlight: Claro\nsolarized_light: Claro solarizado\nsolarized_dark: Escuro solarizado\nfont_size: Tamaño da letra\nsize: Tamaño\nboosts: Promocións\nshow_users_avatars: Mostrar avateres das usuarias\nyes: Si\nno: Non\nshow_magazines_icons: Mostrar iconas das revistas\nshow_thumbnails: Mostrar miniaturas\nrounded_edges: Bordo redondeado\nremoved_thread_by: eliminou un tema de\nrestored_thread_by: restableceu un tema de\nremoved_comment_by: eliminou un comentario de\nrestored_comment_by: restableceu o comentario de\nremoved_post_by: eliminou unha publicación de\nrestored_post_by: restableceu unha publicación de\nhe_banned: vetada\nhe_unbanned: retirouse o veto\nread_all: Ler todo\nshow_all: Mostrar todo\nflash_register_success: Benvida! Creouse a túa conta. Para rematar - comproba no\n  correo electrónico se recibiches a ligazón para activar a túa conta.\nflash_thread_new_success: O tema creouse correctamente e xa é visible para \n  outras usuarias.\nset_magazines_bar: Barra das revistas\nflash_thread_edit_success: Editouse correctamente o tema.\nflash_thread_delete_success: Eliminouse correctamente o tema.\nflash_thread_pin_success: Fixouse correctamente o tema.\nflash_thread_unpin_success: O tema xa non está fixado.\nflash_magazine_new_success: Creouse correctamente a revista. Xa podes engadir \n  contido ou explorar o panel de administración da revista.\nset_magazines_bar_desc: engade os nomes das revistas após a vírgula\nset_magazines_bar_empty_desc: se o campo queda baleiro, as revistas activas \n  mostraranse na barra.\nmod_log_alert: AVISO - O rexistro da moderación podería conter contido \n  desagradable ou estresante que foi eliminado pola moderación. Procede con \n  cautela.\nadded_new_thread: Engadiu un novo tema\nedited_thread: Editou un tema\nmod_remove_your_thread: A moderación eliminou o teu tema\nadded_new_comment: Engadiu un novo comentario\nedited_comment: Editou un comentario\nreplied_to_your_comment: Respondeu ao teu comentario\nmod_deleted_your_comment: A moderación eliminou o teu comentario\nadded_new_post: Engadiu unha nova publicación\nedited_post: Editou unha publicación\nmod_remove_your_post: A moderación eliminou a túa publicación\nadded_new_reply: Engadiu unha nova resposta\nwrote_message: Escribeu unha mensaxe\nbanned: Vetoute\nremoved: Eliminado pola moderación\ndeleted: Eliminado pola autora\nmentioned_you: Mencionoute\ncomment: Comentar\npost: Publicación\nban_expired: Caducou o veto\ninfinite_scroll: Desprazamento sen fin\npurge: Purgar\nsend_message: Enviar mensaxe directa\nmessage: Mensaxe\nshow_top_bar: Mostrar barra superior\nsticky_navbar: Barra nav. fixa\nsubject_reported: O contido foi denunciado.\nsidebar_position: Posición da barra lateral\nleft: Esquerda\nmagazine_panel: Panel da revista\napprove: Aprobar\nexpired_at: Caducou o\nban: Vetar\nexpires: Caduca\nperm: Permanente\nadd_ban: Engadir veto\ntrash: Lixo\nchange_magazine: Cambiar revista\nchange_language: Cambiar idioma\nweek: Semana\nmonths: Meses\nyear: Ano\nfederated: Federado\nweeks: Semanas\nmonth: Mes\nlocal: Local\nadmin_panel: Panel Admin\npages: Páxinas\nFAQ: PMF\ntype_search_term: Escribe termo a buscar\nfederation_enabled: A federación está activada\nregistrations_enabled: Permítese a creación de contas\naccount_deletion_button: Eliminar Conta\naccount_deletion_immediate: Eliminar inmediatamente\nerrors.server500.title: 500 Erro Interno do Servidor\nprivate_instance: Forzar ás usuarias a iniciar sesión antes de poder ver \n  calquera contido\ndelete_account_desc: Eliminar a conta, incluíndo as respostas doutras usuarias \n  nos temas, publicacións e comentarios creados.\nschedule_delete_account: Programar a eliminación\nremove_schedule_delete_account: Desbotar a eliminación programada\nremove_schedule_delete_account_desc: Borrar a eliminación programada. Todo o \n  contido volverá a estar dispoñible e a usuaria poderá iniciar sesión.\n2fa.setup_error: Erro ao activar o 2FA para a conta\n2fa.user_active_tfa.title: A usuaria ten o 2FA activo\npassword_and_2fa: Contrasinal e 2FA\nflash_account_settings_changed: Cambiaronse correctamente os axustes da conta. \n  Debes volver a iniciar sesión.\nshow_subscriptions: Mostrar subscricións\nsubscription_sort: Orde\nsidebars_same_side: En barras laterais no mesmo lado\nsubscription_sidebar_pop_out_right: Mover a unha barra lateral separada na \n  dereita\nsubscription_sidebar_pop_out_left: Mover a unha barra lateral separada na \n  esquerda\nerrors.server500.description: Desculpa, pero algo fallou pola nosa parte. Se \n  continúas a ver este erro intenta contactar coa administración da instancia. \n  Se a instancia non funciona en absoluto entón mira %link_start%outras \n  instancias Mbin%link_end% ata que resolvamos o problema.\nschedule_delete_account_desc: Programa a eliminación desta conta en 30 días. Así\n  ocultarás á usuaria e aos seus contidos e evitarás que a usuaria poida iniciar\n  sesión.\nalphabetically: Alfabético\nsubscriptions_in_own_sidebar: Nunha barra lateral separada\naccount_deletion_description: Vaise eliminar a túa conta en 30 días a non ser \n  que ti a elimines antes. A conta non pode restablecerse unha vez sexa \n  eliminada. Para restablecer a conta durante eses 30 días accede coas \n  credenciais habituais ou contacta coa administración.\nflash_comment_new_success: Creouse correctamente o comentario.\nflash_user_settings_general_success: Gardáronse correctamente os axustes como \n  usuaria.\nunsuspend_account: Reactivar conta\nremove_user_avatar: Retirar avatar\nedit_entry: Editar tema\nnotify_on_user_signup: Novas contas\nviewing_one_signup_request: Só estás a ver unha solicitude de conta de \n  %username%\nclose: Fechar\nuser_badge_moderator: Mod\nannouncement: Anuncio\ncontinue_with: Continuar con\nshow_active_users: Mostrar usuarias activas\nsignup_requests_header: Solicitudes de contas\nremove_user_cover: Retirar portada\nenabled: Activado\nbot_body_content: \"Benvida ao Mbin Agent! Este axente ten un rol moi importante ao\n  activar as características ActivityPub de Mbin. Fai que Mbin se poida comunicar\n  e federar con outras instancias do Fediverso.\\n\\nActivityPub é un protocolo aberto\n  estandarizado que permite a plataformas de redes sociais descentralizadas comunicarse\n  e interactuar unhas con outras. Permite que persoas usuarias en diferentes instancias\n  (servidores) poidan seguirse, interactuar, e compartir contidos na rede social federada\n  coñecida como o Fediverso. Proporciona un xeito estandarizado para que as usuarias\n  publiquen contidos, se sigan entre si e se relacionen compartindo, gustando e comentando\n  nos temas ou publicacións.\"\noauth2.grant.user.bookmark: Engadir e retirar marcadores\nsubscription_panel_large: Taboleiro grande\nsubscription_header: Subscricións a revistas\nposition_bottom: Abaixo\nposition_top: Arriba\nflash_image_download_too_large_error: Non se puido crear a imaxe, é demasiado \n  grande (tam. máx %bytes%)\nflash_magazine_theme_changed_success: Actualizouse correctamente a aparencia da \n  revista.\nflash_email_was_sent: Enviouse correctamente o correo.\nflash_email_failed_to_sent: Non se enviou o correo.\nflash_post_new_error: Non se creou a publicación. Algo fallou.\nflash_comment_edit_success: Actualizouse correctamente o comentario.\nflash_comment_edit_error: Fallou a edición do comentario. Algo fallou.\nflash_user_settings_general_error: Non se gardaron os axustes como usuaria.\nflash_user_edit_profile_error: Non se gardaron os axustes do perfil.\nflash_user_edit_profile_success: Gardáronse os axustes do perfil da usuaria.\nchange_my_cover: Cambiar a miña portada\nedit_my_profile: Editar o meu perfil\naccount_banned: Vetouse a conta.\naccount_is_suspended: A conta da usuaria está suspendida.\nkeywords: Palabras clave\nsso_registrations_enabled: Activados os rexistros tipo SSO\nrestrict_magazine_creation: Restrinxir a creación de revistas locais á \n  administración e moderadoras globais\nsso_show_first: Mostrar primeiro SSO no acceso e páxinas para crear contas\nreported_user: Usuaria denunciada\nown_content_reported_accepted: Aceptouse unha denuncia sobre o teu contido.\nmagazine_log_mod_removed: retirou a unha persoa da moderación\ndirect_message: Mensaxe directa\nmanually_approves_followers: Aproba manualmente as seguidoras\nregister_push_notifications_button: Rexistrar para notificacións Push\nnotification_title_mention: Mencionáronte\nnotification_title_new_signup: Rexistrouse unha nova conta\nnotification_body2_new_signup_approval: Debes aprobar a solicitude para que \n  poida acceder\nshow_related_magazines: Mostrar revistas ao chou\nshow_related_entries: Mostrar temas ao chou\nnotification_title_new_report: Creouse unha nova denuncia\nflash_posting_restricted_error: A creación de temas nesta revista está \n  restrinxida á moderación e ti non pertences a ela\nserver_software: Software do servidor\nversion: Versión\nlast_successful_deliver: Última entrega correcta\nbookmark_remove_from_list: Retirar marcador de %list%\nadmin_users_active: Activas\nmax_image_size: Tamaño máximo do ficheiro\nbookmark_remove_all: Retirar todos os marcadores\nbookmark_lists: Listas de marcadores\nbookmark_add_to_list: Engadir marcador a %list%\nbookmarks: Marcadores\nbookmarks_list: Marcadores en %lista%\ncount: Número\nis_default: Por defecto\nbookmark_list_is_default: É a lista por defecto\nbookmark_list_make_default: Converter en Por defecto\nbookmark_list_selected_list: Lista seleccionada\ntable_of_contents: Táboa do contido\nsearch_type_all: Todos os tipos\nsearch_type_entry: Temas\nsearch_type_post: Microblogs\nsignup_requests: Solicitudes de novas contas\napplication_text: Explica por que queres unirte\nflash_application_info: A administración ten que aprobar a túa conta para que \n  poidas iniciar sesión. Vas recibir un correo cando a solicitude se tramite.\nemail_application_approved_title: Aprobouse a túa solicitude dunha nova conta\nsignup_requests_paragraph: Estas usuarias queren unirse ao teu servidor. Non \n  poden iniciar sesión ata que ti aprobes a súa solicitude.\nemail_verification_pending: Tes que verificar o enderezo de correo para poder \n  iniciar sesión.\nshow_new_icons: Mostrar novas iconas\nemail_application_approved_body: A administración do servidor aprobou a túa nova\n  conta. Podes acceder ao servidor en <a href=\"%link%\">%siteName%</a>.\nemail_application_rejected_title: Rexeitouse a solicitude dunha nova conta\nemail_application_pending: A conta precisa aprobación pola administración para \n  iniciar sesión.\nshow_new_icons_help: Mostra a icona para novas revistas/usuarias (30 días ou \n  máis recente)\nmagazine_log_mod_added: engadiu unha persoa á moderación\nflash_thread_tag_banned_error: Non se puido crear o tema. O contido non está \n  permitido.\nimage_lightbox_in_list: As miniaturas dos temas abren a pantalla completa\ncompact_view_help: Unha vista compacta con marxes menores, e o multimedia móvese\n  á dereita.\nshow_users_avatars_help: Mostrar imaxe do avatar da usuaria.\nshow_magazines_icons_help: Mostrar a icona da revista.\nshow_thumbnails_help: Mostrar miniaturas.\nimage_lightbox_in_list_help: Ao marcar, cando premes nunha miniatura aparece \n  unha xanela modal coa imaxe. Se non, ao premer na miniatura abres o tema.\nyour_account_is_not_yet_approved: Aínda non se aprobou a túa conta. \n  Enviarémosche un correo tan pronto como a administración xestione a túa \n  solicitude dunha conta.\nfederation_page_dead_title: Instancias defuntas\nfederation_page_dead_description: Instancias ás que non puidemos entregarlle 10 \n  actividades seguidas e que a última entrega ou recepción correcta se fixo hai \n  máis dunha semana\nflash_post_new_success: Creouse correctamente a publicación.\nflash_thread_edit_error: Fallou a edición do tema. Algo foi mal.\nflash_user_edit_password_error: Fallou o cambio do contrasinal.\nflash_post_edit_error: Fallou a edición da publicación. Algo foi mal.\npage_width: Anchura da páxina\npage_width_max: Máx\npage_width_auto: Auto\naccount_unsuspended: Reactivouse a conta.\nsensitive_show: Preme para mostrar\ndeleted_by_moderator: A moderación eliminou o tema, publicación ou comentario\nnotification_title_edited_comment: Editouse un comentario\nnotification_title_message: Nova mensaxe directa\nnotification_title_new_post: Nova publicación\nnotification_title_removed_post: Eliminouse unha publicación\nnotification_title_edited_post: Editouse unha publicación\nlast_successful_receive: Última recepción correcta\nlast_failed_contact: Último contacto fallado\nuser_verify: Activar conta\nbookmark_list_create_label: Nome da lista\nbookmarks_list_edit: Editar lista de marcadores\nselect_user: Elixe unha usuaria\nnew_users_need_approval: A administración ten que aprobar as novas usuarias \n  antes de que poidan iniciar sesión.\nand: e\noauth2.grant.user.bookmark.add: Engadir marcadores\noauth2.grant.user.bookmark.remove: Retirar marcadores\noauth2.grant.user.bookmark_list: Ler, editar e eliminar as túas listas de \n  marcadores\noauth2.grant.user.bookmark_list.read: Ler as túas listas de marcadores\noauth2.grant.user.bookmark_list.edit: Editar a túas listas de marcadores\noauth2.grant.user.bookmark_list.delete: Eliminar as túas listas de marcadores\nflash_comment_new_error: Fallo ao comentar. Algo fallou.\nuser_badge_op: AO\nuser_badge_admin: Admin\nuser_badge_global_moderator: Mod Glob\nuser_badge_bot: Robot\nopen_report: Abrir denuncia\nreport_accepted: Aceptouse unha denuncia\nunregister_push_notifications_button: Retirar o rexistro de Push\nnotification_title_new_thread: Novo tema\ntest_push_message: Ola Mundo!\nnotification_title_new_comment: Novo comentario\nnotification_title_removed_comment: Eliminouse un comentario\nnotification_title_removed_thread: Eliminouse un tema\nnotification_title_edited_thread: Editouse un tema\nnotification_title_ban: Vetáronte\nnotification_body_new_signup: A usuaria %s% creou a conta.\nmagazine_posting_restricted_to_mods_warning: Só a moderación pode crear temas \n  nesta revista\nmagazine_posting_restricted_to_mods: Restrinxir a creación de temas á moderación\nnew_user_description: Esta é unha nova usuaria (activa hai menos de %s% días)\nnew_magazine_description: A revista é nova (activa desde hai menos de %days% \n  días)\nbookmark_list_create: Crear\nbookmark_list_create_placeholder: escribe un nome...\nbookmark_list_edit: Editar\nemail_application_rejected_body: Grazas polo teu interese, pero lamentamos \n  informarte de que a túa petición foi rexeitada.\nshow_magazine_domains: Mostrar dominios das revistas\nshow_user_domains: Mostrar dominios das usuarias\nanswered: con resposta\nby: por\nfront_default_sort: Orde na páxina de inicio\ncomment_default_sort: Orde por defecto dos comentarios\nopen_signup_request: Abrir petición de nova conta\ndownvotes_mode: Modo votos negativos\nchange_downvotes_mode: Cambiar o modo dos votos negativos\ndisabled: Desactivado\nhidden: Oculto\nmagazine_log_entry_pinned: entrada fixada\nmagazine_log_entry_unpinned: eliminouse a entrada fixada\nlast_updated: Última actualización\nshow_related_posts: Mostrar publicacións ao chou\ncomments_count: '{0}Comentarios|{1}Comentario|]1,Inf[ Comentarios'\nsubscribers_count: '{0}Subscritoras|{1}Subscritora|]1,Inf[ Subscritoras'\nfollowers_count: '{0}Seguidoras|{1}Seguidora|]1,Inf[ Seguidoras'\nmarked_for_deletion_at: Marcado para eliminación o %date%\ndisconnected_magazine_info: Esta revista non se está actualizando (última \n  actividade hai %days% día(s)).\nsubscription_sidebar_pop_in: Mover as subscricións ao taboleiro\nflash_thread_new_error: Non se puido crear o tema. Algo fallou.\nflash_magazine_theme_changed_error: Non se actualizou a aparencia da revista.\nflash_post_edit_success: Editouse correctamente a publicación.\nfilter_labels: Filtrar etiquetas\nauto: Auto\nchange_my_avatar: Cambiar o avatar\nall_time: Sempre\nshow: Mostrar\nsso_registrations_enabled.error: A creación de novas contas usando xestores de \n  identidades alleos está desactivada actualmente.\ntoolbar.spoiler: Velado\naction: Acción\nsso_only_mode: Restrinxir o acceso e creación de contas só a métodos SSO\nrelated_entry: Relacionado\nreporting_user: Denunciando usuaria\nreported: denunciada\nreport_subject: Asunto\nown_report_rejected: Rexeitouse a túa denuncia\nown_report_accepted: Aceptouse a túa denuncia\ncake_day: Aniversario\nsomeone: Alguén\nback: Atrás\ntest_push_notifications_button: Comprobar notificacións Push\nbookmark_add_to_default_list: Engadir marcador á lista por defecto\ncomment_not_found: Non se atopa o comentario\nsensitive_hide: Preme para ocultar\ndetails: Detalles\nspoiler: Velado\nhide: Ocultar\nedited: editado\npage_width_fixed: Fixa\nflash_user_edit_email_error: Fallou o cambio do correo.\naccount_unbanned: Retirouse o veto á conta.\nsensitive_warning: Contido sensible\nsensitive_toggle: Cambiar a visibilidade do contido sensible\ndeleted_by_author: A persoa autora eliminou o tema, publicación ou comentario\nnotification_title_new_reply: Nova resposta\nadmin_users_inactive: Inactivas\nadmin_users_suspended: Suspendidas\nadmin_users_banned: Vetadas\n2fa.manual_code_hint: Se non podes escanear o código QR escribe a chave secreta \n  manualmente\ntoolbar.emoji: Emoji\nmagazine_instance_defederated_info: A instancia desta revista foi desfederada. A\n  revista non vai recibir actualizacións.\nuser_instance_defederated_info: A instancia desta usuaria foi desfederada.\nflash_thread_instance_banned: A instancia desta revista foi vetada.\nshow_rich_mention: Mencións melloradas\nshow_rich_mention_help: Mostrar datos da usuaria cando é mencionada. Inclúe o \n  seu nome público e imaxe de perfil.\nshow_rich_mention_magazine: Mencións melloradas de revistas\nshow_rich_mention_magazine_help: Mostra datos da revista cando se menciona. Isto\n  inclúe o seu nome público e icona.\nshow_rich_ap_link: Ligazóns AP melloradas\nshow_rich_ap_link_help: Mostra información en liña cando se inclúe a ligazón a \n  contidos ActivityPub.\nattitude: Actitude\ntype_search_term_url_handle: Escribe a palabra, url ou alcume a buscar\nsearch_type_magazine: Revistas\nsearch_type_user: Usuarias\nsearch_type_actors: Revistas + Usuarias\nsearch_type_content: Temas + Microblogs\ntype_search_magazine: Limitar busca á revista…\ntype_search_user: Limitar a busca á autoría…\nmodlog_type_entry_deleted: Fío eliminado\nmodlog_type_entry_restored: Fío restaurado\nmodlog_type_entry_comment_deleted: Comentario do fío eliminado\nmodlog_type_entry_comment_restored: Comentario do fío restaurado\nmodlog_type_entry_pinned: Fío fixado\nmodlog_type_entry_unpinned: Fío solto\nmodlog_type_post_deleted: Microblog eliminado\nmodlog_type_post_restored: Microblog restaurado\nmodlog_type_post_comment_deleted: Resposta do microblog eliminada\nmodlog_type_post_comment_restored: Resposta do microblog restaurada\nmodlog_type_ban: Usuaria expulsada da revista\nmodlog_type_moderator_add: Engadiuse unha moderadora da revista\nmodlog_type_moderator_remove: Moderadora da revista retirada\neveryone: Todo o mundo\nnobody: Ninguén\nfollowers_only: Só seguidoras\ndirect_message_setting_label: Quen pode enviarche unha mensaxe directa\nbanner: Imaxe cabeceira\nmagazine_theme_appearance_banner: Imaxe de cabeceira persoal para a revista. \n  Móstrase na parte superior de todos os fíos e debería ter proporcións anchas \n  (5:1, ou 1500px * 300px).\ndelete_magazine_icon: Eliminar icona da revista\nflash_magazine_theme_icon_detached_success: Eliminouse correctamente a icona da \n  revista\ndelete_magazine_banner: Eliminar cabeceira da revista\nflash_magazine_theme_banner_detached_success: Eliminouse correctamente a imaxe \n  de cabeceira\nflash_thread_ref_image_not_found: Non se puido atopar a imaxe referenciada por \n  'imageHash'.\nfederation_uses_allowlist: Usa lista de permitidos para a federación\ndefederating_instance: Deixando de federar con %i\ntheir_user_follows: Cantidade de usuarias desa instancia que seguen a usuarias \n  da túa instancia\nour_user_follows: Cantidade de usuarias da nosa instancia que seguen usuarias da\n  outra instancia\ntheir_magazine_subscriptions: Cantidade de usuarias da outra instancia \n  subscritas a revistas da nosa instancia\nour_magazine_subscriptions: Cantidade de usuarias da nosa instancia subscritas a\n  revistas da outra instancia\nconfirm_defederation: Confirmar a desfederación\nflash_error_defederation_must_confirm: Tes que confirmar a desfederación\nallowed_instances: Instancias permitidas\nbtn_deny: Rexeitar\nbtn_allow: Permitir\nban_instance: Vetar instancia\nallow_instance: Permitir instancia\nfederation_page_use_allowlist_help: Se utilizas unha lista de permitidas, esta \n  instancia só federará coas que estén incluídas nesa lista. Se non é así, esta \n  instancia federará con todas as instancias excepto as que estean vetadas.\ncrosspost: Compartir fóra\nban_expires: O veto caduca\nyou_have_been_banned_from_magazine: Vetáronte na revista %m.\nyou_have_been_banned_from_magazine_permanently: Vetáronte permanentemente na \n  revista %m.\nyou_are_no_longer_banned_from_magazine: Xa non estás vetada na revista %m.\nfront_default_content: Vista por defecto da páxina inicial\ndefault_content_default: Por defecto (Fíos)\ndefault_content_combined: Fíos + Microblog\ndefault_content_threads: Fíos\ndefault_content_microblog: Microblog\ncombined: Combinada\nsidebar_sections_random_local_only: Limitar as seccións do panel lateral a \n  \"Fíos/Publicacións ao chou\" só locais\nsidebar_sections_users_local_only: Limitar a sección \"Persoas activas\" do panel \n  lateral a só locais\nrandom_local_only_performance_warning: Activar \"Ao chou só local\" podería \n  impactar no rendemento SQL.\noauth2.grant.moderate.entry.lock: Bloquea os fíos nas revistas que moderas, polo\n  que ninguén poderá comentar neles\noauth2.grant.moderate.post.lock: Bloquea os microblogs nas revistas que moderas,\n  polo que ninguén poderá comentalos\ndiscoverable: Descubrible\nuser_discoverable_help: Se activas isto poderase atopar nas buscas e paneis ao \n  chou tanto o teu perfil, fíos, microblogs e comentarios. O teu perfil tamén \n  vai aparecer no panel de usuarias activas e na páxina de persoas. Se o \n  desactivas, as túas publicacións seguirán sendo visibles por outras usuarias, \n  pero non se mostrarán na cronoloxía global.\nflash_thread_lock_success: Fío bloqueado correctamente\nflash_thread_unlock_success: Fío desbloqueado correctamente\nflash_post_lock_success: Microblog bloqueado correctamente\nflash_post_unlock_success: Microblog desbloqueado correctamente\nlock: Bloquear\nunlock: Desbloquear\nmagazine_discoverable_help: Se activas isto poderanse atopar esta revista e \n  fíos, microblogs e comentarios desta revista nas buscas e paneis ao chou. Se \n  está desactivado a revista aparecerá igualmente na lista de revistas, pero os \n  fíos e microblogs non aparecerán na cronoloxía global.\ncomments_locked: Os comentarios están bloqueados.\nmagazine_log_entry_locked: bloqueou os comentarios en\nmagazine_log_entry_unlocked: desbloqueou os comentarios en\nmodlog_type_entry_lock: Fío bloqueado\nmodlog_type_entry_unlock: Fío desbloqueado\nmodlog_type_post_lock: Microblog bloqueado\nmodlog_type_post_unlock: Microblog desbloqueado\ncontentnotification.muted: Silenciar | non ter notificacións\ncontentnotification.default: Predeterminado | recibir notificacións seguindo os \n  axustes\ncontentnotification.loud: Ruidoso | notificar todo\nindexable_by_search_engines: Os motores de busca poden atopalo\nuser_indexable_by_search_engines_help: Se este axuste se desactiva, os motores \n  de busca reciben o sinal de non incluír ningún dos teus fíos ou microblogs, \n  pero os comentarios non se ven afectados e actores maliciosos poderían \n  ignoralo. Este axuste fedérase con outros servidores.\nmagazine_indexable_by_search_engines_help: Se este axuste se desactiva, os \n  motores de busca reciben o aviso de non incluír non resultados ningún dos fíos\n  ou microblogs nestas revistas. Isto inclúe a páxina de inicio e todas as \n  páxinas de comentarios. Este axuste tamén se federa con outros servidores.\nmagazine_name_as_tag: Usar o nome da revista como etiqueta\nmagazine_name_as_tag_help: As etiquetas dunha revista úsanse para ligar as \n  publicacións microblog a esta revista. Por exemplo, se o nome é «fediverso» e \n  a revista ten a etiqueta «fediverso», entón todas as publicacións microblog \n  que conteñan «#fediverso» vanse incluír nesta revista.\nmagazine_rules_deprecated: o campo coas regras xa non se usa, e vaise retirar no\n  futuro. Pon as túas regras na cadro coa descrición.\n"
  },
  {
    "path": "translations/messages.gsw.yaml",
    "content": "add: Dezuefüege\nfilter_by_subscription: Nach Abonnemänt filterä\ntype.article: Thema\ntype.smart_contract: Smart Contract\ntype.magazine: Magazin\nthread: Thema\nthreads: Themä\nmicroblog: Mikroblog\npeople: Lüüt\nevents: Ereignissä\nmagazine: Magazin\nmagazines: Magazinä\nsearch: Suechä\nselect_channel: Wähl en Kanal\nlogin: Ahmeldä\ntop: Top\nhot: Heiss\nactive: Aktiv\nnewest: Neu\noldest: Alt\ncommented: Kommentiert\nchange_view: Ahsicht wächsle\nfilter_by_time: Nach de Ziit filterä\nfilter_by_type: Nach em Typ filterä\nfilter_by_federation: Nach Föderierigsstatus filterä\ncomments_count: '{0}Kommentär|{1}Kommentar|]1,Inf[ Kommentär'\nfollowers_count: '{0}Follower|{1}Follower|]1,Inf[ Follower'\nmarked_for_deletion: Für Löschig markiert\nmarked_for_deletion_at: Für Löschig am %date% markiert\nmore: Meh\navatar: Avatar\nadded: Dezuegfüegt\nup_votes: Förderä\nfavourite: Duume ufä\nregister: Registrierä\nreset_password: Passwort zrüggsetzä\nin: i\nfrom: vo\nusername: Bnutzername\nemail: E-Mail\nrepeat_password: Passwort wiederholä\ntype.link: Link\ntype.video: Video\ntype.photo: Foti\nsort_by: Sortierä nach\nsubscribers_count: '{0}Abonnänte|{1}Abonnänt|]1,Inf[ Abonnänte'\nfavourites: Favoritä\nshow_more: Meh zeige\nalready_have_account: Hesch scho es Konto?\nto: ah\n"
  },
  {
    "path": "translations/messages.it.yaml",
    "content": "type.article: Argomento\ntype.photo: Foto\ntype.video: Video\ntype.smart_contract: Contratto Smart\ntype.magazine: Rivista\nthread: Argomento\npeople: Persone\nevents: Eventi\nmagazine: Rivista\nmagazines: Riviste\nsearch: Cerca\nadd: Aggiungi\nselect_channel: Seleziona un canale\nthreads: ArgomentiThread\nhot: Popolari\nactive: Attivi\nnewest: Più recenti\noldest: Più vecchi\ncommented: Più commentati\nchange_view: Cambia vista\ncomments_count: '{0}Commenti|{1}Commento|]1,Inf[ Commenti'\nfavourite: Preferito\nmore: Altro\navatar: Avatar\nadded: Aggiunto\nup_votes: Boost\ndown_votes: Voti negativi\ncreated_at: Creato\nowner: Proprietario\nonline: Online\ncomments: Commenti\nposts: Post\nreplies: Risposte\nmarkdown_howto: Come funziona l'editor?\nmoderators: Moderatori\nadd_comment: Aggiungi commento\nadd_post: Aggiungi post\nadd_media: Aggiungi media\nenter_your_comment: Inserisci il tuo commento\nenter_your_post: Inserisci il tuo post\nactivity: Attività\ncover: Copertina\nrelated_posts: Post correlati\nrandom_posts: Post casuali\ngo_to_original_instance: Sfoglia più contenuti sull'istanza originale.\nempty: Vuoto\nsubscribe: Iscriviti\nunsubscribe: Annulla iscrizione\nfollow: Segui\nreply: Rispondi\nlogin_or_email: Accedi con il nome utente o l'e-mail\ndont_have_account: Non hai ancora un account?\nalready_have_account: Hai già un account?\npassword: Password\nremember_me: Ricordami\nreset_password: Reimposta password\nshow_more: Mostra di più\nemail_confirm_content: 'Sei pronto ad attivare il tuo account Mbin? Clicca sul link\n  sottostante:'\nreset_check_email_desc2: Se non vedi alcuna e-mail, controlla nella cartella \n  della posta indesiderata.\nemail_confirm_expire: Ricorda che il link scade tra un ora.\nemail_confirm_header: Ciao! Conferma il tuo indirizzo email.\nsubscriptions: Iscrizioni\nsubscribed: Iscritto\nnotify_on_new_posts: Avvisami di nuovi post nelle riviste a cui sono iscritto\nnotify_on_new_post_comment_reply: Avvisami in caso di risposte ai commenti dei \n  miei post\nnotify_on_new_entry_reply: Avvisami in caso di commenti nei miei thread\nnotify_on_new_post_reply: Avvisami in caso di risposte ai miei post\nshow_profile_subscriptions: Mostra le iscrizioni alla rivista\nflash_thread_new_success: Il thread è stato creato con successo ed è ora \n  visibile agli altri utenti.\nset_magazines_bar_desc: aggiungi il nome della rivista dopo la virgola\nflash_thread_delete_success: Il thread è stato eliminato con successo.\nflash_thread_unpin_success: Il thread è stato rimosso dai fissati in evidenza \n  con successo.\nflash_magazine_edit_success: La rivista è stata modificata con successo.\ntoo_many_requests: Limite superato, riprova più tardi.\nmod_remove_your_thread: Un moderatore ha rimosso il tuo thread\nadd_mentions_posts: Aggiungi tag di menzione degli utenti nei post\nYour account is not active: Il tuo account non è attivo.\nYour account has been banned: Il tuo account è stato sospeso.\nmagazine_panel_tags_info: Da inserire solo se si desidera che i contenuti del \n  fediverso vengano aggiunti a questa rivista in base ai tag\nbrowsing_one_thread: Stai visualizzando solo uno dei thread nella discussione! \n  Nella pagina del post specifico potrai trovare tutti commenti.\nkbin_intro_desc: è una piattaforma decentralizzata per l'aggregazione di \n  contenuti e microblogging che opera all'interno della rete del Fediverso.\nkbin_promo_desc: '%link_start%Clona il repository%link_end% e sviluppa il fediverso'\ntype.link: Link\nlogin: Accedi\nregister: Registrati\nflash_register_success: \"Benvenuto a bordo. Il tuo account è stato registrato. Un\n  ultimo passo, controlla la tua casella email per il link di attivazione del tuo\n  account.\"\nmicroblog: Microblog\ntop: Migliori\nfilter_by_time: Filtra per data\ntype_search_term: Digita un termine di ricerca\nfavourites: voti positivi\nunfollow: Non seguire\nyou_cant_login: Hai dimenticato la password?\nreset_check_email_desc: Se esiste un account associato al tuo indirizzo email, \n  dovresti a breve ricevere un'email contenente un link che puoi usare per \n  reimpostare la tua password. Tra %expire% il link non sarà più attivo.\nflash_magazine_new_success: La rivista è stata creata con successo. Ora puoi \n  aggiungere nuovi contenuti o esplorare il pannello di amministrazione delle \n  riviste.\nmod_log: Registro di moderazione\nmod_log_alert: ATTENZIONE - Il registro di moderazione potrebbe contenere \n  contenuti non piacevoli o angoscianti che sono stati rimossi dai moderatori. \n  Per favore procedi con cautela.\nset_magazines_bar_empty_desc: se il campo è vuoto, nella barra vengono \n  visualizzate le riviste attive.\nfederated_magazine_info: 'La rivista appartiene a un server federato e potrebbe essere\n  incompleta.'\nfederated_user_info: 'Il profilo appartiene a un server federato e potrebbe essere\n  incompleto.'\nnotify_on_new_entry: Avvisami di nuovi thread nelle riviste a cui sono iscritto\nagree_terms: Acconsenti ai %terms_link_start%Termini e \n  condizioni%terms_link_end% e all'%policy_link_start%Informativa sulla \n  privacy%policy_link_end%\nnotify_on_new_entry_comment_reply: Avvisami in caso di risposte ai commenti dei \n  miei thread\nflash_thread_edit_success: Il thread è stato modificato con successo.\nflash_thread_pin_success: Il thread è stato fissato in evidenza con successo.\nmod_deleted_your_comment: Un moderatore ha cancellato il tuo commento\nmod_remove_your_post: Un moderatore ha rimosso il tuo post\nadd_mentions_entries: Aggiungi tag di menzione degli utenti nei contenuti\nemail_confirm_title: Conferma il tuo indirizzo email.\ncopy_url_to_fediverse: Copia l'url univoco nel fediverso\nno_comments: Non ci sono commenti\nsubscribers: Iscritti\nfilter_by_type: Filtra per tipo\nto: a\nin: in\nusername: Nome utente\nemail: Email\nrepeat_password: Ripeti la password\nterms: Termini di servizio\nprivacy_policy: Politica della privacy\nabout_instance: A proposito\nall_magazines: Tutte le riviste\nstats: Statistiche\nfediverse: Fediverso\ncreate_new_magazine: Crea una nuova rivista\nadd_new_article: Aggiungi un nuovo thread\nadd_new_link: Aggiungi un nuovo link\nadd_new_photo: Aggiungi una nuova foto\nadd_new_post: Aggiungi un nuovo post\nadd_new_video: Aggiungi un nuovo video\ncontact: Contatti\nfaq: FAQ\nrss: RSS\nchange_theme: Cambia tema\nuseful: Utile\nhelp: Aiuto\ntry_again: Riprova\ndown_vote: Riduci\nemail_verify: Conferma l’indirizzo email\nselect_magazine: Seleziona una rivista\nadd_new: Aggiungi nuovo\nurl: URL\ntitle: Titolo\ntags: Etichette\nbadges: Distintivi\nis_adult: 18+ / Contenuti espliciti\neng: ENG\noc: OC\nimage: Immagine\nimage_alt: Testo alternativo per l’immagine\nname: Nome\ndescription: Descrizione\nrules: Regole\ndomain: Dominio\nfollowers: Seguaci\nfollowing: Seguiti\noverview: Panoramica\ncards: Carte\ncolumns: Colonne\nuser: Utente\nmoderated: Moderato\npeople_local: Locale\npeople_federated: Federato\nreputation_points: Punti reputazione\nrelated_tags: Etichette correlate\ngo_to_content: Vai al contenuto\ngo_to_search: Vai alla ricerca\nall: Tutto\nlogout: Esci\nclassic_view: Vista classica\ncompact_view: Vista compatta\nchat_view: Vista chat\ntree_view: Vista ad albero\ncards_view: Vista a schede\n3h: 3 ore\n6h: 6 ore\n12h: 12 ore\n1d: 1 giorno\n1w: 1 settimana\n1m: 1 mese\n1y: 1 anno\nlinks: Collegamenti\narticles: Thread\nphotos: Foto\nreport: Rapporto\nshare: Condividi\ncopy_url: Copia l’url\nshare_on_fediverse: Condividi sul Fediverso\nedit: Modifica\nare_you_sure: Confermi?\nreason: Motivo\ndelete: Cancella\nedit_post: Modifica il post\nedit_comment: Modifica il commento\nsettings: Preferenze\ngeneral: Generale\nprofile: Profilo\nblocked: Bloccati\nreports: Rapporti\nmessages: Messaggi\nappearance: Aspetto\nhomepage: Homepage\nhide_adult: Nascondi contenuto esplicito\nprivacy: Privacy\nshow_profile_followings: Mostra gli utenti seguaci\nsave: Salva\nabout: A proposito\nold_email: Email attuale\nnew_email: Nuova email\nnew_email_repeat: Conferma nuova email\ncurrent_password: Password attuale\nnew_password: Nuova password\nnew_password_repeat: Conferma la nuova password\nchange_email: Modifica email\nchange_password: Modifica password\nexpand: Espandi\ncollapse: Riduci\ndomains: Domini\nerror: Errore\nvotes: Voti\ndark: Scuro\nlight: Chiaro\nsolarized_light: Solarized Light\nsolarized_dark: Solarized Dark\nfont_size: Dimensione carattere\nsize: Dimensione\nboosts: Boost\nyes: Si\nno: No\nshow_magazines_icons: Mostra le icone delle riviste\nshow_thumbnails: Mostra le miniature\nrounded_edges: Angoli arrotondati\nremoved_thread_by: ha rimosso un thread di\nrestored_thread_by: ha ripristinato un thread di\nrestored_comment_by: ha ripristinato un commento di\nremoved_post_by: ha rimosso un post di\nrestored_post_by: ha ripristinato un post di\nread_all: Leggi tutto\nshow_all: Mostra tutto\nPassword is invalid: La password non è valida.\ncheck_email: Controlla la tua email\njoined: Iscritto\ngo_to_filters: Vai ai filtri\ntable_view: Vista a tabella\nvideos: Video\nnotifications: Notifiche\ntheme: Tema\nshow_users_avatars: Mostra gli avatar degli utenti\nremoved_comment_by: ha rimosso un commento di\nbody: Contenuto\nup_vote: Boost\nmoderate: Moderare\nfeatured_magazines: Riviste in primo piano\nhe_banned: sospendi\nhe_unbanned: rimuovi sospensione\nset_magazines_bar: Barra delle riviste\nadded_new_thread: Ha aggiunto un nuovo thread\nedited_thread: Ha modificato un thread\nadded_new_comment: Ha aggiunto un nuovo commento\nedited_comment: Ha modificato un commento\nreplied_to_your_comment: Ha risposto al tuo commento\nadded_new_post: Ha aggiunto un nuovo post\nwrote_message: Ha scritto un messaggio\nedited_post: Ha modificato un post\nbanned: Ti ha sospeso\nremoved: Rimosso da un moderatore\ndeleted: Cancellato dall'autore\nmentioned_you: Ti ha menzionato\ncomment: Commento\npost: Post\nban_expired: Sospensione scaduta\npurge: Rimuovi\nsend_message: Invia messaggio\nmessage: Messaggio\ninfinite_scroll: Scroll infinito\nsticky_navbar: Barra di navigazione a scomparsa\nsubject_reported: Il contenuto è stato segnalato.\nsidebar_position: Posizione della barra laterale\nleft: Sinistra\nright: Destra\nfederation: Federazione\non: On\noff: Spento\ninstances: Istanze\nupload_file: Carica file\nfrom_url: Da url\nmagazine_panel: Pannello della rivista\nreject: Rifiuta\nban: Sospendi\nfilters: Filtri\napproved: Approvato\nadd_moderator: Aggiungi moderatore\nadd_badge: Aggiungi distintivo\nbans: Sospensioni\ncreated: Creato\nexpires: Scade\nperm: Permanente\nexpired_at: Scaduto il\nadd_ban: Aggiungi sospensione\ntrash: Cestino\nicon: Icona\ndone: Fatto\npin: Fissa\nchange: Modifica\nchange_magazine: Modifica rivista\nchange_language: Modifica lingua\npinned: Fissato\npreview: Anteprima\narticle: Thread\nreputation: Reputazione\nnote: Nota\nusers: Utenti\ncontent: Contenuto\nweek: Settimana\nweeks: Settimane\nmonth: Mese\nmonths: Mesi\nyear: Anno\nfederated: Federato\nlocal: Locale\ndashboard: Pannello di controllo\ncontact_email: Email di contatto\nmeta: Meta\ninstance: Istanza\npages: Pagine\nFAQ: FAQ\nfederation_enabled: Federazione abilitata\nregistrations_enabled: Registrazioni abilitate\nregistration_disabled: Registrazioni disabilitate\nrestore: Ripristina\nfirstname: Nome\nsend: Invia\nactive_users: Utenti attivi\nrelated_entries: Thread correlati\ndelete_account: Elimina account\npurge_account: Rimuovi account\nban_account: Sospendi account\nrandom_entries: Thread casuali\nsidebar: Barra laterale\nunban_account: Rimuovi sospensione all'account\nrelated_magazines: Riviste correlate\nrandom_magazines: Riviste casuali\nauto_preview: Anteprima automatica media\ndynamic_lists: Elenco dinamico\nbanned_instances: Istanze sospese\nkbin_intro_title: Esplora il Fediverso\nkbin_promo_title: Crea la tua istanza\ncaptcha_enabled: Captcha abilitato\nheader_logo: Logo di intestazione\nreturn: Indietro\nadded_new_reply: Ha aggiunto una nuova risposta\nshow_top_bar: Mostra la barra superiore\nstatus: Stato\napprove: Approva\nrejected: Respinto\nunpin: Non fissare\nwriting: Scrivere\nadmin_panel: Pannello di amministrazione\nmercure_enabled: Mercure abilitato\nreport_issue: Segnala un problema\ntokyo_night: Tokyo Night\nboost: Boost\npreferred_languages: Filtra le lingue dei thread e dei post\nsort_by: Ordina per\nfilter_by_subscription: Filtra per abbonamento\nfilter_by_federation: Filtra per stato della federazione\ncreated_since: Creato dal\nsubscribers_count: '{0}Abbonati|{1}Abbonato|]1,Inf[ Abbonati'\nmarked_for_deletion: Contrassegnato per la cancellazione\nmarked_for_deletion_at: Contrassegnato per la cancellazione in data %date%\n"
  },
  {
    "path": "translations/messages.ja.yaml",
    "content": "type.link: リンク\ntype.article: スレッド\ntype.photo: 画像\ntype.video: 動画\ntype.smart_contract: スマート契約\ntype.magazine: マガジン\nthread: スレッド\nthreads: スレッド\nmicroblog: ミニブログ\npeople: ユーザー\nevents: イベント\nmagazine: マガジン\nmagazines: マガジン\nsearch: 検索\nadd: 追加\nselect_channel: チャンネルを選択\nlogin: ログイン\ntop: トップ\nhot: 人気\nactive: 注目\nnewest: 新着\noldest: 古い順\ncommented: コメントの多い順\nchange_view: 表示を変更\nfilter_by_time: 時間でフィルター\nfilter_by_type: 投稿でフィルター\nfavourites: お気に入り\nfavourite: お気に入り\nmore: さらに\navatar: アバター\nadded: 追加されました\nup_votes: ブースト数\ndown_votes: 「良くない」数\nno_comments: コメントなし\ncreated_at: 作成された\nowner: オーナー\nsubscribers: 購読者\nonline: オンライン\ncomments: コメント\nposts: 投稿\nreplies: 返信\nmoderators: モデレーター\nmod_log: モデレーターの記録\nadd_comment: コメントする\nadd_post: 投稿する\nadd_media: メディアを添付\nmarkdown_howto: エディターの使い方は？\nenter_your_post: 投稿を入力してください\nactivity: アクティビティ\ncover: プロフィールのバナー\nrelated_posts: 関連投稿\nrandom_posts: 最近のコメント\nfederated_user_info: このプロフィールは連合サーバからのもので、不完全な可能性があります。\ngo_to_original_instance: 連合元インスタンスで表示\nempty: 投稿なし\nsubscribe: 購読する\nunsubscribe: 購読を解除\nfollow: フォロー\nreply: 返信\nlogin_or_email: ユーザー名またはメールアドレス\npassword: パスワード\ndont_have_account: アカウントを持っていませんか？\nyou_cant_login: パスワードを忘れましたか？\nalready_have_account: すでにアカウントを持っていますか？\nregister: 新規登録する\nshow_more: もっと見る\nto: マガジン\nin: 中に\nusername: ユーザー名\nemail: メールアドレス\nterms: 利用規約\nprivacy_policy: プライバシーポリシー\nabout_instance: このインスタンスについて\nall_magazines: すべてのマガジン\nstats: 統計\nadd_new_article: 新しいスレッドを追加\nadd_new_link: 新しいリンクを追加\nadd_new_post: 新しい投稿を追加\nadd_new_video: 新しい動画を追加\ncontact: お問い合わせ\nfaq: FAQ\nrss: RSS\nchange_theme: テーマ変更\nuseful: 便利リンク\nhelp: ヘルプ\nreset_check_email_desc2: メールを受信しなかったら、迷惑メールのフォルダを確認してください。\ntry_again: もう一度\nup_vote: ブースト\ndown_vote: 「よくない」\nemail_confirm_header: こんにちは！メールアドレスを検証してください。\nemail_confirm_content: 準備はいいですか？ あなたのMbinアカウントを有効にするには下のリンクをクリックしてください。\nemail_verify: メールアドレスの検証\nemail_confirm_title: メールアドレスを検証してください。\nselect_magazine: マガジンを選択\nadd_new: 追加する\nurl: URL\ntitle: タイトル\nbody: 本文\ntags: タグ\nbadges: バッジ\nis_adult: R-18 / NSFW\neng: 英語\noc: オリジナルコンテンツ\nimage: 画像\nname: 名前\ndescription: 詳細\nrules: ルール\ndomain: ドメイン\nfollowers: フォロワー\nfollowing: フォロー中\nsubscriptions: 購読\noverview: 概要\ncards: カード\ncolumns: 列\nuser: ユーザー\njoined: 登録日\npeople_local: ローカル\npeople_federated: 連合\nreputation_points: 評判ポイント\nrelated_tags: 関連タグ\ngo_to_content: コンテンツへ\ngo_to_filters: フィルターへ\ngo_to_search: 検索へ\nsubscribed: 購読中\nall: すべて\nlogout: ログアウト\nlinks: リンク\ncompact_view: コンパクト表示\nchat_view: チャット表示\ntree_view: ツリー表示\ncards_view: カード表示\n3h: 3時間\n6h: 6時間\n12h: 12時間\n1d: 1日\n1w: 1週間\n1m: 1ヶ月\n1y: 1年\narticles: スレッド\nphotos: 画像\nvideos: 動画\nreport: 通報\nshare: 共有\ncopy_url_to_fediverse: オリジナルURLをコピー\nshare_on_fediverse: フェディバースへシェア\nedit: 編集\nare_you_sure: よろしいですか？\nmoderate: モデレートする\nreason: 理由\ndelete: 削除する\nedit_post: 投稿を編集する\nsettings: 設定\nprofile: プロフィール\nblocked: ブロック\nreports: 通報\nnotifications: 通知\nmessages: メッセージ\nappearance: 外観\nhomepage: ホーム\ncomments_count: '{0} コメント | {1} コメント | ]1,Inf[ コメント'\nagree_terms: '%terms_link_start% 利用規約 %terms_link_end% と %policy_link_start% プライバシーポリシー\n  %policy_link_end% を同意する'\nemail_confirm_expire: リンクの有効期限は１時間です。\nenter_your_comment: コメントを入力してください\nfediverse: フェディバース\ncreate_new_magazine: 新しいマガジンを作成\nfederated_magazine_info: このマガジンは連合サーバからのもので、不完全な可能性があります。\nadd_new_photo: 新しい画像を追加\nimage_alt: 画像の代替テキスト\nmoderated: モデレートしているマガジン\ncheck_email: メールを確認してください\nunfollow: フォローを解除\nreset_check_email_desc: \n  あなたのアカウントに紐づくメールアドレスが存在する場合、パスワードをリセットするためのメールが送信されます。このリンクの有効期限は %expire% \n  までです。\nremember_me: ログイン情報を保存\nreset_password: パスワードをリセットする\nrepeat_password: パスワードを再入力\nclassic_view: クラシック表示\ntable_view: テーブル表示\ncopy_url: MbinのURLをコピー\nedit_comment: 上書き保存\ngeneral: 全般\nhide_adult: NSFWのコンテンツを非表示にする\nfeatured_magazines: おすすめのマガジン\nprivacy: プライバシー\nshow_profile_subscriptions: 購読しているマガジンを表示\nshow_profile_followings: フォローしているユーザーを表示する\nnotify_on_new_post_reply: 自分の投稿のリプライを通知する\nnotify_on_new_entry_reply: 自分のスレッドのコメントを通知する\nnotify_on_new_entry_comment_reply: 自分のスレッドのコメントのリプライを通知する\nnotify_on_new_post_comment_reply: 自分の投稿のコメントのリプライを通知する\nnotify_on_new_entry: 購読しているマガジンの新スレッドを通知する\nnotify_on_new_posts: 購読しているマガジンの新規投稿を通知する\nsave: 保存\nabout: Mbinについて\nold_email: 現在のメールアドレス\nnew_email: 新しいメールアドレス\nnew_email_repeat: もう一度新しいメールを入力\ncurrent_password: 現在のパスワード\nnew_password: 新しいパスワード\nnew_password_repeat: もう一度新しいパスワードを入力\nchange_email: メールアドレスの変更\nchange_password: パスワードの変更\nexpand: もっと表示する\ncollapse: 小さく表示する\ndomains: ドメイン\nerror: エラー\nvotes: 投票\ntheme: テーマ\ndark: ダーク\nlight: ライト\nsolarized_light: Solarizedライト\nsolarized_dark: Solarizedダーク\nsize: サイズ\nboosts: ブースト\nshow_users_avatars: ユーザーのアバターを表示する\nyes: はい\nno: いいえ\nshow_magazines_icons: マガジンのアイコンを表示\nshow_thumbnails: サムネイルを表示する\nremoved_thread_by: はこのアカウントが作成したスレッドを削除しました：\nremoved_comment_by: はこのアカウントが投稿したコメントを削除しました：\nrestored_comment_by: はこのアカウントが投稿したコメントを復旧しました：\nrestored_post_by: はこのアカウントが作成した投稿を復旧しました：\nhe_banned: バンする\nhe_unbanned: バン解除\nread_all: 全部既読にする\nshow_all: 全部表示する\nflash_thread_new_success: スレッドが作成されました。他のユーザーがスレッドを見られます。\nflash_thread_edit_success: スレッド編集ができました。\nflash_thread_delete_success: スレッドを削除しました。\nflash_thread_pin_success: スレッドを固定しました。\nflash_magazine_edit_success: マガジンを編集しました。\ntoo_many_requests: 制限を超えました。時間が経ってからもう一度試してください。\nset_magazines_bar: マガジンバー\nset_magazines_bar_desc: マガジン名はカンマで区切って入力してください\nset_magazines_bar_empty_desc: 空欄にするとアクティブなマガジンが表示されます。\nfont_size: 文字サイズ\nrounded_edges: 丸角\nflash_register_success: \n  ようこそ！アカウント登録ができました。最後の手順として、メールアドレスにアカウント検証リンクを送信しました。メールボックスを確認して下さい。\nrestored_thread_by: はこのアカウントが作成したスレッドを復旧しました：\nremoved_post_by: はこのアカウントが作成した投稿を削除しました：\nflash_thread_unpin_success: スレッドの固定を外しました。\nflash_magazine_new_success: マガジンを作成しました。新しいコンテンツを追加したりマガジンパネルから検索することができます。\nadded_new_thread: 新しいスレッドを追加しました\nedited_thread: スレッドを編集しました\nadded_new_comment: 新しいコメントを追加しました\nedited_comment: コメントを編集しました\nreplied_to_your_comment: あなたのコメントにリプライしました\nadded_new_post: 新しい投稿を追加しました\nedited_post: 投稿を編集しました\nadded_new_reply: 新しいリプライを追加しました\nwrote_message: メッセージを書きました\nbanned: あなたをバンしました\nremoved: モデレーターに削除されました\nmod_log_alert: モデレーターの記録ではモデレーターに削除された過激な内容が表示される可能性があります。大変ご注意ください。\nmod_remove_your_thread: モデレーターにあなたのスレッドが削除されました\nmod_deleted_your_comment: モデレーターにあなたのコメントが削除されました\nmod_remove_your_post: モデレーターにあなたの投稿が削除されました\ndeleted: 作成者に削除されました\nmentioned_you: はあなた宛てに言った\npost: 投稿する\ncomment: コメントする\nban_expired: バンの期限切れました\npurge: 消去する\nsend_message: ダイレクトメッセージを送る\nmessage: メッセージ\ninfinite_scroll: 無限スクロール\nshow_top_bar: トップバーを表示する\nsticky_navbar: ナビゲーションバーを固定する\nsubject_reported: コンテンツが通報されました。\nsidebar_position: サイドバーの位置\nleft: 左\nright: 右\nfederation: 連合\nstatus: ステータス\non: 有効\noff: 無効\ninstances: インスタンス\nupload_file: ファイルをアップロード\nfrom_url: URLからアップロード\nmagazine_panel: マガジンパネル\nreject: 拒否する\napprove: 許可する\nban: バンする\nfilters: フィルター\napproved: 許可された\nrejected: 拒否された\nadd_moderator: モデレーターを追加する\nadd_badge: バッジを追加する\nbans: バンの回数\ncreated: 作成した\nexpires: 有効期限\nperm: 無期限\nexpired_at: 期限切れ：\nadd_ban: バンを追加する\ntrash: ゴミ箱に入れる\nicon: アイコン\ndone: 完了\npin: 固定する\nunpin: 固定を外す\nchange_magazine: マガジンを変更\nchange_language: 言語を変更\nchange: 変更する\npinned: 固定された\npreview: プレビュー表示\narticle: スレッド\nreputation: 評判度\nnote: ノート\nwriting: 著作\nusers: ユーザー\ncontent: コンテンツ\nweeks: 週間\nweek: １週間\nmonth: 1ヶ月間\nmonths: 月間\nyear: 1年間\nlocal: ローカル\nadmin_panel: 管理パネル\ndashboard: ダッシュボード\ncontact_email: 連絡先のメールアドレス\nmeta: メタ情報\ninstance: インスタンス\npages: ページ\nFAQ: FAQ\nregistrations_enabled: 新規登録可能\nfederation_enabled: 連合を有効\nregistration_disabled: 新規登録不可\nrestore: 復旧する\nadd_mentions_posts: タグを投稿に自動的につける\nPassword is invalid: パスワードが違います。\nYour account is not active: アカウントは無効です。\nfirstname: 名前\nsend: 送信する\nactive_users: アクティブなユーザー\nrandom_entries: 最近のスレッド\nrelated_entries: 関連のスレッド\ndelete_account: アカウントを削除する\npurge_account: アカウントを除去する\nban_account: アカウントをバンする\nrelated_magazines: 関連するマガジン\nrandom_magazines: ランダムなマガジン\nsidebar: サイドバー\nauto_preview: メディア自動プレビュー\ndynamic_lists: ダイナミックリスト\nbanned_instances: バンされたインスタンス\nkbin_intro_title: フェディバースに参加しよう\nkbin_promo_title: 自分のインスタンスを作る\nkbin_promo_desc: '%link_start% リポジトリをクローンして %link_end% フェディバースを広げましょう'\ncaptcha_enabled: Captchaは有効\nheader_logo: ヘッダーロゴ\nreturn: 戻る\nfederated: 連合している\ntype_search_term: 検索入力\nadd_mentions_entries: タグを自動的に追加する\nYour account has been banned: アカウントは停止されています。\nunban_account: アカウントのバンを解除する\nmagazine_panel_tags_info: 連合するサーバーからマガジンへ特定のタグの投稿を自動投稿する場合は、タグを入力します\nkbin_intro_desc: はフェディバースネットワーク内で動作する、コンテンツの集約とミニブログのための分散型プラットフォームです。\nbrowsing_one_thread: 現在ディスカッション内の一つのスレッドだけを見ています！すべてのコメントは投稿ページから見られます。\nboost: ブースト\nmercure_enabled: Mercureが有効\nreport_issue: バグを報告\ntokyo_night: 東京ナイト\noauth2.grant.post.edit: 既存ポストを編集 。\noauth2.grant.user.message.all: メッセージを読んで、 他ユーザーにメッセージを飛ばす。\noauth2.grant.moderate.magazine_admin.create: 新しいマガジンを作る。\nfilter.adult.hide: R18/NSFWを見せない\ntoolbar.bold: ボールド\ntoolbar.header: ヘッダー\noauth2.grant.user.message.read: メッセージを読む。\nfilter.fields.only_names: 名前だけ\noauth2.grant.entry.create: 新しいスレッドを作る。\nfilter.adult.show: R18/NSFWを見せる\noauth.consent.allow: 許す\nfilter.adult.only: R18/NSFWだけ\nfilter.fields.names_and_descriptions: 名前と詳細\noauth2.grant.user.profile.all: プロフィールを読んで、 エディットする。\noauth2.grant.entry_comment.create: スレッドに新しいコメントを作る。\noauth.consent.deny: 打ち消す\noauth2.grant.entry_comment.delete: スレッドの既存コメントを消す。\nsubject_reported_exists: このコンテンツはもう報告しました。\noauth2.grant.entry.edit: 既存スレッドを編集する。\noauth2.grant.entry_comment.edit: スレッドの既存コメントを編集する。\noauth2.grant.user.profile.read: プロフィールを読む。\ntoolbar.link: リンク\nmoderation.report.ban_user_title: ユーザーを禁止します\noauth2.grant.entry.delete: 既存スレッドを消す。\ntoolbar.code: コード\noauth2.grant.user.message.create: 他ユーザーにメッセージを飛ばす。\nupdate_comment: コメントをアップデート\noauth2.grant.post_comment.create: ポストに新しいコメントを作る。\noauth2.grant.post.delete: 既存ポストを消す。\noauth2.grant.post.create: 新しいポストを作る。\ntoolbar.image: 映像\ntoolbar.italic: 斜体\noauth2.grant.user.profile.edit: プロフィールをエディット。\nmoderation.report.approve_report_title: 通知を許す\nmoderation.report.reject_report_title: 通知を断る\nerrors.server429.title: 429 Too Many Requests\nlocal_and_federated: ローカルと連合\ntoolbar.ordered_list: 順序付きリスト\ncustom_css: カスタムCSS\ntoolbar.quote: 引用\ntoolbar.unordered_list: 順序不同リスト\nerrors.server404.title: 404 Not found\nresend_account_activation_email: もう一度アカウントアクティブ化メールを飛ばす\nerrors.server500.title: 500 Internal Server Error\ntoolbar.mention: メンション\noauth2.grant.write.general: スレッド、投稿もしくはコメントを作るか、編集する。\nsingle_settings: 単一\nresend_account_activation_email_question: 非活動アカウント？\nyour_account_has_been_banned: あなたのアカウントはバンされました\noauth2.grant.delete.general: スレッド、投稿もしくはコメントを消す。\noauth2.grant.user.notification.delete: 通知を消去する。\nemail.delete.title: ユーザーのアカウント削除を要望する\ncomment_reply_position: コメントのレスの位置\nkbin_bot: Mbinのボット\nfederation_page_enabled: 連合ページは有効化されました\nshow_avatars_on_comments: コメントでアバターを見せる\ntag: タグ\nremove_media: メディアを削除\nedit_entry: スレッドを編集\nshow_related_magazines: ランダムなマガジンを表示\ndefault_theme: デフォルトのテーマ\nsolarized_auto: Solarized(自動検出)\ndefault_theme_auto: ライト/ダーク(自動検出)\nmenu: メニュー\nfederation_page_allowed_description: 連合している既知のインスタンス\nfederation_page_disallowed_description: 連合していないインスタンス\ntoolbar.spoiler: スポイラー\nerrors.server403.title: 403 Forbidden\nsubscription_header: 購読中のマガジン\nfederation_page_dead_title: 消息不明インスタンス\ntoolbar.strikethrough: 取り消し線\nfederation_page_dead_description: 10回のアクティビティが連続で配送に失敗したか、一週間以上配送に成功していないインスタンス\nfederated_search_only_loggedin: ログインしていない場合は連合検索が制限されます\nignore_magazines_custom_css: マガジンのカスタムCSSを無視する\nshow_related_entries: ランダムなスレッドを表示\nshow_related_posts: ランダムな投稿を表示\nshow_active_users: アクティブなユーザーを表示\n"
  },
  {
    "path": "translations/messages.nb_NO.yaml",
    "content": "{}\n"
  },
  {
    "path": "translations/messages.nl.yaml",
    "content": "show_profile_subscriptions: Tijdschriftabonnementen tonen\nnotify_on_new_entry_comment_reply: Stel me op de hoogte van reacties op alle \n  gesprekken\nnotify_on_new_posts: Stel me op de hoogte van nieuwe berichten in een \n  tijdschrift waarop ik geabonneerd ben\nsolarized_light: Gepolariseerd Licht\nshow_magazines_icons: Tijdschriftpictogrammen tonen\nremoved_post_by: heeft een bericht verwijderd van\nflash_register_success: Welkom aan boord, je account is nu geregistreerd. Nog \n  één stap! - Check je inbox voor een activatielink die je account tot leven zal\n  brengen.\ntype.article: Gesprek\ntype.photo: Foto\ntype.video: Video\ntype.smart_contract: Slim contract\ntype.magazine: Tijdschrift\nthread: Gesprek\nmicroblog: Microblog\npeople: Mensen\nevents: Evenementen\nmagazine: Tijdschrift\nmagazines: Tijdschriften\nsearch: Zoeken\nadd: Toevoegen\nlogin: Inloggen\ntop: Top\nhot: Populair\nactive: Actief\nnewest: Nieuwste\noldest: Oudste\ncommented: Gereageerd\nchange_view: Andere weergave\nfilter_by_time: Filteren op tijdstip\nfilter_by_type: Filteren op type\nfavourites: Omhoog stemmen\nfavourite: Favoriet\nmore: Meer\navatar: Profielfoto\nadded: Toegevoegd\nno_comments: Geen reacties\ncreated_at: Aangemaakt\nowner: Eigenaar\nsubscribers: Abonnees\nonline: Online\ncomments: Reacties\nposts: Berichten\nmoderators: Moderators\nmod_log: Moderatielogboek\nadd_comment: Reactie plaatsen\nadd_post: Bericht plaatsen\nadd_media: Media plaatsen\nenter_your_comment: Voer een reactie in\nenter_your_post: Voer een bericht in\nactivity: Activiteit\ncover: Omslag\nrelated_posts: Verwante berichten\nrandom_posts: Willekeurige berichten\nfederated_user_info: Dit profiel is van een gefedereerde server en is mogelijk \n  onvolledig.\ngo_to_original_instance: Bekijk op externe instantie\nempty: Leeg\nsubscribe: Abonneren\ndown_votes: Omlaagstemmen\nup_votes: Omhoogstemmen\nfollow: Volgen\nunfollow: Ontvolgen\nlogin_or_email: Gebruikersnaam of e-mailadres\npassword: Wachtwoord\nremember_me: Ingelogd blijven\ndont_have_account: Heb je nog geen account?\nalready_have_account: Heb je al een account?\nregister: Registreren\nreset_password: Wachtwoord herstellen\nshow_more: Meer informatie\nto: aan\nin: in\nusername: Gebruikersnaam\nemail: E-mailadres\nterms: Algemene voorwaarden\nprivacy_policy: Privacybeleid\nabout_instance: Over\nall_magazines: Alle tijdschriften\nstats: Statistieken\nfediverse: Fediverse\ncreate_new_magazine: Nieuw tijdschrift maken\nadd_new_article: Artikel toevoegen\nadd_new_link: Link toevoegen\nadd_new_photo: Foto toevoegen\nadd_new_post: Bericht toevoegen\nadd_new_video: Video toevoegen\ncontact: Contact\nfaq: Veelgestelde vragen\nrss: RSS\nchange_theme: Thema wijzigen\nuseful: Nuttig\nhelp: Hulp\ncheck_email: Controleer je postvak in\nreset_check_email_desc2: Wanneer je geen e-mail hebt ontvangen, controleer dan \n  je spammap.\ntry_again: Probeer opnieuw\nup_vote: Boost\ndown_vote: Stem omlaag\nemail_confirm_header: Hallo! Bevestig je e-mailadres.\nemail_confirm_content: 'Klaar om je Mbin account te activeren? Klik dan op onderstaande\n  link:'\nemail_verify: E-mailadres bevestigen\nemail_confirm_expire: 'Let op: de link is 1 uur geldig.'\nemail_confirm_title: Bevestig je e-mailadres.\nselect_magazine: Selecteer een tijdschrift\nadd_new: Toevoegen\nurl: Url\ntitle: Titel\nbody: Inhoud\nbadges: Emblemen\neng: ENG\noc: OI\nimage: Afbeelding\nname: Naam\ndescription: Omschrijving\nrules: Regels\ndomain: Domeinnaam\nfollowers: Aantal volgers\nfollowing: Aantal gevolgden\nsubscriptions: Aantal abonnementen\noverview: Overzicht\ncards: Kaarten\ncolumns: Kolommen\nuser: Gebruiker\njoined: Lid\nmoderated: Met moderatie\npeople_local: Lokaal\npeople_federated: Gefedereerd\nreputation_points: Aantal reputatiepunten\nrelated_tags: Verwante labels\ngo_to_content: Ga naar inhoud\ngo_to_filters: Ga naar filters\nsubscribed: Geabonneerd\nall: Alles\nlogout: Uitloggen\nclassic_view: Klassieke weergave\ncompact_view: Compacte weergave\nchat_view: Gespreksweergave\ntree_view: Boomweergave\ncards_view: Kaartenweergave\n3h: 3u\n6h: 6u\n12h: 12u\n1d: 1d\n1w: 1w\n1m: 1m\n1y: 1j\nlinks: Links\narticles: Artikelen\nphotos: Foto's\nreport: Melden\nshare: Delen\ncopy_url: Kopieer Mbin URL\ncopy_url_to_fediverse: Kopieer origineel URL\nshare_on_fediverse: Delen op fediverse\nedit: Bewerken\nmoderate: Modereren\nreason: Reden\ndelete: Verwijderen\nedit_post: Bericht bewerken\nshow_profile_followings: Gevolgde gebruikers tonen\nnotify_on_new_entry_reply: Stel me op de hoogte van reacties in gesprekken die \n  ik heb geschreven\nnotify_on_new_post_reply: Stel me op de hoogte van reacties op berichten die ik \n  heb geschreven\nnotify_on_new_post_comment_reply: Stel me op de hoogte van reacties op alle \n  berichten\nnotify_on_new_entry: Stel me op de hoogte van nieuwe gesprekken (links of \n  artikelen) in elk tijdschrift waarop ik op ben geabonneerd\nsave: Opslaan\nabout: Over\nold_email: Huidig e-mailadres\nnew_email: Nieuw e-mailadres\nnew_email_repeat: E-mailadres bevestigen\ncurrent_password: Huidig wachtwoord\nnew_password: Nieuw wachtwoord\nsettings: Instellingen\ngeneral: Algemeen\nprofile: Profiel\nblocked: Geblokkeerd\nnew_password_repeat: Nieuw wachtwoord bevestigen\nnotifications: Meldingen\nmessages: Berichten\nappearance: Uiterlijk\nchange_email: E-mailadres wijzigen\nhomepage: Startpagina\nhide_adult: Inhoud voor volwassenen verbergen\nfeatured_magazines: Uitgelichte tijdschriften\nprivacy: Privacy\nchange_password: Wachtwoord wijzigen\nexpand: Uitklappen\ncollapse: Inklappen\ndomains: Domeinnamen\nerror: Foutmelding\nvotes: Stemmen\ntheme: Thema\ndark: Donker\nlight: Licht\nfont_size: Tekstgrootte\nsolarized_dark: Gepolariseerd Donker\nsize: Grootte\nshow_users_avatars: Profielfoto's tonen\nyes: Ja\nno: Nee\nshow_thumbnails: Miniaturen tonen\nrounded_edges: Afgeronde hoeken\nremoved_thread_by: heeft een gesprek verwijderd van\nrestored_thread_by: heeft een gesprek hersteld van\nremoved_comment_by: heeft een reactie verwijderd van\nrestored_comment_by: heeft een reactie hersteld van\nrestored_post_by: heeft een bericht hersteld van\nhe_banned: Verbannen\nhe_unbanned: Ongeblokkeerd\nread_all: Alles als gelezen markeren\nshow_all: Alles tonen\nflash_thread_new_success: Het gesprek is succesvol aangemaakt en zichtbaar voor \n  andere gebruikers.\nflash_thread_edit_success: Het gesprek is succesvol bewerkt.\nflash_thread_delete_success: Het gesprek is succesvol verwijderd.\nflash_thread_pin_success: Het gesprek is succesvol vastgemaakt.\nflash_thread_unpin_success: Het gesprek is succesvol losgemaakt.\ntype.link: Link\nthreads: Gesprekken\nselect_channel: Kies een kanaal\ncomments_count: '{0}Reacties|{1}Reactie|]1,Inf[ Reacties'\nreplies: Antwoorden\nmarkdown_howto: Hoe werkt het bewerkveld?\nfederated_magazine_info: Dit tijdschrift is van een gefedereerde server en is \n  mogelijk onvolledig.\nunsubscribe: Deabonneren\nreply: Beantwoorden\nyou_cant_login: Wachtwoord vergeten?\nrepeat_password: Wachtwoord herhalen\nagree_terms: Ik ga akkoord met de %terms_link_start%algemene \n  voorwaarden%terms_link_end% and %policy_link_start%Privacy \n  Policy%policy_link_end%\nreset_check_email_desc: 'Als er een account bestaat dat overeenkomt met je e-mailadres,\n  dan ontvang je op korte termijn een email met een link om je wachtwoord opnieuw\n  in te stellen. Deze link vervalt over %expire%.'\nboosts: Omhoogstemmen\ntags: Labels\nis_adult: 18+/NSFW\nimage_alt: Afbeeldingslabel\ngo_to_search: Ga naar zoeken\ntable_view: Tabelweergave\nvideos: Video's\nare_you_sure: Weet je het zeker?\nedit_comment: Wijzigingen opslaan\nreports: Gemelde berichten\nbanned_instances: Verbannen instanties\ndeleted: Verwijderd door de auteur\nsticky_navbar: Bovenbalk meeschuiven\nmod_log_alert: WAARSCHUWING - Het moderatielogboek kan onaangename of \n  schrijnende inhoud verwijderd door moderators bevatten. Wees voorzichtig.\nupload_file: Bestand uploaden\ncreated: Toegekend op\nadd_ban: Verbanning toekennen\nchange_language: Taal wijzigen\nadmin_panel: Beheerderspaneel\ntype_search_term: Voer een zoekopdracht in\nadd_mentions_entries: Vermeldingslabels toekennen aan gesprekken\nrelated_entries: Verwante gesprekken\nflash_magazine_new_success: Het tijdschrift is succesvol aangemaakt. Je kunt \n  inhoud toevoegen of het tijdschrift bekijken in het beheerpaneel.\nflash_magazine_edit_success: Het tijdschrift is succesvol bewerkt.\ntoo_many_requests: Het limiet is bereikt - probeer het later opnieuw.\nset_magazines_bar: Tijdschriftenbalk\nset_magazines_bar_desc: Voeg tijdschriftnamen toe achter de komma\nset_magazines_bar_empty_desc: Als het veld blanco is, dan worden actieve \n  tijdschriften op de balk getoond.\nmod_remove_your_thread: Een moderator heeft je gesprek verwijderd\nadded_new_thread: Gesprek toegevoegd\nedited_thread: Gesprek bewerkt\nadded_new_comment: Plaats een nieuwe reactie\nedited_comment: Reactie bewerkt\nreplied_to_your_comment: Heeft je reactie beantwoord\nmod_deleted_your_comment: Een moderator heeft je reactie verwijderd\nadded_new_post: Bericht geplaatst\nedited_post: Bericht bewerkt\nmod_remove_your_post: Een moderator heeft je bericht verwijderd\nadded_new_reply: Nieuwe reactie is geplaatst\nwrote_message: Bericht geschreven\nbanned: Heeft je verbannen\nremoved: Verwijderd door een moderator\nmentioned_you: Heeft je vermeld\ncomment: Reactie\npost: Bericht\npurge: Vernietigen\nsend_message: Direct bericht versturen\nmessage: Bericht\ninfinite_scroll: Oneindig scrollen\nshow_top_bar: Bovenbalk tonen\nsubject_reported: Er is melding gemaakt van de inhoud.\nsidebar_position: Zijbalklocatie\nleft: Linkerkant\nright: Rechterkant\nfederation: Verspreiding\nstatus: Status\non: Aan\noff: Uit\ninstances: Instanties\nfrom_url: Van url\nmagazine_panel: Tijdschriftpaneel\nreject: Afwijzen\napprove: Goedkeuren\nban: Verbannen\nfilters: Filters\napproved: Goedgekeurd\nrejected: Afgewezen\nadd_moderator: Moderator toevoegen\nadd_badge: Embleem toekennen\nbans: Verbanningen\nexpires: Vervalt op\nperm: Permanent\nexpired_at: Vervallen op\nban_expired: De verbanperiode is afgelopen\ntrash: Prullenbak\nicon: Pictogram\ndone: Klaar\npin: Vastmaken\nunpin: Losmaken\nchange_magazine: Tijdschrift wijzigen\nchange: Wijzigen\npinned: Vastgemaakt\npreview: Voorvertoning\narticle: Artikel\nreputation: Reputatie\nnote: Opmerking\nwriting: Schrijven\nusers: Gebruikers\ncontent: Inhoud\nweek: Week\nweeks: Weken\nmonth: Maand\nmonths: Maanden\nyear: Jaar\nfederated: Verspreid\nlocal: Lokaal\ndashboard: Overzichtspaneel\ncontact_email: Contactadres\nmeta: Meta\ninstance: Instantie\npages: Pagina's\nFAQ: Veelgestelde vragen\nfederation_enabled: Verspreiding is ingeschakeld\nregistrations_enabled: Registratie zijn ingeschakeld\nregistration_disabled: Registraties zijn uitgeschakeld\nrestore: Herstellen\nadd_mentions_posts: Vermeldingslabels toekennen aan berichten\nPassword is invalid: Het wachtwoord is ongeldig.\nYour account is not active: Je account is inactief.\nYour account has been banned: Je account is verbannen.\nfirstname: Voornaam\nsend: Versturen\nactive_users: Actieve mensen\nrandom_entries: Willekeurige gesprekken\ndelete_account: Account verwijderen\npurge_account: Account vernietigen\nban_account: Account verbannen\nunban_account: Account toelaten\nrelated_magazines: Verwante tijdschriften\nrandom_magazines: Willekeurige tijdschriften\nmagazine_panel_tags_info: Alleen als je inhoud van het fediverse op basis van \n  labels in dit tijdschrift wilt tonen\nsidebar: Zijbalk\nauto_preview: Media automatisch voorvertonen\ndynamic_lists: Dynamische lijsten\nkbin_intro_title: Ontdek de Fediverse\nkbin_intro_desc: is een gedecentraliseerd platform voor het verzamelen van \n  inhoud en microbloggen dat opereert binnen het fediversenetwerk.\nkbin_promo_title: Zet je eigen instantie op\nkbin_promo_desc: '%link_start%Kloon de repo%link_end% en ontwikkel het fediverse'\ncaptcha_enabled: Captcha ingeschakeld\nheader_logo: Koplogo\nbrowsing_one_thread: Je bekijkt slechts één gesprek binnen deze discussie. Je \n  kunt alle reacties lezen op de berichtpagina.\nreturn: Terug\nboost: Omhoogstemmen\nmercure_enabled: Mercure ingeschakeld\nreport_issue: Rapporteer probleem\ntokyo_night: Tokyo Night\npreferred_languages: Filter op talen van gesprekken en berichten\ninfinite_scroll_help: Laad automatisch meer content zodra je de onderkant van de\n  pagina hebt bereikt.\nsticky_navbar_help: De navigatiebalk blijft bovenaan de pagina staan wanneer u \n  naar beneden scrolt.\nauto_preview_help: Geef de mediavoorbeelden (foto's, video's) in een groter \n  formaat weer onder de inhoud.\nreload_to_apply: Laad de pagina opnieuw om wijzigingen toe te passen\nfilter.origin.label: Kies herkomst\nfilter.fields.label: Kies in welke velden u wilt zoeken\nfilter.adult.hide: NSFW verbergen\nfilter.adult.show: NSFW weergeven\nfilter.adult.only: Enkel NSFW\nfilter.fields.only_names: Enkel op namen\nfilter.fields.names_and_descriptions: Namen en beschrijvingen\nfilter.adult.label: Kies of u NSFW (niet veilig voor werk) wilt weergeven\nlocal_and_federated: Lokaal en gefedereerd\nkbin_bot: Mbin Agent\nbot_body_content: \"Welkom bij de Mbin Agent! Deze bot agent speelt een cruciale rol\n  bij het inschakelen van de ActivityPub-functionaliteit binnen Mbin. Het zorgt ervoor\n  dat Mbin kan communiceren en federeren met andere instanties in de fediverse.\\n\\n\\\n  \\ ActivityPub is een open standaardprotocol waarmee gedecentraliseerde sociale netwerkplatforms\n  met elkaar kunnen communiceren en interacteren. Het stelt gebruikers in staat om\n  op verschillende instanties (servers) inhoud te volgen, te gebruiken en te delen\n  via het gefedereerde sociale netwerk dat bekend staat als het fediverse. Het biedt\n  een gestandaardiseerde manier voor gebruikers om inhoud te publiceren, andere gebruikers\n  te volgen en deel te nemen aan sociale interacties zoals liken, delen en reageren\n  op threads of berichten.\"\npassword_confirm_header: Bevestig het verzoek om je wachtwoord te wijzigingen.\nyour_account_is_not_active: Je account is niet geactiveerd. Controleer uw e-mail\n  voor instructies voor accountactivatie of <a href=\"%link_target%\">vraag een \n  nieuwe e-mail voor accountactivatie aan.</a>\nyour_account_has_been_banned: Je account is verbannen\ntoolbar.bold: Vetgedrukt\ntoolbar.italic: Cursief\ntoolbar.header: Koptekst\ntoolbar.quote: Citaat\ntoolbar.code: Code\ntoolbar.link: Koppeling\ntoolbar.image: Afbeelding\ntoolbar.strikethrough: Doorhalen\ntoolbar.unordered_list: Ongeordende lijst\ntoolbar.ordered_list: Geordende lijst\ntoolbar.mention: Vermelding\nfederation_page_disallowed_description: Instanties waarmee we niet mee \n  gefedereerd zijn\nfederated_search_only_loggedin: Federatieve zoekopdracht is beperkt indien niet \n  ingelogd\noauth2.grant.user.notification.read: Lees uw meldingen, inclusief \n  berichtmeldingen.\noauth2.grant.user.oauth_clients.edit: Bewerk de machtigingen die u aan andere \n  OAuth2-applicaties hebt verleend.\noauth2.grant.user.follow: Volg of ontvolg gebruikers en lees een lijst met \n  gebruikers die u volgt.\nmore_from_domain: Meer van domein\noauth2.grant.user.block: Blokkeer of deblokkeer gebruikers en lees een lijst met\n  gebruikers die u blokkeert.\nresend_account_activation_email_error: Er is een probleem opgetreden bij het \n  indienen van dit verzoek. Mogelijk is er geen account aan dat e-mailadres \n  gekoppeld of is het misschien al geactiveerd.\nresend_account_activation_email_success: Als er een account bestaat dat aan dat \n  e-mailadres is gekoppeld, sturen we een nieuwe activatie-e-mail.\noauth2.grant.moderate.all: Voer elke moderatieactie uit waarvoor u toestemming \n  heeft om deze uit te voeren in uw gemodereerde tijdschriften.\nresend_account_activation_email_description: Voer het e-mailadres in dat aan uw \n  account is gekoppeld. Wij sturen u nogmaals een activatie-e-mail.\noauth2.grant.moderate.entry.all: Beheer discussies in uw gemodereerde \n  tijdschriften.\noauth.consent.grant_permissions: Verleen machtigingen\noauth2.grant.moderate.entry.pin: Zet discussies vast bovenin uw gemodereerde \n  tijdschriften.\noauth.consent.app_requesting_permissions: wil namens u de volgende handelingen \n  uitvoeren\noauth2.grant.moderate.entry.set_adult: Markeer discussies als NSFW in uw \n  gemodereerde tijdschriften.\noauth.consent.app_has_permissions: kan de volgende acties al uitvoeren\noauth2.grant.moderate.entry.trash: Verwijder of herstel discussies in uw \n  gemodereerde tijdschriften.\noauth.consent.to_allow_access: Om deze toegang toe te staan, klikt u hieronder \n  op de knop 'Toestaan'\noauth2.grant.moderate.entry_comment.change_language: Verander de taal van \n  reacties in discussies in uw gemodereerde tijdschriften.\noauth.consent.allow: Toestaan\noauth.consent.deny: Weigeren\noauth2.grant.moderate.entry_comment.set_adult: Markeer reacties in discussies \n  als NSFW in uw gemodereerde tijdschriften.\noauth.client_identifier.invalid: Ongeldige OAuth-client-ID!\noauth.client_not_granted_message_read_permission: Deze app heeft geen \n  toestemming gekregen om jouw berichten te lezen.\nrestrict_oauth_clients: Beperk het maken van OAuth2-clients tot Beheerders\noauth2.grant.domain.all: Abonneer u op domeinen of geblokkeerde domeinen en \n  bekijk de domeinen waarop u zich abonneert of blokkeert bent.\noauth2.grant.domain.block: Blokkeer of deblokkeer domeinen en bekijk de domeinen\n  die u hebt geblokkeerd.\noauth2.grant.moderate.post.change_language: Verander de taal van berichten in uw\n  gemodereerde tijdschriften.\noauth2.grant.moderate.post.set_adult: Markeer berichten als NSFW in uw \n  gemodereerde tijdschriften.\noauth2.grant.entry.all: Maak, bewerk of verwijder uw discussies en stem, promoot\n  of rapporteer elke discussie.\noauth2.grant.moderate.post.trash: Verwijder of herstel berichten in uw \n  gemodereerde tijdschriften.\noauth2.grant.entry.vote: Stem een discussie omhoog, omlaag of boost.\noauth2.grant.moderate.post_comment.all: Beheer reacties op on berichten in uw \n  gemodereerde tijdschriften.\noauth2.grant.moderate.post_comment.change_language: Wijzig de taal van reacties \n  op berichten in uw gemodereerde tijdschriften.\noauth2.grant.entry.report: Rapporteer elke discussie.\noauth2.grant.moderate.post_comment.set_adult: Markeer reacties op berichten als \n  NSFW in uw gemodereerde tijdschriften.\noauth2.grant.entry_comment.edit: Bewerk uw bestaande opmerkingen in discussies.\noauth2.grant.moderate.post_comment.trash: Verwijder of herstel reacties op \n  berichten in uw gemodereerde tijdschriften.\noauth2.grant.entry_comment.report: Rapporteer elke opmerking in een discussie.\noauth2.grant.moderate.magazine.ban.all: Beheer verbannen gebruikers in uw \n  gemodereerde tijdschriften.\noauth2.grant.magazine.all: Abonneer u op tijdschriften of blokkeer ze en bekijk \n  de tijdschriften waarop u geabonneerd of geblokkeerd bent.\noauth2.grant.magazine.subscribe: Schrijf je in of uit op tijdschriften en bekijk\n  de tijdschriften waarop je geabonneerd bent.\noauth2.grant.post.all: Maak, bewerk of verwijder uw microblogs en stem, promoot \n  of rapporteer microblogs.\noauth2.grant.moderate.magazine.ban.delete: Hef de ban van gebruikers op in uw \n  gemodereerde tijdschriften.\noauth2.grant.post_comment.delete: Verwijder uw bestaande reacties op berichten.\noauth2.grant.user.all: Lees en bewerk uw profiel, berichten of meldingen; Lees- \n  en bewerk machtigingen die u aan andere apps hebt verleend; andere gebruikers \n  volgen of blokkeren; bekijk lijsten met gebruikers die u volgt of blokkeert.\noauth2.grant.moderate.magazine_admin.update: Bewerk de regels, beschrijving, \n  NSFW-status of het pictogram van uw eigen tijdschriften.\noauth2.grant.admin.all: Voer administratieve actie uit op uw instantie.\noauth2.grant.admin.entry.purge: Verwijder alle discussies volledig uit uw \n  instantie.\nlast_active: Laatst actief\noauth2.grant.moderate.magazine.list: Lees een lijst van uw gemodereerde \n  tijdschriften.\noauth2.grant.moderate.magazine.reports.all: Beheer rapporten in uw gemodereerde \n  tijdschriften.\noauth2.grant.moderate.magazine.reports.read: Lees rapporten in uw gemodereerde \n  tijdschriften.\noauth2.grant.moderate.magazine.reports.action: Accepteer of wijs rapporten af in\n  uw gemodereerde tijdschriften.\noauth2.grant.moderate.magazine.trash.read: Bekijk weggegooide inhoud in uw \n  gemodereerde tijdschriften.\noauth2.grant.moderate.magazine_admin.all: Maak, bewerk of verwijder uw eigen \n  tijdschriften.\noauth2.grant.moderate.magazine_admin.create: Nieuwe tijdschriften maken.\noauth2.grant.moderate.magazine_admin.delete: Verwijder alle tijdschriften die u \n  bezit.\noauth2.grant.moderate.magazine_admin.edit_theme: Bewerk de aangepaste CSS van al\n  uw tijdschriften.\noauth2.grant.moderate.magazine_admin.moderators: Voeg of verwijder moderators op\n  een van uw tijdschriften.\noauth2.grant.moderate.magazine_admin.badges: Maak of verwijder badges uit uw \n  eigen tijdschriften.\noauth2.grant.moderate.magazine_admin.tags: Maak of verwijder tags uit uw eigen \n  tijdschriften.\noauth2.grant.moderate.magazine_admin.stats: Bekijk de inhoud, stem en bekijk \n  statistieken van uw eigen tijdschriften.\nerrors.server500.title: 500 Interne Server Foutmelding\nerrors.server500.description: Sorry! Er is iets fout gegaan aan onze kant. Als u\n  deze fout blijft zien, kunt u contact opnemen met de eigenaar van deze \n  instantie. Als deze instantie helemaal niet werkt, bekijk dan in de tussentijd\n  %link_start%andere Mbin-instanties%link_end% totdat het probleem is opgelost.\nerrors.server429.title: 429 Te Veel Verzoeken\nerrors.server404.title: 404 Niet Gevonden\nerrors.server403.title: 403 Verboden\nemail_confirm_button_text: Bevestig uw verzoek tot wachtwoordwijziging\nemail_confirm_link_help: Als alternatief kunt u het volgende kopiëren en plakken\n  in uw browser\nemail.delete.title: Verzoek om verwijdering van gebruikersaccount\nemail.delete.description: De volgende gebruiker heeft verzocht om verwijdering \n  van zijn of haar account\nresend_account_activation_email_question: Inactief account?\nresend_account_activation_email: Stuur de accountactivatie-e-mail opnieuw\ncustom_css: Aangepaste CSS\nignore_magazines_custom_css: Negeer de aangepaste CSS op tijdschriften\noauth.consent.title: OAuth2-toestemmingsformulier\nblock: Blokkeren\nunblock: Deblokkeren\noauth2.grant.read.general: Lees alle inhoud waartoe u toegang heeft.\noauth2.grant.write.general: Maak of bewerk uw discussies, berichten of \n  opmerkingen.\noauth2.grant.delete.general: Verwijder al uw discussies, berichten of \n  opmerkingen.\noauth2.grant.report.general: Rapporteer discussies, berichten of opmerkingen.\noauth2.grant.vote.general: Stem omhoog, omlaag of promoot discussies, berichten \n  of opmerkingen.\noauth2.grant.subscribe.general: Abonneer of volg een tijdschrift, domein of \n  gebruiker en bekijk de tijdschriften, domeinen en gebruikers waarop u zich \n  abonneert.\noauth2.grant.block.general: Blokkeer of deblokkeer een tijdschrift, domein of \n  gebruiker en bekijk de tijdschriften, domeinen en gebruikers die u hebt \n  geblokkeerd.\noauth2.grant.domain.subscribe: Schrijf u in of uit op domeinen en bekijk de \n  domeinen waarop u zich abonneert.\noauth2.grant.entry.create: Maak nieuwe discussies.\noauth2.grant.entry.edit: Bewerk uw bestaande discussies.\noauth2.grant.entry.delete: Verwijder uw bestaande discussies.\noauth2.grant.entry_comment.all: Maak, bewerk of verwijder uw opmerkingen in \n  discussies, en stem, promoot of rapporteer elke opmerking in een discussie.\noauth2.grant.entry_comment.create: Maak nieuwe opmerkingen in discussies.\noauth2.grant.entry_comment.delete: Verwijder uw bestaande opmerkingen in \n  discussies.\noauth2.grant.entry_comment.vote: Stem een reactie in een thread omhoog, omlaag \n  of boost.\noauth2.grant.magazine.block: Blokkeer of deblokkeer tijdschriften en bekijk de \n  tijdschriften die je hebt geblokkeerd.\noauth2.grant.post.create: Maak nieuwe berichten.\noauth2.grant.post.edit: Bewerk uw bestaande berichten.\noauth2.grant.post.delete: Verwijder uw bestaande berichten.\noauth2.grant.post.vote: Stem een bericht omhoog, omlaag of boost.\noauth2.grant.post.report: Rapporteer elk bericht.\noauth2.grant.post_comment.all: Maak, bewerk of verwijder uw opmerkingen over \n  berichten, en stem, promoot of rapporteer opmerkingen over een bericht.\noauth2.grant.post_comment.create: Maak nieuwe reacties op berichten.\noauth2.grant.post_comment.edit: Bewerk uw bestaande reacties op berichten.\noauth2.grant.post_comment.vote: Stem een reactie op een bericht omhoog, omlaag \n  of boost.\noauth2.grant.post_comment.report: Rapporteer elke reactie op een bericht.\noauth2.grant.user.profile.all: Lees en bewerk uw profiel.\noauth2.grant.user.profile.read: Lees je profiel.\noauth2.grant.user.profile.edit: Pas je profiel aan.\noauth2.grant.user.message.all: Lees uw berichten en stuur berichten naar andere \n  gebruikers.\noauth2.grant.user.message.read: Lees uw berichten.\noauth2.grant.user.message.create: Stuur berichten naar andere gebruikers.\noauth2.grant.user.notification.all: Lees en wis uw meldingen.\noauth2.grant.user.notification.delete: Wis uw meldingen.\noauth2.grant.user.oauth_clients.all: Lees en bewerk de machtigingen die u aan \n  andere OAuth2-applicaties hebt verleend.\noauth2.grant.user.oauth_clients.read: Lees de machtigingen die u aan andere \n  OAuth2-applicaties hebt verleend.\noauth2.grant.moderate.entry.change_language: Verander de taal van de discussies \n  in uw gemodereerde tijdschriften.\noauth2.grant.moderate.entry_comment.all: Modereer reacties in discussies in uw \n  gemodereerde tijdschriften.\nfederation_page_enabled: Federatiepagina ingeschakeld\nfederation_page_allowed_description: Bekende instanties waarmee we federeren\noauth2.grant.moderate.entry_comment.trash: Verwijder of herstel reacties in \n  discussies in uw gemodereerde tijdschriften.\noauth2.grant.moderate.post.all: Beheer berichten in uw gemodereerde \n  tijdsschriften.\noauth2.grant.moderate.magazine.all: Beheer verbannen, rapporteer en bekijk \n  verwijderde items in uw gemodereerde tijdschriften.\noauth2.grant.moderate.magazine.ban.read: Bekijk verbannen gebruikers in uw \n  gemodereerde tijdschriften.\noauth2.grant.moderate.magazine.ban.create: Verban gebruikers in uw gemodereerde \n  tijdschriften.\nflash_post_pin_success: Het bericht is succesvol vastgezet.\nflash_post_unpin_success: Het bericht is succesvol losgemaakt.\noauth2.grant.admin.entry_comment.purge: Verwijder alle reacties in een gesprek \n  volledig uit jouw instantie.\noauth2.grant.admin.magazine.all: Verplaats gesprekken tussen tijdschriften of \n  verwijder ze volledig op jouw instantie.\noauth2.grant.admin.post.purge: Verwijder elk bericht volledig uit jouw \n  instantie.\noauth2.grant.admin.post_comment.purge: Verwijder alle reacties op een bericht \n  volledig uit jouw instantie.\noauth2.grant.admin.magazine.move_entry: Verplaats gesprekken tussen \n  tijdschriften op jouw instantie.\noauth2.grant.admin.magazine.purge: Verwijder tijdschriften op jouw instantie \n  volledig.\noauth2.grant.admin.user.all: Gebruikers op jouw instantie verbannen, verifiëren \n  of volledig verwijderen.\noauth2.grant.admin.user.ban: Gebruikers van jouw instantie verbannen of de \n  verbanning opheffen.\noauth2.grant.admin.user.verify: Verifieer gebruikers op jouw instantie.\noauth2.grant.admin.user.delete: Verwijder gebruikers uit jouw instantie.\noauth2.grant.admin.user.purge: Verwijder gebruikers volledig uit jouw instantie.\noauth2.grant.admin.instance.all: Bekijk en update instantie instellingen of \n  informatie.\noauth2.grant.admin.instance.stats: Bekijk de statistieken van jouw instantie.\noauth2.grant.admin.instance.settings.all: Bekijk of update de instellingen op \n  jouw instantie.\noauth2.grant.admin.instance.settings.read: Bekijk de instellingen op jouw \n  instantie.\noauth2.grant.admin.instance.settings.edit: Update de instellingen op jouw \n  instantie.\noauth2.grant.admin.instance.information.edit: \"Werk de pagina's: Over, Veelgestelde\n  vragen, Contact, Servicevoorwaarden en Privacybeleid bij op jouw instantie.\"\noauth2.grant.admin.federation.all: Bekijk en update momenteel gedefedereerde \n  instanties.\noauth2.grant.admin.federation.read: Bekijk de lijst met de-federatieve \n  instanties.\noauth2.grant.admin.federation.update: Voeg instanties toe aan of verwijder ze \n  uit de lijst met de-federatieve instanties.\noauth2.grant.admin.oauth_clients.all: Bekijk of trek OAuth2-clients in die op \n  jouw instanties bestaan.\noauth2.grant.admin.oauth_clients.read: Bekijk de OAuth2-clients die op jouw \n  instantie aanwezig zijn, en hun gebruiksstatistieken.\noauth2.grant.admin.oauth_clients.revoke: Trek de toegang tot OAuth2-clients op \n  jouw instantie in.\nshow_avatars_on_comments: Toon reactie profielfoto's\nsingle_settings: Enkel\ncomment_reply_position_help: Toon het reactie-antwoordformulier bovenaan of \n  onderaan de pagina. Wanneer 'oneindig scrollen' is ingeschakeld, verschijnt de\n  positie altijd bovenaan.\nupdate_comment: Reactie bijwerken\nshow_avatars_on_comments_help: Toon/verberg profielfoto's bij het bekijken van \n  reacties op een enkele discussie of post.\ncomment_reply_position: Commentaar reactie positie\nmagazine_theme_appearance_custom_css: Aangepaste CSS die van toepassing is bij \n  het bekijken van inhoud in uw tijdschrift.\nmagazine_theme_appearance_icon: Aangepast pictogram voor het tijdschrift.\nmagazine_theme_appearance_background_image: Aangepaste CSS die van toepassing is\n  bij het bekijken van inhoud in uw tijdschrift.\nmoderation.report.approve_report_title: Rapport Goedkeuren\nmoderation.report.reject_report_title: Rapport Afwijzen\nmoderation.report.ban_user_description: Wilt u de gebruiker (%username%) \n  verbannen die deze inhoud heeft gemaakt van dit tijdschrift?\nmoderation.report.approve_report_confirmation: Weet u zeker dat u dit rapport \n  wilt goedkeuren?\nsubject_reported_exists: Deze inhoud is al reeks gerapporteerd.\nmoderation.report.ban_user_title: Verban gebruiker\nmoderation.report.reject_report_confirmation: Weet u zeker dat u dit rapport \n  wilt afwijzen?\n2fa.authentication_code.label: Authenticatiecode\n2fa.remove: Verwijder 2FA\ndelete_content_desc: Verwijder de inhoud van de gebruiker maar laat de reacties \n  van andere gebruikers achter in de gemaakte discussies, berichten en \n  opmerkingen.\n2fa.backup_codes.help: U kunt deze codes gebruiken als u niet over een apparaat \n  of app voor tweefactorauthenticatie beschikt. Je krijgt ze <strong>niet meer \n  te zien</strong> en je kunt ze enkel <strong>slechts één keer</strong> \n  gebruiken.\n2fa.verify: Verifiëren\n2fa.add: Voeg toe aan mijn account\n2fa.disable: Schakel tweefactorauthenticatie uit\npurge_content: Inhoud leegmaken\noauth2.grant.moderate.post.pin: Pin berichten bovenaan uw gemodereerde \n  tijdschriften.\n2fa.backup_codes.recommendation: Het wordt aanbevolen dat u een kopie ervan op \n  een veilige plaats bewaart.\n2fa.qr_code_img.alt: Een QR-code waarmee tweefactorauthenticatie voor uw account\n  ingesteld kan worden\ndelete_account_desc: Verwijder het account, inclusief de reacties van andere \n  gebruikers in gemaakte discussies, berichten en opmerkingen.\n2fa.enable: Stel tweefactorauthenticatie in\n2fa.user_active_tfa.title: Gebruiker heeft actieve 2FA\n2fa.code_invalid: De authenticatiecode is niet geldig\n2fa.available_apps: Gebruik een tweefactorauthenticatie-app zoals \n  %google_authenticator%, %aegis% (Android) of %raivo% (iOS) om de QR-code te \n  scannen.\ncancel: Annuleren\npurge_content_desc: De inhoud van de gebruiker volledig opschonen, inclusief het\n  verwijderen van de reacties van andere gebruikers in gemaakte discussies, \n  berichten en opmerkingen.\ndelete_content: Inhoud verwijderen\ntwo_factor_backup: Back-upcodes voor tweefactorauthenticatie\npassword_and_2fa: Wachtwoord & 2FA\n2fa.backup-create.label: Maak nieuwe back-upverificatiecodes\n2fa.backup: Uw twee-factor back-upcodes\n2fa.qr_code_link.title: Als u deze link bezoekt, kan uw platform deze \n  tweefactorauthenticatie mogelijk registreren\n2fa.backup-create.help: U kunt nieuwe back-upauthenticatiecodes aanmaken; Als u \n  dit doet, worden bestaande codes ongeldig.\ntwo_factor_authentication: Tweefactorauthenticatie\n2fa.verify_authentication_code.label: Voer een tweefactorcode in om de \n  configuratie te verifiëren\nflash_account_settings_changed: Uw accountinstellingen zijn succesvol gewijzigd.\n  U dient opnieuw in te loggen.\nclose: Sluiten\nflash_post_new_success: Het bericht is succesvol aangemaakt.\nflash_comment_edit_error: Kan reactie niet bewerken. Er is iets fout gegaan.\nflash_comment_new_error: Kan reactie niet aanmaken. Er is iets fout gegaan.\nflash_post_new_error: Bericht kon niet worden gemaakt. Er is iets fout gegaan.\nflash_thread_new_error: Discussie kon niet worden gemaakt. Er is iets fout \n  gegaan.\nflash_magazine_theme_changed_error: Kan het uiterlijk van het tijdschrift niet \n  bijwerken.\nflash_post_edit_error: Kan bericht niet bewerken. Er is iets fout gegaan.\nflash_post_edit_success: Bericht is succesvol bewerkt.\nflash_user_edit_password_error: Kan wachtwoord niet wijzigen.\nflash_magazine_theme_changed_success: Het uiterlijk van het tijdschrift is \n  bijgewerkt.\nflash_thread_edit_error: Kan thread niet bewerken. Er is iets fout gegaan.\nsidebars_same_side: Zijbalken aan dezelfde kant\nflash_user_edit_email_error: Kan het e-mailadres niet wijzigen.\nflash_comment_new_success: Reactie is succesvol aangemaakt.\nflash_user_settings_general_error: Kan gebruikersinstellingen niet opslaan.\nalphabetically: Alfabetisch\nflash_email_was_sent: E-mail is succesvol verzonden.\nshow_subscriptions: Toon abonnementen\nflash_comment_edit_success: Reactie is succesvol bijgewerkt.\nflash_user_edit_profile_success: Gebruikersprofielinstellingen zijn succesvol \n  opgeslagen.\nsubscription_sort: Sorteren\nflash_user_settings_general_success: Gebruikersinstellingen succesvol \n  opgeslagen.\npending: In behandeling\nsubscriptions_in_own_sidebar: In aparte zijbalk\nflash_email_failed_to_sent: E-mail kon niet worden verzonden.\nflash_user_edit_profile_error: Kan profielinstellingen niet opslaan.\npage_width_fixed: Vast\npage_width_auto: Auto\nposition_bottom: Onder\npage_width_max: Max\npage_width: Paginabreedte\nposition_top: Boven\nchange_my_cover: Verander mijn cover\nopen_url_to_fediverse: Originele URL openen\nchange_my_avatar: Verander mijn avatar\nrestore_magazine: Tijdschrift herstellen\naccount_settings_changed: Uw accountinstellingen zijn succesvol gewijzigd. U \n  dient opnieuw in te loggen.\nsuspend_account: Account opschorten\naccount_suspended: Het account is opgeschort.\nsubscription_header: Geabonneerde tijdschriften\ndeletion: Verwijdering\naccount_unbanned: Het account is gedeblokkeerd.\nmagazine_is_deleted: Tijdschrift is verwijderd. U kunt het binnen 30 dagen <a \n  href=\"%link_target%\">herstellen</a>.\naccount_is_suspended: Gebruikersaccount is opschorting is opgeheven.\nuser_suspend_desc: Als u uw account opschort, wordt uw inhoud op de instantie \n  verborgen, maar niet permanent verwijderd. U kunt deze op elk gewenst moment \n  herstellen.\naccount_unsuspended: De account is niet langer opgeschort.\nsubscription_panel_large: Groot paneel\nunsuspend_account: Account opschorting opheffen\naccount_banned: Het account is geblokkeerd.\ndelete_magazine: Tijdschrift verwijderen\nsubscription_sidebar_pop_out_right: Ga naar de aparte zijbalk aan de rechterkant\nsubscription_sidebar_pop_out_left: Ga naar de aparte zijbalk aan de linkerkant\npurge_magazine: Tijdschrift opruimen\nmagazine_deletion: Tijdschrift verwijderen\nsubscription_sidebar_pop_in: Verplaats abonnementen naar het inline paneel\nedit_my_profile: Bewerk mijn profiel\nuser_badge_moderator: Mod\ndefault_theme: Standaard thema\nremove_following: Verwijder volgen\nmark_as_adult: Markeer NSFW\nuser_badge_admin: Admin\n2fa.setup_error: Fout bij het inschakelen van 2FA voor account\nuser_badge_global_moderator: Globale Mod\napply_for_moderator: Solliciteer als moderator\nunmark_as_adult: Onmarkeer NSFW\ndeleted_by_author: Gesprek, bericht of reactie is verwijderd door de auteur\nsolarized_auto: Gesolariseerd (Automatische detectie)\nannouncement: Aankondiging\nsensitive_toggle: Schakel de zichtbaarheid van gevoelige inhoud in of uit\nsensitive_show: Klik om te tonen\nuser_badge_bot: Bot\nownership_requests: Eigendomsverzoeken\nsensitive_warning: Gevoelige inhoud\nkeywords: Trefwoorden\nmoderator_requests: Mod verzoeken\ndefault_theme_auto: Licht/Donker (Automatische detectie)\naction: Actie\nflash_mark_as_adult_success: Het bericht is succesvol gemarkeerd als NSFW.\ncancel_request: Annuleer verzoek\ndeleted_by_moderator: Gesprek, bericht of reactie is verwijderd door de \n  moderator\nflash_unmark_as_adult_success: Het bericht is met succes ongemarkeerd als NSFW.\nabandoned: Verlaten\nremove_subscriptions: Verwijder abonnementen\nsensitive_hide: Klik om te verbergen\nrequest_magazine_ownership: Vraag tijdschrifteigendom aan\naccept: Accepteer\nuser_badge_op: OP\nsubscribers_count: '{0}Abonnees|{1}Abonnee|]1,Inf[ Abonnees'\nmenu: Menu\ndetails: Details\nfollowers_count: '{0}Volgers|{1}Volger|]1,Inf[ Volgers'\nremove_media: Media verwijderen\nall_time: Alle tijden\nspoiler: Spoiler\nshow: Tonen\nhide: Verbergen\nedited: bewerkt\ndisconnected_magazine_info: Dit tijdschrift heeft geen updates ontvangen \n  (laatste activiteit %days% dag(en) geleden).\nsubscribe_for_updates: Abonneer om updates te ontvangen.\nsso_registrations_enabled: SSO -registraties ingeschakeld\nsso_registrations_enabled.error: Nieuwe accountregistraties met \n  identiteitsbeheerders van derden zijn momenteel uitgeschakeld.\nalways_disconnected_magazine_info: Dit tijdschrift heeft geen updates ontvangen.\nmarked_for_deletion_at: Gemarkeerd voor verwijdering op %date%\naccount_deletion_title: Accountverwijdering\naccount_deletion_button: Account verwijderen\naccount_deletion_immediate: Onmiddellijk verwijderen\nmarked_for_deletion: Gemarkeerd voor verwijdering\naccount_deletion_description: Uw account wordt binnen 30 dagen verwijderd, \n  tenzij u ervoor kiest om het account onmiddellijk te verwijderen. Om uw \n  account binnen 30 dagen te herstellen, logt u in met dezelfde \n  gebruikersreferenties of neemt u contact op met een beheerder.\nremove_schedule_delete_account_desc: Verwijder de geplande verwijdering. Alle \n  inhoud is weer beschikbaar en de gebruiker kan inloggen.\nschedule_delete_account: Plan Verwijdering\nschedule_delete_account_desc: Plan de verwijdering van dit account over 30 \n  dagen. Hierdoor worden de gebruiker en zijn inhoud verborgen en kan de \n  gebruiker niet inloggen.\nremove_schedule_delete_account: Geplande verwijdering verwijderen\nfrom: van\nban_hashtag_description: Als je een hashtag verbiedt, dan worden er geen \n  berichten met deze hashtag meer aangemaakt en tevens worden bestaande \n  berichten met deze hashtag verborgen.\nrestrict_magazine_creation: Beperk het maken van lokale tijdschriften tot \n  beheerders en algemene moderatoren\ndirect_message: Direct bericht\nmagazine_log_mod_added: heeft een moderator toegevoegd\nmagazine_log_mod_removed: heeft een moderator verwijderd\ntag: Label\nunban: Ban opheffen\nban_hashtag_btn: Ban Hastag\nunban_hashtag_btn: Opheffen Hashtag Ban\nunban_hashtag_description: Als je een ban van een hashtag ongedaan maakt, dan \n  kun je weer berichten met deze hashtag aanmaken. Bestaande berichten met deze \n  hashtag zijn niet langer verborgen.\nprivate_instance: Dwing gebruikers om in te loggen voordat ze toegang krijgen \n  tot inhoud\nflash_thread_tag_banned_error: Discussie kon niet worden aangemaakt. Deze inhoud\n  is niet toegestaan.\nfilter_labels: Filter labels\nsso_only_mode: Beperk inloggen en registratie alleen tot SSO (Single-Sign-On) \n  methoden\nrelated_entry: Gerelateerd\ncake_day: Taart dag\nsomeone: Iemand\nlast_updated: Laatst bijgewerkt\nsort_by: Sorteer op\nfilter_by_subscription: Filter op abonnement\nfilter_by_federation: Filter op federatiestatus\nauto: Automatisch\nback: Terug\nand: en\nsso_show_first: Toon SSO (Single-Sign-On) als eerste op de inlog-en \n  registratiepagina's\ncontinue_with: Daargaan met\nmagazine_log_entry_pinned: vastgezette gesprek\nmagazine_log_entry_unpinned: verwijderde vastgezette gesprek\nown_content_reported_accepted: Een melding over uw inhoud was geaccepteerd.\nopen_report: Open melding\nreport_accepted: Een melding was geaccepteerd\nshow_related_entries: Toon willekeurige gesprekken\nshow_related_magazines: Toon willekeurige tijdschriften\nshow_related_posts: Toon willekeurige berichten\nshow_active_users: Toon actieve gebruikers\nfederation_page_dead_title: Dode instanties\nfederation_page_dead_description: Instanties waarin we niet minimaal 10 \n  activiteiten achter elkaar konden leveren en waarbij de laatste succesvolle \n  aflevering en ontvangst meer dan een week geleden was\nreporting_user: Rapporterende gebruiker\nreported: gerapporteerd\nreport_subject: Onderwerp\nown_report_rejected: Uw melding was afgewezen\nown_report_accepted: Uw rapport is geaccepteerd\nmanually_approves_followers: Volgers handmatig goedkeuren\nregister_push_notifications_button: Registreer voor Pushmeldingen\nunregister_push_notifications_button: Verwijder Push registratie\ntest_push_notifications_button: Test pushmeldingen\ntest_push_message: Hallo Wereld!\nnotification_title_new_comment: New commentaar\nnotification_title_mention: Je werd genoemd\nnotification_title_new_reply: Nieuw Antwoord\nnotification_title_new_thread: Nieuw gesprek\nnotification_title_new_report: Een nieuwe melding is aangemaakt\nflash_posting_restricted_error: Het maken van gesprekken is beperkt tot \n  moderators van dit tijdschrift en jij bent er geen van\nserver_software: Server software\nversion: Versie\nreported_user: Gerapporteerde gebruiker\nnotification_title_removed_comment: Een opmerking is verwijderd\nnotification_title_ban: Je bent verbannen\nnotification_title_removed_post: Een bericht is verwijderd\nnotification_title_removed_thread: Een gesprek is verwijderd\nnotification_title_message: Nieuw direct bericht\nnotification_title_edited_comment: Een opmerking is gewijzigd\nnotification_title_new_post: Nieuw Bericht\nnotification_title_edited_thread: Een gesprek is gewijzgd\nnotification_title_edited_post: Een bericht is gewijzigd\nmagazine_posting_restricted_to_mods_warning: Enkel moderators kunnen een nieuw \n  gesprek aanmaken in dit tijdschrift\nlast_successful_deliver: Laatste succesvolle levering\nlast_successful_receive: Laatste succesvolle ontvangst\nlast_failed_contact: Laatste mislukte contact\nmagazine_posting_restricted_to_mods: Beperk het aanmaken van gesprekken tot \n  moderators\nnew_user_description: Deze gebruiker is nieuw (actief voor minder dan %days% \n  dagen)\nnew_magazine_description: Dit tijdschrift is nieuw (actief voor minder dan \n  %days% dagen)\nflash_image_download_too_large_error: Afbeelding kon niet worden aangemaakt, \n  deze is te groot (max. grootte %bytes%)\nadmin_users_banned: Geband\nmax_image_size: Maximale bestandsgrootte\nuser_verify: Account activeren\nadmin_users_active: Actief\nadmin_users_inactive: Inactief\nadmin_users_suspended: Opgeschort\nedit_entry: Bewerk gesprek\ndownvotes_mode: Omlaagstem-modus\nchange_downvotes_mode: Wijzig omlaagstem modus\ndisabled: Uitgeschakeld\nhidden: Verborgen\nenabled: Ingeschakeld\ntoolbar.spoiler: Spoiler\ncomment_not_found: Commentaar niet gevonden\ntable_of_contents: Inhoudsopgave\nnotification_body2_new_signup_approval: Je moet het verzoek goedkeuren voordat \n  ze kunnen inloggen\nbookmark_remove_from_list: Bladwijzer verwijderen uit %list%\nbookmark_remove_all: Verwijder alle bladwijzers\nbookmark_add_to_default_list: Bladwijzer toevoegen aan standaardlijst\nbookmark_lists: Bladwijzerlijst\nbookmarks_list: Bladwijzers in %list%\ncount: Aantal\nis_default: Is Standaard\nbookmark_list_create: Aanmaken\nbookmark_list_create_placeholder: type naam...\nbookmark_list_create_label: Lijstnaam\nbookmarks_list_edit: Bladwijzerlijst bewerken\nbookmark_list_edit: Bewerken\nbookmark_list_selected_list: Geselecteerde lijst\nsearch_type_all: Alles\nsearch_type_post: Microblogs\nselect_user: Kies een gebruiker\nnew_users_need_approval: Nieuwe gebruikers moeten door een beheerder worden \n  goedgekeurd voordat ze kunnen inloggen.\nsignup_requests: Aanmeldingsverzoeken\napplication_text: Leg uit waarom je lid wilt worden\nsignup_requests_header: Aanmeldingsverzoeken\nflash_application_info: Een beheerder moet uw account goedkeuren voordat u kunt \n  inloggen. U ontvangt een e-mail zodra uw registratieverzoek is verwerkt.\nemail_application_approved_title: Uw registratieverzoek is goedgekeurd\nemail_application_rejected_title: Uw registratieverzoek is afgewezen\nemail_application_rejected_body: Hartelijk dank voor uw interesse, maar helaas \n  moeten wij u mededelen dat uw aanmeldingsverzoek is afgewezen.\nemail_application_pending: Voordat u kunt inloggen, is goedkeuring van de \n  beheerder nodig.\nemail_verification_pending: U moet uw e-mailadres verifiëren voordat u kunt \n  inloggen.\nyour_account_is_not_yet_approved: Uw account is nog niet goedgekeurd. We sturen \n  u een e-mail zodra de beheerders uw registratieverzoek hebben verwerkt.\nnotification_title_new_signup: Een nieuwe gebruiker heeft zich geregistreerd\nbookmark_add_to_list: Bladwijzer toevoegen aan %list%\nnotification_body_new_signup: De gebruiker %u% heeft zich geregistreerd.\nbookmarks: Bladwijzers\nbookmark_list_is_default: Is standaard lijst\nsearch_type_entry: Discussies\nsignup_requests_paragraph: Deze gebruikers willen graag lid worden van uw \n  server. Ze kunnen niet inloggen totdat je hun registratieverzoeken hebt \n  goedgekeurd.\nemail_application_approved_body: Uw registratieverzoek is goedgekeurd door de \n  server admins. U kunt nu inloggen op de server op <a \n  href=\"%link%\">%siteName%</a>.\nnotify_on_user_signup: Nieuwe aanmeldingen\nbookmark_list_make_default: Maak Standaard\noauth2.grant.user.bookmark_list.delete: Verwijder jouw bladwijzerlijsten\ncompact_view_help: Een compacte weergave met minder marges, waarbij de media \n  naar de rechterkant is verplaatst.\nshow_thumbnails_help: Toon de miniatuurafbeeldingen.\nshow_magazines_icons_help: Geef het tijdschrifticoon weer.\nimage_lightbox_in_list_help: Als het is aangevinkt, wordt door te klikken op de \n  miniatuur een dialoogvenster met een afbeeldingsvak weergegeven. Als het niet \n  is aangevinkt, wordt door te klikken op de miniatuur de thread geopend.\nviewing_one_signup_request: Je bekijkt slechts één registratieverzoek van \n  %username%\nfront_default_sort: Standaard sortering op voorpagina\nby: op\nshow_users_avatars_help: Geef de avatarafbeelding van de gebruiker weer.\noauth2.grant.user.bookmark: Bladwijzers toevoegen en verwijderen\noauth2.grant.user.bookmark.add: Bladwijzers toevoegen\noauth2.grant.user.bookmark.remove: Bladwijzers verwijderen\noauth2.grant.user.bookmark_list: Lees, bewerk en verwijder jouw \n  bladwijzerlijsten\noauth2.grant.user.bookmark_list.read: Lees jouw bladwijzerlijsten\noauth2.grant.user.bookmark_list.edit: Bewerk jouw bladwijzerlijsten\nshow_magazine_domains: Toon domeinen tijdschrift\nanswered: beantwoord\ncomment_default_sort: Standaard sortering op opmerkingen\nopen_signup_request: Open inschrijvingsverzoek\nimage_lightbox_in_list: Thread-miniaturen openen volledig scherm\nshow_user_domains: Gebruikersdomeinen weergeven\nremove_user_avatar: Avatar verwijderen\nremove_user_cover: Cover verwijderen\nshow_new_icons: Laat \"nieuw\" pictogrammen zien\nshow_new_icons_help: Toon pictogram voor nieuw tijdschrift/gebruiker (30 dagen \n  oud of nieuwer)\ntoolbar.emoji: Emoji\n2fa.manual_code_hint: Als u de QR-code niet kunt scannen, voer dan het \n  geheimcode handmatig in\ncrosspost: Kruistpost\nbanner: Banner\ntype_search_term_url_handle: Typ zoekterm, url of gebruikersnaam\nmagazine_theme_appearance_banner: Aangepaste banner voor het tijdschrift. Deze \n  wordt boven alle discussies weergegeven en moet een brede beeldverhouding \n  hebben (5:1, of 1500px * 300px).\nflash_thread_ref_image_not_found: De afbeelding waarnaar 'imageHash' verwijst, \n  kon niet worden gevonden.\nsearch_type_magazine: Tijdschriften\nsearch_type_user: Gebruikers\nsearch_type_actors: Tijdschriften + Gebruikers\nsearch_type_content: Discussies + Microblogs\nmagazine_instance_defederated_info: De instantie van dit tijdschrift is \n  gedefedereerd. Het tijdschrift ontvangt daarom geen updates.\nuser_instance_defederated_info: De instantie van deze gebruiker is \n  gedefedereerd.\nflash_thread_instance_banned: De instantie van dit tijdschrift is verbannen.\nshow_rich_mention: Rijke vermeldingen\nshow_rich_mention_help: Geef een gebruikerscomponent weer wanneer een gebruiker \n  wordt genoemd. Dit omvat de weergavenaam en profielfoto.\nshow_rich_mention_magazine: Rijke tijdschriften vermeldingen\nshow_rich_mention_magazine_help: Geef een tijdschriftcomponent weer wanneer een \n  tijdschrift wordt genoemd. Dit omvat de weergavenaam en het pictogram.\nshow_rich_ap_link: Rijke AP-koppelingen\nshow_rich_ap_link_help: Geef een inline-component weer wanneer er naar andere \n  ActivityPub-inhoud wordt gelinkt.\nattitude: Houding\ntype_search_magazine: Beperk uw zoekopdracht tot tijdschrift...\ntype_search_user: Beperk zoekopdracht tot auteur...\nmodlog_type_entry_deleted: Discussie is verwijderd\nmodlog_type_entry_restored: Discussie is hersteld\nmodlog_type_entry_comment_deleted: Discussie commentaar is verwijderd\nmodlog_type_entry_comment_restored: Discussie commentaar is hersteld\nmodlog_type_entry_pinned: Discussie vastgezet\nmodlog_type_entry_unpinned: Discussie losmaken\nmodlog_type_post_deleted: Microblog verwijderd\nmodlog_type_post_restored: Microblog hersteld\nmodlog_type_post_comment_deleted: Microblog-antwoord verwijderd\nmodlog_type_post_comment_restored: Microblog-antwoord hersteld\nmodlog_type_ban: Gebruiker verbannen uit tijdschrift\nmodlog_type_moderator_add: Moderator voor het tijdschrift toegevoegd\nmodlog_type_moderator_remove: Magazine moderator verwijderd\neveryone: Iedereen\nnobody: Niemand\nfollowers_only: Alleen volgers\ndirect_message_setting_label: Wie kan jou een direct bericht sturen\ndelete_magazine_icon: Tijdschriftpictogram verwijderen\nflash_magazine_theme_icon_detached_success: Tijdschriftpictogram succesvol \n  verwijderd\ndelete_magazine_banner: Verwijder tijdschriftbanner\nflash_magazine_theme_banner_detached_success: Tijdschriftbanner succesvol \n  verwijderd\nfederation_uses_allowlist: Gebruik een toegestane lijst voor federatie\ndefederating_instance: Defederatie-instantie %i\ntheir_user_follows: Aantal gebruikers van hun instantie die gebruikers op onze \n  instantie volgen\nour_user_follows: Aantal gebruikers van onze instantie dat gebruikers op hun \n  instantie volgen\ntheir_magazine_subscriptions: Aantal gebruikers van hun instantie dat zich heeft\n  geabonneerd op tijdschriften op onze instantie\nour_magazine_subscriptions: Aantal gebruikers op ons instantie dat zich heeft \n  geabonneerd op tijdschriften op hun instantie\nconfirm_defederation: Bevestig de defederatie\nflash_error_defederation_must_confirm: U moet de defederatie bevestigen\nallowed_instances: Toegestane instanties\nbtn_deny: Weigeren\nbtn_allow: Toestaan\nban_instance: Ban instantie\nallow_instance: Sta instantie toe\nfederation_page_use_allowlist_help: Als een toegestane lijst wordt gebruikt, zal\n  deze instantie alleen federeren met de expliciet toegestane instanties. Anders\n  zal deze instantie federeren met elke instantie, behalve met instanties die \n  geblokkeerd zijn.\nfront_default_content: Standaardweergave voorpagina\ndefault_content_default: Serverstandaard (Gesprekken)\ndefault_content_microblog: Microblog\ndefault_content_threads: Gesprekken\ncombined: Gecombineerd\nsidebar_sections_random_local_only: Beperk de 'Willekeurige threads/berichten' \n  in de zijblak tot alleen lokaal\nsidebar_sections_users_local_only: Beperk de 'Actieve mensen' in de zijbalk tot \n  alleen lokaal\nrandom_local_only_performance_warning: Het inschakelen van 'Alleen willekeurig \n  lokaal' kan gevolgen hebben voor de SQL prestaties.\ndefault_content_combined: Gesprekken + Microblog\nban_expires: Ban vervalt\nyou_have_been_banned_from_magazine: Je bent verbannen uit het tijdschrift %m.\nyou_have_been_banned_from_magazine_permanently: Je bent permanent verbannen uit \n  het tijdschrift %m.\nyou_are_no_longer_banned_from_magazine: Je bent niet langer verbannen uit \n  tijdschrift %m.\n"
  },
  {
    "path": "translations/messages.pl.yaml",
    "content": "type.photo: Obraz\ndone: Gotowe\ntype.link: Link\ntype.video: Video\nimage: Obraz\n1y: 1r\nexpand: Rozwiń\nerror: Błąd\nicon: Ikona\npin: Przypnij\nunpin: Odepnij\nmonth: Miesiąc\nmessage: Wiadomość\ninfinite_scroll: Nieskończone przewijanie\nshow_top_bar: Pokaż top bar\nsticky_navbar: Przyklejony pasek nawigacyjny\nsubject_reported: Treść została zgłoszona.\nsidebar_position: Pozycja sidebara\nleft: Lewo\nright: Prawo\nfederation: Federacja\nstatus: Status\non: Wł\noff: Wył\ninstances: Instancje\nupload_file: Dołącz plik\nfrom_url: Dołącz url\nmagazine_panel: Panel magazynu\nreject: Odrzuć\napprove: Zatwierdź\nban: Ban\nfilters: Filtry\napproved: Zatwierdzone\nrejected: Odrzucone\nadd_moderator: Dodaj moderatora\nadd_badge: Dodaj etykietę\nbans: Bany\ncreated: Utworzono\nexpires: Wygasa\nperm: Perm\nexpired_at: Wygasa\nadd_ban: Dodaj ban\nreports: Zgłoszenia\nnotifications: Powiadomienia\nmessages: Wiadomości\nappearance: Wygląd\nhomepage: Strona główna\nhide_adult: Ukryj treści dla do dorosłych\nfeatured_magazines: Polecane magazyny\nprivacy: Prywatność\nshow_profile_subscriptions: Pokaż w profilu obserwowane magazyny\nshow_profile_followings: Pokaż w profilu obserwowanych ludzi\nnotify_on_new_entry_reply: Powiadom mnie o nowym komentarzu w moich treściach\nnotify_on_new_entry_comment_reply: Powiadom mnie o odpowiedzi na moje komentarze\n  w treściach\nnotify_on_new_post_reply: Powiadom mnie o odpowiedzi na moje posty\nnotify_on_new_post_comment_reply: Powiadom mnie o odpowiedzi na moje komentarze \n  na mikroblogu\nnotify_on_new_entry: Powiadom mnie o nowych treściach w obserwowanych magazynach\nnotify_on_new_posts: Powiadom mnie o nowych postach w obserwowanych magazynach\nsave: Zapisz\nabout: Notka\nold_email: Stary email\nnew_email: Nowy email\nnew_email_repeat: Powtórz nowy email\ncurrent_password: Obecne hasło\nnew_password: Nowe hasło\nnew_password_repeat: Powtórz nowe hasło\nchange_email: Zmień email\nchange_password: Zmień hasło\ncollapse: Zwiń\ndomains: Domeny\nvotes: Głosy\ntheme: Wygląd\ndark: Ciemny\nlight: Jasny\nsolarized_light: Solarized Light\nsolarized_dark: Solarized Dark\nfont_size: Rozmiar czcionki\nsize: Rozmiar\nboosts: Podbicia\nyes: Tak\nno: Nie\nshow_magazines_icons: Pokaż ikony magazynów\nshow_thumbnails: Pokaż miniaturki\nrounded_edges: Zaokrąglone rogi\nremoved_thread_by: Usunął treść utworzoną przez\nremoved_comment_by: usunął komentarz utworzony przez\nrestored_comment_by: przywrócił komentarz utworzony przez\nremoved_post_by: usunął post utworzony przez\nrestored_post_by: przywrócił post utworzony przez\nhe_banned: ban\nhe_unbanned: odbanuj\nshow_all: Pokaż wszystko\nflash_thread_new_success: Treść została poprawnie utworzona i za chwilę będzie \n  widoczna dla innych.\nflash_thread_edit_success: Treść została poprawnie zedytowana.\nflash_thread_delete_success: Treść została poprawnie usunięta.\nflash_thread_pin_success: Treść została przypięta.\nflash_thread_unpin_success: Treść została odpięta.\nflash_magazine_edit_success: Magazyn został poprawnie zedytowany.\ntoo_many_requests: Limit wyczerpany, sprawdź ponownie później.\nset_magazines_bar: Pasek magazynów\nset_magazines_bar_desc: podaj nazwy magazynów oddzielone przecinkiem\nset_magazines_bar_empty_desc: jeżeli pole jest puste, na pasku wyświetlane będą \n  aktywne magazyny.\ntrash: Kosz\ntype.magazine: Magazyn\nthread: Treść\nthreads: Treści\nmicroblog: Mikroblog\npeople: Ludzie\nevents: Wydarzenia\nmagazine: Magazyn\nsearch: Szukaj\nadd: Dodaj\nselect_channel: Wybierz kanał\nlogin: Zaloguj\ntop: Ważne\nhot: Gorące\nactive: Aktywne\nnewest: Najnowsze\noldest: Najstarsze\ncommented: Komentowane\nchange_view: Zmień widok\nfilter_by_type: Filtruj po typie\ncomments_count: '{0}Komentarzy|{1}Komentarz|]1,Inf[ Komentarze'\nfavourites: Ulubione\nfavourite: Ulubione\nmore: Więcej\navatar: Avatar\nadded: Dodano\ndown_votes: Minusy\nno_comments: Brak komentarzy\ncreated_at: Utworzono\nowner: Właściciel\nsubscribers: Obserwujący\nonline: Online\ncomments: Komentarze\nposts: Wpisy\nreplies: Odpowiedzi\nmod_log: Log moderatorski\nadd_comment: Dodaj komentarz\nadd_post: Dodaj post\nadd_media: Dodaj media\nmarkdown_howto: Jak działa edytor?\nenter_your_comment: Napisz komentarz\nenter_your_post: Napisz post\nactivity: Aktywność\ncover: Okładka\nrandom_posts: Losowe wpisy\nfederated_magazine_info: Magazyn ze zdalnego serwera może być niekompletny.\ngo_to_original_instance: Zobacz więcej na oryginalnej instancji.\nempty: Pusto\nsubscribe: Subskrybuj\nfollow: Obserwuj\nunsubscribe: Subskrybujesz\nunfollow: Obserwujesz\nreply: Odpowiedz\npassword: Hasło\nremember_me: Zapamiętaj mnie\nyou_cant_login: Nie pamiętasz hasła?\nalready_have_account: Masz już konto?\nregister: Zarejestruj\nreset_password: Przypomnij hasło\nshow_more: Zobacz więcej\nto: do\nin: w\nusername: Nazwa użytkownika\nemail: Email\nterms: Regulamin\nprivacy_policy: Polityka prywatności\nabout_instance: O instancji\nall_magazines: Wszystkie magazyny\nstats: Statystyki\nfediverse: Fediwersum\ncreate_new_magazine: Utwórz nowy magazyn\nadd_new_video: Dodaj video\nadd_new_article: Dodaj nowy wątek\nadd_new_link: Dodaj link\nadd_new_post: Dodaj wpis na mikroblogu\ncontact: Kontakt\nfaq: FAQ\nrss: RSS\nchange_theme: Zmień wygląd\nuseful: Przydatne\nhelp: Pomoc\ncheck_email: Sprawdź swój mail\nreset_check_email_desc2: Jeśli nie otrzymasz e-maila, sprawdź folder ze spamem.\ntry_again: Spróbuj ponownie\nup_vote: Podbij\ndown_vote: Zminusuj\nemail_confirm_header: Cześć! Potwierdź adres email.\nemail_confirm_content: 'Chcesz aktywować swoje konto Mbin? Kliknij poniższy link:'\nemail_verify: Potwierdź adres email\nemail_confirm_expire: Link wygaśnie za godzinę.\nemail_confirm_title: Potwierdź adres email.\nselect_magazine: Wybierz magazyn\nadd_new: Dodaj nowy\nurl: URL\ntitle: Tytuł\nbody: Treść\ntags: Tagi\nbadges: Etykiety\nis_adult: +18 / NSFW\neng: ENG\noc: OC\nadd_new_photo: Dodaj obraz\nname: Nazwa\ndescription: Opis\nrules: Zasady\ndomain: Domena\nfollowers: Obserwujący\nfollowing: Obserwowani\noverview: Przegląd\ncards: Karty\ncolumns: Kolumny\nuser: Użytkownik\njoined: Dołączył\nmoderated: Moderowane\npeople_local: Lokalne\npeople_federated: Sfederowane\nrelated_tags: Powiązane tagi\ngo_to_content: Przejdź to treści\ngo_to_filters: Przejdź do filtrów\ngo_to_search: Przejdź do wyszukiwarki\nsubscribed: Subskrybowane\nall: Wszystkie\nlogout: Wyloguj\ncompact_view: Widok kompaktowy\nchat_view: Widok czatu\ntree_view: Widok drzewa\ntable_view: Widok tabeli\ncards_view: Widok kart\n3h: 3g\n6h: 6g\n12h: 12g\n1d: 1d\n1w: 1t\n1m: 1m\nlinks: Linki\narticles: Wątki\nphotos: Obrazy\nvideos: Video\nreport: Zgłoś\nshare: Udostępnij\ncopy_url: Kopiuj link Mbin\nshare_on_fediverse: Podziel się w Fediwersum\nedit: Edytuj\nare_you_sure: Jesteś pewien?\nmoderate: Moderuj\nreason: Powód\ndelete: Usuń\nedit_post: Edytuj post\nsettings: Ustawienia\ngeneral: Ogólne\nprofile: Profil\nblocked: Blokady\nedited_thread: Edytował/a treść\nmod_remove_your_thread: Moderator usunął twoją treść\nadded_new_thread: Dodał/a nową treść\nadded_new_comment: Dodał/a nowy komentarz\nedited_comment: Edytował/a komentarz\nreplied_to_your_comment: Odpowiedział/a na twój komentarz\nmod_deleted_your_comment: Moderator usunął twój komentarz\nadded_new_post: Dodał/a nowy post\nedited_post: Edytował/a post\nadded_new_reply: Dodał/a nową odpowiedź\nwrote_message: Napisał/a wiadomość\nbanned: Zbanował/a cię\nremoved: Usunięte przez moderację\ndeleted: Usunięte przez autora\nmentioned_you: Wspomniał cię\ncomment: Komentarz\npost: Post\npurge: Wyczyść\nsend_message: Wyślij wiadomość\ntype.article: Artykuł\ntype.smart_contract: Inteligentny kontrakt\nmagazines: Magazyny\nfilter_by_time: Wybierz zakres\nup_votes: Podbicia\nmoderators: Moderatorzy\nrelated_posts: Powiązane posty\nfederated_user_info: Profil ze zdalnego serwera może być niekompletny.\nlogin_or_email: Login lub email\ndont_have_account: Nie masz konta?\nrepeat_password: Powtórz hasło\nagree_terms: Zgadzam się z %terms_link_start%Regulaminem%terms_link_end% i \n  %policy_link_start%Polityką prywatności%policy_link_end%\nreset_check_email_desc: Jeśli już istnieje konto powiązane z twoim adresem \n  email, to wkrótce otrzymasz email zawierający link, który możesz użyć do \n  zresetowania hasła. Ten link wygaśnie za %expire%.\nimage_alt: Alternatywny tekst opisujący obraz\nsubscriptions: Subskrypcje\nreputation_points: Punkty reputacji\nchange_language: Zmień język\nchange: Zmień\npinned: Przypięty\npreview: Podgląd\narticle: Artykuł\nreputation: Reputacja\nnote: Notatka\nwriting: Pisanie\nusers: Ludzie\ncontent: Treści\nweek: Tydzień\nweeks: Tygodnie\nmonths: Miesiące\nyear: Rok\nfederated: Sfederowane\nlocal: Lokalne\nadmin_panel: Panel administratora\ndashboard: Dashboard\ncontact_email: Email kontaktowy\nmeta: Meta\ninstance: Instancja\npages: Strony\nFAQ: FAQ\ntype_search_term: Wpisz frazę wyszukiwania\nfederation_enabled: Federacją włączona\nregistrations_enabled: Rejestracja włączona\nrestore: Przywróć\nadd_mentions_entries: Dodaj oznaczenia użytkowników w treściach\nadd_mentions_posts: Dodaj oznaczenia użytkowników na mikroblogu\nPassword is invalid: Hasło jest nieprawidłowe.\nYour account has been banned: Twoje konto zostało zbanowane.\nclassic_view: Widok klasyczny\ncopy_url_to_fediverse: Kopiuj link (Fediwersum)\nedit_comment: Zapisz zmiany\nshow_users_avatars: Pokaż avatary użytkowników\nrestored_thread_by: przywrócił treść utworzoną przez\nread_all: Przeczytaj wszystkie\nflash_register_success: Twoje konto zostało zarejestrowane. Sprawdź swoją \n  skrzynkę e-mail, wysłaliśmy wiadomość z linkiem aktywacyjnym.\nflash_magazine_new_success: Magazyn został poprawnie utworzony. Możesz dodawać \n  nowe treści lub zapoznać się z panelem administratora.\nmod_log_alert: W modlogu możesz trafić na drastyczne treści usunięte przez \n  moderatorów. Upewnij się, że wiesz co robisz...\nmod_remove_your_post: Moderator usunął twój post\nban_expired: Ban wygasa\nchange_magazine: Zmień magazyn\nregistration_disabled: Rejestracja wyłączona\nYour account is not active: Twoje konto nie jest aktywne.\nsend: Wyślij\nfirstname: Imię\nactive_users: Ostatnio aktywni\nrandom_entries: Losowe treści\nrelated_entries: Powiązane treści\ndelete_account: Usuń konto\npurge_account: Wyczyść konto\nban_account: Zbanuj konto\nunban_account: Odbanuj konto\nrelated_magazines: Powiązane magazyny\nrandom_magazines: Losowe magazyny\nmagazine_panel_tags_info: Wprowadź tylko wtedy, gdy chcesz żeby treści z \n  fediwersum były umieszczane w tym magazynie na podstawie tagów\nsidebar: Menu boczne\nauto_preview: Pogląd mediów\ndynamic_lists: Dynamiczne listy\nbanned_instances: Zbanowane instancje\nkbin_intro_title: Zanurkuj w Fediwersum\nkbin_intro_desc: to zdecentralizowana platforma służąca do agregowania treści i \n  mikroblogowania, działająca w sieci Fediverse.\nkbin_promo_title: Utwórz własną instancję\nkbin_promo_desc: '%link_start%Sklonuj repo%link_end% i rozwijaj fediverse'\ncaptcha_enabled: Captcha włączona\nheader_logo: Logo w nagłówku\nreturn: Wróć\nbrowsing_one_thread: Przeglądasz tylko jeden wątek w dyskusji! Wszystkie \n  Komentarze dostępne są na stronie posta.\nboost: Podbij\nreport_issue: Zgłoś błąd\nmercure_enabled: Włączono Mercure\ntokyo_night: Tokyo Nocą\npreferred_languages: Filtruj język wątków i wpisów\ninfinite_scroll_help: Automatycznie wczytuj więcej treści, gdy dotrzesz do końca\n  strony.\nsticky_navbar_help: Pasek nawigacyjny przyczepi się do góry strony, gdy \n  przewijasz w dół.\nauto_preview_help: Automatycznie pokazuj podgląd mediów.\nreload_to_apply: Przeładuj stronę, żeby zaakceptować zmiany\nfilter.origin.label: Wybierz pochodzenie\nfilter.fields.label: Wybierz, które pola przeszukać\nfilter.adult.only: Tylko NSFW\nfilter.fields.only_names: Tylko nazwy\nfilter.fields.names_and_descriptions: Nazwy i opis\nfilter.adult.label: Wybierz, czy wyświetlać treści NSFW\nfilter.adult.hide: Ukryj NSFW\nfilter.adult.show: Pokaż NSFW\nlocal_and_federated: Lokalne i sfederowane\nbot_body_content: \"Witaj w Bocie Mbin! Ten bot odgrywa kluczową rolę w umożliwianiu\n  funkcji ActivityPub w obrębie Mbin. Zapewnia, że Mbin może komunikować się i federować\n  z innymi instancjami w fediverse.\\n\\nActivityPub to otwarty standardowy protokół,\n  który umożliwia komunikację i interakcje między zdecentralizowanymi platformami\n  społecznościowymi. Umożliwia użytkownikom na różnych instancjach (serwerach) śledzenie,\n  współdziałanie i udostępnianie treści w ramach federowanej sieci społecznościowej\n  znanej jako fediverse. Daje standaryzowany sposób na publikowanie treści, śledzenie\n  innych użytkowników oraz angażowanie się w interakcje społeczne, takie jak polubienia,\n  udostępnianie i komentowanie wątków lub wpisów.\"\nkbin_bot: Bot Mbin\npassword_confirm_header: Potwierdzi żądanie zmiany hasła.\nyour_account_is_not_active: Twoje konto nie zostało aktywowane. Proszę sprawdź \n  swój email i kliknij na link aktywacyjny, żeby kontynuować\nyour_account_has_been_banned: Twoje konto zostało zbanowane\ntoolbar.bold: Pogrubienie\ntoolbar.italic: Kursywa\ntoolbar.header: Nagłówek\ntoolbar.quote: Cytat\ntoolbar.code: Kod\ntoolbar.link: Link\ntoolbar.image: Obraz\ntoolbar.unordered_list: Lista punktowana\ntoolbar.ordered_list: Lista numerowana\ntoolbar.mention: Wzmianka\ntoolbar.strikethrough: Przekreślenie\nmore_from_domain: Więcej z domeny\nerrors.server404.title: 404 Nie znaleziono\nfederation_page_enabled: Strona federacji włączona\nflash_post_unpin_success: Wpis został odpięty.\nflash_post_pin_success: Wpis został przypięty.\n2fa.authentication_code.label: Kod uwierzytelniający\n2fa.remove: Usuń 2FA\n2fa.verify: Weryfikuj\nlast_active: Ostatnia aktywność\noauth.consent.allow: Zezwalaj\ncustom_css: Niestandardowy CSS\nblock: Zablokuj\nerrors.server403.title: 403 Dostęp zabroniony\noauth.consent.deny: Odrzuć\nsingle_settings: Pojedynczy\nmoderation.report.ban_user_title: Zablokuj użytkownika\nresend_account_activation_email_question: Nieaktywne konto?\ncancel: Anuluj\ndelete_content: Usuń zawartość\nunblock: Odblokuj\noauth.consent.grant_permissions: Udziel uprawnienia\n"
  },
  {
    "path": "translations/messages.pt.yaml",
    "content": "type.link: Ligação\ntype.article: Tópico\ntype.photo: Foto\ntype.video: Video\ntype.magazine: Magazine\nthread: Tópico\nthreads: Tópicos\nmicroblog: Microblog\npeople: Pessoas\nevents: Eventos\nmagazine: Magazine\nmagazines: Magazines\nsearch: Procura\nadd: Adicionar\nselect_channel: Selecionar um canal\nlogin: Log in\ntop: Topo\nhot: Quente\nnewest: Novo\noldest: Antigo\ncommented: Comentado\nchange_view: Alterar vista\nfilter_by_time: Filtrar por tempo\nfilter_by_type: Filtrar por tipo\nfavourites: Votos a favor\nfavourite: Favorito\nmore: Mais\navatar: Avatar\nadded: Adicionado\nup_votes: Upvoto\ndown_votes: Downvoto\nno_comments: Sem comentários\ncreated_at: Criado\nowner: Dono\nsubscribers: Subscritores\nonline: Online\ncomments: Comentários\nposts: Postagens\nmoderators: Moderadores\nmod_log: Log de Moderação\nadd_comment: Adicionar comentário\nadd_post: Adicionar postagem\nadd_media: Adicionar media\nenter_your_comment: Introduza o seu comentário\nenter_your_post: Introduza a sua postagem\nactivity: Atividade\ncover: Capa\nrelated_posts: Postagens relacionadas\nrandom_posts: Postagens aleatórias\nfederated_user_info: Esse perfil é de um servidor federado e pode estar \n  incompleto.\ngo_to_original_instance: Visualizar na instância remota\nempty: Vazio\nsubscribe: Subscrever\nunsubscribe: Cancelar subscrição\nfollow: Seguir\nunfollow: Deixar de seguir\nreply: Resposta\nlogin_or_email: Login ou email\npassword: Password\ndont_have_account: Não tem uma conta?\nyou_cant_login: Esqueceu-se da palavra-passe?\nalready_have_account: Já tem uma conta?\nregister: Registar\nreset_password: Redefinir password\nshow_more: Ver mais\nto: para\nin: em\nemail: Email\nrepeat_password: Repetir password\nterms: Termos do serviço\nprivacy_policy: Política de Privacidade\nabout_instance: Sobre\nall_magazines: Todas as magazines\nstats: Estatísticas\nfediverse: Fediverse\ncreate_new_magazine: Criar uma nova magazine\nadd_new_article: Adicionar um novo tópico\nadd_new_link: Adicionar um novo link\nadd_new_photo: Adicionar uma nova foto\nadd_new_post: Adicionar uma nova postagem\nadd_new_video: Adicionar um novo video\ncontact: Contato\nfaq: FAQ\nrss: RSS\nchange_theme: Alterar tema\nuseful: Útil\nhelp: Ajuda\nreset_check_email_desc2: Se não receber um email, verifique a pasta de spam.\ntry_again: Tente de novo\nup_vote: Upvoto\ndown_vote: Downvoto\nemail_confirm_header: Olá! Confirme o seu endereço de email.\nemail_confirm_content: 'Pronto para ativar a sua conta Mbin? Clique no link abaixo:'\nemail_verify: Confirme endereço de email\nemail_confirm_title: Confirme o seu endereço de email.\nselect_magazine: Selecione uma magazine\ngo_to_search: Ir para a procura\nsubscribed: Subscrito\nall: Tudo\nlogout: Terminar sessão\nclassic_view: Vista clássica\ncompact_view: Vista compacta\nchat_view: Vista de chat\ntree_view: Vista de árvore\ncards_view: Vista de cartão\n3h: 3h\n6h: 6h\n1d: 1d\n1m: 1m\nlinks: Links\narticles: Tópicos\n1w: 1sem\nphotos: Fotos\nvideos: Videos\nreport: Reportar\nshare: Partilhar\ncopy_url_to_fediverse: Copiar URL original\nshare_on_fediverse: Partilhar no Fediverso\nedit: Editar\nare_you_sure: Tem a certeza?\nmoderate: Moderar\nreason: Motivo\ndelete: Apagar\nedit_post: Editar postagem\nedit_comment: Gravar alterações\nsettings: Definições\ngeneral: Geral\nprofile: Perfil\nblocked: Bloqueado\nreports: Relatórios\nnotifications: Notificações\nmessages: Mensagens\nhomepage: Página principal\nhide_adult: Esconder conteúdo NSFW\nfeatured_magazines: Magazines em destaque\nprivacy: Privacidade\nshow_profile_followings: Mostrar utilizadores seguidos\nnotify_on_new_entry_reply: Qualquer nível de comentários nos tópicos da minha \n  autoria\nnotify_on_new_post_reply: Qualquer nível de resposta a publicações da minha \n  autoria\nnotify_on_new_post_comment_reply: Respostas aos meus comentários em qualquer \n  publicação\nnotify_on_new_entry: Novos tópicos (ligações ou artigos) em qualquer revista da \n  qual seja assinante\nsave: Guardar\nabout: Sobre\nold_email: Email atual\nnew_email: Novo email\nnew_email_repeat: Confirmar o novo email\ncurrent_password: Password atual\nnew_password: Nova password\nnew_password_repeat: Confirmar nova password\nchange_email: Alterar email\nchange_password: Alterar password\nexpand: Expandir\ncollapse: Colapsar\ndomains: Domínios\nerror: Erro\nvotes: Votos\ndark: Escuro\nlight: Claro\nsolarized_light: Claro Solarizado\nsolarized_dark: Escuro Solarizado\nfont_size: Tamanho da letra\nsize: Tamanho\nboosts: Upvotos\nyes: Sim\nno: Não\nshow_magazines_icons: Mostrar os ícones das magazines\nshow_thumbnails: Mostrar miniaturas\nrounded_edges: Cantos redondos\nremoved_thread_by: removeu um tópico de\nremoved_comment_by: removeu um comentário de\nrestored_comment_by: restaurou comentário por\nremoved_post_by: removeu um post de\nrestored_post_by: restaurou uma publicação de\nhe_banned: banido\nhe_unbanned: desbanidos\nread_all: Ler tudo\nshow_all: Mostrar tudo\nadded_new_thread: Adicionado um novo tópico\nedited_thread: Editado um tópico\nmod_remove_your_thread: Um moderador removeu o seu tópico\nadded_new_comment: Adicionado um novo comentário\nedited_comment: Editado um comentário\nreplied_to_your_comment: Respondeu ao seu comentário\nmod_deleted_your_comment: Um moderador apagou o seu comentário\nadded_new_post: Adicionado uma nova postagem\nmod_remove_your_post: Um moderador removeu a sua postagem\nadded_new_reply: Adicionada uma nova resposta\nwrote_message: Escreva uma mensagem\nbanned: Baniu-o\nremoved: Removido por um moderador\nmentioned_you: Mencionou-o\ncomment: Comentário\npost: Postagem\nban_expired: O ban expirou\npurge: Limpar\nsend_message: Enviar mensagem direta\nmessage: Mensagem\ninfinite_scroll: Rolagem infinita\nshow_top_bar: Mostrar barra do topo\nsubject_reported: O conteúdo foi reportado.\nsidebar_position: Posição da barra lateral\nleft: Esquerda\nright: Direita\nfederation: Federação\nstatus: Estado\non: Ligado\ninstances: Instâncias\nupload_file: Carregar ficheiro\nfrom_url: Do url\nmagazine_panel: Painel da magazine\nreject: Rejeitar\napprove: Aprovar\nban: Banir\nfilters: Filtros\napproved: Aprovado\nadd_moderator: Adicionar moderador\nadd_badge: Adicionar distintivo\nbans: Expulsões\ncreated: Criado\nexpires: Expira\nperm: Permanente\nexpired_at: Expirou em\nadd_ban: Adicionar expulsão\ntrash: Lixo\nicon: Ícone\npin: Fixar\nunpin: Desafixar\nchange_magazine: Alterar magazine\nchange_language: Alterar idioma\nchange: Alterar\npinned: Fixado\npreview: Previsualizar\narticle: Tópico\nreputation: Reputação\nnote: Nota\nusers: Utilizadores\ncontent: Conteúdo\nweek: Semana\nweeks: Semanas\nmonth: Mês\nmonths: Meses\nyear: Ano\nfederated: Federado\nlocal: Local\ndashboard: Painel\ncontact_email: Email de contato\nmeta: Meta\ninstance: Instância\npages: Páginas\nFAQ: FAQ\ntype_search_term: Introduza termo de pesquisa\nfederation_enabled: Federação ativada\nregistrations_enabled: Registo ativado\nregistration_disabled: Registos desativados\nrestore: Restaurar\ntype.smart_contract: Contrato inteligente\nactive: Ativo\nagree_terms: Consentimento dos %terms_link_start%Termos e \n  Condições%terms_link_end% e %policy_link_start%Política de \n  Privacidade%policy_link_end%\ncheck_email: Verifique o seu email\ncomments_count: '{0}Comentários|{1}Comentário|]1,Inf[ Comentários'\nreset_check_email_desc: Se já houver uma conta associada ao seu endereço de \n  e-mail, deverá receber um e-mail em breve contendo uma ligação que poderá ser \n  usada para redefinir a sua palavra-passe. Esta ligação expirará em %expire%.\nreplies: Respostas\nmarkdown_howto: Como funciona o editor?\nemail_confirm_expire: O link expira em uma hora.\nfederated_magazine_info: Esta revista é de um servidor federado e pode estar \n  incompleta.\nremember_me: Lembrar-me\nusername: Username\ntable_view: Vista de tabela\nedited_post: Editou uma publicação\n12h: 12h\ndeleted: Apagado pelo autor\n1y: 1a\nmod_log_alert: AVISO - O Modlog pode conter conteúdo desagradável ou perturbador\n  que foi removido pelos moderadores. Por favor, tenha cuidado.\nsticky_navbar: Barra de navegação fixa\ncopy_url: Copiar url de Mbin\noff: Desligado\nrejected: Rejeitado\nappearance: Aparência\ndone: Feito\nshow_profile_subscriptions: Mostrar subscrições de magazines\nwriting: Escrita\nnotify_on_new_entry_comment_reply: Respostas aos meus comentários em qualquer \n  tópico\nadmin_panel: Painel de administração\nnotify_on_new_posts: Novas publicações de qualquer revista a que estou inscrito\ntheme: Tema\nshow_users_avatars: Mostrar os avatars dos utilizadores\nrestored_thread_by: restaurou um tópico de\nadd_new: Adicionar novo\nurl: URL\ntitle: Título\nbody: Corpo\ntags: Etiquetas\nbadges: Distintivos\nis_adult: 18+ / NSFW\neng: ENG\noc: OC\nimage: Imagem\nimage_alt: Texto alternativo da imagem\nname: Nome\ndescription: Descrição\nrules: Regras\ndomain: Domínio\nfollowers: Seguidores\nfollowing: A seguir\nsubscriptions: Subscrições\noverview: Visão geral\ncards: Cartões\ncolumns: Colunas\nuser: Utilizador\njoined: Aderiu\nmoderated: Moderado\npeople_local: Local\npeople_federated: Federado\nreputation_points: Pontos de reputação\nrelated_tags: Etiquetas relacionadas\ngo_to_content: Ir para o conteúdo\ngo_to_filters: Ir para os filtros\nflash_thread_new_success: O tópico foi criado com sucesso e agora está visível \n  para outros utilizadores.\nflash_thread_edit_success: O tópico foi editado com sucesso.\nflash_thread_delete_success: O tópico foi apagado com sucesso.\nflash_thread_unpin_success: O tópico foi desafixado com sucesso.\nflash_register_success: 'Bem-vindo a bordo! A sua conta já está registada. Uma última\n  etapa: verifique a sua caixa de entrada para receber uma ligação de ativação que\n  dará vida à sua conta.'\nflash_thread_pin_success: O tópico foi fixado com sucesso.\nflash_magazine_new_success: A revista foi criada com sucesso. Pode adicionar \n  novo conteúdo ou explorar o painel de administração da revista.\nflash_magazine_edit_success: A revista foi editada com sucesso.\ntoo_many_requests: Limite excedido, por favor tente novamente mais tarde.\nset_magazines_bar: Barra de magazines\nset_magazines_bar_desc: adicione os nomes dos magazines após a virgula\nset_magazines_bar_empty_desc: se o espaço estiver vazio, magazines ativos serão \n  mostrados na barra\nsubscribe_for_updates: Inscreva-se para começar a receber atualizações.\nremove_media: Remover mídia\nunmark_as_adult: Desmarcar como NSFW\nflash_mark_as_adult_success: A publicação foi marcada com sucesso como NSFW.\ndelete_account: Excluir conta\nmenu: Menu\nflash_unmark_as_adult_success: A publicação foi desmarcada com sucesso como \n  NSFW.\nunban_hashtag_btn: Desbanir Hashtag\nunban_hashtag_description: Ao \"desbanir\" uma hashtag, novas publicações com essa\n  hashtag poderão ser criadas. As publicações existentes com essa hashtag não \n  serão mais ocultadas.\ndynamic_lists: Listas dinâmicas\nbanned_instances: Instâncias banidas\nreport_issue: Relatar problema\ndisabled: Desativado\nhidden: Oculto\nenabled: Ativado\ndefault_theme: Tema padrão\nban_hashtag_description: Ao banir uma hashtag, você impedirá a criação de \n  publicações com essa hashtag, além de ocultar as publicações existentes com \n  essa hashtag.\nYour account is not active: Sua conta não está ativa.\nfirstname: Nome\nreload_to_apply: Recarregar a página para aplicar as alterações\nmarked_for_deletion_at: Marcado para ser excluído em %date%\nmark_as_adult: Marcar como NSFW\nban_account: Banir conta\nheader_logo: Logotipo do cabeçalho\nfilter.fields.only_names: Somente nomes\nfilter.fields.names_and_descriptions: Nomes e descrições\nunban_account: Desbanir conta\nsidebar: Barra lateral\ncaptcha_enabled: Captcha ativado\nbrowsing_one_thread: Você está navegando apenas em um tópico da discussão! Todos\n  os comentários estão disponíveis na página do post.\nreturn: Retornar\nfilter.adult.hide: Ocultar NSFW\nfilter.adult.show: Mostrar NSFW\nfilter.adult.only: Somente NSFW\nyour_account_has_been_banned: Sua conta foi banida\nsend: Enviar\nsticky_navbar_help: A barra de navegação permanecerá na parte superior da página\n  quando você rolar para baixo.\ntoolbar.italic: Itálico\nkbin_promo_title: Crie sua própria instância\nkbin_intro_title: Explorar o Fediverso\ninfinite_scroll_help: Carregar automaticamente mais conteúdo quando chegar ao \n  final da página.\nsubscribers_count: '{0}Inscritos|{1}Inscrito|]1,Inf[ Inscritos'\nfollowers_count: '{0}Seguidores|{1}Seguidor|]1,Inf[ Seguidores'\nmarked_for_deletion: Marcado para ser excluído\nsort_by: Ordenar por\nfilter_by_subscription: Filtrar por assinatura\nfilter_by_federation: Filtrar por status da federação\nkbin_bot: Agente Mbin\ntoolbar.bold: Negrito\nYour account has been banned: Sua conta foi banida.\nactive_users: Pessoas ativas\npassword_confirm_header: Confirme sua solicitação de alteração de senha.\ntoolbar.strikethrough: Riscado\nPassword is invalid: Senha inválida.\ntoolbar.header: Cabeçalho\noauth2.grant.domain.all: Assine ou bloqueie domínios e visualize os domínios que\n  você assinou ou bloqueou.\ndownvotes_mode: Modo de votos negativos\nchange_downvotes_mode: Alterar o modo de votos negativos\nerrors.server500.description: Desculpe, algo deu errado aqui do nosso lado. Se \n  você continuar a ver esse erro, tente entrar em contato com o proprietário da \n  instância. Se essa instância não estiver funcionando, verifique \n  %link_start%outras instâncias do Mbin%link_end% enquanto isso, até que o \n  problema seja resolvido.\nerrors.server404.title: 404 Não encontrado\nerrors.server403.title: 403 Proibido\nemail.delete.title: Pedido de exclusão da conta do usuário\nblock: Bloquear\nalways_disconnected_magazine_info: Esta revista não está recebendo atualizações.\nfrom: de\nresend_account_activation_email_success: Se houver uma conta associada a esse \n  e-mail, enviaremos um novo e-mail de ativação.\nresend_account_activation_email_description: Digite o endereço de e-mail \n  associado à sua conta. Nós te enviaremos outro e-mail de ativação.\ncustom_css: CSS Personalizado\nignore_magazines_custom_css: Ignorar o CSS personalizado das revistas\noauth.consent.title: Formulário de Consentimento OAuth2\noauth.consent.app_requesting_permissions: gostaria de realizar as seguintes \n  ações em seu nome\noauth.consent.to_allow_access: Para permitir esse acesso, clique no botão \n  'Permitir' abaixo\noauth.consent.allow: Permitir\noauth.client_identifier.invalid: ID de cliente OAuth inválido!\noauth2.grant.moderate.magazine.ban.delete: Desbanir usuários em suas revistas \n  moderadas.\nunblock: Desbloquear\nprivate_instance: Forçar os usuários a fazer login antes de poderem acessar \n  qualquer conteúdo\noauth2.grant.moderate.magazine.list: Leia uma lista de suas revistas moderadas.\noauth2.grant.moderate.magazine.reports.all: Gerencie denúncias nas suas revistas\n  moderadas.\noauth2.grant.moderate.magazine_admin.all: Crie, edite ou exclua suas próprias \n  revistas.\noauth2.grant.moderate.magazine.reports.read: Leia as denúncias nas suas revistas\n  moderadas.\noauth2.grant.moderate.magazine.trash.read: Veja o conteúdo descartado em suas \n  revistas moderadas.\noauth2.grant.moderate.magazine_admin.create: Crie novas revistas.\noauth2.grant.moderate.magazine_admin.edit_theme: Edite o CSS personalizado das \n  suas revistas.\noauth2.grant.subscribe.general: Assine ou siga qualquer revista, domínio ou \n  usuário e veja as revistas, domínios e usuários nos quais você se inscreveu.\noauth2.grant.moderate.magazine_admin.update: Edite as regras, a descrição, o \n  status NSFW ou o ícone de qualquer uma das suas revistas.\noauth2.grant.domain.block: Bloqueie ou desbloqueie domínios e visualize os \n  domínios que você bloqueou.\nemail.delete.description: O usuário seguinte solicitou que sua conta fosse \n  excluída\nresend_account_activation_email: Reenviar o e-mail de ativação da conta\nerrors.server429.title: 429 Solicitações em excesso\nemail_confirm_button_text: Confirme sua solicitação de alteração de senha\nemail_confirm_link_help: Como alternativa, você pode copiar e colar o seguinte \n  em seu navegador\noauth.consent.app_has_permissions: já pode executar as seguintes ações\noauth.client_not_granted_message_read_permission: Este aplicativo não recebeu \n  permissão para ler suas mensagens.\nrestrict_oauth_clients: Restringir a criação de clientes OAuth2 aos \n  administradores\noauth2.grant.moderate.magazine.reports.action: Aceite ou rejeite denúncias nas \n  suas revistas moderadas.\noauth2.grant.admin.all: Execute ações administrativas em sua instância.\noauth2.grant.read.general: Leia todo o conteúdo ao qual você tem acesso.\nresend_account_activation_email_error: Houve um problema ao enviar esta \n  solicitação. Talvez não tenha uma conta associada a esse e-mail ou talvez ele \n  já esteja ativado.\noauth2.grant.domain.subscribe: Assine ou cancele a assinatura de domínios e \n  visualize os domínios a que você se inscreveu.\noauth2.grant.moderate.magazine_admin.delete: Exclua qualquer uma de suas \n  revistas.\noauth2.grant.block.general: Bloqueie ou desbloqueie qualquer revista, domínio ou\n  usuário e visualize as revistas, os domínios e os usuários que você bloqueou.\ncomment_not_found: Comentário não encontrado\noauth.consent.deny: Recusar\noauth2.grant.moderate.magazine_admin.moderators: Adicione ou remova moderadores \n  das suas revistas.\noauth2.grant.moderate.magazine_admin.badges: Crie ou remova selos das revistas \n  que possui.\ndisconnected_magazine_info: Esta revista não está recebendo atualizações (última\n  atividade %dias% dia(s) atrás).\nresend_account_activation_email_question: Conta inativa?\noauth.consent.grant_permissions: Conceder permissões\naccount_banned: A conta foi banida.\nflash_image_download_too_large_error: Não foi possível criar a imagem, pois ela \n  é muito grande (tamanho máximo %bytes%)\nflash_comment_edit_error: Não foi possível editar o comentário. Algo deu errado.\nflash_user_settings_general_success: As configurações do utilizador foram \n  gravadas com sucesso.\nflash_user_settings_general_error: Não foi possível gravar as configurações do \n  utilizador.\nflash_user_edit_profile_error: Não foi possível gravar as configurações de \n  perfil.\nannouncement: Anúncio\nmagazine_log_entry_unpinned: a entrada fixada foi removida\nmanually_approves_followers: Aprovar manualmente os seguidores\nuser_verify: Ativar conta\nrandom_entries: Tópicos aleatórios\nrandom_magazines: Revistas aleatórias\noauth2.grant.user.block: Bloquear ou desbloquear utilizadores e ver a lista de \n  utilizadores bloqueados.\noauth2.grant.moderate.entry_comment.set_adult: Marcar comentários em tópicos \n  como NSFW nas suas revistas moderadas.\noauth2.grant.moderate.magazine.ban.read: Visualizar utilizadores banidos nas \n  suas revistas moderadas.\noauth2.grant.admin.instance.information.edit: Atualizar as páginas Sobre, \n  Perguntas frequentes, Contato, Termos de serviço e Política de privacidade da \n  sua instância.\nmagazine_theme_appearance_custom_css: CSS personalizado que será aplicado quando\n  visualizar o conteúdo da sua revista.\n2fa.backup_codes.recommendation: Recomenda-se que mantenha uma cópia deles num \n  local seguro.\nsubscription_sidebar_pop_out_left: Mover para a barra lateral separada à \n  esquerda\nsubscription_sidebar_pop_in: Mover as assinaturas para o painel em linha\nflash_comment_new_success: O comentário foi criado com sucesso.\npage_width_fixed: Fixo\ndeletion: Apagar\ndirect_message: Mensagem direta\ntag: Tag\nunban: Desbanir\nban_hashtag_btn: Banir Hashtag\nauto_preview: Visualização automática de mídia\noauth2.grant.user.notification.all: Leia e limpe as suas notificações.\noauth2.grant.moderate.magazine.ban.all: Gerir utilizadores banidos nas suas \n  revistas moderadas.\noauth2.grant.moderate.magazine.ban.create: Banir utilizadores nas suas revistas \n  moderadas.\noauth2.grant.admin.entry_comment.purge: Apagar completamente um comentário em \n  tópicos da sua instância.\n2fa.qr_code_img.alt: Um código QR que permite a configuração da autenticação de \n  dois fatores para a sua conta\n2fa.qr_code_link.title: Ao aceder esta ligação, permite que a sua plataforma \n  registe esta autenticação de dois fatores\n2fa.user_active_tfa.title: O utilizador tem a 2FA ativada\n2fa.backup_codes.help: Pode usar estes códigos quando não tiver o seu \n  dispositivo ou aplicação de autenticação de dois fatores.  <strong>Não os verá\n  novamente</strong> e poderá usar cada um deles <strong>apenas uma \n  vez</strong>.\nmagazine_log_mod_removed: removeu um moderador\nedit_entry: Editar tópico\ndefault_theme_auto: Claro/escuro (Detecção Automática)\npreferred_languages: Filtrar idiomas dos tópicos e postagens\noauth2.grant.user.oauth_clients.read: Leia as permissões que concedeu a outras \n  aplicações do OAuth2.\noauth2.grant.moderate.post.trash: Eliminar ou restaurar publicações nas suas \n  revistas moderadas.\n2fa.authentication_code.label: Código de Autenticação\n2fa.backup-create.help: Pode criar códigos de autenticação de backup; ao fazer \n  isso, os códigos existentes serão invalidados.\nflash_account_settings_changed: As configurações da sua conta foram alteradas \n  com sucesso. Precisará fazer login novamente.\nsubscription_sort: Ordenar\npending: Pendente\ndeleted_by_moderator: O tópico, a publicação ou o comentário foi apagado pelo \n  moderador\nregister_push_notifications_button: Cadastre-se para notificações push\ntokyo_night: Noite em Tóquio\nfederation_page_enabled: Página da federação ativada\noauth2.grant.magazine.subscribe: Assine ou cancele a assinatura de revistas e \n  veja as revistas que assinou.\noauth2.grant.post.vote: Dê um voto positivo, negativo ou um impulso numa \n  publicação.\noauth2.grant.user.message.all: Leia as suas mensagens e envie mensagens para \n  outros utilizadores.\noauth2.grant.user.message.read: Leia as suas mensagens.\ndelete_content_desc: Apagar o conteúdo do utilizador, deixando as respostas de \n  outros utilizadores nos tópicos, publicações e comentários criados.\naction: Ação\nsolarized_auto: Solarizado (Detecção Automática)\nkbin_promo_desc: '%link_start%Clone o repositório%link_end% e desenvolva o fediverso'\nfilter.origin.label: Escolha a origem\nfilter.fields.label: Escolha os campos que deseja pesquisar\nfilter.adult.label: Escolha se deseja exibir NSFW\nyour_account_is_not_active: A sua conta não foi ativada. Verifique o seu e-mail \n  para obter instruções de ativação da conta ou<a href=\"%link_target%\">solicite \n  um novo e-mail de ativação da conta.</a>\ntoolbar.quote: Citação\ntoolbar.code: Código\ntoolbar.link: Ligação\ntoolbar.image: Imagem\noauth2.grant.entry.create: Crie novos tópicos.\noauth2.grant.post.all: Crie, edite ou apague os seus microblogs e vote, \n  impulsione ou denuncie qualquer microblog.\nmoderation.report.approve_report_title: Aprovar a Denúncia\nmoderation.report.reject_report_title: Rejeitar a Denúncia\nmoderation.report.reject_report_confirmation: Tem certeza de que deseja rejeitar\n  essa denúncia?\nshow_subscriptions: Mostrar assinaturas\nflash_post_new_success: A publicação foi criada com sucesso.\npurge_magazine: Purgar revista\nsuspend_account: Suspender conta\nunsuspend_account: Cancelar a suspensão da conta\naccept: Aceitar\nsso_registrations_enabled.error: Novos registos de conta com gestores de \n  identidade de terceiros estão desativados no momento.\nsso_only_mode: Restringir o login e o registo apenas aos métodos de SSO\nrelated_entry: Relacionado\ncontinue_with: Continue com\ntwo_factor_authentication: Autenticação de dois fatores\noauth2.grant.moderate.magazine_admin.tags: Crie ou remova tags das revistas que \n  possui.\noauth2.grant.entry.all: Crie, edite ou apague os seus tópicos e vote, impulsione\n  ou denuncie qualquer um deles.\noauth2.grant.entry_comment.edit: Edite os seus comentários existentes nos \n  tópicos.\noauth2.grant.post.delete: Apague as suas publicações existentes.\noauth2.grant.user.profile.all: Leia e edite o seu perfil.\n2fa.verify_authentication_code.label: Inserir um código de dois fatores para \n  verificar a configuração\npassword_and_2fa: Palavra-passe e 2FA\nsubscriptions_in_own_sidebar: Numa barra lateral separada\nsidebars_same_side: Barras laterais no mesmo lado\nsubscription_sidebar_pop_out_right: Mover para a barra lateral separada à \n  direita\nsubscription_panel_large: Painel grande\nrestore_magazine: Restaurar revista\naccount_unsuspended: A suspensão da conta foi cancelada.\naccount_unbanned: A conta foi desbanida.\naccount_is_suspended: A conta do utilizador está suspensa.\nremove_following: Remover seguidor\napply_for_moderator: Candidatar-se a moderador\nabandoned: Abandonado\nownership_requests: Solicitações de propriedade\nrelated_magazines: Revistas relacionadas\nboost: Dar Boost\nmercure_enabled: Mercure ativado\nerrors.server500.title: 500 Erro interno do servidor\noauth2.grant.vote.general: Pode votar a favor, contra ou impulsionar tópicos, \n  postagens ou comentários.\noauth2.grant.entry.report: Denunciar qualquer tópico.\noauth2.grant.entry_comment.delete: Apague os seus comentários existentes nos \n  tópicos.\noauth2.grant.entry_comment.vote: Dê um voto positivo, negativo ou impulsione \n  qualquer comentário num tópico.\noauth2.grant.post.create: Criar publicações.\noauth2.grant.post_comment.delete: Apague os seus comentários existentes em \n  publicações.\noauth2.grant.moderate.post_comment.all: Moderar comentários nas publicações das \n  suas revistas moderadas.\noauth2.grant.admin.instance.stats: Veja as estatísticas da sua instância.\nflash_post_unpin_success: A publicação foi desafixada com sucesso.\noauth2.grant.moderate.post.pin: Fixe as publicações na parte superior das suas \n  revistas moderadas.\ndelete_content: Apagar conteúdo\npurge_content_desc: Apagar completamente o conteúdo do utilizador, incluindo \n  apagar as respostas de outros utilizadores em tópicos, postagens e comentários\n  criados.\n2fa.verify: Verificar\n2fa.code_invalid: O código de autenticação não é válido\nflash_email_was_sent: O email foi enviado com sucesso.\nflash_user_edit_password_error: Não foi possível alterar a palavra-passe.\nchange_my_avatar: Modificar o meu avatar\nchange_my_cover: Modificar a minha capa\noauth2.grant.admin.entry.purge: Apagar completamente qualquer tópico da sua \n  instância.\noauth2.grant.entry_comment.report: Denunciar comentário num tópico.\noauth2.grant.magazine.all: Assine ou bloqueie revistas e veja as revistas \n  assinadas ou bloqueadas.\noauth2.grant.post.edit: Edite as suas publicações existentes.\noauth2.grant.post.report: Denuncie qualquer publicação.\noauth2.grant.admin.user.ban: Banir ou desbanir utilizadores da sua instância.\noauth2.grant.admin.instance.all: Visualizar e atualizar as configurações ou \n  informações da instância.\noauth2.grant.admin.instance.settings.all: Exibir ou atualizar as configurações \n  da sua instância.\noauth2.grant.admin.oauth_clients.all: Visualizar ou revogar clientes OAuth2 que \n  existem na sua instância.\nrequest_magazine_ownership: Solicitar a propriedade da revista\nhide: Ocultar\nsensitive_warning: Conteúdo sensível\nshow: Exibir\noauth2.grant.write.general: Pode criar ou editar qualquer um dos seus tópicos, \n  postagens ou comentários.\noauth2.grant.delete.general: Apague qualquer um dos seus tópicos, postagens ou \n  comentários.\noauth2.grant.report.general: Denunciar tópicos, postagens ou comentários.\noauth2.grant.moderate.entry.trash: Jogar fora ou restaurar tópicos nas suas \n  revistas moderadas.\nreport_accepted: Uma denúncia foi aceita\nkbin_intro_desc: é uma plataforma descentralizada para agregação de conteúdo e \n  microblogging que opera dentro da rede Fediverso.\nauto_preview_help: Mostre as visualizações de mídia (foto, vídeo) em um tamanho \n  maior abaixo do conteúdo.\noauth2.grant.entry_comment.create: Criar comentários nos tópicos.\nbot_body_content: \"Bem-vindo ao Agente Mbin! Este Agente desempenha um papel crucial\n  na implementação do ActivityPub na Mbin. Ele garante que a Mbin possa comunicar\n  e se federar com outras instâncias no fediverso.\\n\\nO ActivityPub é um protocolo\n  de padrão aberto que permite que plataformas descentralizadas de redes sociais comuniquem\n  e interajam entre si. Ele permite que utilizadores em diferentes instâncias (servidores)\n  sigam, interajam e partilhem conteúdo na rede social federada conhecida como fediverso.\n  Ele fornece uma maneira padronizada para que os utilizadores publiquem conteúdo,\n  sigam outros utilizadores e participem de interações sociais, como curtir, partilhar\n  e comentar em tópicos ou postagens.\"\ntoolbar.spoiler: Spoiler\nfederated_search_only_loggedin: Pesquisa federada limitada se não estiver logado\naccount_deletion_title: Apagar contas\naccount_deletion_immediate: Apagar imediatamente\nmore_from_domain: Mais do domínio\noauth2.grant.entry.vote: Vote a favor, contra ou dê um impulso em qualquer \n  tópico.\noauth2.grant.entry_comment.all: Crie, edite ou apague os seus comentários nos \n  tópicos e vote, impulsione ou denuncie quaisquer comentários num tópico.\noauth2.grant.post_comment.all: Crie, edite ou apague os seus comentários nas \n  publicações e vote, impulsione ou denuncie qualquer comentário numa \n  publicação.\noauth2.grant.post_comment.create: Crie novos comentários nas publicações.\noauth2.grant.post_comment.report: Denuncie qualquer comentário numa publicação.\noauth2.grant.user.profile.read: Leia o seu perfil.\noauth2.grant.user.profile.edit: Edite o seu perfil.\noauth2.grant.user.notification.read: Leia as suas notificações, inclusive as de \n  mensagens.\noauth2.grant.user.notification.delete: Limpe as suas notificações.\noauth2.grant.moderate.entry.all: Moderar tópicos nas suas revistas moderadas.\noauth2.grant.moderate.entry.change_language: Altere o idioma dos tópicos nas \n  suas revistas moderadas.\noauth2.grant.moderate.entry.pin: Fixar os tópicos na parte superior das revistas\n  moderadas.\noauth2.grant.moderate.entry_comment.change_language: Altere o idioma dos \n  comentários nos tópicos das suas revistas moderadas.\noauth2.grant.user.oauth_clients.all: Leia e edite as permissões que concedeu a \n  outras aplicatções do OAuth2.\noauth2.grant.user.oauth_clients.edit: Edite as permissões que concedeu a outras \n  aplicações do OAuth2.\noauth2.grant.moderate.all: Execute ações de moderação para as quais tem \n  permissão nas suas revistas moderadas.\noauth2.grant.moderate.entry_comment.trash: Eliminar ou restaurar comentários em \n  tópicos das suas revistas moderadas.\noauth2.grant.moderate.post.all: Moderar as publicações nas suas revistas \n  moderadas.\noauth2.grant.moderate.post.change_language: Altere o idioma das publicações das \n  suas revistas moderadas.\noauth2.grant.moderate.post.set_adult: Marcar as publicações como NSFW nas suas \n  revistas moderadas.\noauth2.grant.moderate.post_comment.change_language: Alterar o idioma dos \n  comentários das publicações nas suas revistas moderadas.\noauth2.grant.moderate.post_comment.set_adult: Marcar comentários em publicações \n  como NSFW nas suas revistas moderadas.\noauth2.grant.moderate.post_comment.trash: Remover ou restaurar comentários de \n  publicações nas suas revistas moderadas.\noauth2.grant.admin.post.purge: Apagar completamente qualquer publicação da sua \n  instância.\noauth2.grant.admin.post_comment.purge: Apagar completamente uma publicação da \n  sua instância.\noauth2.grant.admin.magazine.all: Mover tópicos entre revistas ou apagá-las \n  completamente da sua instância.\noauth2.grant.admin.user.verify: Verificar utilizadores da sua instância.\noauth2.grant.admin.user.delete: Apagar utilizadores da sua instância.\noauth2.grant.admin.oauth_clients.read: Veja os clientes OAuth2 que existem na \n  sua instância e as suas estatísticas de uso.\noauth2.grant.admin.oauth_clients.revoke: Revogar o acesso a clientes OAuth2 na \n  sua instância.\nlast_active: Última atividade\nflash_post_pin_success: A publicação foi fixada com sucesso.\ncomment_reply_position_help: Exibir o formulário de resposta a comentários na \n  parte superior ou inferior da página. Quando a “rolagem infinita” estiver \n  ativada, a posição sempre aparecerá na parte superior.\nshow_avatars_on_comments: Mostrar avatares de comentários\nshow_avatars_on_comments_help: Exibir/ocultar avatares de utilizadores ao \n  visualizar comentários num único tópico ou postagem.\nmagazine_theme_appearance_background_image: Imagem de fundo personalizada que \n  será aplicada quando visualizar o conteúdo da sua revista.\nsubject_reported_exists: Este conteúdo já foi denunciado.\ndelete_account_desc: Apagar a conta, incluindo as respostas de outros \n  utilizadores em tópicos, postagens e comentários criados.\nschedule_delete_account: Programar o apagar\nschedule_delete_account_desc: Programar o apagar desta conta em 30 dias. Isto \n  ocultará o utilizador e o seu conteúdo, além de impedir que ele faça login.\nremove_schedule_delete_account: Remover o apagar programado\nremove_schedule_delete_account_desc: Remover o apagar programado. Todo o \n  conteúdo estará disponível novamente e o utilizador poderá fazer login.\ntwo_factor_backup: Códigos de backup da autenticação de dois fatores\n2fa.setup_error: Erro ao ativar a 2FA para a conta\n2fa.enable: Configurar a autenticação de dois fatores\n2fa.disable: Desativar a autenticação de dois fatores\n2fa.backup-create.label: Criar códigos de autenticação de backup\n2fa.add: Adicionar à minha conta\n2fa.available_apps: Use uma aplicação de autenticação de dois fatores, como \n  %google_authenticator%, %aegis% (Android) ou %raivo% (iOS) para fazer a \n  leitura do código QR.\nflash_thread_new_error: Não foi possível criar o tópico. Algo deu errado.\nflash_thread_tag_banned_error: Não foi possível criar o tópico. O conteúdo não é\n  permitido.\nflash_post_new_error: Não foi possível criar a publicação. Algo deu errado.\nflash_magazine_theme_changed_success: Atualizou com sucesso a aparência da \n  revista.\nflash_magazine_theme_changed_error: Não foi possível atualizar a aparência da \n  revista.\nflash_comment_edit_success: O comentário foi atualizado com sucesso.\nflash_comment_new_error: Não foi possível criar o comentário. Algo deu errado.\nflash_user_edit_profile_success: As configurações do perfil do utilizador foram \n  gravadas com sucesso.\nflash_post_edit_success: A publicação foi editada com sucesso.\npage_width: Largura da página\nedit_my_profile: Editar o meu perfil\nkeywords: Palavras-chave\ndeleted_by_author: O tópico, a publicação ou o comentário foi apagado pelo autor\nsensitive_show: Clique para mostrar\ndetails: Pormenores\nspoiler: Spoiler\nall_time: Todo o período\nedited: editado\nrestrict_magazine_creation: Restringir a criação de revistas locais a \n  administradores e moderadores globais\nmagazine_log_mod_added: adicionou um moderador\nlast_updated: Última atualização\nunregister_push_notifications_button: Remover registo de push\ntest_push_notifications_button: Teste as notificações por push\nnotification_title_removed_post: Uma publicação foi removida\nnotification_title_edited_post: Uma publicação foi editada\nnotification_title_new_report: Uma nova denúncia foi criada\nversion: Versão\nlast_successful_deliver: Última entrega bem-sucedida\nlast_successful_receive: Última receção bem-sucedida\nlast_failed_contact: Último contato que não deu certo\nmagazine_posting_restricted_to_mods: Restringir a criação de tópicos aos \n  moderadores\nmax_image_size: Tamanho máximo do ficheiro\nfederation_page_dead_title: Instâncias mortas\nfederation_page_dead_description: Instâncias em que não poderíamos entregar ao \n  menos 10 atividades seguidas e onde a última entrega bem sucedida e -recebida \n  foi a mais de uma semana atrás\nopen_url_to_fediverse: Abrir URL original\ndelete_magazine: Apagar revista\nmagazine_is_deleted: A revista foi apagada. Pode <a \n  href=\"%link_target%\">restaurá-la</a> dentro de 30 dias.\naccount_suspended: A conta foi suspensa.\noauth2.grant.moderate.magazine.all: Gerir banimentos, denúncias e visualizar \n  elementos descartados nas suas revistas moderadas.\noauth2.grant.admin.magazine.move_entry: Mover tópicos entre revistas da sua \n  instância.\noauth2.grant.admin.user.all: Banir, verificar ou apagar completamente os \n  utilizadores da sua instância.\noauth2.grant.admin.magazine.purge: Apagar completamente revistas da sua \n  instância.\noauth2.grant.admin.user.purge: Apagar completamente utilizadores da sua \n  instância.\noauth2.grant.admin.instance.settings.read: Exibir as configurações da sua \n  instância.\nupdate_comment: Atualizar comentário\nmagazine_theme_appearance_icon: Ícone personalizado para a revista.\nmoderation.report.ban_user_description: Deseja banir o utilizador (%username%) \n  que criou este conteúdo desta revista?\nmoderation.report.approve_report_confirmation: Tem certeza de que deseja aprovar\n  esta denúncia?\nalphabetically: Por ordem alfabética\nflash_email_failed_to_sent: O e-mail não pode ser enviado.\npage_width_auto: Automático\nfilter_labels: Filtrar Etiquetas\nauto: Automático\npage_width_max: Max\naccount_settings_changed: As configurações da sua conta foram alteradas com \n  sucesso. Precisará fazer login novamente.\nmagazine_deletion: Apagar a revista\nsensitive_hide: Clique para ocultar\noauth2.grant.user.message.create: Envie mensagens para outros utilizadores.\noauth2.grant.admin.instance.settings.edit: Atualizar as configurações da sua \n  instância.\noauth2.grant.admin.federation.all: Exibir e atualizar instâncias atualmente \n  desfederadas.\noauth2.grant.admin.federation.read: Exibir a lista das instâncias desfederadas.\noauth2.grant.admin.federation.update: Adicionar ou remover instâncias de ou para\n  a lista de instâncias desfederadas.\nmoderation.report.ban_user_title: Banir Utilizador\npurge_content: Purgar conteúdo\n2fa.remove: Remover 2FA\ncancel: Cancelar\nsensitive_toggle: Alternar a visibilidade de conteúdo sensível\nflash_posting_restricted_error: Criar tópicos é restrito aos moderadores desta \n  revista e não é um deles\nmagazine_posting_restricted_to_mods_warning: Somente os moderadores podem criar \n  tópicos nesta revista\nserver_software: Software do servidor\npurge_account: Purgar conta\nclose: Fechar\nflash_thread_edit_error: Não foi possível editar o tópico. Algo deu errado.\noauth2.grant.moderate.magazine_admin.stats: Veja o conteúdo, vote e veja as \n  estatísticas das revistas que possui.\noauth2.grant.entry.edit: Edite os tópicos existentes.\noauth2.grant.entry.delete: Apague os tópicos existentes.\noauth2.grant.post_comment.edit: Edite os seus comentários existentes em \n  publicações.\noauth2.grant.post_comment.vote: Dê um voto positivo, negativo ou impulsione um \n  comentário numa publicação.\noauth2.grant.user.all: Leia e edite o seu perfil, mensagens ou notificações; \n  Leia e edite as permissões que concedeu a outras apps; siga ou bloqueie outros\n  utilizadores; visualize listas de utilizadores que segue ou bloqueia.\noauth2.grant.user.follow: Siga ou deixe de seguir utilizadores e veja uma lista \n  dos utilizadores que segue.\ncancel_request: Cancelar pedido\nuser_suspend_desc: Suspender a sua conta oculta o seu conteúdo na instância, mas\n  não o remove permanentemente e pode restaurá-la a qualquer momento.\nremove_subscriptions: Remover assinaturas\nsubscription_header: Revistas Assinadas\nposition_bottom: Inferior\nposition_top: Topo\nand: e\nshow_related_magazines: Mostrar revistas aleatórias\nshow_related_entries: Mostrar tópicos aleatórios\nshow_related_posts: Mostrar publicações aleatórias\nshow_active_users: Mostrar utilizadores ativos\nadd_mentions_posts: Adicionar tags de menção em publicações\nrelated_entries: Tópicos relacionados\nlocal_and_federated: Local e federado\nadd_mentions_entries: Adicionar tags de menção nos tópicos\ntoolbar.unordered_list: Lista desordenada\ntoolbar.ordered_list: Lista ordenada\ntoolbar.mention: Menção\nfederation_page_allowed_description: Instâncias conhecidas com as quais \n  federamos\nfederation_page_disallowed_description: Instâncias com as quais não nos \n  federamos\naccount_deletion_description: A sua conta será apagada em 30 dias, a menos que \n  opte por apagar a conta imediatamente. Para restaurar a sua conta dentro de 30\n  dias, faça login com as mesmas credenciais de utilizador ou entre em contato \n  com um administrador.\noauth2.grant.magazine.block: Bloqueie ou desbloqueie revistas e veja as revistas\n  que bloqueou.\noauth2.grant.moderate.entry.set_adult: Marcar os tópicos como NSFW nas suas \n  revistas moderadas.\noauth2.grant.moderate.entry_comment.all: Moderar comentários em tópicos nas suas\n  revistas moderadas.\nflash_user_edit_email_error: Não foi possível alterar o e-mail.\nflash_post_edit_error: Não foi possível editar a publicação.\nuser_badge_admin: Administrador\nsso_registrations_enabled: Registos SSO ativados\nsso_show_first: Mostrar o SSO primeiro nas páginas de login e registo\nreported_user: Utilizador denunciado\nreported: denunciado\nreport_subject: Assunto\nown_report_rejected: Uma denúncia foi rejeitada\nown_report_accepted: A sua denúncia foi aceita\nown_content_reported_accepted: Uma denúncia do seu conteúdo foi aceita.\ncake_day: Dia do bolo\nsomeone: Alguém\nback: Anterior\ntest_push_message: Olá, mundo!\nnotification_title_new_comment: Novo comentário\nnotification_title_removed_comment: Um comentário foi removido\nnotification_title_edited_comment: Um comentário foi editado\nnotification_title_mention: Foi mencionado\nnotification_title_new_reply: Nova resposta\nnotification_title_new_thread: Novo tópico\nnotification_title_removed_thread: Um tópico foi removido\nnotification_title_edited_thread: Um tópico foi editado\nnotification_title_ban: Foi banido\nnotification_title_message: Nova mensagem direta\nnotification_title_new_post: Nova publicação\nnew_user_description: Este utilizador é novo (ativo há menos que %days% dias)\nnew_magazine_description: Esta revista é nova (ativa há menos que %days% dias)\nadmin_users_active: Ativos\nadmin_users_inactive: Inativos\nadmin_users_suspended: Suspensos\nadmin_users_banned: Banidos\nremove_user_avatar: Remover avatar\nremove_user_cover: Remover capa\ncrosspost: Postagem cruzada\nnotify_on_user_signup: Novas inscrições\nban_expires: Expira o banimento\nbanner: Banner\ntype_search_term_url_handle: Digite o termo de pesquisa, URL ou identificador.\nmagazine_panel_tags_info: Fornecer apenas se você quiser que o conteúdo do \n  fediverse seja incluído nesta revista com base em tags\nviewing_one_signup_request: Você só está vendo um pedido de inscrição por \n  %username%\nyour_account_is_not_yet_approved: Sua conta ainda não foi aprovada. Lhe \n  enviaremos um e-mail assim que os administradores tiverem aceitado o seu \n  pedido de inscrição.\ntoolbar.emoji: Emoji\naccount_deletion_button: Apagar conta\noauth2.grant.user.bookmark: Adicionar e remover favoritos\noauth2.grant.user.bookmark.add: Adicionar aos favoritos\noauth2.grant.user.bookmark.remove: Remover dos favoritos\noauth2.grant.user.bookmark_list: Leia, edite e exclua suas listas de favoritos\noauth2.grant.user.bookmark_list.read: Leia suas listas de favoritos\noauth2.grant.user.bookmark_list.edit: Edite suas listas de favoritos\noauth2.grant.user.bookmark_list.delete: Excluir suas listas de favoritos\noauth2.grant.moderate.entry.lock: Fechar tópicos em suas revistas moderadas, \n  para que ninguém possa comentar sobre eles\noauth2.grant.moderate.post.lock: Fechar microblogs em suas revistas moderadas, \n  para que ninguém possa comentar sobre eles\nsingle_settings: Único\ncomment_reply_position: Posição de resposta de comentário\nmagazine_theme_appearance_banner: Banner personalizado para a revista. Ele é \n  exibido acima de todos os tópicos e deve estar possuir um amplo aspect ratio \n  (5:1, ou 1500px * 300px).\n2fa.backup: Seus códigos de backup de dois fatores\n2fa.manual_code_hint: Se você não puder digitalizar o QR code, digite o segredo \n  manualmente\nflash_thread_ref_image_not_found: A imagem referenciada por 'imageHash' não pôde\n  ser encontrada.\nsearch_type_content: Tópicos + Microblogs\nselect_user: Escolha um usuário\nnew_users_need_approval: Novos usuários têm que ser aprovados por um \n  administrador antes que eles possam fazer login.\nsignup_requests: Pedidos de inscrição\napplication_text: Explique por que você gostaria de participar\nsignup_requests_header: Pedidos de inscrição\nsignup_requests_paragraph: Esses usuários gostariam de se juntar ao seu \n  servidor. Não podem entrar até aprovar o pedido de inscrição.\nflash_application_info: Um administrador precisa aprovar sua conta antes de \n  poder fazer login. Você receberá um e-mail assim que o pedido de inscrição for\n  processado.\nemail_application_approved_title: Seu pedido de inscrição foi aprovado\nemail_application_approved_body: Seu pedido de inscrição foi aprovado pelo \n  administrador do servidor. Agora você pode fazer login no servidor em <a \n  href=\"%link%\">%siteName%</a>.\nemail_application_rejected_title: Seu pedido de inscrição foi rejeitado\nemail_application_rejected_body: Obrigado pelo seu interesse, mas lamentamos \n  informar que o seu pedido de inscrição foi recusado.\nemail_application_pending: Sua conta requer aprovação do administrador antes de \n  poder fazer login.\nemail_verification_pending: Você tem que verificar seu endereço de e-mail antes \n  de fazer login.\nshow_magazine_domains: Mostrar domínios de revistas\nshow_user_domains: Mostrar domínios de usuário\nanswered: respondidas\nby: por\nfront_default_sort: Tipo padrão da página inicial\ncomment_default_sort: Tipo padrão de comentário\nopen_signup_request: Pedidos de inscrição abertos\nimage_lightbox_in_list: Thumbnails de tópicos abrem em tela cheia\ncompact_view_help: Uma visão compacta com menos margens, onde a mídia é movida \n  para o lado direito.\nshow_users_avatars_help: Exibir a imagem de avatar do usuário.\nshow_magazines_icons_help: Exibir o ícone da revista.\nshow_thumbnails_help: Mostre as imagens de thumbnails.\nimage_lightbox_in_list_help: Quando marcado, clicando no thumbnail mostra uma \n  janela de caixa de imagem modal. Quando desmarcado, clicar na miniatura abrirá\n  o tópico.\nshow_new_icons: Mostrar novos ícones\nshow_new_icons_help: Mostrar ícone para nova revista / usuário (mais ou menos 30\n  dias)\nmagazine_instance_defederated_info: A instância desta revista não é federada. A \n  revista, portanto, não receberá atualizações.\nuser_instance_defederated_info: A instância deste usuário não é federada.\nflash_thread_instance_banned: A instância desta revista está banida.\nshow_rich_mention: Menções ricas\nshow_rich_mention_help: Renderizar um componente de usuário quando um usuário \n  for mencionado. Isso incluirá seu nome de exibição e foto de perfil.\nshow_rich_mention_magazine: Quantidade de menções em revistas\nshow_rich_mention_magazine_help: Renderize um componente de revista quando uma \n  revista for mencionada. Isso incluirá seu nome de exibição e ícone.\nshow_rich_ap_link: Quantidade de AP links\nshow_rich_ap_link_help: Renderizar um componente embutido quando outro conteúdo \n  do ActivityPub for vinculado.\nattitude: Atitude\ntype_search_magazine: Limitar a busca à revista...\ntype_search_user: Limitar a busca ao autor...\nmodlog_type_entry_deleted: Tópico apagado\nmodlog_type_entry_restored: Tópico restaurado\nmodlog_type_entry_comment_deleted: Comentário do tópico apagado\nmodlog_type_entry_comment_restored: Comentário de tópico restaurado\nmodlog_type_entry_pinned: Tópico fixado\nmodlog_type_entry_unpinned: Tópico desfixado\nmodlog_type_post_deleted: Microblog apagado\nmodlog_type_post_restored: Microblog restaurado\nmodlog_type_post_comment_deleted: Resposta do microblog apagada\nmodlog_type_post_comment_restored: Resposta do microblog restaurada\nmodlog_type_ban: Usuário banido da revista\nmodlog_type_moderator_add: Moderador de revista adicionado\nmodlog_type_moderator_remove: Moderador de revista removido\neveryone: Todo mundo\nnobody: Ninguém\nfollowers_only: Apenas seguidores\ndirect_message_setting_label: Quem pode enviar uma mensagem direta\ndelete_magazine_icon: Excluir ícone da revista\nflash_magazine_theme_icon_detached_success: Ícone da revista excluído com \n  sucesso\ndelete_magazine_banner: Excluir banner de revista\nflash_magazine_theme_banner_detached_success: Banner de revista excluído com \n  sucesso\nfederation_uses_allowlist: Use a lista de permissões para federação\ndefederating_instance: Desfederar instância %i\ntheir_user_follows: Quantidade de usuários de sua instância seguindo usuários em\n  nossa instância\nour_user_follows: Quantidade de usuários de nossa instância seguindo usuários em\n  sua instância\ntheir_magazine_subscriptions: Quantidade de usuários de sua instância inscritos \n  em revistas em nossa instância\nour_magazine_subscriptions: Quantidade de usuários em nossa instância inscritos \n  em revistas de sua instância\nconfirm_defederation: Confirmar desfederação\nflash_error_defederation_must_confirm: Você tem que confirmar a desfederação\nallowed_instances: Instâncias permitidas\nbtn_deny: Recusar\nbtn_allow: Permitir\nban_instance: Banir instância\nallow_instance: Permitir instância\nfederation_page_use_allowlist_help: Se uma lista de permissão for usada, essa \n  instância somente federará com as instâncias explicitamente permitidas. Caso \n  contrário, esta instância vai federar com cada instância, exceto aquelas que \n  estão banidas.\nyou_have_been_banned_from_magazine: Você foi banido da revista %m.\nyou_have_been_banned_from_magazine_permanently: Você foi permanentemente banido \n  da revista %m.\nyou_are_no_longer_banned_from_magazine: Você não está mais banido da revista %m.\nfront_default_content: Visão padrão da página inicial\ndefault_content_default: Predefinição do servidor (Tópicos)\ndefault_content_combined: Tópicos + Microblog\ndefault_content_threads: Tópicos\ndefault_content_microblog: Microblog\ncombined: Combinado\nsidebar_sections_random_local_only: Restringir seções da barra lateral \"Tópicos \n  aleatórios/Postagens\" para apenas local\nsidebar_sections_users_local_only: Restringir seção de barra lateral \"pessoas \n  ativas\" para apenas local\nrandom_local_only_performance_warning: Habilitar \"Aleatoriedade apenas local\" \n  pode causar impacto de desempenho SQL.\ndiscoverable: Descobrível\nuser_discoverable_help: Se isso estiver ativado, seu perfil, tópicos, microblogs\n  e comentários podem ser encontrados através da pesquisa e pelos painéis \n  aleatórios. Seu perfil também pode aparecer no painel de usuário ativo e na \n  página de pessoas. Se for desativado, seus posts ainda serão visíveis para \n  outros usuários, mas eles não aparecerão por todo feed.\nmagazine_discoverable_help: Se isso estiver ativado, esta revista e tópicos, \n  microblogs e comentários desta revista podem ser encontrados através da \n  pesquisa e por painéis aleatórios. Se isso for desativado, a revista ainda \n  aparecerá na lista de revistas, mas os tópicos e microblogs não aparecerão em \n  todo o feed.\nflash_thread_lock_success: Tópico fechado com sucesso\nflash_thread_unlock_success: Tópico reaberto com sucesso\nflash_post_lock_success: Microblog fechado com sucesso\nflash_post_unlock_success: Microblog reaberto com sucesso\nlock: Fechar\nunlock: Reabrir\ncomments_locked: Fechado para comentários.\nmagazine_log_entry_locked: fechar comentários de\nmagazine_log_entry_unlocked: reabrir comentários de\nmodlog_type_entry_lock: Tópico fechado\nmodlog_type_entry_unlock: Tópico reaberto\nmodlog_type_post_lock: Microblog fechado\nmodlog_type_post_unlock: Microblog reaberto\ncontentnotification.muted: Mutar | não receber notificações\ncontentnotification.default: Padrão | obter notificações de acordo com suas \n  configurações padrão\ncontentnotification.loud: Geral | obter todas as notificações\nindexable_by_search_engines: Indexável por motores de busca\nuser_indexable_by_search_engines_help: Se esta configuração for falsa, os \n  motores de busca são aconselhados a não indexar qualquer um dos seus tópicos e\n  microblogs, no entanto, seus comentários não são afetados por este e maus \n  atores podem ignorá-lo. Esta configuração também é federada para outros \n  servidores.\nmagazine_indexable_by_search_engines_help: Se esta configuração for falsa, os \n  motores de busca são aconselhados a não indexar nenhum dos tópicos e \n  microblogs nestas revistas. Isso inclui a landing page e todas as páginas de \n  comentários. Esta configuração também é federada para outros servidores.\nsearch_type_entry: Tópicos\n"
  },
  {
    "path": "translations/messages.pt_BR.yaml",
    "content": "type.link: Link\ntype.article: Fio\ntype.photo: Foto\ntype.video: Vídeo\ntype.smart_contract: Contrato inteligente\ntype.magazine: Magazine\nthread: Fio\nthreads: Fios\nmicroblog: Microblog\npeople: Pessoas\nevents: Eventos\nmagazine: Magazine\nsearch: Buscar\nadd: Adicionar\ncommented: Comentado\nchange_view: Alterar visualização\nfilter_by_time: Filtrar por tempo\nfilter_by_type: Filtrar por tipo\nfilter_by_subscription: Filtrar por inscrição\nmagazines: Magazines\nselect_channel: Selecionar um canal\nsort_by: Ordenar por\nlogin: Fazer login\noldest: Mais velho\ntop: Topo\nhot: Popular\nactive: Ativo\nnewest: Mais novo\nfilter_by_federation: Filtrar por status de federação\nmarked_for_deletion: Marcado para exclusão\nmarked_for_deletion_at: Marcado para exclusão em %date%\nfavourites: Votos a favor\nfavourite: Favorito\nmore: Mais\navatar: Avatar\nadded: Adicionou\nno_comments: Sem comentários\ncreated_at: Criado\nowner: Dono\nsubscribers: Inscritos\nonline: Online\ncomments: Comentários\nposts: Postagens\nreplies: Respostas\nmoderators: Moderadores\nmod_log: Registro de moderação\nadd_comment: Adicionar comentário\nadd_post: Adicionar postagem\ncontact: Contato\nfaq: Perguntas Freqüentes (FAQ)\ndelete: Excluir\ncomments_count: '{0}Comentários|{1}Comentário|]1,Inf[ Comentários'\nsubscribers_count: '{0}Inscritos|{1}Inscrito|]1,Inf[ Inscritos'\nfollowers_count: '{0}Seguidores|{1}Seguidor|]1,Inf[ Seguidores'\nadd_media: Adicionar mídia\nremove_media: Remover mídia\nmarkdown_howto: Como funciona o editor?\nenter_your_comment: Insira seu comentário\nactivity: Atividade\ncover: Capa\nsubscribe_for_updates: Inscreva-se para começar a receber atualizações.\nfederated_user_info: Este perfil é de um servidor federado e pode estar \n  incompleto.\nempty: Vazio\ngo_to_original_instance: Ver na instância remota\nsubscribe: Se inscrever\nfollow: Seguir\nunfollow: Deixar de seguir\nunsubscribe: Cancelar inscrição\nremember_me: Lembrar de mim\nreply: Responder\nlogin_or_email: Login ou e-mail\npassword: Senha\ndont_have_account: Não possui uma conta?\nyou_cant_login: Esqueceu a sua senha?\nshow_more: Mostrar mais\nto: para\nin: em\nalready_have_account: Você já possui uma conta?\nreset_password: Redefinir a senha\nusername: Nome do usuário\nemail: E-mail\nrepeat_password: Repetir a senha\nprivacy_policy: Política de privacidade\nuseful: Útil\nhelp: Ajuda\ncheck_email: Verifique o seu e-mail\nemail_confirm_content: 'Pronto para ativar a sua conta Mbin? Clique no link abaixo:'\nemail_verify: Confirmar endereço de e-mail\nemail_confirm_expire: O link expirará em uma hora.\nemail_confirm_title: Confirme seu endereço de e-mail.\nadd_new: Adicionar novo(a)\noverview: Visão Geral\nnew_email_repeat: Confirme novo e-mail\ncurrent_password: Senha atual\nnew_password: Nova senha\nnew_password_repeat: Confirmar nova senha\nchange_email: Alterar e-mail\nchange_password: Alterar a senha\nexpand: Expandir\ndomains: Domínios\nflash_register_success: 'Bem-vindo a bordo! Sua conta já está registrada. Uma última\n  etapa: verifique sua caixa de entrada para receber um link de ativação que dará\n  vida à sua conta.'\nsend: Enviar\nfirstname: Nome\nunban_account: Desbanir conta\nbanned_instances: Instâncias banidas\nkbin_intro_title: Explore o Fediverso\nkbin_promo_title: Crie sua própria instância\nreturn: Retornar\nreload_to_apply: Recarregar a página para aplicar alterações\nfilter.origin.label: Escolha a origem\nfilter.adult.hide: Ocultar NSFW\nfilter.adult.show: Mostrar NSFW\nfilter.adult.only: Somente NSFW\npassword_confirm_header: Confirme seu pedido de alteração de senha.\ndisabled: Desativado\nhidden: Oculto\nenabled: Ativado\nreset_check_email_desc2: Se você não receber um e-mail, verifique sua pasta de \n  spam.\nurl: URL\ntitle: Título\nreset_check_email_desc: Se já houver uma conta associada ao seu endereço de \n  e-mail, você deverá receber um e-mail em breve contendo um link que poderá ser\n  usado para redefinir sua senha. Esse link expirará em %expire%.\nbody: Conteúdo\nrules: Regras\ndomain: Domínio\nfollowers: Seguidores\ncompact_view: Vista compacta\nchat_view: Vista do chat\nlinks: Links\nphotos: Fotos\n1m: 1m\ngeneral: Geral\nabout: Sobre\nold_email: E-mail atual\nsolarized_light: Luz Solarizada\nsolarized_dark: Escuro Solarizado\nsize: Tamanho\ntype_search_term: Digite o termo de pesquisa\nauto_preview: Visualização automática de mídia\ndynamic_lists: Listas dinâmicas\ncaptcha_enabled: Captcha habilitado\nfrom: de\nabout_instance: Sobre\nstats: Estatísticas\nfediverse: Fediverso\nadd_new_link: Adicionar novo link\nadd_new_photo: Adicionar nova foto\nadd_new_video: Adicionar novo vídeo\nrss: RSS\nchange_theme: Mudar tema\ntry_again: Tente novamente\ndown_vote: Reduzir\nis_adult: 18+ / NSFW\nemail_confirm_header: Olá! Confirme seu endereço de e-mail.\nimage_alt: Texto alternativo da imagem\ndescription: Descrição\nimage: Imagem\nname: Nome\nfollowing: Seguindo\ncolumns: Colunas\nuser: Usuário\npeople_federated: Federado\ngo_to_content: Ir para o conteúdo\nlogout: Sair\nclassic_view: Vista clássica\ngo_to_filters: Ir para os filtros\nsubscribed: Inscrito\nall: Todos\n3h: 3h\n12h: 12h\n1d: 1d\n6h: 6h\n1w: 1s\n1y: 1a\nvideos: Vídeos\nshare: Compartilhar\ncopy_url: Copiar URL Mbin\ncopy_url_to_fediverse: Copiar URL original\nshare_on_fediverse: Compartilhar no Fediverso\nedit: Editar\nare_you_sure: Tem certeza?\nmoderate: Moderar\nreason: Razão\nmenu: Menu\nprofile: Perfil\nblocked: Bloqueado\nreports: Denúncias\nnotifications: Notificações\nmessages: Mensagens\nappearance: Aparência\nhomepage: Página inicial\nhide_adult: Ocultar conteúdo NSFW\nprivacy: Privacidade\nsave: Salvar\nerror: Erro\nnew_email: Novo e-mail\ntheme: Tema\ndark: Escuro\nlight: Claro\ndefault_theme: Tema padrão\nsolarized_auto: Solarizado (Detecção Automática)\ndefault_theme_auto: Claro/escuro (Detecção Automática)\nfont_size: Tamanho da fonte\nshow_users_avatars: Mostrar avatares dos usuários\nyes: Sim\nno: Não\nshow_thumbnails: Mostrar miniaturas\nrounded_edges: Bordas arredondadas\nread_all: Ler tudo\nshow_all: Mostrar tudo\nFAQ: Perguntas Frequentes (FAQ)\nrestore: Restaurar\nsettings: Configurações\nfederation_enabled: Federação habilitada\nregistration_disabled: Registro desativado\nregistrations_enabled: Registro ativado\nPassword is invalid: A senha é inválida.\nYour account is not active: Sua conta não está ativa.\nYour account has been banned: Sua conta foi banida.\ndelete_account: Excluir conta\npurge_account: Purgar conta\nban_account: Banir conta\nsidebar: Barra lateral\nsticky_navbar_help: A barra de navegação permanecerá na parte superior da página\n  quando você rolar para baixo.\nauto_preview_help: Expanda automaticamente as pré-visualizações de mídia.\nfilter.adult.label: Escolha se você deseja exibir NSFW\nlocal_and_federated: Local e federado\nfilter.fields.only_names: Apenas nomes\nheader_logo: Logotipo do cabeçalho\nmercure_enabled: Mercure ativado\nreport_issue: Relatar problema\ninfinite_scroll_help: Carregar automaticamente mais conteúdo quando chegar ao \n  final da página.\nfilter.fields.names_and_descriptions: Nomes e descrições\nkbin_bot: Agente Mbin\nup_votes: Boosts\nenter_your_post: Insira sua publicação\nrelated_posts: Publicações relacionadas\nrandom_posts: Publicações aleatórias\nfederated_magazine_info: Esta revista é de um servidor federado e pode estar \n  incompleta.\ndisconnected_magazine_info: Esta revista não está recebendo atualizações (última\n  atividade %days% dia(s) atrás).\nalways_disconnected_magazine_info: Esta revista não está recebendo atualizações.\nselect_magazine: Selecione uma revista\ncollapse: Mostrar menos\nflash_magazine_edit_success: A revista foi editada com sucesso.\nset_magazines_bar_empty_desc: se o campo estiver vazio, as revistas ativas serão\n  exibidas na barra.\nedited_comment: Editou um comentário\nadded_new_comment: Adicionou um novo comentário\nedited_post: Editou uma publicação\nreplied_to_your_comment: Respondeu ao seu comentário\nmod_remove_your_post: Um moderador removeu a sua publicação\nadded_new_reply: Adicionou uma nova resposta\nchange_downvotes_mode: Alterar o modo de votos negativos\ndownvotes_mode: Modo de votos negativos\nnotify_on_new_post_comment_reply: Respostas aos meus comentários em qualquer \n  publicação\nnotify_on_new_posts: Novas publicações de qualquer revista a que estou inscrito\nflash_unmark_as_adult_success: A publicação foi desmarcada como NSFW.\ntoo_many_requests: Limite ultrapassado, tente novamente mais tarde.\nset_magazines_bar: Barra de revistas\nmod_deleted_your_comment: Um moderador excluiu seu comentário\nadded_new_post: Adicionou uma nova publicação\nwrote_message: Escreveu uma mensagem\nremoved: Removido por mod\ndeleted: Excluído pelo autor\nmentioned_you: Mencionou você\nall_magazines: Todas as revistas\ncreate_new_magazine: Criar nova revista\nadd_new_post: Adicionar nova publicação\nup_vote: Impulsionar\nbadges: Selos\njoined: Membro desde\nmoderated: Moderado\nreputation_points: Pontos de reputação\ngo_to_search: Ir para a busca\ntable_view: Visualização da tabela\ntree_view: Visualização em árvore\nreport: Denunciar\nedit_comment: Salvar alterações\nedit_post: Editar publicação\nshow_profile_subscriptions: Mostrar assinaturas de revistas\nfeatured_magazines: Revistas em destaque\nvotes: Votos\nboosts: Boosts\nshow_magazines_icons: Mostrar ícones das revistas\nflash_magazine_new_success: A revista foi criada com sucesso. Você pode \n  adicionar novo conteúdo ou explorar o painel de administração da revista.\nflash_mark_as_adult_success: A publicação foi marcada como NSFW.\npost: Publicação\nterms: Termos de Serviço\npeople_local: Local\npage_width_fixed: Fixo\nmagazine_posting_restricted_to_mods_warning: Somente os moderadores podem criar \n  tópicos nesta revista\nflash_posting_restricted_error: Criar fios é restrito aos moderadores desta \n  revista e você não é um deles\nmagazine_log_mod_removed: removeu um moderador\nmagazine_log_mod_added: adicionou um moderador\ndown_votes: Reduz\nregister: Criar conta\nagree_terms: Concordo com os %terms_link_start%Termos e \n  Condições%terms_link_end% e a %policy_link_start%Política de \n  Privacidade%policy_link_end%\nflash_thread_new_success: O tópico foi criado com sucesso e agora está visível \n  para outros usuários.\nflash_thread_delete_success: O fio foi excluído com sucesso.\nflash_thread_edit_success: O fio foi editado com sucesso.\nflash_thread_pin_success: O fio foi fixado com sucesso.\nflash_thread_unpin_success: O tópico foi desafixado com sucesso.\nmod_log_alert: AVISO - O Modlog pode conter conteúdo desagradável ou perturbador\n  que foi removido pelos moderadores. Por favor, tenha cuidado.\nbanned: baniu você\nshow_top_bar: Mostrar barra superior\nsticky_navbar: Barra de navegação fixa\nfederation: Federação\nstatus: Status\nsidebar_position: Posição da barra lateral\nleft: Esquerda\nright: Direita\nban_hashtag_description: Ao banir uma hashtag, você impedirá a criação de \n  publicações com essa hashtag, além de ocultar as publicações existentes que a \n  utilizam.\nunban_hashtag_btn: Desbanir Hashtag\nunban_hashtag_description: Ao desbanir uma hashtag, você poderá criar novamente \n  publicações com essa hashtag. As publicações existentes com essa hashtag não \n  serão mais ocultadas.\nfilters: Filtros\nadd_moderator: Adicionar moderador\nadd_badge: Adicionar selo\nchange_magazine: Mudar a revista\nchange_language: Alterar idioma\nmark_as_adult: Marcar como NSFW\nusers: Usuários\nwriting: Escrevendo\nmeta: Meta\ncontact_email: E-mail de contato\nactive_users: Pessoas ativas\nrelated_magazines: Revistas relacionadas\nrandom_magazines: Revistas aleatórias\nkbin_intro_desc: é uma plataforma descentralizada para agregação de conteúdo e \n  microblogging que opera dentro da rede Fediverso.\nkbin_promo_desc: '%link_start%Clone o repositório%link_end% e desenvolva o fediverso'\nyour_account_is_not_active: Sua conta não foi ativada. Verifique seu e-mail para\n  obter instruções de ativação da conta ou<a href=\"%link_target%\">solicite um \n  novo e-mail de ativação da conta.</a>\ntoolbar.code: Código\ntoolbar.bold: Negrito\ntoolbar.italic: Itálico\ntoolbar.header: Cabeçalho\ntoolbar.quote: Citação\ntoolbar.strikethrough: Tachado\nfederated_search_only_loggedin: Pesquisa federada limitada se você não estiver \n  logado\nfederation_page_allowed_description: Instâncias conhecidas com as quais \n  federamos\nfederation_page_disallowed_description: Instâncias com as quais não nos \n  federamos\nerrors.server500.description: Desculpe, algo deu errado do nosso lado. Se você \n  continuar a ver esse erro, tente entrar em contato com o proprietário da \n  instância. Se essa instância não estiver funcionando, verifique enquanto isso \n  %link_start%outras instâncias do Mbin%link_end%, até que o problema seja \n  resolvido.\nerrors.server500.title: 500 Erro interno do servidor\nresend_account_activation_email_description: Digite o endereço de e-mail \n  associado à sua conta. Nós enviaremos outro e-mail de ativação para você.\ncustom_css: CSS personalizado\noauth.consent.to_allow_access: Para permitir esse acesso, clique no botão \n  “Permitir” abaixo\noauth.consent.allow: Permitir\noauth.consent.deny: Negar\noauth.consent.app_requesting_permissions: gostaria de realizar as seguintes \n  ações em seu nome\noauth.client_identifier.invalid: ID de cliente OAuth inválido!\nblock: Bloquear\noauth.consent.app_has_permissions: já pode executar as seguintes ações\noauth2.grant.admin.all: Execute ações administrativas na sua instância.\noauth2.grant.delete.general: Exclua qualquer um dos seus fios, publicações ou \n  comentários.\noauth2.grant.write.general: Você pode criar ou editar qualquer um dos seus fios,\n  publicações ou comentários.\noauth2.grant.read.general: Leia todo o conteúdo ao qual você tem acesso.\noauth2.grant.entry.create: Criar novos fios.\noauth2.grant.entry.edit: Edite os fios existentes.\noauth2.grant.entry.all: Crie, edite ou exclua seus fios e vote, impulsione ou \n  denuncie qualquer um deles.\noauth2.grant.post.report: Denuncie qualquer publicação.\noauth2.grant.moderate.entry.set_adult: Marcar os fios como NSFW nas suas \n  revistas moderadas.\noauth2.grant.moderate.entry.trash: Jogar no lixo ou restaurar fios nas suas \n  revistas moderadas.\ntags: Tags\narticles: Fios\nnotify_on_new_post_reply: Qualquer nível de resposta a publicações de minha \n  autoria\nmod_remove_your_thread: Um moderador removeu o seu fio\npurge: Purgar\ninstances: Instâncias\npreview: Visualizar\nrandom_entries: Fios aleatórios\nfederation_page_dead_title: Instâncias mortas\nerrors.server429.title: 429 Requisições em excesso\noauth2.grant.moderate.magazine.reports.all: Gerencie as denúncias de suas \n  revistas moderadas.\nignore_magazines_custom_css: Ignorar o CSS personalizado das revistas\noauth2.grant.moderate.magazine.reports.read: Leia as denúncias nas suas revistas\n  moderadas.\noauth2.grant.moderate.magazine_admin.moderators: Adicione ou remova moderadores \n  das suas revistas.\noauth2.grant.report.general: Denunciar fios, publicações ou comentários.\noauth2.grant.moderate.magazine_admin.edit_theme: Edite o CSS personalizado das \n  suas revistas.\noauth2.grant.vote.general: Você pode votar a favor, contra ou impulsionar fios, \n  publicações ou comentários.\noauth2.grant.post.all: Crie, edite ou exclua seus microblogs e vote, impulsione \n  ou denuncie qualquer microblog.\noauth2.grant.magazine.block: Bloqueie ou desbloqueie revistas e veja as revistas\n  que você bloqueou.\noauth2.grant.user.message.read: Leia suas mensagens.\noauth2.grant.moderate.post.all: Moderar as publicações nas suas revistas \n  moderadas.\napprove: Aprovar\napproved: Aprovado\nrejected: Rejeitado\ncreated: Criado\nexpires: Expira em\nbans: Banimentos\nmonths: Meses\ncontent: Conteúdo\nweek: Semana\nmonth: Mês\nyear: Ano\nadd_new_article: Adicionar novo fio\nsubscriptions: Assinaturas\nnotify_on_new_entry_comment_reply: Respostas aos meus comentários em qualquer \n  fio\nremoved_comment_by: removeu um comentário de\nrestored_comment_by: restaurou o comentário de\nremoved_post_by: removeu um post de\nrestored_post_by: restaurou uma publicação de\nhe_banned: banido\nhe_unbanned: desbanir\nset_magazines_bar_desc: adicionar os nomes da revista após a vírgula\nadded_new_thread: Novo fio adicionado\ncomment: Comentário\nsend_message: Enviar mensagem direta\nmessage: Mensagem\ninfinite_scroll: Rolagem infinita\nfrom_url: Do URL\nupload_file: Fazer upload do arquivo\nmagazine_panel: Painel de revista\nreject: Rejeitar\nban: Banir\nadd_ban: Adicionar banimento\nunban: Desbanir\nban_hashtag_btn: Banir Hashtag\nperm: Permanente\nexpired_at: Expirou em\nchange: Alterar\ntrash: Lixeira\nicon: Ícone\ndone: Feito\npin: Fixar\nunpin: Desafixar\nunmark_as_adult: Desmarcar como NSFW\npinned: Fixado\narticle: Fio\nreputation: Reputação\nnote: Nota\nweeks: Semanas\nfederated: Federado\nlocal: Local\nadmin_panel: Painel de administração\ndashboard: Painel de controle\ninstance: Instância\npages: Páginas\ntoolbar.mention: Menção\nemail.delete.description: O usuário a seguir solicitou a exclusão de sua conta\nresend_account_activation_email: Reenviar email de ativação da conta\nunblock: Desbloquear\noauth2.grant.moderate.magazine.trash.read: Veja o conteúdo descartado em suas \n  revistas moderadas.\noauth2.grant.moderate.magazine_admin.all: Crie, edite ou exclua as revistas que \n  você possui.\noauth2.grant.user.profile.edit: Edite o seu perfil.\noauth2.grant.user.profile.read: Leia o seu perfil.\noauth2.grant.moderate.post.trash: Eliminar ou restaurar publicações nas suas \n  revistas moderadas.\noauth2.grant.moderate.post_comment.set_adult: Marcar comentários em publicações \n  como NSFW nas suas revistas moderadas.\ncontinue_with: Continue com\naccount_deletion_button: Excluir conta\naccount_deletion_immediate: Excluir imediatamente\nmore_from_domain: Mais do domínio\nfederation_page_enabled: Página da federação ativada\nerrors.server404.title: 404 Não encontrado\ntoolbar.spoiler: Spoiler\noauth2.grant.moderate.magazine.ban.delete: Desbanir usuários de suas revistas \n  moderadas.\noauth2.grant.moderate.magazine_admin.delete: Exclua as revistas que você possui.\noauth2.grant.moderate.magazine_admin.update: Edite as regras, a descrição, o \n  status NSFW ou o ícone de qualquer uma das revistas que você possui.\noauth2.grant.admin.entry.purge: Exclua completamente qualquer fio de sua \n  instância.\noauth2.grant.block.general: Bloqueie ou desbloqueie qualquer revista, domínio ou\n  usuário e veja quais foram bloqueados.\noauth2.grant.domain.all: Assine ou bloqueie domínios e visualize os domínios que\n  você assinou ou bloqueou.\noauth2.grant.domain.block: Bloqueie ou desbloqueie domínios e visualize os \n  domínios que você bloqueou.\noauth2.grant.entry.delete: Exclua os fios existentes.\noauth2.grant.entry.vote: Vote a favor, contra ou dê um impulso em qualquer fio.\noauth2.grant.entry.report: Denunciar qualquer fio.\noauth2.grant.entry_comment.create: Criar novos comentários nos fios.\noauth2.grant.entry_comment.edit: Edite seus comentários existentes nos fios.\noauth2.grant.entry_comment.delete: Exclua seus comentários existentes nos fios.\noauth2.grant.entry_comment.report: Denunciar comentário em um fio.\noauth2.grant.magazine.all: Assine ou bloqueie revistas e veja as revistas \n  assinadas ou bloqueadas.\noauth2.grant.post.create: Criar novas publicações.\noauth2.grant.post.edit: Edite suas publicações existentes.\noauth2.grant.post.delete: Exclua suas publicações existentes.\noauth2.grant.post.vote: Dê um voto positivo, negativo ou um impulso em uma \n  publicação.\noauth2.grant.post_comment.all: Crie, edite ou exclua seus comentários nas \n  publicações e vote, impulsione ou denuncie qualquer comentário em uma \n  publicação.\noauth2.grant.post_comment.create: Crie novos comentários nas publicações.\noauth2.grant.post_comment.edit: Edite seus comentários existentes em \n  publicações.\noauth2.grant.post_comment.delete: Exclua seus comentários existentes em \n  publicações.\noauth2.grant.user.message.all: Leia suas mensagens e envie mensagens para outros\n  usuários.\noauth2.grant.user.message.create: Envie mensagens para outros usuários.\noauth2.grant.user.notification.all: Leia e limpe suas notificações.\noauth2.grant.user.notification.read: Leia suas notificações, inclusive as de \n  mensagens.\noauth2.grant.user.notification.delete: Limpe suas notificações.\noauth2.grant.user.follow: Siga ou deixe de seguir usuários e veja uma lista dos \n  usuários que você segue.\noauth2.grant.user.block: Bloquear ou desbloquear usuários e ver a lista de \n  usuários bloqueados.\noauth2.grant.moderate.all: Execute ações de moderação para as quais você tem \n  permissão em suas revistas moderadas.\noauth2.grant.moderate.entry.pin: Fixar os tópicos na parte superior das revistas\n  moderadas.\noauth2.grant.moderate.entry_comment.all: Moderar comentários em fios nas suas \n  revistas moderadas.\noauth2.grant.moderate.entry_comment.change_language: Altere o idioma dos \n  comentários nos fios das suas revistas moderadas.\noauth2.grant.moderate.post_comment.all: Moderar comentários nas publicações das \n  suas revistas moderadas.\nshow_related_posts: Mostrar publicações aleatórias\nsomeone: Alguém\nreport_accepted: Uma denúncia foi aceita\nrelated_entry: Relacionado\nban_expired: Banimento expirado\non: Em\noff: Desligado\nsubject_reported: O conteúdo foi denunciado.\nbrowsing_one_thread: Você está navegando apenas em um fio da discussão! Todos os\n  comentários estão disponíveis na página da publicação.\ntag: Tag\neng: ENG\nnotify_on_new_entry: Novos fios (links ou artigos) em qualquer revista da qual \n  eu seja assinante\nnotify_on_new_entry_reply: Qualquer nível de comentários nos fios de minha \n  autoria\nremoved_thread_by: removeu um fio de\nrestored_thread_by: restaurou um fio de\ntoolbar.ordered_list: Lista ordenada\nerrors.server403.title: 403 Proibido\nresend_account_activation_email_question: Conta inativa?\nresend_account_activation_email_error: Houve um problema ao enviar esta \n  solicitação. Talvez você não tenha uma conta associada a esse e-mail ou talvez\n  ele já esteja ativado.\nresend_account_activation_email_success: Se você tiver uma conta associada a \n  esse e-mail, enviaremos um novo e-mail de ativação.\noauth2.grant.domain.subscribe: Assine ou cancele a assinatura de domínios e \n  visualize os domínios que você assinou.\nrelated_tags: Tags relacionadas\noc: OC\noauth.consent.title: Formulário de Consentimento OAuth2\nprivate_instance: Forçar os usuários a fazer login antes de poderem acessar \n  qualquer conteúdo\noauth2.grant.moderate.magazine.reports.action: Aceite ou rejeite denúncias em \n  suas revistas moderadas.\noauth2.grant.moderate.magazine_admin.create: Criar novas revistas.\noauth2.grant.moderate.magazine_admin.badges: Crie ou remova selos das revistas \n  que você possui.\noauth2.grant.moderate.magazine_admin.stats: Veja o conteúdo, vote e veja as \n  estatísticas das revistas que você possui.\noauth2.grant.moderate.magazine_admin.tags: Crie ou remova tags das revistas que \n  você possui.\noauth2.grant.subscribe.general: Assine ou siga qualquer revista, domínio ou \n  usuário e veja em quais você se inscreveu.\noauth2.grant.entry_comment.all: Crie, edite ou exclua seus comentários nos fios \n  e vote, impulsione ou denuncie quaisquer comentários em um fio.\noauth2.grant.entry_comment.vote: Dê um voto positivo, negativo ou impulsione \n  qualquer comentário em um fio.\noauth2.grant.magazine.subscribe: Assine ou cancele a assinatura de revistas e \n  veja as revistas que você assinou.\noauth2.grant.post_comment.vote: Dê um voto positivo, negativo ou impulsione um \n  comentário em uma publicação.\noauth2.grant.user.oauth_clients.all: Leia e edite as permissões que você \n  concedeu a outros aplicativos OAuth2.\noauth2.grant.user.oauth_clients.read: Leia as permissões que você concedeu a \n  outros aplicativos OAuth2.\noauth2.grant.user.oauth_clients.edit: Edite as permissões que você concedeu a \n  outros aplicativos OAuth2.\noauth2.grant.moderate.entry.all: Moderar fios em suas revistas moderadas.\noauth2.grant.moderate.entry.change_language: Altere o idioma dos fios em suas \n  revistas moderadas.\npage_width: Largura da página\npage_width_max: Max\npage_width_auto: Automático\noauth2.grant.moderate.entry_comment.trash: Eliminar ou restaurar comentários em \n  fios das suas revistas moderadas.\noauth2.grant.moderate.post.change_language: Altere o idioma das publicações das \n  suas revistas moderadas.\noauth2.grant.moderate.post.set_adult: Marcar as publicações como NSFW nas suas \n  revistas moderadas.\nshow_active_users: Mostrar usuários ativos\ncake_day: Dia do bolo\noauth2.grant.moderate.post_comment.change_language: Alterar o idioma dos \n  comentários das publicações nas suas revistas moderadas.\nown_content_reported_accepted: Uma denúncia do seu conteúdo foi aceita.\nrestrict_magazine_creation: Restringir a criação de revistas locais a \n  administradores e moderadores globais\nreport_subject: Assunto\nown_report_accepted: Sua denúncia foi aceita\nown_report_rejected: Uma denúncia foi rejeitada\ndirect_message: Mensagem direta\nlast_updated: Última atualização\nmagazine_log_entry_unpinned: removida a entrada fixada\nemail_confirm_button_text: Confirme seu pedido de alteração de senha\nemail_confirm_link_help: Como alternativa, você pode copiar e colar o que se \n  segue em seu navegador\nemail.delete.title: Pedido de exclusão da conta do usuário\noauth.consent.grant_permissions: Conceder Permissões\noauth.client_not_granted_message_read_permission: Este aplicativo não recebeu \n  permissão para ler suas mensagens.\nrestrict_oauth_clients: Restringir a criação de clientes OAuth2 a \n  administradores\noauth2.grant.moderate.entry_comment.set_adult: Marcar comentários em fios como \n  NSFW nas suas revistas moderadas.\nnotification_title_new_report: Uma nova denúncia foi criada\nserver_software: Software do servidor\nedit_entry: Editar fio\nedited_thread: Editou um fio\nand: e\nshow_related_magazines: Mostrar revistas aleatórias\nrelated_entries: Fios relacionados\nmagazine_panel_tags_info: Preencha apenas se você quiser que o conteúdo do \n  fediverso seja incluído nesta revista com base em tags\nadd_mentions_entries: Adicionar tags de menção nos fios\nadd_mentions_posts: Adicionar tags de menção em publicações\nyour_account_has_been_banned: Sua conta foi banida\ntoolbar.link: Link\ntoolbar.image: Imagem\ntoolbar.unordered_list: Lista desordenada\nboost: Dar Boost\ntokyo_night: Noite em Tóquio\npreferred_languages: Filtrar idiomas dos fios e publicações\nfilter.fields.label: Escolha os campos que você deseja pesquisar\nbot_body_content: \"Bem-vindo ao Agente Mbin! Esse Agente desempenha um papel crucial\n  na implementação do ActivityPub na Mbin. Ele garante que a Mbin possa se comunicar\n  e se federar com outras instâncias no fediverso.\\n\\nO ActivityPub é um protocolo\n  de padrão aberto que permite que plataformas descentralizadas de redes sociais se\n  comuniquem e interajam entre si. Ele permite que usuários em diferentes instâncias\n  (servidores) sigam, interajam e compartilhem conteúdo na rede social federada conhecida\n  como fediverso. Ele fornece uma maneira padronizada para que os usuários publiquem\n  conteúdo, sigam outros usuários e participem de interações sociais, como curtir,\n  compartilhar e comentar em fios ou publicações.\"\noauth2.grant.moderate.magazine.list: Leia uma lista de suas revistas moderadas.\naccount_deletion_title: Exclusão da conta\naccount_deletion_description: Sua conta será excluída em 30 dias, a menos que \n  você opte por excluir a conta imediatamente. Para restaurar sua conta dentro \n  de 30 dias, faça login com as mesmas credenciais de usuário ou entre em \n  contato com um administrador.\nfederation_page_dead_description: Instâncias em que não conseguimos entregar \n  pelo menos 10 atividades seguidas e em que a última entrega bem-sucedida foi \n  há mais de uma semana\noauth2.grant.post_comment.report: Denuncie qualquer comentário em uma \n  publicação.\noauth2.grant.user.all: Leia e edite seu perfil, mensagens ou notificações; Leia \n  e edite as permissões que você concedeu a outros aplicativos; siga ou bloqueie\n  outros usuários; visualize listas de usuários que você segue ou bloqueia.\noauth2.grant.user.profile.all: Leia e edite seu perfil.\nunregister_push_notifications_button: Remover registro de push\nregister_push_notifications_button: Cadastre-se para notificações push\nmanually_approves_followers: Aprovar manualmente os seguidores\nreported_user: Usuário denunciado\nshow_related_entries: Mostrar fios aleatórios\nnotification_title_edited_post: Uma publicação foi editada\nnotification_title_removed_post: Uma publicação foi removida\nnotification_title_new_post: Nova publicação\nnotification_title_message: Nova mensagem direta\nnotification_title_ban: Você foi banido\nnotification_title_edited_thread: Um fio foi editado\nnotification_title_removed_thread: Um fio foi removido\nnotification_title_new_thread: Novo fio\nnotification_title_new_reply: Nova resposta\nnotification_title_mention: Você foi mencionado\nnotification_title_edited_comment: Um comentário foi editado\nnotification_title_removed_comment: Um comentário foi removido\nnotification_title_new_comment: Novo comentário\ntest_push_message: Olá, mundo!\ntest_push_notifications_button: Teste as notificações por push\nflash_magazine_theme_changed_success: Atualizou com sucesso a aparência da \n  revista.\nopen_url_to_fediverse: Abrir URL original\nchange_my_avatar: Modificar meu avatar\nfilter_labels: Filtrar Etiquetas\nunsuspend_account: Cancelar a suspensão da conta\naccount_suspended: A conta foi suspensa.\naccount_unsuspended: A suspensão da conta foi cancelada.\ndeletion: Exclusão\ncancel_request: Cancelar pedido\nabandoned: Abandonado\nownership_requests: Solicitações de propriedade\nsensitive_warning: Conteúdo sensível\nsensitive_toggle: Alternar a visibilidade de conteúdo sensível\nlast_successful_deliver: Última entrega bem-sucedida\nversion: Versão\nlast_successful_receive: Última recepção bem-sucedida\nlast_failed_contact: Último contato que não deu certo\nadmin_users_inactive: Inativos\nadmin_users_active: Ativos\nuser_verify: Ativar conta\noauth2.grant.moderate.magazine.ban.create: Banir usuários nas suas revistas \n  moderadas.\noauth2.grant.admin.entry_comment.purge: Exclua completamente um comentário em \n  fios da sua instância.\noauth2.grant.admin.post.purge: Excluir completamente qualquer publicação de sua \n  instância.\noauth2.grant.moderate.magazine.ban.read: Visualizar usuários banidos em suas \n  revistas moderadas.\noauth2.grant.admin.user.ban: Banir ou desbanir usuários da sua instância.\noauth2.grant.admin.magazine.move_entry: Mover fios entre revistas da sua \n  instância.\noauth2.grant.admin.magazine.purge: Excluir completamente revistas da sua \n  instância.\noauth2.grant.admin.user.all: Banir, verificar ou excluir completamente os \n  usuários da sua instância.\noauth2.grant.admin.user.verify: Verificar usuários da sua instância.\noauth2.grant.admin.user.delete: Excluir usuários da sua instância.\ncomment_reply_position: Posição de resposta ao comentário\nmagazine_theme_appearance_custom_css: CSS personalizado que será aplicado quando\n  você visualizar o conteúdo da sua revista.\nupdate_comment: Atualizar comentário\nshow_avatars_on_comments_help: Exibir/ocultar avatares de usuários ao visualizar\n  comentários em um único fio ou publicação.\nmoderation.report.ban_user_title: Banir Usuário\nmoderation.report.reject_report_confirmation: Você tem certeza de que deseja \n  rejeitar essa denúncia?\nschedule_delete_account: Programar exclusão\nschedule_delete_account_desc: Programar a exclusão dessa conta em 30 dias. Isso \n  ocultará o usuário e seu conteúdo, além de impedir que ele faça login.\n2fa.code_invalid: O código de autenticação não é válido\ncancel: Cancelar\n2fa.enable: Configurar a autenticação de dois fatores\n2fa.setup_error: Erro ao ativar a 2FA para a conta\nmagazine_is_deleted: A revista foi excluída. Você pode <a \n  href=\"%link_target%\">restaurá-la</a> dentro de 30 dias.\nuser_suspend_desc: Suspender sua conta oculta seu conteúdo na instância, mas não\n  o remove permanentemente, e você pode restaurá-la a qualquer momento.\nremove_subscriptions: Remover assinaturas\nremove_following: Remover seguidor\napply_for_moderator: Candidatar-se a moderador\ndeleted_by_moderator: O fio, a publicação ou o comentário foi excluído pelo \n  moderador\nannouncement: Anúncio\nkeywords: Palavras-chave\ndelete_content: Excluir conteúdo\nremove_schedule_delete_account: Remover a exclusão programada\ntwo_factor_backup: Códigos de backup da autenticação de dois fatores\ncards_view: Visualização dos cartões\noauth2.grant.admin.user.purge: Excluir completamente usuários da sua instância.\noauth2.grant.moderate.magazine.ban.all: Gerenciar usuários banidos em suas \n  revistas moderadas.\n2fa.verify: Verificar\nflash_email_failed_to_sent: O e-mail não pode ser enviado.\nflash_user_edit_password_error: Não foi possível alterar a senha.\nedit_my_profile: Editar meu perfil\nhide: Ocultar\nall_time: Todo o período\nspoiler: Spoiler\noauth2.grant.moderate.magazine.all: Gerenciar banimentos, denúncias e visualizar\n  itens descartados em suas revistas moderadas.\noauth2.grant.admin.post_comment.purge: Excluir completamente uma publicação da \n  sua instância.\noauth2.grant.admin.instance.all: Visualizar e atualizar as configurações ou \n  informações da instância.\noauth2.grant.admin.instance.settings.all: Exibir ou atualizar as configurações \n  da sua instância.\noauth2.grant.admin.federation.read: Exibir a lista das instâncias desfederadas.\noauth2.grant.admin.oauth_clients.all: Visualizar ou revogar clientes OAuth2 que \n  existem em sua instância.\nflash_post_pin_success: A publicação foi fixada com sucesso.\nshow_avatars_on_comments: Mostrar avatares de comentários\nsingle_settings: Único\nmoderation.report.approve_report_confirmation: Você tem certeza de que deseja \n  aprovar esta denúncia?\nsubject_reported_exists: Esse conteúdo já foi denunciado.\noauth2.grant.moderate.post.pin: Fixe as publicações na parte superior de suas \n  revistas moderadas.\npurge_content: Purgar conteúdo\ndelete_content_desc: Excluir o conteúdo do usuário, deixando as respostas de \n  outros usuários nos fios, publicações e comentários criados.\nremove_schedule_delete_account_desc: Remover a exclusão programada. Todo o \n  conteúdo estará disponível novamente e o usuário poderá fazer login.\n2fa.authentication_code.label: Código de Autenticação\n2fa.disable: Desativar a autenticação de dois fatores\n2fa.backup: Seus códigos de backup de dois fatores\n2fa.backup-create.help: Você pode criar novos códigos de autenticação de backup;\n  ao fazer isso, os códigos existentes serão invalidados.\n2fa.backup-create.label: Criar novos códigos de autenticação de backup\n2fa.remove: Remover 2FA\n2fa.verify_authentication_code.label: Inserir um código de dois fatores para \n  verificar a configuração\n2fa.qr_code_link.title: Ao acessar este link, você permite que sua plataforma \n  registre essa autenticação de dois fatores\n2fa.backup_codes.recommendation: Recomenda-se que você mantenha uma cópia deles \n  em um local seguro.\npassword_and_2fa: Senha e 2FA\nshow_subscriptions: Mostrar assinaturas\nalphabetically: Por ordem alfabética\nsubscriptions_in_own_sidebar: Em uma barra lateral separada\nsidebars_same_side: Barras laterais no mesmo lado\nsubscription_sidebar_pop_out_right: Mover para a barra lateral separada à \n  direita\nsubscription_sidebar_pop_out_left: Mover para a barra lateral separada à \n  esquerda\nsubscription_panel_large: Painel grande\nsubscription_header: Revistas Assinadas\nclose: Fechar\nposition_bottom: Inferior\nflash_image_download_too_large_error: Não foi possível criar a imagem, pois ela \n  é muito grande (tamanho máximo %bytes%)\nflash_post_new_error: Não foi possível criar a publicação. Algo deu errado.\nflash_magazine_theme_changed_error: Não foi possível atualizar a aparência da \n  revista.\nflash_comment_new_success: O comentário foi criado com sucesso.\nflash_comment_edit_success: O comentário foi atualizado com sucesso.\nflash_comment_edit_error: Não foi possível editar o comentário. Algo deu errado.\nflash_user_settings_general_error: Não foi possível salvar as configurações do \n  usuário.\nflash_user_edit_profile_error: Não foi possível salvar as configurações de \n  perfil.\nflash_user_edit_email_error: Não foi possível alterar o e-mail.\nflash_thread_edit_error: Não foi possível editar o fio. Algo deu errado.\nflash_post_edit_error: Não foi possível editar a publicação.\nchange_my_cover: Modificar minha capa\naccount_settings_changed: As configurações da sua conta foram alteradas com \n  sucesso. Você precisará fazer login novamente.\nmagazine_deletion: Exclusão da revista\nrestore_magazine: Restaurar revista\npurge_magazine: Purgar revista\nsuspend_account: Suspender conta\naccount_banned: A conta foi banida.\naccount_unbanned: A conta foi desbanida.\naccount_is_suspended: A conta do usuário está suspensa.\nrequest_magazine_ownership: Solicitar a propriedade da revista\naction: Ação\nuser_badge_op: OP\nuser_badge_admin: Administrador\ndeleted_by_author: O fio, a publicação ou o comentário foi excluído pelo autor\nsensitive_show: Clique para mostrar\nsensitive_hide: Clique para ocultar\ndetails: Detalhes\nshow: Exibir\nedited: editado\nsso_registrations_enabled.error: Novos registros de conta com gerenciadores de \n  identidade de terceiros estão desativados no momento.\nsso_only_mode: Restringir o login e o registro apenas aos métodos de SSO\nmagazine_posting_restricted_to_mods: Restringir a criação de fios aos \n  moderadores\nnew_user_description: Este usuário é novo (ativo há menos de %days% dias)\nadmin_users_suspended: Suspensos\nadmin_users_banned: Banidos\nmax_image_size: Tamanho máximo do arquivo\ncards: Cartões\noauth2.grant.moderate.post_comment.trash: Remover ou restaurar comentários de \n  publicações em suas revistas moderadas.\noauth2.grant.admin.magazine.all: Mover fios entre revistas ou excluí-las \n  completamente da sua instância.\noauth2.grant.admin.instance.stats: Veja as estatísticas da sua instância.\noauth2.grant.admin.federation.all: Exibir e atualizar instâncias atualmente \n  desfederadas.\noauth2.grant.admin.federation.update: Adicionar ou remover instâncias de ou para\n  a lista de instâncias desfederadas.\noauth2.grant.admin.oauth_clients.read: Veja os clientes OAuth2 que existem em \n  sua instância e suas estatísticas de uso.\nlast_active: Última atividade\nmagazine_theme_appearance_icon: Ícone personalizado para a revista. Se você não \n  selecionar nenhum, será usado o ícone padrão.\npurge_content_desc: Purgar completamente o conteúdo do usuário, incluindo \n  excluir as respostas de outros usuários em fios, publicações e comentários \n  criados.\ndelete_account_desc: Excluir a conta, incluindo as respostas de outros usuários \n  em fios, publicações e comentários criados.\ntwo_factor_authentication: Autenticação de dois fatores\n2fa.add: Adicionar à minha conta\n2fa.qr_code_img.alt: Um código QR que permite a configuração da autenticação de \n  dois fatores para sua conta\n2fa.user_active_tfa.title: O usuário tem a 2FA ativada\n2fa.available_apps: Use um aplicativo de autenticação de dois fatores, como \n  %google_authenticator%, %aegis% (Android) ou %raivo% (iOS) para fazer a \n  leitura do código QR.\n2fa.backup_codes.help: Você pode usar esses códigos quando não tiver seu \n  dispositivo ou aplicativo de autenticação de dois fatores. Você <strong>não os\n  verá novamente</strong> e poderá usar cada um deles <strong>apenas uma \n  vez</strong>.\nsubscription_sort: Ordenar\nflash_thread_tag_banned_error: Não foi possível criar o fio. O conteúdo não é \n  permitido.\nflash_email_was_sent: O email foi enviado com sucesso.\nflash_post_new_success: A publicação foi criada com sucesso.\nflash_comment_new_error: Não foi possível criar o comentário. Algo deu errado.\nflash_user_edit_profile_success: As configurações do perfil do usuário foram \n  salvas com sucesso.\nflash_post_edit_success: A publicação foi editada com sucesso.\nauto: Automático\ndelete_magazine: Excluir revista\nsso_show_first: Mostrar o SSO primeiro nas páginas de login e registro\nnew_magazine_description: Esta revista é nova (ativa há menos de %days% dias)\ncomment_not_found: Comentário não encontrado\noauth2.grant.admin.oauth_clients.revoke: Revogar o acesso a clientes OAuth2 na \n  sua instância.\nflash_post_unpin_success: A publicação foi desafixada com sucesso.\noauth2.grant.admin.instance.settings.read: Exibir as configurações da sua \n  instância.\noauth2.grant.admin.instance.settings.edit: Atualizar as configurações da sua \n  instância.\noauth2.grant.admin.instance.information.edit: Atualizar as páginas Sobre, \n  Perguntas frequentes, Contato, Termos de serviço e Política de privacidade da \n  sua instância.\nmagazine_theme_appearance_background_image: Imagem de fundo personalizada que \n  será aplicada quando você visualizar o conteúdo da sua revista.\nmoderation.report.approve_report_title: Aprovar a Denúncia\nflash_user_settings_general_success: As configurações do usuário foram salvas \n  com sucesso.\naccept: Aceitar\nsso_registrations_enabled: Registros SSO ativados\nback: Anterior\ncomment_reply_position_help: Exibir o formulário de resposta a comentários na \n  parte superior ou inferior da página. Quando a “rolagem infinita” estiver \n  ativada, a posição sempre aparecerá na parte superior.\nmoderation.report.reject_report_title: Rejeitar a Denúncia\nmoderation.report.ban_user_description: Você deseja banir o usuário (%username%)\n  que criou esse conteúdo desta revista?\nsubscription_sidebar_pop_in: Mover as assinaturas para o painel em linha\nposition_top: Topo\npending: Pendente\nflash_account_settings_changed: As configurações da sua conta foram alteradas \n  com sucesso. Você precisará fazer login novamente.\nflash_thread_new_error: Não foi possível criar o fio. Algo deu errado.\nreported: denunciado\nopen_report: Abrir denúncia\nremove_user_avatar: Remover avatar\nremove_user_cover: Remover Capa\ncrosspost: Postagem cruzada\nshow_profile_followings: Mostrar usuários seguidos\nnotify_on_user_signup: Novas inscrições\nban_expires: Banimento expira\nbanner: Banner\ntype_search_term_url_handle: Digite termo de busca, URL ou identificador\nviewing_one_signup_request: Você só está vendo um pedido de inscrição por \n  %username%\nyour_account_is_not_yet_approved: Sua conta ainda não foi aprovada. Lhe \n  enviaremos um e-mail assim que os administradores tiverem processado o seu \n  pedido de inscrição.\ntoolbar.emoji: Emoji\noauth2.grant.user.bookmark: Adicionar ou remover favorito\noauth2.grant.user.bookmark.add: Adicionar aos favoritos\noauth2.grant.user.bookmark.remove: Remover dos favoritos\noauth2.grant.user.bookmark_list: Leia, edite e exclua suas listas de favoritos\noauth2.grant.user.bookmark_list.read: Leia suas listas de favoritos\noauth2.grant.user.bookmark_list.edit: Edite suas listas de favoritos\noauth2.grant.user.bookmark_list.delete: Excluir suas listas de favoritos\noauth2.grant.moderate.entry.lock: Bloquear tópicos em suas revistas moderadas, \n  para que ninguém possa comentar sobre ele\noauth2.grant.moderate.post.lock: Bloquear microblogs em suas revistas moderadas,\n  para que ninguém possa comentar neles\nbookmark_list_make_default: Tornar padrão\nbookmark_list_create_placeholder: Digite o nome...\nbookmarks_list_edit: Editar lista de favoritos\nbookmark_list_create_label: Nome da lista\nbookmark_list_edit: Editar\nbookmark_list_selected_list: Lista selecionada\ntable_of_contents: Quadro de conteúdos\nsearch_type_all: Tudo\nsearch_type_entry: Tópicos\nsearch_type_post: Microblogs\nsearch_type_magazine: Revistas\nsearch_type_user: Usuários\nsearch_type_actors: Revistas + Usuários\nsearch_type_content: Tópicos + Microblogs\nselect_user: Escolha um usuário\nshow_magazine_domains: Mostrar domínios de revistas\nshow_user_domains: Mostrar domínios de usuário\nanswered: respondidas\nby: por\nfront_default_sort: Tipo padrão da página inicial\ncomment_default_sort: Tipo padrão de comentário\nopen_signup_request: Abrir pedidos de inscrição\nimage_lightbox_in_list: Miniaturas de tópicos abrem tela cheia\ncompact_view_help: Uma visão compacta com menos margens, onde a mídia é movida \n  para o lado direito.\nshow_users_avatars_help: Exibir a imagem de avatar do usuário.\nshow_magazines_icons_help: Exibir o ícone da revista.\nshow_thumbnails_help: Mostre as imagens da miniatura.\nimage_lightbox_in_list_help: Quando verificado, clicando na miniatura mostra uma\n  janela de caixa de imagem modal. Quando desmarcado, clicar na miniatura abrirá\n  o tópico.\nshow_new_icons: Mostrar novos ícones\nshow_new_icons_help: Mostrar ícone para nova revista / usuário (mais ou menos 30\n  dias)\nmagazine_instance_defederated_info: A instância desta revista não é federada. A \n  revista, portanto, não receberá atualizações.\nuser_instance_defederated_info: A instância deste usuário não é federada.\nflash_thread_instance_banned: A instância desta revista está banida.\nshow_rich_mention: Menções populares\nshow_rich_mention_help: Renderize um componente de usuário quando um usuário for\n  mencionado. Isso incluirá o nome de exibição e a foto de perfil dele.\nshow_rich_mention_magazine: Menções populares de revistas\nshow_rich_mention_magazine_help: Renderize um componente de revista quando uma \n  revista for mencionada. Isso incluirá o nome de exibição e o ícone dela.\ndelete_magazine_icon: Excluir ícone da revista\nflash_magazine_theme_icon_detached_success: Ícone da revista excluído com \n  sucesso\ndelete_magazine_banner: Excluir banner de revista\nflash_magazine_theme_banner_detached_success: Banner de revista excluído com \n  sucesso\nfederation_uses_allowlist: Use a lista de permissões para federação\ndefederating_instance: Desfederar instância %i\ntheir_user_follows: Quantidade de usuários de sua instância seguindo usuários em\n  nossa instância\nour_user_follows: Quantidade de usuários de nossa instância seguindo usuários em\n  sua instância\ntheir_magazine_subscriptions: Quantidade de usuários de sua instância inscritos \n  em revistas em nossa instância\nour_magazine_subscriptions: Quantidade de usuários em nossa instância inscritos \n  em revistas de sua instância\nconfirm_defederation: Confirmar desfederação\nflash_error_defederation_must_confirm: Você tem que confirmar a desfederação\nallowed_instances: Instâncias permitidas\nbtn_deny: Recusar\nbtn_allow: Permitir\nban_instance: Banir instância\nallow_instance: Permitir instância\nfederation_page_use_allowlist_help: Se uma lista de permissão for usada, essa \n  instância somente irá federar com as instâncias explicitamente permitidas. \n  Caso contrário, esta instância  irá federar com cada instância, exceto aqueles\n  que são proibidos.\nyou_have_been_banned_from_magazine: Você foi banido da revista %m.\nyou_have_been_banned_from_magazine_permanently: Você foi permanentemente banido \n  da revista %m.\nyou_are_no_longer_banned_from_magazine: Você não está mais banido da revista %m.\nfront_default_content: Visão padrão da página inicial\ndefault_content_default: Predefinição do servidor (Tópicos)\ndefault_content_combined: Tópicos + Microblog\ndefault_content_threads: Tópicos\ndefault_content_microblog: Microblog\ncombined: Combinado\nsidebar_sections_random_local_only: Restringir seções da barra lateral \n  \"Tópicos/Postagens aleatórios\" para apenas local\nsidebar_sections_users_local_only: Restringir seção de barra lateral \"pessoas \n  ativas\" apenas local\nrandom_local_only_performance_warning: Habilitar \"Apenas local aleatoriamente\" \n  pode causar impacto de desempenho SQL.\ndiscoverable: Descobrível\nuser_discoverable_help: Se isso estiver ativado, seu perfil, tópicos, microblogs\n  e comentários podem ser encontrados através da pesquisa e os painéis \n  aleatórios. Seu perfil também pode aparecer no painel de usuário ativo e na \n  página de pessoas. Se isso for desativado, seus posts ainda serão visíveis \n  para outros usuários, mas eles não aparecerão no feed todo.\nmagazine_discoverable_help: Se isso estiver ativado, esta revista e tópicos, \n  microblogs e comentários desta revista podem ser encontrados através da \n  pesquisa e os painéis aleatórios. Se isso for desativado, a revista ainda \n  aparecerá na lista de revistas, mas os fios e microblogs não aparecerão em \n  todo o feed.\nflash_thread_lock_success: Tópico bloqueado com sucesso\nflash_thread_unlock_success: Tópico desbloqueado com sucesso\nflash_post_lock_success: Microblog bloqueado com sucesso\nflash_post_unlock_success: Microblog desbloqueado com sucesso\nlock: Fechado\nunlock: Reabrir\ncomments_locked: Os comentários estão fechado.\nmagazine_log_entry_locked: fechar os comentários de\nmagazine_log_entry_unlocked: reabrir os comentários de\nmodlog_type_entry_lock: Tópico fechado\nmodlog_type_entry_unlock: Tópico reaberto\nmodlog_type_post_lock: Microblog fechado\nmodlog_type_post_unlock: Microblog reaberto\ncontentnotification.muted: Mutar | não receber notificações\ncontentnotification.default: Padrão | obter notificações de acordo com suas \n  configurações padrão\ncontentnotification.loud: Geral | obter todas as notificações\nindexable_by_search_engines: Indexável por motores de busca\nuser_indexable_by_search_engines_help: Se esta configuração é falsa, os motores \n  de busca são aconselhados a não indexar qualquer um dos seus tópicos e \n  microblogs, no entanto, seus comentários não são afetados por este e maus \n  atores podem ignorá-lo. Esta configuração também é federada para outros \n  servidores.\nmagazine_indexable_by_search_engines_help: Se esta configuração é falsa, os \n  motores de busca são aconselhados a não indexar nenhum dos tópicos e \n  microblogs nestas revistas. Isso inclui a landing page e todas as páginas de \n  comentários. Esta configuração também é federada para outros servidores.\nmagazine_name_as_tag: Use o nome da revista como uma tag\nmagazine_name_as_tag_help: As tags de uma revista são usadas para combinar \n  postagens microblog para esta revista. Por exemplo, se o nome é \"fediverso\" e \n  as tags da revista contêm \"fediverso\", então cada post microblog contendo \n  \"#fediverso\" será colocado nesta revista.\nmagazine_theme_appearance_banner: Banner personalizado para a revista. Ele é \n  exibido acima de todos os fios e deve estar em uma relação de aspecto amplo \n  (5:1, ou 1500px * 300px).\n2fa.manual_code_hint: Se você não puder digitalizar o QR code, digite o segredo \n  manualmente\nflash_thread_ref_image_not_found: A imagem referenciada por 'imageHash' não pôde\n  ser encontrada.\nmoderator_requests: Pedidos de Mod\nuser_badge_global_moderator: Mod Global\nuser_badge_moderator: Mod\nuser_badge_bot: Bot\nreporting_user: Reportando o usuário\nmagazine_log_entry_pinned: Entrada fixada\nnotification_title_new_signup: Um novo usuário registrado\nnotification_body_new_signup: O usuário %u% registrado.\nnotification_body2_new_signup_approval: Você precisa aprovar o pedido antes que \n  eles possam fazer login\nbookmark_add_to_list: Adicionar favorito a %list%\nbookmark_remove_from_list: Remover favorito de %list%\nbookmark_remove_all: Remover todos os favoritos\nbookmark_add_to_default_list: Adicionar favorito à lista padrão\nbookmark_lists: Listas de favoritos\nbookmarks: Favoritos\nbookmarks_list: Favoritos na %list%\ncount: Contagem\nis_default: É o padrão\nbookmark_list_is_default: É a lista padrão\nbookmark_list_create: Criar\nnew_users_need_approval: Novos usuários têm que ser aprovados por um \n  administrador antes que eles possam fazer login.\nsignup_requests: Pedidos de inscrição\napplication_text: Explique por que você quer participar\nsignup_requests_header: Pedidos de inscrição\nsignup_requests_paragraph: Esses usuários gostariam de se juntar ao seu \n  servidor. Não podem entrar até aprovar o pedido de inscrição.\nflash_application_info: Um administrador precisa aprovar sua conta antes de \n  poder fazer login. Você receberá um e-mail assim que o pedido de inscrição for\n  processado.\nemail_application_approved_title: Seu pedido de inscrição foi aprovado\nemail_application_approved_body: Seu pedido de inscrição foi aprovado pelo \n  administrador do servidor. Agora você pode fazer login no servidor em <a \n  href=\"%link%\">%siteName%</a>.\nemail_application_rejected_title: Seu pedido de inscrição foi rejeitado\nemail_application_rejected_body: Obrigado pelo seu interesse, mas lamentamos \n  informar que o seu pedido de inscrição foi recusado.\nemail_application_pending: Sua conta requer aprovação do administrador antes de \n  poder fazer login.\nemail_verification_pending: Você tem que verificar seu endereço de e-mail antes \n  de fazer login.\nshow_rich_ap_link: Múltiplos PA links\nshow_rich_ap_link_help: Renderiza um componente embutido quando outro conteúdo \n  do ActivityPub estiver vinculado.\nattitude: Atitude\ntype_search_magazine: Limitar a busca à revista...\ntype_search_user: Limitar a busca ao autor...\nmodlog_type_entry_deleted: Tópico apagado\nmodlog_type_entry_restored: Tópico restaurado\nmodlog_type_entry_comment_deleted: Comentário do Tópico excluído\nmodlog_type_entry_comment_restored: Comentário de Tópico restaurado\nmodlog_type_entry_pinned: Tópico fixado\nmodlog_type_entry_unpinned: Tópico desfixado\nmodlog_type_post_deleted: Microblog excluído\nmodlog_type_post_restored: Microblog restaurado\nmodlog_type_post_comment_deleted: Resposta do microblog apagada\nmodlog_type_post_comment_restored: Resposta do microblog restaurada\nmodlog_type_ban: Usuário baniddo da revista\nmodlog_type_moderator_add: Moderador de revista adicionado\nmodlog_type_moderator_remove: Moderador de revista removido\neveryone: Todo mundo\nnobody: Ninguém\nfollowers_only: Apenas seguidores\ndirect_message_setting_label: Quem pode enviar uma mensagem direta\n"
  },
  {
    "path": "translations/messages.ru.yaml",
    "content": "type.link: Ссылка\ntype.article: Ветка\ntype.photo: Фото\ntype.video: Видео\ntype.smart_contract: Умный контракт\ntype.magazine: Журнал\nthread: Ветка\nthreads: Ветки\nmicroblog: Микроблог\npeople: Люди\nevents: События\nmagazine: Журнал\nmagazines: Журналы\nsearch: Поиск\nadd: Добавить\nselect_channel: Выберите канал\nlogin: Войти\ntop: Лучшее\nhot: Горячее\nactive: Активное\nnewest: Свежее\noldest: Более старое\ncommented: Оставлен комментарий\nchange_view: Изменить вид\nfilter_by_time: Сортировка по времени\nfilter_by_type: Сортировка по типу\ncomments_count: '{0}Комментариев|{1}Комментарий|]1,Inf[ Комментариев'\nfavourites: Положительные оценки\nfavourite: Нравиться\nmore: Больше\navatar: Аватар\nadded: Добавлено\nup_votes: Голос За\ndown_votes: Голос против\nno_comments: Нет комментариев\ncreated_at: Создано\nowner: Владелец\nsubscribers: Подписки\nonline: Онлайн\ncomments: Комментарии\nposts: Посты\nreplies: Ответы\nmoderators: Модераторы\nmod_log: Лог модерации\nadd_comment: Добавить комментарий\nadd_post: Добавить пост\nadd_media: Добавить медиа\nmarkdown_howto: Как работает наш редактор?\nenter_your_comment: Введите комментарий\nenter_your_post: Введите содержимое поста\nactivity: Активность\ncover: Обложка\nrelated_posts: Связанные посты\nrandom_posts: Случайный пост\nfederated_magazine_info: Данный журнал из другого инстанса и информация может \n  быть неполной.\nfederated_user_info: Данный профиль из другого инстанса и информация может быть \n  неполной.\ngo_to_original_instance: Открыть на удалённом сервере\nempty: Здесь ничего нет\nsubscribe: Подписка\nunsubscribe: Отписки\nfollow: Подписаться\nunfollow: Отписаться\nreply: Ответить\nlogin_or_email: Логин или почта\npassword: Пароль\nremember_me: Запомнить меня\ndont_have_account: Еще нет аккаунта?\nyou_cant_login: Не можете войти?\nalready_have_account: Уже есть аккаунт?\nregister: Регистрация\nreset_password: Сбросить пароль\nshow_more: Показать больше\nto: к\nin: в\nusername: Имя пользователя\nemail: Почта\nrepeat_password: Повторить пароль\nagree_terms: Принять %terms_link_start%Условия использования%terms_link_end% и \n  %policy_link_start%Политику конфиденциальности%policy_link_end%\nterms: Условия использования\nprivacy_policy: Политика конфиденциальности\nabout_instance: Об инстансе\nall_magazines: Все журналы\nstats: Статистика\nfediverse: Федерация\ncreate_new_magazine: Создать новый журнал\nadd_new_article: Добавить новую ветку\nadd_new_link: Добавить новую ссылку\nadd_new_photo: Добавить новое фото\nadd_new_post: Добавить новый пост\nadd_new_video: Добавить новое видео\ncontact: Контакты\nfaq: ЧаВо\nrss: RSS\nchange_theme: Изменить тему\nuseful: Полезное\nhelp: Помощь\ncheck_email: Проверьте свою почту\nreset_check_email_desc: Если мы нашли аккаунт связанный с вашей почтой, То вы \n  должны получить ссылку для сброса пароля. Ссылка доступна до %expire%.\nreset_check_email_desc2: Если вы не получили письмо проверьте папку со спамом\ntry_again: Попробовать еще раз\nup_vote: Продвинуть\ndown_vote: Уменьшить\nemail_confirm_header: Привет! Подтвердите вашу почту.\nemail_confirm_content: 'Готов активировать аккаунт в Mbin? Переходи по ссылке ниже:'\nemail_verify: Проверить адрес почты\nemail_confirm_expire: Учтите что ссылка доступна лишь час.\nemail_confirm_title: Подтвердите название вашей почты.\nselect_magazine: Выберите журнал\nadd_new: Добавить новый\nurl: Ссылка\ntitle: Заголовок\nbody: Тело\ntags: Теги\nbadges: Бейджи\nis_adult: 18+ / NSFW\neng: ENG\noc: OC\nimage: Изображение\nimage_alt: Другое изображение\nname: Имя\ndescription: Описание\nrules: Правила\ndomain: Домен\nfollowers: Подписчики\nfollowing: Подписываться\nsubscriptions: Подписки\noverview: Обзор\ncards: Карточки\ncolumns: Колонки\nuser: Пользователь\njoined: Присоединиться\nmoderated: Модерируется\npeople_local: Местные\npeople_federated: В федерации\nreputation_points: Очки репутации\nrelated_tags: Связанные теги\ngo_to_content: Перейти к контенту\ngo_to_filters: Перейти к фильтрам\ngo_to_search: Перейти к поиску\nsubscribed: Подписан\nall: Все\nlogout: Выход\nclassic_view: Стандартное отображение\ncompact_view: Компактный вид\nchat_view: Просмотр чата\ntree_view: В виде дерева\ntable_view: Просмотр таблицы\ncards_view: Просмотр карточек\n3h: 3 часа\n6h: 6 часов\n12h: 12 часов\n1d: 1 день\n1w: 1 неделя\n1m: 1 месяц\n1y: 1 год\nlinks: Ссылки\narticles: Ветки\nphotos: Фоторграфии\nvideos: Видео\nreport: Отчёт\nshare: Поделиться\ncopy_url: Копировать Mbin URL\ncopy_url_to_fediverse: Копировать оригинальный URL\nshare_on_fediverse: Поделиться в Федерации\nedit: Редактировать\nare_you_sure: Вы уверены?\nmoderate: Модерация\nreason: Причина\ndelete: Удалить\nedit_post: Редактировать пост\nedit_comment: Сохранить изменения\nsettings: Настройки\ngeneral: Основные\nprofile: Профиль\nblocked: Заблокирован\nreports: Отчёты\nnotifications: Уведомления\nmessages: Сообщения\nappearance: Появление\nhomepage: Домашняя страница\nhide_adult: Скрыть NSFW контент\nfeatured_magazines: Избранные журналы\nprivacy: Приватное\nshow_profile_subscriptions: Показать журналы в подписках\nshow_profile_followings: Показать профили подписчиков\nnotify_on_new_entry_reply: Уведомление о новом ответе\nnotify_on_new_entry_comment_reply: Ответы на мои комментарии в любой ветке\nnotify_on_new_post_reply: Любой уровень ответа в моих постах\nnotify_on_new_post_comment_reply: Ответы на мои комментарии в любом посте\nnotify_on_new_entry: Новые ветки в любом журнале из моих подписок\nnotify_on_new_posts: Новые посты в любом журнале из моих подписок\nsave: Сохранить\nabout: О\nold_email: Текущая электронная почта\nnew_email: Новая электронная почта\nnew_email_repeat: Подтвердить новую электронную почту\ncurrent_password: Текущий пароль\nnew_password: Новый пароль\nnew_password_repeat: Подтвердить новый пароль\nchange_email: Изменить электронную почту\nchange_password: Изменить пароль\nexpand: Развернуть\ncollapse: Свернуть\ndomains: Домены\nerror: Ошибка\nvotes: Голоса\ntheme: Тема\ndark: Тёмный\nlight: Светлый\nsolarized_light: Солнечный светлый\nsolarized_dark: Солнечный тёмный\ndefault_theme: Стандартная тема\ndefault_theme_auto: Светлый/Тёмный (Определять автоматически)\nsolarized_auto: Солнечное (Определять автоматически)\nfont_size: Размер шрифта\nsize: Размер\nboosts: Продвижение\nshow_users_avatars: Показать аватары пользователей\nyes: Да\nno: Нет\nshow_magazines_icons: Показать иконки журналов\nshow_thumbnails: Показать миниатюры\nrounded_edges: Скруглить края\nremoved_thread_by: удалил ветвь, созданную\nrestored_thread_by: восстановил ветвь, созданную\nremoved_comment_by: Комментарий удалён\nrestored_comment_by: Комментарий восстановлен\nremoved_post_by: Пост удалён\nrestored_post_by: Пост восстановлен\nhe_banned: Бан\nhe_unbanned: Разбан\nread_all: Прочитать всё\nshow_all: Показать всё\nflash_register_success: Добро пожаловать! Ваша учетная запись зарегистрирована. \n  Последний шаг - Проверьте свою электронную почту и перейдите по ссылке для \n  активации Вашей учётной записи.\nflash_thread_new_success: Ветка успешно создана и теперь видна другим \n  пользователям.\nflash_thread_edit_success: Ветка успешно отредактирована.\nflash_thread_delete_success: Ветка была успешно удалёна.\nflash_thread_pin_success: Ветка успешно закреплена.\nflash_thread_unpin_success: Ветка успешно откреплёна.\nflash_magazine_new_success: Журнал успешно создан. Сейчас можно изучить панель \n  администратора и добавить новый контент.\nflash_magazine_edit_success: Журнал успешно изменён.\nflash_mark_as_adult_success: Пост отмечен как NSFW.\nflash_unmark_as_adult_success: Пост больше не имеет отметки NSFW.\ntoo_many_requests: Лимит запросов превышен, попробуйте ещё раз позднее.\nset_magazines_bar: Активная панель журнала\nset_magazines_bar_desc: Добавить названия журналов после запятой\nset_magazines_bar_empty_desc: Если поле пустое, активные журналы отображаются на\n  активной панели.\nmod_log_alert: ВНИМАНИЕ! - Данный материал может содержать неприятный или \n  опасный контент! Это было удалено модераторами. Пожалуйста, будьте \n  внимательны.\nadded_new_thread: Добавлен в новую ветку\nedited_thread: Отредактированная ветка\nmod_remove_your_thread: Модератор удалил вашу ветку\nadded_new_comment: Добавлен новый комментарий\nedited_comment: Отредактированный комментарий\nreplied_to_your_comment: Ответил на ваш комментарий\nmod_deleted_your_comment: Модератор удалил ваш комментарий\nadded_new_post: Добавлен новый пост\nedited_post: Отредактированный пост\nmod_remove_your_post: Модератор удалил ваш пост\nadded_new_reply: Добавлен новый ответ\nwrote_message: Написал сообщение\nbanned: Забанил вас\nremoved: Перемещён модератором\ndeleted: Удалён автором\nmentioned_you: Упомянул тебя\ncomment: Комментарий\npost: Пост\nban_expired: Бан окончен\npurge: Очистить\nsend_message: Написать личное сообщение\nmessage: Сообщение\ninfinite_scroll: Бесконечная прокрутка\nshow_top_bar: Показать активную панель\nsticky_navbar: Липкая панель навигации\nsubject_reported: Содержимое было зарегистрировано.\nsidebar_position: Положение боковой панели\nleft: Слева\nright: Справа\nfederation: Федерация\nstatus: Статус\non: Включен\noff: выключен\ninstances: Инстансы\nupload_file: Загрузить файл\nfrom_url: От отправителя\nmagazine_panel: Панель журнала\nreject: Отклонить\napprove: Согласиться\nban: Бан\nfilters: Фильтры\napproved: Согласовано\nrejected: Отклонённые\nadd_moderator: Добавить модератора\nadd_badge: Добавить обозначение\nbans: Баны\ncreated: Созданные\nexpires: Истекает\nperm: Постоянный\nexpired_at: Истёк в\nadd_ban: Добавить в бан\ntrash: Мусор\nicon: Иконка\ndone: Сделано\npin: Закрепление\nunpin: Открепление\nchange_magazine: Выбрать журнал\nchange_language: Выбрать язык\nmark_as_adult: Отметить NSFW\nunmark_as_adult: Снять отметку NSFW\nchange: Выбрать\npinned: Закрепить\npreview: Предпросмотр\narticle: Ветка\nreputation: Репутация\nnote: Заметка\nwriting: Письмо\nusers: Пользователи\ncontent: Контент\nweek: Неделя\nweeks: Недели\nmonth: Месяц\nmonths: Месяцы\nyear: Годы\nfederated: Федеративный\nlocal: Локальный\nadmin_panel: Панель администратора\ndashboard: Панель инструментов\ncontact_email: Контактная электронная почта\nmeta: Meta\ninstance: Инстанс\npages: Страницы\nFAQ: Часто задаваемые вопросы\ntype_search_term: Напишите вопрос\nfederation_enabled: Федерация включена\nregistrations_enabled: Регистрация включена\nregistration_disabled: Регистрация отключена\nrestore: Восстановление\nadd_mentions_entries: Добавить тэги в ветки\nadd_mentions_posts: Добавить тэги в посты\nPassword is invalid: Неправильный пароль.\nYour account is not active: Ваш аккаунт не был активирован.\nYour account has been banned: Ваш аккаунт был заблокирован.\nfirstname: Имя\nsend: Отправить\nactive_users: Активные пользователи\nrandom_entries: Случайные ветки\nrelated_entries: Связанные ветки\ndelete_account: Удалить учётную запись\npurge_account: Очистить аккаунт\nban_account: Забанить аккаунт\nunban_account: Разбанить аккаунт\nrelated_magazines: Связанные журналы\nrandom_magazines: Случайные журналы\nmagazine_panel_tags_info: Предоставить, только если вы хотите, чтобы контент из \n  Федиверса был включен в этот журнал на основе тегов\nsidebar: Боковая панель\nauto_preview: Авто предпросмотр\ndynamic_lists: Динамические списки\nbanned_instances: Забаненные инстансы\nkbin_intro_title: Посмотреть Федиверс\nkbin_intro_desc: является децентрализованной платформой для агрегирования \n  контента и микроблогинга, которая работает в сети Fediverse.\nkbin_promo_title: Создайте ваш инстанс\nkbin_promo_desc: '%link_start%перейдите по ссылке%link_end% и начните свой проект'\ncaptcha_enabled: Капча включена\nheader_logo: Логотип заголовка\nbrowsing_one_thread: Вы просматриваете только одну ветку обсуждения! Все \n  комментарии доступны на странице поста.\nreturn: Вернуться\nboost: Продвигать\nmercure_enabled: Включено\nreport_issue: Описание релиза\ntokyo_night: Ночной Токио\npreferred_languages: Языковая фильтрация в ветках и постах\ninfinite_scroll_help: Автоматически загружать контент, когда вы дочитаете \n  страницу до конца.\nsticky_navbar_help: Навигационная панель будет находиться вверху, пока вы \n  прокручиваете вниз.\nauto_preview_help: Автоматически увеличить поле просмотра медиа.\nreload_to_apply: Перезагрузить страницу для принятия изменений\nfilter.origin.label: Выбрать оригинал\nfilter.fields.label: Выбрать поля для поиска\nfilter.adult.label: Выберите отображать или нет NSFW\nfilter.adult.hide: Скрыть NSFW\nfilter.adult.show: Показать NSFW\nfilter.adult.only: Только NSFW\nlocal_and_federated: Локальный и федеративный\nfilter.fields.only_names: Только имена\nfilter.fields.names_and_descriptions: Имена и описания\nkbin_bot: Mbin бот\nbot_body_content: \"Добро поажловать в Mbin Бот! Этот бот важный элемент при использовании\n  функционала ActivityPub в Mbin. Это гарантирует, что MBIN может общаться и быть\n  в Федерации с другими сервисами в Fediverse. \\n\\nActivityPub является открытым стандартом,\n  протокол, который позволяет децентрализованным социальным сетям платформ общаться\n  и взаимодействовать друг с другом. Это позволяет пользователям в разных инстансах\n  (серверы) взаимодействовать и делиться контентом в федеративной социальной сети,\n  известной как Fediverse.\"\npassword_confirm_header: Подтвердите запрос на изменение пароля.\nyour_account_is_not_active: Ваш аккаунт не может быть активирован. Пожалуйста, \n  проверьте инструкцию для активации в вашей электронной почте <a \n  href=\"%link_target%\">Сделать новый запрос для активации аккаунта.</a>\nyour_account_has_been_banned: Баш аккаунт был забанен\ntoolbar.bold: Жирный шрифт\ntoolbar.italic: Италик\ntoolbar.strikethrough: Выделенный\ntoolbar.header: Заглавие\ntoolbar.quote: Цитировать\ntoolbar.code: Код\ntoolbar.link: Ссылка на панель инструментов\ntoolbar.image: Изображение\ntoolbar.unordered_list: Неуопрядоченный список\ntoolbar.ordered_list: Упорядоченный список\ntoolbar.mention: Упомянуть\nfederation_page_enabled: Страница федерации включена\nfederation_page_allowed_description: Известные инстансы Федерации\nfederation_page_disallowed_description: Инстансы не подключенные к Федерации\nfederated_search_only_loggedin: Ограниченный поиск в Федерации, если нет \n  регистрации\nmore_from_domain: Больше о домене\nerrors.server500.title: 500 - внутренняя ошибка сервера\nerrors.server500.description: Что-то пошло не так с нашей стороны. Если вы \n  продолжите видеть эту ошибку, попробуйте связаться с владельцем инстанса.\nerrors.server429.title: 429 - много запросов\nerrors.server404.title: 404 - страница не найдена\nerrors.server403.title: 403 - недоступно\nemail_confirm_button_text: Подтвердите ваш запрос на изменение пароля\nemail_confirm_link_help: Если не смогли перейти автоматически, скопируйте ссылку\n  и откройте в вашем барузере\nemail.delete.title: Запрос на удаление учётной записи\nemail.delete.description: Запрос на удаление учётной записи подписчика\nresend_account_activation_email_question: Неактивный аккаунт?\nresend_account_activation_email: Ещё раз отправить запрос на активацию аккаунта\nresend_account_activation_email_error: Ошибка при отправке запроса. Запрос \n  отправлен ошибочно или аккаунт уже активирован.\nresend_account_activation_email_success: Если учетная запись, связанная с этим \n  электронным письмом существует, мы отправим новое электронное письмо с \n  активацией.\nresend_account_activation_email_description: Введите адрес электронной почты, \n  связанный с вашим аккаунтом. Мы отправим вам еще одно электронное письмо с \n  активацией.\ncustom_css: Пользовательский CSS\nignore_magazines_custom_css: Игнориовать журналы с пользовательстким CSS\noauth.consent.title: OAuth2 форма согласия\noauth.consent.grant_permissions: Предоставление разрешеий\noauth.consent.app_requesting_permissions: Разрешение выполнить дейтсвия от \n  вашего имени\noauth.consent.app_has_permissions: Можете выполнить следующие действия\noauth.consent.to_allow_access: Чтобы разрешить этот доступ, нажмите РАЗРЕШИТЬ \n  ниже\noauth.consent.allow: Разрешить\noauth.consent.deny: Запретить\noauth.client_identifier.invalid: Не верный ID OAuth!\noauth.client_not_granted_message_read_permission: У этого приложения нет \n  разрешения читать ваши сообщения.\nrestrict_oauth_clients: Ограничить создание OAuth2 Администарторами\nblock: Заблокировано\nunblock: Разблокировано\noauth2.grant.moderate.magazine.ban.delete: Разбанить пользователей в \n  модерируемых журналах.\noauth2.grant.moderate.magazine.list: Прочитать список ваших модерируемых \n  журналов.\noauth2.grant.moderate.magazine.reports.all: Управлять отчётом в модерируемых \n  журналах.\noauth2.grant.moderate.magazine.reports.read: Читать отчёты в модерируемых \n  журналах.\noauth2.grant.moderate.magazine.reports.action: Принять или отклонить отчёты в \n  модерируемых журналах.\noauth2.grant.moderate.magazine.trash.read: Просмотреть удалённый контент в \n  модерируемых журналах.\noauth2.grant.moderate.magazine_admin.all: Создать, изменить или удалить \n  собственный журнал.\noauth2.grant.moderate.magazine_admin.create: Создать новый журнал.\noauth2.grant.moderate.magazine_admin.delete: удалить любой ваш журнал.\noauth2.grant.moderate.magazine_admin.update: Редактировать описание правил \n  любого из ваших журналов, статус или знак NSFW.\noauth2.grant.moderate.magazine_admin.edit_theme: Изменить пользовательский CSS в\n  вашем журнале.\noauth2.grant.moderate.magazine_admin.moderators: Добавить или удалить \n  модераторов в вашем журнале.\noauth2.grant.moderate.magazine_admin.badges: Создать или удалить значки в своём \n  журнале.\noauth2.grant.moderate.magazine_admin.tags: Создать или удалить тэги в своём \n  журнале.\noauth2.grant.moderate.magazine_admin.stats: Просмотреть контент, голоса и \n  статусы в своём журнале.\noauth2.grant.admin.all: Выполнять любые административные действия в отношени \n  вашего журнала.\noauth2.grant.admin.entry.purge: Полностью удалите любую ветку из вашего \n  инстанса.\noauth2.grant.read.general: Читать весь контент, к которому у вас есть доступ.\noauth2.grant.write.general: Создать или изменить любую вашу ветку, пост или \n  комментарий.\noauth2.grant.delete.general: Удалить любую вашу ветку, пост или комментарий.\noauth2.grant.report.general: Сообщить о ветках, постах или комментариях.\noauth2.grant.vote.general: Оценивать положительно, отрицательно или продвигать \n  ветки, записи и комментарии.\noauth2.grant.subscribe.general: Подпишитесь или подписывайтесь на любой журнал. \n  домен или пользователей и просматривайте журналы, домены и пользователей на \n  которых вы подписаны.\noauth2.grant.block.general: Блокируйте или разброкируйте любой журнал, домен или\n  пользователя и просматривайте журналы, домены и пользователей, которых вы \n  заблокировали.\noauth2.grant.domain.all: Подписывайтесь на домены или заблокируйте их, а также \n  просматривайте домены на которые вы подписаны или заблокровали.\noauth2.grant.domain.subscribe: Подписывайтесь или отписывайтель на домены, и \n  просматривайте домены на которые вы подписаны.\noauth2.grant.domain.block: Блокируйте или разблокируйте домены, и просмтривате \n  домены которые вы заблокировали.\noauth2.grant.entry.all: Создайте, редактируйте или удалте ваши ветки, и голоса, \n  продвижения, или сообщите о любых ветках.\noauth2.grant.entry.create: Создайте новую ветку.\noauth2.grant.entry.edit: Отредактируйте существующие ветки.\noauth2.grant.entry.delete: Удалите существующие ветки.\noauth2.grant.entry.vote: Оценивать положительно, отрицательно и продвигать \n  ветки.\noauth2.grant.entry.report: Сообщить о любых темах.\noauth2.grant.entry_comment.all: Создать, отредактировать или удалить любой \n  комментраий в ветке, проголосовавть, продвинуть или сообщить о любом \n  комментариив ветке.\noauth2.grant.entry_comment.create: Создать новый комментарий в ветке.\noauth2.grant.entry_comment.edit: Редактировать существующий комментарий в ветке.\noauth2.grant.entry_comment.delete: Удалить существующие комментарии в ветке.\noauth2.grant.entry_comment.vote: Оценивать положительно, отрицательно и \n  продвигать любые комментарии в ветках.\noauth2.grant.entry_comment.report: Сообщить о комментарии в ветке.\noauth2.grant.magazine.all: Подписаться на или заблокировать журнал, и \n  просматривать журналы на которые вы подписаны или заблокировали.\noauth2.grant.magazine.subscribe: Подписаться или отписаться от журнала, и \n  посмотреть журнал на который вы подписаны.\noauth2.grant.magazine.block: Блокировать или разблокировать журнал и посмотреть \n  жарнал, который вы блокируете.\noauth2.grant.post.all: Создать, редактировать или удалить ваш микроблог, \n  головать, продвинуть или сообщить о микроблоге.\noauth2.grant.post.create: Создать новый пост.\noauth2.grant.post.edit: Редактировать существующий пост.\noauth2.grant.post.delete: Удалить ваши существующие посты.\noauth2.grant.post.vote: Оценивать положительно, отрицательно и продвигать \n  записи.\noauth2.grant.post.report: Сщщбщить о любом посте.\noauth2.grant.post_comment.all: Сздать, редактировать или удалить ваши \n  комментарии к посту, и голосовать, продвигать или сообщить о комментраии к \n  посту.\noauth2.grant.post_comment.create: Создать новые комментарии к посту.\noauth2.grant.post_comment.edit: Редактировать ваши существующие комментарии к \n  посту.\noauth2.grant.post_comment.delete: Удалить ваши существующие комментарии к посту.\noauth2.grant.post_comment.vote: Оценивать положительно, отрицательно и \n  продвигать комментарии записей.\noauth2.grant.post_comment.report: Сообщить о комментарии к посту.\noauth2.grant.user.all: Просмотреть и изменить ваш профиль, сообщения, \n  уведомления; просмотреть и изменить ваши права доступа в приложениях; \n  подписываться или блокировать других пользователей; просмотреть списки \n  пользователей на которых вы подписались, или блокировали.\noauth2.grant.user.profile.all: Просматривать и редактировать ваш профиль.\noauth2.grant.user.profile.read: Просматривать ваш профиль.\noauth2.grant.user.profile.edit: Редактировать ваш профиль.\noauth2.grant.user.message.all: Просматривать ваши сообщения и отправлять \n  сообщения другим пользователям.\noauth2.grant.user.message.read: Просматривать ваши сообщения.\noauth2.grant.user.message.create: Отправлять сообщения другим пользователям.\noauth2.grant.user.notification.all: Просматривать и очистить ваши уведомления.\noauth2.grant.user.notification.read: Просматривать ваши уведомления, включая \n  уведомления о сообщениях.\noauth2.grant.user.notification.delete: Очищать ваши уведомления.\noauth2.grant.user.oauth_clients.all: Просматривать и изменять разрешения, \n  которые вы предоставили другим приложениям OAuth2.\noauth2.grant.user.oauth_clients.read: Просматривать разрешения, предоставленные \n  другим приложениям OAuth2.\noauth2.grant.user.oauth_clients.edit: Изменять разрешения, предоставленные \n  другим приложениям OAuth2.\noauth2.grant.user.follow: Подписываться на пользователей и отписываться от них, \n  просматривать список пользователей, на которых вы подписаны.\noauth2.grant.user.block: Блокировать и разблокировать пользователей, \n  просматривать ваш список заблокированных пользователей.\noauth2.grant.moderate.all: Выполнить любое действие модерации, на которые у вас \n  есть разрешения, в модерируемом журнале.\noauth2.grant.moderate.entry.all: Модерировать ветви в журналах, которые вы \n  модерируете.\noauth2.grant.moderate.entry.change_language: Изменять языки веток в журналах, \n  которые вы модерируете.\noauth2.grant.moderate.entry.pin: Закреплять ветви наверху журналов, которые вы \n  модерируете.\noauth2.grant.moderate.entry.set_adult: Помечать ветви как NSFW в журналах, \n  которые вы модерируете.\noauth2.grant.moderate.entry.trash: Удалять и восстанавливать ветви в журналах, \n  которые вы модерируете.\noauth2.grant.moderate.entry_comment.all: Модерировать комментарии в ветвях \n  журналов, которые вы модерируете.\noauth2.grant.moderate.entry_comment.change_language: Изменять язык комментариев \n  в ветвях журналов, которые вы модерируете.\noauth2.grant.moderate.entry_comment.set_adult: Помечать комментарии NSFW в \n  ветвях журналов, которые вы модерируете.\noauth2.grant.moderate.entry_comment.trash: Удалять и восстанавливать комментарии\n  в ветвях журналов, которые вы модерируете.\noauth2.grant.moderate.post.all: Модерировать записи в журналах, которые вы \n  модерируете.\noauth2.grant.moderate.post.change_language: Изменять язык записей в журналах, \n  которые вы модерируете.\noauth2.grant.moderate.post.set_adult: Помечать записи как NSFW в журналах, \n  которые вы модерируете.\noauth2.grant.moderate.post.trash: Удалять и восстанавливать записи в журналах, \n  которые вы модерируете.\noauth2.grant.moderate.post_comment.all: Модерировать комментарии под записями \n  журналов, которые вы модерируете.\noauth2.grant.moderate.post_comment.change_language: Изменять язык комментариев \n  под записями в журналах, которые вы модерируете.\noauth2.grant.moderate.post_comment.set_adult: Помечать как NSFW комментарии под \n  записями в журналах, которые вы модерируете.\noauth2.grant.moderate.post_comment.trash: Удалять и восстанавливать комментарии \n  под записями в журналах, которые вы модерируете.\noauth2.grant.moderate.magazine.all: Управлять банами, отчётами и просматривать \n  удалённые элементы в вашем модерируемом журнале.\noauth2.grant.moderate.magazine.ban.all: Управлять забаненными полльзователями в \n  вашем модерируемом журнале.\noauth2.grant.moderate.magazine.ban.read: просмотреть забанненых полльзователей в\n  вашем модерируемом журнале.\noauth2.grant.moderate.magazine.ban.create: Банить пользователей в вашем \n  модерируемом журнале.\noauth2.grant.admin.entry_comment.purge: полностью удалить любой комментарий в \n  ветке вашего инстанса.\noauth2.grant.admin.post.purge: Полностью удалить любой пост в вашем инстансе.\noauth2.grant.admin.post_comment.purge: Полностью удалить любой комментарий к \n  посту в вашем инстансе.\noauth2.grant.admin.magazine.all: Перемещать ветки между журналами или полностью \n  удалять их в вашем инстансе.\noauth2.grant.admin.magazine.move_entry: Перемещать ветки между журналами вашего \n  инстанса.\noauth2.grant.admin.magazine.purge: Полностью удалить журнал в вашем инстансе.\noauth2.grant.admin.user.all: Банить, проверить, полностью удалить пользователей \n  вашего инстанса.\noauth2.grant.admin.user.ban: Банить или разбанить пользователей вашего инстанса.\noauth2.grant.admin.user.verify: Проверить пользователей вашего инстанса.\noauth2.grant.admin.user.delete: Удалить пользователей вашего инстанса.\noauth2.grant.admin.user.purge: Полностью удалить пользователей вашего инстанса.\noauth2.grant.admin.instance.all: Посмотеть и обновить настройки или инфрмацию \n  инстанса.\noauth2.grant.admin.instance.stats: Посмотреть статус вашего инстанса.\noauth2.grant.admin.instance.settings.all: Посмотреть и обновить настройки вашего\n  инстанса.\noauth2.grant.admin.instance.settings.read: Посмотреть настройки вашего инстанса.\noauth2.grant.admin.instance.settings.edit: Обновить настройки вашего инстанса.\noauth2.grant.admin.instance.information.edit: Обновите О, ЧаВо, Контакты, Сервис\n  и поддержка, А также старницу Политики Конфиденциальности в вашем инстансе.\noauth2.grant.admin.federation.all: Посмотреть и обновить текущую дефедерацию \n  инстанса.\noauth2.grant.admin.federation.read: Посмотреть список федерации инстанса.\noauth2.grant.admin.federation.update: Добавить или удалить инстанс в списке \n  дефедерации инстансов.\noauth2.grant.admin.oauth_clients.all: Посмотреть или отозвать существующих \n  клиентов OAuth2 в вашем инстансе.\noauth2.grant.admin.oauth_clients.read: Посмотреть существующих клиентов OAuth2 в\n  вашем инстасне и статистику их использования.\noauth2.grant.admin.oauth_clients.revoke: Отозвать доступ клиентов OAuth2 в вашем\n  инстансе.\nlast_active: Последняя активность\nflash_post_pin_success: Пост успешно закреплён.\nflash_post_unpin_success: Пост успешно откреплён.\ncomment_reply_position_help: Отображать ответ на комментарий вверху или внизу \n  страницы. Когда включена бесконечная прокрутка, позиция всегда будет \n  отображаться вверху.\nshow_avatars_on_comments: Показать аватары комментаторов\nsingle_settings: Отдельные настройки\nupdate_comment: Обновить комментарий\nshow_avatars_on_comments_help: Показать или скрыть автары пользователя во время \n  просмотра отдельных веток или постов.\ncomment_reply_position: Позиция ответа на комментарий\nmagazine_theme_appearance_custom_css: Пользовательский CSS будет использоваться \n  при просмотре контента в вашем журнале.\nmagazine_theme_appearance_icon: Пользовательская иконка журнала не выбрана. \n  Будет выбрана и использована стандартная иконка.\nmagazine_theme_appearance_background_image: Пользовательское фоновое изображение\n  будет использовано при просмотре контента вашего журнала.\nmoderation.report.approve_report_title: Утвердить отчёт\nmoderation.report.reject_report_title: Отклонитьотчёт\nmoderation.report.ban_user_description: Вы хотите забанить полльзователя \n  (%username%) который создаёт контент в этом журнале??\nmoderation.report.approve_report_confirmation: Вы уверены, что хотите \n  подтвердить этот отчёт?\nsubject_reported_exists: Об этом контенте уже сообщалось..\nmoderation.report.ban_user_title: Заблокировать пользователя\nmoderation.report.reject_report_confirmation: Вы уверены, что хотите отозвать \n  этот отчёт?\noauth2.grant.moderate.post.pin: Закрепить пост в топе вашего модерируемого \n  журнала.\ndelete_content: Удалить контент\npurge_content: Очистить контент\ndelete_content_desc: Удалить контент полльзователся, оставив при этом ответы \n  других полльзователей в созданных ветках,сообщениях и комментариях.\npurge_content_desc: Полная очистка контента полльзователя, включая удаление \n  ответов других пользователей в созданных ветках, постах и комментариях.\ndelete_account_desc: Удалить учётную запись, включая ответы других пользователей\n  в созданных темах, сообщениях и комментариях.\ntwo_factor_authentication: Двухфакторная аутентификация\ntwo_factor_backup: Коды восстановления двухфакторной аутентификации\n2fa.authentication_code.label: Код аутентификации\n2fa.verify: Подтверждение\n2fa.code_invalid: Код аутентификации не действителен\n2fa.setup_error: Ошибка подключения 2FA аккаунта\n2fa.enable: Установка двухфакторной аутентификации\n2fa.disable: Отключение двухфакторной аутентификации\n2fa.backup: Ваши коды восстановления 2FA\n2fa.backup-create.help: Вы можете создать новые коды восстановления \n  аутентификации; это приведёт к отмене существующего кода.\n2fa.backup-create.label: Создать новый код восстановления аутентификации\n2fa.remove: Удалить 2FA\n2fa.add: Добавить мой аккаунт\n2fa.verify_authentication_code.label: Введите двухфакторный код для проверки \n  настройки\n2fa.qr_code_img.alt: QR-код позволяющий настроить двухфакторную аутентификацию \n  для вашего аккаунта\n2fa.qr_code_link.title: Переход по этой ссылке может позволить вашей платформе \n  заригистрировать этк двухфакторную аутентификацию\n2fa.user_active_tfa.title: 2ФА активна\n2fa.available_apps: Используйте приложение для двухфакторной аутентификации, \n  например %google_authenticator%, %aegis% (Android) или %raivo% (iOS) для \n  сканирования QR-кода.\n2fa.backup_codes.help: вы можете использовать эти коды. если у вас нет вашего \n  устройства или приложения. <strong>Коды будут показаны снова</strong>, вы \n  сможете использовать каждый из них только один<strong> раз</strong>.\n2fa.backup_codes.recommendation: Рекомендуем сохранить эту заметку в безопасное \n  место.\ncancel: Отмена\npassword_and_2fa: Пароль и 2ФА\nflash_account_settings_changed: Настройки вашего аккаунта успешно изменены. Вам \n  нужно войти ещё раз.\nshow_subscriptions: Показать подписки\nsubscription_sort: Сортировать.\nalphabetically: По алфавиту\nsubscriptions_in_own_sidebar: В отдельной боковой панели\nsidebars_same_side: боковая панель на той же стороне\nsubscription_sidebar_pop_out_right: Переместить отдельную боковую панель вправо\nsubscription_sidebar_pop_out_left: Переместить отдельную боковую панель на лево\nsubscription_sidebar_pop_in: Переместить подписки во встроенную панель\nsubscription_panel_large: Большая панель\nsubscription_header: Подписки на журналы\nclose: Звкрыто\nposition_bottom: Нижний\nposition_top: Верхний.\npending: В ожидании\nflash_thread_new_error: Ветка не может быть создана. Что-то пошло не так.\nflash_email_was_sent: Электронное письмо успешно было отправлено.\nflash_email_failed_to_sent: Электронное письмо не может быть отправлено.\nflash_post_new_success: Пост успешно создан.\nflash_post_new_error: Пост не может быть создан. Что-то пошло не так.\nflash_magazine_theme_changed_success: Успешное обновление внешнего вида журнала.\nflash_magazine_theme_changed_error: Неудачное обновление внешнего вида журнала.\nflash_comment_new_success: Комментарий успешно создан.\nflash_comment_edit_success: Комментарий успешно обновлён.\nflash_comment_new_error: Комментарий не создан. Что-то пошло не так.\nflash_comment_edit_error: Неудачное редактироване комментария. Что-то пошло не \n  так.\nflash_user_settings_general_success: Настройки пользователя успешно сохранены.\nflash_user_settings_general_error: ошибка сохранения настроек пользователя.\nflash_user_edit_profile_error: ошибка сохранения настроек профиля.\nflash_user_edit_profile_success: Настройки профиля пользователя успешно \n  сохранены.\nflash_user_edit_email_error: Ошибка изменения адреса электронной почты.\nflash_user_edit_password_error: Ошибка изменения пароля.\nflash_thread_edit_error: Ошибка изменения ветки. Что-то пошло не так.\nflash_post_edit_error: Ошибка редактирования поста. Что-то пошло не так.\nflash_post_edit_success: Пост был успешно отредактирован.\npage_width: Ширина страницы\npage_width_max: максимальная.\npage_width_auto: Автоматически.\npage_width_fixed: Фиксированная\nopen_url_to_fediverse: Открыть оригинальный URL\nchange_my_avatar: Изменить мой аватар\nchange_my_cover: Изменить мою обложку\nedit_my_profile: Редактировать мой профиль\naccount_settings_changed: Настройки вашего аккаунта успешно изменены. Вам нужно \n  войти ещё раз.\nmagazine_deletion: Удалённый журнала\ndelete_magazine: Удаление журнала\nrestore_magazine: Восстановление журнала\npurge_magazine: Очистить журнал\nmagazine_is_deleted: Журнал удалёен. Вы можете <a href=\"%link_target%\"> \n  восстановить</a> егов течениие 30 дней.\nsuspend_account: Заблокировать аккаунт\nunsuspend_account: Разблокировать account\naccount_suspended: Аккаунт был заблокирован.\naccount_unsuspended: Аккаунт был разблокирован.\ndeletion: Удаление\nuser_suspend_desc: Приостановить ваш аккаунт и скрыть контент в инстансе не \n  удаляя навсегда, вы сможете восстановить его через какое то время.\naccount_banned: Аккаунт был забанен.\naccount_unbanned: Аккаунт был разбанен.\naccount_is_suspended: Аккаунт пользователя приостановлен.\nremove_following: Удалить фоловеров\nremove_subscriptions: Удалить подписчиков\napply_for_moderator: Применить для модератора\nrequest_magazine_ownership: Запрос права собственности на журнал\ncancel_request: Отменить запрос\nabandoned: Заброшенный\nownership_requests: Запрос на владение\naccept: Принять\nmoderator_requests: Запрос мода\naction: Действие\nuser_badge_op: ОР\nuser_badge_admin: Администратор\nuser_badge_global_moderator: Глобальный мод\nuser_badge_moderator: Мод.\nuser_badge_bot: Бот.\nannouncement: Объявление\nkeywords: Ключевые слова\ndeleted_by_moderator: Ветка, пост или комментарий были удалены модератором\ndeleted_by_author: Ветка, пост или комментарий были удалены автором\nsensitive_warning: Деликатный контент\nsensitive_toggle: Изменить видимость конфиденциального контента\nsensitive_show: Нажать чтобы посмотреть\nsensitive_hide: Нажать чтобы скрыть\nall_time: Всё время\nsubscribers_count: '{0}Подписки|{1}Подписка|]1,Inf[ Подписок'\nmenu: Меню\nfollowers_count: '{0}Подписчиков|{1}Подписчик|]1,Inf[ Подписчиков'\nremove_media: Удалить медиа\ndetails: Детали\nspoiler: Спойлер\nprivate_instance: Требовать авторизацию для просмотра содержимого сервера\ncake_day: День варенья\nfrom: от\nsort_by: Упорядочивание\nhidden: Скрыты\ndisabled: Выключены\ntest_push_message: Привет, мир!\nnotification_title_new_post: Новая запись\ndownvotes_mode: Режим отрицательных оценок\nenabled: Включены\ntag: Метка\nedit_entry: Редактировать ветку\nunban: Разблокировать\nban_hashtag_btn: Заблокировать хештег\ntoolbar.spoiler: Спойлер\nfederation_page_dead_title: Мёртвые серверы\naccount_deletion_title: Удаление аккаунта\nnotification_title_new_thread: Новая ветвь\nmarked_for_deletion_at: Помечено для удаления %date%\nmarked_for_deletion: Помечено для удаления\ndirect_message: Личное сообщение\nsubscribe_for_updates: Подпишитесь, чтобы получать оповещения о новых \n  публикациях.\naccount_deletion_button: Удалить учётную запись\naccount_deletion_immediate: Удалить сейчас\nnotification_title_new_reply: Новый ответ\nbookmark_add_to_list: Добавить закладку в %list%\nbookmark_remove_from_list: Удалить закладку из %list%\nmax_image_size: Макс. размер файла\nbookmark_lists: Списки закладок\nbookmarks: Закладки\nbookmark_list_make_default: Сделать основным\nbookmark_add_to_default_list: Добавить закладку в основной список\ntable_of_contents: Содержимое\nbookmark_remove_all: Удалить все закладки\nsignup_requests_paragraph: Эти пользователи желают зарегистрироваться на вашем \n  сервере. Они не могут входить в учётные записи, пока вы не одобрите их \n  запросы.\ncomment_not_found: Комментарий не найден\ncount: Количество\nbookmark_list_create: Создать\nbookmark_list_selected_list: Выбранный список\nsearch_type_all: Ветки и микроблоги\nsearch_type_entry: Ветки\nselect_user: Выбор пользователя\nshow_magazine_domains: Показывать домены журналов\nshow_user_domains: Показывать домены уч. записей\nis_default: Основной\nbookmark_list_is_default: Основной список\nbookmarks_list: Закладки из %list%\nsignup_requests_header: Запрошенные регистрации\nemail_verification_pending: Для входа требуется подтверждение адреса почты.\nemail_application_pending: Для входа требуется одобрение регистрации \n  администратором.\nemail_application_approved_title: Ваша регистрация одобрена\nemail_application_rejected_title: Ваш запрос регистрации отклонён\nedited: изменено\nremove_user_cover: Убрать шапку\nrelated_entry: Связанное\nsso_show_first: Сперва предлагать SSO на страницах регистрации и входа\nremove_user_avatar: Убрать изображение профиля\nchange_downvotes_mode: Сменить режим отрицательного оценивания\nviewing_one_signup_request: Вы просматриваете запрос регистрации %username%\nreporting_user: Доносчик\nown_report_rejected: Ваша жалоба была отклонена\nown_report_accepted: Ваша жалоба была принята\nsso_registrations_enabled: Регистрации через SSO включены\nsso_only_mode: Разрешить регистрации и вход только через SSO\nreported_user: Жалоба на\nshow: Показать\nhide: Скрыть\nremove_schedule_delete_account_desc: Всё содержимое будет снова доступно и \n  пользователь снова сможет входить в учётную запись.\nrestrict_magazine_creation: Разрешить создание локальных журналов только \n  администраторам и модераторам сервера\ncontinue_with: Продолжить с\nnotify_on_user_signup: Новые регистрации\nflash_image_download_too_large_error: Невозможно добавить изображение, т.к. оно \n  слишком большое (макс. размер %bytes%)\nflash_thread_tag_banned_error: Невозможно создать ветвь, т.к. содержимое \n  запрещено.\nsso_registrations_enabled.error: Создание новых уч. записей через сторонние \n  системы идентификации сейчас недоступно.\nreport_accepted: Жалоба была принята\nown_content_reported_accepted: Жалоба на содержимое, которую вы создали, была \n  рассмотрена и принята.\nopen_report: Открыть жалобу\nremove_schedule_delete_account: Отменить запланированное удаление\nnotification_title_message: Новое личное сообщение\nmagazine_posting_restricted_to_mods: Разрешить создание ветвей только \n  модераторам\nnew_magazine_description: Новый журнал (активен менее чем %days% дней)\nadmin_users_inactive: Неактивен\nnew_user_description: Новый пользователь (активен менее чем %days% дней)\nadmin_users_active: Активен\nnotification_title_new_comment: Новый комментарий\nuser_verify: Активировать уч. запись\nnotification_title_edited_comment: Комментарий отредактирован\nnotification_title_mention: Вас упомянули\nunregister_push_notifications_button: Удалить регистрацию пуш-уведомлений\ntest_push_notifications_button: Проверить уведомления\nnotification_title_removed_comment: Комментарий удалён\nversion: Версия\nlast_failed_contact: Последнее неуспешное взаимодействие\nadmin_users_suspended: Приостановлен\nadmin_users_banned: Заблокирован\nbookmark_list_create_placeholder: введите название...\nbookmark_list_create_label: Название списка\nbookmarks_list_edit: Ред. список заметок\nbookmark_list_edit: Редактировать\nsearch_type_post: Микроблоги\noauth2.grant.user.bookmark.remove: Удалять заметки\noauth2.grant.user.bookmark_list: Просматривать, изменять и удалять ваши списки \n  заметок\noauth2.grant.user.bookmark.add: Добавлять заметки\nnotification_body_new_signup: Зарегистрировался пользователь %u%.\nanswered: отвечен\nnotification_title_ban: Вы заблокированы\nnotification_title_edited_post: Запись отредактирована\noauth2.grant.user.bookmark_list.delete: Удалять ваши списки заметок\noauth2.grant.user.bookmark: Добавлять и удалять заметки\noauth2.grant.user.bookmark_list.edit: Изменять ваши списки заметок\nlast_successful_deliver: Последняя успешная отправка\nnotification_title_edited_thread: Ветвь была отредактирована\n2fa.manual_code_hint: Если не получается сканировать QR-код, введите секретный \n  ключ вручную\noauth2.grant.user.bookmark_list.read: Просматривать ваши списки заметок\nregister_push_notifications_button: Зарегистрировать канал пуш-увдеомлений\nnotification_title_new_signup: Пользователь зарегистрировался\nnotification_body2_new_signup_approval: Необходимо утвердить запрос регистрации,\n  прежде, чем пользователь сможет войти\nlast_successful_receive: Последнее успешное получение\nschedule_delete_account_desc: Запланировать удаление этой учётной записи через \n  30 дней. Пользователь и все его публикации будут скрыты, и он не сможет \n  входить в учётную запись.\nsignup_requests: Запросы на регистрацию\nnotification_title_removed_thread: Ветвь была удалена\nnotification_title_removed_post: Запись удалена\nschedule_delete_account: Запланировать удаление\ntoolbar.emoji: Эмодзи\napplication_text: Расскажите, почему вы хотите присоединиться\nshow_rich_mention_help: Отображать имена и изображения профилей пользователей, \n  упомянутых в тексте.\nshow_rich_mention: Расширенные упоминания\nshow_rich_mention_magazine: Расширенные упоминания журналов\nshow_rich_mention_magazine_help: Отображать названия и изображения журналов, \n  упомянутых в тексте.\nshow_rich_ap_link: Расширенные ссылки AP\nand: и\n"
  },
  {
    "path": "translations/messages.sv.yaml",
    "content": "{}\n"
  },
  {
    "path": "translations/messages.ta.yaml",
    "content": "sort_by: வரிசைப்படுத்தவும்\ntop: மேலே\nempty: காலி\nfollow: பின்தொடர்\nunfollow: பின்தொடரவும்\nreply: பதில்\nto: பெறுநர்\nfrom: இருந்து\nusername: பயனர்பெயர்\nemail: மின்னஞ்சல்\nrelated_tags: தொடர்புடைய குறிச்சொற்கள்\ngo_to_content: உள்ளடக்கத்திற்குச் செல்லுங்கள்\ngo_to_filters: வடிப்பான்களுக்குச் செல்லுங்கள்\nlogout: விடுபதிகை\n6h: தாஆ\n12h: 12 ம\n1d: 1 டி\n1w: 1W\n1m: 1 மீ\ngeneral: பொது\nprofile: சுயவிவரம்\nreports: அறிக்கைகள்\nnotifications: அறிவிப்புகள்\nmessages: செய்திகள்\nappearance: தோற்றம்\nhomepage: முகப்புப்பக்கம்\nsave: சேமி\ndefault_theme: இயல்புநிலை கருப்பொருள்\nflash_register_success: கப்பலில் வரவேற்கிறோம்! உங்கள் கணக்கு இப்போது பதிவு \n  செய்யப்பட்டுள்ளது. ஒரு கடைசி படி - உங்கள் கணக்கை உயிர்ப்பிக்கும் \n  செயல்படுத்தும் இணைப்பிற்கு உங்கள் இன்பாக்சைச் சரிபார்க்கவும்.\nadded_new_post: புதிய இடுகையைச் சேர்த்தது\npurge: தூய்மைப்படுத்துதல்\nright: வலது\nfederation: கூட்டமைப்பு\nstatus: நிலை\non: ஆன்\nunban: முணுமுணுப்பு\nban_hashtag_btn: தடை ஏச்டேக்\nunban_hashtag_btn: ஐட்\nfilters: வடிப்பான்கள்\nbans: தடைகள்\ndone: முடிந்தது\nunban_account: கட்டுப்பாடற்ற கணக்கு\nsidebar: பக்கப்பட்டி\naccount_deletion_button: கணக்கை நீக்கு\nerrors.server500.title: 500 உள் சேவையக பிழை\ndelete_content: உள்ளடக்கத்தை நீக்கு\ntwo_factor_authentication: இரண்டு காரணி ஏற்பு\n2fa.setup_error: கணக்கிற்கு 2FA ஐ இயக்குவதில் பிழை\n2fa.enable: இரண்டு காரணி அங்கீகாரத்தை அமைக்கவும்\ncancel: ரத்துசெய்\nadmin_users_banned: தடைசெய்யப்பட்டது\nuser_verify: கணக்கைச் செயல்படுத்தவும்\nmax_image_size: அதிகபட்ச கோப்பு அளவு\ncomment_not_found: கருத்து கிடைக்கவில்லை\ntype.link: இணைப்பு\ntype.article: நூல்\ntype.photo: புகைப்படம்\ntype.video: ஒளிதோற்றம்\ntype.smart_contract: அறிவுள்ள ஒப்பந்தம்\ntype.magazine: செய்தித் தாள்\nthread: நூல்\nthreads: நூல்கள்\nmicroblog: மைக்ரோ பிளாக்\npeople: மக்கள்\nevents: நிகழ்வுகள்\nmagazine: செய்தித் தாள்\nmagazines: பத்திரிகைகள்\nsearch: தேடல்\nadd: கூட்டு\nselect_channel: ஒரு சேனலைத் தேர்ந்தெடுக்கவும்\nlogin: புகுபதிகை\nmarked_for_deletion: நீக்குவதற்கு குறிக்கப்பட்டுள்ளது\nmarked_for_deletion_at: '%தேதி %இல் நீக்கப்படுவதற்கு குறிக்கப்பட்டுள்ளது'\nfavourites: மேம்பாடுகள்\nfavourite: பிடித்த\nmore: மேலும்\navatar: அவதார்\nhot: சூடான\nactive: செயலில்\nnewest: புதியது\noldest: பழமையானது\ncommented: கருத்து\nchange_view: பார்வையை மாற்றவும்\nfilter_by_time: நேரம் மூலம் வடிகட்டவும்\nfilter_by_type: வகை மூலம் வடிகட்டவும்\nfilter_by_subscription: சந்தா மூலம் வடிகட்டவும்\nfilter_by_federation: கூட்டமைப்பு நிலை மூலம் வடிகட்டவும்\ncomments_count: '{0} கருத்துகள் | {1} கருத்து |] 1, Inf [கருத்துகள்'\nsubscribers_count: '{0} சந்தாதாரர்கள் | {1} சந்தாதாரர் |] 1, INF [சந்தாதாரர்கள்'\nfollowers_count: '{0} பின்தொடர்பவர்கள் | {1} பின்தொடர்பவர் |] 1, INF [பின்தொடர்பவர்கள்'\nadded: சேர்க்கப்பட்டது\nup_votes: ஊக்கங்கள்\ndown_votes: குறைக்கிறது\nno_comments: கருத்துகள் இல்லை\ncreated_at: உருவாக்கப்பட்டது\nowner: உரிமையாளர்\nsubscribers: சந்தாதாரர்கள்\nonline: ஆன்லைனில்\ncomments: கருத்துகள்\nposts: இடுகைகள்\nreplies: பதில்கள்\nmoderators: மதிப்பீட்டாளர்கள்\nmod_log: மிதமான பதிவு\nadd_comment: கருத்து சேர்க்கவும்\nadd_post: இடுகையைச் சேர்க்கவும்\nadd_media: மீடியாவைச் சேர்க்கவும்\nremove_media: மீடியாவை அகற்று\nmarkdown_howto: ஆசிரியர் எவ்வாறு செயல்படுகிறார்?\nenter_your_comment: உங்கள் கருத்தை உள்ளிடவும்\nenter_your_post: உங்கள் இடுகையை உள்ளிடவும்\nactivity: செய்கைப்பாடு\ncover: கவர்\nrelated_posts: தொடர்புடைய இடுகைகள்\nrandom_posts: சீரற்ற பதிவுகள்\nfederated_magazine_info: இந்த செய்தித் தாள் ஒரு கூட்டாட்சி சேவையகத்திலிருந்து \n  வந்தது மற்றும் முழுமையடையாது.\ndisconnected_magazine_info: இந்த செய்தித் தாள் புதுப்பிப்புகளைப் பெறவில்லை \n  (கடைசி செயல்பாடு % நாட்கள் % நாள் (கள்) முன்பு).\nalways_disconnected_magazine_info: இந்த செய்தித் தாள் புதுப்பிப்புகளைப் \n  பெறவில்லை.\nsubscribe_for_updates: புதுப்பிப்புகளைப் பெறத் தொடங்க குழுசேரவும்.\nfederated_user_info: இந்த சுயவிவரம் கூட்டாட்சி சேவையகத்திலிருந்து வந்தது மற்றும்\n  முழுமையடையாமல் இருக்கலாம்.\ngo_to_original_instance: தொலைநிலை நிகழ்வைக் காண்க\nsubscribe: குழுசேர்\nunsubscribe: குழுவிலகவும்\nlogin_or_email: உள்நுழைவு அல்லது மின்னஞ்சல்\npassword: கடவுச்சொல்\nremember_me: என்னை நினைவில் கொள்ளுங்கள்\ndont_have_account: கணக்கு இல்லையா?\nyou_cant_login: உங்கள் கடவுச்சொல்லை மறந்துவிட்டீர்களா?\nalready_have_account: ஏற்கனவே ஒரு கணக்கு இருக்கிறதா?\nregister: பதிவு செய்யுங்கள்\nreset_password: கடவுச்சொல்லை மீட்டமைக்கவும்\ndownvotes_mode: டவுன்வோட்ச் பயன்முறை\nchange_downvotes_mode: டவுன்வோட்ச் பயன்முறையை மாற்றவும்\ndisabled: முடக்கப்பட்டது\nhidden: மறைக்கப்பட்ட\nenabled: இயக்கப்பட்டது\nuseful: பயனுள்ள\nshow_more: மேலும் காட்டு\nin: இல்\nrepeat_password: கடவுச்சொல்லை மீண்டும் செய்யவும்\nagree_terms: '%விதிமுறைகளுக்கு ஒப்புதல்_LINK_START%விதிமுறைகள் மற்றும் நிபந்தனைகள்%விதிமுறைகள்_லின்க்_எண்ட்%மற்றும்%பாலிசி_லின்க்_ச்டார்ட்%தனியுரிமைக்\n  கொள்கை%பாலிசி_லின்க்_எண்ட்%'\nterms: பணி விதிமுறைகள்\nprivacy_policy: தனியுரிமைக் கொள்கை\nabout_instance: பற்றி\nall_magazines: அனைத்து பத்திரிகைகளும்\nstats: புள்ளிவிவரங்கள்\nfediverse: ஃபெடிவர்ச்\ncreate_new_magazine: புதிய பத்திரிகையை உருவாக்கவும்\nadd_new_article: புதிய நூலைச் சேர்க்கவும்\nadd_new_link: புதிய இணைப்பைச் சேர்க்கவும்\nadd_new_photo: புதிய புகைப்படத்தைச் சேர்க்கவும்\nadd_new_post: புதிய இடுகையைச் சேர்க்கவும்\nadd_new_video: புதிய வீடியோவைச் சேர்க்கவும்\ncontact: தொடர்பு\nfaq: கேள்விகள்\nrss: ஆர்.எச்.எச்\nchange_theme: கருப்பொருள் மாற்றவும்\nhelp: உதவி\ncheck_email: உங்கள் மின்னஞ்சலை சரிபார்க்கவும்\nreset_check_email_desc: உங்கள் மின்னஞ்சல் முகவரியுடன் ஏற்கனவே ஒரு கணக்கு \n  இருந்தால், உங்கள் கடவுச்சொல்லை மீட்டமைக்க நீங்கள் பயன்படுத்தக்கூடிய இணைப்பைக் \n  கொண்ட மின்னஞ்சலைப் பெற வேண்டும். இந்த இணைப்பு %காலாவதியாகும்.\nimage_alt: பட மாற்று உரை\nname: பெயர்\ndescription: விவரம்\nrules: விதிகள்\ndomain: டொமைன்\nfollowers: பின்தொடர்பவர்கள்\nfollowing: பின்வருமாறு\nreset_check_email_desc2: நீங்கள் மின்னஞ்சல் பெறவில்லை என்றால் உங்கள் ச்பேம் \n  கோப்புறையை சரிபார்க்கவும்.\ntry_again: மீண்டும் முயற்சிக்கவும்\nup_vote: பூச்ட்\ndown_vote: குறைக்க\nemail_confirm_header: வணக்கம்! உங்கள் மின்னஞ்சல் முகவரியை உறுதிப்படுத்தவும்.\nemail_confirm_content: 'உங்கள் MBIN கணக்கை செயல்படுத்த தயாரா? கீழே உள்ள இணைப்பைக்\n  சொடுக்கு செய்க:'\nemail_verify: மின்னஞ்சல் முகவரியை உறுதிப்படுத்தவும்\nemail_confirm_expire: இணைப்பு ஒரு மணி நேரத்தில் காலாவதியாகும் என்பதை நினைவில் \n  கொள்க.\nemail_confirm_title: உங்கள் மின்னஞ்சல் முகவரியை உறுதிப்படுத்தவும்.\nselect_magazine: ஒரு பத்திரிகையைத் தேர்ந்தெடுக்கவும்\nadd_new: புதியதைச் சேர்க்கவும்\nurl: முகவரி\ntitle: தலைப்பு\nbody: உடல்\ntags: குறிச்சொற்கள்\ntag: குறிச்சொல்\nbadges: பேட்ச்கள்\nis_adult: 18+ / NSFW\neng: இன்சி\noc: OC\nimage: படம்\nsubscriptions: சந்தாக்கள்\noverview: கண்ணோட்டம்\ncards: அட்டைகள்\nchat_view: அரட்டை காட்சி\ntree_view: மரக் காட்சி\ntable_view: அட்டவணை பார்வை\ncards_view: அட்டைகள் பார்வை\n3h: 3x\n1y: 1y\nlinks: இணைப்புகள்\narticles: நூல்கள்\nphotos: புகைப்படங்கள்\nvideos: வீடியோக்கள்\nreport: அறிக்கை\nshare: பங்கு\ncopy_url: MIN முகவரி ஐ நகலெடுக்கவும்\ncopy_url_to_fediverse: அசல் முகவரி ஐ நகலெடுக்கவும்\nshare_on_fediverse: ஃபெடிவர்சில் பங்கு\nedit: தொகு\nare_you_sure: நீங்கள் உறுதியாக இருக்கிறீர்களா?\nmoderate: மிதமான\nreason: காரணம்\nedit_entry: நூல் திருத்து\ncolumns: நெடுவரிசைகள்\nuser: பயனர்\njoined: இணைந்தது\nmoderated: மிதமான\npeople_local: உள்ளக\npeople_federated: கூட்டாட்சி\nreputation_points: நற்பெயர் புள்ளிகள்\ngo_to_search: தேடச் செல்லவும்\nsubscribed: சந்தா\nall: அனைத்தும்\nclassic_view: கிளாசிக் பார்வை\ncompact_view: சிறிய பார்வை\ndelete: நீக்கு\nedit_post: இடுகையைத் திருத்து\nedit_comment: மாற்றங்களைச் சேமிக்கவும்\nmenu: பட்டியல்\nsettings: அமைப்புகள்\nblocked: தடுக்கப்பட்டது\nhide_adult: NSFW உள்ளடக்கத்தை மறைக்கவும்\nfeatured_magazines: சிறப்பு பத்திரிகைகள்\nshow_profile_subscriptions: செய்தித் தாள் சந்தாக்களைக் காட்டு\nshow_profile_followings: பின்வரும் பயனர்களைக் காட்டு\nnotify_on_new_entry_reply: நான் எழுதிய நூல்களில் எந்த நிலை கருத்துகளும்\nnotify_on_new_entry_comment_reply: எந்த நூல்களிலும் எனது கருத்துக்களுக்கான \n  பதில்கள்\nnotify_on_new_post_reply: நான் எழுதிய இடுகைகளுக்கு எந்த நிலை பதில்களும்\nnotify_on_new_post_comment_reply: எந்தவொரு இடுகைகளிலும் எனது கருத்துக்களுக்கான \n  பதில்கள்\nprivacy: தனியுரிமை\nnotify_on_new_entry: நான் சந்தா செலுத்திய எந்த பத்திரிகையிலும் புதிய நூல்கள் \n  (இணைப்புகள் அல்லது கட்டுரைகள்)\nnotify_on_new_posts: நான் சந்தா செலுத்திய எந்த பத்திரிகையிலும் புதிய இடுகைகள்\nnotify_on_user_signup: புதிய கையொப்பங்கள்\nlight: ஒளி\nsolarized_light: சோலரிச் லைட்\nsolarized_dark: சோலரிச் இருண்ட\ndefault_theme_auto: ஒளி/இருண்ட (ஆட்டோ கண்டறிதல்)\nsolarized_auto: சோலரிச் (ஆட்டோ கண்டறிதல்)\nfont_size: எழுத்துரு அளவு\nsize: அளவு\nabout: பற்றி\nold_email: தற்போதைய மின்னஞ்சல்\nnew_email: புதிய மின்னஞ்சல்\nnew_email_repeat: புதிய மின்னஞ்சலை உறுதிப்படுத்தவும்\ncurrent_password: தற்போதைய கடவுச்சொல்\nnew_password: புதிய கடவுச்சொல்\nnew_password_repeat: புதிய கடவுச்சொல்லை உறுதிப்படுத்தவும்\nchange_email: மின்னஞ்சலை மாற்றவும்\nchange_password: கடவுச்சொல்லை மாற்றவும்\nexpand: விரிவாக்கு\ncollapse: சரிவு\ndomains: களங்கள்\nerror: பிழை\nvotes: வாக்குகள்\ntheme: கருப்பொருள்\ndark: இருண்ட\nboosts: ஊக்கங்கள்\nshow_users_avatars: பயனர்களின் அவதாரங்களைக் காட்டு\nyes: ஆம்\nno: இல்லை\nshow_thumbnails: சிறு உருவங்களைக் காட்டு\nrounded_edges: வட்ட விளிம்புகள்\nremoved_thread_by: ஒரு நூலை அகற்றிவிட்டது\nrestored_thread_by: ஒரு நூலை மீட்டெடுத்துள்ளார்\nshow_magazines_icons: பத்திரிகைகளின் சின்னங்களைக் காட்டு\nremoved_comment_by: ஒரு கருத்தை நீக்கிவிட்டார்\nrestored_comment_by: கருத்தை மீட்டெடுத்துள்ளார்\nremoved_post_by: ஒரு இடுகையை அகற்றியுள்ளது\nflash_magazine_edit_success: செய்தித் தாள் வெற்றிகரமாக திருத்தப்பட்டுள்ளது.\nflash_mark_as_adult_success: இந்த இடுகை வெற்றிகரமாக NSFW என குறிக்கப்பட்டுள்ளது.\nflash_unmark_as_adult_success: இந்த இடுகை வெற்றிகரமாக NSFW என குறிக்கப்படவில்லை.\nrestored_post_by: ஒரு இடுகையை மீட்டெடுத்துள்ளார்\nhe_banned: தடை\nhe_unbanned: முணுமுணுப்பு\nread_all: அனைத்தையும் படியுங்கள்\nshow_all: அனைத்தையும் காட்டு\nflash_thread_new_success: நூல் வெற்றிகரமாக உருவாக்கப்பட்டுள்ளது, இப்போது மற்ற \n  பயனர்களுக்குத் தெரியும்.\nflash_thread_edit_success: நூல் வெற்றிகரமாக திருத்தப்பட்டுள்ளது.\nflash_thread_delete_success: நூல் வெற்றிகரமாக நீக்கப்பட்டுள்ளது.\nflash_thread_pin_success: நூல் வெற்றிகரமாக பொருத்தப்பட்டுள்ளது.\nflash_thread_unpin_success: நூல் வெற்றிகரமாக இணைக்கப்படவில்லை.\nflash_magazine_new_success: செய்தித் தாள் வெற்றிகரமாக உருவாக்கப்பட்டுள்ளது. \n  நீங்கள் இப்போது புதிய உள்ளடக்கத்தைச் சேர்க்கலாம் அல்லது பத்திரிகையின் \n  நிர்வாகக் குழுவை ஆராயலாம்.\ntoo_many_requests: வரம்பு மீறியது, தயவுசெய்து பின்னர் மீண்டும் முயற்சிக்கவும்.\nset_magazines_bar: பத்திரிகைகள் பட்டி\nmod_log_alert: எச்சரிக்கை - மதிப்பீட்டாளர்களால் அகற்றப்பட்ட விரும்பத்தகாத அல்லது\n  துன்பகரமான உள்ளடக்கம் மோட்லாக் இருக்கலாம். தயவுசெய்து எச்சரிக்கையுடன் \n  உடற்பயிற்சி செய்யுங்கள்.\nadded_new_thread: புதிய நூல் சேர்க்கப்பட்டது\nedited_thread: ஒரு நூல் திருத்தப்பட்டது\nmod_remove_your_thread: ஒரு மதிப்பீட்டாளர் உங்கள் நூலை அகற்றினார்\nadded_new_comment: புதிய கருத்தைச் சேர்த்தது\nedited_comment: ஒரு கருத்தைத் திருத்தியுள்ளார்\nset_magazines_bar_desc: கமாவுக்குப் பிறகு செய்தித் தாள் பெயர்களைச் சேர்க்கவும்\nset_magazines_bar_empty_desc: புலம் காலியாக இருந்தால், செயலில் உள்ள பத்திரிகைகள்\n  பட்டியில் காட்டப்படும்.\nreplied_to_your_comment: உங்கள் கருத்துக்கு பதிலளித்தது\nmod_deleted_your_comment: ஒரு மதிப்பீட்டாளர் உங்கள் கருத்தை நீக்கிவிட்டார்\nedited_post: ஒரு இடுகையைத் திருத்தியுள்ளார்\nmod_remove_your_post: ஒரு மதிப்பீட்டாளர் உங்கள் இடுகையை அகற்றினார்\nadded_new_reply: புதிய பதிலைச் சேர்த்தது\nwrote_message: ஒரு செய்தி எழுதினார்\nbanned: உங்களுக்கு தடை விதித்தது\nremoved: மோட் மூலம் அகற்றப்பட்டது\ncomment: கருத்து\npost: இடுகை\ndeleted: ஆசிரியரால் நீக்கப்பட்டது\nmentioned_you: நீங்கள் குறிப்பிட்டுள்ளீர்கள்\nban_expired: தடை காலாவதியானது\nsend_message: நேரடி செய்தியை அனுப்பவும்\nmessage: செய்தி\ninfinite_scroll: எல்லையற்ற ச்க்ரோலிங்\nshow_top_bar: மேல் பட்டியைக் காட்டு\nsticky_navbar: ஒட்டும் நவ்பர்\nsubject_reported: உள்ளடக்கம் தெரிவிக்கப்பட்டுள்ளது.\nsidebar_position: பக்கப்பட்டி நிலை\nleft: இடது\noff: அணை\ninstances: நிகழ்வுகள்\nupload_file: கோப்பைப் பதிவேற்றவும்\nfrom_url: முகவரி இலிருந்து\nmagazine_panel: செய்தித் தாள் குழு\nreject: நிராகரிக்கவும்\napprove: ஒப்புதல்\nban: தடை\nban_hashtag_description: ஒரு ஏச்டேக்கைத் தடைசெய்வது இந்த ஏச்டேக் \n  உருவாக்கப்படுவதைத் தடுக்கும், அதே போல் ஏற்கனவே உள்ள இடுகைகளை இந்த ஏச்டேக்குடன்\n  மறைக்கும்.\nunban_hashtag_description: ஒரு ஏச்டேக்கை தடைசெய்வது இந்த ஏச்டேக்குடன் மீண்டும் \n  இடுகைகளை உருவாக்க அனுமதிக்கும். இந்த ஏச்டேக்குடன் இருக்கும் இடுகைகள் இனி \n  மறைக்கப்படவில்லை.\napproved: அங்கீகரிக்கப்பட்டது\nrejected: நிராகரிக்கப்பட்டது\nadd_moderator: மதிப்பீட்டாளரைச் சேர்க்கவும்\nadd_badge: ஒட்டு சேர்க்கவும்\ncreated: உருவாக்கப்பட்டது\nexpires: காலாவதியாகிறது\nperm: நிரந்தர\nexpired_at: காலாவதியானது\nadd_ban: சேர்\ntrash: குப்பை\nicon: படவுரு\npin: முள்\nunpin: மூள்நீக்கு\nchange_magazine: பத்திரிகையை மாற்றவும்\nchange_language: மொழியை மாற்றவும்\nmark_as_adult: மார்க் என்.எச்.எஃப்.டபிள்யூ\nunmark_as_adult: NSFW UNCOLDER\nchange: மாற்றம்\npinned: பின்\npreview: முன்னோட்டம்\nfirstname: முதல் பெயர்\nsend: அனுப்பு\nactive_users: செயலில் உள்ளவர்கள்\nrandom_entries: சீரற்ற நூல்கள்\nrelated_entries: தொடர்புடைய நூல்கள்\ndelete_account: கணக்கை நீக்கு\npurge_account: கணக்கை தூய்மைப்படுத்துங்கள்\narticle: நூல்\nreputation: நற்பெயர்\nnote: குறிப்பு\nwriting: எழுதுதல்\nusers: பயனர்கள்\ncontent: உள்ளடக்கம்\nweek: வாரம்\nweeks: வாரங்கள்\nmonth: மாதம்\nmonths: மாதங்கள்\nyear: ஆண்டு\nfederated: கூட்டாட்சி\nlocal: உள்ளக\nadmin_panel: நிர்வாக குழு\ndashboard: முகப்புப்பெட்டி\ncontact_email: மின்னஞ்சல் தொடர்பு\nmeta: மெட்டா\ninstance: சான்று\npages: பக்கங்கள்\nFAQ: கேள்விகள்\ntype_search_term: தேடல் காலத்தைத் தட்டச்சு செய்க\nfederation_enabled: கூட்டமைப்பு இயக்கப்பட்டது\nregistrations_enabled: பதிவு இயக்கப்பட்டது\nregistration_disabled: பதிவு முடக்கப்பட்டது\nrestore: மீட்டமை\nadd_mentions_entries: குறிச்சொற்களை நூல்களில் சேர்க்கவும்\nadd_mentions_posts: இடுகைகளில் குறிப்பிடப்பட்ட குறிச்சொற்களைச் சேர்க்கவும்\nPassword is invalid: கடவுச்சொல் தவறானது.\nYour account is not active: உங்கள் கணக்கு செயலில் இல்லை.\nYour account has been banned: உங்கள் கணக்கு தடைசெய்யப்பட்டுள்ளது.\nban_account: கணக்கு தடை\nrelated_magazines: தொடர்புடைய பத்திரிகைகள்\nrandom_magazines: சீரற்ற பத்திரிகைகள்\nmagazine_panel_tags_info: குறிச்சொற்களின் அடிப்படையில் இந்த பத்திரிகையில் \n  ஃபெடிவர்சிலிருந்து உள்ளடக்கம் சேர்க்கப்பட வேண்டும் என்று நீங்கள் விரும்பினால் \n  மட்டுமே வழங்கவும்\nauto_preview: ஆட்டோ மீடியா முன்னோட்டம்\ndynamic_lists: மாறும் பட்டியல்கள்\nbanned_instances: தடைசெய்யப்பட்ட நிகழ்வுகள்\nkbin_intro_title: ஃபெடிவர்சை ஆராயுங்கள்\nkbin_intro_desc: ஃபெடிவர்ச் நெட்வொர்க்கில் செயல்படும் உள்ளடக்க திரட்டல் மற்றும் \n  மைக்ரோ பிளாக்கிங் செய்வதற்கான ஒரு பரவலாக்கப்பட்ட தளமாகும்.\nkbin_promo_title: உங்கள் சொந்த நிகழ்வை உருவாக்கவும்\nkbin_promo_desc: '%link_start%குளோன் ரெப்போ%இணைப்பு_எண்ட்%மற்றும் ஃபெடிவர்சை உருவாக்குங்கள்'\ncaptcha_enabled: கேப்ட்சா இயக்கப்பட்டது\nheader_logo: தலைப்பு லோகோ\nbrowsing_one_thread: விவாதத்தில் நீங்கள் ஒரு நூலை மட்டுமே உலாவுகிறீர்கள்! \n  அனைத்து கருத்துகளும் தபால் பக்கத்தில் கிடைக்கின்றன.\nreturn: திரும்ப\nboost: பூச்ட்\nmercure_enabled: மெர்குர் இயக்கப்பட்டது\nreport_issue: சிக்கல் அறிக்கை\ntokyo_night: டோக்கியோ இரவு\npreferred_languages: நூல்கள் மற்றும் இடுகைகளின் மொழிகளை வடிகட்டவும்\ninfinite_scroll_help: நீங்கள் பக்கத்தின் அடிப்பகுதியை அடையும்போது தானாக அதிக \n  உள்ளடக்கத்தை ஏற்றவும்.\nauto_preview_help: உள்ளடக்கத்திற்கு கீழே பெரிய அளவில் மீடியா (புகைப்படம், \n  வீடியோ) முன்னோட்டங்களைக் காட்டுங்கள்.\nreload_to_apply: மாற்றங்களைப் பயன்படுத்த பக்கத்தை மீண்டும் ஏற்றவும்\nfilter.origin.label: தோற்றத்தைத் தேர்வுசெய்க\nfilter.fields.label: எந்த புலங்களைத் தேட வேண்டும் என்பதைத் தேர்வுசெய்க\nsticky_navbar_help: நீங்கள் கீழே உருட்டும்போது நவ்பார் பக்கத்தின் மேற்புறத்தில் \n  ஒட்டிக்கொண்டிருக்கும்.\nfilter.adult.label: NSFW ஐக் காண்பிக்க வேண்டுமா என்பதைத் தேர்வுசெய்க\nfilter.adult.hide: NSFW ஐ மறைக்கவும்\nfilter.adult.show: NSFW ஐக் காட்டு\nfilter.adult.only: NSFW மட்டுமே\nlocal_and_federated: உள்ளக மற்றும் கூட்டாட்சி\nfilter.fields.only_names: பெயர்கள் மட்டுமே\nfilter.fields.names_and_descriptions: பெயர்கள் மற்றும் விளக்கங்கள்\nkbin_bot: Ing முகவர்\nbot_body_content: \"Mbin முகவருக்கு வருக! MBIN க்குள் செயல்பாட்டு பப் செயல்பாட்டை செயல்படுத்துவதில்\n  இந்த முகவர் முக்கிய பங்கு வகிக்கிறது. ஃபெடிவர்சில் உள்ள பிற நிகழ்வுகளுடன் MBIN தொடர்பு\n  கொள்ளவும் கூட்டமாகவும் இருக்க முடியும் என்பதை இது உறுதி செய்கிறது.\\n\\n செயல்பாட்டு\n  பப் என்பது ஒரு திறந்த நிலையான நெறிமுறையாகும், இது பரவலாக்கப்பட்ட சமூக வலைப்பின்னல்\n  தளங்களை ஒருவருக்கொருவர் தொடர்பு கொள்ளவும் தொடர்பு கொள்ளவும் அனுமதிக்கிறது. ஃபெடிவர்ச்\n  என அழைக்கப்படும் கூட்டாட்சி சமூக வலைப்பின்னல் முழுவதும் உள்ளடக்கத்தைப் பின்பற்றவும்,\n  தொடர்பு கொள்ளவும், பகிர்ந்து கொள்ளவும் வெவ்வேறு நிகழ்வுகளில் (சேவையகங்கள்) பயனர்களுக்கு\n  இது உதவுகிறது. பயனர்கள் உள்ளடக்கத்தை வெளியிடுவதற்கும், பிற பயனர்களைப் பின்தொடர்வதற்கும்,\n  நூல்கள் அல்லது இடுகைகளில் விரும்புவது, பகிர்வது மற்றும் கருத்து தெரிவிப்பது போன்ற\n  சமூக தொடர்புகளில் ஈடுபடுவதற்கும் இது ஒரு தரப்படுத்தப்பட்ட வழியை வழங்குகிறது.\"\npassword_confirm_header: உங்கள் கடவுச்சொல் மாற்ற கோரிக்கையை உறுதிப்படுத்தவும்.\nyour_account_is_not_active: உங்கள் கணக்கு செயல்படுத்தப்படவில்லை. கணக்கு \n  செயல்படுத்தும் வழிமுறைகளுக்கு உங்கள் மின்னஞ்சலைச் சரிபார்க்கவும் அல்லது <a \n  href = \"%link_target%\"> புதிய கணக்கு செயல்படுத்தும் மின்னஞ்சலைக் கோருங்கள். \n  </a>\nyour_account_has_been_banned: உங்கள் கணக்கு தடைசெய்யப்பட்டுள்ளது\nyour_account_is_not_yet_approved: உங்கள் கணக்கு இன்னும் அங்கீகரிக்கப்படவில்லை. \n  உங்கள் பதிவுபெறும் கோரிக்கையை நிர்வாகிகள் செயலாக்கியவுடன் நாங்கள் உங்களுக்கு \n  ஒரு மின்னஞ்சல் அனுப்புவோம்.\ntoolbar.bold: தடிமான\ntoolbar.italic: சாய்வு\ntoolbar.strikethrough: ச்ட்ரைகெத்ரோ\ntoolbar.header: தலைப்பி\ntoolbar.quote: மேற்கோள்\ntoolbar.code: குறியீடு\ntoolbar.link: இணைப்பு\ntoolbar.image: படம்\ntoolbar.unordered_list: வரிசைப்படுத்தப்படாத பட்டியல்\ntoolbar.ordered_list: ஆர்டர் செய்யப்பட்ட பட்டியல்\ntoolbar.mention: குறிப்பு\nfederation_page_allowed_description: அறியப்பட்ட நிகழ்வுகள் நாங்கள் கூட்டுறவு \n  கொள்கிறோம்\ntoolbar.spoiler: இறக்கைத்தடை\nfederation_page_enabled: கூட்டமைப்பு பக்கம் இயக்கப்பட்டது\nfederation_page_disallowed_description: நாங்கள் கூட்டுறவு கொள்ளாத நிகழ்வுகள்\nfederation_page_dead_title: இறந்த நிகழ்வுகள்\nfederated_search_only_loggedin: உள்நுழையவில்லை என்றால் ஃபெடரேட்டட் தேடல் \n  லிமிடெட்\naccount_deletion_title: கணக்கு நீக்குதல்\nfederation_page_dead_description: ஒரு வரிசையில் குறைந்தது 10 நடவடிக்கைகளை \n  எங்களால் வழங்க முடியவில்லை, கடைசியாக வெற்றிகரமான மகப்பேறு மற்றும் ரெசிவ் ஒரு \n  வாரத்திற்கு முன்னர் இருந்தன\naccount_deletion_description: கணக்கை உடனடியாக நீக்க நீங்கள் தேர்வு \n  செய்யாவிட்டால் உங்கள் கணக்கு 30 நாட்களில் நீக்கப்படும். 30 நாட்களுக்குள் \n  உங்கள் கணக்கை மீட்டெடுக்க, ஒரே பயனர் சான்றுகளுடன் உள்நுழைக அல்லது நிர்வாகியை \n  தொடர்பு கொள்ளவும்.\naccount_deletion_immediate: உடனடியாக நீக்கு\nmore_from_domain: டொமைனில் இருந்து மேலும்\nerrors.server500.description: மன்னிக்கவும், எங்கள் முடிவில் ஏதோ தவறு ஏற்பட்டது. \n  இந்த பிழையை நீங்கள் தொடர்ந்து பார்த்தால், நிகழ்வு உரிமையாளரைத் தொடர்பு கொள்ள \n  முயற்சிக்கவும். இந்த நிகழ்வு செயல்படவில்லை என்றால், இதற்கிடையில் சிக்கல் \n  தீர்க்கப்படும் வரை%link_start%பிற MBIN நிகழ்வுகள்%இணைப்பு_என்ட்%ஐப் பாருங்கள்.\nerrors.server429.title: 429 பல கோரிக்கைகள்\nerrors.server404.title: 404 கண்டுபிடிக்கப்படவில்லை\nerrors.server403.title: 403 தடைசெய்யப்பட்டுள்ளது\nemail.delete.title: பயனர் கணக்கு நீக்குதல் கோரிக்கை\nemail_confirm_button_text: உங்கள் கடவுச்சொல் மாற்ற கோரிக்கையை உறுதிப்படுத்தவும்\nemail_confirm_link_help: மாற்றாக நீங்கள் பின்வருவனவற்றை உங்கள் உலாவியில் \n  நகலெடுத்து ஒட்டலாம்\nemail.delete.description: பின்வரும் பயனர் தங்கள் கணக்கை நீக்குமாறு கோரியுள்ளார்\nresend_account_activation_email_question: செயலற்ற கணக்கு?\nresend_account_activation_email: கணக்கு செயல்படுத்தும் மின்னஞ்சலை மீண்டும் \n  வழங்கவும்\nresend_account_activation_email_error: இந்த கோரிக்கையை சமர்ப்பிப்பதில் சிக்கல் \n  இருந்தது. அந்த மின்னஞ்சலுடன் தொடர்புடைய எந்தக் கணக்கும் இருக்கலாம் அல்லது அது \n  ஏற்கனவே செயல்படுத்தப்பட்டிருக்கலாம்.\nresend_account_activation_email_success: அந்த மின்னஞ்சலுடன் தொடர்புடைய ஒரு \n  கணக்கு இருந்தால், நாங்கள் ஒரு புதிய செயல்படுத்தல் மின்னஞ்சலை அனுப்புவோம்.\nresend_account_activation_email_description: உங்கள் கணக்குடன் தொடர்புடைய \n  மின்னஞ்சல் முகவரியை உள்ளிடவும். உங்களுக்காக மற்றொரு செயல்படுத்தும் மின்னஞ்சலை \n  நாங்கள் அனுப்புவோம்.\ncustom_css: தனிப்பயன் சிஎச்எச்\noauth.consent.title: OAuth2 ஒப்புதல் படிவம்\noauth.consent.grant_permissions: அனுமதிகள் வழங்கவும்\nignore_magazines_custom_css: பத்திரிகைகள் தனிப்பயன் சிஎச்எச் ஐ புறக்கணிக்கவும்\noauth.consent.app_requesting_permissions: உங்கள் சார்பாக பின்வரும் செயல்களைச் \n  செய்ய விரும்புகிறேன்\noauth.consent.app_has_permissions: ஏற்கனவே பின்வரும் செயல்களைச் செய்யலாம்\noauth.consent.to_allow_access: இந்த அணுகலை அனுமதிக்க, கீழே உள்ள 'இசைவு' \n  பொத்தானைக் சொடுக்கு செய்க\noauth.consent.allow: இசைவு\noauth.consent.deny: மறுக்கவும்\noauth.client_identifier.invalid: தவறான OAUTH கிளையன்ட் ஐடி!\noauth.client_not_granted_message_read_permission: உங்கள் செய்திகளைப் படிக்க இந்த\n  பயன்பாட்டிற்கு இசைவு கிடைக்கவில்லை.\nrestrict_oauth_clients: OAuth2 கிளையன்ட் உருவாக்கத்தை நிர்வாகிகளுக்கு \n  கட்டுப்படுத்துங்கள்\nprivate_instance: எந்தவொரு உள்ளடக்கத்தையும் அணுகுவதற்கு முன்பு பயனர்களை உள்நுழைய\n  கட்டாயப்படுத்துங்கள்\nblock: தொகுதி\nunblock: தடை\noauth2.grant.moderate.magazine.ban.delete: உங்கள் மிதமான பத்திரிகைகளில் தடையற்ற \n  பயனர்கள்.\noauth2.grant.moderate.magazine.list: உங்கள் மிதமான பத்திரிகைகளின் பட்டியலைப் \n  படியுங்கள்.\noauth2.grant.moderate.magazine.reports.all: உங்கள் மிதமான பத்திரிகைகளில் \n  அறிக்கைகளை நிர்வகிக்கவும்.\noauth2.grant.moderate.magazine.reports.read: உங்கள் மிதமான பத்திரிகைகளில் \n  அறிக்கைகளைப் படியுங்கள்.\noauth2.grant.moderate.magazine.reports.action: உங்கள் மிதமான பத்திரிகைகளில் \n  அறிக்கைகளை ஏற்கவும் அல்லது நிராகரிக்கவும்.\noauth2.grant.moderate.magazine.trash.read: உங்கள் மிதமான பத்திரிகைகளில் \n  குப்பைத்தொட்டிய உள்ளடக்கத்தைக் காண்க.\noauth2.grant.admin.entry.purge: உங்கள் நிகழ்விலிருந்து எந்த நூலையும் முழுமையாக \n  நீக்கவும்.\noauth2.grant.read.general: நீங்கள் அணுகக்கூடிய அனைத்து உள்ளடக்கங்களையும் \n  படியுங்கள்.\noauth2.grant.write.general: உங்கள் நூல்கள், இடுகைகள் அல்லது கருத்துகள் ஏதேனும் \n  ஒன்றை உருவாக்கவும் அல்லது திருத்தவும்.\noauth2.grant.delete.general: உங்கள் நூல்கள், இடுகைகள் அல்லது கருத்துகள் ஏதேனும் \n  ஒன்றை நீக்கவும்.\noauth2.grant.moderate.magazine_admin.all: உங்களுக்கு சொந்தமான பத்திரிகைகளை \n  உருவாக்கவும், திருத்தவும் அல்லது நீக்கவும்.\noauth2.grant.moderate.magazine_admin.create: புதிய பத்திரிகைகளை உருவாக்கவும்.\noauth2.grant.moderate.magazine_admin.delete: உங்களுக்கு சொந்தமான எதையும் நீக்கு.\noauth2.grant.moderate.magazine_admin.update: உங்களுக்கு சொந்தமான எந்த \n  பத்திரிகைகளின் விதிகள், விளக்கம், NSFW நிலை அல்லது ஐகானைத் திருத்தவும்.\noauth2.grant.moderate.magazine_admin.edit_theme: உங்களுக்கு சொந்தமான எந்தவொரு \n  பத்திரிகைகளின் தனிப்பயன் சிஎச்எச் ஐத் திருத்தவும்.\noauth2.grant.moderate.magazine_admin.moderators: உங்களுக்கு சொந்தமான எந்தவொரு \n  பத்திரிகைகளின் மதிப்பீட்டாளர்களையும் சேர்க்கவும் அல்லது அகற்றவும்.\noauth2.grant.moderate.magazine_admin.badges: உங்களுக்கு சொந்தமான \n  பத்திரிகைகளிலிருந்து பேட்ச்களை உருவாக்கவும் அல்லது அகற்றவும்.\noauth2.grant.moderate.magazine_admin.tags: உங்களுக்கு சொந்தமான \n  பத்திரிகைகளிலிருந்து குறிச்சொற்களை உருவாக்கவும் அல்லது அகற்றவும்.\noauth2.grant.moderate.magazine_admin.stats: உங்களுக்கு சொந்தமான பத்திரிகைகளின் \n  உள்ளடக்கம், வாக்களிப்பு மற்றும் புள்ளிவிவரங்களைக் காண்க.\noauth2.grant.admin.all: உங்கள் நிகழ்வில் எந்தவொரு நிர்வாக நடவடிக்கையும் \n  செய்யுங்கள்.\noauth2.grant.report.general: நூல்கள், இடுகைகள் அல்லது கருத்துகளைப் \n  புகாரளிக்கவும்.\noauth2.grant.subscribe.general: எந்தவொரு செய்தித் தாள், டொமைன் அல்லது பயனரையும் \n  குழுசேரவும் அல்லது பின்பற்றவும், நீங்கள் குழுசேரும் பத்திரிகைகள், களங்கள் \n  மற்றும் பயனர்களைக் காண்க.\noauth2.grant.block.general: எந்தவொரு செய்தித் தாள், டொமைன் அல்லது பயனரைத் \n  தடுத்து அல்லது தடைசெய்க, நீங்கள் தடுத்த பத்திரிகைகள், களங்கள் மற்றும் \n  பயனர்களைக் காண்க.\noauth2.grant.vote.general: நூல்கள், இடுகைகள் அல்லது கருத்துகளை உயர்த்தவும், \n  குறைத்து மதிப்பிடவும் அல்லது உயர்த்தவும்.\noauth2.grant.domain.all: களங்களுக்கு குழுசேரவும் அல்லது தடுக்கவும், நீங்கள் \n  குழுசேரும் களங்களை அல்லது தடுக்கவும்.\noauth2.grant.domain.subscribe: களங்களுக்கு குழுசேரவும் அல்லது குழுவிலகவும் \n  மற்றும் நீங்கள் குழுசேரும் களங்களைக் காண்க.\noauth2.grant.domain.block: களங்களைத் தடுத்து அல்லது தடைசெய்தல் மற்றும் நீங்கள் \n  தடுத்த களங்களைக் காண்க.\noauth2.grant.entry.all: உங்கள் நூல்களை உருவாக்கவும், திருத்தவும் அல்லது \n  நீக்கவும், எந்த நூலையும் வாக்களிக்கவும், உயர்த்தவும் அல்லது புகாரளிக்கவும்.\noauth2.grant.entry.create: புதிய நூல்களை உருவாக்கவும்.\noauth2.grant.post.delete: உங்கள் இருக்கும் இடுகைகளை நீக்கவும்.\noauth2.grant.post.vote: எந்தவொரு இடுகையையும் உயர்த்தவும், பூச்ட் செய்யவும் \n  அல்லது குறைக்கவும்.\noauth2.grant.post.report: எந்த இடுகையையும் புகாரளிக்கவும்.\noauth2.grant.entry.edit: உங்கள் இருக்கும் நூல்களைத் திருத்தவும்.\noauth2.grant.entry.delete: உங்கள் இருக்கும் நூல்களை நீக்கவும்.\noauth2.grant.entry.vote: எந்தவொரு நூலையும் உயர்த்தவும், உயர்த்தவும் அல்லது \n  குறைக்கவும்.\noauth2.grant.entry.report: எந்த நூலையும் புகாரளிக்கவும்.\noauth2.grant.entry_comment.all: உங்கள் கருத்துகளை நூல்களில் உருவாக்கவும், \n  திருத்தவும் அல்லது நீக்கவும், வாக்களிக்கவும், உயர்த்தவும் அல்லது எந்தவொரு \n  கருத்தையும் ஒரு நூலில் புகாரளிக்கவும்.\noauth2.grant.entry_comment.create: நூல்களில் புதிய கருத்துகளை உருவாக்கவும்.\noauth2.grant.entry_comment.edit: உங்கள் இருக்கும் கருத்துகளை நூல்களில் \n  திருத்தவும்.\noauth2.grant.entry_comment.delete: உங்கள் இருக்கும் கருத்துகளை நூல்களில் \n  நீக்கவும்.\noauth2.grant.entry_comment.vote: எந்தவொரு கருத்தையும் ஒரு நூலில் உயர்த்தவும், \n  உயர்த்தவும் அல்லது குறைக்கவும்.\noauth2.grant.entry_comment.report: எந்தவொரு கருத்தையும் ஒரு நூலில் \n  புகாரளிக்கவும்.\noauth2.grant.magazine.all: பத்திரிகைகளுக்கு குழுசேரவும் அல்லது தடுக்கவும், \n  நீங்கள் குழுசேரும் அல்லது தடுக்கும் பத்திரிகைகளைக் காண்க.\noauth2.grant.magazine.subscribe: பத்திரிகைகளுக்கு குழுசேரவும் அல்லது \n  குழுவிலகவும் மற்றும் நீங்கள் குழுசேரும் பத்திரிகைகளைப் பார்க்கவும்.\noauth2.grant.magazine.block: பத்திரிகைகளைத் தடுத்து நிறுத்தி, நீங்கள் தடுத்த \n  பத்திரிகைகளைப் பார்க்கவும்.\noauth2.grant.post.all: உங்கள் மைக்ரோ வலைப்பதிவுகளை உருவாக்கவும், திருத்தவும் \n  அல்லது நீக்கவும், வாக்களிக்கவும், பூச்ட் செய்யவும் அல்லது எந்த மைக்ரோ \n  வலைப்பதிவைப் புகாரளிக்கவும்.\noauth2.grant.post.create: புதிய இடுகைகளை உருவாக்கவும்.\noauth2.grant.post.edit: உங்கள் இருக்கும் இடுகைகளைத் திருத்தவும்.\noauth2.grant.post_comment.all: இடுகைகளில் உங்கள் கருத்துகளை உருவாக்கவும், \n  திருத்தவும் அல்லது நீக்கவும், ஒரு இடுகையில் வாக்களிக்கவும், உயர்த்தவும் அல்லது\n  எந்த கருத்தையும் புகாரளிக்கவும்.\noauth2.grant.post_comment.create: இடுகைகளில் புதிய கருத்துகளை உருவாக்கவும்.\noauth2.grant.post_comment.edit: இடுகைகளில் உங்கள் இருக்கும் கருத்துகளைத் \n  திருத்தவும்.\noauth2.grant.post_comment.delete: இடுகைகளில் உங்கள் இருக்கும் கருத்துகளை \n  நீக்கவும்.\noauth2.grant.post_comment.vote: ஒரு இடுகையில் எந்தவொரு கருத்தையும் உயர்த்தவும், \n  பூச்ட் செய்யவும் அல்லது குறைத்து மதிப்பிடவும்.\noauth2.grant.post_comment.report: ஒரு இடுகையில் எந்த கருத்தையும் தெரிவிக்கவும்.\noauth2.grant.user.all: உங்கள் சுயவிவரம், செய்திகள் அல்லது அறிவிப்புகளைப் படித்து\n  திருத்தவும்; நீங்கள் பிற பயன்பாடுகளை வழங்கிய அனுமதிகளைப் படித்து திருத்தவும்; \n  பிற பயனர்களைப் பின்தொடரவும் அல்லது தடுக்கவும்; நீங்கள் பின்பற்றும் அல்லது \n  தடுக்கும் பயனர்களின் பட்டியல்களைக் காண்க.\noauth2.grant.user.profile.read: உங்கள் சுயவிவரத்தைப் படியுங்கள்.\noauth2.grant.user.profile.edit: உங்கள் சுயவிவரத்தைத் திருத்தவும்.\noauth2.grant.user.profile.all: உங்கள் சுயவிவரத்தைப் படித்து திருத்தவும்.\noauth2.grant.user.message.all: உங்கள் செய்திகளைப் படித்து பிற பயனர்களுக்கு \n  செய்திகளை அனுப்பவும்.\noauth2.grant.user.message.read: உங்கள் செய்திகளைப் படியுங்கள்.\noauth2.grant.user.message.create: பிற பயனர்களுக்கு செய்திகளை அனுப்பவும்.\noauth2.grant.user.notification.all: உங்கள் அறிவிப்புகளைப் படித்து அழிக்கவும்.\noauth2.grant.user.notification.read: செய்தி அறிவிப்புகள் உட்பட உங்கள் \n  அறிவிப்புகளைப் படியுங்கள்.\noauth2.grant.user.notification.delete: உங்கள் அறிவிப்புகளை அழிக்கவும்.\noauth2.grant.user.oauth_clients.all: பிற OAuth2 விண்ணப்பங்களுக்கு நீங்கள் \n  வழங்கிய அனுமதிகளைப் படித்து திருத்தவும்.\noauth2.grant.user.oauth_clients.read: பிற OAuth2 விண்ணப்பங்களுக்கு நீங்கள் \n  வழங்கிய அனுமதிகளைப் படியுங்கள்.\noauth2.grant.user.oauth_clients.edit: பிற OAuth2 விண்ணப்பங்களுக்கு நீங்கள் \n  வழங்கிய அனுமதிகளைத் திருத்தவும்.\noauth2.grant.user.follow: பயனர்களைப் பின்தொடரவும் அல்லது பின்தொடரவும், நீங்கள் \n  பின்பற்றும் பயனர்களின் பட்டியலைப் படியுங்கள்.\noauth2.grant.user.block: பயனர்களைத் தடு அல்லது தடைசெய்க, நீங்கள் தடுக்கும் \n  பயனர்களின் பட்டியலைப் படியுங்கள்.\noauth2.grant.moderate.all: உங்கள் மிதமான பத்திரிகைகளில் செய்ய உங்களுக்கு இசைவு \n  உள்ள எந்த மிதமான செயலையும் செய்யுங்கள்.\noauth2.grant.moderate.entry.all: உங்கள் மிதமான பத்திரிகைகளில் மிதமான நூல்கள்.\noauth2.grant.moderate.entry.change_language: உங்கள் மிதமான பத்திரிகைகளில் \n  நூல்களின் மொழியை மாற்றவும்.\noauth2.grant.moderate.entry.pin: உங்கள் மிதமான பத்திரிகைகளின் மேற்புறத்தில் முள்\n  நூல்கள்.\noauth2.grant.moderate.entry.set_adult: உங்கள் மிதமான பத்திரிகைகளில் நூல்களை NSFW\n  ஆக குறிக்கவும்.\noauth2.grant.moderate.entry.trash: உங்கள் மிதமான பத்திரிகைகளில் நூல்களை குப்பை \n  அல்லது மீட்டமை.\noauth2.grant.moderate.entry_comment.change_language: உங்கள் மிதமான \n  பத்திரிகைகளில் உள்ள நூல்களில் கருத்துகளின் மொழியை மாற்றவும்.\noauth2.grant.moderate.entry_comment.all: உங்கள் மிதமான பத்திரிகைகளில் நூல்களில் \n  மிதமான கருத்துகள்.\noauth2.grant.moderate.entry_comment.set_adult: உங்கள் மிதமான பத்திரிகைகளில் NSFW\n  ஆக நூல்களில் கருத்துகளை குறிக்கவும்.\noauth2.grant.moderate.entry_comment.trash: உங்கள் மிதமான பத்திரிகைகளில் \n  நூல்களில் கருத்துகளை குப்பை அல்லது மீட்டமைக்கவும்.\noauth2.grant.moderate.post.all: உங்கள் மிதமான பத்திரிகைகளில் மிதமான இடுகைகள்.\noauth2.grant.moderate.post.change_language: உங்கள் மிதமான பத்திரிகைகளில் \n  இடுகைகளின் மொழியை மாற்றவும்.\noauth2.grant.moderate.post.set_adult: உங்கள் மிதமான பத்திரிகைகளில் இடுகைகளை NSFW\n  ஆக குறிக்கவும்.\noauth2.grant.moderate.post.trash: உங்கள் மிதமான பத்திரிகைகளில் இடுகைகளை குப்பை \n  அல்லது மீட்டமை.\noauth2.grant.moderate.post_comment.all: உங்கள் மிதமான பத்திரிகைகளில் இடுகைகளில் \n  மிதமான கருத்துகள்.\noauth2.grant.moderate.post_comment.change_language: உங்கள் மிதமான பத்திரிகைகளில்\n  இடுகைகளில் கருத்துகளின் மொழியை மாற்றவும்.\noauth2.grant.moderate.post_comment.set_adult: உங்கள் மிதமான பத்திரிகைகளில் \n  இடுகைகளில் கருத்துகளை NSFW ஆகக் குறிக்கவும்.\noauth2.grant.moderate.post_comment.trash: உங்கள் மிதமான பத்திரிகைகளில் \n  இடுகைகளில் கருத்துகளை குப்பை அல்லது மீட்டெடுக்கவும்.\noauth2.grant.moderate.magazine.ban.all: உங்கள் மிதமான பத்திரிகைகளில் \n  தடைசெய்யப்பட்ட பயனர்களை நிர்வகிக்கவும்.\noauth2.grant.moderate.magazine.ban.read: உங்கள் மிதமான பத்திரிகைகளில் \n  தடைசெய்யப்பட்ட பயனர்களைக் காண்க.\noauth2.grant.moderate.magazine.all: உங்கள் மிதமான பத்திரிகைகளில் \n  குப்பைத்தொட்டியான பொருட்களைக் காண்க, அறிக்கைகள் மற்றும் பார்வை.\noauth2.grant.moderate.magazine.ban.create: உங்கள் மிதமான பத்திரிகைகளில் பயனர்களை\n  தடை செய்யுங்கள்.\noauth2.grant.admin.entry_comment.purge: உங்கள் நிகழ்விலிருந்து ஒரு நூலில் உள்ள \n  எந்த கருத்தையும் முழுமையாக நீக்கவும்.\noauth2.grant.admin.post.purge: உங்கள் நிகழ்விலிருந்து எந்த இடுகையையும் முழுமையாக\n  நீக்கவும்.\noauth2.grant.admin.magazine.move_entry: உங்கள் நிகழ்வில் பத்திரிகைகளுக்கு \n  இடையில் நூல்களை நகர்த்தவும்.\noauth2.grant.admin.magazine.purge: உங்கள் நிகழ்வில் பத்திரிகைகளை முழுவதுமாக \n  நீக்கவும்.\noauth2.grant.admin.post_comment.purge: உங்கள் நிகழ்விலிருந்து ஒரு இடுகையின் \n  எந்தவொரு கருத்தையும் முழுமையாக நீக்கவும்.\noauth2.grant.admin.magazine.all: உங்கள் நிகழ்வில் பத்திரிகைகளுக்கு இடையில் \n  நூல்களை நகர்த்தவும் அல்லது முழுமையாக நீக்கவும்.\noauth2.grant.admin.user.all: உங்கள் நிகழ்வில் பயனர்களை தடை செய்யுங்கள், \n  சரிபார்க்கவும் அல்லது முழுமையாக நீக்கவும்.\noauth2.grant.admin.user.ban: உங்கள் நிகழ்விலிருந்து தடை அல்லது தடைசெய்யும் \n  பயனர்கள்.\noauth2.grant.admin.user.verify: உங்கள் நிகழ்வில் பயனர்களை சரிபார்க்கவும்.\noauth2.grant.admin.user.delete: உங்கள் நிகழ்விலிருந்து பயனர்களை நீக்கு.\noauth2.grant.admin.user.purge: உங்கள் நிகழ்விலிருந்து பயனர்களை முழுமையாக \n  நீக்கவும்.\noauth2.grant.admin.instance.all: நிகழ்வு அமைப்புகள் அல்லது தகவல்களைக் காணலாம் \n  மற்றும் புதுப்பிக்கவும்.\noauth2.grant.admin.instance.stats: உங்கள் நிகழ்வின் புள்ளிவிவரங்களைக் காண்க.\noauth2.grant.admin.instance.settings.edit: உங்கள் நிகழ்வில் அமைப்புகளைப் \n  புதுப்பிக்கவும்.\noauth2.grant.admin.instance.settings.all: உங்கள் நிகழ்வில் அமைப்புகளைப் \n  பார்க்கவும் அல்லது புதுப்பிக்கவும்.\noauth2.grant.admin.instance.settings.read: உங்கள் நிகழ்வில் அமைப்புகளைக் காண்க.\noauth2.grant.admin.instance.information.edit: உங்கள் நிகழ்வில் கேள்விகள், \n  தொடர்பு, பணி விதிமுறைகள் மற்றும் தனியுரிமைக் கொள்கை பக்கங்களைப் \n  புதுப்பிக்கவும்.\noauth2.grant.admin.federation.all: தற்போது வரையறுக்கப்பட்ட நிகழ்வுகளைக் காணவும் \n  புதுப்பிக்கவும்.\noauth2.grant.admin.federation.read: வரையறுக்கப்பட்ட நிகழ்வுகளின் பட்டியலைக் \n  காண்க.\noauth2.grant.admin.oauth_clients.all: உங்கள் நிகழ்வில் இருக்கும் OAuth2 \n  வாடிக்கையாளர்களைக் காண்க அல்லது ரத்து செய்யவும்.\noauth2.grant.admin.oauth_clients.read: உங்கள் நிகழ்வில் இருக்கும் OAuth2 \n  வாடிக்கையாளர்களையும் அவற்றின் பயன்பாட்டு புள்ளிவிவரங்களையும் காண்க.\noauth2.grant.admin.federation.update: வரையறுக்கப்பட்ட நிகழ்வுகளின் \n  பட்டியலிலிருந்து அல்லது நிகழ்வுகளைச் சேர்க்கவும் அல்லது அகற்றவும்.\noauth2.grant.admin.oauth_clients.revoke: உங்கள் நிகழ்வில் OAuth2 \n  வாடிக்கையாளர்களுக்கான அணுகலைத் திரும்பப் பெறுங்கள்.\nlast_active: கடைசியாக செயலில்\nflash_post_pin_success: இடுகை வெற்றிகரமாக பொருத்தப்பட்டுள்ளது.\nflash_post_unpin_success: இந்த இடுகை வெற்றிகரமாக இணைக்கப்படவில்லை.\ncomment_reply_position_help: கருத்து பதில் படிவத்தை பக்கத்தின் மேல் அல்லது கீழ் \n  காண்பிக்கவும். 'எல்லையற்ற சுருள்' இயக்கப்பட்டால், நிலை எப்போதும் மேலே \n  தோன்றும்.\nshow_avatars_on_comments: கருத்து அவதாரங்களைக் காட்டு\nsingle_settings: ஒற்றை\nupdate_comment: கருத்தைப் புதுப்பிக்கவும்\nshow_avatars_on_comments_help: ஒற்றை நூல் அல்லது இடுகையில் கருத்துகளைப் \n  பார்க்கும்போது பயனர் அவதாரங்களைக் காண்பி/மறைக்கவும்.\ncomment_reply_position: கருத்து பதில் நிலை\nmagazine_theme_appearance_custom_css: உங்கள் பத்திரிகைக்குள் உள்ளடக்கத்தைப் \n  பார்க்கும்போது பொருந்தும் தனிப்பயன் CSS.\nmagazine_theme_appearance_icon: பத்திரிகைக்கான தனிப்பயன் படவுரு. எதுவும் \n  தேர்ந்தெடுக்கப்படவில்லை என்றால், இயல்புநிலை படவுரு பயன்படுத்தப்படும்.\nmagazine_theme_appearance_background_image: உங்கள் பத்திரிகைக்குள் \n  உள்ளடக்கத்தைப் பார்க்கும்போது பயன்படுத்தப்படும் தனிப்பயன் பின்னணி படம்.\nmoderation.report.approve_report_title: ஒப்புதல் அறிக்கை\nmoderation.report.reject_report_title: அறிக்கையை நிராகரிக்கவும்\nmoderation.report.ban_user_description: இந்த பத்திரிகையிலிருந்து இந்த \n  உள்ளடக்கத்தை உருவாக்கிய பயனரை (%பயனர்பெயர்%) தடை செய்ய விரும்புகிறீர்களா?\nmoderation.report.approve_report_confirmation: இந்த அறிக்கையை நீங்கள் \n  அங்கீகரிக்க விரும்புகிறீர்கள் என்பதில் உறுதியாக இருக்கிறீர்களா?\nsubject_reported_exists: இந்த உள்ளடக்கம் ஏற்கனவே அறிவிக்கப்பட்டுள்ளது.\nmoderation.report.ban_user_title: பயனரை தடை செய்யுங்கள்\nmoderation.report.reject_report_confirmation: இந்த அறிக்கையை நிராகரிக்க \n  விரும்புகிறீர்கள் என்பதில் உறுதியாக இருக்கிறீர்களா?\noauth2.grant.moderate.post.pin: உங்கள் மிதமான பத்திரிகைகளின் மேலே இடுகைகளை முள்.\npurge_content: உள்ளடக்கத்தை தூய்மைப்படுத்துங்கள்\ndelete_content_desc: உருவாக்கப்பட்ட நூல்கள், இடுகைகள் மற்றும் கருத்துகளில் பிற \n  பயனர்களின் பதில்களை விட்டு வெளியேறும்போது பயனரின் உள்ளடக்கத்தை நீக்கவும்.\ndelete_account_desc: உருவாக்கப்பட்ட நூல்கள், இடுகைகள் மற்றும் கருத்துகளில் பிற \n  பயனர்களின் பதில்கள் உட்பட கணக்கை நீக்கவும்.\nschedule_delete_account: அட்டவணை நீக்குதல்\npurge_content_desc: உருவாக்கப்பட்ட நூல்கள், இடுகைகள் மற்றும் கருத்துகளில் பிற \n  பயனர்களின் பதில்களை நீக்குவது உட்பட பயனரின் உள்ளடக்கத்தை முற்றிலுமாக \n  தூய்மைப்படுத்துங்கள்.\nschedule_delete_account_desc: இந்த கணக்கை நீக்குவதற்கு 30 நாட்களில் \n  திட்டமிடவும். இது பயனரையும் அவற்றின் உள்ளடக்கத்தையும் மறைக்கும், அத்துடன் \n  பயனரை உள்நுழைவதைத் தடுக்கும்.\nremove_schedule_delete_account: திட்டமிடப்பட்ட நீக்குதலை அகற்று\nremove_schedule_delete_account_desc: திட்டமிடப்பட்ட நீக்குதலை அகற்றவும். எல்லா \n  உள்ளடக்கங்களும் மீண்டும் கிடைக்கும், மேலும் பயனருக்கு உள்நுழைய முடியும்.\ntwo_factor_backup: இரண்டு காரணி அங்கீகார காப்புப்பிரதி குறியீடுகள்\n2fa.authentication_code.label: அங்கீகார குறியீடு\n2fa.verify: சரிபார்க்கவும்\n2fa.code_invalid: அங்கீகார குறியீடு செல்லுபடியாகாது\n2fa.disable: இரண்டு காரணி அங்கீகாரத்தை முடக்கு\n2fa.backup: உங்கள் இரண்டு காரணி காப்பு குறியீடுகள்\n2fa.backup-create.help: நீங்கள் புதிய காப்பு அங்கீகார குறியீடுகளை உருவாக்கலாம்; \n  அவ்வாறு செய்வது ஏற்கனவே உள்ள குறியீடுகளை செல்லாது.\n2fa.backup-create.label: புதிய காப்பு அங்கீகார குறியீடுகளை உருவாக்கவும்\n2fa.remove: 2fa ஐ அகற்று\n2fa.add: எனது கணக்கில் சேர்க்கவும்\n2fa.verify_authentication_code.label: அமைப்பை சரிபார்க்க இரண்டு காரணி குறியீட்டை\n  உள்ளிடவும்\n2fa.qr_code_img.alt: உங்கள் கணக்கிற்கான இரண்டு காரணி அங்கீகாரத்தை அமைக்க \n  அனுமதிக்கும் QR குறியீடு\n2fa.user_active_tfa.title: பயனருக்கு செயலில் 2FA உள்ளது\n2fa.qr_code_link.title: இந்த இணைப்பைப் பார்வையிடுவது இந்த இரண்டு காரணி \n  அங்கீகாரத்தை பதிவு செய்ய உங்கள் தளத்தை அனுமதிக்கலாம்\n2fa.available_apps: QR-குறியீட்டை ச்கேன் செய்ய %google_authenticator %, %aegis \n  %(Android) அல்லது %raivo %(iOS) போன்ற இரண்டு காரணி அங்கீகார பயன்பாட்டைப் \n  பயன்படுத்தவும்.\n2fa.backup_codes.recommendation: அவற்றின் நகலை பாதுகாப்பான இடத்தில் வைத்திருக்க \n  பரிந்துரைக்கப்படுகிறது.\n2fa.backup_codes.help: உங்கள் இரண்டு காரணி அங்கீகார சாதனம் அல்லது பயன்பாடு \n  இல்லாதபோது இந்த குறியீடுகளைப் பயன்படுத்தலாம். நீங்கள் <strong> அவற்றைக் காட்ட \n  மாட்டீர்கள் </strong> மற்றும் அவை ஒவ்வொன்றையும் <strong> ஒரு முறை மட்டும் \n  </strong> ஐப் பயன்படுத்த முடியும்.\npassword_and_2fa: கடவுச்சொல் மற்றும் 2FA\nflash_account_settings_changed: உங்கள் கணக்கு அமைப்புகள் வெற்றிகரமாக \n  மாற்றப்பட்டுள்ளன. நீங்கள் மீண்டும் உள்நுழைய வேண்டும்.\nshow_subscriptions: சந்தாக்களைக் காட்டு\nsubscription_sort: வரிசைப்படுத்து\nalphabetically: அகரவரிசை\nsubscriptions_in_own_sidebar: தனி பக்கப்பட்டியில்\nsidebars_same_side: ஒரே பக்கத்தில் பக்கப்பட்டிகள்\nsubscription_sidebar_pop_out_right: வலதுபுறத்தில் பக்கப்பட்டியை பிரிக்க \n  நகர்த்தவும்\nsubscription_sidebar_pop_out_left: இடதுபுறத்தில் பக்கப்பட்டியை பிரிக்க \n  நகர்த்தவும்\nsubscription_sidebar_pop_in: சந்தாக்களை இன்லைன் பேனலுக்கு நகர்த்தவும்\nsubscription_panel_large: பெரிய குழு\nsubscription_header: சந்தா பத்திரிகைகள்\nclose: மூடு\nposition_bottom: கீழே\nposition_top: மேலே\npending: நிலுவையில் உள்ளது\nflash_thread_new_error: நூலை உருவாக்க முடியவில்லை. ஏதோ தவறு நடந்தது.\nflash_thread_tag_banned_error: நூலை உருவாக்க முடியவில்லை. உள்ளடக்கம் \n  அனுமதிக்கப்படவில்லை.\nflash_image_download_too_large_error: படத்தை உருவாக்க முடியவில்லை, இது மிகப் \n  பெரியது (அதிகபட்ச அளவு %பைட்டுகள் %)\nflash_email_was_sent: மின்னஞ்சல் வெற்றிகரமாக அனுப்பப்பட்டுள்ளது.\nflash_email_failed_to_sent: மின்னஞ்சலை அனுப்ப முடியவில்லை.\nflash_post_new_success: இடுகை வெற்றிகரமாக உருவாக்கப்பட்டுள்ளது.\nflash_post_new_error: இடுகையை உருவாக்க முடியவில்லை. ஏதோ தவறு நடந்தது.\nflash_magazine_theme_changed_success: செய்தித் தாள் தோற்றத்தை வெற்றிகரமாக \n  புதுப்பித்தது.\nflash_magazine_theme_changed_error: செய்தித் தாள் தோற்றத்தை புதுப்பிக்கத் \n  தவறிவிட்டது.\nflash_comment_new_success: கருத்து வெற்றிகரமாக உருவாக்கப்பட்டுள்ளது.\nflash_comment_edit_success: கருத்து வெற்றிகரமாக புதுப்பிக்கப்பட்டுள்ளது.\nflash_comment_new_error: கருத்தை உருவாக்கத் தவறிவிட்டது. ஏதோ தவறு நடந்தது.\nflash_comment_edit_error: கருத்தைத் திருத்தத் தவறிவிட்டது. ஏதோ தவறு நடந்தது.\nflash_user_settings_general_success: பயனர் அமைப்புகள் வெற்றிகரமாக \n  சேமிக்கப்பட்டன.\nflash_user_settings_general_error: பயனர் அமைப்புகளைச் சேமிப்பதில் தோல்வி.\nflash_user_edit_profile_error: சுயவிவர அமைப்புகளை சேமிப்பதில் தோல்வி.\nflash_user_edit_profile_success: பயனர் சுயவிவர அமைப்புகள் வெற்றிகரமாக \n  சேமிக்கப்பட்டன.\nflash_user_edit_email_error: மின்னஞ்சலை மாற்றத் தவறிவிட்டது.\nflash_user_edit_password_error: கடவுச்சொல்லை மாற்றுவதில் தோல்வி.\nflash_thread_edit_error: நூலைத் திருத்தத் தவறிவிட்டது. ஏதோ தவறு நடந்தது.\nflash_post_edit_error: இடுகையைத் திருத்தத் தவறிவிட்டது. ஏதோ தவறு நடந்தது.\nflash_post_edit_success: போச்ட் வெற்றிகரமாக திருத்தப்பட்டுள்ளது.\npage_width: பக்க அகலம்\npage_width_max: அதிகபட்சம்\npage_width_auto: தானி\npage_width_fixed: சரி\nfilter_labels: வடிகட்டி லேபிள்கள்\nauto: தானி\nopen_url_to_fediverse: அசல் முகவரி ஐத் திறக்கவும்\nchange_my_avatar: எனது அவதாரத்தை மாற்றவும்\nedit_my_profile: எனது சுயவிவரத்தைத் திருத்தவும்\nchange_my_cover: எனது அட்டையை மாற்றவும்\naccount_settings_changed: உங்கள் கணக்கு அமைப்புகள் வெற்றிகரமாக மாற்றப்பட்டுள்ளன.\n  நீங்கள் மீண்டும் உள்நுழைய வேண்டும்.\nmagazine_deletion: செய்தித் தாள் நீக்குதல்\ndelete_magazine: பத்திரிகையை நீக்கு\nrestore_magazine: பத்திரிகையை மீட்டெடுங்கள்\npurge_magazine: பர்ச் செய்தித் தாள்\nmagazine_is_deleted: செய்தித் தாள் நீக்கப்பட்டது. நீங்கள் <a href = \n  \"%link_target%\"> மீட்டமைக்கலாம் </a> இது 30 நாட்களுக்குள்.\nsuspend_account: கணக்கு இடைநீக்கம்\nunsuspend_account: விரும்பத்தகாத கணக்கு\naccount_suspended: கணக்கு இடைநிறுத்தப்பட்டுள்ளது.\naccount_unsuspended: கணக்கு சந்தேகத்திற்கு இடமின்றி உள்ளது.\ndeletion: நீக்குதல்\nuser_suspend_desc: உங்கள் கணக்கை இடைநிறுத்துவது உங்கள் உள்ளடக்கத்தை உதாரணமாக \n  மறைக்கிறது, ஆனால் அதை நிரந்தரமாக அகற்றாது, நீங்கள் எந்த நேரத்திலும் அதை \n  மீட்டெடுக்கலாம்.\naccount_banned: கணக்கு தடைசெய்யப்பட்டுள்ளது.\naccount_unbanned: கணக்கு தடைசெய்யப்படவில்லை.\naccount_is_suspended: பயனர் கணக்கு இடைநிறுத்தப்பட்டுள்ளது.\nremove_following: பின்வருவனவற்றை அகற்று\nremove_subscriptions: சந்தாக்களை அகற்று\napply_for_moderator: மதிப்பீட்டாளருக்கு விண்ணப்பிக்கவும்\nrequest_magazine_ownership: செய்தித் தாள் உரிமையை கோருங்கள்\ncancel_request: கோரிக்கையை ரத்துசெய்\nmoderator_requests: மோட் கோரிக்கைகள்\nabandoned: கைவிடப்பட்டது\nownership_requests: உரிமை கோரிக்கைகள்\naccept: ஏற்றுக்கொள்\naction: செயல்\nuser_badge_op: ஒப்\nuser_badge_admin: நிர்வாகி\nuser_badge_global_moderator: உலகளாவிய துணிவு\nuser_badge_moderator: மோட்\nuser_badge_bot: போட்\nannouncement: அறிவிப்பு\nkeywords: முக்கிய வார்த்தைகள்\ndeleted_by_moderator: நூல், இடுகை அல்லது கருத்து மதிப்பீட்டாளரால் நீக்கப்பட்டது\ndeleted_by_author: நூல், இடுகை அல்லது கருத்து ஆசிரியரால் நீக்கப்பட்டது\nsensitive_warning: உணர்திறன் உள்ளடக்கம்\nsensitive_toggle: முக்கியமான உள்ளடக்கத்தின் தெரிவுநிலையை மாற்றவும்\nsensitive_show: காண்பிக்க சொடுக்கு செய்க\ndetails: விவரங்கள்\nsensitive_hide: மறைக்க சொடுக்கு செய்க\nspoiler: இறக்கைத்தடை\nall_time: எல்லா நேரமும்\nshow: காட்டு\nhide: மறை\nedited: திருத்தப்பட்டது\nsso_registrations_enabled: ஒருகைஉள் பதிவுகள் இயக்கப்பட்டன\nsso_registrations_enabled.error: மூன்றாம் தரப்பு அடையாள மேலாளர்களுடன் புதிய \n  கணக்கு பதிவுகள் தற்போது முடக்கப்பட்டுள்ளன.\nrestrict_magazine_creation: உள்ளக செய்தித் தாள் உருவாக்கத்தை நிர்வாகிகள் மற்றும்\n  உலகளாவிய மோட்சுக்கு கட்டுப்படுத்துங்கள்\nsso_show_first: உள்நுழைவு மற்றும் பதிவு பக்கங்களில் ஒருகைஉள் ஐ முதலில் காட்டு\ncontinue_with: தொடருங்கள்\nreported_user: அறிவிக்கப்பட்ட பயனர்\nreporting_user: புகாரளிக்கும் பயனர்\nreported: அறிக்கை\nsso_only_mode: உள்நுழைவு மற்றும் பதிவை ஒருகைஉள் முறைகளுக்கு மட்டுமே \n  கட்டுப்படுத்தவும்\nrelated_entry: தொடர்புடைய\nreport_subject: பொருள்\nown_report_rejected: உங்கள் அறிக்கை நிராகரிக்கப்பட்டது\nown_report_accepted: உங்கள் அறிக்கை ஏற்றுக்கொள்ளப்பட்டது\nreport_accepted: ஒரு அறிக்கை ஏற்றுக்கொள்ளப்பட்டது\nopen_report: திறந்த அறிக்கை\ncake_day: கேக் நாள்\nown_content_reported_accepted: உங்கள் உள்ளடக்கத்தின் அறிக்கை \n  ஏற்றுக்கொள்ளப்பட்டது.\nsomeone: யாரோ\nback: பின்\nmagazine_log_mod_added: ஒரு மதிப்பீட்டாளரைச் சேர்த்துள்ளார்\nmagazine_log_entry_pinned: பின் செய்யப்பட்ட நுழைவு\nmagazine_log_mod_removed: ஒரு மதிப்பீட்டாளரை அகற்றியுள்ளது\nmagazine_log_entry_unpinned: அகற்றப்பட்ட பின் நுழைவு\nlast_updated: கடைசியாக புதுப்பிக்கப்பட்டது\nand: மற்றும்\ndirect_message: நேரடி செய்தி\nmanually_approves_followers: பின்பற்றுபவர்களுக்கு கைமுறையாக ஒப்புதல் அளிக்கிறது\nregister_push_notifications_button: புச் அறிவிப்புகளுக்கு பதிவு செய்யுங்கள்\nunregister_push_notifications_button: புச் பதிவை அகற்று\ntest_push_notifications_button: சோதனை புச் அறிவிப்புகள்\ntest_push_message: வணக்கம் உலகம்!\nnotification_title_new_comment: புதிய கருத்து\nnotification_title_removed_comment: ஒரு கருத்து அகற்றப்பட்டது\nnotification_title_edited_comment: ஒரு கருத்து திருத்தப்பட்டது\nnotification_title_mention: நீங்கள் குறிப்பிடப்பட்டீர்கள்\nnotification_title_new_reply: புதிய பதில்\nnotification_title_new_thread: புதிய நூல்\nnotification_title_removed_thread: ஒரு நூல் அகற்றப்பட்டது\nnotification_title_edited_thread: ஒரு நூல் திருத்தப்பட்டது\nnotification_title_ban: உங்களுக்கு தடை விதிக்கப்பட்டது\nnotification_title_message: புதிய நேரடி செய்தி\nnotification_title_new_post: புதிய இடுகை\nnotification_title_removed_post: ஒரு இடுகை அகற்றப்பட்டது\nnotification_title_edited_post: ஒரு இடுகை திருத்தப்பட்டது\nnotification_title_new_signup: ஒரு புதிய பயனர் பதிவு செய்யப்பட்டார்\nnotification_body_new_signup: பயனர் % உ % பதிவு செய்யப்பட்டுள்ளது.\nnotification_body2_new_signup_approval: அவர்கள் உள்நுழைவதற்கு முன்பு நீங்கள் \n  கோரிக்கையை அங்கீகரிக்க வேண்டும்\nshow_related_magazines: சீரற்ற பத்திரிகைகளைக் காட்டு\nshow_related_entries: சீரற்ற நூல்களைக் காட்டு\nshow_related_posts: சீரற்ற இடுகைகளைக் காட்டு\nshow_active_users: செயலில் உள்ள பயனர்களைக் காட்டு\nnotification_title_new_report: ஒரு புதிய அறிக்கை உருவாக்கப்பட்டது\nmagazine_posting_restricted_to_mods_warning: இந்த பத்திரிகையில் மோட்ச் மட்டுமே \n  நூல்களை உருவாக்க முடியும்\nflash_posting_restricted_error: நூல்களை உருவாக்குவது இந்த பத்திரிகையில் உள்ள \n  மோட்களுக்கு மட்டுப்படுத்தப்பட்டுள்ளது, நீங்கள் ஒன்றல்ல\nserver_software: சேவையக மென்பொருள்\nlast_failed_contact: கடைசியாக தோல்வியுற்ற தொடர்பு\nmagazine_posting_restricted_to_mods: நூல் உருவாக்கத்தை மதிப்பீட்டாளர்களுக்கு \n  கட்டுப்படுத்துங்கள்\nnew_user_description: இந்த பயனர் புதியது ( % நாட்களுக்கு குறைவான நாட்களுக்கு \n  செயலில் உள்ளது % நாட்கள்)\nnew_magazine_description: இந்த செய்தித் தாள் புதியது ( % நாட்களுக்கும் குறைவான \n  நாட்களுக்கு செயலில் உள்ளது % நாட்கள்)\nversion: பதிப்பு\nlast_successful_deliver: கடைசி வெற்றிகரமான வழங்கல்\nlast_successful_receive: கடைசியாக வெற்றிகரமாக பெறுதல்\nadmin_users_active: செயலில்\nadmin_users_inactive: செயலற்றது\nadmin_users_suspended: இடைநீக்கம்\nbookmark_add_to_list: '%பட்டியலில் புக்மார்க்கைச் சேர்க்கவும் %'\nbookmark_remove_from_list: '%பட்டியலிலிருந்து புத்தகக்குறியை அகற்று'\nbookmark_remove_all: அனைத்து புக்மார்க்குகளையும் அகற்று\nbookmark_add_to_default_list: இயல்புநிலை பட்டியலில் புக்மார்க்கைச் சேர்க்கவும்\nbookmark_lists: புக்மார்க்கு பட்டியல்கள்\nbookmarks: புக்மார்க்குகள்\nbookmarks_list: '%பட்டியலில் புக்மார்க்குகள் %'\ncount: எண்ணுங்கள்\nis_default: இயல்புநிலை\nbookmark_list_is_default: இயல்புநிலை பட்டியல்\nbookmark_list_make_default: இயல்புநிலை செய்யுங்கள்\nbookmark_list_create: உருவாக்கு\nbookmark_list_create_placeholder: பெயரைத் தட்டச்சு செய்க ...\nbookmark_list_create_label: பட்டியல் பெயர்\nbookmarks_list_edit: புக்மார்க்கு பட்டியலைத் திருத்து\nbookmark_list_edit: தொகு\nbookmark_list_selected_list: தேர்ந்தெடுக்கப்பட்ட பட்டியல்\nsearch_type_entry: நூல்கள்\nsearch_type_post: மைக்ரோ பிளாக்ச்\nselect_user: ஒரு பயனரைத் தேர்வுசெய்க\nnew_users_need_approval: புதிய பயனர்கள் உள்நுழைவதற்கு முன்பு ஒரு நிர்வாகியால் \n  அங்கீகரிக்கப்பட வேண்டும்.\ntable_of_contents: உள்ளடக்க அட்டவணை\nsearch_type_all: நூல்கள் + மைக்ரோ பிளாக்ச்\nsignup_requests: பதிவுபெறும் கோரிக்கைகள்\napplication_text: நீங்கள் ஏன் சேர விரும்புகிறீர்கள் என்பதை விளக்குங்கள்\nsignup_requests_header: பதிவுபெறும் கோரிக்கைகள்\nsignup_requests_paragraph: இந்த பயனர்கள் உங்கள் சேவையகத்தில் சேர \n  விரும்புகிறார்கள். அவர்களின் பதிவுபெறும் கோரிக்கையை நீங்கள் அங்கீகரிக்கும் வரை\n  அவர்களால் உள்நுழைய முடியாது.\nemail_application_rejected_body: உங்கள் ஆர்வத்திற்கு நன்றி, ஆனால் உங்கள் \n  பதிவுபெறும் கோரிக்கை மறுக்கப்பட்டுள்ளது என்பதை உங்களுக்குத் தெரிவிக்க \n  வருத்தப்படுகிறோம்.\nemail_application_pending: நீங்கள் உள்நுழைவதற்கு முன்பு உங்கள் கணக்கில் நிர்வாக \n  ஒப்புதல் தேவைப்படுகிறது.\nemail_verification_pending: நீங்கள் உள்நுழைவதற்கு முன்பு உங்கள் மின்னஞ்சல் \n  முகவரியை சரிபார்க்க வேண்டும்.\nremove_user_avatar: அவதாரத்தை அகற்று\nremove_user_cover: அட்டையை அகற்று\nshow_new_icons: புதிய சின்னங்களைக் காட்டு\nshow_users_avatars_help: பயனர் அவதார் படத்தைக் காண்பி.\nshow_magazines_icons_help: செய்தித் தாள் ஐகானைக் காண்பி.\nshow_thumbnails_help: சிறு படங்களைக் காட்டு.\nviewing_one_signup_request: '%பயனர்பெயர் %மூலம் ஒரு பதிவுபெறும் கோரிக்கையை மட்டுமே\n  நீங்கள் பார்க்கிறீர்கள்'\nopen_signup_request: பதிவுபெறும் கோரிக்கையை திறக்கவும்\nshow_new_icons_help: புதிய பத்திரிகை/பயனருக்கான ஐகானைக் காட்டு (30 நாட்கள் அகவை \n  அல்லது புதியது)\noauth2.grant.user.bookmark.remove: புக்மார்க்குகளை அகற்று\noauth2.grant.user.bookmark: புக்மார்க்குகளைச் சேர்த்து அகற்றவும்\noauth2.grant.user.bookmark.add: புக்மார்க்குகளைச் சேர்க்கவும்\noauth2.grant.user.bookmark_list.delete: உங்கள் புக்மார்க்கு பட்டியல்களை \n  நீக்கவும்\nshow_magazine_domains: செய்தித் தாள் களங்களைக் காட்டு\nfront_default_sort: முன் பக்கம் இயல்புநிலை வரிசை\ncomment_default_sort: கருத்து இயல்புநிலை வரிசை\nemail_application_approved_body: உங்கள் பதிவுபெறும் கோரிக்கையை சேவையக நிர்வாகி \n  அங்கீகரித்தார். நீங்கள் இப்போது சேவையகத்தில் <a href = \n  \"%இணைப்பு%\">%sitename%</a> இல் உள்நுழையலாம்.\ntoolbar.emoji: ஈமோசி\n2fa.manual_code_hint: நீங்கள் QR குறியீட்டை வருடு செய்ய முடியாவிட்டால், \n  கைமுறையாக ரகசியத்தை உள்ளிடவும்\noauth2.grant.user.bookmark_list: உங்கள் புத்தகக்குறி பட்டியல்களைப் படிக்கவும், \n  திருத்தவும் நீக்கவும்\noauth2.grant.user.bookmark_list.read: உங்கள் புத்தகக்குறி பட்டியல்களைப் \n  படியுங்கள்\noauth2.grant.user.bookmark_list.edit: உங்கள் புத்தகக்குறி பட்டியல்களைத் \n  திருத்தவும்\nflash_application_info: நீங்கள் உள்நுழைவதற்கு முன்பு ஒரு நிர்வாகி உங்கள் கணக்கை \n  அங்கீகரிக்க வேண்டும். உங்கள் பதிவுபெறும் கோரிக்கை செயலாக்கப்பட்டவுடன் \n  உங்களுக்கு மின்னஞ்சலைப் பெறுவீர்கள்.\nemail_application_approved_title: உங்கள் பதிவுபெறும் கோரிக்கை \n  அங்கீகரிக்கப்பட்டுள்ளது\nemail_application_rejected_title: உங்கள் பதிவுபெறும் கோரிக்கை \n  நிராகரிக்கப்பட்டுள்ளது\nshow_user_domains: பயனர் களங்களைக் காட்டு\nby: மூலம்\nimage_lightbox_in_list: நூல் சிறுபடங்கள் முழுத் திரையைத் திறக்கும்\ncompact_view_help: குறைந்த ஓரங்களுடன் ஒரு சிறிய பார்வை, அங்கு ஊடகங்கள் வலது \n  பக்கத்திற்கு நகர்த்தப்படுகின்றன.\nimage_lightbox_in_list_help: சரிபார்க்கும்போது, சிறுபடத்தைக் சொடுக்கு செய்வது \n  ஒரு மாதிரி பட பெட்டி சாளரத்தைக் காட்டுகிறது. தேர்வு செய்யப்படும்போது, \n  சிறுபடத்தைக் சொடுக்கு செய்தால் நூலைத் திறக்கும்.\nanswered: பதில்அளிக்கப்பட்டது\n"
  },
  {
    "path": "translations/messages.tr.yaml",
    "content": "type.photo: Fotoğraf\ntype.video: Video\nsearch: Ara\nadd: Ekle\nnewest: En Yeni\noldest: En Eski\nlogin: Giriş yap\nfilter_by_time: Zamana göre filtrele\nfilter_by_type: Türe göre filtrele\nfavourites: Oylar\nup_votes: Boostlar\nadd_comment: Yorum ekle\nadd_post: Gönderi ekle\nadd_media: Medya ekle\nowner: Sahibi\nsubscribers: Aboneler\nonline: Çevrim İçi\ncomments: Yorumlar\nmore: Daha Fazla\navatar: Avatar\nadded: Eklendi\nmoderators: Moderatörler\nmod_log: Moderasyon kaydı\nenter_your_post: Gönderinizi girin\nactivity: Etkinlik\nempty: Boş\nmicroblog: Mikroblog\nevents: Olaylar\npassword: Şifre\nremember_me: Beni hatırla\nyou_cant_login: Parolanızı mı unuttunuz?\nalready_have_account: Halihazırda hesabınız var mı?\nregister: Kaydol\nreset_password: Şifreyi sıfırla\nshow_more: Daha fazlası\nabout_instance: Hakkında\nfediverse: Fediverse\nfollow: Takip et\nunfollow: Takipten çık\nreply: Cevapla\nemail: E-posta\nrepeat_password: Şifreyi tekrarla\nfaq: SSS\nrss: RSS\nchange_theme: Temayı değiştir\nhelp: Yardım\ncheck_email: E-postanızı kontrol edin\nemail_confirm_content: 'Mbin hesabınızı etkinleştirmeye hazır mısınız? Aşağıdaki bağlantıya\n  tıklayın:'\ntype.link: Bağlantı\nselect_channel: Bir kanal seç\nmarkdown_howto: Editör nasıl çalışır?\nenter_your_comment: Yorumunuzu girin\nsubscribe: Abone ol\ndont_have_account: Hesabınız yok mu?\nusername: Kullanıcı adı\ncontact: İletişim\nemail_confirm_header: Merhaba! E-posta adresinizi doğrulayın.\ntype.smart_contract: Akıllı sözleşme\ntype.magazine: Magazin\npeople: İnsanlar\nmagazine: Magazin\nmagazines: Dergiler\nactive: Aktif\ncommented: Yorum yapıldı\nchange_view: Görünümü değiştir\ncomments_count: '{0}Yorum|{1}Yorum|]1,Inf[ Yorumlar'\nfavourite: Favori\ndown_votes: Azaltır\nno_comments: Yorum yok\ncreated_at: Oluşturuldu\nposts: Gönderiler\nreplies: Cevaplar\ncover: Kapak\nrelated_posts: İlgili gönderiler\nrandom_posts: Rasgele gönderiler\nunsubscribe: Abonelikden çık\nlogin_or_email: Giriş veya e-posta\nterms: Kullanım Şartları\nprivacy_policy: Gizlilik Politikası\nall_magazines: Tüm dergiler\nstats: İstatistik\ncreate_new_magazine: Yeni dergi oluştur\nadd_new_link: Yeni bağlantı ekle\nadd_new_photo: Yeni fotoğraf ekle\nadd_new_post: Yeni gönderi ekle\nadd_new_video: Yeni video ekle\nuseful: Kullanışlı\nreset_check_email_desc: Halihazırda e-posta adresinizle ilişkilendirilmiş bir \n  hesap varsa, kısa süre içinde parolanızı sıfırlamak için kullanabileceğiniz \n  bir bağlantı içeren bir e-posta alacaksınız. Bu bağlantı %expire% içinde \n  geçerliliğini yitirecek.\nreset_check_email_desc2: Bir e-posta almazsanız, lütfen spam klasörünüzü kontrol\n  edin.\ntry_again: Yeniden dene\nup_vote: Yükselt\nurl: URL\neng: ENG\noc: Oİ\nimage: Resim\nname: İsim\ndescription: Tanım\nis_adult: +18 / NSFW\ndown_vote: Azalt\nemail_verify: E-posta adresini onayla\nemail_confirm_expire: Lütfen bağlantının bir saat içinde geçerliliğini \n  kaybedeceğini unutmayın.\nselect_magazine: Dergi seçin\nadd_new: Yenisini ekle\ntitle: Başlık\ntags: Etiketler\nbadges: Rozetler\nemail_confirm_title: E-posta adresinizi onaylayın.\nsubscriptions: Abonelikler\noverview: Genel bakış\ncards: Kartlar\ncolumns: Sütunlar\nuser: Kullanıcı\nmoderated: Yönetilenler\npeople_local: Yerel\nrelated_tags: İlgili etiketler\ngo_to_content: İçeriğe git\ngo_to_filters: Filtrelere git\ngo_to_search: Aramaya git\nsubscribed: Abone olundu\nall: Hepsi\nclassic_view: Klassik görünüm\ncompact_view: Kompakt görünüm\nchat_view: Sohbet görünümü\ntree_view: Ağaç görünümü\ntable_view: Tablo görünümü\n3h: 3 saat\n6h: 6 saat\n12h: 12 saat\n1d: 1 gün\n1y: 1 yıl\nlinks: Bağlantılar\nphotos: Fotoğraflar\nvideos: Videolar\nreport: Raporla\nshare: Paylaş\ncopy_url_to_fediverse: Bağlantıyı Fediverse'e kopyala\nshare_on_fediverse: Fediverse'de paylaş\nedit: Düzenle\nare_you_sure: Emin misin?\nmoderate: Yönet\nreason: Sebep\ndelete: Sil\nedit_post: Gönderiyi düzenle\nedit_comment: Yorumu düzenle\nsettings: Ayarlar\ngeneral: Genel\nprofile: Profil\nblocked: Engelli\nreports: Raporlar\nnotifications: Bildirimler\nmessages: Mesajlar\nhomepage: Ana sayfa\nhide_adult: Yetişkin içeriği gizle\nfeatured_magazines: Öne çıkan dergiler\nprivacy: Gizlilik\nshow_profile_followings: İzlenilen kullanıcıları göster\nold_email: Mevcut e-posta\nnew_email: Yeni e-posta\ncurrent_password: Şimdiki şifre\nnew_password: Yeni şifre\nnew_password_repeat: Yeni şifreyi onayla\nchange_email: E-postayı değiştir\nchange_password: Şifreyi değiştir\nexpand: Genişlet\nerror: Hata\nvotes: Oylar\ntheme: Tema\ndark: Karanlık\nlight: Aydınlık\nfont_size: Yazı boyutu\nsize: Boyut\nyes: Evet\nno: Hayır\nshow_thumbnails: Küçük resimleri göster\nrounded_edges: Yuvarlak kenarlar\nsubject_reported: İçerik rapor edildi.\nshow_top_bar: Üst çubuğu göster\ninfinite_scroll: Sonsuz kaydırma\nmessage: Mesaj\nsend_message: Mesaj gönder\npurge: Tamamen sil\npost: Gönderi\ncomment: Yorum\nmentioned_you: Sizden bahsetti\ndeleted: Yazar tarafından silindi\nbanned: Sizi yasakladı\nadded_new_reply: Yeni cevap ekledi\nmod_remove_your_post: Bir yönetici sizin gönderinizi kaldırdı\nremoved: Yönetici tarafından kaldırıldı\nedited_post: Gönderi düzenledi\nadded_new_post: Yeni gönderi eklendi\nreplied_to_your_comment: Yorumunuza cevap verdi\nedited_comment: Bir yorum düzenlendi\nadded_new_comment: Yeri yorum ekledi\nregistration_disabled: Kayıt devre dışı\nregistrations_enabled: Kayıt etkinleştirildi\ntype_search_term: Arama terimini yaz\nFAQ: SSS\npages: Sayfalar\ninstance: Örnek\ndashboard: Gösterge Paneli\nadmin_panel: Yönetici paneli\nlocal: Yerel\nyear: Yıl\nmonths: Aylar\nmonth: Ay\nweeks: Haftalar\nweek: Hafta\ncontent: İçerik\nusers: Kullanıcılar\nwriting: Yazı\nnote: Not\nreputation: İtibar\npinned: Sabitlendi\nchange: Değiştir\nchange_language: Dili değiştir\nchange_magazine: Dergi değiştir\nunpin: Sabitlemeyi kaldır\npin: Sabitle\ndone: Oldu\nicon: Simge\ntrash: Çöp\nperm: Kalıcı\nexpires: Süresi doluyor\ncreated: Oluşturuldu\nbans: Yasaklar\nadd_badge: Rozet ekle\nadd_moderator: Yönetici ekle\nrejected: Reddedilmiş\nfilters: Filtreler\nban: Yasakla\napprove: Onayla\nreject: Reddet\nmagazine_panel: Dergi paneli\nfrom_url: URL'den\nupload_file: Dosya yükle\ninstances: Örnekler\nstatus: Durum\nright: Sağ\nleft: Sol\nboost: Yükselt\nreturn: Geri dön\nkbin_intro_title: Fediverse'ü keşfet\ndynamic_lists: Dinamik listeler\nauto_preview: Otomatik medya önizleme\nrandom_magazines: Rasgele dergiler\nrelated_magazines: İlgili dergiler\nban_account: Hesabı yasakla\nunban_account: Hesap yasağını kaldır\npurge_account: Hesabı tamamen sil\ndelete_account: Hesabı sil\nactive_users: Aktif insanlar\nsend: Gönder\nfirstname: Ad\nYour account has been banned: Hesabınız yasaklandı.\nYour account is not active: Heabınız aktif değil.\nPassword is invalid: Şifre yanlış.\nfollowers: Takipçiler\nrestore: Onar\nfollowing: Takip edilenler\ncontact_email: E-posta aracılığıyla iletişme geç\nreputation_points: İtibar puanları\npreview: Ön izleme\nlogout: Çıkış yap\nexpired_at: Süresinin dolma zamanı\ncards_view: Kart görünümü\napproved: Onaylanmış\ncopy_url: Mbin bağlantıyı kopyala\nban_expired: Yasak süresi doldu\nwrote_message: Mesaj yazdı\nmod_deleted_your_comment: Bir yönetici sizin yorumunuzu sildi\nappearance: Görünüm\nmod_log_alert: UYARI - Modlog'da yöneticiler tarafından kaldırılmış önemli \n  gönderiler bula bilirsiniz. Ne yaptığınızı bildiğinizden emin olun.\nshow_profile_subscriptions: Dergi aboneliklerini göster\nnew_email_repeat: Yeni e-postayı onayla\nshow_users_avatars: Kullanıcı avatarını göster\nbody: Gövde\nrules: Kurallar\nnotify_on_new_post_reply: Gönderilerimdeki bütün cevaplar\nnotify_on_new_post_comment_reply: Gönderilerdeki yorumlarıma gelen cevaplar\nhe_banned: ban\nhe_unbanned: yasağı kaldır\nshow_all: Hepsini göster\nabout: Hakkında\ntoo_many_requests: Limit aşıldı, lütfen daha sonra tekrar deneyiniz.\nflash_thread_edit_success: Başlık başarıyla düzenlendi.\nflash_thread_delete_success: Başlık başarıyla silindi.\nflash_thread_pin_success: Başlık başarıyla sabitlendi.\nflash_thread_unpin_success: Başlığın sabitlenmesi başarıyla kaldırıldı.\nset_magazines_bar_empty_desc: Alan boş ise aktif dergiler bar üzerinde \n  gösterilir.\nflash_register_success: Aramıza hoş geldin! Hesabın başarıyla kaydedildi. Son \n  bir adım kaldı! - Gelen kutunu kontrol et, hesabını aktif hale getirecek bir \n  aktivasyon bağlantısı gönderdik.\nflash_thread_new_success: Başlık başarıyla oluşturuldu ve artık diğer \n  kullanıcılara görünebilir durumda.\nflash_magazine_new_success: Magazin başarıyla oluşturuldu. Artık yeni içerik \n  ekleyebilirsiniz veya magazinin yonetici panelini keşfedebilirsiniz.\ntype.article: İçerik\nthread: Başlık\nthreads: Başlıklar\ntop: Üst\nhot: Sıcak\nfederated_magazine_info: Bu magazin federe bir sunucudan geliyor ve eksik \n  olabilir.\nfederated_user_info: Bu profil federe bir sunucudan geliyor ve eksik olabilir.\ngo_to_original_instance: Özgün oluşuma daha fazla göz atın.\nagree_terms: '%terms_link_start%Kullanım şartlarını%terms_link_end% ve %policy_link_start%Gizlilik\n  Politikasını%policy_link_end% onayla'\nadd_new_article: Yeni başlık ekle\ndomain: Alan\njoined: Katılanlar\npeople_federated: Birleştirilmiş\nnotify_on_new_entry_reply: Başlıklarımdaki bütün yorumlar\nnotify_on_new_entry_comment_reply: Herhangi bir başlıkta yorumlarıma gelen \n  yanıtlar\nnotify_on_new_entry: Abone olduğum magazinlerdeki yeni başlıklar (bağlantılar \n  veya makaleler)\nnotify_on_new_posts: Abone olduğum magazinlerdeki yeni gönderiler\nsave: Kayıt et\ncollapse: Daralt\ndomains: Alanlar\nboosts: Boost'lar\nshow_magazines_icons: Dergilerin simgelerini göster\nrestored_thread_by: 'tarafından açılan başlık canlandırıldı:'\nremoved_thread_by: 'tarafından açılan başlık kaldırıldı:'\nremoved_comment_by: 'tarafından paylaşılan yorum kaldırıldı:'\nrestored_comment_by: 'tarafından paylaşılan yorum canlandırıldı:'\nremoved_post_by: 'tarafından paylaşılan gönderi kaldırıldı:'\nrestored_post_by: 'tarafından paylaşılan gönderi canlandırıldı:'\nread_all: Hepsini oku\nflash_magazine_edit_success: Magazin başarılı bir şekilde düzenlendi.\nset_magazines_bar: Derginin bar'ları\nset_magazines_bar_desc: virgülden sonra magazin isimlerini ekle\narticles: Başlıklar\nsidebar_position: Yanbar pozisyonu\nfederation: Federasyon\nadd_ban: Yasaklama ekle\nimage_alt: Resim alternatif metni\n1m: 1 ay\n1w: 1 hafta\nadded_new_thread: Yeni konu eklendi\nedited_thread: Konu düzenlendi\nmod_remove_your_thread: Bir yönetici konunuzu kaldırdı\nsticky_navbar: Sabit menü çubuğu\nfederated: Birleştirilmiş\nfederation_enabled: Federasyon aktifleştirildi\nadd_mentions_posts: Gönderilere bahsetme etiketi ekle\ncaptcha_enabled: Captcha aktifleştirildi\nsidebar: Yan bar\nrandom_entries: Rastgele konular\nrelated_entries: Alakalı konular\nbrowsing_one_thread: Tartışmadaki sadece bir konuya bakıyorsunuz! Tüm yorumlar \n  gönderi sayfasında mevcut.\nkbin_promo_desc: \"%link_start%Repo'yu klonlayın%link_end% ve Fediverse'ı geliştirin\"\narticle: Başlık\nkbin_intro_desc: Fediverse ağı içinde işleyen ve merkezi olmayan, içerik \n  biriktirme ve mikrobloglama platformudur.\nto: ile\nin: içinde\nsolarized_light: Solarize Işık\nsolarized_dark: Solarize Karanlık\non: Açık\noff: Kapalı\nmeta: Meta\nadd_mentions_entries: Başlıklara bahsetme etiketi ekle\nbanned_instances: Engellenmiş sunucular\nmagazine_panel_tags_info: Bu bilgiyi sadece fediverse'ten etiketler aracılığıyla\n  içerik gelmesini istiyorsanız girin\nkbin_promo_title: Kendi sunucunu oluştur\nheader_logo: Header logosu\nmercure_enabled: Merkür etkinleştir\nreport_issue: Sorun bildir\ntokyo_night: Tokyo Gecesi\ninfinite_scroll_help: Sayfanın en altına ulaştığınızda otomatik olarak daha \n  fazla içerik yükleyin.\nsticky_navbar_help: Navigasyon paneli aşağı kaydırdığınızda sayfanın üst kısmına\n  yapışacaktır.\nauto_preview_help: Medya önizlemelerini otomatik olarak genişletin.\nreload_to_apply: Değişiklikleri uygulamak için sayfayı yeniden yükleyin\npreferred_languages: Başlıkların ve gönderilerin dillerini filtreleyin\nfilter.adult.show: NSFW Göster\nbot_body_content: \"Mbin Botuna hoş geldiniz! Bu bot, ActivityPub işlevselliğini Mbin\n  içinde etkinleştirmede çok önemli bir rol oynar. Bu, Mbin ’in fediverse’deki diğer\n  örneklerle iletişim kurabilmesini ve federasyon kurabilmesini sağlar.\\n\\nActivityPub,\n  Merkezi olmayan sosyal ağ platformlarının birbirleriyle iletişim kurmasını ve etkileşimde\n  bulunmasını sağlayan açık standart bir protokoldür. Farklı örneklerdeki (sunucular)\n  kullanıcıların Fediverse olarak bilinen birleşik sosyal ağdaki içeriği takip etmelerini,\n  etkileşimde bulunmalarını ve paylaşmalarını sağlar. Kullanıcıların içerik yayınlamaları,\n  diğer kullanıcıları takip etmeleri ve konuları veya gönderileri beğenme, paylaşma\n  ve yorum yapma gibi sosyal etkileşimlere katılmaları için standart bir yol sağlar.\"\nfilter.origin.label: Kaynak seç\nfilter.fields.label: Hangi alanları aracağınızı seçin\nfilter.adult.label: NSFW’nin görüntülenip görüntülenmeyeceğini seçin\nfilter.adult.hide: NSFW Gizle\nfilter.adult.only: Sadece NSFW\nlocal_and_federated: Yerel ve federe\nfilter.fields.only_names: Sadece isimler\nfilter.fields.names_and_descriptions: İsimler ve açıklamalar\nkbin_bot: Mbin Bot\npassword_confirm_header: Parola değiştirme isteğinizi onaylayın.\nsort_by: Göre sırala\nfilter_by_subscription: Aboneliğe göre filtrele\nfilter_by_federation: Federasyon durumuna göre filtrele\nsubscribers_count: '{0}Aboneler|{1}Abone|]1,Bilgi[Aboneler'\nfollowers_count: '{0}Takipçiler|{1}Takipçi|]1,Bilg[ Takipçiler'\nmarked_for_deletion: Silinmek üzere işaretlendi\nmarked_for_deletion_at: '%date% tarihinde silinmek üzere işaretlendi'\nremove_media: Medyayı kaldır\nremove_user_avatar: Avatarı kaldır\nremove_user_cover: Kapağı kaldır\ndisconnected_magazine_info: Bu dergi güncelleme almıyor (son etkinlik %days% gün\n  önce).\nalways_disconnected_magazine_info: Bu dergiye güncelleme gelmiyor.\nsubscribe_for_updates: Güncellemeleri almaya başlamak için abone olun.\nchange_downvotes_mode: Oylama modunu değiştir\nhidden: Gizlenmiş\nenabled: Etkinleştirilmiş\ntag: Etiket\n"
  },
  {
    "path": "translations/messages.uk.yaml",
    "content": "filter_by_type: Фільтр за типом\n2fa.authentication_code.label: Код автентифікації\ncomment: Коментар\nsize: Розмір\noauth2.grant.post.edit: Редагувати ваші наявні дописи.\nalready_have_account: Вже є обліковий запис?\noauth2.grant.moderate.post.trash: Видаляти або відновлювати дописи у ваших \n  модерованих спільнотах.\nmoderation.report.approve_report_title: Прийняти скаргу\npreview: Попередній перегляд\nmoderation.report.reject_report_title: Відхилити скаргу\nkbin_bot: Mbin Бот\ndashboard: Панель керування\nadded_new_reply: Додає нову відповідь\nbans: Заборонені\ndeleted: Видалено автором\noauth2.grant.moderate.magazine.reports.all: Управляти скаргами у ваших \n  модерованих спільнотах.\nreputation_points: Бали репутації\noauth2.grant.admin.federation.update: Додавати або видаляти інстанси у список/зі\n  списку дефедерованих.\nfeatured_magazines: Рекомендовані спільноти\nmod_remove_your_thread: Модератор видаляє вашу гілку\nfilter.adult.label: Виберіть, чи відображати делікатний вміст\nresend_account_activation_email_error: Під час надсилання цього запиту виникла \n  проблема. Можливо, з цією е-поштою не повʼязано облікового запису або він уже \n  активований.\nfederation_page_enabled: Сторінку федерації ввімкнено\nshare_on_fediverse: Поділитися у Федіверс\nfederated_magazine_info: Спільнота з федерованого сервера, може відображатися не\n  повністю.\nmonth: Місяць\nreset_check_email_desc: Якщо з вашою адресою е-пошти вже повʼязано обліковий \n  запис, незабаром ви отримаєте електронного листа з посиланням, за яким можна \n  скинути пароль. Посилання стане недійсним за %expire%.\noauth2.grant.user.message.all: Читати ваші повідомлення та надсилати \n  повідомлення іншим користувачам.\nflash_account_settings_changed: Налаштування вашого облікового запису успішно \n  змінено. Вам потрібно буде увійти ще раз.\nclose: Закрити\nset_magazines_bar_empty_desc: якщо це поле порожнє, на панелі відображатимуться \n  активні спільноти.\nreply: Відповісти\ndown_vote: Невподобати\nfollowing: Відстежувані\ntop: Провідні\nreports: Скарги\noauth2.grant.moderate.magazine.trash.read: Переглядати видалений вміст у ваших \n  модерованих спільнотах.\nshow_thumbnails: Показувати мініатюри\nemail_confirm_button_text: Необхідно підтвердити запит на зміну пароля\nfont_size: Розмір шрифту\nsave: Зберегти\nwriting: Написання\nchange_view: Змінити вигляд\noauth2.grant.moderate.magazine_admin.create: Створювати нові спільноти.\nweeks: Тижні\nfilter.adult.hide: Сховати делікатний вміст\noauth2.grant.post.vote: Голосувати за, проти або поширювати будь-який допис.\nsubscribe: Підписатися\nFAQ: ЧаП\n2fa.remove: Видалити 2FA\ncreated_at: Створено\ndelete_content_desc: Видалити вміст користувача, залишивши відповіді інших \n  користувачів у створених гілках, дописах і коментарях.\nmagazine_theme_appearance_custom_css: Власний CSS для застосування під час \n  перегляду вашої спільноти відвідувачами.\nvotes: Голоси\ntitle: Заголовок\nflash_post_new_success: Публікація успішно створена.\ncopy_url: Скопіювати локальне посилання\nmoderators: Модератори\nflash_comment_edit_error: Не вдалося відредагувати коментар. Щось пішло не так.\ntoolbar.bold: Жирний\nerrors.server429.title: 429 Забагато запитів\nauto_preview_help: Показати попередній перегляд медіафайлів (фото, відео) у \n  збільшеному розмірі під вмістом.\nfilter.fields.label: Виберіть поля для пошуку\nare_you_sure: Ви впевнені?\n2fa.backup_codes.help: 'Скористайтеся цими кодами, якщо у вас не буде пристрою двофакторної\n  автентифікації або програми. <strong>Збережіть їх просто зараз</strong>, оскільки\n  вони більше ніколи не відображатимуться. Памʼятайте: ви зможете використати кожен\n  із них <strong>лише один раз</strong>.'\nfederation: Федерація\nthread: Гілка\ntoolbar.header: Заголовок\ncards: Картки\ncomments_count: '{0}коментарів|{1}коментар|]1,Inf[ коментарів'\nYour account is not active: Ваш обліковий запис не активний.\nflash_comment_new_error: Не вдалося створити коментар. Щось пішло не так.\nyou_cant_login: Забули свій пароль?\npassword: Пароль\noauth2.grant.user.oauth_clients.edit: Редагувати дозволи, які ви надали іншим \n  програмам OAuth2.\nmentioned_you: Згадує вас\nuser: Користувач\noauth2.grant.user.all: Читати і редагувати ваш профіль, повідомлення чи \n  сповіщення; читати і редагувати дозволи, які ви надали іншим програмам; \n  відстежувати або блокувати інших користувачів; переглядати списки \n  користувачів, яких ви відстежуєте або блокуєте.\noauth2.grant.moderate.post.set_adult: Позначати дописи як «Делікатне» у ваших \n  модерованих спільнотах.\nrandom_posts: Випадкові дописи\noauth2.grant.moderate.magazine_admin.edit_theme: Редагувати користувацький CSS \n  будь-якої вашої спільноти.\noauth2.grant.moderate.magazine_admin.tags: Створювати або видаляти теги з ваших \n  спільнот.\nsubscribed: Підписане\nsend_message: Надіслати повідомлення\nadd_new: Додати\ntrash: Кошик\nmoderation.report.ban_user_description: Бажаєте заборонити користувачу \n  (%username%), який створив цей вміст, доступ до цієї спільноти?\nflash_thread_unpin_success: Гілку успішно відкріплено.\noauth2.grant.moderate.entry.pin: Закріплювати гілки вгорі у ваших модерованих \n  спільнотах.\n1w: 1 тиждень\noauth2.grant.user.message.read: Читати ваші повідомлення.\nflash_post_new_error: Не вдалося створити публікацію. Щось пішло не так.\nmessage: Повідомлення\noauth2.grant.admin.entry.purge: Повністю видаляти будь-яку гілку з вашого \n  інстансу.\noldest: Старі\nfediverse: Федіверс\n2fa.verify: Підтвердити\ncards_view: Вигляд карток\nchange_password: Змінити пароль\n2fa.add: Додати до мого облікового запису\noauth.consent.to_allow_access: Щоб дозволити цей доступ, натисніть кнопку \n  «Дозволити» нижче\nemail_verify: Підтвердити адресу е-пошти\ntype.link: Посилання\noauth2.grant.admin.magazine.all: Переміщати гілки між спільнотами або повністю \n  видаляти спільноти на вашому інстансі.\nfilter.fields.only_names: Тільки назви\ncopy_url_to_fediverse: Скопіювати пряме посилання\nshow_top_bar: Показувати верхню панель\nfavourites: Уподобання\nread_all: Усі прочитані\nnotify_on_new_posts: Нові дописи в будь-якій спільноті, на яку ви підписані\noauth2.grant.admin.instance.settings.read: Переглядати налаштування на вашому \n  інстансі.\nblocked: Заблоковане\npage_width_fixed: Фіксовано\noauth2.grant.entry.report: Скаржитися на будь-яку гілку.\noauth2.grant.moderate.post_comment.all: Модерувати коментарі до дописів у ваших \n  модерованих спільнотах.\nrss: RSS\nremoved_thread_by: видаляє гілку, створену\n2fa.disable: Вимкнути двофакторну автентифікацію\nlocal_and_federated: Місцеві та федеративні\nemail.delete.description: Наступний користувач подав запит на видалення свого \n  облікового запису\nflash_thread_new_error: Не вдалося створити гілку. Щось пішло не так.\nlast_active: Остання активність\npurge_account: Повністю видалити обліковий запис\nshow_profile_subscriptions: Показувати підписки на спільноти\ndelete_account: Видалити обліковий запис\nban: Заборонити\nflash_thread_edit_success: Гілку успішно відредаговано.\nadded_new_comment: Додає новий коментар\nrestored_post_by: відновлює допис, створений\npurge_content: Повністю видалити вміст\noauth2.grant.domain.subscribe: Підписуватись, відписуватись, а також переглядати\n  домени, на які ви підписані.\nsolarized_light: Solarized Світла\nsticky_navbar: Закріплена навігаційна панель\nmagazine_theme_appearance_icon: Власний значок для спільноти. Якщо не вибрано \n  жодного, буде використано значок за замовчуванням.\ntoolbar.ordered_list: Упорядкований список\nkbin_intro_title: Досліджуйте Федіверс\nmicroblog: Мікроблог\nemail_confirm_content: 'Готові активувати свій обліковий запис Mbin? Натисніть на\n  посилання нижче:'\noauth2.grant.moderate.magazine.ban.create: Забороняти користувачів у ваших \n  модерованих спільнотах.\nfirstname: Імʼя\nsidebar_position: Положення бічної панелі\noauth2.grant.admin.user.delete: Видаляти користувачів з вашого інстансу.\noauth.consent.app_requesting_permissions: хоче виконати наступні дії від вашого \n  імені\nno: Ні\noauth2.grant.moderate.post.change_language: Змінювати мову дописів у ваших \n  модерованих спільнотах.\noauth2.grant.moderate.magazine_admin.delete: Видаляти будь-які ваші спільноти.\nunpin: Відкріпити\noauth2.grant.moderate.entry_comment.all: Модерувати коментарі в гілках у ваших \n  модерованих спільнотах.\nflash_magazine_theme_changed_error: Не вдалося оновити зовнішній вигляд \n  спільноти.\nadd_badge: Додати значок\noauth2.grant.admin.magazine.move_entry: Переміщати гілки між спільнотами на \n  вашому інстансі.\noauth2.grant.post_comment.edit: Редагувати ваші наявні коментарі до дописів.\nflash_post_edit_error: Не вдалося відредагувати публікацію. Щось пішло не так.\nall: Усе\ntags: Теги\noauth2.grant.moderate.post.pin: Закріплювати дописи вгорі у ваших модерованих \n  спільнотах.\nchange: Змінити\nvideos: Відео\nicon: Значок\nnew_password: Новий пароль\nnewest: Нові\noauth2.grant.entry.create: Створювати нові гілки.\nfederated_search_only_loggedin: Федерований пошук обмежено, якщо ви не ввійшли\nreason: Причина\npage_width_auto: Автоматично\nset_magazines_bar_desc: введіть назви спільнот, розділивши їх комами\nselect_channel: Показати\ntree_view: Вигляд дерева\nfollowers: Відстежувачі\ntype.photo: Зображення\noauth2.grant.moderate.magazine.reports.action: Приймати і відхиляти скарги у \n  ваших модерованих спільнотах.\nonline: У мережі\nsolarized_dark: Solarized Темна\nactivity: Залученість\noauth2.grant.admin.magazine.purge: Повністю видаляти спільноти на вашому \n  інстансі.\nadd_post: Додати допис\nrelated_tags: Повʼязані теги\nhot: Гарячі\nyour_account_is_not_active: Ваш обліковий запис не було активовано. Перевірте \n  свою е-пошту, щоб отримати інструкції щодо активації облікового запису, або <a\n  href=\"%link_target%\">надішліть запит на новий електронний лист для активації \n  облікового запису.</a>\nimage_alt: Опис зображення\nflash_post_edit_success: Публікація успішно відредагована.\noauth2.grant.user.notification.read: Читати ваші сповіщення, у тому числі \n  сповіщення про повідомлення.\nmeta: Метаінформація\nright: Праворуч\non: Увімк.\nemail: Е-пошта\nfilter.adult.show: Показувати делікатний вміст\nedit: Редагувати\noc: ОВ\nrestore: Відновити\nflash_user_edit_password_error: Не вдалося змінити пароль.\nunfollow: Не відстежувати\ncover: Обкладинка\noauth.consent.allow: Дозволити\nsubscribers: Підписники\noauth2.grant.magazine.block: Блокувати або розблокувати спільноти та переглядати\n  спільноти, які ви заблокували.\nposition_bottom: Кнопка\ntype.magazine: Спільнота\npeople_local: Місцеві\nshow_more: Показати більше\noauth2.grant.magazine.subscribe: Підписуватись, відписуватись, а також \n  переглядати спільноти, на які ви підписалися.\ndown_votes: Невподобання\noauth2.grant.admin.user.all: Забороняти, перевіряти або повністю видаляти \n  користувачів на вашому інстансі.\n2fa.backup_codes.recommendation: Рекомендуємо зберігати копію цих кодів у \n  безпечному місці.\nnotify_on_new_post_comment_reply: Відповіді на ваші коментарі до будь-яких \n  дописів\ntheme: Тема\nlogin_or_email: Імʼя користувача або е-пошта\nfrom_url: З вебадреси\nadd_new_link: Додати нове посилання\nadded: Додано\nyear: Рік\noauth2.grant.post_comment.report: Скаржитися на будь-який коментар до допису.\noauth2.grant.magazine.all: Підписуватись, блокувати, а також переглядати \n  спільноти, на які ви підписалися або заблокували.\nnew_password_repeat: Підтвердити новий пароль\noauth2.grant.vote.general: Голосувати за, проти або поширювати гілку, допис чи \n  коментар.\nrelated_magazines: Схожі спільноти\nappearance: Вигляд\nremember_me: Запамʼятати мене\ncustom_css: Користувацький CSS\noauth2.grant.entry.vote: Голосувати за, проти або поширювати будь-яку гілку.\ndescription: Опис\nreport_issue: Повідомити про помилку\noauth2.grant.moderate.magazine_admin.all: Створювати, редагувати або видаляти \n  ваші спільноти.\ncomment_reply_position_help: Розмістити форму відповіді на коментар вгорі або \n  внизу сторінки. Якщо «Нескінченна прокрутка» ввімкнена, форма завжди \n  відображатиметься вгорі.\nname: Імʼя\nfilter.adult.only: Тільки делікатний вміст\ncurrent_password: Поточний пароль\nin: в\nyes: Так\noauth2.grant.post_comment.vote: Голосувати за, проти або поширювати будь-який \n  коментар до допису.\nkbin_intro_desc: це децентралізована платформа для збору вмісту та ведення \n  мікроблогів, яка діє у мережі Федіверсу.\nfilter.fields.names_and_descriptions: Назви та описи\noauth2.grant.user.oauth_clients.read: Читати дозволи, які ви надали іншим \n  програмам OAuth2.\nadd_moderator: Додати модератора\noauth2.grant.moderate.entry.change_language: Змінювати мову гілок у ваших \n  модерованих спільнотах.\nusername: Імʼя користувача\npassword_confirm_header: Необхідно підтвердити запит на зміну пароля.\noff: Вимк.\nlight: Світла\nterms: Умови використання\ncompact_view: Компактний вигляд\ntype.article: Гілка\nemail_confirm_header: Вітаємо! Необхідно підтвердити вашу адресу е-пошти.\nblock: Блокувати\nrejected: Відхилено\nimage: Зображення\ntable_view: Вигляд таблиці\noauth2.grant.moderate.all: Виконувати будь-яку дію модерації, на яку ви маєте \n  дозвіл, у ваших модерованих спільнотах.\nhelp: Довідка\noauth2.grant.moderate.magazine.ban.all: Управляти заборонами у ваших модерованих\n  спільнотах.\ncreate_new_magazine: Створити нову спільноту\npeople: Люди\noauth2.grant.moderate.magazine.all: Управляти заборонами, скаргами, а також \n  переглядати видалене у ваших модерованих спільнотах.\noauth2.grant.admin.federation.all: Переглядати й оновлювати дефедеровані наразі \n  інстанси.\n12h: 12 годин\ntoolbar.quote: Цитата\noauth2.grant.user.notification.all: Читати й очищати ваші сповіщення.\nsidebar: Бічна панель\nfilters: Фільтри\nenter_your_post: Введіть ваш допис\noauth2.grant.report.general: Скаржитися на гілки, дописи чи коментарі.\noauth2.grant.moderate.magazine.list: Читати список ваших модерованих спільнот.\noauth2.grant.admin.post.purge: Повністю видаляти будь-який допис із вашого \n  інстансу.\nflash_magazine_theme_changed_success: Вдалося оновити зовнішній вигляд \n  спільноти.\noauth2.grant.moderate.magazine.ban.read: Переглядати заборонених користувачів у \n  ваших модерованих спільнотах.\ndelete: Видалити\nadd_new_photo: Додати нове зображення\nflash_thread_pin_success: Гілку успішно закріплено.\nregistration_disabled: Реєстрацію вимкнено\noauth2.grant.user.profile.all: Читати і редагувати ваш профіль.\noauth2.grant.admin.user.ban: Забороняти або допускати користувачів на вашому \n  інстансі.\n1m: 1 місяць\nshow_avatars_on_comments: Показувати аватари в коментарях\noauth2.grant.admin.all: Виконувати будь-які адміністративні дії на вашому \n  інстансі.\nselect_magazine: Оберіть спільноту\ntoolbar.unordered_list: Невпорядкований список\nto: до\nerrors.server404.title: 404 Не знайдено\nexpired_at: Закінчився\nresend_account_activation_email_success: Якщо обліковий запис, повʼязаний із \n  цією е-поштою, існує, ми надішлемо нового електронного листа для активації.\nregister: Зареєструватися\npinned: Закріплене\nerrors.server403.title: 403 Заборонено\nflash_magazine_new_success: Спільноту успішно створено. Тепер ви можете додати \n  новий вміст або дослідити панель адміністрування спільноти.\noauth2.grant.post.report: Скаржитися на будь-який допис.\noauth2.grant.moderate.magazine.reports.read: Читати скарги у ваших модерованих \n  спільнотах.\nignore_magazines_custom_css: Ігнорувати користувацький CSS спільнот\nmercure_enabled: Mercure увімкнено\nflash_thread_edit_error: Не вдалося відредагувати гілку. Щось пішло не так.\ndont_have_account: Немає облікового запису?\nset_magazines_bar: Панель спільнот\nadd_new_article: Додати нову гілку\nrounded_edges: Заокруглені краї\narticle: Гілка\noauth2.grant.entry_comment.create: Створювати нові коментарі в гілках.\n2fa.qr_code_img.alt: QR-код, який дозволяє налаштувати двофакторну \n  автентифікацію для вашого облікового запису\nurl: URL\nsidebars_same_side: Бічні панелі на одній стороні\noauth.consent.deny: Заборонити\noauth2.grant.user.follow: Відстежувати або не відстежувати користувачів, а також\n  читати список користувачів, яких ви відстежуєте.\nflash_user_edit_email_error: Не вдалося змінити електронну пошту.\nflash_post_pin_success: Допис успішно закріплено.\nsend: Надіслати\nactive_users: Активні зараз\npage_width_max: Максимум\nfaq: FAQ\nbanned: Забороняє вас\noauth2.grant.entry_comment.report: Скаржитися на будь-який коментар у гілці.\nmod_log_alert: УВАГА! Журнал модерації може містити неприємний або тривожний \n  вміст, який було видалено модераторами. Будь ласка, будьте обережні.\nmoderation.report.approve_report_confirmation: Ви впевнені, що хочете прийняти \n  цю скаргу?\nadd_new_post: Додати новий допис\nmoderate: Модерувати\n6h: 6 годин\nbody: Основний текст\noauth2.grant.moderate.entry.all: Модерувати гілки у ваших модерованих \n  спільнотах.\nreturn: Повернутися\noauth.consent.title: Форма згоди OAuth2\ncontact: Звʼязок\nopen_url_to_fediverse: Відкрити оригінальну URL-адресу\nfederation_page_allowed_description: Відомі інстанси, з якими ми федеруємо\nbanned_instances: Заборонені інстанси\nedited_thread: Редагує гілку\npage_width: Ширина сторінки\nresend_account_activation_email: Повторно надіслати електронного листа для \n  активації облікового запису\nfederated: Федерований\noauth2.grant.entry_comment.all: Створювати, редагувати або видаляти ваші \n  коментарі в гілках, а також голосувати, поширювати або скаржитися на будь-який\n  коментар у гілці.\nnotifications: Сповіщення\noauth2.grant.entry_comment.delete: Видаляти ваші наявні коментарі в гілках.\npost: Допис\nchange_theme: Змінити тему\ndelete_account_desc: Видалити обліковий запис, залишивши відповіді інших \n  користувачів у створених гілках, дописах і коментарях.\ninstance: Інстанс\ncaptcha_enabled: Капча увімкнена\nperm: Назавжди\nsubject_reported_exists: На цей вміст уже подано скаргу.\nflash_comment_new_success: Коментар успішно створено.\nsubject_reported: На цей вміст подано скаргу.\nreset_check_email_desc2: Якщо ви не отримали електронного листа, перевірте теку \n  зі спамом.\nadd_new_video: Додати нове відео\ndynamic_lists: Рухомі списки\nrules: Правила\ncolumns: Стовпці\noauth2.grant.moderate.entry_comment.set_adult: Позначати коментарі в гілках як \n  «Делікатне» у ваших модерованих спільнотах.\nmagazine_panel_tags_info: Вкажіть, лише якщо ви хочете, щоб вміст із Федіверсу \n  додавався до цієї спільноти на підставі тегів\noauth2.grant.entry.edit: Редагувати ваші наявні гілки.\nmoderated: Модероване\ntoo_many_requests: Перевищено ліміт, будь ласка, спробуйте ще раз пізніше.\nflash_user_settings_general_error: Не вдалося зберегти налаштування користувача.\noauth2.grant.moderate.entry_comment.trash: Видаляти або відновлювати коментарі в\n  гілках у ваших модерованих спільнотах.\nadd_mentions_entries: Автоматично додавати теги згадок у гілках\noauth2.grant.moderate.post.all: Модерувати дописи у ваших модерованих \n  спільнотах.\ncollapse: Згорнути\npreferred_languages: Фільтрувати мови гілок і дописів\nerrors.server500.title: 500 Внутрішня помилка сервера\n2fa.enable: Увімкнути двофакторну автентифікацію\noauth2.grant.entry_comment.edit: Редагувати ваші наявні коментарі в гілках.\nthreads: Гілки\nabout: Про вас\nalphabetically: За алфавітом\nauto_preview: Автоматичний перегляд медіа\nup_votes: Поширення\nlocal: Місцеві\n2fa.user_active_tfa.title: Використовує 2FA\noauth2.grant.moderate.magazine_admin.update: Редагувати правила, опис, статус \n  «Делікатне» або значок будь-якої вашої спільноти.\nremoved_comment_by: видаляє коментар, створений\nowner: Власник\nwrote_message: Пише повідомлення\nnotify_on_new_entry_comment_reply: Відповіді на ваші коментарі в будь-яких \n  гілках\nflash_email_was_sent: Лист успішно відправлено.\noauth2.grant.moderate.entry.trash: Видаляти або відновлювати гілки у ваших \n  модерованих спільнотах.\nreport: Поскаржитися\nactive: Активні\nmod_deleted_your_comment: Модератор видаляє ваш коментар\nstatus: Стан\nnew_email: Нова е-пошта\nshow_subscriptions: Показати підписки\nprivacy_policy: Політика приватності\noauth2.grant.user.oauth_clients.all: Читати і редагувати дозволи, які ви надали \n  іншим програмам OAuth2.\n2fa.code_invalid: Код автентифікації недійсний\nleft: Ліворуч\nmod_log: Журнал модерації\nevents: Події\noauth2.grant.user.profile.read: Читати ваш профіль.\ntoolbar.link: Посилання\noauth2.grant.admin.oauth_clients.read: Переглядати клієнти OAuth2, наявні на \n  вашому інстансі, а також статистику їх використання.\nregistrations_enabled: Реєстрацію увімкнено\ntoolbar.mention: Згадка\nmore: Більше\ntype_search_term: Введіть пошуковий запит\nup_vote: Поширити\noauth2.grant.write.general: Створювати або редагувати будь-які ваші гілки, \n  дописи чи коментарі.\nrelated_entries: Схожі гілки\ntry_again: Спробуйте знову\nsingle_settings: Окреме\noauth2.grant.moderate.post_comment.set_adult: Позначати коментарі до дописів як \n  «Делікатне» у ваших модерованих спільнотах.\nstats: Статистика\noauth2.grant.moderate.post_comment.change_language: Змінювати мову коментарів до\n  дописів у ваших модерованих спільнотах.\nmoderation.report.ban_user_title: Заборонити користувача\nfilter.origin.label: Виберіть походження\n2fa.available_apps: Щоб відсканувати цей QR-код, використовуйте програму \n  двофакторної автентифікації, наприклад %google_authenticator%, %aegis% \n  (Android) чи %raivo% (iOS).\nresend_account_activation_email_question: Неактивний обліковий запис?\nunban_account: Допустити обліковий запис\nrandom_magazines: Випадкові спільноти\nlinks: Посилання\nupload_file: Додати файл\noauth2.grant.admin.user.verify: Перевіряти користувачів на вашому інстансі.\ndark: Темна\nfederation_enabled: Федерацію ввімкнено\nflash_thread_new_success: Гілку успішно створено і тепер її бачать інші \n  користувачі.\ngo_to_search: Перейти до пошуку\nrestored_comment_by: відновлює коментар, створений\ncancel: Скасувати\nchange_email: Змінити е-пошту\ninstances: Інстанси\nrandom_entries: Випадкові гілки\nmarkdown_howto: Як працює редактор?\ngo_to_filters: Перейти до фільтрів\nreputation: Репутація\nflash_comment_edit_success: Коментар успішно оновлено.\nresend_account_activation_email_description: Введіть адресу е-пошти, повʼязану з\n  вашим обліковим записом. Ми надішлемо вам іншого електронного листа для \n  активації.\nreload_to_apply: Перезавантажте сторінку, щоб застосувати зміни\nnotify_on_new_post_reply: Відповіді будь-якого рівня на ваші дописи\noauth2.grant.entry.delete: Видаляти ваші наявні гілки.\nhe_unbanned: допускає\nyour_account_has_been_banned: Ваш обліковий запис заборонено\noauth2.grant.admin.instance.information.edit: Оновлювати на вашому інстансі \n  сторінки «Про інстанс», «FAQ», «Звʼязок», «Умови використання» і «Політика \n  приватності».\noauth2.grant.read.general: Читати весь вміст, до якого ви маєте доступ.\noauth2.grant.domain.all: Підписуватись, блокувати, а також переглядати домени, \n  на які ви підписалися або заблокували.\nreset_password: Скинути пароль\ncommented: Коментовані\ntoolbar.code: Код\nerrors.server500.description: Вибачте, в нас щось пішло не так. Ми працюємо над \n  розвʼязанням цієї проблеми, завітайте пізніше.\ngeneral: Загальні\nsubscriptions: Підписки\nfilter_by_time: Фільтр за часом\noauth.client_not_granted_message_read_permission: Ця програма не отримала \n  дозволу на читання ваших повідомлень.\nadd_mentions_posts: Автоматично додавати теги згадок у дописах\nrestrict_oauth_clients: Дозволити створення клієнта OAuth2 лише адміністраторам\nemail_confirm_title: Необхідно підтвердити вашу адресу е-пошти.\npurge_content_desc: Повністю видалити вміст користувача, включаючи видалення \n  відповідей інших користувачів у створених гілках, дописах і коментарях.\nflash_post_unpin_success: Допис успішно відкріплено.\noauth2.grant.post_comment.delete: Видаляти ваші наявні коментарі до дописів.\nedit_post: Редагувати допис\nno_comments: Немає коментарів\nfederation_page_disallowed_description: Інстанси, з якими ми не федеруємо\nbot_body_content: \"Ласкаво просимо до /kbin Бота! Цей бот відіграє вирішальну роль\n  в активації функціональності ActivityPub у Mbin. Він забезпечує Mbin спілкування\n  та федерацію з іншими інстансами у Федіверсі.\\n\\nActivityPub — це мережевий протокол\n  відкритого стандарту. Він дозволяє децентралізованим платформам соціальних мереж\n  спілкуватися та взаємодіяти одна з одною. Це дозволяє користувачам на різних інстансах\n  (серверах) стежити, взаємодіяти та ділитися вмістом у федеративній соціальній мережі,\n  відомій як Федіверс. Він надає користувачам стандартизований спосіб публікувати\n  вміст, відстежувати інших користувачів та брати участь у соціальних взаємодіях:\n  наприклад, уподобати, поширити чи коментувати гілки або дописи.\"\ncontent: Вміст\njoined: Приєднання\nnotify_on_new_entry: Нові гілки (посилання чи статті) в будь-якій спільноті, на \n  яку ви підписані\nadded_new_thread: Додає нову гілку\noauth2.grant.admin.oauth_clients.revoke: Відкликати доступ до клієнтів OAuth2 на\n  вашому інстансі.\noauth2.grant.admin.instance.settings.edit: Оновлювати налаштування на вашому \n  інстансі.\nusers: Користувачі\nremoved: Видалено модератором\ndomain: Домен\nsearch: Шукати\ngo_to_original_instance: Переглянути на віддаленому інстансі.\noauth2.grant.moderate.entry.set_adult: Позначати гілки як «Делікатне» у ваших \n  модерованих спільнотах.\noauth2.grant.delete.general: Видаляти будь-які ваші гілки, дописи чи коментарі.\nerror: Помилка\noauth2.grant.entry_comment.vote: Голосувати за, проти або поширювати будь-який \n  коментар у гілці.\noauth2.grant.admin.instance.stats: Переглядати статистику вашого інстансу.\noauth2.grant.admin.instance.settings.all: Переглядати або оновлювати \n  налаштування на вашому інстансі.\nlogout: Вийти\noauth2.grant.entry.all: Створювати, редагувати або видаляти ваші гілки, а також \n  голосувати, поширювати або скаржитися на будь-яку гілку.\nreplies: Відповіді\nadd_ban: Заборонити\nnotify_on_new_entry_reply: Коментарі будь-якого рівня у гілках вашого авторства\ndelete_content: Видалити вміст\ndomains: Домени\ntwo_factor_backup: Резервні коди двофакторної автентифікації\nphotos: Зображення\noverview: Огляд\n1y: 1 рік\nclassic_view: Класичний вигляд\nban_account: Заборонити обліковий запис\nflash_user_edit_profile_success: Налаштування профілю користувача успішно \n  збережено.\nsubscription_sort: Сортування підписок\nedit_comment: Зберегти зміни\nexpand: Розгорнути\ngo_to_content: Перейти до вмісту\nmessages: Повідомлення\nmagazine_theme_appearance_background_image: Власне зображення для тла вашої \n  спільноти.\nlogin: Увійти\nunblock: Розблокувати\nshow_profile_followings: Показувати відстежуваних\nweek: Тиждень\nedited_comment: Редагує коментар\nboost: Поширити\noauth2.grant.admin.federation.read: Переглядати список дефедерованих інстансів.\noauth2.grant.moderate.entry_comment.change_language: Змінювати мову коментарів у\n  гілках у ваших модерованих спільнотах.\nmod_remove_your_post: Модератор видаляє ваш допис\noauth2.grant.moderate.magazine_admin.stats: Переглядати вміст, статистику \n  голосів і переглядів ваших спільнот.\npassword_and_2fa: Пароль і 2FA\nflash_user_settings_general_success: Налаштування користувача успішно збережено.\n1d: 1 день\noauth.consent.grant_permissions: Надати дозволи\ninfinite_scroll: Нескінченна прокрутка\nempty: Пусто\nban_expired: Термін заборони —\nchange_language: Змінити мову\nfederated_user_info: Профіль із федерованого сервера, може відображатися не \n  повністю.\noauth2.grant.user.message.create: Надсилати повідомлення іншим користувачам.\noauth2.grant.admin.oauth_clients.all: Переглядати або відкликати клієнти OAuth2,\n  наявні на вашому інстансі.\nflash_thread_delete_success: Гілку успішно видалено.\nemail_confirm_expire: 'Зверніть увагу: термін дії посилання закінчується за годину.'\nbrowsing_one_thread: Ви переглядаєте лише одну гілку в обговоренні! Усі \n  коментарі доступні на сторінці допису.\npeople_federated: Федеровані\nsettings: Налаштування\npages: Сторінки\n2fa.backup-create.label: Створити нові резервні коди автентифікації\nmagazines: Спільноти\noauth2.grant.moderate.magazine.ban.delete: Допускати користувачів у ваших \n  модерованих спільнотах.\n2fa.backup: Ваші резервні коди автентифікації\npending: На розгляді\nadd_media: Додати медіа\nuseful: Корисне\nsubscriptions_in_own_sidebar: Підписки окремо\nrestored_thread_by: відновлює гілку, створену\nchat_view: Вигляд чату\nPassword is invalid: Пароль недійсний.\noauth.client_identifier.invalid: Недійсний ідентифікатор клієнта OAuth!\noauth2.grant.post_comment.all: Створювати, редагувати або видаляти ваші \n  коментарі до дописів, а також голосувати, поширювати або скаржитися на \n  будь-який коментар до допису.\ncontact_email: Е-пошта для звʼязку\nedited_post: Редагує допис\nadd_comment: Додати коментар\noauth2.grant.admin.user.purge: Повністю видаляти користувачів з вашого інстансу.\nadmin_panel: Панель адміністратора\nmonths: Місяці\nupdate_comment: Оновити коментар\ntype.video: Відео\nadded_new_post: Додає новий допис\nall_magazines: Усі спільноти\n2fa.qr_code_link.title: Перейшовши за цим посиланням, ви дозволите вашій \n  платформі зареєструвати цю двофакторну автентифікацію\nenter_your_comment: Введіть ваш коментар\ninfinite_scroll_help: Автоматично завантажувати більше вмісту, коли ви досягнете\n  низу сторінки.\nreject: Відмовити\n2fa.backup-create.help: Ви можете створити нові резервні коди автентифікації; це\n  зробить наявні коди недійсними.\nexpires: Спливає\narticles: Гілки\nfavourite: Уподоба\noauth2.grant.user.notification.delete: Очищати ваші сповіщення.\ntwo_factor_authentication: Двофакторна автентифікація\nreplied_to_your_comment: Відповідає на ваш коментар\nshow_avatars_on_comments_help: Показати/приховати аватари користувачів під час \n  перегляду коментарів до окремої гілки чи допису.\n2fa.verify_authentication_code.label: Введіть код двофакторної автентифікації, \n  щоб підтвердити налаштування\noauth2.grant.post.all: Створювати, редагувати або видаляти ваші мікроблоги, а \n  також голосувати, поширювати або скаржитися на будь-який мікроблог.\nshow_users_avatars: Показувати аватари користувачів\nYour account has been banned: Ваш обліковий запис заборонено.\noauth.consent.app_has_permissions: вже може виконувати наступні дії\neng: АНГЛ\nis_adult: 18+ / делікатне\nemail.delete.title: Запит на видалення облікового запису\nmagazine: Спільнота\nkbin_promo_desc: '%link_start%Клонуйте сховище%link_end% і розбудовуйте Федіверс'\nshare: Поділитися\npurge: Очистити\nadd: Додати\nagree_terms: Погоджуюся з %terms_link_start%Правилами та умовами%terms_link_end%\n  і %policy_link_start%Політикою приватності%policy_link_end%\nremoved_post_by: видаляє допис, створений\noauth2.grant.block.general: Блокувати або розблокувати будь-яку спільноту, домен\n  або користувача, а також переглядати спільноти, домени та користувачів, яких \n  ви заблокували.\nprivacy: Приватність\nrepeat_password: Повторіть пароль\ncreated: Створено\nmoderation.report.reject_report_confirmation: Ви впевнені, що хочете відхилити \n  цю скаргу?\nold_email: Поточна е-пошта\noauth2.grant.post_comment.create: Створювати нові коментарі до дописів.\noauth2.grant.user.block: Блокувати або розблокувати користувачів, а також читати\n  список користувачів, яких ви блокуєте.\noauth2.grant.post.delete: Видаляти ваші наявні дописи.\nflash_register_success: Ласкаво просимо! Ваш обліковий запис зареєстровано. \n  Залишився один крок — перевірте свою поштову скриньку на наявність посилання \n  для активації, яке оживить ваш обліковий запис.\nhe_banned: забороняє\nposts: Дописи\noauth2.grant.subscribe.general: Підписуватись або відстежувати будь-яку \n  спільноту, домен або користувача, а також переглядати спільноти, домени та \n  користувачів, на яких ви підписані.\noauth2.grant.moderate.post_comment.trash: Видаляти або відновлювати коментарі до\n  дописів у ваших модерованих спільнотах.\noauth2.grant.moderate.magazine_admin.moderators: Додавати або видаляти \n  модераторів у будь-якій вашій спільноті.\nflash_magazine_edit_success: Спільноту успішно відредаговано.\nemail_confirm_link_help: Крім того, ви можете скопіювати та вставити наступне у \n  свій браузер\noauth2.grant.admin.entry_comment.purge: Повністю видаляти будь-який коментар у \n  гілці з вашого інстансу.\nrelated_posts: Схожі дописи\nshow_magazines_icons: Показувати значки спільнот\nboosts: Поширення\napprove: Схвалити\ntype.smart_contract: Розумний контракт\ntoolbar.strikethrough: Закреслений\nnote: Примітка\ncomment_reply_position: Розміщення форми для відповіді\nchange_magazine: Змінити спільноту\nflash_email_failed_to_sent: Лист не було відправлено.\noauth2.grant.post.create: Створювати нові дописи.\ntoolbar.image: Зображення\nhomepage: Головна\nabout_instance: Про інстанс\navatar: Аватар\noauth2.grant.domain.block: Блокувати або розблокувати домени та переглядати \n  домени, які ви заблокували.\ncomments: Коментарі\noauth2.grant.admin.instance.all: Переглядати й оновлювати налаштування інстансу \n  або інформацію про нього.\nbadges: Значки\nkbin_promo_title: Створіть свій власний інстанс\nunsubscribe: Відписатися\nflash_user_edit_profile_error: Не вдалося зберегти налаштування профілю.\nfollow: Відстежувати\nshow_all: Показати все\npin: Закріпити\nprofile: Профіль\nnew_email_repeat: Підтвердити нову е-пошту\nsticky_navbar_help: Панель навігації прилипне до верху сторінки, коли ви \n  прокрутите вниз.\ntoolbar.italic: Курсив\nmore_from_domain: Більше з домену\nhide_adult: Приховати делікатний вміст\noauth2.grant.user.profile.edit: Редагувати ваш профіль.\napproved: Схвалено\ncheck_email: Перевірте вашу е-пошту\ndone: Готово\ntokyo_night: Tokyo Night\nposition_top: Зверху\noauth2.grant.admin.post_comment.purge: Повністю видаляти будь-який коментар до \n  допису з вашого інстансу.\nheader_logo: Логотип заголовка\n3h: 3 години\nmagazine_panel: Панель спільноти\noauth2.grant.moderate.magazine_admin.badges: Створювати або видаляти значки з \n  ваших спільнот.\nmenu: Меню\ndefault_theme: Тема за замовчуванням\ntoolbar.emoji: Емодзі\naccount_deletion_button: Видалити обліковий запис\nshow: Показати\nhide: Приховати\nand: та\nbookmarks: Закладки\nbookmark_list_edit: Редагувати\nversion: Версія\nhidden: Приховано\nbookmark_list_create: Створити\ncount: Кількість\ncancel_request: Скасувати запит\nnotification_title_new_comment: Новий коментар\ncomment_not_found: Коментар не знайдено\nsort_by: Сортувати за\nfilter_by_subscription: Фільтрувати по підпискам\nmarked_for_deletion: Позначено для видалення\nremove_media: Видалити медіа\nremove_user_avatar: Видалити аватар\nsubscribe_for_updates: Підпишіться, щоб отримувати оновлення.\nfrom: з\ndisabled: Вимкнено\nenabled: Увімкнено\nmark_as_adult: Позначити як NSFW\nunmark_as_adult: Зняти позначку NSFW\nyour_account_is_not_yet_approved: Ваш обліковий запис ще не затверджено. Ми \n  надішлемо вам електронного листа, щойно адміністратори опрацюють вашу заявку \n  на реєстрацію.\naccount_deletion_title: Видалення облікового запису\naccount_deletion_immediate: Видалити негайно\noauth2.grant.user.bookmark.remove: Видалити закладки\noauth2.grant.user.bookmark_list.edit: Відредагувати ваш список закладок\noauth2.grant.user.bookmark_list.delete: Видалити ваш список закладок\n"
  },
  {
    "path": "translations/messages.zh_Hans.yaml",
    "content": "sidebar_position: 侧边栏位置\nleft: 左侧\nright: 右侧\nfederation: 联邦\nstatus: 状态\non: 开启\noff: 关闭\ninstances: 实例\nupload_file: 上传文件\nfrom_url: 来自网址\nmagazine_panel: 杂志面板\nreject: 拒绝\napprove: 批准\nban: 封禁\nunban: 解禁\nban_hashtag_btn: 封禁标签\nban_hashtag_description: 封禁标签将阻止使用此标签创建帖子，并隐藏现有的带有此标签的帖子。\nunban_hashtag_btn: 解禁标签\nunban_hashtag_description: 解禁标签将允许再次创建带有此标签的帖子。现有的带有此标签的帖子不再被隐藏。\nfilters: 过滤器\napproved: 已批准\nrejected: 已拒绝\nadd_moderator: 添加版主\nadd_badge: 添加徽章\nbans: 封禁\ncreated: 创建\nexpires: 过期\nperm: 永久\nexpired_at: 过期于\nadd_ban: 添加封禁\ntrash: 垃圾箱\nicon: 图标\ndone: 完成\npin: 固定\nunpin: 取消固定\nchange_magazine: 更改杂志\nchange_language: 更改语言\nmark_as_adult: 标记为 NSFW\nunmark_as_adult: 取消标记为 NSFW\nchange: 更改\npinned: 已固定\npreview: 预览\narticle: 主题\nreputation: 声誉\nnote: 备注\nwriting: 写作\nusers: 用户\ncontent: 内容\nweek: 周\nweeks: 周\nmonth: 月\nmonths: 月\nyear: 年\nfederated: 联邦化\nlocal: 本地\nadmin_panel: 管理员面板\ndashboard: 仪表板\ncontact_email: 联系邮箱\nmeta: 元\ninstance: 实例\npages: 页面\nFAQ: 常见问题\ntype_search_term: 输入搜索词\nfederation_enabled: 联邦已启用\nregistrations_enabled: 注册已启用\nregistration_disabled: 注册已禁用\nrestore: 恢复\nadd_mentions_entries: 在主题中添加提及标签\nadd_mentions_posts: 在帖子中添加提及标签\nPassword is invalid: 密码无效。\nYour account is not active: 您的账号未激活。\nYour account has been banned: 您的账号已被禁止。\nfirstname: 名字\nsend: 发送\nactive_users: 活跃用户\nrandom_entries: 随机主题\nrelated_entries: 相关主题\ndelete_account: 删除账号\npurge_account: 清除账号\nban_account: 封禁账号\nunban_account: 解禁账号\nrelated_magazines: 相关杂志\nrandom_magazines: 随机杂志\nmagazine_panel_tags_info: 仅在您希望根据标签将联邦内容包含在此杂志中时提供\nsidebar: 侧边栏\nauto_preview: 自动媒体预览\ndynamic_lists: 动态列表\nbanned_instances: 被封禁实例\nkbin_intro_title: 探索联邦宇宙\nkbin_intro_desc: 是一个去中心化的内容聚合和微博平台，运行在联邦网络中。\nkbin_promo_title: 创建你自己的实例\nkbin_promo_desc: '%link_start%克隆仓库%link_end%并开发联邦宇宙'\ncaptcha_enabled: 已启用验证码\nheader_logo: 页眉徽标\nbrowsing_one_thread: 你现在只浏览了讨论中的一个线索！所有评论都可在帖子页面上查看。\nreturn: 返回\nboost: 转发\nmercure_enabled: Mercure 已启用\ntokyo_night: 东京之夜\npreferred_languages: 过滤线索和帖子的语言\ninfinite_scroll_help: 当你滚动到页面底部时自动加载更多内容。\nsticky_navbar_help: 滚动时导航栏将固定在页面顶部。\nauto_preview_help: 自动展开媒体预览。\nreload_to_apply: 重新加载页面以应用更改\nfilter.origin.label: 选择来源\nfilter.fields.label: 选择要搜索的字段\nfilter.adult.label: 选择是否显示 NSFW 内容\nfilter.adult.hide: 隐藏 NSFW\nfilter.adult.show: 显示 NSFW\nfilter.adult.only: 仅 NSFW\nreports: 举报\nfederated_magazine_info: 此杂志来自一个联合服务器，可能不完整。\ndisconnected_magazine_info: 此杂志未收到更新（最后活动在 %days% 天前）。\nalways_disconnected_magazine_info: 此杂志未收到更新。\nfederated_user_info: 此个人资料来自一个联合服务器，可能不完整。\ntype.video: 视频\ntype.smart_contract: 智能合约\ntype.magazine: 杂志\nthread: 主题\nthreads: 主题\nmicroblog: 微博\npeople: 人\nevents: 事件\nmagazine: 杂志\nmagazines: 杂志\nsearch: 搜索\nadd: 添加\nselect_channel: 选择频道\nlogin: 登录\nsort_by: 排序方式\nactive: 活跃\nnewest: 最新\noldest: 最旧\ncommented: 已评论\nchange_view: 更改视图\nfilter_by_time: 按时间过滤\nfilter_by_type: 按类型过滤\nfilter_by_subscription: 按订阅过滤\nfilter_by_federation: 按联合状态过滤\ncomments_count: '{0}评论|{1}评论|]1,Inf[ 评论'\nsubscribers_count: '{0}订阅者|{1}订阅者|]1,Inf[ 订阅者'\nfollowers_count: '{0}关注者|{1}关注者|]1,Inf[ 关注者'\nmarked_for_deletion: 标记为删除\nmarked_for_deletion_at: 在 %date% 标记为删除\nfavourites: 收藏夹\nfavourite: 最爱\nmore: 更多\navatar: 头像\nadded: 已添加\nup_votes: 转发\ndown_votes: 减少\nno_comments: 没有评论\ncreated_at: 创建于\nowner: 所有者\nsubscribers: 订阅者\nonline: 在线\ncomments: 评论\nposts: 帖子\nreplies: 回复\nmoderators: 管理员\nmod_log: 管理日志\nadd_comment: 添加评论\nadd_post: 添加帖子\nadd_media: 添加媒体\nremove_media: 移除媒体\nmarkdown_howto: 编辑器如何工作？\nenter_your_comment: 输入您的评论\nenter_your_post: 输入您的帖子\nrelated_posts: 相关帖子\nrandom_posts: 随机帖子\nsubscribe_for_updates: 订阅以开始接收更新。\ngo_to_original_instance: 在远程实例上查看\nempty: 空\nsubscribe: 订阅\nunsubscribe: 取消订阅\nfollow: 关注\nunfollow: 取消关注\nreply: 回复\nlogin_or_email: 登录或电子邮件\npassword: 密码\nremember_me: 记住我\ndont_have_account: 没有账号？\nyou_cant_login: 忘记密码？\nalready_have_account: 已经有账号？\nregister: 注册\nreset_password: 重置密码\nshow_more: 显示更多\nto: 到\nin: 在\nfrom: 来自\nusername: 用户名\nemail: 电子邮件\nrepeat_password: 重复密码\nagree_terms: 同意 %terms_link_start%条款和条件%terms_link_end% 和 \n  %policy_link_start%隐私政策%policy_link_end%\nterms: 服务条款\nprivacy_policy: 隐私政策\nabout_instance: 关于\nall_magazines: 所有杂志\nstats: 统计\nfediverse: 联邦宇宙\ncreate_new_magazine: 创建新杂志\nadd_new_article: 添加新主题\nadd_new_link: 添加新链接\nadd_new_photo: 添加新照片\nadd_new_post: 添加新帖子\nadd_new_video: 添加新视频\ncontact: 联系\nfaq: 常见问题\nrss: RSS\nchange_theme: 更改主题\ndownvotes_mode: 点踩模式\nchange_downvotes_mode: 更改点踩模式\ndisabled: 已禁用\nhidden: 已隐藏\nenabled: 已启用\nuseful: 有用\nhelp: 帮助\ncheck_email: 检查您的电子邮件\nreset_check_email_desc: 如果您的电子邮件地址已经关联了一个账号，您应该会很快收到一封包含重置密码链接的电子邮件。该链接将在 \n  %expire% 后过期。\nreset_check_email_desc2: 如果您没有收到电子邮件，请检查您的垃圾邮件文件夹。\ntry_again: 再试一次\nup_vote: 转发\ndown_vote: 减少\nemail_confirm_header: 你好！确认您的电子邮件地址。\nemail_confirm_content: 准备激活您的 Mbin 账号吗？点击下面的链接：\nemail_verify: 确认电子邮件地址\nemail_confirm_expire: 请注意，该链接将在一小时内过期。\nemail_confirm_title: 确认您的电子邮件地址。\nselect_magazine: 选择一本杂志\nadd_new: 添加新内容\nurl: 网址\ntitle: 标题\nbody: 正文\ntags: 标签\ntag: 标签\nbadges: 徽章\nis_adult: 18+ / NSFW\neng: 英语\noc: 原创内容\nimage: 图片\nimage_alt: 图片替代文本\nname: 名称\ndescription: 描述\nrules: 规则\ndomain: 域名\nfollowers: 关注者\nfollowing: 正在关注\nsubscriptions: 订阅\noverview: 概述\ncards: 卡片\ncolumns: 列\nuser: 用户\njoined: 加入时间\nmoderated: 已审核\npeople_local: 本地\npeople_federated: 联邦\nreputation_points: 声望积分\nrelated_tags: 相关标签\ngo_to_content: 转到内容\ngo_to_filters: 转到过滤器\ngo_to_search: 转到搜索\nsubscribed: 已订阅\nall: 全部\nlogout: 登出\nclassic_view: 经典视图\ncompact_view: 紧凑视图\nchat_view: 聊天视图\ntree_view: 树状视图\ntable_view: 表格视图\n3h: 3 小时\n6h: 6 小时\n12h: 12 小时\n1d: 1 天\n1w: 1 周\n1m: 1 个月\n1y: 1 年\nlinks: 链接\narticles: 主题\nphotos: 照片\nvideos: 视频\nshare: 分享\ncopy_url: 复制 Mbin URL\ncopy_url_to_fediverse: 复制原始 URL\nshare_on_fediverse: 在联邦网络上分享\nedit: 编辑\nare_you_sure: 您确定吗？\nmoderate: 审核\nreason: 理由\nedit_entry: 编辑主题\ndelete: 删除\nedit_post: 编辑帖子\nedit_comment: 保存更改\nmenu: 菜单\nsettings: 设置\ngeneral: 常规\nprofile: 个人资料\nblocked: 已屏蔽\nnotifications: 通知\nmessages: 消息\nappearance: 外观\nhomepage: 主页\nhide_adult: 隐藏 NSFW 内容\nfeatured_magazines: 推荐杂志\nprivacy: 隐私\nshow_profile_subscriptions: 显示杂志订阅\nshow_profile_followings: 显示关注的用户\nnotify_on_new_entry_reply: 我撰写的主题中的任何级别评论\nnotify_on_new_entry_comment_reply: 对我在任何主题中的评论的回复\nnotify_on_new_post_reply: 我撰写的帖子中的任何级别回复\nnotify_on_new_post_comment_reply: 对我在任何帖子中的评论的回复\nnotify_on_new_entry: 我订阅的任何杂志中的新主题（链接或文章）\nnotify_on_new_posts: 我订阅的任何杂志中的新帖子\nnotify_on_user_signup: 新注册用户\nsave: 保存\nabout: 关于\nold_email: 当前电子邮件\nnew_email: 新电子邮件\nnew_email_repeat: 确认新电子邮件\ncurrent_password: 当前密码\nnew_password: 新密码\nchange_email: 更改电子邮件\nchange_password: 更改密码\nexpand: 展开\ncollapse: 折叠\ndomains: 域名\nerror: 错误\nvotes: 投票\ntheme: 主题\ndark: 黑暗\nlight: 明亮\nsolarized_light: 太阳能亮色\nsolarized_dark: 太阳能暗色\ndefault_theme: 默认主题\ndefault_theme_auto: 明亮/黑暗（自动检测）\nsolarized_auto: 太阳能（自动检测）\nfont_size: 字体大小\nsize: 大小\nboosts: 转发\nshow_users_avatars: 显示用户头像\nyes: 是\nno: 否\nshow_magazines_icons: 显示杂志图标\nshow_thumbnails: 显示缩略图\nrounded_edges: 圆角边缘\nremoved_thread_by: 已删除主题由\nrestored_thread_by: 已恢复主题由\nremoved_comment_by: 已删除评论由\nrestored_comment_by: 已恢复评论由\nremoved_post_by: 已删除帖子由\nrestored_post_by: 已恢复帖子由\nhe_banned: 封禁\nhe_unbanned: 解封\nread_all: 阅读全部\nshow_all: 显示全部\nflash_register_success: 欢迎加入！您的账号现在已注册。最后一步 - 检查您的收件箱以获取激活链接，使您的账号生效。\nflash_thread_new_success: 主题已成功创建，现在对其他用户可见。\nflash_thread_edit_success: 主题已成功编辑。\nflash_thread_delete_success: 主题已成功删除。\nflash_thread_pin_success: 主题已成功置顶。\nflash_thread_unpin_success: 主题已成功取消置顶。\nflash_magazine_new_success: 杂志已成功创建。您现在可以添加新内容或探索杂志的管理面板。\nflash_magazine_edit_success: 杂志已成功编辑。\nflash_mark_as_adult_success: 帖子已成功标记为 NSFW。\nflash_unmark_as_adult_success: 帖子已成功取消 NSFW 标记。\ntoo_many_requests: 超出限制，请稍后再试。\nset_magazines_bar: 杂志栏\nset_magazines_bar_desc: 在逗号后添加杂志名称\nset_magazines_bar_empty_desc: 如果字段为空，将在栏中显示活动杂志。\nmod_log_alert: 警告 - Modlog 可能包含已被版主删除的不愉快或令人不安的内容。请谨慎处理。\nadded_new_thread: 添加了新主题\nedited_thread: 编辑了主题\nmod_remove_your_thread: 版主已删除您的主题\nadded_new_comment: 添加了新评论\nedited_comment: 编辑了评论\nreplied_to_your_comment: 回复了您的评论\ninfinite_scroll: 无限滚动\nshow_top_bar: 显示顶部栏\nsticky_navbar: 固定导航栏\nsubject_reported: 内容已被举报。\npassword_confirm_header: 确认你的密码更改请求。\nyour_account_is_not_active: 你的账号尚未激活。请检查你的电子邮件以获取账号激活说明，或<a \n  href=\"%link_target%\">请求新的账号激活邮件。</a>\nyour_account_has_been_banned: 你的账号已被禁用\nyour_account_is_not_yet_approved: 你的账号尚未获得批准。管理员处理你的注册请求后，我们将立即发送电子邮件通知你。\ntoolbar.bold: 粗体\ntoolbar.italic: 斜体\ntoolbar.strikethrough: 删除线\ntoolbar.header: 标题\ntoolbar.quote: 引用\ntoolbar.code: 代码\ntoolbar.link: 链接\ntoolbar.image: 图片\ntoolbar.unordered_list: 无序列表\ntoolbar.ordered_list: 有序列表\ntoolbar.mention: 提及\ntoolbar.spoiler: 剧透\nfederation_page_enabled: 联邦页面已启用\nfederation_page_allowed_description: 我们联邦的已知实例\nfederation_page_disallowed_description: 我们不联邦的实例\nfederation_page_dead_title: 失效实例\nfederation_page_dead_description: 连续至少 10 个活动无法送达，且最后一次成功送达和接收已超过一周的实例\nfederated_search_only_loggedin: 未登录时联邦搜索受限\naccount_deletion_title: 账号删除\naccount_deletion_description: 除非您选择立即删除账号，否则您的账号将在 30 天后被删除。要在 30 \n  天内恢复账号，请使用相同的用户凭据登录或联系管理员。\naccount_deletion_button: 删除账号\naccount_deletion_immediate: 立即删除\nmore_from_domain: 更多来自该域名\nerrors.server500.title: 500 内部服务器错误\nerrors.server403.title: 403 禁止访问\nemail_confirm_button_text: 确认您的密码更改请求\nemail_confirm_link_help: 或者您可以复制并粘贴以下链接到浏览器\nemail.delete.title: 用户账号删除请求\nemail.delete.description: 以下用户已请求删除其账号\nresend_account_activation_email_question: 账号不活跃？\nresend_account_activation_email: 重新发送账号激活邮件\nresend_account_activation_email_error: 提交此请求时出现问题。可能没有与该邮箱关联的账号，或者账号已经激活。\nresend_account_activation_email_success: 如果存在与该邮箱关联的账号，我们将发送新的激活邮件。\nresend_account_activation_email_description: 输入与您账号关联的邮箱地址。我们将为您重新发送一封激活邮件。\nignore_magazines_custom_css: 忽略杂志的自定义 CSS\noauth.consent.title: OAuth2 同意表单\noauth.consent.grant_permissions: 授予权限\noauth.consent.app_requesting_permissions: 希望代表您执行以下操作\noauth.consent.app_has_permissions: 已经可以执行以下操作\noauth.consent.to_allow_access: 要允许此访问，请点击下方的\"允许\"按钮\noauth.consent.allow: 允许\noauth.consent.deny: 拒绝\noauth.client_identifier.invalid: 无效的 OAuth 客户端 ID！\noauth.client_not_granted_message_read_permission: 此应用未获得读取您消息的权限。\nrestrict_oauth_clients: 将 OAuth2 客户端创建限制为管理员\nprivate_instance: 强制用户在访问任何内容之前登录\nblock: 屏蔽\nunblock: 取消屏蔽\noauth2.grant.moderate.magazine.ban.delete: 在您管理的杂志中解禁用户。\noauth2.grant.moderate.magazine.list: 读取您管理的杂志列表。\noauth2.grant.moderate.magazine.reports.all: 管理您所管理的杂志中的举报。\noauth2.grant.moderate.magazine.reports.read: 读取您所管理的杂志中的举报。\noauth2.grant.moderate.magazine_admin.create: 创建新杂志。\noauth2.grant.moderate.magazine_admin.delete: 删除您拥有的任何杂志。\noauth2.grant.moderate.magazine_admin.update: 编辑您拥有的杂志的规则、描述、NSFW 状态或图标。\noauth2.grant.moderate.magazine_admin.edit_theme: 编辑您拥有的杂志的自定义 CSS。\noauth2.grant.moderate.magazine_admin.moderators: 添加或移除您拥有的杂志的版主。\noauth2.grant.moderate.magazine_admin.badges: 在您拥有的杂志中创建或移除徽章。\noauth2.grant.moderate.magazine_admin.tags: 在您拥有的杂志中创建或移除标签。\noauth2.grant.moderate.magazine_admin.stats: 查看您拥有的杂志的内容、投票和统计数据。\noauth2.grant.admin.all: 对您的实例执行任何管理操作。\noauth2.grant.admin.entry.purge: 完全删除您实例中的任何主题。\noauth2.grant.read.general: 阅读您有权访问的所有内容。\noauth2.grant.write.general: 创建或编辑您的任何主题、帖子或评论。\noauth2.grant.delete.general: 删除您的任何主题、帖子或评论。\noauth2.grant.report.general: 举报主题、帖子或评论。\noauth2.grant.vote.general: 对主题、帖子或评论进行点赞、转发或点踩。\noauth2.grant.subscribe.general: 订阅或关注任何杂志、域名或用户，并查看您订阅的杂志、域名和用户。\noauth2.grant.block.general: 屏蔽或取消屏蔽任何杂志、域名或用户，并查看您屏蔽的杂志、域名和用户。\noauth2.grant.domain.all: 订阅或屏蔽域名，并查看您订阅或屏蔽的域名。\noauth2.grant.domain.subscribe: 订阅或取消订阅域名，并查看您订阅的域名。\noauth2.grant.domain.block: 屏蔽或取消屏蔽域名，并查看您屏蔽的域名。\noauth2.grant.entry.all: 创建、编辑或删除您的主题，并对任何主题进行投票、转发或举报。\noauth2.grant.entry.create: 创建新主题。\noauth2.grant.entry.edit: 编辑您现有的主题。\noauth2.grant.entry.delete: 删除您现有的主题。\noauth2.grant.entry.vote: 对任何主题进行点赞、转发或点踩。\noauth2.grant.entry.report: 举报任何主题。\noauth2.grant.entry_comment.all: 在主题中创建、编辑或删除您的评论，并对主题中的任何评论进行投票、转发或举报。\noauth2.grant.entry_comment.create: 在主题中创建新评论。\noauth2.grant.entry_comment.edit: 编辑您在主题中现有的评论。\noauth2.grant.entry_comment.delete: 删除您在主题中现有的评论。\noauth2.grant.entry_comment.vote: 对主题中的任何评论进行点赞、转发或点踩。\noauth2.grant.entry_comment.report: 举报主题中的任何评论。\noauth2.grant.magazine.all: 订阅或屏蔽杂志，并查看您订阅或屏蔽的杂志。\noauth2.grant.magazine.subscribe: 订阅或取消订阅杂志，并查看您订阅的杂志。\noauth2.grant.magazine.block: 屏蔽或取消屏蔽杂志，并查看您屏蔽的杂志。\noauth2.grant.post.all: 创建、编辑或删除您的微博，并对任何微博进行投票、转发或举报。\noauth2.grant.post.create: 创建新帖。\noauth2.grant.post.edit: 编辑您现有的帖子。\noauth2.grant.post.delete: 删除您现有的帖子。\noauth2.grant.post.report: 举报任何帖子。\noauth2.grant.post_comment.all: 创建、编辑或删除您在帖子上的评论，并对任何评论进行投票、转发或举报。\noauth2.grant.post_comment.create: 在帖子上创建新评论。\noauth2.grant.post_comment.edit: 编辑您在帖子上的现有评论。\noauth2.grant.post_comment.delete: 删除您在帖子上的现有评论。\noauth2.grant.post_comment.vote: 对帖子中的任何评论进行点赞、转发或点踩。\noauth2.grant.post_comment.report: 举报帖子上的任何评论。\noauth2.grant.user.all: \n  阅读和编辑您的个人资料、消息或通知；阅读和编辑您授予其他应用的权限；关注或屏蔽其他用户；查看您关注或屏蔽的用户列表。\noauth2.grant.user.profile.all: 阅读和编辑您的个人资料。\noauth2.grant.user.profile.read: 阅读您的个人资料。\noauth2.grant.user.profile.edit: 编辑您的个人资料。\noauth2.grant.user.message.all: 阅读您的消息并向其他用户发送消息。\noauth2.grant.user.message.read: 阅读您的消息。\noauth2.grant.user.message.create: 向其他用户发送消息。\noauth2.grant.user.notification.all: 阅读和清除您的通知。\noauth2.grant.user.notification.read: 阅读您的通知，包括消息通知。\noauth2.grant.user.notification.delete: 清除您的通知。\noauth2.grant.user.oauth_clients.all: 阅读和编辑您授予其他 OAuth2 应用的权限。\noauth2.grant.user.oauth_clients.read: 阅读您授予其他 OAuth2 应用的权限。\noauth2.grant.user.oauth_clients.edit: 编辑您授予其他 OAuth2 应用的权限。\noauth2.grant.user.follow: 关注或取消关注用户，并阅读您关注的用户列表。\noauth2.grant.user.block: 屏蔽或取消屏蔽用户，并阅读您屏蔽的用户列表。\noauth2.grant.moderate.all: 在您管理的杂志中执行您有权限执行的任何管理操作。\noauth2.grant.moderate.entry.all: 管理您管理的杂志中的主题。\noauth2.grant.moderate.entry.change_language: 更改您管理的杂志中主题的语言。\noauth2.grant.moderate.entry.pin: 将主题固定在您管理的杂志顶部。\noauth2.grant.moderate.entry.set_adult: 在您管理的杂志中将主题标记为 NSFW。\noauth2.grant.moderate.entry.trash: 在您管理的杂志中删除或恢复主题。\noauth2.grant.moderate.entry_comment.all: 管理您管理的杂志中的评论。\noauth2.grant.moderate.entry_comment.change_language: 更改您管理的杂志中评论的语言。\noauth2.grant.moderate.entry_comment.set_adult: 在您管理的杂志中将评论标记为 NSFW。\noauth2.grant.moderate.entry_comment.trash: 在您管理的杂志中删除或恢复评论。\noauth2.grant.moderate.post.all: 管理您管理的杂志中的帖子。\noauth2.grant.moderate.post.change_language: 更改您管理的杂志中帖子的语言。\noauth2.grant.moderate.post.set_adult: 在您管理的杂志中将帖子标记为 NSFW。\noauth2.grant.moderate.post.trash: 在您管理的杂志中删除或恢复帖子。\noauth2.grant.moderate.post_comment.all: 在您管理的杂志中审核帖子上的评论。\noauth2.grant.moderate.post_comment.change_language: 更改您管理的杂志中帖子上的评论语言。\noauth2.grant.moderate.post_comment.set_adult: 在您管理的杂志中将帖子上的评论标记为 NSFW。\noauth2.grant.moderate.post_comment.trash: 在您管理的杂志中删除或恢复帖子上的评论。\noauth2.grant.moderate.magazine.ban.all: 管理您管理的杂志中的被封禁用户。\noauth2.grant.moderate.magazine.ban.read: 查看您管理的杂志中的被封禁用户。\noauth2.grant.moderate.magazine.ban.create: 在您管理的杂志中封禁用户。\noauth2.grant.admin.entry_comment.purge: 从您的实例中完全删除线程中的任何评论。\noauth2.grant.admin.post.purge: 从您的实例中完全删除任何帖子。\noauth2.grant.admin.post_comment.purge: 从您的实例中完全删除帖子上的任何评论。\noauth2.grant.admin.magazine.all: 在您的实例中在杂志之间移动线程或完全删除杂志。\noauth2.grant.admin.magazine.move_entry: 在您的实例中在杂志之间移动线程。\noauth2.grant.admin.magazine.purge: 在您的实例中完全删除杂志。\noauth2.grant.admin.user.all: 在您的实例中封禁、验证或完全删除用户。\noauth2.grant.admin.user.ban: 在您的实例中封禁或解禁用户。\noauth2.grant.admin.user.verify: 在您的实例中验证用户。\noauth2.grant.admin.user.delete: 从您的实例中删除用户。\noauth2.grant.admin.user.purge: 从您的实例中完全删除用户。\noauth2.grant.admin.instance.all: 查看和更新实例设置或信息。\noauth2.grant.admin.instance.stats: 查看您的实例统计。\noauth2.grant.admin.instance.settings.all: 查看或更新您的实例设置。\noauth2.grant.admin.instance.settings.read: 查看您的实例设置。\noauth2.grant.admin.instance.settings.edit: 更新您的实例设置。\noauth2.grant.admin.instance.information.edit: 更新关于、常见问题、联系、服务条款和隐私政策页面。\noauth2.grant.admin.federation.all: 查看和更新当前去联邦的实例。\noauth2.grant.admin.federation.read: 查看去联邦实例列表。\noauth2.grant.admin.federation.update: 向去联邦实例列表中添加或删除实例。\noauth2.grant.admin.oauth_clients.all: 查看或撤销您实例上存在的 OAuth2 客户端。\noauth2.grant.admin.oauth_clients.read: 查看您实例上存在的 OAuth2 客户端及其使用统计。\noauth2.grant.admin.oauth_clients.revoke: 撤销对您实例上 OAuth2 客户端的访问。\nlast_active: 最后活跃\nflash_post_pin_success: 帖子已成功置顶。\nflash_post_unpin_success: 帖子已成功取消置顶。\ncomment_reply_position_help: 在页面顶部或底部显示评论回复表单。启用\"无限滚动\"时，位置将始终显示在顶部。\nshow_avatars_on_comments: 显示评论头像\nsingle_settings: 单个\nupdate_comment: 更新评论\nshow_avatars_on_comments_help: 在查看单个线程或帖子的评论时显示/隐藏用户头像。\ncomment_reply_position: 评论回复位置\nmagazine_theme_appearance_custom_css: 自定义 CSS，将在浏览杂志内容时应用。\nmagazine_theme_appearance_icon: 杂志的自定义图标。如果未选择，将使用默认图标。\nmagazine_theme_appearance_background_image: 自定义背景图像，将在浏览杂志内容时应用。\nmoderation.report.ban_user_description: 您是否要封禁创建此内容的用户 (%username%) 访问此杂志？\nsubject_reported_exists: 此内容已被举报。\nmoderation.report.ban_user_title: 封禁用户\noauth2.grant.moderate.post.pin: 将帖子置顶到您管理的杂志顶部。\ndelete_content: 删除内容\npurge_content: 清除内容\ndelete_content_desc: 删除用户的内容，同时保留其他用户在创建的主题、帖子和评论中的回复。\npurge_content_desc: 完全清除用户的内容，包括删除其他用户在创建的主题、帖子和评论中的回复。\ndelete_account_desc: 删除账号，包括其他用户在创建的主题、帖子和评论中的回复。\nschedule_delete_account: 定时删除\nschedule_delete_account_desc: 安排在 30 天内删除此账号。这将隐藏用户及其内容，并阻止用户登录。\nremove_schedule_delete_account: 取消定时删除\nremove_schedule_delete_account_desc: 取消定时删除。所有内容将重新可用，用户将能够登录。\ntwo_factor_authentication: 双重认证\ntwo_factor_backup: 双重认证备份码\n2fa.authentication_code.label: 认证码\n2fa.verify: 验证\n2fa.code_invalid: 认证码无效\n2fa.setup_error: 为账号启用双重认证时出错\n2fa.enable: 设置双重认证\n2fa.disable: 禁用双重认证\n2fa.backup: 您的双重认证备份码\n2fa.backup-create.help: 您可以创建新的备份认证码；这样做将使现有的码无效。\n2fa.backup-create.label: 创建新的备份认证码\n2fa.remove: 移除双重认证\n2fa.add: 添加到我的账号\n2fa.verify_authentication_code.label: 输入双重认证码以验证设置\n2fa.qr_code_img.alt: 允许为您的账号设置双重认证的二维码\n2fa.qr_code_link.title: 访问此链接可能允许您的平台注册此双重认证\n2fa.user_active_tfa.title: 用户已启用双重认证\n2fa.available_apps: 使用双重认证应用程序，如 %google_authenticator%、%aegis%（Android）或 \n  %raivo%（iOS）扫描二维码。\n2fa.backup_codes.help: \n  当您没有双重认证设备或应用程序时，可以使用这些码。这些码将<strong>不会再次显示</strong>，并且每个码<strong>仅可使用一次</strong>。\n2fa.backup_codes.recommendation: 建议您将它们的副本保存在安全的地方。\ncancel: 取消\npassword_and_2fa: 密码和双重认证\nflash_account_settings_changed: 您的账号设置已成功更改。您需要重新登录。\nshow_subscriptions: 显示订阅\nsubscription_sort: 排序\nalphabetically: 按字母顺序\nsubscriptions_in_own_sidebar: 在单独的侧边栏中\nsidebars_same_side: 侧边栏在同一侧\nsubscription_sidebar_pop_out_right: 移动到右侧单独的侧边栏\nsubscription_sidebar_pop_out_left: 移动到左侧单独的侧边栏\nsubscription_sidebar_pop_in: 将订阅移动到内联面板\nsubscription_panel_large: 大面板\nsubscription_header: 已订阅的杂志\nclose: 关闭\nposition_bottom: 底部\nposition_top: 顶部\npending: 待处理\nflash_thread_new_error: 无法创建主题。出现了一些问题。\nflash_thread_tag_banned_error: 无法创建主题。内容不被允许。\nflash_image_download_too_large_error: 无法创建图像，图像过大（最大尺寸 %bytes%）\nflash_email_was_sent: 邮件已成功发送。\nflash_email_failed_to_sent: 邮件无法发送。\nflash_post_new_success: 帖子已成功创建。\nflash_post_new_error: 无法创建帖子。出现了一些问题。\nflash_magazine_theme_changed_success: 已成功更新杂志外观。\nflash_magazine_theme_changed_error: 更新杂志外观失败。\nflash_comment_new_success: 评论已成功创建。\nflash_comment_edit_success: 评论已成功更新。\nflash_comment_new_error: 无法创建评论。出现了一些问题。\nflash_comment_edit_error: 无法编辑评论。出现了一些问题。\nflash_user_settings_general_success: 用户设置已成功保存。\nflash_user_settings_general_error: 保存用户设置失败。\nflash_user_edit_profile_error: 保存个人资料设置失败。\nflash_user_edit_profile_success: 用户个人资料设置已成功保存。\nflash_user_edit_email_error: 更改邮箱失败。\nflash_user_edit_password_error: 更改密码失败。\nflash_thread_edit_error: 编辑主题失败。出现了一些问题。\nflash_post_edit_error: 编辑帖子失败。出现了一些问题。\nflash_post_edit_success: 帖子已成功编辑。\npage_width: 页面宽度\npage_width_max: 最大\npage_width_auto: 自动\npage_width_fixed: 固定\nfilter_labels: 筛选标签\nauto: 自动\nopen_url_to_fediverse: 打开原始网址\nchange_my_avatar: 更改我的头像\nchange_my_cover: 更改我的封面\nedit_my_profile: 编辑我的个人资料\naccount_settings_changed: 您的账号设置已成功更改。您需要重新登录。\nmagazine_deletion: 杂志删除\ndelete_magazine: 删除杂志\nrestore_magazine: 恢复杂志\npurge_magazine: 清除杂志\nmagazine_is_deleted: 杂志已删除。您可以在 30 天内<a href=\"%link_target%\">恢复</a>。\nsuspend_account: 暂停账号\nunsuspend_account: 解除账号暂停\naccount_suspended: 账号已被暂停。\naccount_unsuspended: 账号已解除暂停。\ndeletion: 删除\nuser_suspend_desc: 暂停您的账号会隐藏您在实例上的内容，但不会永久删除，并且您可以随时恢复。\naccount_banned: 账号已被封禁。\naccount_unbanned: 账号已解除封禁。\nremove_subscriptions: 删除订阅\napply_for_moderator: 申请成为版主\nrequest_magazine_ownership: 请求杂志所有权\ncancel_request: 取消请求\nabandoned: 已放弃\nownership_requests: 所有权请求\naccept: 接受\nmoderator_requests: 版主请求\naction: 操作\nuser_badge_op: 楼主\nuser_badge_admin: 管理员\nuser_badge_global_moderator: 全局版主\nuser_badge_moderator: 版主\nuser_badge_bot: 机器人\nannouncement: 公告\nkeywords: 关键词\ndeleted_by_moderator: 线程、帖子或评论已被版主删除\ndeleted_by_author: 线程、帖子或评论已被作者删除\nsensitive_warning: 敏感内容\nsensitive_toggle: 切换敏感内容的可见性\nsensitive_show: 点击显示\nsensitive_hide: 点击隐藏\ndetails: 详情\nspoiler: 剧透\nall_time: 所有时间\nshow: 显示\nhide: 隐藏\nedited: 已编辑\nsso_registrations_enabled: 已启用 SSO 注册\nsso_registrations_enabled.error: 当前禁用与第三方身份管理器的新账号注册。\nsso_only_mode: 仅限制登录和注册为 SSO 方法\nrelated_entry: 相关\nrestrict_magazine_creation: 仅限管理员和全球版主创建本地杂志\nsso_show_first: 在登录和注册页面上优先显示 SSO\ncontinue_with: 继续使用\nreported_user: 被举报用户\nreporting_user: 举报用户\nreported: 已举报\nreport_subject: 主题\nown_report_rejected: 您的举报已被拒绝\nown_report_accepted: 您的举报已被接受\nown_content_reported_accepted: 您的内容的举报已被接受。\nreport_accepted: 举报已被接受\nopen_report: 打开举报\nback: 返回\nmagazine_log_mod_added: 已添加一名版主\nmagazine_log_mod_removed: 已移除一名版主\nmagazine_log_entry_pinned: 固定条目\nmagazine_log_entry_unpinned: 已移除固定条目\nlast_updated: 最后更新\nand: 和\ndirect_message: 直接消息\nmanually_approves_followers: 手动批准关注者\nregister_push_notifications_button: 注册推送通知\nunregister_push_notifications_button: 移除推送注册\ntest_push_notifications_button: 测试推送通知\ntest_push_message: 你好，世界！\nnotification_title_removed_comment: 评论已被移除\nnotification_title_edited_comment: 评论已被编辑\nnotification_title_mention: 您被提及\nnotification_title_new_reply: 新回复\nnotification_title_new_thread: 新线程\nnotification_title_removed_thread: 线程已被移除\nnotification_title_edited_thread: 线程已被编辑\nnotification_title_ban: 您已被封禁\nnotification_title_message: 新直接消息\nnotification_title_removed_post: 帖子已被移除\nnotification_title_edited_post: 帖子已被编辑\nnotification_title_new_signup: 新用户已注册\nnotification_body_new_signup: 用户 %u% 已注册。\nnotification_body2_new_signup_approval: 您需要在他们登录之前批准请求\nshow_related_magazines: 显示随机杂志\nshow_related_entries: 显示随机线程\nshow_related_posts: 显示随机帖子\nshow_active_users: 显示活跃用户\nnotification_title_new_report: 已创建新举报\nmagazine_posting_restricted_to_mods_warning: 只有版主可以在此杂志中创建线程\nflash_posting_restricted_error: 在此杂志中创建线程仅限版主，而您不是版主\nserver_software: 服务器软件\nversion: 版本\nlast_successful_deliver: 最后成功送达\nlast_successful_receive: 最后成功接收\nlast_failed_contact: 最后失败联系\nmagazine_posting_restricted_to_mods: 限制线程创建为版主\nnew_user_description: 此用户是新用户（活跃时间少于 %days% 天）\nnew_magazine_description: 此杂志是新杂志（活跃时间少于 %days% 天）\nadmin_users_active: 活跃\nadmin_users_inactive: 非活跃\nadmin_users_suspended: 已暂停\nadmin_users_banned: 已封禁\nuser_verify: 激活账号\nmax_image_size: 最大文件大小\ncomment_not_found: 评论未找到\nbookmark_add_to_list: 将书签添加到 %list%\nbookmark_remove_from_list: 从 %list% 移除书签\nbookmark_remove_all: 移除所有书签\nbookmark_add_to_default_list: 将书签添加到默认列表\nbookmark_lists: 书签列表\nbookmarks: 书签\nbookmarks_list: 在 %list% 中的书签\ncount: 计数\nis_default: 是否默认\nbookmark_list_create: 创建\nbookmark_list_create_placeholder: 输入名称...\nbookmark_list_create_label: 列表名称\nbookmarks_list_edit: 编辑书签列表\nbookmark_list_edit: 编辑\nbookmark_list_selected_list: 选定列表\ntable_of_contents: 目录\nsearch_type_all: 主题 + 微博\nsearch_type_entry: 主题\nsearch_type_post: 微博\nselect_user: 选择用户\nnew_users_need_approval: 新用户必须经过管理员批准才能登录。\nsignup_requests: 注册请求\napplication_text: 申请文本\nsignup_requests_header: 注册请求\nsignup_requests_paragraph: 这些用户希望加入您的服务器。在您批准他们的注册请求之前，他们无法登录。\nflash_application_info: 管理员需要批准您的账号才能登录。您的注册请求处理完毕后，您将收到一封电子邮件。\nemail_application_approved_title: 您的注册请求已被批准\nemail_application_approved_body: 您的注册请求已被服务器管理员批准。您现在可以在 <a \n  href=\"%link%\">%siteName%</a> 登录服务器。\nemail_application_rejected_title: 您的注册请求已被拒绝\nemail_application_rejected_body: 感谢您的关注，但我们遗憾地通知您，您的注册请求已被拒绝。\nemail_application_pending: 您的账号需要管理员批准才能登录。\nemail_verification_pending: 您必须验证您的电子邮件地址才能登录。\nhot: 热门\ntop: 最佳\nbot_body_content: \"欢迎使用 Mbin 代理！此代理在 Mbin 中启用 ActivityPub 功能方面起着至关重要的作用。它确保 Mbin 可以与联邦宇宙中的其他实例通信和联邦。\\n\\\n  \\ \\nActivityPub 是一个开放标准协议，允许去中心化的社交网络平台相互通信和交互。它使不同实例（服务器）上的用户能够关注、交互和分享跨联邦社交网络（称为联邦宇宙）的内容。它为用户提供了一种标准化的方式来发布内容、关注其他用户，并进行社交互动，如对线索或帖子进行点赞、分享和评论。\"\nmoderation.report.reject_report_confirmation: 您确定要拒绝此举报吗？\nreport: 举报\nreport_issue: 举报问题\noauth2.grant.moderate.magazine.all: 管理封禁、举报并查看您管理的杂志中的已删除项目。\nmoderation.report.approve_report_title: 批准举报\nmoderation.report.reject_report_title: 拒绝举报\nmoderation.report.approve_report_confirmation: 您确定要批准此举报吗？\ntype.link: 链接\ntype.article: 主题\ntype.photo: 照片\nactivity: 活动\ncover: 封面\ncards_view: 卡片视图\nnew_password_repeat: 确认新密码\nmod_deleted_your_comment: 版主已删除您的评论\nadded_new_post: 添加了新帖子\nedited_post: 编辑了帖子\nmod_remove_your_post: 版主已删除您的帖子\nadded_new_reply: 添加了新回复\nwrote_message: 写了一条消息\nbanned: 已封禁您\nremoved: 已被版主删除\ndeleted: 已被作者删除\nmentioned_you: 提到您\ncomment: 评论\npost: 帖子\nban_expired: 封禁已过期\npurge: 清除\nsend_message: 发送直接消息\nmessage: 消息\nlocal_and_federated: 本地和联邦\nfilter.fields.only_names: 仅名称\nfilter.fields.names_and_descriptions: 名称和描述\nkbin_bot: Mbin 代理\nerrors.server429.title: 429 请求过多\nerrors.server404.title: 404 未找到\ncustom_css: 自定义 CSS\nerrors.server500.description: \n  抱歉，我们这边出现了一些问题。如果您持续看到此错误，请尝试联系实例所有者。如果此实例完全无法工作，您可以在问题解决期间查看 %link_start%其他 \n  Mbin 实例%link_end%。\noauth2.grant.moderate.magazine.trash.read: 查看您管理的杂志中的已删除内容。\noauth2.grant.moderate.magazine_admin.all: 创建、编辑或删除您拥有的杂志。\noauth2.grant.moderate.magazine.reports.action: 接受或拒绝您管理的杂志中的举报。\noauth2.grant.post.vote: 对任何帖子进行点赞、转发或点踩。\naccount_is_suspended: 用户账号已被暂停。\nremove_following: 取消关注\ncake_day: 蛋糕日\nsomeone: 某人\nnotification_title_new_comment: 新评论\nnotification_title_new_post: 新帖子\nbookmark_list_is_default: 是否默认列表\nbookmark_list_make_default: 设为默认\n"
  },
  {
    "path": "translations/messages.zh_TW.yaml",
    "content": "type.photo: 圖片\ntype.video: 影片\ntype.smart_contract: 智能契約\ntype.magazine: 刊版\ntype.article: 帖子\nthread: 帖子\nthreads: 帖子\nmicroblog: 微鋪\npeople: 人\nevents: 事件\nmagazines: 刊版\nsearch: 搜尋\nadd: 發表\nselect_channel: 選擇頻道\nlogin: 登入\ntop: 最佳\nhot: 熱門\nactive: 活躍\nnewest: 最新\noldest: 最舊\ncommented: 有帖子\nchange_view: 改變版面\nfilter_by_type: 按類型篩選\ncomments_count: '{0}評論|{1}評論|]1,Inf[ 評論'\nfavourites: 喜歡的\nfavourite: 喜歡\nmore: 更多\navatar: 頭像\nadded: 已發表\nup_votes: 推\ndown_votes: 討厭\nno_comments: 沒有留言\ntype.link: 連結\nmagazine: 刊版\nfilter_by_time: 按時間篩選\nonline: 在線\ncomments: 留言\nposts: 鋪文\nreplies: 回覆\nmoderators: 管理員\nmod_log: 管理誌\nadd_comment: 發表留言\nadd_post: 發表鋪文\nadd_media: 加入媒體\nmarkdown_howto: 如何使用編輯器？\nenter_your_post: 請撰寫鋪文\nenter_your_comment: 請撰寫留言\nactivity: 活動\nrelated_posts: 相關鋪文\nrandom_posts: 隨緣看鋪\nfederated_magazine_info: 該刊版來自其他聯邦的伺服器，未必完整。\nfederated_user_info: 此用戶來自其他聯邦的伺服器，未必完整。\ngo_to_original_instance: 到原來的實體去看更多內容。\nempty: 空\nsubscribe: 訂閲\nunsubscribe: 取消訂閱\nfollow: 追蹤\nunfollow: 取消追蹤\nreply: 回覆\nlogin_or_email: 用戶名稱或電子郵件地址\npassword: 密碼\nremember_me: 記住我\ndont_have_account: 沒有帳號嗎？\nyou_cant_login: 忘記密碼嗎？\nalready_have_account: 已有帳號了嗎？\nregister: 註冊\nreset_password: 重設密碼\nshow_more: 顯示更多\nusername: 用戶名稱\nemail: 電子郵件地址\nrepeat_password: 重複輸入密碼\nterms: 服務條款\nprivacy_policy: 隱私政策\nabout_instance: 關於\nall_magazines: 所有刊版\nstats: 統計\nfediverse: 聯邦宇宙\ncreate_new_magazine: 立刊\nadd_new_post: 新鋪文\nadd_new_article: 新帖子\nadd_new_photo: 新圖片\nadd_new_link: 新連結\ncontact: 聯繫\nfaq: 常見問題\nrss: RSS\nuseful: 常用\nhelp: 說明\nadd_new_video: 新影片\ncheck_email: 請查看您的電子郵件\nreset_check_email_desc2: 若您沒有收到電子郵件，請查看是否被歸類為垃圾郵件了。\ntry_again: 再試一次\nup_vote: 推\ndown_vote: 討厭\nemail_confirm_header: 您好！請確認你的電子郵件地址。\nemail_confirm_title: 確認您的電子郵件地址。\nselect_magazine: 請選擇刊版\nadd_new: 新增\nurl: 網址\ntitle: 標題\ntags: 標籤\nbadges: 勳章\nis_adult: 成人內容\neng: 英文\noc: 原創\nimage: 圖片\nname: 名稱\nrules: 規則\ndomain: 網域\nfollowers: 追蹤者\nfollowing: 追蹤中\nsubscriptions: 訂閲項目\nuser: 用戶\njoined: 加入於\npeople_federated: 聯邦的\nsubscribed: 已訂閲\nall: 全部\nlogout: 登出\n3h: 3小時\n6h: 6小時\n12h: 12小時\n1d: 1天\n1w: 1星期\n1y: 1年\nlinks: 連結\nphotos: 圖片\nvideos: 影片\nreport: 回報\nedit: 編輯\nmoderate: 管理\nreason: 理由\nedit_post: 編輯鋪文\nedit_comment: 編輯留言\nsettings: 設定\ngeneral: 整體\nprofile: 用戶檔\nblocked: 封鎖項目\nnotifications: 通知\nmessages: 訊息\nappearance: 外觀\nhide_adult: 隱藏成人內容\nfeatured_magazines: 推薦的刊版\nprivacy: 隱私\nshow_profile_followings: 顯示正在追蹤的用戶\nnotify_on_new_post_reply: 有人回覆我的鋪文時\nsave: 儲存\nabout: 關於\nold_email: 目前的電子郵件地址\ncurrent_password: 目前的密碼\nnew_password: 新密碼\nnew_password_repeat: 確認新密碼\nchange_email: 更改電子郵件地址\nchange_password: 更改密碼\nexpand: 展開\ncollapse: 收起\ndomains: 網域\nerror: 錯誤\nvotes: 票數\ndark: 黑暗\nlight: 明亮\nfont_size: 字型大小\nsize: 大\nbody: 內文\ndescription: 描述\n1m: 1個月\nshare: 分享\ndelete: 刪除\nnew_email: 新的電子郵件地址\nnew_email_repeat: 確認新的電子郵件地址\ntheme: 主題\nmoderated: 管理的刊數\nreports: 回報\nshow_profile_subscriptions: 顯示已訂閱的刊版\ncreated_at: 創於\nowner: 所有者\nsubscribers: 訂閲者\nchange_theme: 更換主題\ncards: 卡片\ncolumns: 列\nreputation_points: 聲望值\nrelated_tags: 相關標籤\ngo_to_content: 進至內容\ngo_to_filters: 到篩選器\ngo_to_search: 進至搜尋\nchat_view: 對話版面\ntree_view: 樹狀版面\ntable_view: 表格版面\ncards_view: 卡片版面\ncopy_url: 複製 Mbin 網址\ncopy_url_to_fediverse: 複製聯邦網址\nshare_on_fediverse: 分享至聯邦宇宙\nare_you_sure: 您確定嗎？\narticles: 帖子\nnotify_on_new_entry_reply: 有人回覆我的帖子時\nnotify_on_new_entry_comment_reply: 有人在帖子回覆我的留言時\nnotify_on_new_entry: 訂閱的刊版有新帖子時\nremoved_thread_by: 移除了此用戶的帖子：\nrestored_thread_by: 還原了此用戶的帖子：\nremoved_comment_by: 移除了此用戶的留言：\nrestored_comment_by: 還原了此用戶的留言：\nremoved_post_by: 移除了此用戶的鋪文：\nrestored_post_by: 還原了此用戶的鋪文：\nhe_banned: 封鎖\nhe_unbanned: 解除封鎖\nread_all: 全標示為已讀\nshow_all: 顯示全部\nflash_register_success: 歡迎！現在您的帳號只差一步就可以使用，請您的電子郵件中確認可以啟動帳號的啟用連結。\nflash_thread_new_success: 成功立帖。其他用戶現在已可閲覽。\nflash_thread_edit_success: 成功編輯帖子。\nflash_thread_delete_success: 成功刪除帖子。\nflash_thread_pin_success: 成功釘選帖子。\nflash_thread_unpin_success: 成功取消釘選帖子。\ncomment: 留言\nchange_language: 更改語言\nchange: 更改\narticle: 帖子\nusers: 用戶\nweek: 週\nweeks: 週\nmonth: 月\nmonths: 月\nyear: 年\nfederated: 聯邦的\nlocal: 本地\noverview: 概覽\npeople_local: 本地\ncompact_view: 緊湊版面\ncontent: 內容\nclassic_view: 傳統版面\ncover: 封面\nto: 傳給\nin: 在\nagree_terms: \n  同意%terms_link_start%使用條款%terms_link_end%和%policy_link_start%隱私政策%policy_link_end%\nemail_verify: 確認電子郵件地址\nemail_confirm_expire: 請注意，此連結於一小時後失效。\nimage_alt: 替代文字\nemail_confirm_content: 準備好啟用你的 Mbin 帳號了嗎？點擊下方的連結：\nhomepage: 首頁\nnotify_on_new_post_comment_reply: 有人在任何鋪文回覆我的留言時\nnotify_on_new_posts: 訂閱的刊版有新鋪文時\nboosts: 推\nshow_users_avatars: 顯示用戶頭像\nyes: 是\nno: 否\nshow_magazines_icons: 顯示刊版頭像\nshow_thumbnails: 顯示縮圖\nrounded_edges: 圓角介面\nreset_check_email_desc: 若您的電子郵件地址有連繫的帳號，您很快會收到一封含重設密碼連結的電子郵件，此連結於%expire%後失效。\nicon: 頭像\nmessage: 訊息\ninfinite_scroll: 無限瀏覽\nsubject_reported: 內容己回報。\nsidebar_position: 側欄位置\nleft: 左側\nright: 右側\nstatus: 狀態\non: 開\noff: 關\nupload_file: 上傳檔案\nfrom_url: 從網址\nban: 封鎖\nfilters: 篩選器\nperm: 永久\nexpired_at: 已到期於\ntrash: 垃圾\nFAQ: 常見問題\nrandom_entries: 隨緣看帖\nrelated_entries: 相關帖子\ndelete_account: 刪除帳號\nban_account: 封鎖帳號\nunban_account: 解除封鎖帳號\nrelated_magazines: 相關刊版\nrandom_magazines: 隨緣看刊\npreview: 預覽\nreputation: 聲望\nfederation: 聯邦\nadd_moderator: 新增管理員\nreport_issue: 回報問題\nflash_magazine_edit_success: 成功編輯刊版。\nflash_magazine_new_success: 成功立刊。 你現在可以新增內容或瀏覽刊版管理面板。\nset_magazines_bar_empty_desc: 如果留空，活躍的刊版便會顯示於上列。\ntoo_many_requests: 請求己達上限，請稍候再試。\nset_magazines_bar_desc: 逗號後加上刊版名稱\nsolarized_light: 間明色\nsolarized_dark: 間暗色\nadded_new_thread: 開了新帖子\nmagazine_panel: 刊版面板\ndeleted: 被作者刪除\nmentioned_you: 提及您\npost: 鋪文\nban_expired: 封鎖過期\nshow_top_bar: 顯示頂列\nsticky_navbar: 固定導航列\nadded_new_post: 開了新鋪文\nadded_new_reply: 開了新回覆\napprove: 認可\nmod_remove_your_thread: 管理員移除了您的帖子\nmod_remove_your_post: 管理員移除了您的鋪文\napproved: 已認可\nremoved: 被管理員移除\nreject: 駁回\nrejected: 已駁回\nreplied_to_your_comment: 回覆了您的留言\nmod_deleted_your_comment: 管理員刪除了您的留言\nedited_post: 編輯了一個鋪文\nwrote_message: 傳了訊息\nbanned: 封鎖了您\ninstances: 實體\nedited_thread: 編輯了一個帖子\nadded_new_comment: 己發表留言\nsend_message: 傳送訊息\ndynamic_lists: 動態列表\nauto_preview: 自動預覽媒體\nsidebar: 側欄\nwriting: 寫文時\nPassword is invalid: 密碼有誤。\nadmin_panel: 管理員面板\nactive_users: 活躍用戶\nYour account has been banned: 您的帳號已被封鎖。\nfirstname: 名\nsend: 傳送\ncontact_email: 聯絡用電子郵件\nmeta: Meta\ninstance: 實體\nunpin: 取消訂選\nregistrations_enabled: 已開放註冊\nfederation_enabled: 已連邦\npinned: 已釘選\npin: 釘選\ndashboard: 資訊面板\nregistration_disabled: 註冊已停用\nrestore: 還原\nYour account is not active: 您的帳號尚未啟用。\ntype_search_term: 輸入關鍵字\nboost: 推\ncaptcha_enabled: 已啟用Captcha\nkbin_promo_title: 創造你自己的實體\nkbin_intro_title: 探索聯邦宇宙\nbanned_instances: 封鎖中的實體\nbrowsing_one_thread: 您現在正在瀏覽帖子的其中一文！所有的留言請從原鋪文閱覽。\nreturn: 返回\nkbin_intro_desc: 是一個在聯邦宇宙的去中心化的內容蒐集器和微鋪（微博）平台。\nkbin_promo_desc: '%link_start%複製源碼目錄%link_end%以一同開發聯邦宇宙'\nmod_log_alert: 警告：管理日誌可能有不討喜和令人不安而受管理員移除的內容，請謹慎小心。\nmercure_enabled: Mercure 已啟動\ntokyo_night: 東京之夜\nset_magazines_bar: 刊版欄\nbans: 封鎖\nadd_badge: 新增勳章\nadd_ban: 新增封鎖\nedited_comment: 編輯了一個留言\nchange_magazine: 更改刊版\npages: 頁\nadd_mentions_entries: 在刊版中提及\nadd_mentions_posts: 在鋪文中提及\nheader_logo: 頭版圖像\ninfinite_scroll_help: 在滑到頁面底部時自動載入更多的內容。\nreload_to_apply: 請重載頁面以更新設定\nfilter.origin.label: 選擇來源\nfilter.adult.label: 選擇是否顯示成人內容\nfilter.fields.only_names: 只有名字\nfilter.fields.names_and_descriptions: 名字和描述\nkbin_bot: Mbin 機器人\npreferred_languages: 過濾帖子和鋪文的語言\nmagazine_panel_tags_info: 不建議使用，除非你想要來自其他聯邦宇宙的內容透過標籤(hashtags) 能被引進到這個刊版\nbot_body_content: \"歡迎使用 Mbin 機器人！這個機器人在讓 Mbin 啟用 ActivityPub 的功能中有很重要的作用。它能確保 /kbin\n  可以聯繫並並與其他聯邦宇宙的實體組成聯邦。\\n\\nActivityPub 是一個開放、標準的傳輸協定，能夠讓去中心化的社交網路平台可以互相構通和交流。讓用戶能在不同實體（也就是伺服器）在聯邦的社交網路中－又稱為聯邦宇宙－去追蹤、交流、並分享內容。\n  提供一個標準，讓用戶可以推出新內容、追蹤其他用戶、進行像是按讚、分享、在刊版或鋪文留言等等的社交活動。\"\nauto_preview_help: 自動展開媒體預覽。\nsticky_navbar_help: 在滾動頁面時，導航欄會持續貼在螢幕。\nfilter.adult.hide: 隱藏成人內容\nfilter.adult.show: 顯示成人內容\nfilter.adult.only: 只顯示成人內容\nlocal_and_federated: 本地和其他聯邦宇宙的\ncreated: 創於\ndone: 完成\nexpires: 到期於\nnote: 備註\npurge: 清除\npurge_account: 停用帳號\npassword_confirm_header: 請確認您的密碼變更請求。\nfilter.fields.label: 選擇搜尋哪個項目\nsort_by: 排序方式\nfilter_by_subscription: 依訂閱篩選\nfilter_by_federation: 依聯邦狀態篩選\nsubscribers_count: '{0}位訂閱者|{1}位訂閱者|]1,Inf[ 位訂閱者'\nfollowers_count: '{0}位追蹤者|{1}位追蹤者|]1,Inf[ 位追蹤者'\nmarked_for_deletion: 標記為待刪除\nmarked_for_deletion_at: 標記為待刪除於 %date%\nremove_media: 移除媒體\nremove_user_avatar: 移除頭像\nremove_user_cover: 移除封面\ndisconnected_magazine_info: 此雜誌未接收更新（上次活動於 %days% 天前）。\nalways_disconnected_magazine_info: 此雜誌未接收更新。\nsubscribe_for_updates: 訂閱以開始接收更新。\nfrom: 來自\ndownvotes_mode: 負評模式\nchange_downvotes_mode: 變更負評模式\ndisabled: 已停用\nhidden: 已隱藏\nenabled: 已啟用\ntag: 標籤\ncrosspost: 跨鋪文\nedit_entry: 編輯帖子\nmenu: 選單\nnotify_on_user_signup: 新註冊通知\ndefault_theme: 預設主題\ndefault_theme_auto: 淺色/深色（自動偵測）\nsolarized_auto: Solarized（自動偵測）\nflash_mark_as_adult_success: 鋪文已成功標記為 NSFW。\nflash_unmark_as_adult_success: 鋪文已成功取消標記為 NSFW。\nban_expires: 封鎖到期時間\nunban: 解除封鎖\nban_hashtag_btn: 封鎖主題標籤\nban_hashtag_description: 封鎖主題標籤將阻止建立帶有此標籤的鋪文，並隱藏現有帶有此標籤的鋪文。\nunban_hashtag_btn: 解除封鎖主題標籤\nunban_hashtag_description: 解除封鎖主題標籤將允許再次建立帶有此標籤的鋪文。現有帶有此標籤的鋪文將不再隱藏。\nbanner: 橫幅\nmark_as_adult: 標記為 NSFW\nunmark_as_adult: 取消標記為 NSFW\ntype_search_term_url_handle: 輸入搜尋詞、網址或使用者代號\nviewing_one_signup_request: 您正在檢視 %username% 的一項註冊請求\nyour_account_is_not_active: 您的帳號尚未啟用。請檢查您的電子郵件以取得帳號啟用說明，或<a \n  href=\"%link_target%\">請求新的帳號啟用郵件。</a>\nyour_account_has_been_banned: 您的帳號已被封鎖\nyour_account_is_not_yet_approved: 您的帳號尚未獲得批准。一旦管理員處理完您的註冊請求，我們將向您發送電子郵件。\ntoolbar.bold: 粗體\ntoolbar.italic: 斜體\ntoolbar.strikethrough: 刪除線\ntoolbar.header: 標題\ntoolbar.quote: 引用\ntoolbar.code: 程式碼\ntoolbar.link: 連結\ntoolbar.image: 圖片\ntoolbar.unordered_list: 無序清單\ntoolbar.ordered_list: 有序清單\ntoolbar.mention: 提及\ntoolbar.spoiler: 劇透\ntoolbar.emoji: 表情符號\nfederation_page_enabled: 聯邦頁面已啟用\nfederation_page_allowed_description: 我們與之聯邦的已知實例\nfederation_page_disallowed_description: 我們不與之聯邦的實例\nfederation_page_dead_title: 失效實例\nfederation_page_dead_description: 我們連續無法傳送至少 10 個活動，且上次成功傳送與接收均超過一週前的實例\nfederated_search_only_loggedin: 未登入時聯邦搜尋受限\naccount_deletion_title: 帳號刪除\naccount_deletion_description: 您的帳號將在 30 天後刪除，除非您選擇立即刪除帳號。若要在 30 \n  天內恢復帳號，請使用相同的使用者憑證登入或聯絡管理員。\naccount_deletion_button: 刪除帳號\naccount_deletion_immediate: 立即刪除\nmore_from_domain: 更多來自此網域\nerrors.server500.title: 500 內部伺服器錯誤\nerrors.server500.description: \n  抱歉，我們這邊出了點問題。如果您持續看到此錯誤，請嘗試聯絡站台擁有者。如果此站台完全無法運作，在問題解決之前，您可以先查看 %link_start%其他 \n  Mbin 站台%link_end%。\nerrors.server429.title: 429 請求過多\nerrors.server404.title: 404 找不到\nerrors.server403.title: 403 禁止存取\nemail_confirm_button_text: 確認您的密碼變更請求\nemail_confirm_link_help: 或者，您可以將以下連結複製並貼到您的瀏覽器中\nemail.delete.title: 使用者帳號刪除請求\nemail.delete.description: 以下使用者已請求刪除其帳號\nresend_account_activation_email_question: 帳號未啟用？\nresend_account_activation_email: 重新發送帳號啟用電子郵件\nresend_account_activation_email_error: 提交此請求時發生問題。可能沒有與該電子郵件關聯的帳號，或者該帳號可能已經啟用。\nresend_account_activation_email_success: 如果存在與該電子郵件關聯的帳號，我們將發送新的啟用電子郵件。\nresend_account_activation_email_description: 輸入與您帳號關聯的電子郵件地址。我們將為您重新發送一封啟用電子郵件。\ncustom_css: 自訂 CSS\nignore_magazines_custom_css: 忽略雜誌的自訂 CSS\noauth.consent.title: OAuth2 授權同意表單\noauth.consent.grant_permissions: 授予權限\noauth.consent.app_requesting_permissions: 希望代表您執行以下操作\noauth.consent.app_has_permissions: 已可執行以下操作\noauth.consent.to_allow_access: 若要允許此存取，請點擊下方的「允許」按鈕\noauth.consent.allow: 允許\noauth.consent.deny: 拒絕\noauth.client_identifier.invalid: 無效的 OAuth 客戶端 ID！\noauth.client_not_granted_message_read_permission: 此應用程式未獲得讀取您訊息的權限。\nrestrict_oauth_clients: 將 OAuth2 客戶端建立限制為管理員\nprivate_instance: 強制使用者在登入後才能存取任何內容\nblock: 封鎖\nunblock: 解除封鎖\noauth2.grant.moderate.magazine.ban.delete: 在您管理的雜誌中解除封鎖使用者。\noauth2.grant.moderate.magazine.list: 讀取您管理的雜誌清單。\noauth2.grant.moderate.magazine.reports.all: 管理您管理的雜誌中的檢舉。\noauth2.grant.moderate.magazine.reports.read: 讀取您管理的雜誌中的檢舉。\noauth2.grant.moderate.magazine.reports.action: 在您管理的雜誌中接受或拒絕檢舉。\noauth2.grant.moderate.magazine.trash.read: 檢視您管理的雜誌中已刪除的內容。\noauth2.grant.moderate.magazine_admin.all: 建立、編輯或刪除您擁有的雜誌。\noauth2.grant.moderate.magazine_admin.create: 建立新雜誌。\noauth2.grant.moderate.magazine_admin.delete: 刪除您擁有的任何雜誌。\noauth2.grant.moderate.magazine_admin.update: 編輯您擁有的任何雜誌的規則、描述、NSFW 狀態或圖示。\noauth2.grant.moderate.magazine_admin.edit_theme: 編輯您擁有的任何雜誌的自訂 CSS。\noauth2.grant.moderate.magazine_admin.moderators: 在您擁有的任何雜誌中新增或移除版主。\noauth2.grant.moderate.magazine_admin.badges: 在您擁有的雜誌中建立或移除徽章。\noauth2.grant.moderate.magazine_admin.tags: 在您擁有的雜誌中建立或移除標籤。\noauth2.grant.moderate.magazine_admin.stats: 檢視您擁有的雜誌的內容、投票和檢視統計資料。\noauth2.grant.admin.all: 在您的站台上執行任何管理操作。\noauth2.grant.admin.entry.purge: 完全刪除您實例中的任何帖子。\noauth2.grant.read.general: 讀取您有權存取的所有內容。\noauth2.grant.write.general: 建立或編輯您的任何帖子、鋪文或評論。\noauth2.grant.delete.general: 刪除您的任何帖子、鋪文或評論。\noauth2.grant.report.general: 檢舉帖子、鋪文或評論。\noauth2.grant.vote.general: 對帖子、鋪文或評論進行贊成、反對或推廣。\noauth2.grant.subscribe.general: 訂閱或追蹤任何雜誌、網域或使用者，並查看您訂閱的雜誌、網域和使用者。\noauth2.grant.block.general: 封鎖或解除封鎖任何雜誌、網域或使用者，並查看您已封鎖的雜誌、網域和使用者。\noauth2.grant.domain.all: 訂閱或封鎖網域，並查看您訂閱或封鎖的網域。\noauth2.grant.domain.subscribe: 訂閱或取消訂閱網域，並查看您訂閱的網域。\noauth2.grant.domain.block: 封鎖或解除封鎖網域，並查看您已封鎖的網域。\noauth2.grant.entry.all: 建立、編輯或刪除您的帖子，並對任何帖子進行投票、推廣或檢舉。\noauth2.grant.entry.create: 建立新的帖子。\noauth2.grant.entry.edit: 編輯您現有的帖子。\noauth2.grant.entry.delete: 刪除您現有的帖子。\noauth2.grant.entry.vote: 對任何帖子進行贊成、推廣或反對。\noauth2.grant.entry.report: 檢舉任何帖子。\noauth2.grant.entry_comment.all: 在帖子中建立、編輯或刪除您的評論，並對帖子中的任何評論進行投票、推廣或檢舉。\noauth2.grant.entry_comment.create: 在帖子中建立新的評論。\noauth2.grant.entry_comment.edit: 編輯您在帖子中的現有評論。\noauth2.grant.entry_comment.delete: 刪除您在帖子中的現有評論。\noauth2.grant.entry_comment.vote: 對帖子中的任何評論進行贊成、推廣或反對。\noauth2.grant.entry_comment.report: 檢舉帖子中的任何評論。\noauth2.grant.magazine.all: 訂閱或封鎖雜誌，並查看您訂閱或封鎖的雜誌。\noauth2.grant.magazine.subscribe: 訂閱或取消訂閱雜誌，並查看您訂閱的雜誌。\noauth2.grant.magazine.block: 封鎖或解除封鎖雜誌，並查看您已封鎖的雜誌。\noauth2.grant.post.all: 建立、編輯或刪除您的微鋪，並對任何微鋪進行投票、推廣或檢舉。\noauth2.grant.post.create: 建立新的鋪文。\noauth2.grant.post.edit: 編輯您現有的鋪文。\noauth2.grant.post.delete: 刪除您現有的鋪文。\noauth2.grant.post.vote: 對任何鋪文進行贊成、推廣或反對。\noauth2.grant.post.report: 檢舉任何鋪文。\noauth2.grant.post_comment.all: 在鋪文上建立、編輯或刪除您的評論，並對鋪文上的任何評論進行投票、推廣或檢舉。\noauth2.grant.post_comment.create: 在鋪文上建立新的評論。\noauth2.grant.post_comment.edit: 編輯您在鋪文上的現有評論。\noauth2.grant.post_comment.delete: 刪除您在鋪文上的現有評論。\noauth2.grant.post_comment.vote: 對鋪文上的任何評論進行贊成、推廣或反對。\noauth2.grant.post_comment.report: 檢舉鋪文上的任何評論。\noauth2.grant.user.all: \n  讀取和編輯您的個人資料、訊息或通知；讀取和編輯您授予其他應用程式的權限；追蹤或封鎖其他使用者；查看您追蹤或封鎖的使用者清單。\noauth2.grant.user.bookmark: 新增與移除書籤\noauth2.grant.user.bookmark.add: 新增書籤\noauth2.grant.user.bookmark.remove: 移除書籤\noauth2.grant.user.bookmark_list: 讀取、編輯與刪除您的書籤清單\noauth2.grant.user.bookmark_list.read: 讀取您的書籤清單\noauth2.grant.user.bookmark_list.edit: 編輯您的書籤清單\noauth2.grant.user.bookmark_list.delete: 刪除您的書籤清單\noauth2.grant.user.profile.all: 讀取與編輯您的個人檔案。\noauth2.grant.user.profile.read: 讀取您的個人檔案。\noauth2.grant.user.profile.edit: 編輯您的個人檔案。\noauth2.grant.user.message.all: 讀取您的訊息並傳送訊息給其他使用者。\noauth2.grant.user.message.read: 讀取您的訊息。\noauth2.grant.user.message.create: 傳送訊息給其他使用者。\noauth2.grant.user.notification.all: 讀取與清除您的通知。\noauth2.grant.user.notification.read: 讀取您的通知，包含訊息通知。\noauth2.grant.user.notification.delete: 清除您的通知。\noauth2.grant.user.oauth_clients.all: 讀取與編輯您授予其他 OAuth2 應用程式的權限。\noauth2.grant.user.oauth_clients.read: 讀取您授予其他 OAuth2 應用程式的權限。\noauth2.grant.user.oauth_clients.edit: 編輯您授予其他 OAuth2 應用程式的權限。\noauth2.grant.user.follow: 追蹤或取消追蹤使用者，並讀取您追蹤的使用者清單。\noauth2.grant.user.block: 封鎖或解除封鎖使用者，並讀取您封鎖的使用者清單。\noauth2.grant.moderate.all: 在您管理的雜誌中執行您有權執行的任何審核操作。\noauth2.grant.moderate.entry.all: 在您管理的雜誌中審核帖子。\noauth2.grant.moderate.entry.change_language: 變更您管理的雜誌中帖子的語言。\noauth2.grant.moderate.entry.pin: 將帖子置頂於您管理的雜誌中。\noauth2.grant.moderate.entry.set_adult: 在您管理的雜誌中將帖子標記為 NSFW。\noauth2.grant.moderate.entry.trash: 在您管理的雜誌中將帖子移至垃圾桶或還原。\noauth2.grant.moderate.entry_comment.all: 在您管理的雜誌中審核帖子內的留言。\noauth2.grant.moderate.entry_comment.change_language: 變更您管理的雜誌中帖子內留言的語言。\noauth2.grant.moderate.entry_comment.set_adult: 在您管理的雜誌中將帖子內的留言標記為 NSFW。\noauth2.grant.moderate.entry_comment.trash: 在您管理的雜誌中將帖子內的留言移至垃圾桶或還原。\noauth2.grant.moderate.post.all: 在您管理的雜誌中審核鋪文。\noauth2.grant.moderate.post.change_language: 變更您管理的雜誌中鋪文的語言。\noauth2.grant.moderate.post.set_adult: 在您管理的雜誌中將鋪文標記為 NSFW。\noauth2.grant.moderate.post.trash: 在您管理的雜誌中將鋪文移至垃圾桶或還原。\noauth2.grant.moderate.post_comment.all: 在您管理的雜誌中審核鋪文上的留言。\noauth2.grant.moderate.post_comment.change_language: 變更您管理的雜誌中鋪文上留言的語言。\noauth2.grant.moderate.post_comment.set_adult: 在您管理的雜誌中將鋪文上的留言標記為 NSFW。\noauth2.grant.moderate.post_comment.trash: 在您管理的雜誌中將鋪文上的留言移至垃圾桶或還原。\noauth2.grant.moderate.magazine.all: 在您管理的雜誌中管理封鎖、檢舉，並檢視已移至垃圾桶的項目。\noauth2.grant.moderate.magazine.ban.all: 在您管理的雜誌中管理被封鎖的使用者。\noauth2.grant.moderate.magazine.ban.read: 檢視您管理的雜誌中被封鎖的使用者。\noauth2.grant.moderate.magazine.ban.create: 在您管理的雜誌中封鎖使用者。\noauth2.grant.admin.entry_comment.purge: 完全刪除您實例中帖子內的任何評論。\noauth2.grant.admin.post.purge: 完全刪除您實例中的任何鋪文。\noauth2.grant.admin.post_comment.purge: 完全刪除您實例中鋪文上的任何評論。\noauth2.grant.admin.magazine.all: 在您的實例中移動帖子或完全刪除雜誌。\noauth2.grant.admin.magazine.move_entry: 在您的實例中，於雜誌之間移動帖子。\noauth2.grant.admin.magazine.purge: 完全刪除您實例中的雜誌。\noauth2.grant.admin.user.all: 在您的實例中封鎖、驗證或完全刪除使用者。\noauth2.grant.admin.user.ban: 從您的實例中封鎖或解除封鎖使用者。\noauth2.grant.admin.user.verify: 驗證您實例中的使用者。\noauth2.grant.admin.user.delete: 從您的實例中刪除使用者。\noauth2.grant.admin.user.purge: 從您的實例中完全刪除使用者。\noauth2.grant.admin.instance.all: 檢視與更新實例設定或資訊。\noauth2.grant.admin.instance.stats: 檢視您實例的統計資料。\noauth2.grant.admin.instance.settings.all: 檢視或更新您實例上的設定。\noauth2.grant.admin.instance.settings.read: 檢視您實例上的設定。\noauth2.grant.admin.instance.settings.edit: 更新您實例上的設定。\noauth2.grant.admin.instance.information.edit: 更新您實例的「關於」、常見問題、聯絡方式、服務條款與隱私權政策頁面。\noauth2.grant.admin.federation.all: 檢視與更新目前已斷開聯邦的實例。\noauth2.grant.admin.federation.read: 檢視已斷開聯邦的實例清單。\noauth2.grant.admin.federation.update: 新增或移除已斷開聯邦實例清單中的實例。\noauth2.grant.admin.oauth_clients.all: 檢視或撤銷您實例上存在的 OAuth2 客戶端。\noauth2.grant.admin.oauth_clients.read: 檢視您實例上存在的 OAuth2 客戶端及其使用統計資料。\noauth2.grant.admin.oauth_clients.revoke: 撤銷您實例上 OAuth2 客戶端的存取權限。\nlast_active: 最後活動時間\nflash_post_pin_success: 鋪文已成功置頂。\nflash_post_unpin_success: 鋪文已成功取消置頂。\ncomment_reply_position_help: 將評論回覆表單顯示在頁面頂部或底部。當啟用「無限捲動」時，位置將始終出現在頂部。\nshow_avatars_on_comments: 顯示評論頭像\nsingle_settings: 單一\nupdate_comment: 更新評論\nshow_avatars_on_comments_help: 在檢視單一帖子或鋪文的評論時，顯示/隱藏使用者頭像。\ncomment_reply_position: 評論回覆位置\nmagazine_theme_appearance_custom_css: 自訂 CSS，將在檢視您雜誌內的內容時套用。\nmagazine_theme_appearance_icon: 雜誌的自訂圖示。\nmagazine_theme_appearance_banner: 雜誌的自訂橫幅。它會顯示在所有帖子上方，應為寬幅比例（5:1，或 1500px * \n  300px）。\nmagazine_theme_appearance_background_image: 自訂背景圖片，將在檢視您雜誌內的內容時套用。\nmoderation.report.approve_report_title: 核准檢舉\nmoderation.report.reject_report_title: 駁回檢舉\nmoderation.report.ban_user_description: 您要封鎖在此雜誌中建立此內容的使用者（%username%）嗎？\nmoderation.report.approve_report_confirmation: 您確定要核准此檢舉嗎？\nsubject_reported_exists: 此內容已被檢舉過。\nmoderation.report.ban_user_title: 封鎖使用者\nmoderation.report.reject_report_confirmation: 您確定要拒絕此檢舉嗎？\noauth2.grant.moderate.post.pin: 將鋪文置頂於您管理的雜誌。\ndelete_content: 刪除內容\npurge_content: 清除內容\ndelete_content_desc: 刪除使用者的內容，但保留其他使用者在所建立的主題、鋪文和評論中的回覆。\npurge_content_desc: 完全清除使用者的內容，包括刪除其他使用者在所建立的主題、鋪文和評論中的回覆。\ndelete_account_desc: 刪除帳號，包括其他使用者在所建立的主題、鋪文和評論中的回覆。\nschedule_delete_account: 排程刪除\nschedule_delete_account_desc: 將此帳號排程於 30 天後刪除。這將會隱藏該使用者及其內容，並阻止該使用者登入。\nremove_schedule_delete_account: 移除排程刪除\nremove_schedule_delete_account_desc: 移除已排程的刪除。所有內容將再次可用，且使用者將能夠登入。\ntwo_factor_authentication: 雙重驗證\ntwo_factor_backup: 雙重驗證備用碼\n2fa.authentication_code.label: 驗證碼\n2fa.verify: 驗證\n2fa.code_invalid: 驗證碼無效\n2fa.setup_error: 為帳號啟用雙重驗證時發生錯誤\n2fa.enable: 設定雙重驗證\n2fa.disable: 停用雙重驗證\n2fa.backup: 您的雙重驗證備用碼\n2fa.backup-create.help: 您可以建立新的備用驗證碼；此操作將使現有碼失效。\n2fa.backup-create.label: 建立新的備用驗證碼\n2fa.remove: 移除雙重驗證\n2fa.add: 新增至我的帳號\n2fa.verify_authentication_code.label: 輸入雙重驗證碼以確認設定\n2fa.qr_code_img.alt: 一個可用於為您帳號設定雙重驗證的 QR 碼\n2fa.qr_code_link.title: 造訪此連結可能允許您的平台註冊此雙重驗證\n2fa.user_active_tfa.title: 使用者已啟用雙重驗證\n2fa.available_apps: 使用雙重驗證應用程式（例如 %google_authenticator%、%aegis%（Android）或 \n  %raivo%（iOS））來掃描 QR 碼。\n2fa.backup_codes.help: \n  當您沒有雙重驗證裝置或應用程式時，可以使用這些備用碼。您將<strong>不會再次看到它們</strong>，且每個碼<strong>僅能使用一次</strong>。\n2fa.backup_codes.recommendation: 建議您將它們的副本保存在安全的地方。\n2fa.manual_code_hint: 若無法掃描 QR 碼，請手動輸入密鑰\ncancel: 取消\npassword_and_2fa: 密碼與雙重驗證\nflash_account_settings_changed: 您的帳號設定已成功變更。您需要重新登入。\nshow_subscriptions: 顯示訂閱\nsubscription_sort: 排序\nalphabetically: 依字母順序\nsubscriptions_in_own_sidebar: 於獨立側邊欄中\nsidebars_same_side: 側邊欄位於同一側\nsubscription_sidebar_pop_out_right: 移至右側獨立側邊欄\nsubscription_sidebar_pop_out_left: 移至左側獨立側邊欄\nsubscription_sidebar_pop_in: 將訂閱移至內嵌面板\nsubscription_panel_large: 大型面板\nsubscription_header: 已訂閱雜誌\nclose: 關閉\nposition_bottom: 底部\nposition_top: 頂部\npending: 待處理\nflash_thread_new_error: 無法建立帖子。發生錯誤。\nflash_thread_tag_banned_error: 無法建立帖子。內容不被允許。\nflash_thread_ref_image_not_found: 無法找到 'imageHash' 所引用的圖片。\nflash_image_download_too_large_error: 無法建立圖片，檔案過大（最大尺寸為 %bytes%）\nflash_email_was_sent: 電子郵件已成功寄出。\nflash_email_failed_to_sent: 無法寄出電子郵件。\nflash_post_new_success: 鋪文已成功建立。\nflash_post_new_error: 無法建立鋪文。發生錯誤。\nflash_magazine_theme_changed_success: 已成功更新雜誌外觀。\nflash_magazine_theme_changed_error: 更新雜誌外觀失敗。\nflash_comment_new_success: 留言已成功建立。\nflash_comment_edit_success: 留言已成功更新。\nflash_comment_new_error: 建立留言失敗。發生錯誤。\nflash_comment_edit_error: 編輯留言失敗。發生錯誤。\nflash_user_settings_general_success: 使用者設定已成功儲存。\nflash_user_settings_general_error: 儲存使用者設定失敗。\nflash_user_edit_profile_error: 儲存個人資料設定失敗。\nflash_user_edit_profile_success: 使用者個人資料設定已成功儲存。\nflash_user_edit_email_error: 變更電子郵件失敗。\nflash_user_edit_password_error: 變更密碼失敗。\nflash_thread_edit_error: 編輯帖子失敗。發生錯誤。\nflash_post_edit_error: 編輯鋪文失敗。發生錯誤。\nflash_post_edit_success: 鋪文已成功編輯。\npage_width: 頁面寬度\npage_width_max: 最大\npage_width_auto: 自動\npage_width_fixed: 固定\nfilter_labels: 篩選標籤\nauto: 自動\nopen_url_to_fediverse: 開啟原始網址\nchange_my_avatar: 變更我的頭像\nchange_my_cover: 變更我的封面\nedit_my_profile: 編輯我的個人資料\naccount_settings_changed: 您的帳號設定已成功變更。您需要重新登入。\nmagazine_deletion: 雜誌刪除\ndelete_magazine: 刪除雜誌\nrestore_magazine: 還原雜誌\npurge_magazine: 清除雜誌\nmagazine_is_deleted: 雜誌已被刪除。您可以在 30 天內<a href=\"%link_target%\">還原</a>它。\nsuspend_account: 停用帳號\nunsuspend_account: 啟用帳號\naccount_suspended: 帳號已被停用。\naccount_unsuspended: 帳號已被啟用。\ndeletion: 刪除\nuser_suspend_desc: 停用您的帳號會隱藏您在站台上的內容，但不會永久移除，您可以隨時還原。\naccount_banned: 帳號已被封鎖。\naccount_unbanned: 帳號已被解除封鎖。\naccount_is_suspended: 使用者帳號已停用。\nremove_following: 移除追蹤\nremove_subscriptions: 移除訂閱\napply_for_moderator: 申請成為版主\nrequest_magazine_ownership: 請求雜誌所有權\ncancel_request: 取消請求\nabandoned: 已棄置\nownership_requests: 所有權請求\naccept: 接受\nmoderator_requests: 版主請求\naction: 操作\nuser_badge_op: 原發文者\nuser_badge_admin: 管理員\nuser_badge_global_moderator: 全域版主\nuser_badge_moderator: 版主\nuser_badge_bot: 機器人\nannouncement: 公告\nkeywords: 關鍵字\ndeleted_by_moderator: 帖子、鋪文或留言已被版主刪除\ndeleted_by_author: 帖子、鋪文或留言已被作者刪除\nsensitive_warning: 敏感內容\nsensitive_toggle: 切換敏感內容可見性\nsensitive_show: 點擊顯示\nsensitive_hide: 點擊隱藏\ndetails: 詳細資訊\nspoiler: 劇透\nall_time: 所有時間\nshow: 顯示\nhide: 隱藏\nedited: 已編輯\nsso_registrations_enabled: SSO 註冊已啟用\nsso_registrations_enabled.error: 目前無法使用第三方身份管理員註冊新帳號。\nsso_only_mode: 僅限 SSO 方式登入與註冊\nrelated_entry: 相關\nrestrict_magazine_creation: 僅限管理員與全域版主建立本地雜誌\nsso_show_first: 在登入與註冊頁面優先顯示 SSO\ncontinue_with: 繼續使用\nreported_user: 被檢舉的使用者\nreporting_user: 檢舉的使用者\nreported: 已檢舉\nreport_subject: 主題\nown_report_rejected: 您的檢舉已被駁回\nown_report_accepted: 您的檢舉已被接受\nown_content_reported_accepted: 您內容的檢舉已被接受。\nreport_accepted: 檢舉已被接受\nopen_report: 開啟檢舉\ncake_day: 蛋糕日\nsomeone: 某人\nback: 返回\nmagazine_log_mod_added: 已新增版主\nmagazine_log_mod_removed: 已移除版主\nmagazine_log_entry_pinned: 已置頂帖子\nmagazine_log_entry_unpinned: 已取消置頂帖子\nlast_updated: 最後更新\nand: 與\ndirect_message: 私訊\nmanually_approves_followers: 手動核准追蹤者\nregister_push_notifications_button: 註冊推播通知\nunregister_push_notifications_button: 移除推播註冊\ntest_push_notifications_button: 測試推播通知\ntest_push_message: 哈囉世界！\nnotification_title_new_comment: 新留言\nnotification_title_removed_comment: 留言已被移除\nnotification_title_edited_comment: 留言已被編輯\nnotification_title_mention: 您被提及\nnotification_title_new_reply: 新回覆\nnotification_title_new_thread: 新帖子\nnotification_title_removed_thread: 帖子已被移除\nnotification_title_edited_thread: 帖子已被編輯\nnotification_title_ban: 您已被封鎖\nnotification_title_message: 新私訊\nnotification_title_new_post: 新鋪文\nnotification_title_removed_post: 鋪文已被移除\nnotification_title_edited_post: 鋪文已被編輯\nnotification_title_new_signup: 新使用者註冊\nnotification_body_new_signup: 使用者 %u% 已註冊。\nnotification_body2_new_signup_approval: 您需要在他們登入前核准此請求\nshow_related_magazines: 顯示隨機雜誌\nshow_related_entries: 顯示隨機帖子\nshow_related_posts: 顯示隨機鋪文\nshow_active_users: 顯示活躍使用者\nnotification_title_new_report: 已建立新檢舉\nmagazine_posting_restricted_to_mods_warning: 只有版主可以在這個雜誌建立帖子\nflash_posting_restricted_error: 在此雜誌中建立帖子僅限版主，而您並非版主\nserver_software: 伺服器軟體\nversion: 版本\nlast_successful_deliver: 最後成功傳送\nlast_successful_receive: 最後成功接收\nlast_failed_contact: 最後聯絡失敗\nmagazine_posting_restricted_to_mods: 僅限版主建立帖子\nnew_user_description: 此使用者為新使用者（活躍時間少於 %days% 天）\nnew_magazine_description: 此雜誌為新雜誌（活躍時間少於 %days% 天）\nadmin_users_active: 活躍\nadmin_users_inactive: 非活躍\nadmin_users_suspended: 已停權\nadmin_users_banned: 已封鎖\nuser_verify: 啟用帳號\nmax_image_size: 最大檔案大小\ncomment_not_found: 找不到留言\nbookmark_add_to_list: 將書籤加入 %list%\nbookmark_remove_from_list: 從 %list% 移除書籤\nbookmark_remove_all: 移除所有書籤\nbookmark_add_to_default_list: 將書籤加入預設清單\nbookmark_lists: 書籤清單\nbookmarks: 書籤\nbookmarks_list: '%list% 中的書籤'\ncount: 數量\nis_default: 是否為預設\nbookmark_list_is_default: 是否為預設清單\nbookmark_list_make_default: 設為預設\nbookmark_list_create: 建立\nbookmark_list_create_placeholder: 輸入名稱...\nbookmark_list_create_label: 清單名稱\nbookmarks_list_edit: 編輯書籤清單\nbookmark_list_edit: 編輯\nbookmark_list_selected_list: 已選清單\ntable_of_contents: 目錄\nsearch_type_all: 全部\nsearch_type_entry: 帖子\nsearch_type_post: 微鋪\nsearch_type_magazine: 雜誌\nsearch_type_user: 使用者\nsearch_type_actors: 雜誌 + 使用者\nsearch_type_content: 帖子 + 微鋪\nselect_user: 選擇使用者\nnew_users_need_approval: 新使用者需經管理員核准後才能登入。\nsignup_requests: 註冊申請\napplication_text: 請說明您想加入的原因\nsignup_requests_header: 註冊申請\nsignup_requests_paragraph: 這些使用者希望加入您的伺服器。在您核准他們的註冊申請之前，他們無法登入。\nflash_application_info: 管理員需要核准您的帳號後您才能登入。您的註冊申請處理完成後，您將會收到一封電子郵件。\nemail_application_approved_title: 您的註冊申請已獲核准\nemail_application_approved_body: 您的註冊申請已獲伺服器管理員核准。您現在可以登入伺服器：<a \n  href=\"%link%\">%siteName%</a>。\nemail_application_rejected_title: 您的註冊申請已被拒絕\nemail_application_rejected_body: 感謝您的關注，但我們遺憾地通知您，您的註冊申請已被拒絕。\nemail_application_pending: 您的帳號需要管理員核准後才能登入。\nemail_verification_pending: 您必須驗證您的電子郵件地址才能登入。\nshow_magazine_domains: 顯示雜誌網域\nshow_user_domains: 顯示使用者網域\nanswered: 已回覆\nby: 由\nfront_default_sort: 首頁預設排序\ncomment_default_sort: 留言預設排序\nopen_signup_request: 開啟註冊申請\nimage_lightbox_in_list: 帖子縮圖開啟全螢幕\ncompact_view_help: 一種邊距較小的精簡檢視，媒體會移至右側。\nshow_users_avatars_help: 顯示使用者頭像圖片。\nshow_magazines_icons_help: 顯示雜誌圖示。\nshow_thumbnails_help: 顯示縮圖圖片。\nimage_lightbox_in_list_help: 勾選時，點擊縮圖會顯示模態圖片視窗。未勾選時，點擊縮圖將開啟帖子。\nshow_new_icons: 顯示新圖示\nshow_new_icons_help: 顯示新雜誌/使用者的圖示（30天內新建立）\nmagazine_instance_defederated_info: 此雜誌的實例已解除聯邦。因此，該雜誌將不會收到更新。\nuser_instance_defederated_info: 此使用者的實例已解除聯邦。\nflash_thread_instance_banned: 此雜誌的實例已被封鎖。\nshow_rich_mention: 豐富提及\nshow_rich_mention_help: 當提及使用者時，呈現使用者元件。這將包含他們的顯示名稱和個人資料圖片。\nshow_rich_mention_magazine: 豐富雜誌提及\nshow_rich_mention_magazine_help: 當提及雜誌時，呈現雜誌元件。這將包含它們的顯示名稱和圖示。\nshow_rich_ap_link: 豐富 AP 連結\nshow_rich_ap_link_help: 當連結到其他 ActivityPub 內容時，呈現內嵌元件。\nattitude: 態度\ntype_search_magazine: 將搜尋限制於雜誌...\ntype_search_user: 將搜尋限制於作者...\nmodlog_type_entry_deleted: 帖子已刪除\nmodlog_type_entry_restored: 帖子已還原\nmodlog_type_entry_comment_deleted: 帖子留言已刪除\nmodlog_type_entry_comment_restored: 帖子留言已還原\nmodlog_type_entry_pinned: 帖子已置頂\nmodlog_type_entry_unpinned: 帖子已取消置頂\nmodlog_type_post_deleted: 微鋪已刪除\nmodlog_type_post_restored: 微鋪已還原\nmodlog_type_post_comment_deleted: 微鋪回覆已刪除\nmodlog_type_post_comment_restored: 微鋪回覆已還原\nmodlog_type_ban: 使用者已被雜誌封鎖\nmodlog_type_moderator_add: 雜誌版主已新增\nmodlog_type_moderator_remove: 雜誌版主已移除\neveryone: 所有人\nnobody: 無人\nfollowers_only: 僅限追蹤者\ndirect_message_setting_label: 誰可以向您發送私訊\ndelete_magazine_icon: 刪除雜誌圖示\nflash_magazine_theme_icon_detached_success: 雜誌圖示已成功刪除\ndelete_magazine_banner: 刪除雜誌橫幅\nflash_magazine_theme_banner_detached_success: 雜誌橫幅已成功刪除\nfederation_uses_allowlist: 對聯邦使用允許清單\ndefederating_instance: 正在與實例 %i 解除聯邦\ntheir_user_follows: 來自其實例並追蹤我們實例用戶的用戶數量\nour_user_follows: 來自我們實例並追蹤其實例用戶的用戶數量\ntheir_magazine_subscriptions: 來自其實例並訂閱我們實例雜誌的用戶數量\nour_magazine_subscriptions: 我們實例中訂閱來自其實例雜誌的用戶數量\nconfirm_defederation: 確認解除聯邦\nflash_error_defederation_must_confirm: 您必須確認解除聯邦\nallowed_instances: 允許的實例\nbtn_deny: 拒絕\nbtn_allow: 允許\nban_instance: 封鎖實例\nallow_instance: 允許實例\nfederation_page_use_allowlist_help: \n  如果使用允許清單，此實例將僅與明確允許的實例進行聯邦。否則，此實例將與所有實例進行聯邦，除了那些被封鎖的實例。\nyou_have_been_banned_from_magazine: 您已被雜誌 %m 封鎖。\nyou_have_been_banned_from_magazine_permanently: 您已被雜誌 %m 永久封鎖。\nyou_are_no_longer_banned_from_magazine: 您已不再被雜誌 %m 封鎖。\nfront_default_content: 首頁預設檢視\ndefault_content_default: 伺服器預設（帖子）\ndefault_content_combined: 帖子 + 微鋪\ndefault_content_threads: 帖子\ndefault_content_microblog: 微鋪\ncombined: 合併\nsidebar_sections_random_local_only: 將「隨機帖子/鋪文」側邊欄區塊限制為僅限本地\nsidebar_sections_users_local_only: 將「活躍用戶」側邊欄區塊限制為僅限本地\nrandom_local_only_performance_warning: 啟用「僅限本地隨機」可能會對 SQL 效能造成影響。\n"
  },
  {
    "path": "translations/security.en.yaml",
    "content": "Your account is not active.: Your account is not active.\nYour account has been banned.: Your account has been banned.\n"
  },
  {
    "path": "webpack.config.js",
    "content": "const Encore = require('@symfony/webpack-encore');\n//const sass = require('sass');\n\n// Manually configure the runtime environment if not already configured yet by the \"encore\" command.\n// It's useful when you use tools that rely on webpack.config.js file.\nif (!Encore.isRuntimeEnvironmentConfigured()) {\n    Encore.configureRuntimeEnvironment(process.env.NODE_ENV || 'dev');\n}\n\nEncore\n    // directory where compiled assets will be stored\n    .setOutputPath('public/build/')\n\n    .copyFiles({\n        from: './assets/images',\n        to: 'images/[path][name].[ext]'\n    })\n    // public path used by the web server to access the output path\n    .setPublicPath('/build')\n    // only needed for CDN's or subdirectory deploy\n    //.setManifestKeyPrefix('build/')\n\n    /*\n     * ENTRY CONFIG\n     *\n     * Each entry will result in one JavaScript file (e.g. app.js)\n     * and one CSS file (e.g. app.css) if your JavaScript imports CSS.\n     */\n    .addEntry('app', './assets/app.js')\n    .addEntry('email', './assets/email.js')\n\n    // When enabled, Webpack \"splits\" your files into smaller pieces for greater optimization.\n    .splitEntryChunks()\n\n    // enables the Symfony UX Stimulus bridge (used in assets/stimulus_bootstrap.js)\n    .enableStimulusBridge('./assets/controllers.json')\n\n    // will require an extra script tag for runtime.js\n    // but, you probably want this, unless you're building a single-page app\n    .enableSingleRuntimeChunk()\n\n    /*\n     * FEATURE CONFIG\n     *\n     * Enable & configure other features below. For a full\n     * list of features, see:\n     * https://symfony.com/doc/current/frontend.html#adding-more-features\n     */\n    .cleanupOutputBeforeBuild()\n\n    // Displays build status system notifications to the user\n    // .enableBuildNotifications()\n\n    .enableSourceMaps(!Encore.isProduction())\n    // enables hashed filenames (e.g. app.abc123.css)\n    .enableVersioning(Encore.isProduction())\n\n    // configure Babel\n    // .configureBabel((config) => {\n    //     config.plugins.push('@babel/a-babel-plugin');\n    // })\n\n    // enables and configure @babel/preset-env polyfills\n    .configureBabelPresetEnv((config) => {\n        config.useBuiltIns = 'usage';\n        config.corejs = '3.38';\n    })\n\n    // enables Sass/SCSS support\n    .enableSassLoader(function(options) {\n        // https://sass-lang.com/documentation/js-api/interfaces/options/\n        // Uncomment this line (and the \"require\" at the top) to use \"pkg:\" URLs in Sass\n        //options.sassOptions = {importers: [new sass.NodePackageImporter()]};\n    })\n\n// uncomment if you use TypeScript\n//.enableTypeScriptLoader()\n\n// uncomment if you use React\n//.enableReactPreset()\n\n// uncomment to get integrity=\"...\" attributes on your script & link tags\n// requires WebpackEncoreBundle 1.4 or higher\n//.enableIntegrityHashes(Encore.isProduction())\n\n// uncomment if you're having problems with a jQuery plugin\n//.autoProvidejQuery()\n;\n\nmodule.exports = Encore.getWebpackConfig();\n"
  }
]